Merge branch 'main' of https://gitea.o2o.kr/Negosium/o2o-negosium
72
AGENT_INTEGRATION.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Chat 연동 규약 (backend ↔ agent)
|
||||
|
||||
> 대상: **agent(9500) 담당자**. backend(9300)가 채팅 한 턴을 agent `POST /v1/chat` 으로 위임한다.
|
||||
> backend/frontend 개발은 이 규약을 가정하고 완료했고, 현재는 `AgentConfig.use_mock=true` 로 내장 mock 을 쓴다.
|
||||
> agent 가 준비되면 **아래 항목을 맞춘 뒤** backend `config.local.toml` 의 `[AgentConfig] use_mock=false` 로 전환하면 된다.
|
||||
|
||||
## 1. 호출 흐름
|
||||
|
||||
```
|
||||
프론트(5173) → backend(9300) /v1/negotiation/sessions/{id}/chat/send → agent(9500) POST /v1/chat
|
||||
```
|
||||
|
||||
- 인증·소유권·견적마감·가격범위 검증, 말풍선 영속화(negotiation.chats), 종료 시 세션 입찰확정은 **backend 책임**.
|
||||
- 협상 로직(스텝 전이·카드선택·학습)은 **agent 책임**. backend 는 agent 응답을 그대로 말풍선으로 저장/전달한다.
|
||||
|
||||
## 2. backend → agent 요청 (`POST /v1/chat`)
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "<negotiation.sessions.session_id>",
|
||||
"rq_type": "재협상 | 재견적",
|
||||
"user_input": "<버튼 텍스트 또는 가격문자열, 첫 턴(오프닝)은 null>",
|
||||
"target_price": 100000,
|
||||
"anchor_price": 99000
|
||||
}
|
||||
```
|
||||
헤더: `X-Tenant-ID: <견적(갑) 회사 company_id>`
|
||||
|
||||
## 3. agent → backend 응답 (`Res_Chat`) ↔ 프론트 ChatMessage 매핑
|
||||
|
||||
| agent 필드 | backend/프론트 |
|
||||
|---|---|
|
||||
| `session_id` | 세션 키 |
|
||||
| `step` | `step` |
|
||||
| `client_step` | `display_step` |
|
||||
| `script` | `script` (말풍선 텍스트) |
|
||||
| `input_mode` | `next_input_mode` (confirm·yes_no·percent·price·delivery_type) |
|
||||
| `input_options` | `next_input_type` (버튼 라벨 배열) |
|
||||
| `chat_end` | `chat_end` |
|
||||
| `outcome` | "success"=협상완료(DONE)+입찰가 확정 / 그 외=협상거부(REJECTED) |
|
||||
| `card_id` | (저장만, 표시 범위 외) |
|
||||
|
||||
## 4. agent 쪽에서 맞춰줘야 하는 항목 ⚠️
|
||||
|
||||
1. **session_id honoring** — 첫 턴에 backend 가 보낸 `session_id`(우리 `negotiation.sessions.session_id`)를
|
||||
**새 uuid 발급 없이 그대로 세션 키로 사용**해야 한다.
|
||||
- 현재 `agent/services/chat_service.py` 는 새 세션 생성 시 `session_id=str(uuid.uuid4())` 로 무시한다 → `req.session_id` 우선 사용하도록 수정 필요.
|
||||
- 이미 `learning.experience_logs.session_id` 가 `negotiation.sessions` 를 가리키도록 설계돼 있어 agent 입장에서도 올바른 방향.
|
||||
2. **tenant 헤더** — backend 가 `X-Tenant-ID = company_id` 로 보낸다. agent 의 TenantMiddleware 가 이 키로 엔진 해석.
|
||||
3. **신규 세션 컨텍스트** — `target_price`/`anchor_price`/`rq_type` 를 backend 가 견적 데이터로 채워 보낸다(기본값 의존 X).
|
||||
4. **tenant_id 정밀 해석(backend 측 TODO와 짝)** — 현재 backend 는 `X-Tenant-ID` 를 빈 값으로 보낸다.
|
||||
정확히는 **견적 작성자(갑) 회사 company_id** 여야 하며, `quotation.user_id → company.users.company_id` 조회로 채울 예정.
|
||||
agent 가 기대하는 tenant 키 형식(company_id uuid 문자열 / `_base`)을 확정해주면 backend 가 맞춘다.
|
||||
5. **(범위 외) indicator / summary / reject** — 이번 범위 미포함. agent 응답에 협상지표·최종요약·거부폼이 생기면
|
||||
backend ChatMessage 의 예약 필드(`indicator_value`/`bot_chat_type`/summary)로 확장 협의.
|
||||
|
||||
## 5. mock → 실제 전환 체크리스트
|
||||
|
||||
- [x] backend `config.local.toml` → `[AgentConfig] use_mock=false` (전환 완료 — agent 미기동 시 1402 로 graceful degrade 확인)
|
||||
- [x] backend `httpx` 의존 설치(`requirements.txt` 반영됨)
|
||||
- [ ] 위 4-1 ~ 4-3 반영 (agent 측)
|
||||
- [ ] tenant_id 해석(4-4) 합의 후 backend `chat_service._agent_context` 의 `tenant_id` 채우기
|
||||
- [ ] agent(9500) 기동 후 양 서버 라이브 E2E
|
||||
|
||||
> 로컬에서 agent 없이 mock 으로 개발하려면 환경변수로 덮는다: `AGENT_USE_MOCK=true`
|
||||
|
||||
## 6. 참고 (backend 구현 위치)
|
||||
|
||||
- agent 어댑터: `backend/services/agent_client.py` (IAgentClient / Http / Mock)
|
||||
- 오케스트레이션: `backend/services/chat_service.py`
|
||||
- 계약(프로토콜): `backend/router/v1/negotiation/chat_protocol.py`
|
||||
- 엔드포인트: `backend/router/v1/negotiation/chat.py`
|
||||
123
SHARED_ENUMS.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 공유 ENUM(코드값) 계약
|
||||
|
||||
> 하나의 `negosium_db` 를 여러 서비스(backend·negodata·agent·바이어측)가 공유한다.
|
||||
> 공유 테이블의 `SMALLINT` 코드값은 **모든 서비스가 동일하게** 매핑해야 한다(스키마 주석: *"세션/견적을 생성·갱신하는 쪽과 코드값이 일치해야 한다"*).
|
||||
> 코드값에 DB CHECK 가 없으므로(애플리케이션 enum 매핑), **이 문서가 단일 출처(SSOT)** 다.
|
||||
>
|
||||
> **기준(canonical): `backend/common/enums.py`** + `postgres-init/01-schema.sql`.
|
||||
> 코드를 추가·변경하려면: 이 문서 갱신 → 관련 서비스 enum 동기화 → 타 서비스 담당자 공지(아래 "변경 절차").
|
||||
|
||||
## 컨벤션
|
||||
- 코드값은 `1` 부터의 정수(`SMALLINT`). 의미 매핑은 각 서비스 `common/enums.py`.
|
||||
- 시각은 `TIMESTAMPTZ`(UTC), 금액 `BIGINT`, 비율 `NUMERIC`.
|
||||
- 라벨(한글) 표시는 프론트 책임. 와이어/DB 에는 코드(정수)만.
|
||||
|
||||
---
|
||||
|
||||
## 1. 계정 / 회사 / 권한
|
||||
|
||||
| Enum | 컬럼 | 코드 | 의미 |
|
||||
|---|---|---|---|
|
||||
| AccountStatus | `company.users.status`, `supplier.supplier_users.status` | 1 / 2 | active / inactive |
|
||||
| CompanyStatus | `company.companies.status` | 1 / 2 | active / inactive |
|
||||
| UserRole | `company.users.role`, `supplier.supplier_users.role` | 1 / 2 | user / manager |
|
||||
| TokenType | `*.user_tokens.type` | 1 / 2 | access / refresh |
|
||||
|
||||
> negodata 일치(이름만 `AccountStatus`↔`UserStatus`). agent 미사용.
|
||||
|
||||
## 2. 견적 / 협상 유형
|
||||
|
||||
| Enum | 컬럼 | 코드 | 의미 |
|
||||
|---|---|---|---|
|
||||
| QtType | `quotation.quotations.type`, `negotiation.sessions.qt_type` | 1 / 2 | 재협상(renego, 1:1) / 재견적(requote, 1:N) |
|
||||
|
||||
> negodata 일치(`QuotationType`). agent 는 HTTP 로 문자열 `"재협상"/"재견적"` 사용(DB 미기록) → 충돌 없음.
|
||||
|
||||
## 3. 견적 상태 — `QuotationStatus`
|
||||
|
||||
`quotation.quotations.status`
|
||||
|
||||
| 코드 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 견적생성 (CREATED) |
|
||||
| 2 | 견적진행중 (IN_PROGRESS) |
|
||||
| 3 | 견적마감 (CLOSED) |
|
||||
|
||||
> ⚠️ **미합의 항목**: negodata 는 `4 = 협상보류(ON_HOLD)` 를 추가로 정의함. 채택 여부 합의 필요(아래 §8).
|
||||
|
||||
## 4. 협상 세션 상태 — `SessionStatus` ★기준
|
||||
|
||||
`negotiation.sessions.status`
|
||||
|
||||
| 코드 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 협상생성 (CREATED) |
|
||||
| 2 | 협상중 (IN_PROGRESS) |
|
||||
| 3 | 협상완료 (DONE) |
|
||||
| 4 | 미참여 (NOT_PARTICIPATED) |
|
||||
| 5 | 협상거부 (REJECTED) |
|
||||
|
||||
전이: `CREATED→(participate)→IN_PROGRESS→(chat 종료)→DONE | REJECTED`, 마감 초과 시 `CREATED→NOT_PARTICIPATED`.
|
||||
|
||||
> 🔴 **불일치(반드시 정렬)**: negodata 는 `1=협상중, 2=협상종료, 3=협상거부` 로 **숫자→의미가 완전히 다름**.
|
||||
> 같은 컬럼이라 한쪽 기준으로 통일하지 않으면 데이터 오염. **이 5-state 정의를 기준으로 통일한다**(participate/미참여/거부/chat 종료 흐름 + 스키마 주석에 부합). 상세 §8.
|
||||
|
||||
## 5. 채팅 — `ChatSender` / `card_type`
|
||||
|
||||
`negotiation.chats.sender`
|
||||
|
||||
| 코드 | 의미 |
|
||||
|---|---|
|
||||
| 1 | BOT — 갑(바이어/구매대행 봇/agent) |
|
||||
| 2 | USER — 을(공급사/협력사) |
|
||||
|
||||
`negotiation.chats.card_type`
|
||||
|
||||
| 코드 | 의미 |
|
||||
|---|---|
|
||||
| 1 | nego_card |
|
||||
| 2 | wild_card |
|
||||
|
||||
> negodata 는 코드 동일, 이름만 `2 = PARTNER`(=공급사). **데이터 호환**(같은 코드·같은 주체). 명칭은 `USER` 로 통일 권장.
|
||||
> `negotiation.chats` 쓰기 주체는 **backend** 단독. agent 는 `learning.*` 만 사용하며 chats 미기록.
|
||||
|
||||
## 6. 상품 / 배송 / 카드
|
||||
|
||||
| Enum | 컬럼 | 코드 | 의미 | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| DeliveryType | `partner.items.delivery_type`, `negotiation.sessions.reject_delivery_type` | 1 / 2 / 3 | 협력사배송 / 지정택배배송 / 픽업배송 | **negodata 정의 채택**(우리도 동일 매핑 사용) |
|
||||
| CardStatus | `card.nego_cards`·`card.wild_cards` (사용여부) | 1 / 2 | active / inactive | negodata 정의 |
|
||||
|
||||
## 7. 미확정(TBD) 코드
|
||||
|
||||
아래는 아직 매핑이 확정되지 않음 — 사용 전 이 문서에 먼저 코드 픽스.
|
||||
|
||||
| 컬럼 | 메모 |
|
||||
|---|---|
|
||||
| `partner.items.quantity_unit` | EA/BOX/SET 등 단위 코드 |
|
||||
| `partner.items.category_type` | 자동 증가 정수(코드 아님) |
|
||||
| `partner.item_internet_lowest_prices.website` / `ai_model` | 크롤링 대상·AI 모델 코드 |
|
||||
| `company.companies.industry` | 업종 코드 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 현재 불일치 & 정렬 계획 (negodata)
|
||||
|
||||
| 항목 | 우리(기준) | negodata | 위험 | 조치 |
|
||||
|---|---|---|---|---|
|
||||
| **SessionStatus** | 1~5 (생성/중/완료/미참여/거부) | 1~3 (중/종료/거부) | 🔴 같은 컬럼 의미 충돌 → 오염 | negodata 가 **우리 5-state 로 정렬**. 세션 생성/갱신 와이어업 전 필수 |
|
||||
| QuotationStatus `ON_HOLD` | 없음 | `4=협상보류` 추가 | 🟡 우리 chat 이 `4` 미처리(마감으로 안 봄) | 채택 여부 합의 → 채택 시 우리 enum/마감 분기에 반영 |
|
||||
| ChatSender 명칭 | `USER`(2) | `PARTNER`(2) | 🟢 코드 동일, 명칭만 | `USER` 로 통일 |
|
||||
| DeliveryType | (미정의) | 1/2/3 | 🟢 | negodata 정의를 우리도 채택(본 문서 §6) |
|
||||
|
||||
**현재 상태:** negodata 는 위 enum 을 *정의만* 했고 `sessions`/`chats` 에 실제 기록하는 코드는 없음(지뢰 상태). 협상 세션 생성/갱신을 와이어업하기 **전에** SessionStatus 를 정렬해야 한다.
|
||||
|
||||
**agent:** `learning.*` 스키마 격리. 공유 테이블 미기록, backend 와 HTTP(문자열 outcome/step)로만 통신 → 코드 충돌 없음.
|
||||
|
||||
---
|
||||
|
||||
## 변경 절차
|
||||
1. 본 문서(`SHARED_ENUMS.md`)에서 코드값 추가/변경을 먼저 합의·반영.
|
||||
2. 각 서비스 `common/enums.py` 동기화(backend → 기준).
|
||||
3. 스키마 주석(`postgres-init/01-schema.sql`)과 일치 확인.
|
||||
4. 타 서비스(negodata/agent/바이어측) 담당자에게 공지 — 특히 **이미 적재된 데이터가 있으면 마이그레이션 동반**.
|
||||
@ -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):
|
||||
|
||||
@ -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")) # 소프트 삭제 여부
|
||||
|
||||
@ -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 입력
|
||||
|
||||
@ -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__()
|
||||
|
||||
@ -7,6 +7,8 @@ port = 9300
|
||||
process_count = 1
|
||||
is_ssl = false
|
||||
is_test = true
|
||||
# CORS 허용 오리진(프론트). 비우면 [] (CORS 미적용). 5173=vite dev, 3300=docker 프로덕션 빌드 서빙.
|
||||
cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:3300", "http://127.0.0.1:3300"]
|
||||
|
||||
[LogConfig]
|
||||
print_console = true
|
||||
|
||||
@ -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 개발 중 통합 테스트용)
|
||||
|
||||
@ -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 그대로.
|
||||
|
||||
@ -6,7 +6,6 @@ os.environ.setdefault("APP_ENV", "local")
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from common.database.model.models import MAIN_BASE
|
||||
@ -20,15 +19,13 @@ def _write_url(cfg) -> str:
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_engine():
|
||||
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다.
|
||||
"""테스트용 스키마를 보장한다. 격리는 각 테스트가 전용 행만 시드/정리하는 방식으로 한다.
|
||||
|
||||
앱(DB_SESSION_MNG)은 자체 엔진으로 같은 DB(config.test.toml)에 접속하므로,
|
||||
여기서 만든 스키마를 그대로 공유한다.
|
||||
앱(DB_SESSION_MNG)은 자체 엔진으로 같은 DB 에 접속하므로 여기서 만든 스키마를 그대로 공유한다.
|
||||
"""
|
||||
engine = create_async_engine(_write_url(main_db_config))
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip
|
||||
await conn.execute(text("TRUNCATE TABLE tbl_account"))
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
147
backend/crud/chat_crud.py
Normal file
@ -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
|
||||
156
backend/crud/session_crud.py
Normal file
@ -0,0 +1,156 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import asc, desc, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items, quotations, sessions
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
|
||||
|
||||
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
|
||||
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
|
||||
class ISessionCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_session_by_id(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, sessions]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_quotation_by_id(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, quotations]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_session_status(self, cdb: AsyncSession, session_id, status: int) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_quotation_status(self, cdb: AsyncSession, quotation_id, status: int) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
||||
pass
|
||||
|
||||
|
||||
class SessionCRUD(ISessionCRUD):
|
||||
@staticmethod
|
||||
def __filters(supplier_id, status, qt_type):
|
||||
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
|
||||
if status is not None:
|
||||
conds.append(sessions.status == status)
|
||||
if qt_type is not None:
|
||||
conds.append(sessions.qt_type == qt_type)
|
||||
return conds
|
||||
|
||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
|
||||
try:
|
||||
conds = self.__filters(supplier_id, status, qt_type)
|
||||
order_col = desc(quotations.end_time) if order == "desc" else asc(quotations.end_time)
|
||||
query = (
|
||||
select(
|
||||
sessions.session_id,
|
||||
sessions.status,
|
||||
sessions.qt_type,
|
||||
sessions.qt_number,
|
||||
quotations.end_time, # qt_end_time = 견적 마감 시각
|
||||
items.code,
|
||||
items.name,
|
||||
items.model_name,
|
||||
items.manufacturer,
|
||||
)
|
||||
.join(items, items.item_id == sessions.item_id)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
.where(*conds, items.deleted == False, quotations.deleted == False) # noqa: E712
|
||||
.order_by(order_col)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "list_by_supplier failed.")
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, []
|
||||
return ErrorType.SUCCESS, rows
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]:
|
||||
try:
|
||||
conds = self.__filters(supplier_id, status, qt_type)
|
||||
query = (
|
||||
select(func.count())
|
||||
.select_from(sessions)
|
||||
.join(items, items.item_id == sessions.item_id)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
.where(*conds, items.deleted == False, quotations.deleted == False) # noqa: E712
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_by_supplier failed.")
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, 0
|
||||
return ErrorType.SUCCESS, (rows[0] if rows else 0)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def get_session_by_id(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, sessions]:
|
||||
try:
|
||||
query = select(sessions).where(sessions.session_id == session_id, sessions.deleted == False).limit(1) # noqa: E712
|
||||
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_session_by_id({session_id}) failed.")
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None
|
||||
if len(row_list) != 1:
|
||||
return ErrorType.DB_INVALID_KEY, None
|
||||
return ErrorType.SUCCESS, row_list[0]
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def get_quotation_by_id(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, quotations]:
|
||||
try:
|
||||
query = select(quotations).where(quotations.qt_id == quotation_id, quotations.deleted == False).limit(1) # noqa: E712
|
||||
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_quotation_by_id({quotation_id}) failed.")
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None
|
||||
if len(row_list) != 1:
|
||||
return ErrorType.DB_INVALID_KEY, None
|
||||
return ErrorType.SUCCESS, row_list[0]
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def update_session_status(self, cdb: AsyncSession, session_id, status: int) -> ErrorType:
|
||||
try:
|
||||
query = update(sessions).where(sessions.session_id == session_id).values(status=status)
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def update_quotation_status(self, cdb: AsyncSession, quotation_id, status: int) -> ErrorType:
|
||||
try:
|
||||
query = update(quotations).where(quotations.qt_id == quotation_id).values(status=status)
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
||||
try:
|
||||
query = (
|
||||
update(sessions)
|
||||
.where(sessions.session_id == session_id)
|
||||
.values(status=status, reject_reason=reject_reason)
|
||||
)
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
@ -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)
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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 클라이언트
|
||||
|
||||
@ -2,12 +2,16 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
from config.server_configs import web_server_config
|
||||
import router.v1.auth.account
|
||||
import router.v1.negotiation.session
|
||||
import router.v1.negotiation.chat
|
||||
|
||||
API_SERVER_START_TIME = GTime.UTCStr()
|
||||
|
||||
@ -22,6 +26,17 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Negosium Api Server", lifespan=lifespan)
|
||||
|
||||
# CORS: config 의 cors_origins 가 있을 때만 적용(브라우저 프론트 호출 허용).
|
||||
# 명시적 오리진을 쓰므로 allow_credentials=True 가능(쿠키/Authorization 헤더 허용).
|
||||
if web_server_config.cors_origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=web_server_config.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축.
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
@ -42,3 +57,5 @@ async def healthz():
|
||||
|
||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||
app.include_router(router.v1.auth.account.router)
|
||||
app.include_router(router.v1.negotiation.session.router)
|
||||
app.include_router(router.v1.negotiation.chat.router)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
57
backend/router/v1/negotiation/chat.py
Normal file
@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
|
||||
from services.chat_service import ChatService
|
||||
from .chat_protocol import Req_ChatSend, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
|
||||
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation Chat"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/sessions/{session_id}/chat/init",
|
||||
response_model=Res_ChatInit,
|
||||
summary="채팅 진입(상품·견적 메타)",
|
||||
description="채팅 화면 진입용. 상품/견적 정보 + 현재 세션 상태 + 마감 시각(타이머)을 반환한다. 소유(공급사) 검증.",
|
||||
)
|
||||
async def chat_init(
|
||||
session_id: str,
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
service: ChatService = Depends(),
|
||||
):
|
||||
return RemoveNoneResponse(await service.init(user_info, credentials.credentials, session_id))
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/sessions/{session_id}/chat/messages",
|
||||
response_model=Res_ChatMessages,
|
||||
summary="대화 히스토리",
|
||||
description="세션의 대화 말풍선 목록(seq 오름차순). 비어 있고 협상중이면 오프닝 메시지를 생성해 포함한다.",
|
||||
)
|
||||
async def chat_messages(
|
||||
session_id: str,
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
service: ChatService = Depends(),
|
||||
):
|
||||
return RemoveNoneResponse(await service.messages(user_info, credentials.credentials, session_id))
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/sessions/{session_id}/chat/send",
|
||||
response_model=Res_ChatSend,
|
||||
summary="협상 한 턴 전송",
|
||||
description="유저 입력을 보내고 agent 가 만든 봇 응답 1건을 반환한다(append-only). 종료 시 세션 입찰을 확정한다.",
|
||||
)
|
||||
async def chat_send(
|
||||
session_id: str,
|
||||
req: Req_ChatSend,
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
service: ChatService = Depends(),
|
||||
):
|
||||
return RemoveNoneResponse(
|
||||
await service.send(user_info, credentials.credentials, session_id, req.user_input_type, req.user_input)
|
||||
)
|
||||
66
backend/router/v1/negotiation/chat_protocol.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""채팅(chat) 라우터 프로토콜 — backend ↔ 프론트 계약.
|
||||
|
||||
agent Res_Chat → 이 ChatMessage 매핑:
|
||||
step→step, client_step→display_step, script→script,
|
||||
input_mode→next_input_mode, input_options→next_input_type, chat_end→chat_end.
|
||||
indicator/summary/reject 는 이번 범위 외(예약 필드, 기본 None).
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
# 말풍선 한 건. sender 는 ChatSender 정수 코드(1=BOT, 2=USER)로 내려가고 라벨 매핑은 프론트가 한다.
|
||||
class ChatMessage(WebPacketProtocol):
|
||||
chat_id: str = ""
|
||||
session_id: str = ""
|
||||
seq: int = 0
|
||||
sender: int = 0 # ChatSender 코드
|
||||
script: str = ""
|
||||
user_input_type: Optional[str] = None # 유저 입력 종류: text|percent|price
|
||||
step: str = ""
|
||||
display_step: str = "" # agent client_step
|
||||
next_input_mode: Optional[str] = None # confirm|yes_no|percent|price|delivery_type
|
||||
next_input_type: Optional[list[str]] = None # 다음 입력 선택지
|
||||
chat_end: bool = False
|
||||
indicator_value: Optional[float] = None # (범위 외 예약) 협상 지표
|
||||
bot_chat_type: Optional[str] = None # (범위 외 예약) indicator|summary 등
|
||||
|
||||
|
||||
# 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용)
|
||||
class Res_ChatInit(Res_WebPacketProtocol):
|
||||
session_id: str = ""
|
||||
session_status: int = 0 # SessionStatus 코드
|
||||
quotation_id: str = ""
|
||||
quotation_end_time: str = "" # ISO 8601 (마감 시각)
|
||||
quotation_memo: str = ""
|
||||
item_id: str = ""
|
||||
item_name: str = ""
|
||||
item_code: str = ""
|
||||
item_image: str = ""
|
||||
item_price: int = 0
|
||||
item_model_name: str = ""
|
||||
item_maker_name: str = ""
|
||||
item_spec: str = ""
|
||||
item_lead_time: str = ""
|
||||
item_min_order_quantity: str = ""
|
||||
item_vat_yn: Optional[bool] = None
|
||||
item_delivery_fee_yn: Optional[bool] = None
|
||||
|
||||
|
||||
# 대화 히스토리(재진입 복원)
|
||||
class Res_ChatMessages(Res_WebPacketProtocol):
|
||||
items: list[ChatMessage] = []
|
||||
|
||||
|
||||
# 한 턴 전송. user_input 은 버튼 텍스트 또는 가격/퍼센트 문자열.
|
||||
class Req_ChatSend(WebPacketProtocol):
|
||||
user_input_type: Optional[str] = None # text|percent|price
|
||||
user_input: str = ""
|
||||
|
||||
|
||||
# append-only: 새 봇 메시지 1건 + 갱신된 세션 상태만 반환(전체 refetch 회피)
|
||||
class Res_ChatSend(Res_WebPacketProtocol):
|
||||
message: Optional[ChatMessage] = None
|
||||
session_status: int = 0
|
||||
33
backend/router/v1/negotiation/protocol.py
Normal file
@ -0,0 +1,33 @@
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
# 협상 세션 목록 행. status/qt_type 은 정수 코드로 내려가고 라벨 매핑은 프론트가 한다.
|
||||
class ListItem(WebPacketProtocol):
|
||||
session_id: str = ""
|
||||
session_status: int = 0 # SessionStatus 코드
|
||||
qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적)
|
||||
qt_number: str = ""
|
||||
qt_end_time: str = "" # ISO 8601 (마감 시각)
|
||||
item_code: str = ""
|
||||
item_name: str = ""
|
||||
model_name: str = ""
|
||||
maker_name: str = ""
|
||||
|
||||
|
||||
class Res_SessionList(Res_WebPacketProtocol):
|
||||
items: list[ListItem] = []
|
||||
total: int = 0
|
||||
page: int = 0
|
||||
page_size: int = 0
|
||||
|
||||
|
||||
class Res_Participate(Res_WebPacketProtocol):
|
||||
session_id: str = "" # 참여 성공한 세션 (채팅 진입용)
|
||||
|
||||
|
||||
class Req_Reject(WebPacketProtocol):
|
||||
reject_reason: str = "" # 거부 사유 (단종/품절 프리셋 라벨 또는 기타 직접 입력)
|
||||
|
||||
|
||||
class Res_Reject(Res_WebPacketProtocol):
|
||||
session_id: str = "" # 거부 처리된 세션
|
||||
63
backend/router/v1/negotiation/session.py
Normal file
@ -0,0 +1,63 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
|
||||
from services.negotiation_service import NegotiationService
|
||||
from .protocol import Req_Reject, Res_Participate, Res_Reject, Res_SessionList
|
||||
|
||||
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/sessions",
|
||||
response_model=Res_SessionList,
|
||||
summary="협상 세션 목록",
|
||||
description="로그인한 공급사의 협상 세션 목록. 필터(status/qt_type, 정수 코드)·마감일 정렬·페이지네이션 지원.",
|
||||
)
|
||||
async def list_sessions(
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
service: NegotiationService = Depends(),
|
||||
status: Optional[int] = Query(None, description="세션 상태 코드 (SessionStatus)"),
|
||||
qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적)"),
|
||||
order: str = Query("asc", description="마감일 정렬: asc(임박순)/desc"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
return RemoveNoneResponse(
|
||||
await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/sessions/{session_id}/participate",
|
||||
response_model=Res_Participate,
|
||||
summary="협상 참여",
|
||||
description="세션에 참여한다. 소유(공급사)·세션상태·견적마감·마감시간 검증 후 협상생성→협상중, 견적→견적진행중으로 전이.",
|
||||
)
|
||||
async def participate(
|
||||
session_id: str,
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
service: NegotiationService = Depends(),
|
||||
):
|
||||
return RemoveNoneResponse(await service.participate(user_info, credentials.credentials, session_id))
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/sessions/{session_id}/reject",
|
||||
response_model=Res_Reject,
|
||||
summary="협상 거부",
|
||||
description="세션 참여를 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유를 저장.",
|
||||
)
|
||||
async def reject(
|
||||
session_id: str,
|
||||
req: Req_Reject,
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
service: NegotiationService = Depends(),
|
||||
):
|
||||
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
|
||||
@ -88,8 +88,7 @@ def DecodeRefreshToken(jwt_token: str) -> UserInfo:
|
||||
return __decode_token(jwt_token, JWT_REFRESH_SECRET, EXCEPTION_REFRESH_TOKEN_EXPIRED)
|
||||
|
||||
|
||||
# ---- Depends 용 토큰 검증기 ------------------------------------------------
|
||||
# 보호된 엔드포인트에서 dependencies=[Depends(IsValidAccessToken)] 로 사용.
|
||||
# ---- Depends 용 토큰 검증기 (디코드만; DB 존재/활성 검증은 service 가 담당) -----
|
||||
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
|
||||
return DecodeAccessToken(credentials.credentials)
|
||||
|
||||
|
||||
88
backend/run_local_locust.sh
Executable file
@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 로컬 부하테스트(locust) 실행 (대화형). 실행하면 파일/시드/방식을 골라 입력한다.
|
||||
# loadtest/ 아래 locust 파일이 늘어나도 목록에서 선택만 하면 된다.
|
||||
#
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")" # backend/
|
||||
|
||||
VENV=".venv"
|
||||
LOCUST="$VENV/bin/locust"
|
||||
HOST="${LOCUST_HOST:-http://localhost:9300}"
|
||||
# 시드용 DB 접속 (local 기본값, 환경변수로 override 가능)
|
||||
PGHOST="${PGHOST:-127.0.0.1}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
PGUSER="${PGUSER:-postgres}"
|
||||
PGDATABASE="${PGDATABASE:-negosium_db}"
|
||||
|
||||
# locust 설치 보장
|
||||
if [[ ! -x "$LOCUST" ]]; then
|
||||
echo "[setup] locust 설치..."
|
||||
"$VENV/bin/python" -m pip install -q locust
|
||||
fi
|
||||
|
||||
# 1) locust 파일 선택
|
||||
FILES=()
|
||||
while IFS= read -r f; do
|
||||
FILES+=("$f")
|
||||
done < <(ls -1 loadtest/*.py 2>/dev/null | grep -v __pycache__ || true)
|
||||
if [[ ${#FILES[@]} -eq 0 ]]; then
|
||||
echo "[error] loadtest/ 에 locust 파일이 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
echo "── locust 파일 선택 ──"
|
||||
i=1
|
||||
for f in "${FILES[@]}"; do
|
||||
echo " $i) ${f#loadtest/}"
|
||||
i=$((i + 1))
|
||||
done
|
||||
read -rp "선택 [1]: " fsel
|
||||
fsel="${fsel:-1}"
|
||||
FILE="${FILES[$((fsel - 1))]:-}"
|
||||
if [[ -z "$FILE" ]]; then
|
||||
echo "[error] 잘못된 선택: $fsel"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) 부하용 공급사 1건 보장 → supplier_id 를 LOAD_SUPPLIER_ID 로 넘긴다.
|
||||
# 유저는 사전 시드하지 않고 locust on_start 에서 self-register 한다(tbl 방식).
|
||||
# (없으면 만들고, 있으면 그대로 사용 = get-or-create)
|
||||
LOAD_SUPPLIER_ID="$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t -A -c "
|
||||
WITH ex AS (
|
||||
SELECT supplier_id FROM partner.suppliers WHERE name='부하테스트공급사' AND deleted=false LIMIT 1
|
||||
), ins AS (
|
||||
INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name)
|
||||
SELECT gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '부하테스트공급사'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ex)
|
||||
RETURNING supplier_id
|
||||
)
|
||||
SELECT supplier_id FROM ins UNION ALL SELECT supplier_id FROM ex LIMIT 1;
|
||||
" 2>/dev/null | tr -d '[:space:]')"
|
||||
if [[ -z "$LOAD_SUPPLIER_ID" ]]; then
|
||||
echo "[error] 부하용 공급사 supplier_id 를 확보하지 못했습니다 (DB 접속/스키마 확인)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 이전 실행에서 self-register 로 쌓인 계정/토큰 정리(누적 방지). 공급사 행은 재사용하므로 남긴다.
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -q \
|
||||
-c "DELETE FROM supplier.supplier_user_tokens WHERE su_id IN (SELECT su_id FROM supplier.supplier_users WHERE id LIKE 'load_user_%');
|
||||
DELETE FROM supplier.supplier_users WHERE id LIKE 'load_user_%';" >/dev/null 2>&1 || true
|
||||
echo "[info] 부하용 공급사 supplier_id=$LOAD_SUPPLIER_ID (유저는 self-register, 이전 부하계정 정리됨)"
|
||||
|
||||
# 3) 실행 방식 선택
|
||||
echo "── 실행 방식 ──"
|
||||
echo " 1) 웹 UI (브라우저에서 사용자 수 조절, http://localhost:8089)"
|
||||
echo " 2) 헤드리스 (값 입력)"
|
||||
read -rp "선택 [1]: " mode
|
||||
mode="${mode:-1}"
|
||||
|
||||
if [[ "$mode" == "2" ]]; then
|
||||
read -rp "동시 사용자 수 [50]: " VU; VU="${VU:-50}"
|
||||
read -rp "초당 증가 수 [10]: " RATE; RATE="${RATE:-10}"
|
||||
read -rp "지속 시간(예 30s/2m) [1m]: " DUR; DUR="${DUR:-1m}"
|
||||
echo "[run] $FILE headless -u $VU -r $RATE -t $DUR (host=$HOST)"
|
||||
exec env LOAD_SUPPLIER_ID="$LOAD_SUPPLIER_ID" "$LOCUST" -f "$FILE" --host "$HOST" --headless -u "$VU" -r "$RATE" -t "$DUR"
|
||||
else
|
||||
echo "[run] $FILE 웹 UI → http://localhost:8089 (host=$HOST)"
|
||||
exec env LOAD_SUPPLIER_ID="$LOAD_SUPPLIER_ID" "$LOCUST" -f "$FILE" --host "$HOST"
|
||||
fi
|
||||
62
backend/run_local_pgwatch.sh
Executable file
@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# PostgreSQL 커넥션 모니터 (로그 방식). negosium_db 커넥션을 한 줄씩 쌓아가며 본다.
|
||||
# 부하테스트(run_local_locust.sh) 중 별도 터미널에서 띄워 풀 사용량 추세를 관찰한다.
|
||||
# (화면을 덮어쓰지 않으므로 스크롤로 이력을 그대로 볼 수 있다)
|
||||
#
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")" # backend/
|
||||
|
||||
# DB 접속 (local 기본값, 환경변수로 override 가능)
|
||||
PGHOST="${PGHOST:-127.0.0.1}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
PGUSER="${PGUSER:-postgres}"
|
||||
PGDATABASE="${PGDATABASE:-negosium_db}"
|
||||
|
||||
q() { psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"; }
|
||||
|
||||
# 접속 확인
|
||||
if ! q -tAc "SELECT 1" >/dev/null 2>&1; then
|
||||
echo "[error] DB 접속 실패: $PGUSER@$PGHOST:$PGPORT/$PGDATABASE"
|
||||
exit 1
|
||||
fi
|
||||
MAXCONN="$(q -tAc "SHOW max_connections;" 2>/dev/null | tr -d '[:space:]')"
|
||||
MAXCONN="${MAXCONN:-0}"
|
||||
|
||||
read -rp "갱신 간격(초) [1]: " ITV; ITV="${ITV:-1}"
|
||||
read -rp "로그 파일로도 저장 (경로, 비우면 화면만): " LOGF
|
||||
|
||||
echo "DB=$PGDATABASE max_connections=$MAXCONN (Ctrl+C 로 종료)"
|
||||
echo " - total: 전체 커넥션 / active: 실행 중 / idle: 풀 유휴 / idle_tx: 트랜잭션 유휴(누수 의심)"
|
||||
HEADER="시각 total active idle idle_tx"
|
||||
echo "$HEADER"
|
||||
[[ -n "$LOGF" ]] && { echo "# $HEADER" >>"$LOGF"; }
|
||||
|
||||
emit() { # 화면 + (옵션)파일
|
||||
echo "$1"
|
||||
[[ -n "$LOGF" ]] && echo "$1" >>"$LOGF"
|
||||
}
|
||||
|
||||
# 우리 앱 풀 산식(참고): USER/PARTNER × R/W = 엔진 4벌 × (pool_size+max_overflow) = 최대 120 / 워커
|
||||
while true; do
|
||||
TS="$(date '+%H:%M:%S')"
|
||||
# 상태별 카운트를 한 쿼리로(모니터 자신은 제외). 출력: "total active idle idle_tx"
|
||||
ROW="$(q -tAF' ' -c "SELECT count(*),
|
||||
count(*) FILTER (WHERE state='active'),
|
||||
count(*) FILTER (WHERE state='idle'),
|
||||
count(*) FILTER (WHERE state='idle in transaction')
|
||||
FROM pg_stat_activity
|
||||
WHERE datname='$PGDATABASE' AND pid <> pg_backend_pid();" 2>/dev/null)"
|
||||
read -r TOTAL ACTIVE IDLE IDLETX <<<"${ROW:-0 0 0 0}"
|
||||
TOTAL="${TOTAL:-0}"; ACTIVE="${ACTIVE:-0}"; IDLE="${IDLE:-0}"; IDLETX="${IDLETX:-0}"
|
||||
|
||||
PCT=0
|
||||
if [[ "$MAXCONN" =~ ^[0-9]+$ && "$MAXCONN" -gt 0 ]]; then PCT=$(( TOTAL * 100 / MAXCONN )); fi
|
||||
WARN=""
|
||||
if (( PCT >= 80 )); then WARN=" <- max 임박!"; fi
|
||||
|
||||
emit "$(printf '%s %3s/%s (%3s%%) %5s %4s %4s%s' \
|
||||
"$TS" "$TOTAL" "$MAXCONN" "$PCT" "$ACTIVE" "$IDLE" "$IDLETX" "$WARN")"
|
||||
|
||||
sleep "$ITV"
|
||||
done
|
||||
57
backend/run_local_server.sh
Executable file
@ -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
|
||||
86
backend/scripts/dev_seed.sql
Normal file
@ -0,0 +1,86 @@
|
||||
-- 개발/연동 테스트용 시드 데이터.
|
||||
-- 로그인 계정 1개(공급사 1개) + 협상 세션 6개(상품/견적 조인용)를 채운다.
|
||||
-- 재실행 안전: 고정 UUID 로 먼저 지운 뒤 다시 넣는다.
|
||||
--
|
||||
-- 실행: PGPASSWORD=postgres psql -h 127.0.0.1 -p 5432 -U postgres -d negosium_db -f backend/scripts/dev_seed.sql
|
||||
--
|
||||
-- 로그인: id=test01 / pw=1234 (비밀번호는 pgcrypto bcrypt 로 해시 — 백엔드 bcrypt.checkpw 와 호환)
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 고정 UUID (재실행 시 식별/삭제용)
|
||||
-- supplier : a0000000-...-0001
|
||||
-- dummy fk : e0000000-...-0000 (company_id/user_id 등 no-FK 더미)
|
||||
-- items : b...001~006 / quotations : c...001~006 / sessions : d...001~006
|
||||
|
||||
-- 1) 기존 시드 제거 (하드 삭제)
|
||||
DELETE FROM supplier.supplier_user_tokens
|
||||
WHERE su_id IN (SELECT su_id FROM supplier.supplier_users WHERE id = 'test01');
|
||||
DELETE FROM supplier.supplier_users WHERE id = 'test01';
|
||||
DELETE FROM negotiation.sessions WHERE supplier_id = 'a0000000-0000-0000-0000-000000000001';
|
||||
DELETE FROM partner.items WHERE item_id::text LIKE 'b0000000-0000-0000-0000-0000000000%';
|
||||
DELETE FROM quotation.quotations WHERE qt_id::text LIKE 'c0000000-0000-0000-0000-0000000000%';
|
||||
DELETE FROM partner.suppliers WHERE supplier_id = 'a0000000-0000-0000-0000-000000000001';
|
||||
|
||||
-- 2) 공급사
|
||||
INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name, code)
|
||||
VALUES (
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'e0000000-0000-0000-0000-000000000000',
|
||||
'e0000000-0000-0000-0000-000000000000',
|
||||
'테스트공급사(연동)',
|
||||
'SUP-DEV-01'
|
||||
);
|
||||
|
||||
-- 3) 로그인 계정 (id=test01 / pw=1234)
|
||||
INSERT INTO supplier.supplier_users
|
||||
(supplier_id, id, password, name, email, contact_number, last_accessed_at, status, role)
|
||||
VALUES (
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'test01',
|
||||
crypt('1234', gen_salt('bf', 12)),
|
||||
'홍길동',
|
||||
'test01@example.com',
|
||||
'010-1234-5678',
|
||||
now(),
|
||||
1, -- ACTIVE
|
||||
2 -- MANAGER
|
||||
);
|
||||
|
||||
-- 4) 상품 6개
|
||||
INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer, price)
|
||||
VALUES
|
||||
('b0000000-0000-0000-0000-000000000001','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','사무용 노트북 14인치','IMK-10231','NB-1400-PRO','삼성전자', 1250000),
|
||||
('b0000000-0000-0000-0000-000000000002','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','레이저 복합기','IMK-10232','MFC-7890DW','브라더', 430000),
|
||||
('b0000000-0000-0000-0000-000000000003','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','27인치 4K 모니터','IMK-10233','U2723QE','델', 690000),
|
||||
('b0000000-0000-0000-0000-000000000004','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','무선 기계식 키보드','IMK-10234','MX-KEYS-M','로지텍', 159000),
|
||||
('b0000000-0000-0000-0000-000000000005','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','A4 무선 레이저프린터','IMK-10235','SL-M2030','HP', 210000),
|
||||
('b0000000-0000-0000-0000-000000000006','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','회의실 대형 디스플레이 65인치','IMK-10236','QM65R','삼성전자', 2890000);
|
||||
|
||||
-- 5) 견적 6개 (end_time = 마감 진실값)
|
||||
-- type: 1=재협상(RENEGO) 2=재견적(REQUOTE) / status: 1=생성 2=진행중 3=마감
|
||||
INSERT INTO quotation.quotations
|
||||
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, start_time, end_time)
|
||||
VALUES
|
||||
('c0000000-0000-0000-0000-000000000001','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','노트북 재견적','QT-2026-000101',2,1,1,'2026-06-15 09:00+00','2026-06-20 18:00+00'),
|
||||
('c0000000-0000-0000-0000-000000000002','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','복합기 재협상','QT-2026-000102',1,1,2,'2026-06-14 09:00+00','2026-06-19 12:30+00'),
|
||||
('c0000000-0000-0000-0000-000000000003','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','모니터 재견적','QT-2026-000103',2,1,3,'2026-06-10 09:00+00','2026-06-22 09:00+00'),
|
||||
('c0000000-0000-0000-0000-000000000004','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','키보드 재협상','QT-2026-000104',1,1,2,'2026-06-13 09:00+00','2026-06-21 15:45+00'),
|
||||
('c0000000-0000-0000-0000-000000000005','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','프린터 재견적','QT-2026-000105',2,1,3,'2026-06-10 09:00+00','2026-06-16 11:00+00'),
|
||||
('c0000000-0000-0000-0000-000000000006','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','디스플레이 재견적','QT-2026-000106',2,1,1,'2026-06-16 09:00+00','2026-06-25 17:00+00');
|
||||
|
||||
-- 6) 협상 세션 6개 (supplier_id = 로그인 공급사) / 다양한 상태
|
||||
-- status: 1=협상생성 2=협상중 3=협상완료 4=미참여 5=협상거부
|
||||
INSERT INTO negotiation.sessions
|
||||
(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time)
|
||||
VALUES
|
||||
('d0000000-0000-0000-0000-000000000001','c0000000-0000-0000-0000-000000000001','b0000000-0000-0000-0000-000000000001','a0000000-0000-0000-0000-000000000001','QT-2026-000101',1,2,1180000,1,'2026-06-20 18:00+00'),
|
||||
('d0000000-0000-0000-0000-000000000002','c0000000-0000-0000-0000-000000000002','b0000000-0000-0000-0000-000000000002','a0000000-0000-0000-0000-000000000001','QT-2026-000102',1,1, 400000,2,'2026-06-19 12:30+00'),
|
||||
('d0000000-0000-0000-0000-000000000003','c0000000-0000-0000-0000-000000000003','b0000000-0000-0000-0000-000000000003','a0000000-0000-0000-0000-000000000001','QT-2026-000103',1,2, 650000,3,'2026-06-22 09:00+00'),
|
||||
('d0000000-0000-0000-0000-000000000004','c0000000-0000-0000-0000-000000000004','b0000000-0000-0000-0000-000000000004','a0000000-0000-0000-0000-000000000001','QT-2026-000104',1,1, 150000,5,'2026-06-21 15:45+00'),
|
||||
('d0000000-0000-0000-0000-000000000005','c0000000-0000-0000-0000-000000000005','b0000000-0000-0000-0000-000000000005','a0000000-0000-0000-0000-000000000001','QT-2026-000105',1,2, 200000,4,'2026-06-16 11:00+00'),
|
||||
('d0000000-0000-0000-0000-000000000006','c0000000-0000-0000-0000-000000000006','b0000000-0000-0000-0000-000000000006','a0000000-0000-0000-0000-000000000001','QT-2026-000106',1,2,2700000,1,'2026-06-25 17:00+00');
|
||||
|
||||
COMMIT;
|
||||
153
backend/services/agent_client.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""협상 에이전트(agent, 포트 9500) 호출 클라이언트.
|
||||
|
||||
backend 는 /chat 한 턴을 agent 로 위임한다(README: "backend 가 /chat 을 agent 로 위임").
|
||||
agent 의 계약(Req_Chat/Res_Chat)에 맞춘 어댑터. agent 가 아직 없거나 로컬에서 미연동일 때를 위해
|
||||
mock 구현을 두고 config(AgentConfig.use_mock) 로 선택한다 — 이 격리 덕에 backend/프론트를
|
||||
agent 완성 여부와 무관하게 통합 테스트할 수 있다.
|
||||
|
||||
agent 응답(Res_Chat) → AgentTurn 매핑:
|
||||
step, client_step, script, input_mode(=next_input_mode), input_options(=next_input_type),
|
||||
chat_end, outcome, card_id, indicator_value.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from common.logger import LOG
|
||||
from config.server_configs import agent_config
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentTurn:
|
||||
"""agent 한 턴 응답(Res_Chat) 의 backend 표현."""
|
||||
|
||||
session_id: Optional[str] = None
|
||||
step: str = ""
|
||||
client_step: str = ""
|
||||
script: str = ""
|
||||
input_mode: Optional[str] = None # 프론트 next_input_mode 로 매핑
|
||||
input_options: Optional[list[str]] = None # 프론트 next_input_type 로 매핑
|
||||
chat_end: bool = False
|
||||
outcome: Optional[str] = None # "success" | "failure" (종료 시)
|
||||
card_id: Optional[str] = None
|
||||
indicator_value: Optional[float] = None
|
||||
ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentChatContext:
|
||||
"""새 세션 시작 시 agent 에 주입하는 협상 컨텍스트. 기존 세션이면 user_input 만 의미 있다."""
|
||||
|
||||
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
|
||||
rq_type: str = "재협상" # 재협상 | 재견적
|
||||
target_price: int = 0 # 갑 목표 매입가(원)
|
||||
anchor_price: int = 0 # 앵커링가(목표가보다 낮음)
|
||||
turn: int = 0 # 직전까지의 봇 턴 수(mock 진행용; 실제 agent 는 무시)
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class IAgentClient(ABC):
|
||||
@abstractmethod
|
||||
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
|
||||
"""협상 한 턴. session_id 없으면 새 세션 시작. user_input 으로 진행(버튼 텍스트/가격)."""
|
||||
...
|
||||
|
||||
|
||||
class HttpAgentClient(IAgentClient):
|
||||
"""실제 agent(9500) 위임 구현. agent POST /v1/chat 호출."""
|
||||
|
||||
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
|
||||
import httpx # 지연 import — mock 모드에서는 httpx 의존을 강제하지 않는다.
|
||||
|
||||
body = {
|
||||
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
|
||||
"rq_type": ctx.rq_type,
|
||||
"user_input": user_input,
|
||||
"target_price": ctx.target_price,
|
||||
"anchor_price": ctx.anchor_price,
|
||||
}
|
||||
headers = {"X-Tenant-ID": ctx.tenant_id} # 핸드오프 #2
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=agent_config.base_url, timeout=agent_config.timeout_sec) as cli:
|
||||
resp = await cli.post("/v1/chat", json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[AgentClient] agent 호출 실패: {ex}")
|
||||
return AgentTurn(ok=False)
|
||||
|
||||
return AgentTurn(
|
||||
session_id=data.get("session_id"),
|
||||
step=data.get("step") or "",
|
||||
client_step=data.get("client_step") or "",
|
||||
script=data.get("script") or "",
|
||||
input_mode=data.get("input_mode"),
|
||||
input_options=data.get("input_options"),
|
||||
chat_end=bool(data.get("chat_end", False)),
|
||||
outcome=data.get("outcome"),
|
||||
card_id=data.get("card_id"),
|
||||
indicator_value=data.get("indicator_value"),
|
||||
ok=True,
|
||||
)
|
||||
|
||||
|
||||
class MockAgentClient(IAgentClient):
|
||||
"""agent 미연동용 결정론적 mock. ctx.turn(직전 봇 턴 수)으로 협상 단계를 진행한다.
|
||||
|
||||
플로우(핵심만): 0=인사(확인) → 1=품목안내(확인) → 2=가격협상(가격입력) → 3+=수락/종료.
|
||||
"""
|
||||
|
||||
_SCRIPT = [
|
||||
("서비스안내", "협상에 참여해 주셔서 감사합니다. 시작하시겠어요?", "confirm", ["네, 시작할게요"]),
|
||||
("협상품목안내", "협상 품목을 확인해 주세요. 가격 협상을 진행할까요?", "confirm", ["가격 협상 진행"]),
|
||||
("가격협상", "희망 공급가를 입력해 주세요.", "price", None),
|
||||
]
|
||||
|
||||
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
|
||||
sid = session_id or "mock-session"
|
||||
turn = ctx.turn
|
||||
|
||||
# 공급사가 협상 포기/거부 의사를 밝히면 실패로 종료(거부)한다.
|
||||
if user_input and ("포기" in user_input or "거부" in user_input):
|
||||
return AgentTurn(
|
||||
session_id=sid, step="협상종료", client_step="협상종료",
|
||||
script="협상이 종료되었습니다.", input_mode=None, input_options=None,
|
||||
chat_end=True, outcome="failure",
|
||||
)
|
||||
|
||||
if turn < len(self._SCRIPT):
|
||||
step, script, mode, options = self._SCRIPT[turn]
|
||||
return AgentTurn(
|
||||
session_id=sid, step=step, client_step=step, script=script,
|
||||
input_mode=mode, input_options=options, chat_end=False,
|
||||
)
|
||||
|
||||
# 가격 제시 이후: 목표가 이하면 수락 종료, 아니면 한 번 더 제안 요청
|
||||
price = _parse_price(user_input)
|
||||
if price is not None and ctx.target_price and price <= ctx.target_price:
|
||||
return AgentTurn(
|
||||
session_id=sid, step="협상종료", client_step="협상종료",
|
||||
script=f"제안하신 {price:,}원으로 합의되었습니다. 감사합니다.",
|
||||
input_mode=None, input_options=None, chat_end=True, outcome="success",
|
||||
card_id="NGC-MOCK", indicator_value=100.0,
|
||||
)
|
||||
return AgentTurn(
|
||||
session_id=sid, step="가격협상", client_step="가격협상",
|
||||
script="조금 더 조정된 가격을 제안해 주시겠어요?",
|
||||
input_mode="price", input_options=None, chat_end=False,
|
||||
card_id="NGC-MOCK", indicator_value=50.0,
|
||||
)
|
||||
|
||||
|
||||
def _parse_price(text: Optional[str]) -> Optional[int]:
|
||||
"""'1,500원' / '1500' 등에서 정수 가격을 파싱한다. 실패 시 None."""
|
||||
if not text:
|
||||
return None
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def get_agent_client() -> IAgentClient:
|
||||
"""config 에 따라 mock/실제 클라이언트를 반환한다(FastAPI Depends 용)."""
|
||||
return MockAgentClient() if agent_config.use_mock else HttpAgentClient()
|
||||
@ -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
|
||||
|
||||
334
backend/services/chat_service.py
Normal file
@ -0,0 +1,334 @@
|
||||
"""ChatService — 채팅 페이지 오케스트레이션.
|
||||
|
||||
backend 가 협상 한 턴을 agent(9500) 로 위임하고, 말풍선 로그(negotiation.chats)를 영속화하며,
|
||||
종료 시 세션 상태(negotiation.sessions)를 전이한다. agent 는 외부 고정 계약(agent_client 어댑터).
|
||||
|
||||
- init : 상품/견적 메타 + 현재 세션 상태 (타이머용 마감 시각 포함)
|
||||
- messages : 대화 히스토리 복원. 비어 있고 협상중이면 agent 오프닝 한 턴을 seed(지연 생성).
|
||||
- send : (검증 → 유저 메시지 저장 → agent 위임 → 봇 메시지 저장 → 종료 시 입찰 확정) 단일 트랜잭션.
|
||||
append-only — 새 봇 메시지 1건만 반환(전체 refetch 회피).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import chats, items, quotations, sessions
|
||||
from common.enums import ChatSender, DBWRType, ErrorType, QuotationStatus, SessionStatus
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.chat_crud import ChatCRUD, IChatCRUD
|
||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||
from router.v1.negotiation.chat_protocol import ChatMessage, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
|
||||
from services.auth_service import AuthService
|
||||
|
||||
|
||||
# 가격 허용 범위 배수(목표가 기준). 범위를 벗어난 제시가는 CHAT_PRICE_OUT_OF_RANGE 로 막는다.
|
||||
PRICE_FLOOR_RATIO = 0.3
|
||||
PRICE_CEIL_RATIO = 1.7
|
||||
|
||||
|
||||
class ChatService:
|
||||
def __init__(
|
||||
self,
|
||||
auth: AuthService = Depends(AuthService),
|
||||
session_crud: ISessionCRUD = Depends(SessionCRUD),
|
||||
chat_crud: IChatCRUD = Depends(ChatCRUD),
|
||||
agent: IAgentClient = Depends(get_agent_client),
|
||||
):
|
||||
self.auth = auth
|
||||
self.session_crud = session_crud
|
||||
self.chat_crud = chat_crud
|
||||
self.agent = agent
|
||||
|
||||
# ---- 공통 전처리 ----------------------------------------------------
|
||||
async def _auth_and_own_session(self, user_info: UserInfo, access_token: str, session_id_str: str):
|
||||
"""인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None)."""
|
||||
err_type, info = await self.auth.authenticate(user_info, access_token)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None
|
||||
try:
|
||||
session_id = uuid.UUID(session_id_str)
|
||||
except (ValueError, TypeError):
|
||||
return ErrorType.NEGO_NOT_FOUND, None
|
||||
|
||||
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or sess is None:
|
||||
return ErrorType.NEGO_NOT_FOUND, None
|
||||
if str(sess.supplier_id) != info.supplier_id:
|
||||
return ErrorType.NEGO_FORBIDDEN, None
|
||||
return ErrorType.SUCCESS, sess
|
||||
|
||||
# ---- init -----------------------------------------------------------
|
||||
async def init(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatInit:
|
||||
res = Res_ChatInit()
|
||||
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
err_type, quote = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or quote is None:
|
||||
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||
return res
|
||||
|
||||
err_type, item = await DB_SESSION_MNG.execute_lambda(
|
||||
items.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or item is None:
|
||||
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||
return res
|
||||
|
||||
# 마감 시간 초과 + 협상생성이면 미참여로 정리 (participate 와 동일 일관성).
|
||||
end = quote.end_time
|
||||
if end is not None and end.tzinfo is None:
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
if end is not None and end < datetime.now(timezone.utc) and sess.status == SessionStatus.CREATED.value:
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[sessions.DBType()],
|
||||
[lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.NOT_PARTICIPATED.value)],
|
||||
)
|
||||
sess.status = SessionStatus.NOT_PARTICIPATED.value
|
||||
|
||||
res.session_id = str(sess.session_id)
|
||||
res.session_status = sess.status
|
||||
res.quotation_id = str(sess.quotation_id)
|
||||
res.quotation_end_time = quote.end_time.isoformat(timespec="seconds") if quote.end_time else ""
|
||||
res.quotation_memo = quote.memo or ""
|
||||
res.item_id = str(item.item_id)
|
||||
res.item_name = item.name or ""
|
||||
res.item_code = item.code or ""
|
||||
res.item_image = item.image_url or ""
|
||||
res.item_price = item.price or 0
|
||||
res.item_model_name = item.model_name or ""
|
||||
res.item_maker_name = item.manufacturer or ""
|
||||
res.item_spec = item.spec or ""
|
||||
res.item_lead_time = str(item.lead_time) if item.lead_time is not None else ""
|
||||
res.item_min_order_quantity = item.moq or ""
|
||||
res.item_vat_yn = item.vat_yn
|
||||
res.item_delivery_fee_yn = item.delivery_fee_yn
|
||||
return res
|
||||
|
||||
# ---- messages -------------------------------------------------------
|
||||
async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages:
|
||||
res = Res_ChatMessages()
|
||||
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
chats.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.chat_crud.list_by_session(s, sess.session_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존)
|
||||
if not rows and sess.status == SessionStatus.IN_PROGRESS.value:
|
||||
opening = await self._seed_opening(sess)
|
||||
if opening is not None:
|
||||
res.items = [opening]
|
||||
return res
|
||||
|
||||
res.items = [self._row_to_message(r) for r in rows]
|
||||
return res
|
||||
|
||||
async def _seed_opening(self, sess) -> Optional[ChatMessage]:
|
||||
"""오프닝(턴0) 봇 메시지를 agent 로 생성하고 seq=1 로 저장한다. 동시 진입 충돌은 무시(유니크가 방어)."""
|
||||
ctx = self._agent_context(sess, turn=0)
|
||||
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=None, ctx=ctx)
|
||||
if not turn.ok:
|
||||
return None
|
||||
bot = self._build_bot_chat(sess, seq=1, turn=turn)
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, bot)]
|
||||
)
|
||||
return self._chat_to_message(bot)
|
||||
|
||||
# ---- send (핵심) ----------------------------------------------------
|
||||
async def send(self, user_info: UserInfo, access_token: str, session_id_str: str, user_input_type: Optional[str], user_input: str) -> Res_ChatSend:
|
||||
res = Res_ChatSend()
|
||||
|
||||
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 협상중이 아니면 대화 불가
|
||||
if sess.status != SessionStatus.IN_PROGRESS.value:
|
||||
res.result.SetResult(ErrorType.CHAT_NOT_IN_PROGRESS)
|
||||
return res
|
||||
|
||||
# 견적 마감/시간 검증
|
||||
err_type, quote = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or quote is None:
|
||||
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||
return res
|
||||
if quote.status == QuotationStatus.CLOSED.value:
|
||||
res.result.SetResult(ErrorType.NEGO_QUOTATION_CLOSED)
|
||||
return res
|
||||
end = quote.end_time
|
||||
if end is not None and end.tzinfo is None:
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
if end is not None and end < datetime.now(timezone.utc):
|
||||
res.result.SetResult(ErrorType.NEGO_DEADLINE_PASSED)
|
||||
return res
|
||||
|
||||
# 가격 입력이면 범위 검증
|
||||
price = _parse_price(user_input) if user_input_type == "price" else None
|
||||
if user_input_type == "price":
|
||||
if price is None or not _in_price_range(price, sess.target_price):
|
||||
res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE)
|
||||
return res
|
||||
|
||||
# 직전 메시지(seq/sender) — 동시전송 가드 + seq 채번
|
||||
err_type, (max_seq, last_sender) = await DB_SESSION_MNG.execute_lambda(
|
||||
chats.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.chat_crud.get_last(s, sess.session_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 직전이 유저 메시지면 이전 턴이 아직 처리 중(봇 응답 미도착) → 중복 전송 거절
|
||||
if last_sender == ChatSender.USER.value:
|
||||
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
|
||||
return res
|
||||
|
||||
err_type, turn_no = await DB_SESSION_MNG.execute_lambda(
|
||||
chats.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.chat_crud.count_bot_messages(s, sess.session_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 유저 메시지 선점(pre-claim): (session_id, seq) 부분 유니크로 동시 전송을 직렬화한다.
|
||||
# 경합에서 밀리면(같은 seq 충돌) agent 를 호출하지 않고 CHAT_IN_PROGRESS 로 거절 → 중복 진행 방지.
|
||||
user_msg = self._build_user_chat(sess, seq=max_seq + 1, user_input=user_input, user_input_type=user_input_type, price=price)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, user_msg)]
|
||||
)
|
||||
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
|
||||
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
|
||||
return res
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# agent 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능.
|
||||
ctx = self._agent_context(sess, turn=turn_no)
|
||||
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=user_input, ctx=ctx)
|
||||
if not turn.ok:
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)]
|
||||
)
|
||||
res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE)
|
||||
return res
|
||||
|
||||
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn)
|
||||
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
||||
new_status = sess.status
|
||||
if turn.chat_end:
|
||||
if turn.outcome == "success":
|
||||
new_status = SessionStatus.DONE.value
|
||||
bid = price if price is not None else sess.target_price
|
||||
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
|
||||
else:
|
||||
new_status = SessionStatus.REJECTED.value
|
||||
funcs.append(lambda s: self.chat_crud.finalize_session(
|
||||
s, sess.session_id, new_status,
|
||||
reject_reason=(user_input or None), reject_price=price,
|
||||
))
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
# 봇 저장 실패 시에도 선점 유저 메시지를 롤백해 stuck(CHAT_IN_PROGRESS) 방지.
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)]
|
||||
)
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
res.message = self._chat_to_message(bot_msg)
|
||||
res.session_status = new_status
|
||||
return res
|
||||
|
||||
# ---- 빌더 / 매퍼 ----------------------------------------------------
|
||||
def _agent_context(self, sess, turn: int) -> AgentChatContext:
|
||||
# 핸드오프 #2/#5: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
|
||||
# 현재 quotation.user_id 만 보유 → 정확한 company_id 해석(company.users 조회)은 agent 연동 시 보완.
|
||||
tenant_id = "" # mock 은 무시. 실제 연동 시 quotation 의 buyer company_id 로 채운다.
|
||||
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
|
||||
anchor = int(sess.target_price * 0.99) if sess.target_price else 0
|
||||
return AgentChatContext(
|
||||
tenant_id=tenant_id, rq_type=rq_type,
|
||||
target_price=int(sess.target_price or 0), anchor_price=anchor, turn=turn,
|
||||
)
|
||||
|
||||
def _build_user_chat(self, sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats:
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.USER.value,
|
||||
target_price=int(price) if price is not None else 0,
|
||||
meta={"script": user_input, "user_input_type": user_input_type},
|
||||
)
|
||||
|
||||
def _build_bot_chat(self, sess, seq: int, turn) -> chats:
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.BOT.value,
|
||||
target_price=int(sess.target_price or 0),
|
||||
meta={
|
||||
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||
"chat_end": turn.chat_end, "card_id": turn.card_id,
|
||||
},
|
||||
)
|
||||
|
||||
def _chat_to_message(self, c: chats) -> ChatMessage:
|
||||
"""방금 만든 chats 객체 → 응답 ChatMessage (DB 재조회 없이)."""
|
||||
meta = c.meta or {}
|
||||
return ChatMessage(
|
||||
chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender,
|
||||
script=meta.get("script") or "",
|
||||
user_input_type=meta.get("user_input_type"),
|
||||
step=meta.get("step") or "",
|
||||
display_step=meta.get("client_step") or "",
|
||||
next_input_mode=meta.get("input_mode"),
|
||||
next_input_type=meta.get("input_options"),
|
||||
chat_end=bool(meta.get("chat_end", False)),
|
||||
)
|
||||
|
||||
def _row_to_message(self, r) -> ChatMessage:
|
||||
"""DB 행(chats) → 응답 ChatMessage."""
|
||||
return self._chat_to_message(r)
|
||||
|
||||
|
||||
# ---- 가격 유틸 ----------------------------------------------------------
|
||||
def _parse_price(text: Optional[str]) -> Optional[int]:
|
||||
if not text:
|
||||
return None
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
||||
if not target_price:
|
||||
return price > 0
|
||||
return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO)
|
||||
192
backend/services/negotiation_service.py
Normal file
@ -0,0 +1,192 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import sessions
|
||||
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||
from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_Reject, Res_SessionList
|
||||
from services.auth_service import AuthService
|
||||
|
||||
|
||||
class NegotiationService:
|
||||
"""협상 도메인 비즈니스 로직.
|
||||
- 인증(계정 활성 + 저장 토큰 대조)은 AuthService.authenticate 로 위임(재사용).
|
||||
- 목록은 로그인 유저의 supplier_id 로만 조회한다.
|
||||
"""
|
||||
|
||||
def __init__(self, auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD)):
|
||||
self.auth = auth
|
||||
self.session_crud = session_crud
|
||||
|
||||
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList:
|
||||
res = Res_SessionList()
|
||||
|
||||
# 1) 인증 (활성 + 저장된 access 토큰 대조)
|
||||
err_type, info = await self.auth.authenticate(user_info, access_token)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
supplier_id = uuid.UUID(info.supplier_id)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 2) 목록 조회 (NEGOTIATION Read 세션, sessions ⨝ items)
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 3) 총개수 (페이지네이션용)
|
||||
err_type, total = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
res.items = [
|
||||
ListItem(
|
||||
session_id=str(r[0]),
|
||||
session_status=r[1],
|
||||
qt_type=r[2],
|
||||
qt_number=r[3],
|
||||
qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "",
|
||||
item_code=r[5] or "",
|
||||
item_name=r[6] or "",
|
||||
model_name=r[7] or "",
|
||||
maker_name=r[8] or "",
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
res.total = total
|
||||
res.page = page
|
||||
res.page_size = page_size
|
||||
return res
|
||||
|
||||
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
|
||||
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
|
||||
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
|
||||
blocked_statuses 에 해당하는 세션 상태면 NEGO_NOT_PARTICIPABLE 로 막는다.
|
||||
"""
|
||||
# 1) 인증 (활성 + 저장된 access 토큰 대조)
|
||||
err_type, info = await self.auth.authenticate(user_info, access_token)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None, None
|
||||
try:
|
||||
session_id = uuid.UUID(session_id_str)
|
||||
except (ValueError, TypeError):
|
||||
return ErrorType.NEGO_NOT_FOUND, None, None
|
||||
|
||||
# 2) 세션 조회
|
||||
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or sess is None:
|
||||
return ErrorType.NEGO_NOT_FOUND, None, None
|
||||
|
||||
# 3) 소유 검증 (세션 공급사 == 접속 유저 공급사)
|
||||
if str(sess.supplier_id) != info.supplier_id:
|
||||
return ErrorType.NEGO_FORBIDDEN, None, None
|
||||
|
||||
# 4) 세션 상태 검증 (호출부가 지정한 불가 상태)
|
||||
if sess.status in blocked_statuses:
|
||||
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None
|
||||
|
||||
# 5) 견적 조회 + 마감 상태
|
||||
err_type, quote = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or quote is None:
|
||||
return ErrorType.NEGO_NOT_FOUND, None, None
|
||||
if quote.status == QuotationStatus.CLOSED.value:
|
||||
return ErrorType.NEGO_QUOTATION_CLOSED, None, None
|
||||
|
||||
# 6) 마감 시간 초과 (견적 end_time < 현재). 협상생성(1)일 때만 session→미참여로 정리.
|
||||
end = quote.end_time
|
||||
if end is not None and end.tzinfo is None:
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
if end is not None and end < datetime.now(timezone.utc):
|
||||
if sess.status == SessionStatus.CREATED.value:
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[sessions.DBType()],
|
||||
[lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.NOT_PARTICIPATED.value)],
|
||||
)
|
||||
return ErrorType.NEGO_DEADLINE_PASSED, None, None
|
||||
|
||||
return ErrorType.SUCCESS, sess, quote
|
||||
|
||||
async def participate(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Participate:
|
||||
res = Res_Participate()
|
||||
|
||||
# 미참여/협상거부 상태는 참여 불가
|
||||
err_type, sess, _ = await self._load_actionable_session(
|
||||
user_info,
|
||||
access_token,
|
||||
session_id_str,
|
||||
(SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 참여 성공 — 협상생성(1)일 때만 상태 전이(협상중/완료는 무변경 진입)
|
||||
if sess.status == SessionStatus.CREATED.value:
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[sessions.DBType()],
|
||||
[
|
||||
lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.IN_PROGRESS.value),
|
||||
lambda s: self.session_crud.update_quotation_status(s, sess.quotation_id, QuotationStatus.IN_PROGRESS.value),
|
||||
],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
res.session_id = str(sess.session_id)
|
||||
return res
|
||||
|
||||
async def reject(self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str) -> Res_Reject:
|
||||
res = Res_Reject()
|
||||
|
||||
# 거부 사유 필수
|
||||
reason = (reject_reason or "").strip()[:255]
|
||||
if not reason:
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
return res
|
||||
|
||||
# 협상완료/미참여/협상거부 상태는 거부 불가 (참여와 공통 검증 재사용)
|
||||
err_type, sess, _ = await self._load_actionable_session(
|
||||
user_info,
|
||||
access_token,
|
||||
session_id_str,
|
||||
(SessionStatus.DONE.value, SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 거부 처리 — 세션을 협상거부로 전이하고 사유 저장
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[sessions.DBType()],
|
||||
[lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
res.session_id = str(sess.session_id)
|
||||
return res
|
||||
@ -1,63 +1,324 @@
|
||||
"""auth 도메인 e2e 테스트.
|
||||
"""인증 e2e 테스트 (supplier_users 기반 유저).
|
||||
|
||||
실행 전제: docker-compose 로 PostgreSQL 이 떠 있어야 한다 (negosium_db 사용).
|
||||
docker compose up -d # 또는 로컬 postgres
|
||||
실행 전제: PostgreSQL 이 떠 있어야 한다 (negosium_db, supplier/partner 스키마 적용).
|
||||
cd backend && python -m pytest
|
||||
|
||||
테스트는 dev negosium_db 를 그대로 쓰므로, 다른 데이터를 건드리지 않도록
|
||||
TRUNCATE 대신 전용 테스트 행(pytest_user)만 시드/정리한다.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
async def test_create_and_login_flow(client):
|
||||
# 1) 계정 생성
|
||||
r = await client.post("/v1/auth/create", json={"id": "user1", "pw": "pw1234", "nickname": "닉네임"})
|
||||
import bcrypt
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
TEST_LOGIN_ID = "pytest_user"
|
||||
TEST_PW = "pytest1234"
|
||||
TEST_USER_NAME = "테스트담당자" # supplier_users.name (유저 개인 이름)
|
||||
TEST_SUPPLIER_NAME = "파이테스트공급사" # partner.suppliers.name (공급사명)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def account_seed(db_engine):
|
||||
"""partner.suppliers(공급사) + supplier.supplier_users(유저) 테스트 행을 시드하고, 끝나면 정리한다."""
|
||||
supplier_id = uuid.uuid4()
|
||||
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
async def _cleanup(conn):
|
||||
await conn.execute(
|
||||
text(
|
||||
"DELETE FROM supplier.supplier_user_tokens WHERE su_id IN "
|
||||
"(SELECT su_id FROM supplier.supplier_users WHERE id = :id)"
|
||||
),
|
||||
{"id": TEST_LOGIN_ID},
|
||||
)
|
||||
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
|
||||
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
|
||||
|
||||
async with db_engine.begin() as conn:
|
||||
await _cleanup(conn) # 이전 실행 잔재 제거
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) "
|
||||
"VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"
|
||||
),
|
||||
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO supplier.supplier_users "
|
||||
"(supplier_id, id, password, name, last_accessed_at, status, role) "
|
||||
"VALUES (:sid, :id, :pw, :uname, now(), 1, 1)"
|
||||
),
|
||||
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash, "uname": TEST_USER_NAME},
|
||||
)
|
||||
|
||||
yield {"supplier_id": supplier_id}
|
||||
|
||||
# 테스트 중 생성된 유저/토큰까지 정리하기 위해 supplier_id 기준으로 지운다.
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"DELETE FROM supplier.supplier_user_tokens WHERE su_id IN "
|
||||
"(SELECT su_id FROM supplier.supplier_users WHERE supplier_id = :sid)"
|
||||
),
|
||||
{"sid": supplier_id},
|
||||
)
|
||||
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE supplier_id = :sid"), {"sid": supplier_id})
|
||||
await conn.execute(text("DELETE FROM partner.suppliers WHERE supplier_id = :sid"), {"sid": supplier_id})
|
||||
|
||||
|
||||
async def _set_account(db_engine, **values):
|
||||
"""테스트용 유저 행의 컬럼을 갱신한다 (status/deleted 등)."""
|
||||
sets = ", ".join(f"{k} = :{k}" for k in values)
|
||||
params = {**values, "id": TEST_LOGIN_ID}
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text(f"UPDATE supplier.supplier_users SET {sets} WHERE id = :id"), params)
|
||||
|
||||
|
||||
async def _login(client):
|
||||
return await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
|
||||
|
||||
|
||||
# ---- 생성 -------------------------------------------------------------------
|
||||
async def test_create_success(client, account_seed):
|
||||
sid = str(account_seed["supplier_id"])
|
||||
r = await client.post(
|
||||
"/v1/auth/create",
|
||||
json={"supplier_id": sid, "id": "pytest_new", "pw": "newpw1234", "name": "새담당자", "role": 2},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is True
|
||||
assert body["uid"] > 0
|
||||
assert body["su_id"]
|
||||
# 생성된 계정으로 즉시 로그인 가능
|
||||
r2 = await client.post("/v1/auth/login", json={"id": "pytest_new", "pw": "newpw1234"})
|
||||
lb = r2.json()
|
||||
assert lb["result"]["success"] is True
|
||||
assert lb["role"] == 2 # 매니저로 생성됨
|
||||
|
||||
# 2) 로그인 -> 토큰 발급
|
||||
r = await client.post("/v1/auth/login", json={"id": "user1", "pw": "pw1234"})
|
||||
|
||||
async def test_create_duplicate(client, account_seed):
|
||||
sid = str(account_seed["supplier_id"])
|
||||
payload = {"supplier_id": sid, "id": "pytest_dup", "pw": "x12345"}
|
||||
r1 = await client.post("/v1/auth/create", json=payload)
|
||||
assert r1.json()["result"]["success"] is True
|
||||
r2 = await client.post("/v1/auth/create", json=payload)
|
||||
assert r2.json()["result"]["code"] == 1201 # ACCOUNT_ALREADY_EXIST
|
||||
|
||||
|
||||
async def test_create_invalid_supplier(client, account_seed):
|
||||
# 존재하지 않는 supplier_id (no-FK 라 앱에서 검증)
|
||||
r = await client.post(
|
||||
"/v1/auth/create",
|
||||
json={"supplier_id": str(uuid.uuid4()), "id": "pytest_orphan", "pw": "x12345"},
|
||||
)
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is False
|
||||
assert body["result"]["code"] == 101 # INVALID_REQUEST_DATA
|
||||
|
||||
|
||||
async def test_create_malformed_supplier_id(client, account_seed):
|
||||
r = await client.post(
|
||||
"/v1/auth/create",
|
||||
json={"supplier_id": "not-a-uuid", "id": "pytest_bad", "pw": "x12345"},
|
||||
)
|
||||
assert r.json()["result"]["code"] == 101 # INVALID_REQUEST_DATA
|
||||
|
||||
|
||||
# ---- 로그인 -----------------------------------------------------------------
|
||||
async def test_login_success(client, account_seed):
|
||||
r = await _login(client)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is True
|
||||
assert body["access_token"]
|
||||
assert body["refresh_token"]
|
||||
assert body["nickname"] == "닉네임"
|
||||
access_token = body["access_token"]
|
||||
|
||||
# 3) 보호된 엔드포인트 호출
|
||||
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access_token}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == "user1"
|
||||
assert body["name"] == TEST_USER_NAME # 유저 개인 이름
|
||||
assert body["supplier_name"] == TEST_SUPPLIER_NAME # 공급사명(partner.suppliers)
|
||||
assert body["role"] == 1
|
||||
assert body["su_id"]
|
||||
assert body["supplier_id"] == str(account_seed["supplier_id"])
|
||||
|
||||
|
||||
async def test_login_with_wrong_password(client):
|
||||
await client.post("/v1/auth/create", json={"id": "user2", "pw": "correct", "nickname": "n"})
|
||||
|
||||
r = await client.post("/v1/auth/login", json={"id": "user2", "pw": "wrong"})
|
||||
async def test_login_wrong_password(client, account_seed):
|
||||
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": "wrong"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is False
|
||||
# 자격증명 오류는 ACCOUNT_INVALID_INFO(1200)
|
||||
assert body["result"]["code"] == 1200
|
||||
assert body.get("access_token", "") == "" # 실패 시 토큰은 빈 문자열
|
||||
assert body["result"]["code"] == 1200 # ACCOUNT_INVALID_INFO
|
||||
assert body.get("access_token", "") == ""
|
||||
|
||||
|
||||
async def test_login_nonexistent_account(client):
|
||||
r = await client.post("/v1/auth/login", json={"id": "ghost", "pw": "whatever"})
|
||||
assert r.json()["result"]["success"] is False
|
||||
|
||||
|
||||
async def test_duplicate_account_create(client):
|
||||
r1 = await client.post("/v1/auth/create", json={"id": "dup", "pw": "pw1234", "nickname": "n"})
|
||||
assert r1.json()["result"]["success"] is True
|
||||
|
||||
r2 = await client.post("/v1/auth/create", json={"id": "dup", "pw": "pw5678", "nickname": "n2"})
|
||||
body = r2.json()
|
||||
async def test_login_nonexistent(client):
|
||||
r = await client.post("/v1/auth/login", json={"id": "ghost_user", "pw": "whatever"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is False
|
||||
# ACCOUNT_ALREADY_EXIST(1201)
|
||||
assert body["result"]["code"] == 1201
|
||||
assert body["result"]["code"] == 1200
|
||||
|
||||
|
||||
async def test_me_without_token_is_rejected(client):
|
||||
async def _stored_tokens(db_engine, su_id):
|
||||
"""su_id 의 저장된 토큰을 {type: jwt} dict 로 반환."""
|
||||
import json
|
||||
|
||||
async with db_engine.begin() as conn:
|
||||
rows = (
|
||||
await conn.execute(
|
||||
text("SELECT type, token FROM supplier.supplier_user_tokens WHERE su_id = :sid AND deleted = false"),
|
||||
{"sid": uuid.UUID(su_id)},
|
||||
)
|
||||
).fetchall()
|
||||
out = {}
|
||||
for t, tok in rows:
|
||||
out[t] = (json.loads(tok) if isinstance(tok, str) else tok)["jwt"]
|
||||
return out
|
||||
|
||||
|
||||
async def test_login_stores_access_and_refresh(client, account_seed, db_engine):
|
||||
# 로그인 시 access(type=1) + refresh(type=2) 2행이 저장되고, 응답 토큰과 일치한다.
|
||||
body = (await _login(client)).json()
|
||||
assert body["result"]["success"] is True
|
||||
stored = await _stored_tokens(db_engine, body["su_id"])
|
||||
assert set(stored.keys()) == {1, 2} # ACCESS, REFRESH
|
||||
assert stored[1] == body["access_token"]
|
||||
assert stored[2] == body["refresh_token"]
|
||||
|
||||
|
||||
async def test_relogin_replaces_tokens_single_session(client, account_seed, db_engine):
|
||||
# 단일 세션: 재로그인해도 토큰 행이 누적되지 않고 항상 정확히 2행(access/refresh)만 유지된다.
|
||||
await _login(client)
|
||||
second = (await _login(client)).json()
|
||||
su_id = second["su_id"]
|
||||
async with db_engine.begin() as conn:
|
||||
count = (
|
||||
await conn.execute(
|
||||
text("SELECT count(*) FROM supplier.supplier_user_tokens WHERE su_id = :sid AND deleted = false"),
|
||||
{"sid": uuid.UUID(su_id)},
|
||||
)
|
||||
).scalar()
|
||||
assert count == 2 # 누적되지 않음 (2번 로그인해도 2행)
|
||||
stored = await _stored_tokens(db_engine, su_id)
|
||||
assert stored[2] == second["refresh_token"] # 최신 로그인 토큰으로 교체됨
|
||||
|
||||
|
||||
async def test_login_inactive(client, account_seed, db_engine):
|
||||
await _set_account(db_engine, status=2) # 비활성
|
||||
r = await _login(client)
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is False
|
||||
assert body["result"]["code"] == 1202 # ACCOUNT_BLOCKED_USER
|
||||
|
||||
|
||||
# ---- /me (보호된 엔드포인트) -------------------------------------------------
|
||||
async def test_me_active(client, account_seed):
|
||||
access = (await _login(client)).json()["access_token"]
|
||||
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is True
|
||||
assert body["id"] == TEST_LOGIN_ID
|
||||
assert body["supplier_name"] == TEST_SUPPLIER_NAME
|
||||
|
||||
|
||||
async def test_me_inactive_after_token(client, account_seed, db_engine):
|
||||
# 토큰 발급 후 계정이 비활성(status=2)되면 만료 전이라도 차단된다 (200 + result code).
|
||||
access = (await _login(client)).json()["access_token"]
|
||||
await _set_account(db_engine, status=2)
|
||||
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"]["code"] == 1202 # ACCOUNT_BLOCKED_USER
|
||||
|
||||
|
||||
async def test_me_deleted_after_token(client, account_seed, db_engine):
|
||||
# 소프트 삭제(deleted=TRUE)된 계정도 차단된다.
|
||||
access = (await _login(client)).json()["access_token"]
|
||||
await _set_account(db_engine, deleted=True)
|
||||
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"]["code"] == 1200 # ACCOUNT_INVALID_INFO (조회 안 됨)
|
||||
|
||||
|
||||
async def test_me_without_token(client):
|
||||
r = await client.get("/v1/auth/me")
|
||||
assert r.status_code in (401, 403) # HTTPBearer 가 자격증명 없음을 거부
|
||||
|
||||
|
||||
async def test_me_invalid_token(client):
|
||||
r = await client.get("/v1/auth/me", headers={"Authorization": "Bearer garbage.token.value"})
|
||||
assert r.status_code == 433 # HTTP_INVALID_CLIENT_ACCESS (validator 가 raise)
|
||||
|
||||
|
||||
# ---- refresh ----------------------------------------------------------------
|
||||
async def test_refresh_success(client, account_seed):
|
||||
refresh = (await _login(client)).json()["refresh_token"]
|
||||
r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {refresh}"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is True
|
||||
assert body["access_token"]
|
||||
|
||||
|
||||
async def test_refresh_inactive_after_token(client, account_seed, db_engine):
|
||||
# 삭제/비활성 계정에는 토큰을 재발급하지 않는다.
|
||||
refresh = (await _login(client)).json()["refresh_token"]
|
||||
await _set_account(db_engine, status=2)
|
||||
r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {refresh}"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is False
|
||||
assert body["result"]["code"] == 1202
|
||||
assert body.get("access_token", "") == ""
|
||||
|
||||
|
||||
# ---- 로그아웃 / stateful 토큰 검증 -------------------------------------------
|
||||
async def test_logout_revokes_tokens(client, account_seed, db_engine):
|
||||
body = (await _login(client)).json()
|
||||
su_id, access, refresh = body["su_id"], body["access_token"], body["refresh_token"]
|
||||
|
||||
# 로그아웃 성공
|
||||
r = await client.post("/v1/auth/logout", headers={"Authorization": f"Bearer {access}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"]["success"] is True
|
||||
|
||||
# 저장 토큰이 모두 삭제됨
|
||||
async with db_engine.begin() as conn:
|
||||
count = (
|
||||
await conn.execute(
|
||||
text("SELECT count(*) FROM supplier.supplier_user_tokens WHERE su_id = :sid AND deleted = false"),
|
||||
{"sid": uuid.UUID(su_id)},
|
||||
)
|
||||
).scalar()
|
||||
assert count == 0
|
||||
|
||||
# 로그아웃 후 같은 access 로 /me → TOKEN_REVOKED(1203)
|
||||
r2 = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["result"]["code"] == 1203
|
||||
|
||||
# 로그아웃 후 같은 refresh 로 재발급 → TOKEN_REVOKED(1203)
|
||||
r3 = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {refresh}"})
|
||||
assert r3.json()["result"]["code"] == 1203
|
||||
|
||||
|
||||
async def test_logout_without_token(client):
|
||||
r = await client.post("/v1/auth/logout")
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
async def test_relogin_invalidates_previous_access(client, account_seed):
|
||||
# 단일 세션: 재로그인하면 이전 세션의 access 가 무효화된다(저장 토큰이 교체됨).
|
||||
import asyncio
|
||||
|
||||
first = (await _login(client)).json()
|
||||
await asyncio.sleep(1.1) # exp(초 단위)가 달라져 토큰이 실제로 바뀌도록
|
||||
second = (await _login(client)).json()
|
||||
assert first["access_token"] != second["access_token"]
|
||||
|
||||
# 이전 access → 무효(TOKEN_REVOKED)
|
||||
r_old = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {first['access_token']}"})
|
||||
assert r_old.json()["result"]["code"] == 1203
|
||||
# 새 access → 정상
|
||||
r_new = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"})
|
||||
assert r_new.json()["result"]["success"] is True
|
||||
|
||||
235
backend/tests/test_chat.py
Normal file
@ -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 도 전이됨
|
||||
211
backend/tests/test_negotiation.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""협상 도메인 e2e 테스트 (세션 목록 + 참여).
|
||||
|
||||
dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
|
||||
목록의 qt_end_time 은 quotation.end_time 기준이라 세션마다 견적을 함께 시드한다.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import bcrypt
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
TEST_LOGIN_ID = "pytest_nego_user"
|
||||
TEST_PW = "pytest1234"
|
||||
TEST_SUPPLIER_NAME = "파이테스트협상공급사"
|
||||
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def nego_seed(db_engine):
|
||||
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
|
||||
supplier_id = uuid.uuid4()
|
||||
other_supplier_id = uuid.uuid4()
|
||||
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
# (code, session.status, qt_type, 마감까지 시간(h), quotation.status, 소속 공급사)
|
||||
specs = [
|
||||
("A", 1, 2, 2, 1, supplier_id), # 협상생성 / 재견적 / +2h / 견적생성
|
||||
("B", 2, 1, 1, 2, supplier_id), # 협상중 / 재협상 / +1h / 견적진행중
|
||||
("C", 3, 2, 3, 2, supplier_id), # 협상완료 / 재견적 / +3h / 견적진행중
|
||||
("X", 1, 1, 1, 1, other_supplier_id), # 타 공급사 → 목록/참여에서 제외/차단
|
||||
]
|
||||
sids, qids = {}, {}
|
||||
|
||||
async def _cleanup(conn):
|
||||
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
||||
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
||||
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
||||
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
|
||||
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
|
||||
|
||||
async with db_engine.begin() as conn:
|
||||
await _cleanup(conn)
|
||||
await conn.execute(
|
||||
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
|
||||
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
|
||||
"VALUES (:sid, :id, :pw, '협상담당자', now(), 1, 1)"
|
||||
),
|
||||
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
||||
)
|
||||
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
|
||||
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
sids[code], qids[code] = session_id, qt_id
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
||||
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
|
||||
),
|
||||
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
|
||||
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
|
||||
),
|
||||
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO negotiation.sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
|
||||
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
|
||||
),
|
||||
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
||||
)
|
||||
|
||||
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
|
||||
|
||||
async with db_engine.begin() as conn:
|
||||
await _cleanup(conn)
|
||||
|
||||
|
||||
async def _login_token(client):
|
||||
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
async def _list(client, token, **params):
|
||||
return await client.get("/v1/negotiation/sessions", headers={"Authorization": f"Bearer {token}"}, params=params)
|
||||
|
||||
|
||||
async def _participate(client, token, session_id):
|
||||
return await client.post(f"/v1/negotiation/sessions/{session_id}/participate", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
|
||||
async def _session_status(db_engine, session_id):
|
||||
async with db_engine.begin() as conn:
|
||||
return (await conn.execute(text("SELECT status FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
|
||||
|
||||
|
||||
async def _quotation_status(db_engine, qt_id):
|
||||
async with db_engine.begin() as conn:
|
||||
return (await conn.execute(text("SELECT status FROM quotation.quotations WHERE qt_id = :qid"), {"qid": qt_id})).scalar()
|
||||
|
||||
|
||||
# ---- 목록 -------------------------------------------------------------------
|
||||
async def test_list_returns_only_own_supplier_sessions(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
body = (await _list(client, token)).json()
|
||||
assert body["result"]["success"] is True
|
||||
assert body["total"] == 3 # 본인 공급사 3건만 (타 공급사 X 제외)
|
||||
one = next(i for i in body["items"] if i["item_code"] == f"{MARK}B")
|
||||
assert one["session_status"] == 2 and one["qt_type"] == 1
|
||||
assert one["model_name"] == "MODEL-B" and one["maker_name"] == "테스트제조사"
|
||||
assert one["session_id"] and one["qt_end_time"]
|
||||
|
||||
|
||||
async def test_list_filter_status(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
body = (await _list(client, token, status=2)).json()
|
||||
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
|
||||
|
||||
|
||||
async def test_list_filter_qt_type(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
body = (await _list(client, token, qt_type=2)).json()
|
||||
assert {i["item_code"] for i in body["items"]} == {f"{MARK}A", f"{MARK}C"}
|
||||
|
||||
|
||||
async def test_list_order_by_quotation_end_time(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
asc = (await _list(client, token, order="asc")).json()["items"]
|
||||
desc = (await _list(client, token, order="desc")).json()["items"]
|
||||
assert asc[0]["item_code"] == f"{MARK}B" # +1h 가 가장 임박
|
||||
assert desc[0]["item_code"] == f"{MARK}C" # +3h 가 가장 멈
|
||||
|
||||
|
||||
async def test_list_pagination(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
body = (await _list(client, token, page=1, page_size=2)).json()
|
||||
assert body["total"] == 3 and len(body["items"]) == 2
|
||||
|
||||
|
||||
async def test_list_requires_auth(client):
|
||||
assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403)
|
||||
|
||||
|
||||
# ---- 참여 -------------------------------------------------------------------
|
||||
async def test_participate_success(client, nego_seed, db_engine):
|
||||
token = await _login_token(client)
|
||||
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"] # 협상생성
|
||||
r = await _participate(client, token, sid)
|
||||
assert r.json()["result"]["success"] is True
|
||||
assert r.json()["session_id"] == str(sid)
|
||||
assert await _session_status(db_engine, sid) == 2 # 협상중
|
||||
assert await _quotation_status(db_engine, qid) == 2 # 견적진행중
|
||||
|
||||
|
||||
async def test_participate_forbidden_other_supplier(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _participate(client, token, nego_seed["sids"]["X"]) # 타 공급사 세션
|
||||
assert r.json()["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
||||
|
||||
|
||||
async def test_participate_not_participable(client, nego_seed, db_engine):
|
||||
token = await _login_token(client)
|
||||
sid = nego_seed["sids"]["A"]
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("UPDATE negotiation.sessions SET status = 4 WHERE session_id = :sid"), {"sid": sid}) # 미참여
|
||||
r = await _participate(client, token, sid)
|
||||
assert r.json()["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
||||
|
||||
|
||||
async def test_participate_quotation_closed(client, nego_seed, db_engine):
|
||||
token = await _login_token(client)
|
||||
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"]
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("UPDATE quotation.quotations SET status = 3 WHERE qt_id = :qid"), {"qid": qid}) # 견적마감
|
||||
r = await _participate(client, token, sid)
|
||||
assert r.json()["result"]["code"] == 1302 # NEGO_QUOTATION_CLOSED
|
||||
|
||||
|
||||
async def test_participate_deadline_passed_sets_not_participated(client, nego_seed, db_engine):
|
||||
token = await _login_token(client)
|
||||
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"] # 협상생성
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
|
||||
r = await _participate(client, token, sid)
|
||||
assert r.json()["result"]["code"] == 1303 # NEGO_DEADLINE_PASSED
|
||||
assert await _session_status(db_engine, sid) == 4 # 협상생성이었으므로 미참여로 정리됨
|
||||
assert await _quotation_status(db_engine, qid) == 1 # 견적은 변경 안 됨
|
||||
|
||||
|
||||
async def test_participate_in_progress_no_state_change(client, nego_seed, db_engine):
|
||||
token = await _login_token(client)
|
||||
sid, qid = nego_seed["sids"]["B"], nego_seed["qids"]["B"] # 이미 협상중
|
||||
r = await _participate(client, token, sid)
|
||||
assert r.json()["result"]["success"] is True
|
||||
assert r.json()["session_id"] == str(sid)
|
||||
assert await _session_status(db_engine, sid) == 2 # 무변경 (협상중 유지)
|
||||
assert await _quotation_status(db_engine, qid) == 2 # 무변경
|
||||
|
||||
|
||||
async def test_participate_session_not_found(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _participate(client, token, str(uuid.uuid4()))
|
||||
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND
|
||||
@ -4,9 +4,10 @@
|
||||
# 컨테이너에서 호스트의 DB 에 접속할 때는 host.docker.internal 을 쓴다.
|
||||
#
|
||||
# docker compose up -d
|
||||
# negosium 서버: http://localhost:9300/docs
|
||||
# negodata 서버: http://localhost:9400/docs
|
||||
# agent 서버: http://localhost:9500/docs
|
||||
# negosium 서버: http://localhost:9300/docs
|
||||
# negosium 프론트: http://localhost:3300
|
||||
# negodata 서버: http://localhost:9400/docs
|
||||
# agent 서버: http://localhost:9500/docs
|
||||
#
|
||||
# DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다.
|
||||
# psql -h <host> -p <port> -U <user> -f postgres-init/01-schema.sql (단일 negosium_db + 도메인별 schema)
|
||||
@ -62,3 +63,11 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
# negosium 공급사 프론트 (프로덕션 빌드 정적 서빙, :3300). 브라우저가 backend(:9300)를 직접 호출.
|
||||
negosium-front:
|
||||
build: ./frontend
|
||||
container_name: negosium-front
|
||||
ports:
|
||||
- "3300:3300"
|
||||
restart: unless-stopped
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import ListPage from '@/pages/ListPage'
|
||||
import ChatPage from '@/pages/ChatPage'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/', element: <LoginPage /> },
|
||||
{ path: '/list', element: <ListPage /> },
|
||||
{ path: '/chat', element: <ChatPage /> },
|
||||
])
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
export default App
|
||||
@ -1,13 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
export interface LoginParams {
|
||||
id: string
|
||||
password: string
|
||||
}
|
||||
|
||||
// 임시 stub (검증만 통과하면 성공). TODO: 로그인 API 연동
|
||||
export function useLoginMutation() {
|
||||
return useMutation<void, Error, LoginParams>({
|
||||
mutationFn: async () => {},
|
||||
})
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { useChatInit } from '@/features/chat/hooks/useChatInit'
|
||||
import { ChatSection } from '@/features/chat/components/ChatSection'
|
||||
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
|
||||
|
||||
// 콘텐츠 영역: 채팅 + 우측 메뉴. mock 데이터를 스토어에 적재한다.
|
||||
export function ChatContainer() {
|
||||
useChatInit()
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 w-full">
|
||||
<ChatSection />
|
||||
<MenuSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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: '가격협상',
|
||||
},
|
||||
]
|
||||
@ -1,24 +0,0 @@
|
||||
import { cn, interactive } from '@/lib'
|
||||
|
||||
const PILL =
|
||||
'flex items-center justify-center w-full max-w-[130px] h-[50px] rounded-full py-4 px-6 ' +
|
||||
'text-lg font-semibold whitespace-nowrap ' +
|
||||
interactive
|
||||
|
||||
export function ActionSection() {
|
||||
return (
|
||||
<div className="flex w-full py-[35px] items-center justify-end gap-3">
|
||||
{/* TODO: 협상 참여 동작 연동 */}
|
||||
<button type="button" className={cn(PILL, 'bg-primary text-primary-foreground')}>
|
||||
협상 참여
|
||||
</button>
|
||||
{/* TODO: 거부 동작 연동 */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(PILL, 'bg-background text-primary border border-primary')}
|
||||
>
|
||||
거부
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import { useList } from '@/features/list/hooks/useList'
|
||||
import { ActionSection } from '@/features/list/components/ActionSection'
|
||||
import { TableSection } from '@/features/list/components/TableSection'
|
||||
import { Pagination } from '@/features/list/components/Pagination'
|
||||
|
||||
export function ContentContainer() {
|
||||
const { items, isLoading, totalPages, currentPage, setCurrentPage, selectedId, handleItemClick } =
|
||||
useList()
|
||||
|
||||
return (
|
||||
// 좌우 거터(80px) 일괄 적용
|
||||
<div className="flex flex-1 flex-col min-h-0 px-[80px]">
|
||||
<ActionSection />
|
||||
<TableSection
|
||||
items={items}
|
||||
isLoading={isLoading}
|
||||
selectedId={selectedId ?? undefined}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
<Pagination
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useListStore } from '@/features/list/stores/useListStore'
|
||||
import { MOCK_ITEMS } from '@/features/list/mocks/mockItems'
|
||||
import type { ListItem } from '@/features/list/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// 목데이터 클라이언트 필터 (추후 API 조회로 교체)
|
||||
export function useList() {
|
||||
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } =
|
||||
useListStore()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const result = MOCK_ITEMS.filter(
|
||||
(item) =>
|
||||
(!selectedType || item.qt_type === selectedType) &&
|
||||
(!selectedStatus || item.session_status === selectedStatus),
|
||||
)
|
||||
|
||||
if (selectedDeadline) {
|
||||
const dir = selectedDeadline === '남은 시간 적은 순' ? 1 : -1
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
(new Date(a.qt_end_time).getTime() - new Date(b.qt_end_time).getTime()) * dir,
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}, [selectedType, selectedStatus, selectedDeadline])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||
|
||||
const items = useMemo(
|
||||
() => filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
|
||||
[filtered, currentPage],
|
||||
)
|
||||
|
||||
const selectedItem = useMemo(
|
||||
() => items.find((item) => item.session_id === selectedId) ?? null,
|
||||
[items, selectedId],
|
||||
)
|
||||
|
||||
const handleItemClick = (item: ListItem) =>
|
||||
setSelectedId((prev) => (prev === item.session_id ? null : item.session_id))
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading: false,
|
||||
totalPages,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
selectedId,
|
||||
selectedItem,
|
||||
handleItemClick,
|
||||
}
|
||||
}
|
||||
@ -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())}`
|
||||
}
|
||||
@ -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: '로지텍',
|
||||
},
|
||||
]
|
||||
4
frontend/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
0
front/.gitignore → frontend/.gitignore
vendored
19
frontend/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 의존성 (레이어 캐시)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# 프로덕션 빌드. API URL 은 빌드 타임에 번들로 인라인되므로 여기서 주입한다(로컬 도커: backend=localhost:9300).
|
||||
# npm run build 는 --mode prod → .env.prod 를 읽으므로, 최우선 파일 .env.prod.local 로 덮어쓴다.
|
||||
ARG VITE_API_BASE_URL=http://localhost:9300
|
||||
RUN echo "VITE_API_BASE_URL=$VITE_API_BASE_URL" > .env.prod.local && npm run build
|
||||
|
||||
EXPOSE 3300
|
||||
|
||||
# 빌드 산출물(dist)을 정적 서빙. SPA 라우팅(history fallback)은 vite preview 가 처리.
|
||||
CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3300"]
|
||||
11
front/package-lock.json → frontend/package-lock.json
generated
@ -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",
|
||||
@ -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": {
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
37
frontend/src/App.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
import { setUnauthorizedHandler } from '@/apis'
|
||||
import { RequireAuth } from '@/features/auth'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import ListPage from '@/pages/ListPage'
|
||||
import ChatPage from '@/pages/ChatPage'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/', element: <LoginPage /> },
|
||||
{
|
||||
path: '/list',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<ListPage />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<ChatPage />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
])
|
||||
|
||||
// 토큰 만료/폐기로 인증이 끊기면 로그인 페이지로 이동시킨다.
|
||||
setUnauthorizedHandler(() => {
|
||||
void router.navigate('/')
|
||||
})
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
export default App
|
||||
37
frontend/src/apis/auth/auth.api.ts
Normal file
@ -0,0 +1,37 @@
|
||||
// 인증 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
|
||||
// refresh_token 재발급은 http.ts 인터셉터가 자동 처리하므로 여기서 노출하지 않는다.
|
||||
import { http } from '@/apis/http'
|
||||
import type {
|
||||
CreateAccountRequest,
|
||||
CreateAccountResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
MeResponse,
|
||||
} from './auth.type'
|
||||
|
||||
export const authApi = {
|
||||
/** POST /v1/auth/login — ID/PW 로 로그인, access/refresh 토큰 발급 */
|
||||
login: async (body: LoginRequest): Promise<LoginResponse> => {
|
||||
const res = await http.post<LoginResponse>('/v1/auth/login', body)
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** POST /v1/auth/create — 신규 공급사 유저 계정 생성 */
|
||||
createAccount: async (body: CreateAccountRequest): Promise<CreateAccountResponse> => {
|
||||
const res = await http.post<CreateAccountResponse>('/v1/auth/create', body)
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** GET /v1/auth/me — 현재 로그인 유저 정보 (access token 필요) */
|
||||
me: async (): Promise<MeResponse> => {
|
||||
const res = await http.get<MeResponse>('/v1/auth/me')
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** POST /v1/auth/logout — 서버측 토큰 폐기(단일 세션) */
|
||||
logout: async (): Promise<LogoutResponse> => {
|
||||
const res = await http.post<LogoutResponse>('/v1/auth/logout')
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
5
frontend/src/apis/auth/auth.keys.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// 인증 도메인의 TanStack Query 키 팩토리.
|
||||
export const authKeys = {
|
||||
all: ['auth'] as const,
|
||||
me: () => [...authKeys.all, 'me'] as const,
|
||||
}
|
||||
55
frontend/src/apis/auth/auth.mutations.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// 인증 도메인의 변경(useMutation) 훅.
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { tokenStorage } from '@/apis/tokenStorage'
|
||||
import { authApi } from './auth.api'
|
||||
import { authKeys } from './auth.keys'
|
||||
import type { CreateAccountRequest, LoginResponse } from './auth.type'
|
||||
|
||||
/** 로그인 폼이 다루는 파라미터 (UI 친화적인 camelCase) */
|
||||
export interface LoginParams {
|
||||
id: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인: 성공 시 토큰을 저장하고 me 캐시를 무효화한다.
|
||||
*/
|
||||
export function useLoginMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<LoginResponse, Error, LoginParams>({
|
||||
mutationFn: async ({ id, password }) => {
|
||||
const data = await authApi.login({ id, pw: password })
|
||||
tokenStorage.setTokens(data.access_token, data.refresh_token)
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.me() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃: 서버 토큰 폐기를 시도하고(실패해도) 로컬 토큰/캐시를 비운다.
|
||||
*/
|
||||
export function useLogoutMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} finally {
|
||||
tokenStorage.clear()
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.clear()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 공급사 유저 계정 생성 */
|
||||
export function useCreateAccountMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateAccountRequest) => authApi.createAccount(body),
|
||||
})
|
||||
}
|
||||
20
frontend/src/apis/auth/auth.queries.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// 인증 도메인의 조회(useQuery) 훅.
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { tokenStorage } from '@/apis/tokenStorage'
|
||||
import { authApi } from './auth.api'
|
||||
import { authKeys } from './auth.keys'
|
||||
import { toAuthUser } from './auth.type'
|
||||
|
||||
/**
|
||||
* 현재 로그인 유저 정보 조회.
|
||||
* 토큰이 있을 때만 활성화되며, AuthUser(카멜케이스)로 가공해 반환한다.
|
||||
*/
|
||||
export function useMeQuery() {
|
||||
return useQuery({
|
||||
queryKey: authKeys.me(),
|
||||
queryFn: authApi.me,
|
||||
enabled: tokenStorage.hasToken(),
|
||||
staleTime: 5 * 60 * 1000, // 5분
|
||||
select: toAuthUser,
|
||||
})
|
||||
}
|
||||
91
frontend/src/apis/auth/auth.type.ts
Normal file
@ -0,0 +1,91 @@
|
||||
// 인증 API 의 요청/응답 타입.
|
||||
// 와이어 포맷은 백엔드(snake_case)를 그대로 미러링한다.
|
||||
import type { ApiResult } from '@/apis/types'
|
||||
|
||||
/** 유저 권한 (supplier_users.role) */
|
||||
export const UserRole = {
|
||||
USER: 1,
|
||||
MANAGER: 2,
|
||||
} as const
|
||||
export type UserRole = (typeof UserRole)[keyof typeof UserRole]
|
||||
|
||||
export const USER_ROLE_LABEL: Record<UserRole, string> = {
|
||||
[UserRole.USER]: '일반',
|
||||
[UserRole.MANAGER]: '매니저',
|
||||
}
|
||||
|
||||
// --- 로그인 ---------------------------------------------------------------
|
||||
export interface LoginRequest {
|
||||
id: string
|
||||
pw: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
result: ApiResult
|
||||
su_id: string
|
||||
name: string
|
||||
supplier_id: string
|
||||
supplier_name: string
|
||||
role: number
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
// --- 계정 생성 ------------------------------------------------------------
|
||||
export interface CreateAccountRequest {
|
||||
supplier_id: string
|
||||
id: string
|
||||
pw: string
|
||||
name?: string
|
||||
email?: string
|
||||
contact_number?: string
|
||||
role?: number
|
||||
}
|
||||
|
||||
export interface CreateAccountResponse {
|
||||
result: ApiResult
|
||||
su_id: string
|
||||
}
|
||||
|
||||
// --- 토큰 재발급 ----------------------------------------------------------
|
||||
export interface RefreshTokenResponse {
|
||||
result: ApiResult
|
||||
access_token: string
|
||||
}
|
||||
|
||||
// --- 내 정보 (GET /v1/auth/me) -------------------------------------------
|
||||
export interface MeResponse {
|
||||
result: ApiResult
|
||||
su_id: string
|
||||
id: string
|
||||
name: string
|
||||
supplier_id: string
|
||||
supplier_name: string
|
||||
role: number
|
||||
}
|
||||
|
||||
// --- 로그아웃 -------------------------------------------------------------
|
||||
export interface LogoutResponse {
|
||||
result: ApiResult
|
||||
}
|
||||
|
||||
/** 앱에서 다루기 편한 현재 유저 형태 (MeResponse 에서 파생) */
|
||||
export interface AuthUser {
|
||||
suId: string
|
||||
loginId: string
|
||||
name: string
|
||||
supplierId: string
|
||||
supplierName: string
|
||||
role: number
|
||||
}
|
||||
|
||||
export function toAuthUser(res: MeResponse): AuthUser {
|
||||
return {
|
||||
suId: res.su_id,
|
||||
loginId: res.id,
|
||||
name: res.name,
|
||||
supplierId: res.supplier_id,
|
||||
supplierName: res.supplier_name,
|
||||
role: res.role,
|
||||
}
|
||||
}
|
||||
11
frontend/src/apis/auth/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// 인증 API 모듈 공개 표면.
|
||||
export { authApi } from './auth.api'
|
||||
export { authKeys } from './auth.keys'
|
||||
export { useMeQuery } from './auth.queries'
|
||||
export {
|
||||
useLoginMutation,
|
||||
useLogoutMutation,
|
||||
useCreateAccountMutation,
|
||||
type LoginParams,
|
||||
} from './auth.mutations'
|
||||
export * from './auth.type'
|
||||
33
frontend/src/apis/chat/chat.api.ts
Normal file
@ -0,0 +1,33 @@
|
||||
// 채팅 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
|
||||
import { http } from '@/apis/http'
|
||||
import type {
|
||||
ChatInitResponse,
|
||||
ChatMessagesResponse,
|
||||
ChatSendRequest,
|
||||
ChatSendResponse,
|
||||
} from './chat.type'
|
||||
|
||||
export const chatApi = {
|
||||
/** GET .../chat/init — 상품·견적 메타 + 세션 상태 + 마감 시각 */
|
||||
getInit: async (sessionId: string): Promise<ChatInitResponse> => {
|
||||
const res = await http.get<ChatInitResponse>(`/v1/negotiation/sessions/${sessionId}/chat/init`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** GET .../chat/messages — 대화 히스토리(seq 오름차순). 비어 있으면 오프닝 포함 */
|
||||
getMessages: async (sessionId: string): Promise<ChatMessagesResponse> => {
|
||||
const res = await http.get<ChatMessagesResponse>(
|
||||
`/v1/negotiation/sessions/${sessionId}/chat/messages`,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** POST .../chat/send — 한 턴 전송, 새 봇 메시지 1건 반환(append-only) */
|
||||
send: async (sessionId: string, body: ChatSendRequest): Promise<ChatSendResponse> => {
|
||||
const res = await http.post<ChatSendResponse>(
|
||||
`/v1/negotiation/sessions/${sessionId}/chat/send`,
|
||||
body,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
6
frontend/src/apis/chat/chat.keys.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// 채팅 도메인의 TanStack Query 키 팩토리.
|
||||
export const chatKeys = {
|
||||
all: ['chat'] as const,
|
||||
init: (sessionId: string) => [...chatKeys.all, 'init', sessionId] as const,
|
||||
messages: (sessionId: string) => [...chatKeys.all, 'messages', sessionId] as const,
|
||||
}
|
||||
14
frontend/src/apis/chat/chat.mutations.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// 채팅 도메인의 변경(useMutation) 훅.
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { chatApi } from './chat.api'
|
||||
import type { ChatSendRequest } from './chat.type'
|
||||
|
||||
/**
|
||||
* 협상 한 턴 전송. append-only 라 캐시 무효화/refetch 를 하지 않는다.
|
||||
* 응답의 새 봇 메시지는 호출부(컨트롤러)가 스토어에 직접 append 한다.
|
||||
*/
|
||||
export function useChatSendMutation(sessionId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (body: ChatSendRequest) => chatApi.send(sessionId, body),
|
||||
})
|
||||
}
|
||||
29
frontend/src/apis/chat/chat.queries.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// 채팅 도메인의 조회(useQuery) 훅.
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { chatApi } from './chat.api'
|
||||
import { chatKeys } from './chat.keys'
|
||||
import { mapInit, mapMessage } from './chat.type'
|
||||
|
||||
/** 채팅 진입 메타(상품·견적). 마감 시각은 거의 불변이라 오래 캐싱한다. */
|
||||
export function useChatInitQuery(sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: chatKeys.init(sessionId),
|
||||
queryFn: () => chatApi.getInit(sessionId).then(mapInit),
|
||||
enabled: !!sessionId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 대화 히스토리. 진입 시 1회만 받고 이후엔 send 응답을 로컬에 append 한다(append-only).
|
||||
* 따라서 백그라운드 refetch 가 로컬 상태를 덮지 않도록 staleTime 을 무한대로 둔다.
|
||||
*/
|
||||
export function useChatMessagesQuery(sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: chatKeys.messages(sessionId),
|
||||
queryFn: () => chatApi.getMessages(sessionId).then((r) => r.items.map(mapMessage)),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
}
|
||||
103
frontend/src/apis/chat/chat.type.ts
Normal file
@ -0,0 +1,103 @@
|
||||
// 채팅 API 의 와이어 타입(백엔드 snake_case 미러) + feature 타입 매퍼.
|
||||
// sender 는 정수 코드(1=BOT, 2=USER)로 내려오고 프론트에서 'bot'|'user' 로 매핑한다.
|
||||
import type { ApiResult } from '@/apis/types'
|
||||
import type {
|
||||
ChatInitData,
|
||||
ChatMessage,
|
||||
NextInputMode,
|
||||
UserInputType,
|
||||
} from '@/features/chat/types'
|
||||
|
||||
// RemoveNoneResponse 로 null 필드는 생략될 수 있어 대부분 optional.
|
||||
export interface ChatMessageWire {
|
||||
chat_id: string
|
||||
session_id: string
|
||||
seq: number
|
||||
sender: number
|
||||
script?: string
|
||||
user_input_type?: string | null
|
||||
step?: string
|
||||
display_step?: string
|
||||
next_input_mode?: string | null
|
||||
next_input_type?: string[] | null
|
||||
chat_end?: boolean
|
||||
indicator_value?: number | null
|
||||
bot_chat_type?: string | null
|
||||
}
|
||||
|
||||
export interface ChatInitResponse {
|
||||
result: ApiResult
|
||||
session_id: string
|
||||
session_status: number
|
||||
quotation_id: string
|
||||
quotation_end_time: string
|
||||
quotation_memo?: string
|
||||
item_id: string
|
||||
item_name: string
|
||||
item_code?: string
|
||||
item_image?: string
|
||||
item_price: number
|
||||
item_model_name?: string
|
||||
item_maker_name?: string
|
||||
item_spec?: string
|
||||
item_lead_time?: string
|
||||
item_min_order_quantity?: string
|
||||
item_vat_yn?: boolean
|
||||
item_delivery_fee_yn?: boolean
|
||||
}
|
||||
|
||||
export interface ChatMessagesResponse {
|
||||
result: ApiResult
|
||||
items: ChatMessageWire[]
|
||||
}
|
||||
|
||||
export interface ChatSendRequest {
|
||||
user_input: string
|
||||
user_input_type?: string | null
|
||||
}
|
||||
|
||||
export interface ChatSendResponse {
|
||||
result: ApiResult
|
||||
message?: ChatMessageWire
|
||||
session_status: number
|
||||
}
|
||||
|
||||
// --- 매퍼: 와이어 → feature 타입 --------------------------------------------
|
||||
export function mapMessage(w: ChatMessageWire): ChatMessage {
|
||||
return {
|
||||
chat_id: w.chat_id,
|
||||
sender: w.sender === 1 ? 'bot' : 'user',
|
||||
bot_chat_type: (w.bot_chat_type ?? null) as ChatMessage['bot_chat_type'],
|
||||
user_input_type: (w.user_input_type ?? null) as UserInputType | null,
|
||||
script: w.script ?? null,
|
||||
chat_end: w.chat_end ?? false,
|
||||
next_input_mode: (w.next_input_mode ?? null) as NextInputMode | null,
|
||||
next_input_type: w.next_input_type ?? null,
|
||||
step: w.step ?? '',
|
||||
display_step: w.display_step ?? '',
|
||||
summary: null, // (범위 외) 최종 요약 카드는 추후
|
||||
indicator_value: w.indicator_value ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapInit(r: ChatInitResponse): ChatInitData {
|
||||
return {
|
||||
session_id: r.session_id,
|
||||
item_id: r.item_id,
|
||||
quotation_id: r.quotation_id,
|
||||
item_name: r.item_name,
|
||||
item_code: r.item_code ?? '',
|
||||
item_image: r.item_image ?? '',
|
||||
item_price: r.item_price ?? 0,
|
||||
item_model_name: r.item_model_name ?? '',
|
||||
item_maker_name: r.item_maker_name ?? '',
|
||||
item_vat_yn: r.item_vat_yn == null ? '' : r.item_vat_yn ? 'VAT포함' : 'VAT별도',
|
||||
item_delivery_fee_yn:
|
||||
r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도',
|
||||
item_min_order_quantity: r.item_min_order_quantity ?? '',
|
||||
item_lead_time: r.item_lead_time ?? '',
|
||||
item_spec: r.item_spec ?? '',
|
||||
quotation_memo: r.quotation_memo ?? '',
|
||||
quotation_end_time: r.quotation_end_time ?? '',
|
||||
}
|
||||
}
|
||||
13
frontend/src/apis/chat/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// 채팅 도메인 API 공개 표면.
|
||||
export { chatApi } from './chat.api'
|
||||
export { chatKeys } from './chat.keys'
|
||||
export { useChatInitQuery, useChatMessagesQuery } from './chat.queries'
|
||||
export { useChatSendMutation } from './chat.mutations'
|
||||
export { mapInit, mapMessage } from './chat.type'
|
||||
export type {
|
||||
ChatInitResponse,
|
||||
ChatMessagesResponse,
|
||||
ChatMessageWire,
|
||||
ChatSendRequest,
|
||||
ChatSendResponse,
|
||||
} from './chat.type'
|
||||
118
frontend/src/apis/http.ts
Normal file
@ -0,0 +1,118 @@
|
||||
// 공용 axios 인스턴스.
|
||||
// - 요청 시 access token 을 Authorization 헤더에 주입
|
||||
// - 응답의 result.success=false 봉투를 ApiError 로 변환
|
||||
// - access token 만료(434) 시 refresh_token 으로 1회 자동 재발급 후 원요청 재시도
|
||||
// - refresh 실패 / 토큰 폐기(1203) / refresh 만료(435) 시 세션 종료 처리
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { tokenStorage } from './tokenStorage'
|
||||
import { ApiError, ErrorCode, type ApiResult } from './types'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// 인증 만료 시 앱이 처리할 핸들러 (로그인 페이지로 이동 등). App 에서 등록한다.
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||
onUnauthorized = handler
|
||||
}
|
||||
|
||||
function handleUnauthorized(): void {
|
||||
tokenStorage.clear()
|
||||
onUnauthorized?.()
|
||||
}
|
||||
|
||||
// --- 요청 인터셉터: access token 주입 ------------------------------------
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = tokenStorage.getAccessToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
// --- 토큰 재발급 (단일 비행: 동시 요청은 하나의 refresh 만 공유) ----------
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
|
||||
async function refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = tokenStorage.getRefreshToken()
|
||||
if (!refreshToken) {
|
||||
throw new ApiError(ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED, 'NO_REFRESH_TOKEN')
|
||||
}
|
||||
// 인터셉터 재귀를 피하려고 인스턴스가 아닌 기본 axios 로 호출한다.
|
||||
const res = await axios.post<{ result: ApiResult; access_token?: string }>(
|
||||
`${BASE_URL}/v1/auth/refresh_token`,
|
||||
null,
|
||||
{ headers: { Authorization: `Bearer ${refreshToken}` } },
|
||||
)
|
||||
const { result, access_token } = res.data
|
||||
if (!result.success || !access_token) {
|
||||
throw new ApiError(result.code, result.desc)
|
||||
}
|
||||
tokenStorage.setAccessToken(access_token)
|
||||
return access_token
|
||||
}
|
||||
|
||||
function toApiError(error: unknown): ApiError {
|
||||
if (error instanceof ApiError) return error
|
||||
if (axios.isAxiosError(error)) {
|
||||
const result = (error.response?.data as { result?: ApiResult } | undefined)?.result
|
||||
if (result) return new ApiError(result.code, result.desc, error.message)
|
||||
// 토큰 관련 HTTPException 은 result 봉투 대신 {detail: "HTTP_*"} 형태로 온다
|
||||
const detail = (error.response?.data as { detail?: string } | undefined)?.detail
|
||||
const status = error.response?.status ?? 0
|
||||
return new ApiError(status, detail ?? error.code ?? 'NETWORK_ERROR', error.message)
|
||||
}
|
||||
return new ApiError(ErrorCode.FAIL, 'UNKNOWN', String(error))
|
||||
}
|
||||
|
||||
// --- 응답 인터셉터 -------------------------------------------------------
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
// HTTP 200 이지만 result.success=false 인 비즈니스 에러를 ApiError 로 변환
|
||||
const result = (response.data as { result?: ApiResult } | undefined)?.result
|
||||
if (result && !result.success) {
|
||||
// 저장 토큰 무효화(로그아웃/타기기 로그인)는 200 + TOKEN_REVOKED 로 온다 → 세션 종료
|
||||
if (result.code === ErrorCode.TOKEN_REVOKED && tokenStorage.hasToken()) {
|
||||
handleUnauthorized()
|
||||
}
|
||||
throw new ApiError(result.code, result.desc)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const status = error.response?.status
|
||||
const original = error.config as
|
||||
| (InternalAxiosRequestConfig & { _retried?: boolean })
|
||||
| undefined
|
||||
const bodyCode = (error.response?.data as { result?: ApiResult } | undefined)?.result?.code
|
||||
const isRefreshCall = original?.url?.includes('/v1/auth/refresh_token') ?? false
|
||||
|
||||
// access token 만료 → refresh 후 1회 재시도
|
||||
if (status === 434 && original && !original._retried && !isRefreshCall) {
|
||||
original._retried = true
|
||||
try {
|
||||
refreshPromise ??= refreshAccessToken().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
const newToken = await refreshPromise
|
||||
original.headers.Authorization = `Bearer ${newToken}`
|
||||
return http(original)
|
||||
} catch (refreshError) {
|
||||
handleUnauthorized()
|
||||
throw toApiError(refreshError)
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 실패(헤더 누락 403/401, 잘못된 토큰 433/436, refresh 만료 435) /
|
||||
// 토큰 폐기(200+1203 이 아닌 경로) → 세션 종료
|
||||
const isAuthFailStatus =
|
||||
status === 401 || status === 403 || status === 433 || status === 435 || status === 436
|
||||
if (isAuthFailStatus || bodyCode === ErrorCode.TOKEN_REVOKED || isRefreshCall) {
|
||||
handleUnauthorized()
|
||||
}
|
||||
|
||||
throw toApiError(error)
|
||||
},
|
||||
)
|
||||
9
frontend/src/apis/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// apis 레이어 공개 표면.
|
||||
export { http, setUnauthorizedHandler } from './http'
|
||||
export { tokenStorage } from './tokenStorage'
|
||||
export { ApiError, ErrorCode, isApiError, getApiErrorMessage } from './types'
|
||||
export type { ApiResult, ApiEnvelope } from './types'
|
||||
|
||||
export * from './auth'
|
||||
export * from './negotiation'
|
||||
export * from './chat'
|
||||
6
frontend/src/apis/negotiation/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// 협상 API 모듈 공개 표면.
|
||||
export { negotiationApi } from './negotiation.api'
|
||||
export { negotiationKeys } from './negotiation.keys'
|
||||
export { useSessionListQuery } from './negotiation.queries'
|
||||
export { useParticipateMutation, useRejectMutation } from './negotiation.mutations'
|
||||
export * from './negotiation.type'
|
||||
34
frontend/src/apis/negotiation/negotiation.api.ts
Normal file
@ -0,0 +1,34 @@
|
||||
// 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
|
||||
import { http } from '@/apis/http'
|
||||
import type {
|
||||
ParticipateResponse,
|
||||
RejectRequest,
|
||||
RejectResponse,
|
||||
SessionListParams,
|
||||
SessionListResponse,
|
||||
} from './negotiation.type'
|
||||
|
||||
export const negotiationApi = {
|
||||
/** GET /v1/negotiation/sessions — 로그인 공급사의 협상 세션 목록(필터/정렬/페이지) */
|
||||
getSessions: async (params: SessionListParams = {}): Promise<SessionListResponse> => {
|
||||
const res = await http.get<SessionListResponse>('/v1/negotiation/sessions', { params })
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** POST /v1/negotiation/sessions/{id}/participate — 협상 세션 참여 */
|
||||
participate: async (sessionId: string): Promise<ParticipateResponse> => {
|
||||
const res = await http.post<ParticipateResponse>(
|
||||
`/v1/negotiation/sessions/${sessionId}/participate`,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** POST /v1/negotiation/sessions/{id}/reject — 협상 세션 거부 */
|
||||
reject: async (sessionId: string, body: RejectRequest): Promise<RejectResponse> => {
|
||||
const res = await http.post<RejectResponse>(
|
||||
`/v1/negotiation/sessions/${sessionId}/reject`,
|
||||
body,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
8
frontend/src/apis/negotiation/negotiation.keys.ts
Normal file
@ -0,0 +1,8 @@
|
||||
// 협상 도메인의 TanStack Query 키 팩토리.
|
||||
import type { SessionListParams } from './negotiation.type'
|
||||
|
||||
export const negotiationKeys = {
|
||||
all: ['negotiation'] as const,
|
||||
sessions: () => [...negotiationKeys.all, 'sessions'] as const,
|
||||
sessionList: (params: SessionListParams) => [...negotiationKeys.sessions(), params] as const,
|
||||
}
|
||||
32
frontend/src/apis/negotiation/negotiation.mutations.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// 협상 도메인의 변경(useMutation) 훅.
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { negotiationApi } from './negotiation.api'
|
||||
import { negotiationKeys } from './negotiation.keys'
|
||||
import type { RejectRequest } from './negotiation.type'
|
||||
|
||||
/**
|
||||
* 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다.
|
||||
*/
|
||||
export function useParticipateMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => negotiationApi.participate(sessionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 협상 세션 거부: 성공 시 세션 목록 캐시를 무효화한다.
|
||||
*/
|
||||
export function useRejectMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ sessionId, request }: { sessionId: string; request: RejectRequest }) =>
|
||||
negotiationApi.reject(sessionId, request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
|
||||
},
|
||||
})
|
||||
}
|
||||
17
frontend/src/apis/negotiation/negotiation.queries.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// 협상 도메인의 조회(useQuery) 훅.
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { negotiationApi } from './negotiation.api'
|
||||
import { negotiationKeys } from './negotiation.keys'
|
||||
import type { SessionListParams } from './negotiation.type'
|
||||
|
||||
/**
|
||||
* 협상 세션 목록 조회.
|
||||
* 페이지 전환 시 이전 데이터를 유지해 깜빡임을 줄인다.
|
||||
*/
|
||||
export function useSessionListQuery(params: SessionListParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: negotiationKeys.sessionList(params),
|
||||
queryFn: () => negotiationApi.getSessions(params),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
80
frontend/src/apis/negotiation/negotiation.type.ts
Normal file
@ -0,0 +1,80 @@
|
||||
// 협상 API 의 요청/응답 타입 + 코드값 enum.
|
||||
// 와이어 포맷은 백엔드(snake_case)를 그대로 미러링한다.
|
||||
import type { ApiResult } from '@/apis/types'
|
||||
|
||||
/** 협상 세션 상태 (negotiation.sessions.status) */
|
||||
export const SessionStatus = {
|
||||
CREATED: 1, // 협상생성(참여대기)
|
||||
IN_PROGRESS: 2, // 협상중
|
||||
DONE: 3, // 협상완료
|
||||
NOT_PARTICIPATED: 4, // 미참여(마감)
|
||||
REJECTED: 5, // 협상거부
|
||||
} as const
|
||||
export type SessionStatus = (typeof SessionStatus)[keyof typeof SessionStatus]
|
||||
|
||||
export const SESSION_STATUS_LABEL: Record<SessionStatus, string> = {
|
||||
[SessionStatus.CREATED]: '협상생성',
|
||||
[SessionStatus.IN_PROGRESS]: '협상중',
|
||||
[SessionStatus.DONE]: '협상완료',
|
||||
[SessionStatus.NOT_PARTICIPATED]: '미참여',
|
||||
[SessionStatus.REJECTED]: '협상거부',
|
||||
}
|
||||
|
||||
/** 견적 타입 (negotiation.sessions.qt_type) */
|
||||
export const QtType = {
|
||||
RENEGO: 1, // 재협상(1:1)
|
||||
REQUOTE: 2, // 재견적(1:N)
|
||||
} as const
|
||||
export type QtType = (typeof QtType)[keyof typeof QtType]
|
||||
|
||||
export const QT_TYPE_LABEL: Record<QtType, string> = {
|
||||
[QtType.RENEGO]: '재협상',
|
||||
[QtType.REQUOTE]: '재견적',
|
||||
}
|
||||
|
||||
// --- 세션 목록 (GET /v1/negotiation/sessions) ----------------------------
|
||||
export interface SessionListParams {
|
||||
status?: number // SessionStatus 코드 필터
|
||||
qt_type?: number // QtType 코드 필터
|
||||
order?: 'asc' | 'desc' // 마감(qt_end_time) 정렬, asc=임박순
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
session_id: string
|
||||
session_status: number
|
||||
qt_type: number
|
||||
qt_number: string
|
||||
qt_end_time: string // ISO 8601 마감 시각
|
||||
item_code: string
|
||||
item_name: string
|
||||
model_name: string
|
||||
maker_name: string
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
result: ApiResult
|
||||
items: SessionListItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
|
||||
export interface ParticipateResponse {
|
||||
result: ApiResult
|
||||
session_id: string
|
||||
}
|
||||
|
||||
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
|
||||
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트.
|
||||
// (백엔드 sessions.reject_reason 컬럼에 대응. 엔드포인트는 백엔드 추가 예정)
|
||||
export interface RejectRequest {
|
||||
reject_reason: string
|
||||
}
|
||||
|
||||
export interface RejectResponse {
|
||||
result: ApiResult
|
||||
session_id: string
|
||||
}
|
||||
27
frontend/src/apis/tokenStorage.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// JWT access/refresh 토큰의 영속 저장소.
|
||||
// axios 인터셉터(http.ts)와 인증 mutation 이 공유한다.
|
||||
|
||||
const ACCESS_KEY = 'negosium.accessToken'
|
||||
const REFRESH_KEY = 'negosium.refreshToken'
|
||||
|
||||
export const tokenStorage = {
|
||||
getAccessToken: (): string | null => localStorage.getItem(ACCESS_KEY),
|
||||
getRefreshToken: (): string | null => localStorage.getItem(REFRESH_KEY),
|
||||
|
||||
setTokens: (accessToken: string, refreshToken: string): void => {
|
||||
localStorage.setItem(ACCESS_KEY, accessToken)
|
||||
localStorage.setItem(REFRESH_KEY, refreshToken)
|
||||
},
|
||||
|
||||
/** refresh_token 으로 access_token 만 갱신할 때 사용 */
|
||||
setAccessToken: (accessToken: string): void => {
|
||||
localStorage.setItem(ACCESS_KEY, accessToken)
|
||||
},
|
||||
|
||||
clear: (): void => {
|
||||
localStorage.removeItem(ACCESS_KEY)
|
||||
localStorage.removeItem(REFRESH_KEY)
|
||||
},
|
||||
|
||||
hasToken: (): boolean => localStorage.getItem(ACCESS_KEY) !== null,
|
||||
}
|
||||
88
frontend/src/apis/types.ts
Normal file
@ -0,0 +1,88 @@
|
||||
// 모든 API 응답이 공유하는 공통 봉투(envelope)와 에러 타입.
|
||||
// 백엔드는 HTTP 200 으로 내려주면서 result.success=false 로 비즈니스 에러를 표현한다.
|
||||
|
||||
/** 백엔드가 모든 응답에 공통으로 내려주는 처리 결과 */
|
||||
export interface ApiResult {
|
||||
success: boolean
|
||||
code: number
|
||||
desc: string
|
||||
}
|
||||
|
||||
/** result 봉투를 포함하는 응답의 베이스 */
|
||||
export interface ApiEnvelope {
|
||||
result: ApiResult
|
||||
}
|
||||
|
||||
/** 백엔드 ErrorType 코드 (backend/common 의 ErrorType 과 1:1 매핑) */
|
||||
export const ErrorCode = {
|
||||
SUCCESS: 0,
|
||||
FAIL: 1,
|
||||
DB_RUN_FAILED: 10,
|
||||
DB_ALREADY_SAME_KEY: 11,
|
||||
JSON_PARSE_ERROR: 100,
|
||||
INVALID_REQUEST_DATA: 101,
|
||||
INTERNAL_EXCEPTION: 102,
|
||||
HTTP_INVALID_CLIENT_REQUEST: 419,
|
||||
HTTP_TO_MANY_REQUEST: 429,
|
||||
HTTP_INVALID_CLIENT_ACCESS: 433,
|
||||
HTTP_ACCESS_TOKEN_EXPIRED: 434,
|
||||
HTTP_REFRESH_TOKEN_EXPIRED: 435,
|
||||
HTTP_INVALID_TOKEN_ACCESS: 436,
|
||||
ACCOUNT_INVALID_INFO: 1200,
|
||||
ACCOUNT_ALREADY_EXIST: 1201,
|
||||
ACCOUNT_BLOCKED_USER: 1202,
|
||||
TOKEN_REVOKED: 1203,
|
||||
NEGO_FORBIDDEN: 1300,
|
||||
NEGO_NOT_PARTICIPABLE: 1301,
|
||||
NEGO_QUOTATION_CLOSED: 1302,
|
||||
NEGO_DEADLINE_PASSED: 1303,
|
||||
NEGO_NOT_FOUND: 1304,
|
||||
CHAT_NOT_IN_PROGRESS: 1400,
|
||||
CHAT_PRICE_OUT_OF_RANGE: 1401,
|
||||
CHAT_AGENT_UNAVAILABLE: 1402,
|
||||
CHAT_IN_PROGRESS: 1403,
|
||||
} as const
|
||||
|
||||
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
|
||||
|
||||
/** code → 사용자에게 보여줄 한국어 메시지 */
|
||||
const API_ERROR_MESSAGES: Record<number, string> = {
|
||||
[ErrorCode.ACCOUNT_INVALID_INFO]: '아이디 또는 비밀번호가 올바르지 않습니다.',
|
||||
[ErrorCode.ACCOUNT_ALREADY_EXIST]: '이미 존재하는 아이디입니다.',
|
||||
[ErrorCode.ACCOUNT_BLOCKED_USER]: '비활성화된 계정입니다. 관리자에게 문의하세요.',
|
||||
[ErrorCode.TOKEN_REVOKED]: '다른 기기에서 로그인되어 세션이 종료되었습니다.',
|
||||
[ErrorCode.HTTP_ACCESS_TOKEN_EXPIRED]: '로그인이 만료되었습니다. 다시 로그인해주세요.',
|
||||
[ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED]: '로그인이 만료되었습니다. 다시 로그인해주세요.',
|
||||
[ErrorCode.NEGO_FORBIDDEN]: '해당 협상에 접근할 권한이 없습니다.',
|
||||
[ErrorCode.NEGO_NOT_PARTICIPABLE]: '참여할 수 없는 협상입니다.',
|
||||
[ErrorCode.NEGO_QUOTATION_CLOSED]: '마감된 견적입니다.',
|
||||
[ErrorCode.NEGO_DEADLINE_PASSED]: '협상 마감 시간이 지났습니다.',
|
||||
[ErrorCode.NEGO_NOT_FOUND]: '협상을 찾을 수 없습니다.',
|
||||
[ErrorCode.CHAT_NOT_IN_PROGRESS]: '진행 중인 협상이 아닙니다. 목록으로 돌아갑니다.',
|
||||
[ErrorCode.CHAT_PRICE_OUT_OF_RANGE]: '제시 가격이 허용 범위를 벗어났습니다.',
|
||||
[ErrorCode.CHAT_AGENT_UNAVAILABLE]: '협상 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||||
[ErrorCode.CHAT_IN_PROGRESS]: '이전 메시지를 처리 중입니다. 잠시만 기다려주세요.',
|
||||
}
|
||||
|
||||
/** API 에러: result.code(비즈니스) 또는 HTTP status 를 code 로 담는다 */
|
||||
export class ApiError extends Error {
|
||||
readonly code: number
|
||||
readonly desc: string
|
||||
|
||||
constructor(code: number, desc: string, message?: string) {
|
||||
super(message ?? API_ERROR_MESSAGES[code] ?? desc)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.desc = desc
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiError(error: unknown): error is ApiError {
|
||||
return error instanceof ApiError
|
||||
}
|
||||
|
||||
/** code 에 해당하는 사용자 안내 메시지 (없으면 기본 문구) */
|
||||
export function getApiErrorMessage(error: unknown, fallback = '요청 처리 중 오류가 발생했습니다.'): string {
|
||||
if (isApiError(error)) return API_ERROR_MESSAGES[error.code] ?? error.message ?? fallback
|
||||
return fallback
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
30
frontend/src/components/Modal.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
|
||||
export interface ModalProps {
|
||||
children?: ReactNode
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// 공통 모달: 반투명 배경 + 배경 클릭/ESC 로 닫기.
|
||||
export function Modal({ children, onClose }: ModalProps) {
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleEscKey)
|
||||
return () => document.removeEventListener('keydown', handleEscKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.40)]"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
@ -1,5 +1,6 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -12,5 +13,10 @@ const queryClient = new QueryClient({
|
||||
})
|
||||
|
||||
export function Provider({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<Toaster position="top-center" />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
12
frontend/src/features/auth/components/RequireAuth.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router'
|
||||
import { tokenStorage } from '@/apis'
|
||||
|
||||
// 토큰이 없으면 로그인 페이지로 보낸다 (인증 영역 가드).
|
||||
// 토큰이 있으나 만료/폐기된 경우는 요청 시 인터셉터가 세션을 종료시킨다.
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
if (!tokenStorage.hasToken()) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
@ -1,18 +1,30 @@
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useLogoutMutation, useMeQuery } from '@/apis'
|
||||
import { Button } from '@/components'
|
||||
|
||||
// 사이드바 하단: 공급사명 + 로그아웃
|
||||
export function SidebarFooter() {
|
||||
const navigate = useNavigate()
|
||||
const { data: user } = useMeQuery()
|
||||
const logout = useLogoutMutation()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout.mutate(undefined, {
|
||||
onSuccess: () => navigate('/'),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-[60px] py-3 px-8 gap-3 items-center">
|
||||
<div className="flex-1 flex items-center min-w-0 text-sm text-foreground">
|
||||
{/* TODO: 공급사명 (auth store 연동) */}
|
||||
<span className="truncate">-</span>
|
||||
<span className="truncate">{user?.supplierName ?? '-'}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-[61px] px-2 rounded-[4px] text-xs flex-shrink-0"
|
||||
// TODO: 로그아웃 mutation 연동
|
||||
onClick={handleLogout}
|
||||
disabled={logout.isPending}
|
||||
>
|
||||
로그아웃
|
||||
</Button>
|
||||
2
frontend/src/features/auth/hooks/useLoginMutation.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// 실제 로그인 API 연동은 apis/auth 로 이전됨. 기존 import 경로 호환을 위해 재노출한다.
|
||||
export { useLoginMutation, type LoginParams } from '@/apis/auth'
|
||||
@ -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'
|
||||