requirement.dev.txt 삭제 .
This commit is contained in:
parent
8bb4402b33
commit
ebfa7a15bb
103
README.md
103
README.md
@ -1 +1,102 @@
|
||||
마지막 내 도리는 하자 .
|
||||
# O2O Negosium
|
||||
|
||||
동일 구조의 두 서비스(**negosium**, **negodata**)가 **하나의 PostgreSQL 인스턴스**를 공유한다.
|
||||
|
||||
## 구성
|
||||
|
||||
```
|
||||
o2o-negosium/
|
||||
├── docker-compose.yml # 두 backend (DB 는 외부)
|
||||
├── postgres-init/ # DB·테이블 셋업 SQL (대상 DB 에 1회 적용)
|
||||
├── backend/ # negosium 백엔드 (포트 9300)
|
||||
├── negodata/backend/ # negodata 백엔드 (포트 9400)
|
||||
├── agent/ front/ # (예정)
|
||||
└── negodata/front/ # (예정)
|
||||
```
|
||||
|
||||
두 백엔드는 같은 코드 골격(MVC · 람다 DB · Depends 주입 · JWT 로그인)을 쓴다.
|
||||
아키텍처/패턴 상세는 각 서버 README 참고: [backend](backend/README.md) · [negodata/backend](negodata/backend/README.md)
|
||||
|
||||
### DB 는 compose 밖 (config 로 연결)
|
||||
DB 는 docker-compose 에서 관리하지 않는다. 각 backend 는 `config.<APP_ENV>.toml` 의 접속 정보대로
|
||||
**외부 PostgreSQL**(호스트 로컬 postgres, 또는 따로 떠 있는 docker postgres)에 연결한다.
|
||||
한 PostgreSQL 안에 서비스별 database 를 둔다.
|
||||
```
|
||||
PostgreSQL (외부, 5432)
|
||||
├── negosium_db ← negosium-backend
|
||||
└── negodata_db ← negodata-backend
|
||||
```
|
||||
- 컨테이너(docker env)에서 호스트 DB 접근: `host.docker.internal:5432` (`config.docker.toml`)
|
||||
- 로컬 실행/테스트(local·test env): `127.0.0.1:5432` (`config.local/test.toml`)
|
||||
- 계정/database 명은 config 에 맞춘다 (기본 `postgres` / `password`).
|
||||
|
||||
| 서비스 | 서버 | docs | database |
|
||||
|---|---|---|---|
|
||||
| negosium-backend | http://localhost:9300 | /docs | negosium_db |
|
||||
| negodata-backend | http://localhost:9400 | /docs | negodata_db |
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
```bash
|
||||
# 1) DB 준비 (최초 1회) — 사용할 PostgreSQL 에 스키마 적용
|
||||
psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/01-schema.sql
|
||||
# (negosium_db / negodata_db + tbl_account 생성)
|
||||
|
||||
# 2) 백엔드 기동
|
||||
docker compose up -d # 두 backend (DB 는 config 대로 외부 연결)
|
||||
docker compose logs -f
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# config.test.toml 의 PostgreSQL(기본 127.0.0.1:5432) 이 떠 있어야 한다
|
||||
cd backend # 또는 negodata/backend
|
||||
pip install pytest pytest-asyncio httpx
|
||||
python -m pytest
|
||||
```
|
||||
- httpx `ASGITransport` 로 네트워크 없이 앱을 직접 호출하는 e2e (각 5개).
|
||||
- `DB_SESSION_MNG` 싱글톤의 커넥션 풀이 첫 이벤트 루프에 묶이므로, 모든 테스트가 단일 session 루프를 공유한다(`pytest.ini`).
|
||||
|
||||
## 성능 / 벤치마크
|
||||
|
||||
`/login` 은 **bcrypt(CPU 바운드)** 가 비용의 대부분이다. 초기에는 bcrypt 가 asyncio
|
||||
이벤트 루프를 막아 **아무 일도 안 하는 `/healthz` 조차 p99 3.3s** 가 나왔다.
|
||||
|
||||
**두 가지 최적화**
|
||||
1. **bcrypt 를 `asyncio.to_thread` 로 오프로드** — 이벤트 루프 비차단. bcrypt 는 해싱 중
|
||||
GIL 을 해제하므로 스레드들이 여러 코어에서 실제 병렬 실행된다.
|
||||
2. **워커 수 증가** (`process_count` 1 → 4) — login 처리량을 코어만큼 확장.
|
||||
|
||||
### Before / After (동일 부하: 100 users)
|
||||
| 지표 | Before | After | 변화 |
|
||||
|---|---|---|---|
|
||||
| `/healthz` median | 1700ms | **2ms** | 850배 개선|
|
||||
| `/healthz` p99 | 3300ms | **14ms** | 235배 개선|
|
||||
| `/me` p99 | 3100ms | **12ms** | 258배 개선|
|
||||
| `/login` median | 9900ms | **220ms** | 45배 개선|
|
||||
| `/login` p99 | 15000ms | **1400ms** | 11배 개선|
|
||||
| `/login` RPS | 5.5 | **19.3** | 3.5배 개선|
|
||||
| 전체 RPS | 17.6 | **75.2** | 4.3배 개선|
|
||||
|
||||
### 최적화 후 Locust 차트 (100 users)
|
||||
RPS 가 ~71 로 안정, p95 ~250ms(bcrypt), 실패 0%. median 은 초기 계정생성 버스트 후 바닥으로 떨어진다.
|
||||
|
||||

