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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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/32] =?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") From ef61630deec471d90d75a19ae24b51601165acc6 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 15/32] =?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 ++++++++++++++++++++++++++++ 1 file changed, 39 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) From 075da1192b7272b488d0c04040f88e727bb9966b 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 16/32] =?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/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..bd8bdc4 --- /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_prefixed_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_prefixed_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 89373976fb8d08dcc694a1fdae9639f1334f0cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 14:19:58 +0900 Subject: [PATCH 17/32] =?UTF-8?q?feat:=20API=20Key=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?&=20=EB=8B=A8=EC=9D=BC=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 85 ++++++++++++++++++++++ app/api/api_key/store_api_key_api.py | 39 ----------- app/api/api_router.py | 2 +- app/schemas/api_key.py | 37 ++++++++++ app/services/api_key/service.py | 101 +++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 app/api/api_key/api.py delete mode 100644 app/api/api_key/store_api_key_api.py create mode 100644 app/schemas/api_key.py create mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py new file mode 100644 index 0000000..9bd69c2 --- /dev/null +++ b/app/api/api_key/api.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter + +from app.core.enum.llm_service import LLMServiceEnum +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.api_key import APIKeyInfo, APIKeyStore +from app.services.api_key import service as api_key_service + +router = APIRouter() + + +@router.post( + "/action", + response_model=ResponseMessage[APIKeyInfo], + summary="API KEY 저장 (처음 한 번)", + description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", +) +def store_api_key(credential: APIKeyStore) -> ResponseMessage: + """ + - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") + - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") + """ + + # 우선은 간단하게 존재 여부와 공백 여부로 검증 + # service_name은 enum이라서 pydantic이 자동으로 검증해줌 + # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 + # TODO: 검증 로직 강화 + if not credential.api_key or credential.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + created_credential = api_key_service.store_api_key(credential) + + response_data = APIKeyInfo( + id=created_credential.id, + service_name=created_credential.service_name, + created_at=created_credential.created_at, + updated_at=created_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) + + +@router.get( + "/result", + response_model=ResponseMessage[list[APIKeyInfo]], + summary="저장된 모든 API KEY 정보 조회", + description=""" + ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. + 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. + """, +) +def get_all_api_keys(): + """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + db_credentials = api_key_service.get_all_api_keys() + + response_data = [ + APIKeyInfo( + id=cred.id, + service_name=cred.service_name, + created_at=cred.created_at, + updated_at=cred.updated_at, + ) + for cred in db_credentials + ] + return ResponseMessage.success(value=response_data) + + +@router.get( + "/result/{serviceName}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 정보 조회", + description="필요 없을 것 같음", +) +def get_api_key_by_service_name(serviceName: LLMServiceEnum): + """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" + db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) + + response_data = APIKeyInfo( + id=db_credential.id, + service_name=db_credential.service_name, + created_at=db_credential.created_at, + updated_at=db_credential.updated_at, + ) + return ResponseMessage.success(value=response_data) diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key/store_api_key_api.py deleted file mode 100644 index 79baddd..0000000 --- a/app/api/api_key/store_api_key_api.py +++ /dev/null @@ -1,39 +0,0 @@ -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/api/api_router.py b/app/api/api_router.py index 6964eb9..b1c2d39 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -12,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(api_key_api.router, prefix="/credentials", tags=["Credentials"]) +api_router.include_router(api_key_api.router, prefix="/keys", tags=["API Key"]) diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py new file mode 100644 index 0000000..d7d2f53 --- /dev/null +++ b/app/schemas/api_key.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.core.enum.llm_service import LLMServiceEnum + + +class APIKeyBase(BaseModel): + """모든 API Key 스키마의 기본 모델""" + + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") + + +class APIKeyStore(APIKeyBase): + """API Key 저장을 위한 스키마""" + + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + +class APIKeyInDB(APIKeyBase): + """데이터베이스에 저장된 형태의 스키마 (내부용)""" + + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class APIKeyInfo(APIKeyBase): + """API 응답용 스키마 (민감 정보 제외)""" + + id: str + created_at: datetime + updated_at: datetime diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py new file mode 100644 index 0000000..b793cfe --- /dev/null +++ b/app/services/api_key/service.py @@ -0,0 +1,101 @@ +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_prefixed_uuid, get_db_path +from app.schemas.api_key import APIKeyInDB, APIKeyStore + + +def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: + """API Key를 암호화하여 데이터베이스에 저장합니다.""" + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_prefixed_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.value, 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 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() + + +def get_all_api_keys() -> list[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("SELECT * FROM ai_credential") + rows = cursor.fetchall() + + # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? + # 아니면 예외처리를 해줄지? + return [APIKeyInDB.model_validate(dict(row)) for row in rows] + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + print(e.__class__) + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() + + +def get_api_key_by_service_name(service_name: 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("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + row = cursor.fetchone() + + if not row: + raise APIException(CommonCode.NO_SEARCH_DATA) + + return APIKeyInDB.model_validate(dict(row)) + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From e8d4812c70d82c1bf00158c1e23ba38607522327 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 12:00:52 +0900 Subject: [PATCH 18/32] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20&=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=9C=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 85 --------------- app/api/api_key_api.py | 48 +++++++++ app/repository/api_key_repository.py | 37 +++++++ app/schemas/api_key.py | 37 ------- app/schemas/api_key/response_model.py | 3 - app/services/api_key/service.py | 101 ------------------ app/services/api_key/store_api_key_service.py | 52 --------- app/services/api_key_service.py | 29 +++-- 8 files changed, 107 insertions(+), 285 deletions(-) delete mode 100644 app/api/api_key/api.py delete mode 100644 app/schemas/api_key.py delete mode 100644 app/services/api_key/service.py delete mode 100644 app/services/api_key/store_api_key_service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py deleted file mode 100644 index 9bd69c2..0000000 --- a/app/api/api_key/api.py +++ /dev/null @@ -1,85 +0,0 @@ -from fastapi import APIRouter - -from app.core.enum.llm_service import LLMServiceEnum -from app.core.exceptions import APIException -from app.core.response import ResponseMessage -from app.core.status import CommonCode -from app.schemas.api_key import APIKeyInfo, APIKeyStore -from app.services.api_key import service as api_key_service - -router = APIRouter() - - -@router.post( - "/action", - response_model=ResponseMessage[APIKeyInfo], - summary="API KEY 저장 (처음 한 번)", - description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", -) -def store_api_key(credential: APIKeyStore) -> ResponseMessage: - """ - - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") - """ - - # 우선은 간단하게 존재 여부와 공백 여부로 검증 - # service_name은 enum이라서 pydantic이 자동으로 검증해줌 - # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 - # TODO: 검증 로직 강화 - if not credential.api_key or credential.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - created_credential = api_key_service.store_api_key(credential) - - response_data = APIKeyInfo( - id=created_credential.id, - service_name=created_credential.service_name, - created_at=created_credential.created_at, - updated_at=created_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) - - -@router.get( - "/result", - response_model=ResponseMessage[list[APIKeyInfo]], - summary="저장된 모든 API KEY 정보 조회", - description=""" - ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. - 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. - """, -) -def get_all_api_keys(): - """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" - db_credentials = api_key_service.get_all_api_keys() - - response_data = [ - APIKeyInfo( - id=cred.id, - service_name=cred.service_name, - created_at=cred.created_at, - updated_at=cred.updated_at, - ) - for cred in db_credentials - ] - return ResponseMessage.success(value=response_data) - - -@router.get( - "/result/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 정보 조회", - description="필요 없을 것 같음", -) -def get_api_key_by_service_name(serviceName: LLMServiceEnum): - """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" - db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) - - response_data = APIKeyInfo( - id=db_credential.id, - service_name=db_credential.service_name, - created_at=db_credential.created_at, - updated_at=db_credential.updated_at, - ) - return ResponseMessage.success(value=response_data) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index 15a66ef..e17e1df 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends +from app.core.enum.llm_service_info import LLMServiceEnum from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.api_key.create_model import APIKeyCreate @@ -35,3 +36,50 @@ def store_api_key( ) return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) + + +@router.get( + "/result", + response_model=ResponseMessage[list[APIKeyResponse]], + summary="저장된 모든 API KEY 정보 조회", + description=""" + ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. + 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. + """, +) +def get_all_api_keys( + service: APIKeyService = api_key_service_dependency, +) -> ResponseMessage[list[APIKeyResponse]]: + """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + db_credentials = service.get_all_api_keys() + + response_data = [ + APIKeyResponse( + id=cred.id, + service_name=cred.service_name, + created_at=cred.created_at, + updated_at=cred.updated_at, + ) + for cred in db_credentials + ] + return ResponseMessage.success(value=response_data) + + +@router.get( + "/result/{serviceName}", + response_model=ResponseMessage[APIKeyResponse], + summary="특정 서비스의 API KEY 정보 조회", +) +def get_api_key_by_service_name( + serviceName: LLMServiceEnum, service: APIKeyService = api_key_service_dependency +) -> ResponseMessage[APIKeyResponse]: + """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" + db_credential = service.get_api_key_by_service_name(serviceName) + + response_data = APIKeyResponse( + id=db_credential.id, + service_name=db_credential.service_name, + created_at=db_credential.created_at, + updated_at=db_credential.updated_at, + ) + return ResponseMessage.success(value=response_data) diff --git a/app/repository/api_key_repository.py b/app/repository/api_key_repository.py index 231de98..83f3031 100644 --- a/app/repository/api_key_repository.py +++ b/app/repository/api_key_repository.py @@ -38,5 +38,42 @@ def create_api_key(self, new_id: str, service_name: str, encrypted_key: str) -> if conn: conn.close() + def get_all_api_keys(self) -> list[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("SELECT * FROM ai_credential") + rows = cursor.fetchall() + + return [APIKeyInDB.model_validate(dict(row)) for row in rows] + finally: + if conn: + conn.close() + + def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB | None: + """서비스 이름으로 특정 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("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + row = cursor.fetchone() + + if not row: + return None + + return APIKeyInDB.model_validate(dict(row)) + finally: + if conn: + conn.close() + api_key_repository = APIKeyRepository() diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py deleted file mode 100644 index d7d2f53..0000000 --- a/app/schemas/api_key.py +++ /dev/null @@ -1,37 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - -from app.core.enum.llm_service import LLMServiceEnum - - -class APIKeyBase(BaseModel): - """모든 API Key 스키마의 기본 모델""" - - service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") - - -class APIKeyStore(APIKeyBase): - """API Key 저장을 위한 스키마""" - - api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - - -class APIKeyInDB(APIKeyBase): - """데이터베이스에 저장된 형태의 스키마 (내부용)""" - - id: str - api_key: str # DB 모델에서는 암호화된 키를 의미 - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class APIKeyInfo(APIKeyBase): - """API 응답용 스키마 (민감 정보 제외)""" - - id: str - created_at: datetime - updated_at: datetime diff --git a/app/schemas/api_key/response_model.py b/app/schemas/api_key/response_model.py index fa3a090..236c3b1 100644 --- a/app/schemas/api_key/response_model.py +++ b/app/schemas/api_key/response_model.py @@ -1,7 +1,5 @@ from datetime import datetime -from pydantic import Field - from app.schemas.api_key.base_model import APIKeyBase @@ -9,6 +7,5 @@ class APIKeyResponse(APIKeyBase): """API 응답용 스키마""" id: str - api_key_encrypted: str = Field(..., description="암호화된 API Key") created_at: datetime updated_at: datetime diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py deleted file mode 100644 index b793cfe..0000000 --- a/app/services/api_key/service.py +++ /dev/null @@ -1,101 +0,0 @@ -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_prefixed_uuid, get_db_path -from app.schemas.api_key import APIKeyInDB, APIKeyStore - - -def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: - """API Key를 암호화하여 데이터베이스에 저장합니다.""" - encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_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.value, 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 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() - - -def get_all_api_keys() -> list[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("SELECT * FROM ai_credential") - rows = cursor.fetchall() - - # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? - # 아니면 예외처리를 해줄지? - return [APIKeyInDB.model_validate(dict(row)) for row in rows] - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - print(e.__class__) - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def get_api_key_by_service_name(service_name: 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("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) - row = cursor.fetchone() - - if not row: - raise APIException(CommonCode.NO_SEARCH_DATA) - - return APIKeyInDB.model_validate(dict(row)) - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key/store_api_key_service.py deleted file mode 100644 index bd8bdc4..0000000 --- a/app/services/api_key/store_api_key_service.py +++ /dev/null @@ -1,52 +0,0 @@ -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_prefixed_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_prefixed_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() diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 92c85d5..409a732 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -14,16 +14,17 @@ class APIKeyService: - def store_api_key( - self, credential_data: APIKeyCreate, repository: APIKeyRepository = api_key_repository - ) -> APIKeyInDB: + def __init__(self, repository: APIKeyRepository = api_key_repository): + self.repository = repository + + def store_api_key(self, credential_data: APIKeyCreate) -> APIKeyInDB: """API_KEY를 암호화하고 repository를 통해 데이터베이스에 저장합니다.""" credential_data.validate_with_service() try: encrypted_key = AES256.encrypt(credential_data.api_key) new_id = generate_prefixed_uuid("QGENIE") - created_row = repository.create_api_key( + created_row = self.repository.create_api_key( new_id=new_id, service_name=credential_data.service_name.value, encrypted_key=encrypted_key, @@ -35,13 +36,27 @@ def store_api_key( 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 get_all_api_keys(self) -> list[APIKeyInDB]: + """데이터베이스에 저장된 모든 API Key를 조회합니다.""" + try: + return self.repository.get_all_api_keys() + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + + def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB: + """서비스 이름으로 특정 API Key를 조회합니다.""" + try: + api_key = self.repository.get_api_key_by_service_name(service_name) + if not api_key: + raise APIException(CommonCode.NO_SEARCH_DATA) + return api_key + except sqlite3.Error as e: raise APIException(CommonCode.FAIL) from e From 492a1fb613ba21a4565a50014ff8eaca9214cc3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 14:19:58 +0900 Subject: [PATCH 19/32] =?UTF-8?q?feat:=20API=20Key=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?&=20=EB=8B=A8=EC=9D=BC=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 85 +++++++++++++++++++++++++++ app/schemas/api_key.py | 37 ++++++++++++ app/services/api_key/service.py | 101 ++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 app/api/api_key/api.py create mode 100644 app/schemas/api_key.py create mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py new file mode 100644 index 0000000..9bd69c2 --- /dev/null +++ b/app/api/api_key/api.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter + +from app.core.enum.llm_service import LLMServiceEnum +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.api_key import APIKeyInfo, APIKeyStore +from app.services.api_key import service as api_key_service + +router = APIRouter() + + +@router.post( + "/action", + response_model=ResponseMessage[APIKeyInfo], + summary="API KEY 저장 (처음 한 번)", + description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", +) +def store_api_key(credential: APIKeyStore) -> ResponseMessage: + """ + - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") + - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") + """ + + # 우선은 간단하게 존재 여부와 공백 여부로 검증 + # service_name은 enum이라서 pydantic이 자동으로 검증해줌 + # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 + # TODO: 검증 로직 강화 + if not credential.api_key or credential.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + created_credential = api_key_service.store_api_key(credential) + + response_data = APIKeyInfo( + id=created_credential.id, + service_name=created_credential.service_name, + created_at=created_credential.created_at, + updated_at=created_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) + + +@router.get( + "/result", + response_model=ResponseMessage[list[APIKeyInfo]], + summary="저장된 모든 API KEY 정보 조회", + description=""" + ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. + 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. + """, +) +def get_all_api_keys(): + """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + db_credentials = api_key_service.get_all_api_keys() + + response_data = [ + APIKeyInfo( + id=cred.id, + service_name=cred.service_name, + created_at=cred.created_at, + updated_at=cred.updated_at, + ) + for cred in db_credentials + ] + return ResponseMessage.success(value=response_data) + + +@router.get( + "/result/{serviceName}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 정보 조회", + description="필요 없을 것 같음", +) +def get_api_key_by_service_name(serviceName: LLMServiceEnum): + """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" + db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) + + response_data = APIKeyInfo( + id=db_credential.id, + service_name=db_credential.service_name, + created_at=db_credential.created_at, + updated_at=db_credential.updated_at, + ) + return ResponseMessage.success(value=response_data) diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py new file mode 100644 index 0000000..d7d2f53 --- /dev/null +++ b/app/schemas/api_key.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.core.enum.llm_service import LLMServiceEnum + + +class APIKeyBase(BaseModel): + """모든 API Key 스키마의 기본 모델""" + + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") + + +class APIKeyStore(APIKeyBase): + """API Key 저장을 위한 스키마""" + + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + +class APIKeyInDB(APIKeyBase): + """데이터베이스에 저장된 형태의 스키마 (내부용)""" + + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class APIKeyInfo(APIKeyBase): + """API 응답용 스키마 (민감 정보 제외)""" + + id: str + created_at: datetime + updated_at: datetime diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py new file mode 100644 index 0000000..b793cfe --- /dev/null +++ b/app/services/api_key/service.py @@ -0,0 +1,101 @@ +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_prefixed_uuid, get_db_path +from app.schemas.api_key import APIKeyInDB, APIKeyStore + + +def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: + """API Key를 암호화하여 데이터베이스에 저장합니다.""" + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_prefixed_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.value, 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 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() + + +def get_all_api_keys() -> list[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("SELECT * FROM ai_credential") + rows = cursor.fetchall() + + # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? + # 아니면 예외처리를 해줄지? + return [APIKeyInDB.model_validate(dict(row)) for row in rows] + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + print(e.__class__) + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() + + +def get_api_key_by_service_name(service_name: 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("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + row = cursor.fetchone() + + if not row: + raise APIException(CommonCode.NO_SEARCH_DATA) + + return APIKeyInDB.model_validate(dict(row)) + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From f5b04221e400dfef6992f5274d8925d99226e7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 15:34:30 +0900 Subject: [PATCH 20/32] =?UTF-8?q?feat:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=EC=9A=A9=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/api_key.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py index d7d2f53..5065ea8 100644 --- a/app/schemas/api_key.py +++ b/app/schemas/api_key.py @@ -5,6 +5,12 @@ from app.core.enum.llm_service import LLMServiceEnum +class APIKeyUpdate(BaseModel): + """API Key 수정을 위한 스키마""" + + api_key: str = Field(..., description="새로운 API Key") + + class APIKeyBase(BaseModel): """모든 API Key 스키마의 기본 모델""" From ecc53a6fe94582d79d2833d478f997c0d913b4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 15:35:10 +0900 Subject: [PATCH 21/32] =?UTF-8?q?feat:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/api_key/service.py | 43 ++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py index b793cfe..d9d8372 100644 --- a/app/services/api_key/service.py +++ b/app/services/api_key/service.py @@ -4,7 +4,7 @@ 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.api_key import APIKeyInDB, APIKeyStore +from app.schemas.api_key import APIKeyInDB, APIKeyStore, APIKeyUpdate def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: @@ -99,3 +99,44 @@ def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: finally: if conn: conn.close() + + +def update_api_key(service_name: str, key_data: APIKeyUpdate) -> APIKeyInDB: + """서비스 이름에 해당하는 API Key를 수정합니다.""" + encrypted_key = AES256.encrypt(key_data.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("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) + if not cursor.fetchone(): + raise APIException(CommonCode.NO_SEARCH_DATA) + + # 데이터 업데이트 + cursor.execute( + "UPDATE ai_credential SET api_key = ?, updated_at = datetime('now', 'localtime') WHERE service_name = ?", + (encrypted_key, service_name), + ) + conn.commit() + + # rowcount가 0이면 업데이트된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + raise APIException(CommonCode.FAIL) + + # 업데이트된 정보를 다시 조회하여 반환 + cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + updated_row = cursor.fetchone() + + return APIKeyInDB.model_validate(dict(updated_row)) + + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From f78cfc879444cdc03f1fcf09f0d67f2825f9f6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 15:36:02 +0900 Subject: [PATCH 22/32] =?UTF-8?q?feat:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py index 9bd69c2..b4b0f74 100644 --- a/app/api/api_key/api.py +++ b/app/api/api_key/api.py @@ -4,7 +4,7 @@ from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.core.status import CommonCode -from app.schemas.api_key import APIKeyInfo, APIKeyStore +from app.schemas.api_key import APIKeyInfo, APIKeyStore, APIKeyUpdate from app.services.api_key import service as api_key_service router = APIRouter() @@ -83,3 +83,30 @@ def get_api_key_by_service_name(serviceName: LLMServiceEnum): updated_at=db_credential.updated_at, ) return ResponseMessage.success(value=response_data) + + +@router.put( + "/result/{service_name}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 수정", +) +def update_api_key(service_name: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: + """ + 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. + - **service_name**: 수정할 서비스의 이름 + - **api_key**: 새로운 API Key + """ + # 입력값 검증 + if not key_data.api_key or key_data.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + updated_credential = api_key_service.update_api_key(service_name.value, key_data) + + response_data = APIKeyInfo( + id=updated_credential.id, + service_name=updated_credential.service_name, + created_at=updated_credential.created_at, + updated_at=updated_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data) From 1b0e04a63963a85cbcd3905aa3f8652e5a6f5060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 15:36:57 +0900 Subject: [PATCH 23/32] =?UTF-8?q?refactor:=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=EB=93=A4?= =?UTF-8?q?=EC=97=90=20DB=5FBUSY=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 10 +++++----- app/services/api_key/service.py | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py index b4b0f74..a7e5dbc 100644 --- a/app/api/api_key/api.py +++ b/app/api/api_key/api.py @@ -50,7 +50,7 @@ def store_api_key(credential: APIKeyStore) -> ResponseMessage: 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. """, ) -def get_all_api_keys(): +def get_all_api_keys() -> ResponseMessage: """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" db_credentials = api_key_service.get_all_api_keys() @@ -72,7 +72,7 @@ def get_all_api_keys(): summary="특정 서비스의 API KEY 정보 조회", description="필요 없을 것 같음", ) -def get_api_key_by_service_name(serviceName: LLMServiceEnum): +def get_api_key_by_service_name(serviceName: LLMServiceEnum) -> ResponseMessage: """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) @@ -86,11 +86,11 @@ def get_api_key_by_service_name(serviceName: LLMServiceEnum): @router.put( - "/result/{service_name}", + "/result/{serviceName}", response_model=ResponseMessage[APIKeyInfo], summary="특정 서비스의 API KEY 수정", ) -def update_api_key(service_name: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: +def update_api_key(serviceName: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: """ 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. - **service_name**: 수정할 서비스의 이름 @@ -100,7 +100,7 @@ def update_api_key(service_name: LLMServiceEnum, key_data: APIKeyUpdate) -> Resp if not key_data.api_key or key_data.api_key.isspace(): raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - updated_credential = api_key_service.update_api_key(service_name.value, key_data) + updated_credential = api_key_service.update_api_key(serviceName.value, key_data) response_data = APIKeyInfo( id=updated_credential.id, diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py index d9d8372..481a47b 100644 --- a/app/services/api_key/service.py +++ b/app/services/api_key/service.py @@ -69,7 +69,8 @@ def get_all_api_keys() -> list[APIKeyInDB]: # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 except sqlite3.Error as e: - print(e.__class__) + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e raise APIException(CommonCode.FAIL) from e finally: if conn: @@ -95,6 +96,8 @@ def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e raise APIException(CommonCode.FAIL) from e finally: if conn: From 89924e2b65659b1760520c4a3ac7e8aa8852e51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 16:20:37 +0900 Subject: [PATCH 24/32] =?UTF-8?q?chore:=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py index a7e5dbc..4ac8321 100644 --- a/app/api/api_key/api.py +++ b/app/api/api_key/api.py @@ -86,7 +86,7 @@ def get_api_key_by_service_name(serviceName: LLMServiceEnum) -> ResponseMessage: @router.put( - "/result/{serviceName}", + "/modify/{serviceName}", response_model=ResponseMessage[APIKeyInfo], summary="특정 서비스의 API KEY 수정", ) From 98a48225d08b76bdf5fd634f52b182c431f44902 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 12:31:30 +0900 Subject: [PATCH 25/32] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20&=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 112 --------------------- app/api/api_key_api.py | 28 ++++++ app/repository/api_key_repository.py | 33 ++++++ app/schemas/api_key.py | 43 -------- app/schemas/api_key/update_model.py | 14 +++ app/services/api_key/service.py | 145 --------------------------- app/services/api_key_service.py | 16 +++ 7 files changed, 91 insertions(+), 300 deletions(-) delete mode 100644 app/api/api_key/api.py delete mode 100644 app/schemas/api_key.py create mode 100644 app/schemas/api_key/update_model.py delete mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py deleted file mode 100644 index 4ac8321..0000000 --- a/app/api/api_key/api.py +++ /dev/null @@ -1,112 +0,0 @@ -from fastapi import APIRouter - -from app.core.enum.llm_service import LLMServiceEnum -from app.core.exceptions import APIException -from app.core.response import ResponseMessage -from app.core.status import CommonCode -from app.schemas.api_key import APIKeyInfo, APIKeyStore, APIKeyUpdate -from app.services.api_key import service as api_key_service - -router = APIRouter() - - -@router.post( - "/action", - response_model=ResponseMessage[APIKeyInfo], - summary="API KEY 저장 (처음 한 번)", - description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", -) -def store_api_key(credential: APIKeyStore) -> ResponseMessage: - """ - - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") - """ - - # 우선은 간단하게 존재 여부와 공백 여부로 검증 - # service_name은 enum이라서 pydantic이 자동으로 검증해줌 - # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 - # TODO: 검증 로직 강화 - if not credential.api_key or credential.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - created_credential = api_key_service.store_api_key(credential) - - response_data = APIKeyInfo( - id=created_credential.id, - service_name=created_credential.service_name, - created_at=created_credential.created_at, - updated_at=created_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) - - -@router.get( - "/result", - response_model=ResponseMessage[list[APIKeyInfo]], - summary="저장된 모든 API KEY 정보 조회", - description=""" - ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. - 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. - """, -) -def get_all_api_keys() -> ResponseMessage: - """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" - db_credentials = api_key_service.get_all_api_keys() - - response_data = [ - APIKeyInfo( - id=cred.id, - service_name=cred.service_name, - created_at=cred.created_at, - updated_at=cred.updated_at, - ) - for cred in db_credentials - ] - return ResponseMessage.success(value=response_data) - - -@router.get( - "/result/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 정보 조회", - description="필요 없을 것 같음", -) -def get_api_key_by_service_name(serviceName: LLMServiceEnum) -> ResponseMessage: - """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" - db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) - - response_data = APIKeyInfo( - id=db_credential.id, - service_name=db_credential.service_name, - created_at=db_credential.created_at, - updated_at=db_credential.updated_at, - ) - return ResponseMessage.success(value=response_data) - - -@router.put( - "/modify/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 수정", -) -def update_api_key(serviceName: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: - """ - 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. - - **service_name**: 수정할 서비스의 이름 - - **api_key**: 새로운 API Key - """ - # 입력값 검증 - if not key_data.api_key or key_data.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - updated_credential = api_key_service.update_api_key(serviceName.value, key_data) - - response_data = APIKeyInfo( - id=updated_credential.id, - service_name=updated_credential.service_name, - created_at=updated_credential.created_at, - updated_at=updated_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index e17e1df..22b80cb 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -5,6 +5,7 @@ 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.schemas.api_key.update_model import APIKeyUpdate from app.services.api_key_service import APIKeyService, api_key_service api_key_service_dependency = Depends(lambda: api_key_service) @@ -83,3 +84,30 @@ def get_api_key_by_service_name( updated_at=db_credential.updated_at, ) return ResponseMessage.success(value=response_data) + + +@router.put( + "/modify/{serviceName}", + response_model=ResponseMessage[APIKeyResponse], + summary="특정 서비스의 API KEY 수정", +) +def update_api_key( + serviceName: LLMServiceEnum, + key_data: APIKeyUpdate, + service: APIKeyService = api_key_service_dependency, +) -> ResponseMessage[APIKeyResponse]: + """ + 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. + - **service_name**: 수정할 서비스의 이름 + - **api_key**: 새로운 API Key + """ + updated_credential = service.update_api_key(serviceName.value, key_data) + + response_data = APIKeyResponse( + id=updated_credential.id, + service_name=updated_credential.service_name, + created_at=updated_credential.created_at, + updated_at=updated_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data) diff --git a/app/repository/api_key_repository.py b/app/repository/api_key_repository.py index 83f3031..caaae96 100644 --- a/app/repository/api_key_repository.py +++ b/app/repository/api_key_repository.py @@ -75,5 +75,38 @@ def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB | None: if conn: conn.close() + def update_api_key(self, service_name: str, encrypted_key: str) -> APIKeyInDB | None: + """서비스 이름에 해당하는 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("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) + if not cursor.fetchone(): + return None + + # 데이터 업데이트 + cursor.execute( + "UPDATE ai_credential SET api_key = ?, updated_at = datetime('now', 'localtime') WHERE service_name = ?", + (encrypted_key, service_name), + ) + conn.commit() + + # rowcount가 0이면 업데이트된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + return None + + cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + updated_row = cursor.fetchone() + + return APIKeyInDB.model_validate(dict(updated_row)) + finally: + if conn: + conn.close() + api_key_repository = APIKeyRepository() diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py deleted file mode 100644 index 5065ea8..0000000 --- a/app/schemas/api_key.py +++ /dev/null @@ -1,43 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - -from app.core.enum.llm_service import LLMServiceEnum - - -class APIKeyUpdate(BaseModel): - """API Key 수정을 위한 스키마""" - - api_key: str = Field(..., description="새로운 API Key") - - -class APIKeyBase(BaseModel): - """모든 API Key 스키마의 기본 모델""" - - service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") - - -class APIKeyStore(APIKeyBase): - """API Key 저장을 위한 스키마""" - - api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - - -class APIKeyInDB(APIKeyBase): - """데이터베이스에 저장된 형태의 스키마 (내부용)""" - - id: str - api_key: str # DB 모델에서는 암호화된 키를 의미 - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class APIKeyInfo(APIKeyBase): - """API 응답용 스키마 (민감 정보 제외)""" - - id: str - created_at: datetime - updated_at: datetime diff --git a/app/schemas/api_key/update_model.py b/app/schemas/api_key/update_model.py new file mode 100644 index 0000000..5135cd7 --- /dev/null +++ b/app/schemas/api_key/update_model.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, Field, field_validator + + +class APIKeyUpdate(BaseModel): + """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/services/api_key/service.py b/app/services/api_key/service.py deleted file mode 100644 index 481a47b..0000000 --- a/app/services/api_key/service.py +++ /dev/null @@ -1,145 +0,0 @@ -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_prefixed_uuid, get_db_path -from app.schemas.api_key import APIKeyInDB, APIKeyStore, APIKeyUpdate - - -def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: - """API Key를 암호화하여 데이터베이스에 저장합니다.""" - encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_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.value, 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 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() - - -def get_all_api_keys() -> list[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("SELECT * FROM ai_credential") - rows = cursor.fetchall() - - # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? - # 아니면 예외처리를 해줄지? - return [APIKeyInDB.model_validate(dict(row)) for row in rows] - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def get_api_key_by_service_name(service_name: 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("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) - row = cursor.fetchone() - - if not row: - raise APIException(CommonCode.NO_SEARCH_DATA) - - return APIKeyInDB.model_validate(dict(row)) - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def update_api_key(service_name: str, key_data: APIKeyUpdate) -> APIKeyInDB: - """서비스 이름에 해당하는 API Key를 수정합니다.""" - encrypted_key = AES256.encrypt(key_data.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("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) - if not cursor.fetchone(): - raise APIException(CommonCode.NO_SEARCH_DATA) - - # 데이터 업데이트 - cursor.execute( - "UPDATE ai_credential SET api_key = ?, updated_at = datetime('now', 'localtime') WHERE service_name = ?", - (encrypted_key, service_name), - ) - conn.commit() - - # rowcount가 0이면 업데이트된 행이 없음 (정상적인 경우 발생하기 어려움) - if cursor.rowcount == 0: - raise APIException(CommonCode.FAIL) - - # 업데이트된 정보를 다시 조회하여 반환 - cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) - updated_row = cursor.fetchone() - - return APIKeyInDB.model_validate(dict(updated_row)) - - except sqlite3.Error as e: - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 409a732..5c3f89d 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -9,6 +9,7 @@ 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 +from app.schemas.api_key.update_model import APIKeyUpdate api_key_repository_dependency = Depends(lambda: api_key_repository) @@ -59,5 +60,20 @@ def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB: except sqlite3.Error as e: raise APIException(CommonCode.FAIL) from e + def update_api_key(self, service_name: str, key_data: APIKeyUpdate) -> APIKeyInDB: + """서비스 이름에 해당하는 API Key를 수정합니다.""" + try: + encrypted_key = AES256.encrypt(key_data.api_key) + updated_api_key = self.repository.update_api_key(service_name, encrypted_key) + + if not updated_api_key: + raise APIException(CommonCode.NO_SEARCH_DATA) + + return updated_api_key + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + api_key_service = APIKeyService() From a54269226cf58096cd20a4cfffb57f1347a9b473 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:48:50 +0900 Subject: [PATCH 26/32] =?UTF-8?q?refactor:=20update=5Fmodel=EC=97=90?= =?UTF-8?q?=EB=8F=84=20method=20=EB=B0=A9=EC=8B=9D=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/api_key/update_model.py | 16 +++++++++------- app/services/api_key_service.py | 1 + 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/schemas/api_key/update_model.py b/app/schemas/api_key/update_model.py index 5135cd7..89f2b16 100644 --- a/app/schemas/api_key/update_model.py +++ b/app/schemas/api_key/update_model.py @@ -1,4 +1,7 @@ -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field + +from app.core.exceptions import APIException +from app.core.status import CommonCode class APIKeyUpdate(BaseModel): @@ -6,9 +9,8 @@ class APIKeyUpdate(BaseModel): 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_api_key(self) -> None: + """API Key의 유효성을 검증합니다.""" + # 기본 형식 검증 (공백 또는 빈 문자열) + if not self.api_key or self.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 5c3f89d..f227562 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -62,6 +62,7 @@ def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB: def update_api_key(self, service_name: str, key_data: APIKeyUpdate) -> APIKeyInDB: """서비스 이름에 해당하는 API Key를 수정합니다.""" + key_data.validate_with_api_key() try: encrypted_key = AES256.encrypt(key_data.api_key) updated_api_key = self.repository.update_api_key(service_name, encrypted_key) From 06e106dafc6d4018c0d594bef8494569a20de6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 14:19:58 +0900 Subject: [PATCH 27/32] =?UTF-8?q?feat:=20API=20Key=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?&=20=EB=8B=A8=EC=9D=BC=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 85 +++++++++++++++++++++++++++ app/schemas/api_key.py | 37 ++++++++++++ app/services/api_key/service.py | 101 ++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 app/api/api_key/api.py create mode 100644 app/schemas/api_key.py create mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py new file mode 100644 index 0000000..9bd69c2 --- /dev/null +++ b/app/api/api_key/api.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter + +from app.core.enum.llm_service import LLMServiceEnum +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.api_key import APIKeyInfo, APIKeyStore +from app.services.api_key import service as api_key_service + +router = APIRouter() + + +@router.post( + "/action", + response_model=ResponseMessage[APIKeyInfo], + summary="API KEY 저장 (처음 한 번)", + description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", +) +def store_api_key(credential: APIKeyStore) -> ResponseMessage: + """ + - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") + - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") + """ + + # 우선은 간단하게 존재 여부와 공백 여부로 검증 + # service_name은 enum이라서 pydantic이 자동으로 검증해줌 + # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 + # TODO: 검증 로직 강화 + if not credential.api_key or credential.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + created_credential = api_key_service.store_api_key(credential) + + response_data = APIKeyInfo( + id=created_credential.id, + service_name=created_credential.service_name, + created_at=created_credential.created_at, + updated_at=created_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) + + +@router.get( + "/result", + response_model=ResponseMessage[list[APIKeyInfo]], + summary="저장된 모든 API KEY 정보 조회", + description=""" + ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. + 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. + """, +) +def get_all_api_keys(): + """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + db_credentials = api_key_service.get_all_api_keys() + + response_data = [ + APIKeyInfo( + id=cred.id, + service_name=cred.service_name, + created_at=cred.created_at, + updated_at=cred.updated_at, + ) + for cred in db_credentials + ] + return ResponseMessage.success(value=response_data) + + +@router.get( + "/result/{serviceName}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 정보 조회", + description="필요 없을 것 같음", +) +def get_api_key_by_service_name(serviceName: LLMServiceEnum): + """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" + db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) + + response_data = APIKeyInfo( + id=db_credential.id, + service_name=db_credential.service_name, + created_at=db_credential.created_at, + updated_at=db_credential.updated_at, + ) + return ResponseMessage.success(value=response_data) diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py new file mode 100644 index 0000000..d7d2f53 --- /dev/null +++ b/app/schemas/api_key.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.core.enum.llm_service import LLMServiceEnum + + +class APIKeyBase(BaseModel): + """모든 API Key 스키마의 기본 모델""" + + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") + + +class APIKeyStore(APIKeyBase): + """API Key 저장을 위한 스키마""" + + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + +class APIKeyInDB(APIKeyBase): + """데이터베이스에 저장된 형태의 스키마 (내부용)""" + + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class APIKeyInfo(APIKeyBase): + """API 응답용 스키마 (민감 정보 제외)""" + + id: str + created_at: datetime + updated_at: datetime diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py new file mode 100644 index 0000000..b793cfe --- /dev/null +++ b/app/services/api_key/service.py @@ -0,0 +1,101 @@ +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_prefixed_uuid, get_db_path +from app.schemas.api_key import APIKeyInDB, APIKeyStore + + +def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: + """API Key를 암호화하여 데이터베이스에 저장합니다.""" + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_prefixed_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.value, 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 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() + + +def get_all_api_keys() -> list[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("SELECT * FROM ai_credential") + rows = cursor.fetchall() + + # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? + # 아니면 예외처리를 해줄지? + return [APIKeyInDB.model_validate(dict(row)) for row in rows] + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + print(e.__class__) + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() + + +def get_api_key_by_service_name(service_name: 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("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + row = cursor.fetchone() + + if not row: + raise APIException(CommonCode.NO_SEARCH_DATA) + + return APIKeyInDB.model_validate(dict(row)) + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From d0082fc87631387d8a594a993fc4dbd0dec0d3d8 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 12:31:30 +0900 Subject: [PATCH 28/32] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20&=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/api_key.py | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 app/schemas/api_key.py diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py deleted file mode 100644 index d7d2f53..0000000 --- a/app/schemas/api_key.py +++ /dev/null @@ -1,37 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - -from app.core.enum.llm_service import LLMServiceEnum - - -class APIKeyBase(BaseModel): - """모든 API Key 스키마의 기본 모델""" - - service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") - - -class APIKeyStore(APIKeyBase): - """API Key 저장을 위한 스키마""" - - api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - - -class APIKeyInDB(APIKeyBase): - """데이터베이스에 저장된 형태의 스키마 (내부용)""" - - id: str - api_key: str # DB 모델에서는 암호화된 키를 의미 - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class APIKeyInfo(APIKeyBase): - """API 응답용 스키마 (민감 정보 제외)""" - - id: str - created_at: datetime - updated_at: datetime From ffdc443e7530d0355e820311a76c3fbd6f099d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 16:25:50 +0900 Subject: [PATCH 29/32] =?UTF-8?q?feat:=20=EC=82=AD=EC=A0=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/api_key/service.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py index b793cfe..37ae454 100644 --- a/app/services/api_key/service.py +++ b/app/services/api_key/service.py @@ -99,3 +99,33 @@ def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: finally: if conn: conn.close() + + +def delete_api_key(service_name: str) -> None: + """서비스 이름에 해당하는 API Key를 삭제합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + cursor = conn.cursor() + + # 먼저 해당 서비스의 데이터가 존재하는지 확인 + cursor.execute("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) + if not cursor.fetchone(): + raise APIException(CommonCode.NO_SEARCH_DATA) + + # 데이터 삭제 + cursor.execute("DELETE FROM ai_credential WHERE service_name = ?", (service_name,)) + conn.commit() + + # rowcount가 0이면 삭제된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + raise APIException(CommonCode.NO_SEARCH_DATA) + + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From b30616631def6e1721f50da69ab360082d414813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 16:26:18 +0900 Subject: [PATCH 30/32] =?UTF-8?q?feat:=20API=20Key=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=ED=94=84=EB=A0=88=EC=A0=A0=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py index 9bd69c2..eabf630 100644 --- a/app/api/api_key/api.py +++ b/app/api/api_key/api.py @@ -83,3 +83,44 @@ def get_api_key_by_service_name(serviceName: LLMServiceEnum): updated_at=db_credential.updated_at, ) return ResponseMessage.success(value=response_data) + + +@router.put( + "/modify/{serviceName}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 수정", +) +def update_api_key(serviceName: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: + """ + 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. + - **service_name**: 수정할 서비스의 이름 + - **api_key**: 새로운 API Key + """ + # 입력값 검증 + if not key_data.api_key or key_data.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + updated_credential = api_key_service.update_api_key(serviceName.value, key_data) + + response_data = APIKeyInfo( + id=updated_credential.id, + service_name=updated_credential.service_name, + created_at=updated_credential.created_at, + updated_at=updated_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data) + + +@router.delete( + "/remove/{serviceName}", + response_model=ResponseMessage, + summary="특정 서비스의 API KEY 삭제", +) +def delete_api_key(serviceName: LLMServiceEnum) -> ResponseMessage: + """ + 서비스 이름을 기준으로 특정 API Key를 삭제합니다. + - **serviceName**: 삭제할 서비스의 이름 + """ + api_key_service.delete_api_key(serviceName.value) + return ResponseMessage.success() From cd9c4e5bc269a164824f5db18bd69d2f5218a553 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 12:53:18 +0900 Subject: [PATCH 31/32] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20&=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 126 -------------------------- app/api/api_key_api.py | 14 +++ app/repository/api_key_repository.py | 26 ++++++ app/services/api_key/service.py | 131 --------------------------- app/services/api_key_service.py | 11 +++ 5 files changed, 51 insertions(+), 257 deletions(-) delete mode 100644 app/api/api_key/api.py delete mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py deleted file mode 100644 index eabf630..0000000 --- a/app/api/api_key/api.py +++ /dev/null @@ -1,126 +0,0 @@ -from fastapi import APIRouter - -from app.core.enum.llm_service import LLMServiceEnum -from app.core.exceptions import APIException -from app.core.response import ResponseMessage -from app.core.status import CommonCode -from app.schemas.api_key import APIKeyInfo, APIKeyStore -from app.services.api_key import service as api_key_service - -router = APIRouter() - - -@router.post( - "/action", - response_model=ResponseMessage[APIKeyInfo], - summary="API KEY 저장 (처음 한 번)", - description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", -) -def store_api_key(credential: APIKeyStore) -> ResponseMessage: - """ - - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") - """ - - # 우선은 간단하게 존재 여부와 공백 여부로 검증 - # service_name은 enum이라서 pydantic이 자동으로 검증해줌 - # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 - # TODO: 검증 로직 강화 - if not credential.api_key or credential.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - created_credential = api_key_service.store_api_key(credential) - - response_data = APIKeyInfo( - id=created_credential.id, - service_name=created_credential.service_name, - created_at=created_credential.created_at, - updated_at=created_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) - - -@router.get( - "/result", - response_model=ResponseMessage[list[APIKeyInfo]], - summary="저장된 모든 API KEY 정보 조회", - description=""" - ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. - 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. - """, -) -def get_all_api_keys(): - """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" - db_credentials = api_key_service.get_all_api_keys() - - response_data = [ - APIKeyInfo( - id=cred.id, - service_name=cred.service_name, - created_at=cred.created_at, - updated_at=cred.updated_at, - ) - for cred in db_credentials - ] - return ResponseMessage.success(value=response_data) - - -@router.get( - "/result/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 정보 조회", - description="필요 없을 것 같음", -) -def get_api_key_by_service_name(serviceName: LLMServiceEnum): - """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" - db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) - - response_data = APIKeyInfo( - id=db_credential.id, - service_name=db_credential.service_name, - created_at=db_credential.created_at, - updated_at=db_credential.updated_at, - ) - return ResponseMessage.success(value=response_data) - - -@router.put( - "/modify/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 수정", -) -def update_api_key(serviceName: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: - """ - 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. - - **service_name**: 수정할 서비스의 이름 - - **api_key**: 새로운 API Key - """ - # 입력값 검증 - if not key_data.api_key or key_data.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - updated_credential = api_key_service.update_api_key(serviceName.value, key_data) - - response_data = APIKeyInfo( - id=updated_credential.id, - service_name=updated_credential.service_name, - created_at=updated_credential.created_at, - updated_at=updated_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data) - - -@router.delete( - "/remove/{serviceName}", - response_model=ResponseMessage, - summary="특정 서비스의 API KEY 삭제", -) -def delete_api_key(serviceName: LLMServiceEnum) -> ResponseMessage: - """ - 서비스 이름을 기준으로 특정 API Key를 삭제합니다. - - **serviceName**: 삭제할 서비스의 이름 - """ - api_key_service.delete_api_key(serviceName.value) - return ResponseMessage.success() diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index 22b80cb..b760cfd 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -111,3 +111,17 @@ def update_api_key( ) return ResponseMessage.success(value=response_data) + + +@router.delete( + "/remove/{serviceName}", + response_model=ResponseMessage, + summary="특정 서비스의 API KEY 삭제", +) +def delete_api_key(serviceName: LLMServiceEnum, service: APIKeyService = api_key_service_dependency) -> ResponseMessage: + """ + 서비스 이름을 기준으로 특정 API Key를 삭제합니다. + - **service_name**: 삭제할 서비스의 이름 + """ + service.delete_api_key(serviceName.value) + return ResponseMessage.success() diff --git a/app/repository/api_key_repository.py b/app/repository/api_key_repository.py index caaae96..83d3854 100644 --- a/app/repository/api_key_repository.py +++ b/app/repository/api_key_repository.py @@ -108,5 +108,31 @@ def update_api_key(self, service_name: str, encrypted_key: str) -> APIKeyInDB | if conn: conn.close() + def delete_api_key(self, service_name: str) -> bool: + """서비스 이름에 해당하는 API Key를 삭제하고, 성공 여부를 반환합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + cursor = conn.cursor() + + # 먼저 해당 서비스의 데이터가 존재하는지 확인 + cursor.execute("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) + if not cursor.fetchone(): + return False + + # 데이터 삭제 + cursor.execute("DELETE FROM ai_credential WHERE service_name = ?", (service_name,)) + conn.commit() + + # rowcount가 0이면 삭제된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + return False + + return cursor.rowcount > 0 + finally: + if conn: + conn.close() + api_key_repository = APIKeyRepository() diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py deleted file mode 100644 index 37ae454..0000000 --- a/app/services/api_key/service.py +++ /dev/null @@ -1,131 +0,0 @@ -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_prefixed_uuid, get_db_path -from app.schemas.api_key import APIKeyInDB, APIKeyStore - - -def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: - """API Key를 암호화하여 데이터베이스에 저장합니다.""" - encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_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.value, 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 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() - - -def get_all_api_keys() -> list[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("SELECT * FROM ai_credential") - rows = cursor.fetchall() - - # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? - # 아니면 예외처리를 해줄지? - return [APIKeyInDB.model_validate(dict(row)) for row in rows] - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - print(e.__class__) - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def get_api_key_by_service_name(service_name: 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("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) - row = cursor.fetchone() - - if not row: - raise APIException(CommonCode.NO_SEARCH_DATA) - - return APIKeyInDB.model_validate(dict(row)) - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def delete_api_key(service_name: str) -> None: - """서비스 이름에 해당하는 API Key를 삭제합니다.""" - db_path = get_db_path() - conn = None - try: - conn = sqlite3.connect(str(db_path), timeout=10) - cursor = conn.cursor() - - # 먼저 해당 서비스의 데이터가 존재하는지 확인 - cursor.execute("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) - if not cursor.fetchone(): - raise APIException(CommonCode.NO_SEARCH_DATA) - - # 데이터 삭제 - cursor.execute("DELETE FROM ai_credential WHERE service_name = ?", (service_name,)) - conn.commit() - - # rowcount가 0이면 삭제된 행이 없음 (정상적인 경우 발생하기 어려움) - if cursor.rowcount == 0: - raise APIException(CommonCode.NO_SEARCH_DATA) - - except sqlite3.Error as e: - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index f227562..7af4381 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -76,5 +76,16 @@ def update_api_key(self, service_name: str, key_data: APIKeyUpdate) -> APIKeyInD raise APIException(CommonCode.DB_BUSY) from e raise APIException(CommonCode.FAIL) from e + def delete_api_key(self, service_name: str) -> None: + """서비스 이름에 해당하는 API Key를 삭제합니다.""" + try: + is_deleted = self.repository.delete_api_key(service_name) + if not is_deleted: + raise APIException(CommonCode.NO_SEARCH_DATA) + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + api_key_service = APIKeyService() From 94335b4dc7cdea78823798ece0a9dcc814744901 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 14:15:46 +0900 Subject: [PATCH 32/32] =?UTF-8?q?refactor:=20=EB=AA=A8=ED=98=B8=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EB=B3=80=EC=88=98=EB=AA=85=EC=9D=84=20api=5Fkey=20?= =?UTF-8?q?prefix=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key_api.py | 46 ++++++++++++++++----------------- app/services/api_key_service.py | 8 +++--- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index b760cfd..74884ea 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -20,20 +20,20 @@ description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", ) def store_api_key( - credential: APIKeyCreate, service: APIKeyService = api_key_service_dependency + api_key_data: APIKeyCreate, service: APIKeyService = api_key_service_dependency ) -> ResponseMessage[APIKeyResponse]: """ - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") """ - created_credential = service.store_api_key(credential) + created_api_key = service.store_api_key(api_key_data) response_data = APIKeyResponse( - 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, + id=created_api_key.id, + service_name=created_api_key.service_name.value, + api_key_encrypted=created_api_key.api_key, + created_at=created_api_key.created_at, + updated_at=created_api_key.updated_at, ) return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) @@ -52,16 +52,16 @@ def get_all_api_keys( service: APIKeyService = api_key_service_dependency, ) -> ResponseMessage[list[APIKeyResponse]]: """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" - db_credentials = service.get_all_api_keys() + api_keys_in_db = service.get_all_api_keys() response_data = [ APIKeyResponse( - id=cred.id, - service_name=cred.service_name, - created_at=cred.created_at, - updated_at=cred.updated_at, + id=api_key.id, + service_name=api_key.service_name, + created_at=api_key.created_at, + updated_at=api_key.updated_at, ) - for cred in db_credentials + for api_key in api_keys_in_db ] return ResponseMessage.success(value=response_data) @@ -75,13 +75,13 @@ def get_api_key_by_service_name( serviceName: LLMServiceEnum, service: APIKeyService = api_key_service_dependency ) -> ResponseMessage[APIKeyResponse]: """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" - db_credential = service.get_api_key_by_service_name(serviceName) + api_key_in_db = service.get_api_key_by_service_name(serviceName) response_data = APIKeyResponse( - id=db_credential.id, - service_name=db_credential.service_name, - created_at=db_credential.created_at, - updated_at=db_credential.updated_at, + id=api_key_in_db.id, + service_name=api_key_in_db.service_name, + created_at=api_key_in_db.created_at, + updated_at=api_key_in_db.updated_at, ) return ResponseMessage.success(value=response_data) @@ -101,13 +101,13 @@ def update_api_key( - **service_name**: 수정할 서비스의 이름 - **api_key**: 새로운 API Key """ - updated_credential = service.update_api_key(serviceName.value, key_data) + updated_api_key = service.update_api_key(serviceName.value, key_data) response_data = APIKeyResponse( - id=updated_credential.id, - service_name=updated_credential.service_name, - created_at=updated_credential.created_at, - updated_at=updated_credential.updated_at, + id=updated_api_key.id, + service_name=updated_api_key.service_name, + created_at=updated_api_key.created_at, + updated_at=updated_api_key.updated_at, ) return ResponseMessage.success(value=response_data) diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 7af4381..7e43cb9 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -18,16 +18,16 @@ class APIKeyService: def __init__(self, repository: APIKeyRepository = api_key_repository): self.repository = repository - def store_api_key(self, credential_data: APIKeyCreate) -> APIKeyInDB: + def store_api_key(self, api_key_data: APIKeyCreate) -> APIKeyInDB: """API_KEY를 암호화하고 repository를 통해 데이터베이스에 저장합니다.""" - credential_data.validate_with_service() + api_key_data.validate_with_service() try: - encrypted_key = AES256.encrypt(credential_data.api_key) + encrypted_key = AES256.encrypt(api_key_data.api_key) new_id = generate_prefixed_uuid("QGENIE") created_row = self.repository.create_api_key( new_id=new_id, - service_name=credential_data.service_name.value, + service_name=api_key_data.service_name.value, encrypted_key=encrypted_key, )