From d114fb4940af6c3ac4385ec7ccddc3defcf36b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:31:29 +0900 Subject: [PATCH 01/14] =?UTF-8?q?fix:=20ai=5Fcredential=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=9D=98=20service=5Fname=EC=9D=84=20UNIQUE?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/init_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/db/init_db.py b/app/db/init_db.py index 0b8fa58..79fa774 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -48,7 +48,7 @@ def initialize_database(): """ CREATE TABLE IF NOT EXISTS ai_credential ( id VARCHAR(64) PRIMARY KEY NOT NULL, - service_name VARCHAR(32) NOT NULL, + service_name VARCHAR(32) NOT NULL UNIQUE, api_key VARCHAR(256) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP From b16009313d1b715732c2352d5fdaf991ed9d648a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:35:43 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20api=20key=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EC=8B=9C=EC=97=90=20=EC=82=AC=EC=9A=A9=EB=90=A0=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/llm_api_key.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 app/schemas/llm_api_key.py diff --git a/app/schemas/llm_api_key.py b/app/schemas/llm_api_key.py new file mode 100644 index 0000000..ea6b452 --- /dev/null +++ b/app/schemas/llm_api_key.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ApiKeyCredentialBase(BaseModel): + service_name: str = Field(..., description="외부 서비스 이름 (예: OpenAI, Anthropic)") + + +class ApiKeyCredentialCreate(ApiKeyCredentialBase): + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + +class ApiKeyCredentialInDB(ApiKeyCredentialBase): + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ApiKeyCredentialResponse(ApiKeyCredentialBase): + id: str + api_key_encrypted: str = Field(..., description="암호화된 API Key") + created_at: datetime + updated_at: datetime From e52450d2f91237378bcf1ea1161ca673f1d29503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:41:14 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20DB=5FBUSY=EB=A5=BC=20=EB=8B=A4?= =?UTF-8?q?=EB=A3=B0=20status=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/core/status.py b/app/core/status.py index 7fe3fb1..3d77659 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -55,6 +55,11 @@ class CommonCode(Enum): # ================================== """ 기본 서버 오류 코드 - 50xx """ FAIL = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5000", "서버 처리 중 오류가 발생했습니다.") + DB_BUSY = ( + status.HTTP_503_SERVICE_UNAVAILABLE, + "5001", + "데이터베이스가 현재 사용 중입니다. 잠시 후 다시 시도해주세요.", + ) """ DRIVER, DB 서버 오류 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 오류가 발생했습니다.") From 50d2c6ca82ed54a8413d813487af6e08264c95da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:42:38 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/api_key/store_api_key_service.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 app/services/api_key/store_api_key_service.py diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key/store_api_key_service.py new file mode 100644 index 0000000..dce1200 --- /dev/null +++ b/app/services/api_key/store_api_key_service.py @@ -0,0 +1,52 @@ +import sqlite3 + +from app.core.exceptions import APIException +from app.core.security import AES256 +from app.core.status import CommonCode +from app.core.utils import generate_uuid, get_db_path +from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialInDB + + +def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialInDB: + """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" + + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_uuid() + + db_path = get_db_path() + conn = None + try: + # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO ai_credential (id, service_name, api_key) + VALUES (?, ?, ?) + """, + (new_id, credential_data.service_name, encrypted_key), + ) + conn.commit() + + cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") + + return ApiKeyCredentialInDB.model_validate(dict(created_row)) + + except sqlite3.IntegrityError as e: + # UNIQUE 제약 조건 위반 (service_name) + raise APIException(CommonCode.DUPLICATION) from e + except sqlite3.Error as e: + # "database is locked" 오류를 명시적으로 처리 + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From 99d83dc4cfc41ddbdcd05def8da284f1dbe48151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:56:38 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20AI=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EC=A0=9C=EA=B3=B5=EC=82=AC=20enum=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/llm_service.py | 11 +++++++++++ app/schemas/llm_api_key.py | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 app/core/enum/llm_service.py diff --git a/app/core/enum/llm_service.py b/app/core/enum/llm_service.py new file mode 100644 index 0000000..538948f --- /dev/null +++ b/app/core/enum/llm_service.py @@ -0,0 +1,11 @@ +# app/core/enum/llm_service.py +from enum import Enum + + +class LLMServiceEnum(str, Enum): + """지원하는 외부 LLM 서비스 목록""" + + OPENAI = "OpenAI" + ANTHROPIC = "Anthropic" + GEMINI = "Gemini" + # TODO: 다른 지원 서비스를 여기에 추가 diff --git a/app/schemas/llm_api_key.py b/app/schemas/llm_api_key.py index ea6b452..c7eb43e 100644 --- a/app/schemas/llm_api_key.py +++ b/app/schemas/llm_api_key.py @@ -2,9 +2,11 @@ from pydantic import BaseModel, Field +from app.core.enum.llm_service import LLMServiceEnum + class ApiKeyCredentialBase(BaseModel): - service_name: str = Field(..., description="외부 서비스 이름 (예: OpenAI, Anthropic)") + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") class ApiKeyCredentialCreate(ApiKeyCredentialBase): From 62744fd64c2cab8980621b492201d252b9c1cede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:57:46 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20api=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/store_api_key_api.py | 39 ++++++++++++++++++++++++++++ app/core/status.py | 1 + 2 files changed, 40 insertions(+) create mode 100644 app/api/api_key/store_api_key_api.py diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key/store_api_key_api.py new file mode 100644 index 0000000..79baddd --- /dev/null +++ b/app/api/api_key/store_api_key_api.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter + +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialResponse +from app.services.api_key import store_api_key_service + +router = APIRouter() + + +@router.post( + "/actions", + response_model=ResponseMessage[ApiKeyCredentialResponse], + summary="API KEY 저장 (처음 한 번)", + description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", +) +def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage: + """ + - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") + - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") + """ + + # 우선은 간단하게 존재 여부와 공백 여부로 검증 + # TODO: 검증 로직 강화 + if not credential.api_key or credential.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + created_credential = store_api_key_service.store_api_key(credential) + + response_data = ApiKeyCredentialResponse( + id=created_credential.id, + service_name=created_credential.service_name.value, + api_key_encrypted=created_credential.api_key, + created_at=created_credential.created_at, + updated_at=created_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) diff --git a/app/core/status.py b/app/core/status.py index 3d77659..1e9278b 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -43,6 +43,7 @@ class CommonCode(Enum): NO_DB_DRIVER = (status.HTTP_400_BAD_REQUEST, "4101", "데이터베이스는 필수 값입니다.") """ KEY 클라이언트 오류 코드 - 42xx """ + INVALID_API_KEY_FORMAT = (status.HTTP_400_BAD_REQUEST, "4200", "API 키의 형식이 올바르지 않습니다.") """ AI CHAT, DB 클라이언트 오류 코드 - 43xx """ From 37121069592158b45e3f7c1dccefd2692f00f91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:58:09 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 2 ++ app/services/api_key/store_api_key_service.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/api/api_router.py b/app/api/api_router.py index c7238e1..d6bb494 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from app.api import driver_api, test_api, user_db_api +from app.api.api_key import store_api_key_api api_router = APIRouter() @@ -12,3 +13,4 @@ # 라우터 api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) +api_router.include_router(store_api_key_api.router, prefix="/credentials", tags=["Credentials"]) diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key/store_api_key_service.py index dce1200..bd8bdc4 100644 --- a/app/services/api_key/store_api_key_service.py +++ b/app/services/api_key/store_api_key_service.py @@ -3,7 +3,7 @@ from app.core.exceptions import APIException from app.core.security import AES256 from app.core.status import CommonCode -from app.core.utils import generate_uuid, get_db_path +from app.core.utils import generate_prefixed_uuid, get_db_path from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialInDB @@ -11,7 +11,7 @@ def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialIn """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_uuid() + new_id = generate_prefixed_uuid() db_path = get_db_path() conn = None From 8ef0deb43961412ee6267fd319642684e141bbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 6 Aug 2025 19:12:32 +0900 Subject: [PATCH 08/14] =?UTF-8?q?refactor:=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=A7=A4=EC=B9=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/store_api_key_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key/store_api_key_api.py index 79baddd..92ccc27 100644 --- a/app/api/api_key/store_api_key_api.py +++ b/app/api/api_key/store_api_key_api.py @@ -15,7 +15,7 @@ summary="API KEY 저장 (처음 한 번)", description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", ) -def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage: +def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage[ApiKeyCredentialResponse]: """ - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") From c68c7ed28626eca6213878ceba501dda75c15d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 6 Aug 2025 19:26:10 +0900 Subject: [PATCH 09/14] =?UTF-8?q?refactor:=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EB=AA=A8=EB=8D=B8=EC=97=90=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/store_api_key_api.py | 7 ------- app/schemas/llm_api_key.py | 9 ++++++++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key/store_api_key_api.py index 92ccc27..ac443c3 100644 --- a/app/api/api_key/store_api_key_api.py +++ b/app/api/api_key/store_api_key_api.py @@ -1,6 +1,5 @@ from fastapi import APIRouter -from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialResponse @@ -20,12 +19,6 @@ def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage[ApiKeyC - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") """ - - # 우선은 간단하게 존재 여부와 공백 여부로 검증 - # TODO: 검증 로직 강화 - if not credential.api_key or credential.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - created_credential = store_api_key_service.store_api_key(credential) response_data = ApiKeyCredentialResponse( diff --git a/app/schemas/llm_api_key.py b/app/schemas/llm_api_key.py index c7eb43e..4c5f2f1 100644 --- a/app/schemas/llm_api_key.py +++ b/app/schemas/llm_api_key.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from app.core.enum.llm_service import LLMServiceEnum @@ -12,6 +12,13 @@ class ApiKeyCredentialBase(BaseModel): class ApiKeyCredentialCreate(ApiKeyCredentialBase): api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + @field_validator("api_key", mode="after") + @classmethod + def validate_api_key(cls, v: str) -> str: + if not v or v.isspace(): + raise ValueError("API key cannot be empty or just whitespace.") + return v + class ApiKeyCredentialInDB(ApiKeyCredentialBase): id: str From f69b4a236ad9816818bad4d66edcefc37a38d149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 6 Aug 2025 19:30:20 +0900 Subject: [PATCH 10/14] =?UTF-8?q?refactor:=20=EB=AA=85=ED=99=95=ED=95=9C?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=BD=94=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 7 ++++++- app/services/api_key/store_api_key_service.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 1e9278b..701f1bf 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -61,7 +61,12 @@ class CommonCode(Enum): "5001", "데이터베이스가 현재 사용 중입니다. 잠시 후 다시 시도해주세요.", ) - + FAIL_TO_VERIFY_CREATION = ( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "5002", + "데이터 생성 후 검증 과정에서 오류가 발생했습니다.", + ) + """ DRIVER, DB 서버 오류 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 오류가 발생했습니다.") diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key/store_api_key_service.py index bd8bdc4..c176623 100644 --- a/app/services/api_key/store_api_key_service.py +++ b/app/services/api_key/store_api_key_service.py @@ -34,7 +34,7 @@ def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialIn created_row = cursor.fetchone() if not created_row: - raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") + raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) return ApiKeyCredentialInDB.model_validate(dict(created_row)) From f8f19276962db62b7a0dc3b64329cb3157136ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 11:15:14 +0900 Subject: [PATCH 11/14] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B0=8F=20=ED=8F=B4=EB=8D=94=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store_api_key_api.py => api_key_api.py} | 13 ++++--- app/api/api_router.py | 5 +-- app/schemas/api_key/base_model.py | 10 +++++ app/schemas/api_key/create_model.py | 17 +++++++++ app/schemas/api_key/db_model.py | 16 ++++++++ app/schemas/api_key/response_model.py | 15 ++++++++ app/schemas/llm_api_key.py | 37 ------------------- ..._api_key_service.py => api_key_service.py} | 9 +++-- 8 files changed, 72 insertions(+), 50 deletions(-) rename app/api/{api_key/store_api_key_api.py => api_key_api.py} (66%) create mode 100644 app/schemas/api_key/base_model.py create mode 100644 app/schemas/api_key/create_model.py create mode 100644 app/schemas/api_key/db_model.py create mode 100644 app/schemas/api_key/response_model.py delete mode 100644 app/schemas/llm_api_key.py rename app/services/{api_key/store_api_key_service.py => api_key_service.py} (84%) diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key_api.py similarity index 66% rename from app/api/api_key/store_api_key_api.py rename to app/api/api_key_api.py index ac443c3..f0e0142 100644 --- a/app/api/api_key/store_api_key_api.py +++ b/app/api/api_key_api.py @@ -2,26 +2,27 @@ from app.core.response import ResponseMessage from app.core.status import CommonCode -from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialResponse -from app.services.api_key import store_api_key_service +from app.schemas.api_key.create_model import APIKeyCreate +from app.schemas.api_key.response_model import APIKeyResponse +from app.services import api_key_service router = APIRouter() @router.post( "/actions", - response_model=ResponseMessage[ApiKeyCredentialResponse], + response_model=ResponseMessage[APIKeyResponse], summary="API KEY 저장 (처음 한 번)", description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", ) -def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage[ApiKeyCredentialResponse]: +def store_api_key(credential: APIKeyCreate) -> ResponseMessage[APIKeyResponse]: """ - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") """ - created_credential = store_api_key_service.store_api_key(credential) + created_credential = api_key_service.store_api_key(credential) - response_data = ApiKeyCredentialResponse( + response_data = APIKeyResponse( id=created_credential.id, service_name=created_credential.service_name.value, api_key_encrypted=created_credential.api_key, diff --git a/app/api/api_router.py b/app/api/api_router.py index d6bb494..6964eb9 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -2,8 +2,7 @@ from fastapi import APIRouter -from app.api import driver_api, test_api, user_db_api -from app.api.api_key import store_api_key_api +from app.api import api_key_api, driver_api, test_api, user_db_api api_router = APIRouter() @@ -13,4 +12,4 @@ # 라우터 api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) -api_router.include_router(store_api_key_api.router, prefix="/credentials", tags=["Credentials"]) +api_router.include_router(api_key_api.router, prefix="/credentials", tags=["Credentials"]) diff --git a/app/schemas/api_key/base_model.py b/app/schemas/api_key/base_model.py new file mode 100644 index 0000000..a8f2b8d --- /dev/null +++ b/app/schemas/api_key/base_model.py @@ -0,0 +1,10 @@ +# app/schemas/api_key/base_model.py +from pydantic import BaseModel, Field + +from app.core.enum.llm_service import LLMServiceEnum + + +class APIKeyBase(BaseModel): + """API Key 도메인의 모든 스키마가 상속하는 기본 모델""" + + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") diff --git a/app/schemas/api_key/create_model.py b/app/schemas/api_key/create_model.py new file mode 100644 index 0000000..bf45a1c --- /dev/null +++ b/app/schemas/api_key/create_model.py @@ -0,0 +1,17 @@ +# app/schemas/api_key/create_model.py +from pydantic import Field, field_validator + +from app.schemas.api_key.base_model import APIKeyBase + + +class APIKeyCreate(APIKeyBase): + """API Key 생성을 위한 스키마""" + + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + @field_validator("api_key", mode="after") + @classmethod + def validate_api_key(cls, v: str) -> str: + if not v or v.isspace(): + raise ValueError("API key cannot be empty or just whitespace.") + return v diff --git a/app/schemas/api_key/db_model.py b/app/schemas/api_key/db_model.py new file mode 100644 index 0000000..6156b98 --- /dev/null +++ b/app/schemas/api_key/db_model.py @@ -0,0 +1,16 @@ +# app/schemas/api_key/db_model.py +from datetime import datetime + +from app.schemas.api_key.base_model import APIKeyBase + + +class APIKeyInDB(APIKeyBase): + """데이터베이스에 저장된 형태의 스키마 (내부용)""" + + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/schemas/api_key/response_model.py b/app/schemas/api_key/response_model.py new file mode 100644 index 0000000..0b1356b --- /dev/null +++ b/app/schemas/api_key/response_model.py @@ -0,0 +1,15 @@ +# app/schemas/api_key/response_model.py +from datetime import datetime + +from pydantic import Field + +from app.schemas.api_key.base_model import APIKeyBase + + +class APIKeyResponse(APIKeyBase): + """API 응답용 스키마""" + + id: str + api_key_encrypted: str = Field(..., description="암호화된 API Key") + created_at: datetime + updated_at: datetime diff --git a/app/schemas/llm_api_key.py b/app/schemas/llm_api_key.py deleted file mode 100644 index 4c5f2f1..0000000 --- a/app/schemas/llm_api_key.py +++ /dev/null @@ -1,37 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field, field_validator - -from app.core.enum.llm_service import LLMServiceEnum - - -class ApiKeyCredentialBase(BaseModel): - service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") - - -class ApiKeyCredentialCreate(ApiKeyCredentialBase): - api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - - @field_validator("api_key", mode="after") - @classmethod - def validate_api_key(cls, v: str) -> str: - if not v or v.isspace(): - raise ValueError("API key cannot be empty or just whitespace.") - return v - - -class ApiKeyCredentialInDB(ApiKeyCredentialBase): - id: str - api_key: str # DB 모델에서는 암호화된 키를 의미 - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class ApiKeyCredentialResponse(ApiKeyCredentialBase): - id: str - api_key_encrypted: str = Field(..., description="암호화된 API Key") - created_at: datetime - updated_at: datetime diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key_service.py similarity index 84% rename from app/services/api_key/store_api_key_service.py rename to app/services/api_key_service.py index c176623..b950b2e 100644 --- a/app/services/api_key/store_api_key_service.py +++ b/app/services/api_key_service.py @@ -4,14 +4,15 @@ from app.core.security import AES256 from app.core.status import CommonCode from app.core.utils import generate_prefixed_uuid, get_db_path -from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialInDB +from app.schemas.api_key.create_model import APIKeyCreate +from app.schemas.api_key.db_model import APIKeyInDB -def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialInDB: +def store_api_key(credential_data: APIKeyCreate) -> APIKeyInDB: """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_uuid() + new_id = generate_prefixed_uuid("QGENIE") db_path = get_db_path() conn = None @@ -36,7 +37,7 @@ def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialIn if not created_row: raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) - return ApiKeyCredentialInDB.model_validate(dict(created_row)) + return APIKeyInDB.model_validate(dict(created_row)) except sqlite3.IntegrityError as e: # UNIQUE 제약 조건 위반 (service_name) From 492ab25f6cfe72ff0ae4cd544ec3bd2dabb4eacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 11:30:23 +0900 Subject: [PATCH 12/14] =?UTF-8?q?refactor:=20repository=20->=20service=20-?= =?UTF-8?q?>=20api=20&=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key_api.py | 12 ++-- app/repository/api_key_repository.py | 42 ++++++++++++++ app/schemas/api_key/base_model.py | 1 - app/schemas/api_key/create_model.py | 1 - app/schemas/api_key/db_model.py | 1 - app/schemas/api_key/response_model.py | 1 - app/services/api_key_service.py | 82 +++++++++++++-------------- 7 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 app/repository/api_key_repository.py diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index f0e0142..15a66ef 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -1,10 +1,12 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.api_key.create_model import APIKeyCreate from app.schemas.api_key.response_model import APIKeyResponse -from app.services import api_key_service +from app.services.api_key_service import APIKeyService, api_key_service + +api_key_service_dependency = Depends(lambda: api_key_service) router = APIRouter() @@ -15,12 +17,14 @@ summary="API KEY 저장 (처음 한 번)", description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", ) -def store_api_key(credential: APIKeyCreate) -> ResponseMessage[APIKeyResponse]: +def store_api_key( + credential: APIKeyCreate, service: APIKeyService = api_key_service_dependency +) -> ResponseMessage[APIKeyResponse]: """ - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") """ - created_credential = api_key_service.store_api_key(credential) + created_credential = service.store_api_key(credential) response_data = APIKeyResponse( id=created_credential.id, diff --git a/app/repository/api_key_repository.py b/app/repository/api_key_repository.py new file mode 100644 index 0000000..231de98 --- /dev/null +++ b/app/repository/api_key_repository.py @@ -0,0 +1,42 @@ +import sqlite3 + +from app.core.utils import get_db_path +from app.schemas.api_key.db_model import APIKeyInDB + + +class APIKeyRepository: + def create_api_key(self, new_id: str, service_name: str, encrypted_key: str) -> APIKeyInDB: + """ + 암호화된 API Key 정보를 받아 데이터베이스에 저장하고, + 저장된 객체를 반환합니다. + """ + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO ai_credential (id, service_name, api_key) + VALUES (?, ?, ?) + """, + (new_id, service_name, encrypted_key), + ) + conn.commit() + + cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + return None + + return APIKeyInDB.model_validate(dict(created_row)) + + finally: + if conn: + conn.close() + + +api_key_repository = APIKeyRepository() diff --git a/app/schemas/api_key/base_model.py b/app/schemas/api_key/base_model.py index a8f2b8d..8a4a840 100644 --- a/app/schemas/api_key/base_model.py +++ b/app/schemas/api_key/base_model.py @@ -1,4 +1,3 @@ -# app/schemas/api_key/base_model.py from pydantic import BaseModel, Field from app.core.enum.llm_service import LLMServiceEnum diff --git a/app/schemas/api_key/create_model.py b/app/schemas/api_key/create_model.py index bf45a1c..8803847 100644 --- a/app/schemas/api_key/create_model.py +++ b/app/schemas/api_key/create_model.py @@ -1,4 +1,3 @@ -# app/schemas/api_key/create_model.py from pydantic import Field, field_validator from app.schemas.api_key.base_model import APIKeyBase diff --git a/app/schemas/api_key/db_model.py b/app/schemas/api_key/db_model.py index 6156b98..251c95c 100644 --- a/app/schemas/api_key/db_model.py +++ b/app/schemas/api_key/db_model.py @@ -1,4 +1,3 @@ -# app/schemas/api_key/db_model.py from datetime import datetime from app.schemas.api_key.base_model import APIKeyBase diff --git a/app/schemas/api_key/response_model.py b/app/schemas/api_key/response_model.py index 0b1356b..fa3a090 100644 --- a/app/schemas/api_key/response_model.py +++ b/app/schemas/api_key/response_model.py @@ -1,4 +1,3 @@ -# app/schemas/api_key/response_model.py from datetime import datetime from pydantic import Field diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index b950b2e..f36db09 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -1,53 +1,47 @@ import sqlite3 +from fastapi import Depends + from app.core.exceptions import APIException from app.core.security import AES256 from app.core.status import CommonCode -from app.core.utils import generate_prefixed_uuid, get_db_path +from app.core.utils import generate_prefixed_uuid +from app.repository.api_key_repository import APIKeyRepository, api_key_repository from app.schemas.api_key.create_model import APIKeyCreate from app.schemas.api_key.db_model import APIKeyInDB +api_key_repository_dependency = Depends(lambda: api_key_repository) + + +class APIKeyService: + def store_api_key( + self, credential_data: APIKeyCreate, repository: APIKeyRepository = api_key_repository + ) -> APIKeyInDB: + """API_KEY를 암호화하고 repository를 통해 데이터베이스에 저장합니다.""" + try: + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_prefixed_uuid("QGENIE") + + created_row = repository.create_api_key( + new_id=new_id, + service_name=credential_data.service_name.value, + encrypted_key=encrypted_key, + ) + + if not created_row: + raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) + + return created_row + + except sqlite3.IntegrityError as e: + # UNIQUE 제약 조건 위반 (service_name) + raise APIException(CommonCode.DUPLICATION) from e + except sqlite3.Error as e: + # "database is locked" 오류를 명시적으로 처리 + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + -def store_api_key(credential_data: APIKeyCreate) -> APIKeyInDB: - """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" - - encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_uuid("QGENIE") - - db_path = get_db_path() - conn = None - try: - # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute( - """ - INSERT INTO ai_credential (id, service_name, api_key) - VALUES (?, ?, ?) - """, - (new_id, credential_data.service_name, encrypted_key), - ) - conn.commit() - - cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) - created_row = cursor.fetchone() - - if not created_row: - raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) - - return APIKeyInDB.model_validate(dict(created_row)) - - except sqlite3.IntegrityError as e: - # UNIQUE 제약 조건 위반 (service_name) - raise APIException(CommonCode.DUPLICATION) from e - except sqlite3.Error as e: - # "database is locked" 오류를 명시적으로 처리 - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - # 기타 모든 sqlite3 오류 - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() +api_key_service = APIKeyService() From 3e920e4fbe4731a90dbf4b5bfc16f0f7c137af07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 13:33:01 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EA=B3=84=EC=B8=B5=EA=B3=BC=20=EA=B5=AC=EB=B6=84=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=B4=20llm=5Fservice=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/{llm_service.py => llm_service_info.py} | 1 - app/schemas/api_key/base_model.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) rename app/core/enum/{llm_service.py => llm_service_info.py} (88%) diff --git a/app/core/enum/llm_service.py b/app/core/enum/llm_service_info.py similarity index 88% rename from app/core/enum/llm_service.py rename to app/core/enum/llm_service_info.py index 538948f..c568e8d 100644 --- a/app/core/enum/llm_service.py +++ b/app/core/enum/llm_service_info.py @@ -1,4 +1,3 @@ -# app/core/enum/llm_service.py from enum import Enum diff --git a/app/schemas/api_key/base_model.py b/app/schemas/api_key/base_model.py index 8a4a840..bb2c3f0 100644 --- a/app/schemas/api_key/base_model.py +++ b/app/schemas/api_key/base_model.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field -from app.core.enum.llm_service import LLMServiceEnum +from app.core.enum.llm_service_info import LLMServiceEnum class APIKeyBase(BaseModel): From 3fb1906b8326e79687b0849893e6c9ebe545a999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 13:42:29 +0900 Subject: [PATCH 14/14] =?UTF-8?q?refactor:=20method=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 7 ++++++- app/schemas/api_key/create_model.py | 25 ++++++++++++++++++------- app/services/api_key_service.py | 1 + 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 701f1bf..6d5e538 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -44,6 +44,11 @@ class CommonCode(Enum): """ KEY 클라이언트 오류 코드 - 42xx """ INVALID_API_KEY_FORMAT = (status.HTTP_400_BAD_REQUEST, "4200", "API 키의 형식이 올바르지 않습니다.") + INVALID_API_KEY_PREFIX = ( + status.HTTP_400_BAD_REQUEST, + "4201", + "API 키가 선택한 서비스의 올바른 형식이 아닙니다. (예: OpenAI는 sk-로 시작)", + ) """ AI CHAT, DB 클라이언트 오류 코드 - 43xx """ @@ -66,7 +71,7 @@ class CommonCode(Enum): "5002", "데이터 생성 후 검증 과정에서 오류가 발생했습니다.", ) - + """ DRIVER, DB 서버 오류 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 오류가 발생했습니다.") diff --git a/app/schemas/api_key/create_model.py b/app/schemas/api_key/create_model.py index 8803847..c53e3de 100644 --- a/app/schemas/api_key/create_model.py +++ b/app/schemas/api_key/create_model.py @@ -1,5 +1,8 @@ -from pydantic import Field, field_validator +from pydantic import Field +from app.core.enum.llm_service_info import LLMServiceEnum +from app.core.exceptions import APIException +from app.core.status import CommonCode from app.schemas.api_key.base_model import APIKeyBase @@ -8,9 +11,17 @@ class APIKeyCreate(APIKeyBase): api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - @field_validator("api_key", mode="after") - @classmethod - def validate_api_key(cls, v: str) -> str: - if not v or v.isspace(): - raise ValueError("API key cannot be empty or just whitespace.") - return v + def validate_with_service(self) -> None: + """서비스 종류에 따라 API Key의 유효성을 검증합니다.""" + # 1. 기본 형식 검증 (공백 또는 빈 문자열) + if not self.api_key or self.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + # 2. 서비스별 접두사 검증 + key_prefix_map = { + LLMServiceEnum.OPENAI: "sk-", + } + required_prefix = key_prefix_map.get(self.service_name) + + if required_prefix and not self.api_key.startswith(required_prefix): + raise APIException(CommonCode.INVALID_API_KEY_PREFIX) diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index f36db09..92c85d5 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -18,6 +18,7 @@ def store_api_key( self, credential_data: APIKeyCreate, repository: APIKeyRepository = api_key_repository ) -> APIKeyInDB: """API_KEY를 암호화하고 repository를 통해 데이터베이스에 저장합니다.""" + credential_data.validate_with_service() try: encrypted_key = AES256.encrypt(credential_data.api_key) new_id = generate_prefixed_uuid("QGENIE")