diff --git a/AGENT_INTEGRATION.md b/AGENT_INTEGRATION.md new file mode 100644 index 0000000..8317b9f --- /dev/null +++ b/AGENT_INTEGRATION.md @@ -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": "", + "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` diff --git a/backend/common/database/db_session_manager.py b/backend/common/database/db_session_manager.py index 31d9d2f..f6d81ef 100644 --- a/backend/common/database/db_session_manager.py +++ b/backend/common/database/db_session_manager.py @@ -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): diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index 0da66c6..ae6049f 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -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")) # 소프트 삭제 여부 diff --git a/backend/common/enums.py b/backend/common/enums.py index 8793ea9..9ee216f 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -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 입력 diff --git a/backend/common/models/gmodel.py b/backend/common/models/gmodel.py index b09f002..36d3ab9 100644 --- a/backend/common/models/gmodel.py +++ b/backend/common/models/gmodel.py @@ -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__() diff --git a/backend/config/config.local.toml.example b/backend/config/config.local.toml.example index adb45b8..8f95efc 100644 --- a/backend/config/config.local.toml.example +++ b/backend/config/config.local.toml.example @@ -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 diff --git a/backend/config/config_models.py b/backend/config/config_models.py index d5af607..ea585b3 100644 --- a/backend/config/config_models.py +++ b/backend/config/config_models.py @@ -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 개발 중 통합 테스트용) diff --git a/backend/config/server_configs.py b/backend/config/server_configs.py index 73201f3..b8aef41 100644 --- a/backend/config/server_configs.py +++ b/backend/config/server_configs.py @@ -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 그대로. diff --git a/backend/conftest.py b/backend/conftest.py index d5a3fe8..b1f138f 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -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() diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py new file mode 100644 index 0000000..10a47d2 --- /dev/null +++ b/backend/crud/chat_crud.py @@ -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 diff --git a/backend/crud/session_crud.py b/backend/crud/session_crud.py new file mode 100644 index 0000000..c602101 --- /dev/null +++ b/backend/crud/session_crud.py @@ -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 diff --git a/backend/crud/user_crud.py b/backend/crud/user_crud.py index b2a7ac6..88af505 100644 --- a/backend/crud/user_crud.py +++ b/backend/crud/user_crud.py @@ -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) diff --git a/backend/loadtest/locustfile.py b/backend/loadtest/locustfile.py index af9f02f..900a06f 100644 --- a/backend/loadtest/locustfile.py +++ b/backend/loadtest/locustfile.py @@ -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)") diff --git a/backend/requirements.txt b/backend/requirements.txt index 2f928b6..9f96866 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 클라이언트 diff --git a/backend/router/router.py b/backend/router/router.py index 15eec9c..fd1e71c 100644 --- a/backend/router/router.py +++ b/backend/router/router.py @@ -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.. 를 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) diff --git a/backend/router/v1/auth/account.py b/backend/router/v1/auth/account.py index b5d4648..623924c 100644 --- a/backend/router/v1/auth/account.py +++ b/backend/router/v1/auth/account.py @@ -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)) diff --git a/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py index 2ef226e..bd21a32 100644 --- a/backend/router/v1/auth/protocol.py +++ b/backend/router/v1/auth/protocol.py @@ -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 diff --git a/backend/router/v1/negotiation/chat.py b/backend/router/v1/negotiation/chat.py new file mode 100644 index 0000000..bf632d3 --- /dev/null +++ b/backend/router/v1/negotiation/chat.py @@ -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) + ) diff --git a/backend/router/v1/negotiation/chat_protocol.py b/backend/router/v1/negotiation/chat_protocol.py new file mode 100644 index 0000000..e143a7c --- /dev/null +++ b/backend/router/v1/negotiation/chat_protocol.py @@ -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 diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py new file mode 100644 index 0000000..1101e0e --- /dev/null +++ b/backend/router/v1/negotiation/protocol.py @@ -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 = "" # 거부 처리된 세션 diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py new file mode 100644 index 0000000..324a634 --- /dev/null +++ b/backend/router/v1/negotiation/session.py @@ -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)) diff --git a/backend/router/v1/validator/dependencies.py b/backend/router/v1/validator/dependencies.py index 73ad74e..08d676f 100644 --- a/backend/router/v1/validator/dependencies.py +++ b/backend/router/v1/validator/dependencies.py @@ -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) diff --git a/backend/run_local_locust.sh b/backend/run_local_locust.sh new file mode 100755 index 0000000..bbeea31 --- /dev/null +++ b/backend/run_local_locust.sh @@ -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 diff --git a/backend/run_local_pgwatch.sh b/backend/run_local_pgwatch.sh new file mode 100755 index 0000000..7c3ef5b --- /dev/null +++ b/backend/run_local_pgwatch.sh @@ -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 diff --git a/backend/run_local_server.sh b/backend/run_local_server.sh new file mode 100755 index 0000000..d0b493e --- /dev/null +++ b/backend/run_local_server.sh @@ -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 diff --git a/backend/scripts/dev_seed.sql b/backend/scripts/dev_seed.sql new file mode 100644 index 0000000..039efe5 --- /dev/null +++ b/backend/scripts/dev_seed.sql @@ -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; diff --git a/backend/services/agent_client.py b/backend/services/agent_client.py new file mode 100644 index 0000000..0653e5d --- /dev/null +++ b/backend/services/agent_client.py @@ -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() diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index 1d41675..f95f114 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -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 diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py new file mode 100644 index 0000000..5027447 --- /dev/null +++ b/backend/services/chat_service.py @@ -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) diff --git a/backend/services/negotiation_service.py b/backend/services/negotiation_service.py new file mode 100644 index 0000000..10a11f6 --- /dev/null +++ b/backend/services/negotiation_service.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 47963c4..275ba7c 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py new file mode 100644 index 0000000..289fdfa --- /dev/null +++ b/backend/tests/test_chat.py @@ -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 도 전이됨 diff --git a/backend/tests/test_negotiation.py b/backend/tests/test_negotiation.py new file mode 100644 index 0000000..5ea6ba2 --- /dev/null +++ b/backend/tests/test_negotiation.py @@ -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 diff --git a/front/src/App.tsx b/front/src/App.tsx deleted file mode 100644 index 363b765..0000000 --- a/front/src/App.tsx +++ /dev/null @@ -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: }, - { path: '/list', element: }, - { path: '/chat', element: }, -]) - -function App() { - return -} - -export default App diff --git a/front/src/features/auth/hooks/useLoginMutation.ts b/front/src/features/auth/hooks/useLoginMutation.ts deleted file mode 100644 index 3295d41..0000000 --- a/front/src/features/auth/hooks/useLoginMutation.ts +++ /dev/null @@ -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({ - mutationFn: async () => {}, - }) -} diff --git a/front/src/features/chat/containers/ChatContainer.tsx b/front/src/features/chat/containers/ChatContainer.tsx deleted file mode 100644 index adeb3b6..0000000 --- a/front/src/features/chat/containers/ChatContainer.tsx +++ /dev/null @@ -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 ( -
- - -
- ) -} diff --git a/front/src/features/chat/hooks/useChatInit.ts b/front/src/features/chat/hooks/useChatInit.ts deleted file mode 100644 index c87a008..0000000 --- a/front/src/features/chat/hooks/useChatInit.ts +++ /dev/null @@ -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]) -} diff --git a/front/src/features/chat/mocks/mockChatInit.ts b/front/src/features/chat/mocks/mockChatInit.ts deleted file mode 100644 index a9840fb..0000000 --- a/front/src/features/chat/mocks/mockChatInit.ts +++ /dev/null @@ -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, -} diff --git a/front/src/features/chat/mocks/mockMessages.ts b/front/src/features/chat/mocks/mockMessages.ts deleted file mode 100644 index 34a299b..0000000 --- a/front/src/features/chat/mocks/mockMessages.ts +++ /dev/null @@ -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: '가격협상', - }, -] diff --git a/front/src/features/list/components/ActionSection.tsx b/front/src/features/list/components/ActionSection.tsx deleted file mode 100644 index 36ce83c..0000000 --- a/front/src/features/list/components/ActionSection.tsx +++ /dev/null @@ -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 ( -
- {/* TODO: 협상 참여 동작 연동 */} - - {/* TODO: 거부 동작 연동 */} - -
- ) -} diff --git a/front/src/features/list/containers/ContentContainer.tsx b/front/src/features/list/containers/ContentContainer.tsx deleted file mode 100644 index eb89662..0000000 --- a/front/src/features/list/containers/ContentContainer.tsx +++ /dev/null @@ -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) 일괄 적용 -
- - - -
- ) -} diff --git a/front/src/features/list/hooks/useList.ts b/front/src/features/list/hooks/useList.ts deleted file mode 100644 index dc16832..0000000 --- a/front/src/features/list/hooks/useList.ts +++ /dev/null @@ -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(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, - } -} diff --git a/front/src/features/list/lib/datetime.ts b/front/src/features/list/lib/datetime.ts deleted file mode 100644 index 80da812..0000000 --- a/front/src/features/list/lib/datetime.ts +++ /dev/null @@ -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())}` -} diff --git a/front/src/features/list/mocks/mockItems.ts b/front/src/features/list/mocks/mockItems.ts deleted file mode 100644 index ece904b..0000000 --- a/front/src/features/list/mocks/mockItems.ts +++ /dev/null @@ -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: '로지텍', - }, -] diff --git a/front/.env.sample b/frontend/.env.sample similarity index 100% rename from front/.env.sample rename to frontend/.env.sample diff --git a/front/.gitignore b/frontend/.gitignore similarity index 100% rename from front/.gitignore rename to frontend/.gitignore diff --git a/front/README.md b/frontend/README.md similarity index 100% rename from front/README.md rename to frontend/README.md diff --git a/front/eslint.config.js b/frontend/eslint.config.js similarity index 100% rename from front/eslint.config.js rename to frontend/eslint.config.js diff --git a/front/index.html b/frontend/index.html similarity index 100% rename from front/index.html rename to frontend/index.html diff --git a/front/package-lock.json b/frontend/package-lock.json similarity index 99% rename from front/package-lock.json rename to frontend/package-lock.json index e1dc0fd..a62cc79 100644 --- a/front/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/front/package.json b/frontend/package.json similarity index 97% rename from front/package.json rename to frontend/package.json index 37a1496..d25a5a7 100644 --- a/front/package.json +++ b/frontend/package.json @@ -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": { diff --git a/front/public/favicon.svg b/frontend/public/favicon.svg similarity index 100% rename from front/public/favicon.svg rename to frontend/public/favicon.svg diff --git a/front/public/icons.svg b/frontend/public/icons.svg similarity index 100% rename from front/public/icons.svg rename to frontend/public/icons.svg diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..a864750 --- /dev/null +++ b/frontend/src/App.tsx @@ -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: }, + { + path: '/list', + element: ( + + + + ), + }, + { + path: '/chat', + element: ( + + + + ), + }, +]) + +// 토큰 만료/폐기로 인증이 끊기면 로그인 페이지로 이동시킨다. +setUnauthorizedHandler(() => { + void router.navigate('/') +}) + +function App() { + return +} + +export default App diff --git a/frontend/src/apis/auth/auth.api.ts b/frontend/src/apis/auth/auth.api.ts new file mode 100644 index 0000000..780b0cc --- /dev/null +++ b/frontend/src/apis/auth/auth.api.ts @@ -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 => { + const res = await http.post('/v1/auth/login', body) + return res.data + }, + + /** POST /v1/auth/create — 신규 공급사 유저 계정 생성 */ + createAccount: async (body: CreateAccountRequest): Promise => { + const res = await http.post('/v1/auth/create', body) + return res.data + }, + + /** GET /v1/auth/me — 현재 로그인 유저 정보 (access token 필요) */ + me: async (): Promise => { + const res = await http.get('/v1/auth/me') + return res.data + }, + + /** POST /v1/auth/logout — 서버측 토큰 폐기(단일 세션) */ + logout: async (): Promise => { + const res = await http.post('/v1/auth/logout') + return res.data + }, +} diff --git a/frontend/src/apis/auth/auth.keys.ts b/frontend/src/apis/auth/auth.keys.ts new file mode 100644 index 0000000..3f19d6e --- /dev/null +++ b/frontend/src/apis/auth/auth.keys.ts @@ -0,0 +1,5 @@ +// 인증 도메인의 TanStack Query 키 팩토리. +export const authKeys = { + all: ['auth'] as const, + me: () => [...authKeys.all, 'me'] as const, +} diff --git a/frontend/src/apis/auth/auth.mutations.ts b/frontend/src/apis/auth/auth.mutations.ts new file mode 100644 index 0000000..1711229 --- /dev/null +++ b/frontend/src/apis/auth/auth.mutations.ts @@ -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({ + 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({ + mutationFn: async () => { + try { + await authApi.logout() + } finally { + tokenStorage.clear() + } + }, + onSettled: () => { + queryClient.clear() + }, + }) +} + +/** 공급사 유저 계정 생성 */ +export function useCreateAccountMutation() { + return useMutation({ + mutationFn: (body: CreateAccountRequest) => authApi.createAccount(body), + }) +} diff --git a/frontend/src/apis/auth/auth.queries.ts b/frontend/src/apis/auth/auth.queries.ts new file mode 100644 index 0000000..4167ecb --- /dev/null +++ b/frontend/src/apis/auth/auth.queries.ts @@ -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, + }) +} diff --git a/frontend/src/apis/auth/auth.type.ts b/frontend/src/apis/auth/auth.type.ts new file mode 100644 index 0000000..7b152ea --- /dev/null +++ b/frontend/src/apis/auth/auth.type.ts @@ -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.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, + } +} diff --git a/frontend/src/apis/auth/index.ts b/frontend/src/apis/auth/index.ts new file mode 100644 index 0000000..9d8f09d --- /dev/null +++ b/frontend/src/apis/auth/index.ts @@ -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' diff --git a/frontend/src/apis/chat/chat.api.ts b/frontend/src/apis/chat/chat.api.ts new file mode 100644 index 0000000..e039f3c --- /dev/null +++ b/frontend/src/apis/chat/chat.api.ts @@ -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 => { + const res = await http.get(`/v1/negotiation/sessions/${sessionId}/chat/init`) + return res.data + }, + + /** GET .../chat/messages — 대화 히스토리(seq 오름차순). 비어 있으면 오프닝 포함 */ + getMessages: async (sessionId: string): Promise => { + const res = await http.get( + `/v1/negotiation/sessions/${sessionId}/chat/messages`, + ) + return res.data + }, + + /** POST .../chat/send — 한 턴 전송, 새 봇 메시지 1건 반환(append-only) */ + send: async (sessionId: string, body: ChatSendRequest): Promise => { + const res = await http.post( + `/v1/negotiation/sessions/${sessionId}/chat/send`, + body, + ) + return res.data + }, +} diff --git a/frontend/src/apis/chat/chat.keys.ts b/frontend/src/apis/chat/chat.keys.ts new file mode 100644 index 0000000..fda0a83 --- /dev/null +++ b/frontend/src/apis/chat/chat.keys.ts @@ -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, +} diff --git a/frontend/src/apis/chat/chat.mutations.ts b/frontend/src/apis/chat/chat.mutations.ts new file mode 100644 index 0000000..fc4f439 --- /dev/null +++ b/frontend/src/apis/chat/chat.mutations.ts @@ -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), + }) +} diff --git a/frontend/src/apis/chat/chat.queries.ts b/frontend/src/apis/chat/chat.queries.ts new file mode 100644 index 0000000..824dd9e --- /dev/null +++ b/frontend/src/apis/chat/chat.queries.ts @@ -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, + }) +} diff --git a/frontend/src/apis/chat/chat.type.ts b/frontend/src/apis/chat/chat.type.ts new file mode 100644 index 0000000..dc6227b --- /dev/null +++ b/frontend/src/apis/chat/chat.type.ts @@ -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 ?? '', + } +} diff --git a/frontend/src/apis/chat/index.ts b/frontend/src/apis/chat/index.ts new file mode 100644 index 0000000..2544c3a --- /dev/null +++ b/frontend/src/apis/chat/index.ts @@ -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' diff --git a/frontend/src/apis/http.ts b/frontend/src/apis/http.ts new file mode 100644 index 0000000..9f6d179 --- /dev/null +++ b/frontend/src/apis/http.ts @@ -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 | null = null + +async function refreshAccessToken(): Promise { + 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) + }, +) diff --git a/frontend/src/apis/index.ts b/frontend/src/apis/index.ts new file mode 100644 index 0000000..3985e59 --- /dev/null +++ b/frontend/src/apis/index.ts @@ -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' diff --git a/frontend/src/apis/negotiation/index.ts b/frontend/src/apis/negotiation/index.ts new file mode 100644 index 0000000..f82d7e4 --- /dev/null +++ b/frontend/src/apis/negotiation/index.ts @@ -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' diff --git a/frontend/src/apis/negotiation/negotiation.api.ts b/frontend/src/apis/negotiation/negotiation.api.ts new file mode 100644 index 0000000..0f78a67 --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.api.ts @@ -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 => { + const res = await http.get('/v1/negotiation/sessions', { params }) + return res.data + }, + + /** POST /v1/negotiation/sessions/{id}/participate — 협상 세션 참여 */ + participate: async (sessionId: string): Promise => { + const res = await http.post( + `/v1/negotiation/sessions/${sessionId}/participate`, + ) + return res.data + }, + + /** POST /v1/negotiation/sessions/{id}/reject — 협상 세션 거부 */ + reject: async (sessionId: string, body: RejectRequest): Promise => { + const res = await http.post( + `/v1/negotiation/sessions/${sessionId}/reject`, + body, + ) + return res.data + }, +} diff --git a/frontend/src/apis/negotiation/negotiation.keys.ts b/frontend/src/apis/negotiation/negotiation.keys.ts new file mode 100644 index 0000000..6461f70 --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.keys.ts @@ -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, +} diff --git a/frontend/src/apis/negotiation/negotiation.mutations.ts b/frontend/src/apis/negotiation/negotiation.mutations.ts new file mode 100644 index 0000000..947565f --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.mutations.ts @@ -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() }) + }, + }) +} diff --git a/frontend/src/apis/negotiation/negotiation.queries.ts b/frontend/src/apis/negotiation/negotiation.queries.ts new file mode 100644 index 0000000..38942cd --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.queries.ts @@ -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, + }) +} diff --git a/frontend/src/apis/negotiation/negotiation.type.ts b/frontend/src/apis/negotiation/negotiation.type.ts new file mode 100644 index 0000000..70ba859 --- /dev/null +++ b/frontend/src/apis/negotiation/negotiation.type.ts @@ -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.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.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 +} diff --git a/frontend/src/apis/tokenStorage.ts b/frontend/src/apis/tokenStorage.ts new file mode 100644 index 0000000..ebc5c3a --- /dev/null +++ b/frontend/src/apis/tokenStorage.ts @@ -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, +} diff --git a/frontend/src/apis/types.ts b/frontend/src/apis/types.ts new file mode 100644 index 0000000..86d18ed --- /dev/null +++ b/frontend/src/apis/types.ts @@ -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 = { + [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 +} diff --git a/front/src/assets/imarketkorea-logo-white.png b/frontend/src/assets/imarketkorea-logo-white.png similarity index 100% rename from front/src/assets/imarketkorea-logo-white.png rename to frontend/src/assets/imarketkorea-logo-white.png diff --git a/front/src/assets/imarketkorea-logo.png b/frontend/src/assets/imarketkorea-logo.png similarity index 100% rename from front/src/assets/imarketkorea-logo.png rename to frontend/src/assets/imarketkorea-logo.png diff --git a/front/src/components/Button.tsx b/frontend/src/components/Button.tsx similarity index 100% rename from front/src/components/Button.tsx rename to frontend/src/components/Button.tsx diff --git a/front/src/components/Input.tsx b/frontend/src/components/Input.tsx similarity index 100% rename from front/src/components/Input.tsx rename to frontend/src/components/Input.tsx diff --git a/front/src/components/Logo.tsx b/frontend/src/components/Logo.tsx similarity index 100% rename from front/src/components/Logo.tsx rename to frontend/src/components/Logo.tsx diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..9ace715 --- /dev/null +++ b/frontend/src/components/Modal.tsx @@ -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) => { + 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 ( +
+ {children} +
+ ) +} diff --git a/front/src/components/index.ts b/frontend/src/components/index.ts similarity index 74% rename from front/src/components/index.ts rename to frontend/src/components/index.ts index 78e677c..ac75f31 100644 --- a/front/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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' diff --git a/front/src/core/provider.tsx b/frontend/src/core/provider.tsx similarity index 66% rename from front/src/core/provider.tsx rename to frontend/src/core/provider.tsx index 2749278..1419e65 100644 --- a/front/src/core/provider.tsx +++ b/frontend/src/core/provider.tsx @@ -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 {children} + return ( + + {children} + + + ) } diff --git a/front/src/features/auth/components/LoginForm.tsx b/frontend/src/features/auth/components/LoginForm.tsx similarity index 100% rename from front/src/features/auth/components/LoginForm.tsx rename to frontend/src/features/auth/components/LoginForm.tsx diff --git a/frontend/src/features/auth/components/RequireAuth.tsx b/frontend/src/features/auth/components/RequireAuth.tsx new file mode 100644 index 0000000..fc4608c --- /dev/null +++ b/frontend/src/features/auth/components/RequireAuth.tsx @@ -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 + } + return <>{children} +} diff --git a/front/src/features/auth/components/SidebarFooter.tsx b/frontend/src/features/auth/components/SidebarFooter.tsx similarity index 52% rename from front/src/features/auth/components/SidebarFooter.tsx rename to frontend/src/features/auth/components/SidebarFooter.tsx index aeca1d5..eda61dc 100644 --- a/front/src/features/auth/components/SidebarFooter.tsx +++ b/frontend/src/features/auth/components/SidebarFooter.tsx @@ -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 (
- {/* TODO: 공급사명 (auth store 연동) */} - - + {user?.supplierName ?? '-'}
diff --git a/front/src/features/auth/hooks/useLogin.ts b/frontend/src/features/auth/hooks/useLogin.ts similarity index 100% rename from front/src/features/auth/hooks/useLogin.ts rename to frontend/src/features/auth/hooks/useLogin.ts diff --git a/frontend/src/features/auth/hooks/useLoginMutation.ts b/frontend/src/features/auth/hooks/useLoginMutation.ts new file mode 100644 index 0000000..76fbeee --- /dev/null +++ b/frontend/src/features/auth/hooks/useLoginMutation.ts @@ -0,0 +1,2 @@ +// 실제 로그인 API 연동은 apis/auth 로 이전됨. 기존 import 경로 호환을 위해 재노출한다. +export { useLoginMutation, type LoginParams } from '@/apis/auth' diff --git a/front/src/features/auth/index.ts b/frontend/src/features/auth/index.ts similarity index 66% rename from front/src/features/auth/index.ts rename to frontend/src/features/auth/index.ts index 2b91506..e940c43 100644 --- a/front/src/features/auth/index.ts +++ b/frontend/src/features/auth/index.ts @@ -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' diff --git a/front/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx similarity index 100% rename from front/src/features/chat/components/ChatMessage.tsx rename to frontend/src/features/chat/components/ChatMessage.tsx diff --git a/front/src/features/chat/components/ChatSection.tsx b/frontend/src/features/chat/components/ChatSection.tsx similarity index 100% rename from front/src/features/chat/components/ChatSection.tsx rename to frontend/src/features/chat/components/ChatSection.tsx diff --git a/front/src/features/chat/components/ItemImage.tsx b/frontend/src/features/chat/components/ItemImage.tsx similarity index 100% rename from front/src/features/chat/components/ItemImage.tsx rename to frontend/src/features/chat/components/ItemImage.tsx diff --git a/front/src/features/chat/components/ItemSection.tsx b/frontend/src/features/chat/components/ItemSection.tsx similarity index 100% rename from front/src/features/chat/components/ItemSection.tsx rename to frontend/src/features/chat/components/ItemSection.tsx diff --git a/front/src/features/chat/components/RemainingTime.tsx b/frontend/src/features/chat/components/RemainingTime.tsx similarity index 100% rename from front/src/features/chat/components/RemainingTime.tsx rename to frontend/src/features/chat/components/RemainingTime.tsx diff --git a/front/src/features/chat/components/UserButton.tsx b/frontend/src/features/chat/components/UserButton.tsx similarity index 100% rename from front/src/features/chat/components/UserButton.tsx rename to frontend/src/features/chat/components/UserButton.tsx diff --git a/front/src/features/chat/components/menu/Contact.tsx b/frontend/src/features/chat/components/menu/Contact.tsx similarity index 100% rename from front/src/features/chat/components/menu/Contact.tsx rename to frontend/src/features/chat/components/menu/Contact.tsx diff --git a/front/src/features/chat/components/menu/Guide.tsx b/frontend/src/features/chat/components/menu/Guide.tsx similarity index 100% rename from front/src/features/chat/components/menu/Guide.tsx rename to frontend/src/features/chat/components/menu/Guide.tsx diff --git a/front/src/features/chat/components/menu/MDInformation.tsx b/frontend/src/features/chat/components/menu/MDInformation.tsx similarity index 100% rename from front/src/features/chat/components/menu/MDInformation.tsx rename to frontend/src/features/chat/components/menu/MDInformation.tsx diff --git a/front/src/features/chat/components/menu/MenuSection.tsx b/frontend/src/features/chat/components/menu/MenuSection.tsx similarity index 100% rename from front/src/features/chat/components/menu/MenuSection.tsx rename to frontend/src/features/chat/components/menu/MenuSection.tsx diff --git a/front/src/features/chat/components/menu/NegoStep.tsx b/frontend/src/features/chat/components/menu/NegoStep.tsx similarity index 100% rename from front/src/features/chat/components/menu/NegoStep.tsx rename to frontend/src/features/chat/components/menu/NegoStep.tsx diff --git a/front/src/features/chat/components/templates/BidSummary.tsx b/frontend/src/features/chat/components/templates/BidSummary.tsx similarity index 100% rename from front/src/features/chat/components/templates/BidSummary.tsx rename to frontend/src/features/chat/components/templates/BidSummary.tsx diff --git a/front/src/features/chat/components/templates/Indicator.tsx b/frontend/src/features/chat/components/templates/Indicator.tsx similarity index 100% rename from front/src/features/chat/components/templates/Indicator.tsx rename to frontend/src/features/chat/components/templates/Indicator.tsx diff --git a/front/src/features/chat/components/templates/OtherReason.tsx b/frontend/src/features/chat/components/templates/OtherReason.tsx similarity index 100% rename from front/src/features/chat/components/templates/OtherReason.tsx rename to frontend/src/features/chat/components/templates/OtherReason.tsx diff --git a/front/src/features/chat/components/templates/RejectCM.tsx b/frontend/src/features/chat/components/templates/RejectCM.tsx similarity index 100% rename from front/src/features/chat/components/templates/RejectCM.tsx rename to frontend/src/features/chat/components/templates/RejectCM.tsx diff --git a/front/src/features/chat/components/templates/RejectRSP.tsx b/frontend/src/features/chat/components/templates/RejectRSP.tsx similarity index 100% rename from front/src/features/chat/components/templates/RejectRSP.tsx rename to frontend/src/features/chat/components/templates/RejectRSP.tsx diff --git a/front/src/features/chat/components/templates/Summary.tsx b/frontend/src/features/chat/components/templates/Summary.tsx similarity index 100% rename from front/src/features/chat/components/templates/Summary.tsx rename to frontend/src/features/chat/components/templates/Summary.tsx diff --git a/front/src/features/chat/components/templates/rejectControls.tsx b/frontend/src/features/chat/components/templates/rejectControls.tsx similarity index 100% rename from front/src/features/chat/components/templates/rejectControls.tsx rename to frontend/src/features/chat/components/templates/rejectControls.tsx diff --git a/front/src/features/chat/components/userInputs.tsx b/frontend/src/features/chat/components/userInputs.tsx similarity index 100% rename from front/src/features/chat/components/userInputs.tsx rename to frontend/src/features/chat/components/userInputs.tsx diff --git a/frontend/src/features/chat/containers/ChatContainer.tsx b/frontend/src/features/chat/containers/ChatContainer.tsx new file mode 100644 index 0000000..1159b38 --- /dev/null +++ b/frontend/src/features/chat/containers/ChatContainer.tsx @@ -0,0 +1,14 @@ +import { useChatController } from '@/features/chat/hooks/useChatController' +import { ChatSection } from '@/features/chat/components/ChatSection' +import { MenuSection } from '@/features/chat/components/menu/MenuSection' + +// 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다. +export function ChatContainer({ sessionId }: { sessionId: string }) { + useChatController(sessionId) + return ( +
+ + +
+ ) +} diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts new file mode 100644 index 0000000..ae9f320 --- /dev/null +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -0,0 +1,113 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router' +import { useChatInitQuery, useChatMessagesQuery, useChatSendMutation, mapMessage } from '@/apis/chat' +import { ErrorCode, getApiErrorMessage, isApiError } from '@/apis/types' +import { toast } from '@/lib' +import { useChatStore } from '@/features/chat/stores/useChatStore' +import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' +import type { ChatMessage, UserInputType } from '@/features/chat/types' + +// 더 이상 대화를 이어갈 수 없는(세션 종료/마감/권한) 코드 → 입력 잠금 후 목록으로 복귀. +const TERMINAL_CODES = new Set([ + ErrorCode.CHAT_NOT_IN_PROGRESS, + ErrorCode.NEGO_QUOTATION_CLOSED, + ErrorCode.NEGO_DEADLINE_PASSED, + ErrorCode.NEGO_FORBIDDEN, + ErrorCode.NEGO_NOT_FOUND, +]) + +let tempSeq = 0 + +// 낙관적 유저 말풍선 생성 (전송 즉시 표시). 서버 확정 메시지는 send 응답으로 append 한다. +function makeUserMessage(text: string, inputType: UserInputType): ChatMessage { + return { + chat_id: `temp-user-${tempSeq++}`, + sender: 'user', + bot_chat_type: null, + user_input_type: inputType, + script: text, + chat_end: false, + next_input_mode: null, + next_input_type: null, + step: '', + display_step: '', + summary: null, + indicator_value: null, + } +} + +/** + * 채팅 페이지 컨트롤러: init/messages 조회를 스토어에 적재하고, + * append-only 전송 구현을 스토어에 주입한다. (mock 을 대체) + */ +export function useChatController(sessionId: string) { + const navigate = useNavigate() + const setInitData = useChatInitStore((s) => s.setInitData) + const initQuery = useChatInitQuery(sessionId) + const messagesQuery = useChatMessagesQuery(sessionId) + const sendMutation = useChatSendMutation(sessionId) + + // init 메타 → 스토어 + useEffect(() => { + if (initQuery.data) setInitData(initQuery.data) + }, [initQuery.data, setInitData]) + + // 대화 히스토리 → 스토어 (진입 1회) + useEffect(() => { + if (messagesQuery.data) useChatStore.getState().setMessages(messagesQuery.data) + }, [messagesQuery.data]) + + // sessionId + 전송 구현 주입 + useEffect(() => { + const store = useChatStore.getState() + store.setSessionId(sessionId) + + store.bindSend((text, inputType = 'text') => { + const optimistic = makeUserMessage(text, inputType) + store.appendMessage(optimistic) + store.setIsLoading(true) + store.setPriceErrorMessage('') + + sendMutation.mutate( + { user_input: text, user_input_type: inputType === 'text' ? null : inputType }, + { + onSuccess: (data) => { + const s = useChatStore.getState() + if (data.message) s.appendMessage(mapMessage(data.message)) + s.setIsLoading(false) + }, + onError: (error) => { + const s = useChatStore.getState() + // 낙관적 메시지 롤백 (서버에 저장되지 않음) + s.setMessages(s.messages.filter((m) => m.chat_id !== optimistic.chat_id)) + s.setIsLoading(false) + const code = isApiError(error) ? error.code : 0 + + // 가격 범위 초과: 입력창 인라인 에러로 표시(페이지 유지) + if (code === ErrorCode.CHAT_PRICE_OUT_OF_RANGE) { + s.setPriceErrorMessage(getApiErrorMessage(error)) + return + } + + toast.error(getApiErrorMessage(error, '협상 처리 중 오류가 발생했습니다.')) + + // 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀 + if (TERMINAL_CODES.has(code)) { + s.bindSend(null) // 입력 잠금(추가 전송 차단) + setTimeout(() => navigate('/list'), 1500) + } + }, + }, + ) + }) + + return () => { + useChatStore.getState().reset() + } + }, [sessionId, sendMutation, navigate]) + + return { + isInitLoading: initQuery.isLoading || messagesQuery.isLoading, + initError: initQuery.error ?? messagesQuery.error, + } +} diff --git a/front/src/features/chat/index.ts b/frontend/src/features/chat/index.ts similarity index 100% rename from front/src/features/chat/index.ts rename to frontend/src/features/chat/index.ts diff --git a/front/src/features/chat/lib/koreanNumber.ts b/frontend/src/features/chat/lib/koreanNumber.ts similarity index 100% rename from front/src/features/chat/lib/koreanNumber.ts rename to frontend/src/features/chat/lib/koreanNumber.ts diff --git a/front/src/features/chat/lib/rejectForm.ts b/frontend/src/features/chat/lib/rejectForm.ts similarity index 100% rename from front/src/features/chat/lib/rejectForm.ts rename to frontend/src/features/chat/lib/rejectForm.ts diff --git a/front/src/features/chat/lib/remainingTime.ts b/frontend/src/features/chat/lib/remainingTime.ts similarity index 70% rename from front/src/features/chat/lib/remainingTime.ts rename to frontend/src/features/chat/lib/remainingTime.ts index f483a78..ad383c8 100644 --- a/front/src/features/chat/lib/remainingTime.ts +++ b/frontend/src/features/chat/lib/remainingTime.ts @@ -1,4 +1,6 @@ // 마감까지 남은 시간 'HH시간 MM분 SS초'. 지났으면 종료 문구, 잘못된 값은 '-'. +// 남은 시간은 "절대 시각 간 차이(duration)"라 타임존과 무관하다. 백엔드가 UTC(+00:00 오프셋 포함)로 +// 내려주므로 new Date 파싱이 정확하면 KST 변환이 따로 필요 없다(날짜로 "표시"할 때만 KST 고정 필요 → lib/datetime). export function getTimeRemaining(targetDateTime: string | Date): string { const now = Date.now() const target = new Date(targetDateTime).getTime() diff --git a/front/src/features/chat/lib/userButtonConfig.ts b/frontend/src/features/chat/lib/userButtonConfig.ts similarity index 100% rename from front/src/features/chat/lib/userButtonConfig.ts rename to frontend/src/features/chat/lib/userButtonConfig.ts diff --git a/front/src/features/chat/stores/useChatInitStore.ts b/frontend/src/features/chat/stores/useChatInitStore.ts similarity index 100% rename from front/src/features/chat/stores/useChatInitStore.ts rename to frontend/src/features/chat/stores/useChatInitStore.ts diff --git a/front/src/features/chat/stores/useChatStore.ts b/frontend/src/features/chat/stores/useChatStore.ts similarity index 54% rename from front/src/features/chat/stores/useChatStore.ts rename to frontend/src/features/chat/stores/useChatStore.ts index 18ec603..c4b8b18 100644 --- a/front/src/features/chat/stores/useChatStore.ts +++ b/frontend/src/features/chat/stores/useChatStore.ts @@ -2,24 +2,39 @@ import { create } from 'zustand' import type { ChatMessage, UserButtonConfig, UserInputType } from '@/features/chat/types' import { deriveUserButtonConfig } from '@/features/chat/lib/userButtonConfig' +type SendFn = (text: string, inputType?: UserInputType) => void + type ChatStore = { + sessionId: string messages: ChatMessage[] userButtonConfig: UserButtonConfig isLoading: boolean priceErrorMessage: string + setSessionId: (sessionId: string) => void setMessages: (messages: ChatMessage[]) => void + appendMessage: (message: ChatMessage) => void setIsLoading: (isLoading: boolean) => void setPriceErrorMessage: (message: string) => void - sendMessage: (text: string, inputType?: UserInputType) => void + // 실제 전송 구현은 컨트롤러(useChatController)가 React Query 와 함께 주입한다. + bindSend: (fn: SendFn | null) => void + sendMessage: SendFn + reset: () => void } -let mockSeq = 0 - -export const useChatStore = create((set, get) => ({ - messages: [], - userButtonConfig: { type: '', text: '' }, +const initial = { + sessionId: '', + messages: [] as ChatMessage[], + userButtonConfig: { type: '' } as UserButtonConfig, isLoading: false, priceErrorMessage: '', +} + +let sendImpl: SendFn | null = null + +export const useChatStore = create((set, get) => ({ + ...initial, + + setSessionId: (sessionId) => set({ sessionId }), setMessages: (messages) => set((s) => ({ @@ -27,6 +42,12 @@ export const useChatStore = create((set, get) => ({ userButtonConfig: deriveUserButtonConfig(messages, s.isLoading, s.priceErrorMessage), })), + appendMessage: (message) => + set((s) => { + const messages = [...s.messages, message] + return { messages, userButtonConfig: deriveUserButtonConfig(messages, s.isLoading, s.priceErrorMessage) } + }), + setIsLoading: (isLoading) => set((s) => ({ isLoading, @@ -39,25 +60,17 @@ export const useChatStore = create((set, get) => ({ userButtonConfig: deriveUserButtonConfig(s.messages, s.isLoading, priceErrorMessage), })), - // mock: API 미연동이라 사용자 메시지를 로컬에 추가만 한다 (실제 협상 진행 로직 없음) + bindSend: (fn) => { + sendImpl = fn + }, + sendMessage: (text, inputType = 'text') => { - const { messages } = get() - const last = messages[messages.length - 1] - const userMsg: ChatMessage = { - chat_id: `mock-user-${mockSeq++}`, - sender: 'user', - bot_chat_type: null, - user_input_type: inputType, - script: text, - chat_end: false, - next_input_mode: null, - next_input_type: null, - step: last?.step ?? '', - display_step: last?.display_step ?? '', - summary: null, - indicator_value: null, - } - const next = [...messages, userMsg] - set({ messages: next, priceErrorMessage: '', userButtonConfig: deriveUserButtonConfig(next, false, '') }) + if (get().isLoading) return + sendImpl?.(text, inputType) + }, + + reset: () => { + sendImpl = null + set({ ...initial }) }, })) diff --git a/front/src/features/chat/types.ts b/frontend/src/features/chat/types.ts similarity index 100% rename from front/src/features/chat/types.ts rename to frontend/src/features/chat/types.ts diff --git a/frontend/src/features/list/components/ActionSection.tsx b/frontend/src/features/list/components/ActionSection.tsx new file mode 100644 index 0000000..5a52efc --- /dev/null +++ b/frontend/src/features/list/components/ActionSection.tsx @@ -0,0 +1,49 @@ +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 interface ActionSectionProps { + isParticipating: boolean + isRejecting: boolean + onParticipate: () => void + onReject: () => void +} + +export function ActionSection({ + isParticipating, + isRejecting, + onParticipate, + onReject, +}: ActionSectionProps) { + return ( +
+ + +
+ ) +} diff --git a/front/src/features/list/components/FilterGroup.tsx b/frontend/src/features/list/components/FilterGroup.tsx similarity index 100% rename from front/src/features/list/components/FilterGroup.tsx rename to frontend/src/features/list/components/FilterGroup.tsx diff --git a/front/src/features/list/components/Pagination.tsx b/frontend/src/features/list/components/Pagination.tsx similarity index 100% rename from front/src/features/list/components/Pagination.tsx rename to frontend/src/features/list/components/Pagination.tsx diff --git a/frontend/src/features/list/components/RejectPopup.tsx b/frontend/src/features/list/components/RejectPopup.tsx new file mode 100644 index 0000000..d7f2d45 --- /dev/null +++ b/frontend/src/features/list/components/RejectPopup.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { Modal } from '@/components' +import { cn, interactive } from '@/lib' + +const REASONS = ['단종', '품절', '기타'] as const + +export interface RejectPopupProps { + onClose: () => void + /** 최종 거부 사유 (프리셋 라벨 또는 기타 입력 텍스트) */ + onSubmit: (reason: string) => void +} + +// 거부 사유 입력 팝업 (단종/품절/기타). +export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) { + const [selectedReason, setSelectedReason] = useState(null) + const [customReason, setCustomReason] = useState('') + const [showError, setShowError] = useState(false) + + const isEtcOpen = selectedReason === '기타' + const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim()) + + const handleReasonClick = (reason: string) => { + setShowError(false) + if (selectedReason === reason) { + setSelectedReason(null) + setCustomReason('') + } else { + setSelectedReason(reason) + if (reason !== '기타') setCustomReason('') + } + } + + const handleSubmit = () => { + if (isEtcOpen && !customReason.trim()) { + setShowError(true) + return + } + if (isSubmitDisabled || !selectedReason) return + + const reason = isEtcOpen ? customReason.trim() : selectedReason + onSubmit(reason) + onClose() + } + + return ( + +
+ {/* 헤더 */} +

+ 거부 사유를 입력해주세요 +

+ + {/* 사유 선택 */} +
+ {REASONS.map((reason) => ( + + ))} +
+ + {/* 기타 입력 + 에러 */} +
+
+