|
||||
|
||||
> login 은 여전히 가장 느리다(bcrypt 의 의도된 비용). 핵심은 그게 **서버 전체를 막지 않는다**는 점.
|
||||
> 더 높은 처리량은 워커/인스턴스 수평 확장이 정석이다(bcrypt cost 낮추기는 보안 트레이드오프).
|
||||
> ⚠️ to_thread 가 이미 단일 워커에서 멀티코어 병렬화를 하므로, 워커를 코어 수만큼 늘리면서
|
||||
> to_thread 까지 쓰면 `워커 x 스레드` 가 코어를 넘어 오버서브스크립션이 된다(워커는 코어의 절반 안팎).
|
||||
|
||||
부하 재현:
|
||||
```bash
|
||||
docker compose up -d
|
||||
cd backend && pip install locust
|
||||
python -m locust -f loadtest/locustfile.py --host http://localhost:9300 --headless -u 100 -r 10 -t 2m
|
||||
# 부하 중 커넥션 모니터링: psql -h 127.0.0.1 -U postgres -c "SELECT count(*) FROM pg_stat_activity;"
|
||||
```
|
||||
|
||||
## 기술 스택
|
||||
FastAPI · SQLAlchemy(async) · asyncpg · PostgreSQL 16 · python-jose(JWT) · bcrypt · uvicorn · Docker Compose
|
||||
|
||||
1
agent/README.md
Normal file
1
agent/README.md
Normal file
@ -0,0 +1 @@
|
||||
마지막 내 도리는 하자 .
|
||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@ -0,0 +1,36 @@
|
||||
# Negosium + Negodata 백엔드.
|
||||
# DB 는 compose 에서 관리하지 않는다 — 각 backend 는 config.docker.toml 의 접속 정보대로
|
||||
# 외부 PostgreSQL 에 연결한다(호스트에 떠 있는 로컬 postgres, 또는 따로 실행 중인 docker postgres).
|
||||
# 컨테이너에서 호스트의 DB 에 접속할 때는 host.docker.internal 을 쓴다.
|
||||
#
|
||||
# docker compose up -d
|
||||
# negosium 서버: http://localhost:9300/docs
|
||||
# negodata 서버: http://localhost:9400/docs
|
||||
#
|
||||
# DB 준비(최초 1회): postgres-init/01-schema.sql 을 대상 DB 에 적용한다.
|
||||
# psql -h <host> -p <port> -U <user> -f postgres-init/01-schema.sql
|
||||
# (negosium_db / negodata_db 와 tbl_account 생성)
|
||||
|
||||
services:
|
||||
negosium-backend:
|
||||
build: ./backend
|
||||
container_name: negosium-backend
|
||||
environment:
|
||||
APP_ENV: docker
|
||||
ports:
|
||||
- "9300:9300"
|
||||
# 컨테이너에서 호스트의 DB 로 접근 (config.docker.toml 의 host.docker.internal)
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
negodata-backend:
|
||||
build: ./negodata/backend
|
||||
container_name: negodata-backend
|
||||
environment:
|
||||
APP_ENV: docker
|
||||
ports:
|
||||
- "9400:9400"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
1
front/README.md
Normal file
1
front/README.md
Normal file
@ -0,0 +1 @@
|
||||
마지막 내 도리는 하자 .
|
||||
7
negodata/backend/.dockerignore
Normal file
7
negodata/backend/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.git/
|
||||
tests/
|
||||
loadtest/
|
||||
*.md
|
||||
16
negodata/backend/Dockerfile
Normal file
16
negodata/backend/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 의존성 먼저 설치 (레이어 캐시 활용)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# docker-compose 에서 APP_ENV=docker 로 config.docker.toml 을 읽는다.
|
||||
ENV APP_ENV=docker
|
||||
|
||||
EXPOSE 9300
|
||||
|
||||
CMD ["python", "web_main.py"]
|
||||
61
negodata/backend/README.md
Normal file
61
negodata/backend/README.md
Normal file
@ -0,0 +1,61 @@
|
||||
# Negodata Backend
|
||||
|
||||
DerbyMasters_Server 아키텍처를 이식한 FastAPI 골격. 기능은 **JWT id/pw 로그인**만 예시 구현.
|
||||
[negosium-backend](../../backend/README.md) 와 동일 구조이며, 실행·테스트·벤치마크 종합은 [레포 최상위 README](../../README.md) 참고.
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
negodata/backend/
|
||||
├── web_main.py # 엔트리포인트 (uvicorn)
|
||||
├── config/ # 환경설정 (APP_ENV 별 toml 로드)
|
||||
├── common/
|
||||
│ ├── enums.py # ErrorType / DBType / DBWRType / EXCEPTION_*
|
||||
│ ├── models/gmodel.py # 프로토콜 베이스 (WebPacketProtocol 등)
|
||||
│ └── database/
|
||||
│ ├── db_session_manager.py# ★ DB Read/Write + 람다 실행 핵심
|
||||
│ └── model/models.py # ORM 모델 (tbl_account)
|
||||
├── crud/user_crud.py # DB 접근 (I*CRUD 인터페이스 + 구현)
|
||||
├── services/auth_service.py # 비즈니스 로직
|
||||
└── router/
|
||||
├── router.py # FastAPI app
|
||||
└── v1/
|
||||
├── auth/{account,protocol}.py # 엔드포인트 / Req_·Res_
|
||||
└── validator/dependencies.py # ★ JWT 발급·검증, 해시, RemoveNoneResponse
|
||||
```
|
||||
|
||||
## 핵심 패턴
|
||||
- **MVC**: router(컨트롤러) → service(로직) → crud(쿼리). crud 는 인터페이스/구현 분리.
|
||||
- **Depends 주입**: service 가 `IUserCRUD = Depends(UserCRUD)`, router 가 `Depends(AuthService)`, 인증은 `Depends(IsValidAccessToken)`.
|
||||
- **DB Read/Write 분리**: `DBType` × `DBWRType` 조합마다 별도 async 엔진(조회=Read, 변경=Write).
|
||||
- **람다 DB 실행**: service 는 세션을 직접 열지 않고 람다를 매니저에 넘긴다. 세션/트랜잭션은 매니저가 책임.
|
||||
```python
|
||||
err, acc = await DB_SESSION_MNG.execute_lambda( # 단일 조회
|
||||
tbl_account.DBType(), DBWRType.DB_READ.value, lambda s: crud.get_account_by_id(s, id))
|
||||
err = await DB_SESSION_MNG.execute_lambda_run( # 여러 변경 = 1 트랜잭션
|
||||
[tbl_account.DBType()], [lambda s: crud.update_last_login(s, uid)])
|
||||
```
|
||||
- **비동기**: 전 계층 async/await, SQLAlchemy async + asyncpg, task 단위 scoped session.
|
||||
- **Protocol 규약**: 모든 패킷 `WebPacketProtocol` 상속, `Req_*`/`Res_*`(응답엔 `result: ErrorInfo`), 라우터별 `protocol.py`.
|
||||
- **ResponseNone**: 응답의 `None` 필드 재귀 제거(`RemoveNoneResponse`).
|
||||
- **bcrypt 비차단**: `GetHashedPW`/`VerifyPW` 를 `asyncio.to_thread` 로 오프로드(이벤트 루프 비차단). → [벤치마크](../../README.md#성능--벤치마크)
|
||||
|
||||
## 엔드포인트
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| POST | `/v1/auth/create` | 계정 생성 (pw bcrypt 해시) |
|
||||
| POST | `/v1/auth/login` | 로그인, access/refresh 토큰 발급 |
|
||||
| POST | `/v1/auth/refresh_token` | access 토큰 재발급 (refresh 필요) |
|
||||
| GET | `/v1/auth/me` | 내 정보 (access 토큰 필요) |
|
||||
|
||||
## 실행 / 테스트
|
||||
```bash
|
||||
# 레포 최상위에서 docker compose up -d (backend만; DB 는 외부 PostgreSQL). 상세는 루트 README.
|
||||
cd negodata/backend
|
||||
pip install -r requirements.txt # 실행
|
||||
python web_main.py # APP_ENV 기본 local
|
||||
pip install pytest pytest-asyncio httpx # 테스트 도구
|
||||
python -m pytest
|
||||
```
|
||||
- 서버: http://localhost:9400/docs
|
||||
- 환경: `config.{local,test,docker}.toml` (`APP_ENV` 로 선택, docker 는 DB 호스트=`host.docker.internal`, database=`negodata_db`)
|
||||
Binary file not shown.
BIN
negodata/backend/common/__pycache__/enums.cpython-314.pyc
Normal file
BIN
negodata/backend/common/__pycache__/enums.cpython-314.pyc
Normal file
Binary file not shown.
BIN
negodata/backend/common/__pycache__/logger.cpython-314.pyc
Normal file
BIN
negodata/backend/common/__pycache__/logger.cpython-314.pyc
Normal file
Binary file not shown.
BIN
negodata/backend/common/__pycache__/singleton.cpython-314.pyc
Normal file
BIN
negodata/backend/common/__pycache__/singleton.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
198
negodata/backend/common/database/db_session_manager.py
Normal file
198
negodata/backend/common/database/db_session_manager.py
Normal file
@ -0,0 +1,198 @@
|
||||
from asyncio import current_task
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_scoped_session
|
||||
from sqlalchemy.util._collections import immutabledict
|
||||
|
||||
from common.database.model.models import MAIN_BASE
|
||||
from common.enums import DBType, DBWRType, ErrorType
|
||||
from common.logger import LOG
|
||||
from common.singleton import Singleton
|
||||
from config.server_configs import main_db_config
|
||||
|
||||
|
||||
class DBSessionManager(Singleton):
|
||||
"""DB 세션/엔진 관리자 (싱글톤).
|
||||
|
||||
핵심 패턴
|
||||
- DBType(논리 DB) x DBWRType(Read/Write) 조합마다 별도 async 엔진을 둔다.
|
||||
=> 조회는 Read 복제본, 변경은 Write 주 DB 로 자연스럽게 분리된다.
|
||||
- 비즈니스 로직(service)은 직접 세션을 열지 않고 "람다"를 넘긴다.
|
||||
execute_lambda : 단일 쿼리 (주로 조회)
|
||||
execute_lambda_run : 동일 DB 의 여러 변경 쿼리를 한 트랜잭션으로 commit
|
||||
세션 open/close 와 commit/rollback 은 매니저가 책임진다.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
if DBSessionManager.is_init():
|
||||
LOG.e_no_callstack("already init DBSessionManager")
|
||||
return
|
||||
DBSessionManager.set_init()
|
||||
|
||||
self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"}
|
||||
# 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다.
|
||||
self.__engines = []
|
||||
# 논리 DB -> config. DB 가 늘어나면 여기에 추가만 하면 된다.
|
||||
self.__db_type_map = {
|
||||
DBType.MAIN.value: main_db_config,
|
||||
}
|
||||
|
||||
# Write 엔진 맵
|
||||
self.__write_session = {
|
||||
DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_WRITE.value),
|
||||
}
|
||||
# Read 엔진 맵
|
||||
self.__read_session = {
|
||||
DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_READ.value),
|
||||
}
|
||||
|
||||
def create_engine(self, db_type: int, db_wr_type: int):
|
||||
db_config = self.__db_type_map.get(db_type)
|
||||
if not db_config:
|
||||
raise ValueError("Invalid database type")
|
||||
|
||||
if db_wr_type == DBWRType.DB_READ.value:
|
||||
pw = (":" + db_config.read_pw) if len(db_config.read_pw) > 0 else ""
|
||||
db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.read_id}{pw}@{db_config.read_host}:{db_config.read_port}/{db_config.name}"
|
||||
LOG.i(f"Read DB create engine url : {db_url}")
|
||||
else:
|
||||
pw = (":" + db_config.write_pw) if len(db_config.write_pw) > 0 else ""
|
||||
db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.write_id}{pw}@{db_config.write_host}:{db_config.write_port}/{db_config.name}"
|
||||
LOG.i(f"Write DB create engine url : {db_url}")
|
||||
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
echo=db_config.show_log,
|
||||
pool_size=db_config.pool_size,
|
||||
max_overflow=db_config.max_overflow,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=600,
|
||||
)
|
||||
self.__engines.append(engine)
|
||||
scoped_session = async_scoped_session(
|
||||
sessionmaker(engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False),
|
||||
scopefunc=current_task,
|
||||
)
|
||||
return scoped_session
|
||||
|
||||
async def dispose_all(self):
|
||||
"""모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다.
|
||||
호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다.
|
||||
"""
|
||||
for engine in self.__engines:
|
||||
await engine.dispose()
|
||||
|
||||
# ---- 세션 lifecycle -------------------------------------------------
|
||||
async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession:
|
||||
if db_wr_type == DBWRType.DB_WRITE.value:
|
||||
return self.__write_session[db_type]()
|
||||
return self.__read_session[db_type]()
|
||||
|
||||
async def end_session(self, db_type: int, db_wr_type: int):
|
||||
if db_wr_type == DBWRType.DB_WRITE.value:
|
||||
await self.__write_session[db_type].remove()
|
||||
else:
|
||||
await self.__read_session[db_type].remove()
|
||||
|
||||
# ---- 저수준 DB 연산 (crud 에서 호출) --------------------------------
|
||||
async def run(self, db: AsyncSession, err_msg="DB Run Failed", raise_error=True) -> ErrorType:
|
||||
try:
|
||||
await db.commit()
|
||||
return ErrorType.SUCCESS
|
||||
except IntegrityError as ex:
|
||||
await db.rollback()
|
||||
LOG.e_no_callstack(f"duplicated. {ex}")
|
||||
return ErrorType.DB_ALREADY_SAME_KEY
|
||||
except Exception as ex:
|
||||
await db.rollback()
|
||||
err_type = ErrorType.DB_RUN_FAILED
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
if raise_error:
|
||||
raise RuntimeError(err_type.name, err_msg)
|
||||
return err_type
|
||||
|
||||
async def insert(self, db: AsyncSession, obj, err_msg="DB Failed", raise_error=True) -> ErrorType:
|
||||
try:
|
||||
if isinstance(obj, MAIN_BASE):
|
||||
db.add(obj)
|
||||
elif isinstance(obj, list):
|
||||
db.add_all(obj)
|
||||
else:
|
||||
raise RuntimeError("DO NOT USE QUERY IN DBJOB")
|
||||
return ErrorType.SUCCESS
|
||||
except Exception as ex:
|
||||
await db.rollback()
|
||||
err_type = ErrorType.DB_RUN_FAILED
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
if raise_error:
|
||||
raise RuntimeError(err_type.name, err_msg)
|
||||
return err_type
|
||||
|
||||
async def add(self, db: AsyncSession, query, err_msg="DB Operation Failed", raise_error=True) -> ErrorType:
|
||||
"""update/delete 등 비-select 쿼리 실행."""
|
||||
try:
|
||||
if hasattr(query, "column_descriptions"):
|
||||
raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB")
|
||||
await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"}))
|
||||
return ErrorType.SUCCESS
|
||||
except IntegrityError as ex:
|
||||
await db.rollback()
|
||||
err_type = ErrorType.DB_ALREADY_SAME_KEY
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
return err_type
|
||||
except Exception as ex:
|
||||
await db.rollback()
|
||||
err_type = ErrorType.DB_RUN_FAILED
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
if raise_error:
|
||||
raise RuntimeError(err_type.name, err_msg)
|
||||
return err_type
|
||||
|
||||
async def execute(self, db: AsyncSession, query, err_msg="DB Query Execution Failed", raise_error=True) -> tuple[ErrorType, list]:
|
||||
"""select 쿼리 실행 후 결과 리스트 반환."""
|
||||
try:
|
||||
if not hasattr(query, "column_descriptions"):
|
||||
raise RuntimeError("DO NOT USE NON-SELECT QUERY IN DBJOB")
|
||||
res = await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"}))
|
||||
return ErrorType.SUCCESS, res.scalars().fetchall() if 1 == len(query.column_descriptions) else res.all()
|
||||
except Exception as ex:
|
||||
err_type = ErrorType.DB_RUN_FAILED
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
if raise_error:
|
||||
raise RuntimeError(err_type.name, err_msg)
|
||||
return err_type, []
|
||||
|
||||
# ---- 람다 실행 진입점 (service 에서 호출) ---------------------------
|
||||
async def execute_lambda(self, db_type: int, db_wr_type: int, func):
|
||||
"""단일 쿼리 호출. func(session) 한 개를 실행하고 결과를 그대로 반환."""
|
||||
s = await self.start_session(db_type, db_wr_type)
|
||||
try:
|
||||
return await func(s)
|
||||
finally:
|
||||
await self.end_session(db_type, db_wr_type)
|
||||
|
||||
async def execute_lambda_run(self, db_type_list: list[int], func_list: list):
|
||||
"""동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit.
|
||||
하나라도 SUCCESS 가 아니면 즉시 중단(rollback)된다.
|
||||
"""
|
||||
temp_list = list(set(db_type_list))
|
||||
if len(temp_list) != 1:
|
||||
return ErrorType.DB_INVALID_TYPE
|
||||
|
||||
db_type = temp_list[0]
|
||||
s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
|
||||
try:
|
||||
for func in func_list:
|
||||
err_type = await func(s)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type
|
||||
return await self.run(s)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
finally:
|
||||
await self.end_session(db_type, DBWRType.DB_WRITE.value)
|
||||
|
||||
|
||||
DB_SESSION_MNG = DBSessionManager()
|
||||
Binary file not shown.
Binary file not shown.
26
negodata/backend/common/database/model/models.py
Normal file
26
negodata/backend/common/database/model/models.py
Normal file
@ -0,0 +1,26 @@
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
from common.enums import DBType
|
||||
|
||||
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다.
|
||||
MAIN_BASE = declarative_base()
|
||||
|
||||
|
||||
class tbl_account(MAIN_BASE):
|
||||
# 모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택).
|
||||
@staticmethod
|
||||
def DBType():
|
||||
return DBType.MAIN.value
|
||||
|
||||
__tablename__ = "tbl_account"
|
||||
|
||||
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')"))
|
||||
61
negodata/backend/common/enums.py
Normal file
61
negodata/backend/common/enums.py
Normal file
@ -0,0 +1,61 @@
|
||||
from enum import Enum, auto
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class ErrorType(Enum):
|
||||
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
|
||||
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
|
||||
"""
|
||||
|
||||
SUCCESS = 0
|
||||
FAIL = 1
|
||||
|
||||
# DB / Redis 에러
|
||||
DB_RUN_FAILED = 10
|
||||
DB_ALREADY_SAME_KEY = auto()
|
||||
DB_INVALID_KEY = auto()
|
||||
DB_EMPTY_DATA = auto()
|
||||
DB_INVALID_TYPE = auto()
|
||||
|
||||
# 요청/직렬화 에러
|
||||
JSON_PARSE_ERROR = 100
|
||||
INVALID_REQUEST_DATA = auto()
|
||||
INTERNAL_EXCEPTION = auto()
|
||||
|
||||
# http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션
|
||||
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 = auto()
|
||||
ACCOUNT_BLOCKED_USER = auto()
|
||||
|
||||
|
||||
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
||||
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
|
||||
EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name)
|
||||
EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name)
|
||||
EXCEPTION_ACCESS_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.name)
|
||||
EXCEPTION_REFRESH_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.name)
|
||||
EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_TOKEN_ACCESS.value, detail=ErrorType.HTTP_INVALID_TOKEN_ACCESS.name)
|
||||
|
||||
|
||||
class DBType(Enum):
|
||||
"""논리 DB 구분. 모델마다 DBType() 으로 자신이 속한 DB 를 반환한다.
|
||||
DB 가 늘어나면 여기에 추가하고 db_session_manager 의 맵에 등록만 하면 된다.
|
||||
"""
|
||||
|
||||
MAIN = 1
|
||||
|
||||
|
||||
class DBWRType(Enum):
|
||||
"""Read / Write 접속 구분. 조회는 DB_READ, 변경은 DB_WRITE 엔진을 사용한다."""
|
||||
|
||||
DB_READ = 1
|
||||
DB_WRITE = 2
|
||||
43
negodata/backend/common/logger.py
Normal file
43
negodata/backend/common/logger.py
Normal file
@ -0,0 +1,43 @@
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class _Logger:
|
||||
"""원본 DerbyServer LOG 인터페이스를 간소화한 버전.
|
||||
LOG.i / LOG.d / LOG.w / LOG.e_no_callstack / LOG.SetPrefix 를 제공한다.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._prefix = ""
|
||||
|
||||
def SetPrefix(self, prefix: str):
|
||||
self._prefix = prefix
|
||||
|
||||
def _now(self) -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _write(self, level: str, msg):
|
||||
head = f"{self._now()} [{level}]"
|
||||
if self._prefix:
|
||||
head += f"[{self._prefix}]"
|
||||
print(f"{head} {msg}", file=sys.stderr if level in ("WARN", "ERROR") else sys.stdout)
|
||||
|
||||
def d(self, msg):
|
||||
self._write("DEBUG", msg)
|
||||
|
||||
def i(self, msg):
|
||||
self._write("INFO", msg)
|
||||
|
||||
def w(self, msg):
|
||||
self._write("WARN", msg)
|
||||
|
||||
def e(self, msg):
|
||||
self._write("ERROR", msg)
|
||||
traceback.print_stack()
|
||||
|
||||
def e_no_callstack(self, msg):
|
||||
self._write("ERROR", msg)
|
||||
|
||||
|
||||
LOG = _Logger()
|
||||
Binary file not shown.
64
negodata/backend/common/models/gmodel.py
Normal file
64
negodata/backend/common/models/gmodel.py
Normal file
@ -0,0 +1,64 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common.enums import ErrorType
|
||||
|
||||
|
||||
class StructModel:
|
||||
"""프로토콜/구조체 식별용 마커 클래스."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ErrorInfo(BaseModel, StructModel):
|
||||
"""모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다."""
|
||||
|
||||
success: Optional[bool] = True
|
||||
code: Optional[int] = ErrorType.SUCCESS.value
|
||||
desc: Optional[str] = ErrorType.SUCCESS.name
|
||||
|
||||
def SetResult(self, enum: ErrorType):
|
||||
if enum is not None:
|
||||
self.success = ErrorType.SUCCESS.value == enum.value
|
||||
self.code = enum.value
|
||||
self.desc = enum.name
|
||||
|
||||
|
||||
# ---- Protocol 규약 -------------------------------------------------------
|
||||
# 모든 통신 패킷은 WebPacketProtocol 을 상속한다.
|
||||
# 요청 : Req_xxx (WebPacketProtocol)
|
||||
# 응답 : Res_xxx (Res_WebPacketProtocol) - 항상 result 필드를 가진다.
|
||||
# 각 라우터 폴더의 protocol.py 에 Req_/Res_ 를 정의한다.
|
||||
class WebPacketProtocol(BaseModel, StructModel):
|
||||
pass
|
||||
|
||||
|
||||
class Req_WebPacketProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Res_WebPacketProtocol(WebPacketProtocol):
|
||||
# default_factory 로 인스턴스마다 새 ErrorInfo 를 생성한다 (mutable default 공유 방지).
|
||||
result: ErrorInfo = Field(default_factory=ErrorInfo)
|
||||
msg: Optional[str] = None
|
||||
|
||||
|
||||
class UserInfo(StructModel):
|
||||
"""JWT subject 로 인코딩되는 유저 식별 정보."""
|
||||
|
||||
uid: int
|
||||
id: str
|
||||
nickname: str
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__()
|
||||
for dictionary in args:
|
||||
for key in dictionary:
|
||||
setattr(self, key, dictionary[key])
|
||||
for key in kwargs:
|
||||
setattr(self, key, kwargs[key])
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.__dict__)
|
||||
16
negodata/backend/common/singleton.py
Normal file
16
negodata/backend/common/singleton.py
Normal file
@ -0,0 +1,16 @@
|
||||
class Singleton:
|
||||
_init = False
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if not hasattr(cls, "instance"):
|
||||
cls.instance = super(Singleton, cls).__new__(cls)
|
||||
|
||||
return cls.instance
|
||||
|
||||
@classmethod
|
||||
def is_init(cls):
|
||||
return cls._init
|
||||
|
||||
@classmethod
|
||||
def set_init(cls):
|
||||
cls._init = True
|
||||
BIN
negodata/backend/common/utils/__pycache__/gtime.cpython-314.pyc
Normal file
BIN
negodata/backend/common/utils/__pycache__/gtime.cpython-314.pyc
Normal file
Binary file not shown.
21
negodata/backend/common/utils/gtime.py
Normal file
21
negodata/backend/common/utils/gtime.py
Normal file
@ -0,0 +1,21 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
|
||||
class GTime:
|
||||
"""서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸. (원본 DerbyServer 패턴 축약)"""
|
||||
|
||||
@staticmethod
|
||||
def UTC() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@staticmethod
|
||||
def UTCStr(fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
return GTime.UTC().strftime(fmt)
|
||||
|
||||
@staticmethod
|
||||
def AddMinutes(minutes: int) -> datetime:
|
||||
return GTime.UTC() + timedelta(minutes=minutes)
|
||||
|
||||
@staticmethod
|
||||
def AddDays(days: int) -> datetime:
|
||||
return GTime.UTC() + timedelta(days=days)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
33
negodata/backend/config/config.docker.toml
Normal file
33
negodata/backend/config/config.docker.toml
Normal file
@ -0,0 +1,33 @@
|
||||
[WebServerConfig]
|
||||
server_name = "NegodataServer"
|
||||
port = 9400
|
||||
process_count = 4
|
||||
is_ssl = false
|
||||
is_test = true
|
||||
|
||||
[LogConfig]
|
||||
print_console = true
|
||||
log_level = "debug"
|
||||
|
||||
# docker-compose 네트워크에서는 DB 가 compose 밖에 있으므로 호스트의 DB 에 host.docker.internal 로 접근한다.
|
||||
[MainDBConfig]
|
||||
db_type = "postgresql"
|
||||
name = "negodata_db"
|
||||
write_host = "host.docker.internal"
|
||||
write_port = 5432
|
||||
write_id = "postgres"
|
||||
write_pw = "password"
|
||||
read_host = "host.docker.internal"
|
||||
read_port = 5432
|
||||
read_id = "postgres"
|
||||
read_pw = "password"
|
||||
show_log = false
|
||||
# 워커 4개 기준: (pool_size + max_overflow) x 2(R/W) x 4 = 320 < max_connections(500)
|
||||
pool_size = 20
|
||||
max_overflow = 20
|
||||
|
||||
[JwtToken]
|
||||
access_key = "docker_access_secret_key"
|
||||
refresh_key = "docker_refresh_secret_key"
|
||||
access_expire_min = 30
|
||||
refresh_expire_day = 7
|
||||
32
negodata/backend/config/config.local.toml
Normal file
32
negodata/backend/config/config.local.toml
Normal file
@ -0,0 +1,32 @@
|
||||
[WebServerConfig]
|
||||
server_name = "NegodataServer"
|
||||
port = 9400
|
||||
process_count = 1
|
||||
is_ssl = false
|
||||
is_test = true
|
||||
|
||||
[LogConfig]
|
||||
print_console = true
|
||||
log_level = "debug"
|
||||
|
||||
# DB Read/Write 분리. 단일 DB 환경에서는 read/write 동일 호스트로 설정.
|
||||
[MainDBConfig]
|
||||
db_type = "postgresql"
|
||||
name = "negodata_db"
|
||||
write_host = "127.0.0.1"
|
||||
write_port = 5432
|
||||
write_id = "postgres"
|
||||
write_pw = "password"
|
||||
read_host = "127.0.0.1"
|
||||
read_port = 5432
|
||||
read_id = "postgres"
|
||||
read_pw = "password"
|
||||
show_log = false
|
||||
pool_size = 100
|
||||
max_overflow = 200
|
||||
|
||||
[JwtToken]
|
||||
access_key = "CHANGE_ME_ACCESS_SECRET_KEY"
|
||||
refresh_key = "CHANGE_ME_REFRESH_SECRET_KEY"
|
||||
access_expire_min = 30
|
||||
refresh_expire_day = 7
|
||||
33
negodata/backend/config/config.test.toml
Normal file
33
negodata/backend/config/config.test.toml
Normal file
@ -0,0 +1,33 @@
|
||||
[WebServerConfig]
|
||||
server_name = "NegodataServer-Test"
|
||||
port = 9401
|
||||
process_count = 1
|
||||
is_ssl = false
|
||||
is_test = true
|
||||
|
||||
[LogConfig]
|
||||
print_console = true
|
||||
log_level = "debug"
|
||||
|
||||
# 테스트는 단일 postgres 의 negodata_db 를 사용한다.
|
||||
# pytest 가 tbl_account 를 TRUNCATE 로 비워 격리하므로, 운영 데이터가 있다면 주의.
|
||||
[MainDBConfig]
|
||||
db_type = "postgresql"
|
||||
name = "negodata_db"
|
||||
write_host = "127.0.0.1"
|
||||
write_port = 5432
|
||||
write_id = "postgres"
|
||||
write_pw = "password"
|
||||
read_host = "127.0.0.1"
|
||||
read_port = 5432
|
||||
read_id = "postgres"
|
||||
read_pw = "password"
|
||||
show_log = false
|
||||
pool_size = 100
|
||||
max_overflow = 200
|
||||
|
||||
[JwtToken]
|
||||
access_key = "test_access_secret_key"
|
||||
refresh_key = "test_refresh_secret_key"
|
||||
access_expire_min = 30
|
||||
refresh_expire_day = 7
|
||||
37
negodata/backend/config/config_loader.py
Normal file
37
negodata/backend/config/config_loader.py
Normal file
@ -0,0 +1,37 @@
|
||||
import tomllib
|
||||
from typing import Optional, Type, Dict, TypeVar
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConfigModel(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
# APP_ENV
|
||||
# local : 로컬 환경(개인 pc)
|
||||
# dev : 개발환경 (사내 pc)
|
||||
# prod : 서비스 환경 (클라우드 서버)
|
||||
#
|
||||
# 실행시 환경변수 설정
|
||||
# linux : export APP_ENV=dev
|
||||
# window : set APP_ENV=dev
|
||||
class Configs:
|
||||
ConfigType = TypeVar("ConfigType", bound=ConfigModel)
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self._settings: Dict[Type["Configs.ConfigType"], "Configs.ConfigType"] = self._load_settings_from_toml(file_path)
|
||||
|
||||
def _load_settings_from_toml(self, file_path: str) -> Dict[Type[ConfigType], ConfigType]:
|
||||
with open(file_path, "rb") as f:
|
||||
toml_content = tomllib.load(f)
|
||||
|
||||
config_subclasses = ConfigModel.__subclasses__()
|
||||
configs = {
|
||||
config_class: config_class.model_validate(toml_content[config_class.__name__])
|
||||
for config_class in config_subclasses
|
||||
if config_class.__name__ in toml_content
|
||||
}
|
||||
return configs
|
||||
|
||||
def get(self, config_class: Type[ConfigType]) -> Optional[ConfigType]:
|
||||
return self._settings.get(config_class)
|
||||
41
negodata/backend/config/config_models.py
Normal file
41
negodata/backend/config/config_models.py
Normal file
@ -0,0 +1,41 @@
|
||||
from config.config_loader import ConfigModel
|
||||
|
||||
|
||||
class WebServerConfig(ConfigModel):
|
||||
server_name: str = ""
|
||||
port: int = 0
|
||||
process_count: int = 1
|
||||
is_ssl: bool = False
|
||||
is_test: bool = False
|
||||
|
||||
|
||||
class LogConfig(ConfigModel):
|
||||
print_console: bool = True
|
||||
log_level: str = "debug"
|
||||
|
||||
|
||||
# DB Read/Write 분리 설정.
|
||||
# 하나의 논리 DB 에 대해 write(주) / read(복제) 접속 정보를 각각 가진다.
|
||||
class MainDBConfig(ConfigModel):
|
||||
db_type: str = "postgresql"
|
||||
name: str = ""
|
||||
write_host: str = ""
|
||||
write_port: int = 5432
|
||||
write_id: str = ""
|
||||
write_pw: str = ""
|
||||
read_host: str = ""
|
||||
read_port: int = 5432
|
||||
read_id: str = ""
|
||||
read_pw: str = ""
|
||||
show_log: bool = False
|
||||
# 커넥션 풀 사이징. 실제 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
|
||||
# PostgreSQL max_connections 를 넘지 않도록 설정해야 한다. (예: 10+20=30 x 2 x 5워커 = 300)
|
||||
pool_size: int = 10
|
||||
max_overflow: int = 20
|
||||
|
||||
|
||||
class JwtToken(ConfigModel):
|
||||
access_key: str = ""
|
||||
refresh_key: str = ""
|
||||
access_expire_min: int = 30
|
||||
refresh_expire_day: int = 7
|
||||
20
negodata/backend/config/server_configs.py
Normal file
20
negodata/backend/config/server_configs.py
Normal file
@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
from config.config_loader import Configs
|
||||
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken
|
||||
|
||||
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
|
||||
APP_ENV = os.environ.get("APP_ENV", "local")
|
||||
|
||||
_config_dir = os.path.dirname(__file__)
|
||||
_config_file = os.path.join(_config_dir, f"config.{APP_ENV}.toml")
|
||||
|
||||
if not os.path.exists(_config_file):
|
||||
raise FileNotFoundError(f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). config.<env>.toml 을 생성하세요.")
|
||||
|
||||
configs = Configs(_config_file)
|
||||
|
||||
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)
|
||||
54
negodata/backend/conftest.py
Normal file
54
negodata/backend/conftest.py
Normal file
@ -0,0 +1,54 @@
|
||||
# pytest 진입 시점에 가장 먼저 APP_ENV=test 를 설정해야 한다.
|
||||
# (config.server_configs 가 import 되는 순간 config.<APP_ENV>.toml 을 읽기 때문)
|
||||
import os
|
||||
|
||||
os.environ.setdefault("APP_ENV", "test")
|
||||
|
||||
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
|
||||
from config.server_configs import main_db_config
|
||||
|
||||
|
||||
def _write_url(cfg) -> str:
|
||||
pw = f":{cfg.write_pw}" if cfg.write_pw else ""
|
||||
return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/{cfg.name}"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_engine():
|
||||
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다.
|
||||
|
||||
앱(DB_SESSION_MNG)은 자체 엔진으로 같은 DB(config.test.toml)에 접속하므로,
|
||||
여기서 만든 스키마를 그대로 공유한다.
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def _dispose_app_engines():
|
||||
"""테스트 세션이 끝날 때 앱 싱글톤 엔진을 정리한다.
|
||||
(이벤트 루프 종료 후 커넥션이 GC 되며 나오는 'Event loop is closed' 경고 제거)
|
||||
"""
|
||||
yield
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_engine):
|
||||
"""앱을 실제 네트워크 없이 호출하는 httpx 클라이언트 (ASGITransport)."""
|
||||
from router.router import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
BIN
negodata/backend/crud/__pycache__/user_crud.cpython-314.pyc
Normal file
BIN
negodata/backend/crud/__pycache__/user_crud.cpython-314.pyc
Normal file
Binary file not shown.
75
negodata/backend/crud/user_crud.py
Normal file
75
negodata/backend/crud/user_crud.py
Normal file
@ -0,0 +1,75 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import 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.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
# CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다.
|
||||
# - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이).
|
||||
# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다.
|
||||
class IUserCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def is_account(self, cdb: AsyncSession, user_id: str) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_account(self, cdb: AsyncSession, account: tbl_account) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_last_login(self, cdb: AsyncSession, user_uid: int) -> ErrorType:
|
||||
pass
|
||||
|
||||
|
||||
class UserCRUD(IUserCRUD):
|
||||
async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]:
|
||||
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.")
|
||||
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, user_id: str) -> ErrorType:
|
||||
try:
|
||||
query = select(tbl_account).where(tbl_account.id == user_id).limit(1)
|
||||
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type
|
||||
if row_list:
|
||||
return ErrorType.DB_ALREADY_SAME_KEY
|
||||
return ErrorType.SUCCESS
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def add_account(self, cdb: AsyncSession, account: tbl_account) -> 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:
|
||||
try:
|
||||
query = update(tbl_account).where(tbl_account.uid == user_uid).values(last_login_at=GTime.UTC())
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
80
negodata/backend/loadtest/locustfile.py
Normal file
80
negodata/backend/loadtest/locustfile.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Negodata 인증 서버 부하 테스트.
|
||||
|
||||
실행:
|
||||
pip install locust
|
||||
locust -f loadtest/locustfile.py --host http://localhost:9400
|
||||
# 웹 UI: http://localhost:8089 에서 사용자 수/spawn rate 입력
|
||||
|
||||
# 헤드리스(CI) 예시 - 100 VU, 10/s 증가, 2분:
|
||||
locust -f loadtest/locustfile.py --host http://localhost:9400 \
|
||||
--headless -u 100 -r 10 -t 2m
|
||||
|
||||
주의:
|
||||
- /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 초과 시 실패한다.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from locust import HttpUser, between, events, task
|
||||
|
||||
|
||||
# 각 시뮬레이션 유저는 고유 계정을 만들어 로그인 흐름을 반복한다.
|
||||
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(
|
||||
"/v1/auth/create",
|
||||
json={"id": self.user_id, "pw": self.password, "nickname": "load"},
|
||||
name="POST /v1/auth/create",
|
||||
)
|
||||
self._login()
|
||||
|
||||
def _login(self):
|
||||
with self.client.post(
|
||||
"/v1/auth/login",
|
||||
json={"id": self.user_id, "pw": self.password},
|
||||
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")
|
||||
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:
|
||||
return
|
||||
self.client.get(
|
||||
"/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
name="GET /v1/auth/me",
|
||||
)
|
||||
|
||||
@task(2)
|
||||
def login(self):
|
||||
# bcrypt + DB write 가 포함된 무거운 경로.
|
||||
self._login()
|
||||
|
||||
@task(1)
|
||||
def healthz(self):
|
||||
# 베이스라인 (앱 오버헤드 측정).
|
||||
self.client.get("/healthz", name="GET /healthz")
|
||||
|
||||
|
||||
@events.test_start.add_listener
|
||||
def _on_start(environment, **kwargs):
|
||||
print("부하 테스트 시작 - PostgreSQL 커넥션 수 모니터링 권장 (pg_stat_activity)")
|
||||
9
negodata/backend/pytest.ini
Normal file
9
negodata/backend/pytest.ini
Normal file
@ -0,0 +1,9 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
# DB_SESSION_MNG(싱글톤)의 커넥션 풀이 첫 이벤트 루프에 묶이므로,
|
||||
# 모든 테스트/픽스처가 단일 session 루프를 공유하게 한다.
|
||||
asyncio_default_fixture_loop_scope = session
|
||||
asyncio_default_test_loop_scope = session
|
||||
testpaths = tests
|
||||
filterwarnings =
|
||||
ignore::pytest.PytestUnraisableExceptionWarning
|
||||
8
negodata/backend/requirements.txt
Normal file
8
negodata/backend/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy>=2.0
|
||||
asyncpg
|
||||
python-jose[cryptography]
|
||||
bcrypt
|
||||
orjson
|
||||
pydantic>=2.0
|
||||
BIN
negodata/backend/router/__pycache__/router.cpython-314.pyc
Normal file
BIN
negodata/backend/router/__pycache__/router.cpython-314.pyc
Normal file
Binary file not shown.
44
negodata/backend/router/router.py
Normal file
44
negodata/backend/router/router.py
Normal file
@ -0,0 +1,44 @@
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
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
|
||||
import router.v1.auth.account
|
||||
|
||||
API_SERVER_START_TIME = GTime.UTCStr()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# startup
|
||||
yield
|
||||
# shutdown: DB 엔진 커넥션 풀 정리
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
app = FastAPI(title="Negodata Api Server", lifespan=lifespan)
|
||||
|
||||
# Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축.
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_time(request: Request, call_next):
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
elapsed = time.time() - start_time
|
||||
LOG.d(f"took: {elapsed:.4f} - {request.url.path}")
|
||||
return response
|
||||
|
||||
|
||||
@app.get(path="/healthz", responses={404: {"description": "Not found"}})
|
||||
async def healthz():
|
||||
return API_SERVER_START_TIME
|
||||
|
||||
|
||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||
app.include_router(router.v1.auth.account.router)
|
||||
Binary file not shown.
Binary file not shown.
42
negodata/backend/router/v1/auth/account.py
Normal file
42
negodata/backend/router/v1/auth/account.py
Normal file
@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
|
||||
from services.auth_service import AuthService
|
||||
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_RefreshToken
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
|
||||
router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.post(path="/login", response_model=Res_Login, summary="로그인", description="id/pw 로 로그인하고 JWT 토큰을 발급한다.")
|
||||
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="새 계정을 생성한다.")
|
||||
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))
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/refresh_token",
|
||||
dependencies=[Depends(IsValidRefreshToken)],
|
||||
response_model=Res_RefreshToken,
|
||||
summary="액세스 토큰 갱신",
|
||||
description="refresh 토큰으로 access 토큰을 재발급한다.",
|
||||
)
|
||||
async def refresh_token(service: AuthService = Depends(), credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
return RemoveNoneResponse(await service.refresh_token(credentials.credentials))
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/me",
|
||||
summary="내 정보 (보호된 엔드포인트 예시)",
|
||||
description="유효한 access 토큰이 있어야 호출 가능. 토큰 검증 결과 UserInfo 를 주입받는다.",
|
||||
)
|
||||
async def me(user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return {"uid": user_info.uid, "id": user_info.id, "nickname": user_info.nickname}
|
||||
34
negodata/backend/router/v1/auth/protocol.py
Normal file
34
negodata/backend/router/v1/auth/protocol.py
Normal file
@ -0,0 +1,34 @@
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
# 라우터 폴더마다 protocol.py 를 두고 Req_/Res_ 를 정의한다 (protocol 규약).
|
||||
class AuthProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Req_Login(AuthProtocol):
|
||||
id: str = ""
|
||||
pw: str = ""
|
||||
|
||||
|
||||
class Res_Login(Res_WebPacketProtocol):
|
||||
uid: int = Field(0, description="user uid", json_schema_extra={"format": "int64"})
|
||||
nickname: str = ""
|
||||
access_token: str = ""
|
||||
refresh_token: str = ""
|
||||
|
||||
|
||||
class Req_CreateAccount(AuthProtocol):
|
||||
id: str = ""
|
||||
pw: str = ""
|
||||
nickname: str = ""
|
||||
|
||||
|
||||
class Res_CreateAccount(Res_WebPacketProtocol):
|
||||
uid: int = Field(0, description="생성된 user uid", json_schema_extra={"format": "int64"})
|
||||
|
||||
|
||||
class Res_RefreshToken(Res_WebPacketProtocol):
|
||||
access_token: str = ""
|
||||
Binary file not shown.
113
negodata/backend/router/v1/validator/dependencies.py
Normal file
113
negodata/backend/router/v1/validator/dependencies.py
Normal file
@ -0,0 +1,113 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Union
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
import bcrypt
|
||||
from jose import jwt, JWTError, ExpiredSignatureError
|
||||
|
||||
from common.enums import (
|
||||
EXCEPTION_ACCESS_TOKEN_EXPIRED,
|
||||
EXCEPTION_INVALID_CLIENT_ACCESS,
|
||||
EXCEPTION_REFRESH_TOKEN_EXPIRED,
|
||||
)
|
||||
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
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
# ---- 비밀번호 해시 (bcrypt) ------------------------------------------------
|
||||
# bcrypt 는 CPU 바운드 동기 작업이라 그대로 호출하면 asyncio 이벤트 루프를 막아
|
||||
# 같은 워커의 다른 요청(healthz 등)까지 멈춘다. 스레드풀(asyncio.to_thread)로 보낸다.
|
||||
# bcrypt 는 해싱 중 GIL 을 해제하므로 스레드들이 여러 코어에서 실제 병렬로 돈다.
|
||||
# 입력은 최대 72 bytes 까지만 사용하므로 사전에 잘라준다.
|
||||
def _hash_pw(pw: str) -> str:
|
||||
return bcrypt.hashpw(pw.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def _verify_pw(pw: str, hashed_pw: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(pw.encode("utf-8")[:72], hashed_pw.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
async def GetHashedPW(pw: str) -> str:
|
||||
return await asyncio.to_thread(_hash_pw, pw)
|
||||
|
||||
|
||||
async def VerifyPW(pw: str, hashed_pw: str) -> bool:
|
||||
return await asyncio.to_thread(_verify_pw, pw, hashed_pw)
|
||||
|
||||
|
||||
# ---- JWT 토큰 발급/검증 ----------------------------------------------------
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_ACCESS_SECRET = jwt_token_config.access_key
|
||||
JWT_REFRESH_SECRET = jwt_token_config.refresh_key
|
||||
ACCESS_TOKEN_EXPIRE_MIN = jwt_token_config.access_expire_min
|
||||
REFRESH_TOKEN_EXPIRE_MIN = 60 * 24 * jwt_token_config.refresh_expire_day
|
||||
|
||||
|
||||
def __create_token(subject: Union[str, Any], secret_key: str, expire_min: int) -> str:
|
||||
to_encode = {
|
||||
"sub": str(subject),
|
||||
"exp": GTime.AddMinutes(expire_min), # jose 가 exp 클레임을 자동 검증
|
||||
}
|
||||
return jwt.encode(to_encode, secret_key, JWT_ALGORITHM)
|
||||
|
||||
|
||||
def CreateAccessToken(subject: UserInfo) -> str:
|
||||
return __create_token(subject.to_json(), JWT_ACCESS_SECRET, ACCESS_TOKEN_EXPIRE_MIN)
|
||||
|
||||
|
||||
def CreateRefreshToken(subject: UserInfo) -> str:
|
||||
return __create_token(subject.to_json(), JWT_REFRESH_SECRET, REFRESH_TOKEN_EXPIRE_MIN)
|
||||
|
||||
|
||||
def __decode_token(jwt_token: str, secret_key: str, expired_exception) -> UserInfo:
|
||||
try:
|
||||
decoded = jwt.decode(jwt_token, secret_key, algorithms=[JWT_ALGORITHM])
|
||||
return UserInfo(**json.loads(decoded.get("sub")))
|
||||
except ExpiredSignatureError:
|
||||
raise expired_exception
|
||||
except JWTError as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
raise EXCEPTION_INVALID_CLIENT_ACCESS
|
||||
|
||||
|
||||
def DecodeAccessToken(jwt_token: str) -> UserInfo:
|
||||
return __decode_token(jwt_token, JWT_ACCESS_SECRET, EXCEPTION_ACCESS_TOKEN_EXPIRED)
|
||||
|
||||
|
||||
def DecodeRefreshToken(jwt_token: str) -> UserInfo:
|
||||
return __decode_token(jwt_token, JWT_REFRESH_SECRET, EXCEPTION_REFRESH_TOKEN_EXPIRED)
|
||||
|
||||
|
||||
# ---- Depends 용 토큰 검증기 ------------------------------------------------
|
||||
# 보호된 엔드포인트에서 dependencies=[Depends(IsValidAccessToken)] 로 사용.
|
||||
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
|
||||
return DecodeAccessToken(credentials.credentials)
|
||||
|
||||
|
||||
async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
|
||||
return DecodeRefreshToken(credentials.credentials)
|
||||
|
||||
|
||||
# ---- ResponseNone 처리 -----------------------------------------------------
|
||||
# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다.
|
||||
# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다.
|
||||
def RemoveNoneValues(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {k: RemoveNoneValues(v) for k, v in obj.items() if v is not None}
|
||||
if isinstance(obj, list):
|
||||
return [RemoveNoneValues(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def RemoveNoneResponse(obj) -> ORJSONResponse:
|
||||
return ORJSONResponse(content=RemoveNoneValues(obj.model_dump()))
|
||||
Binary file not shown.
115
negodata/backend/services/auth_service.py
Normal file
115
negodata/backend/services/auth_service.py
Normal file
@ -0,0 +1,115 @@
|
||||
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.logger import LOG
|
||||
from common.models.gmodel import UserInfo
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""비즈니스 로직 계층 (MVC 의 컨트롤러-서비스 분리에서 서비스).
|
||||
|
||||
- CRUD 는 Depends 로 인터페이스 타입으로 주입받는다.
|
||||
- DB 접근은 DB_SESSION_MNG 의 람다 실행으로만 한다.
|
||||
조회 = execute_lambda(..., DB_READ, lambda s: crud.xxx(s, ...))
|
||||
변경 = execute_lambda_run([DBType], [lambda s: crud.xxx(s, ...)])
|
||||
- 모든 메서드는 Res_* 를 만들어 result 에 ErrorType 을 세팅해 반환한다.
|
||||
"""
|
||||
|
||||
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=}")
|
||||
res = Res_CreateAccount()
|
||||
|
||||
# 1) 중복 ID 확인 (Read DB)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda(
|
||||
tbl_account.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.is_account(s, id),
|
||||
)
|
||||
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
|
||||
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
|
||||
return res
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 2) 계정 생성 (비밀번호는 bcrypt 해시로 저장)
|
||||
account = tbl_account(id=id, pw=await GetHashedPW(pw), nickname=nickname or id)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[tbl_account.DBType()],
|
||||
[lambda s: self.user_crud.add_account(s, account)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
# 사전 검사와 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
|
||||
return res
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
|
||||
res = Res_RefreshToken()
|
||||
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨.
|
||||
user_info = DecodeRefreshToken(refresh_token)
|
||||
res.access_token = CreateAccessToken(user_info)
|
||||
return res
|
||||
Binary file not shown.
63
negodata/backend/tests/test_auth.py
Normal file
63
negodata/backend/tests/test_auth.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""auth 도메인 e2e 테스트.
|
||||
|
||||
실행 전제: docker-compose 로 PostgreSQL 이 떠 있어야 한다 (negodata_db 사용).
|
||||
docker compose up -d # 또는 로컬 postgres
|
||||
cd negodata/backend && python -m pytest
|
||||
"""
|
||||
|
||||
|
||||
async def test_create_and_login_flow(client):
|
||||
# 1) 계정 생성
|
||||
r = await client.post("/v1/auth/create", json={"id": "user1", "pw": "pw1234", "nickname": "닉네임"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is True
|
||||
assert body["uid"] > 0
|
||||
|
||||
# 2) 로그인 -> 토큰 발급
|
||||
r = await client.post("/v1/auth/login", json={"id": "user1", "pw": "pw1234"})
|
||||
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"
|
||||
|
||||
|
||||
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"})
|
||||
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", "") == "" # 실패 시 토큰은 빈 문자열
|
||||
|
||||
|
||||
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()
|
||||
assert body["result"]["success"] is False
|
||||
# ACCOUNT_ALREADY_EXIST(1201)
|
||||
assert body["result"]["code"] == 1201
|
||||
|
||||
|
||||
async def test_me_without_token_is_rejected(client):
|
||||
r = await client.get("/v1/auth/me")
|
||||
assert r.status_code in (401, 403) # HTTPBearer 가 자격증명 없음을 거부
|
||||
41
negodata/backend/web_main.py
Normal file
41
negodata/backend/web_main.py
Normal file
@ -0,0 +1,41 @@
|
||||
# 실행 방법
|
||||
# pip install -r requirements.txt
|
||||
# python web_main.py # 기본 local 환경
|
||||
# APP_ENV=dev python web_main.py # 환경 지정
|
||||
#
|
||||
# 또는 uvicorn 직접 실행:
|
||||
# uvicorn router.router:app --reload --host=0.0.0.0 --port=9400
|
||||
|
||||
import uvicorn
|
||||
|
||||
from common.logger import LOG
|
||||
from config.server_configs import web_server_config
|
||||
|
||||
LOG.SetPrefix(web_server_config.server_name)
|
||||
|
||||
# import 시점에 app 및 DB 세션 매니저(싱글톤)가 초기화된다.
|
||||
import router.router
|
||||
|
||||
if __name__ == "__main__":
|
||||
LOG.i(f"Server Name : {web_server_config.server_name}")
|
||||
LOG.i(f"Server Port : {web_server_config.port}")
|
||||
LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}")
|
||||
|
||||
if web_server_config.is_ssl:
|
||||
uvicorn.run(
|
||||
"router.router:app",
|
||||
host="0.0.0.0",
|
||||
port=web_server_config.port,
|
||||
access_log=False,
|
||||
workers=web_server_config.process_count,
|
||||
ssl_keyfile="./SSL/key.pem",
|
||||
ssl_certfile="./SSL/cert.pem",
|
||||
)
|
||||
else:
|
||||
uvicorn.run(
|
||||
"router.router:app",
|
||||
host="0.0.0.0",
|
||||
port=web_server_config.port,
|
||||
access_log=False,
|
||||
workers=web_server_config.process_count,
|
||||
)
|
||||
1
negodata/front/README.md
Normal file
1
negodata/front/README.md
Normal file
@ -0,0 +1 @@
|
||||
마지막 내 도리는 하자 .
|
||||
32
postgres-init/01-schema.sql
Normal file
32
postgres-init/01-schema.sql
Normal file
@ -0,0 +1,32 @@
|
||||
-- 단일 PostgreSQL 인스턴스에 여러 서비스의 database 를 함께 둔다.
|
||||
-- 컨테이너 최초 기동 시 기본 DB(postgres)에 연결된 상태로 1회 실행된다.
|
||||
--
|
||||
-- postgres (1개 서버, 5432)
|
||||
-- ├── negosium_db (negosium 운영)
|
||||
-- └── negodata_db (negodata 운영)
|
||||
|
||||
CREATE DATABASE negosium_db;
|
||||
CREATE DATABASE negodata_db;
|
||||
|
||||
\connect negosium_db
|
||||
CREATE TABLE IF NOT EXISTS tbl_account (
|
||||
uid SERIAL PRIMARY KEY,
|
||||
id VARCHAR(45) NOT NULL UNIQUE,
|
||||
pw VARCHAR(255) NOT NULL DEFAULT '',
|
||||
nickname VARCHAR(45) NOT NULL DEFAULT '',
|
||||
is_blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_login_at TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'utc'),
|
||||
create_at TIMESTAMP DEFAULT (now() AT TIME ZONE 'utc')
|
||||
);
|
||||
|
||||
|
||||
\connect negodata_db
|
||||
CREATE TABLE IF NOT EXISTS tbl_account (
|
||||
uid SERIAL PRIMARY KEY,
|
||||
id VARCHAR(45) NOT NULL UNIQUE,
|
||||
pw VARCHAR(255) NOT NULL DEFAULT '',
|
||||
nickname VARCHAR(45) NOT NULL DEFAULT '',
|
||||
is_blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_login_at TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'utc'),
|
||||
create_at TIMESTAMP DEFAULT (now() AT TIME ZONE 'utc')
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user