feat(V1-API): implement TREX-Basic consumer API contract with encryption and persistence

- Add V1 API endpoints: assets, ledger, user-info
- Implement Fernet-based credential payload encryption (req-010)
- Extend health response schema with runtime info (req-004)
- Add service token runtime ID issuance (req-005)
- Implement database schema migration for V1 API (req-009)
- Add comprehensive test coverage for V1 endpoints
- Update SRS with 10 new requirements (REQ-004 through REQ-011)
- Create batch planning, design, and verification for BAT-001
- Integrate review-discovery StagePilot skill
- Update documentation indices and baseline specs
This commit is contained in:
2026-05-02 09:06:15 +00:00
parent 24096a1e58
commit ce63c75c9c
37 changed files with 2851 additions and 21 deletions
+211
View File
@@ -0,0 +1,211 @@
---
name: review-discovery
description: "Use when: reviewing an existing Discovery document for missing content, additional OQ candidates, internal consistency, duplication, unnecessary content, placeholder residue, and REQ handoff readiness without changing lifecycle state."
argument-hint: "예: dcy-001 또는 docs/discovery/dcy-001_20260424_topic.md"
user-invocable: true
---
# Purpose
This skill reviews an existing Discovery document as a non-gating quality check before `confirm-discovery` or before human review. It looks for missing content, candidate open questions, internal inconsistencies, duplicated statements, unnecessary text, placeholder residue, and weak handoff readiness.
이 skill은 별도 lifecycle stage를 만들지 않는다. 기본 목적은 Discovery 품질을 점검하고 수정 우선순위를 정리하는 것이며, 문서 상태 전환이나 index 갱신은 수행하지 않는다.
# When to use
- `/review-discovery dcy-xyz` 형태로 Discovery 문서를 먼저 점검하고 싶을 때
- `confirm-discovery` 전에 누락, 중복, OQ 후보, 표현 불일치를 빠르게 정리하고 싶을 때
- Discovery 문서가 길어지면서 같은 내용이 여러 섹션에 흩어졌는지 확인해야 할 때
- 템플릿 잔여 문구, 불필요한 상세 구현 설명, 근거 없는 확정 표현이 남아 있는지 보고 싶을 때
- 사람이 수정할 항목과 AI가 바로 정리할 수 있는 항목을 분리해서 보고 싶을 때
# Inputs
- Discovery 식별자
- `dcy-001` 같은 prefix
- `dcy-001_20260424_topic-slug` 같은 전체 ID
- `docs/discovery/<DISCOVERY_ID>.md` 같은 직접 경로
- 대상 Discovery 문서 본문
- 필요 시 관련 저장소 문맥
- 현재 저장소의 관련 파일, 문서, 설정
- `docs/discovery/index.md`
- `docs/project-structure.md`, `docs/runtime-flows.md`
- 현재 시각 (KST)
# Core Rules
## 1. 역할 경계
- 이 skill은 review helper이며, active SDLC 단계로 취급하지 않는다.
- 아래 항목은 수정하지 않는다.
- `# 0. 문서 상태`의 `상태`
- `# 11. Discovery Freeze`의 `Handoff Decision`, `Ready for REQ Drafting`, `Confirmed By`, `Confirmed At (KST)`
- `docs/discovery/index.md`
- 이 skill은 REQ 생성, 승인, 상태 승격을 하지 않는다.
- 사실 관계를 바꾸는 추정성 수정은 하지 않는다. 근거 없는 항목은 finding 또는 OQ 후보로 남긴다.
- 사용자가 단순 검토만 요청한 경우 결과는 findings-first로 보고한다.
- 사용자가 `정리`, `수정`, `보강`, `반영`까지 명시한 경우에만 의미 변화가 없는 경미한 편집을 수행할 수 있다.
## 2. 기본 검토 축
반드시 아래 항목을 검토한다.
### 2-1. 누락 항목
- 템플릿 기준으로 필요한 섹션과 핵심 내용이 빠지지 않았는지 확인한다.
- 빈 bullet, `TBD`, `{{...}}`, `필요 없으면 삭제` 잔여 문구를 누락 또는 미정리 항목으로 본다.
- 아래 핵심 정보가 약하거나 비어 있으면 finding으로 기록한다.
- 문제의 현재 상태와 기대 상태
- 핵심 변경 항목과 영향 범위
- FR/NFR와 범위 경계
- 리스크, 가정, 성공 기준
- REQ handoff 전 확인 기준
- 단, `# 2. 요구사항 판정 결과`가 `완전 중복`이면 템플릿 가이드에 따라 이후 섹션 축약을 허용한다. 이 경우 `# 9. 파일 처리 결과`까지의 중복 판정 근거와 참조만 충분한지 본다.
### 2-2. 추가 OQ 후보
- 아래 조건이면 새 OQ 후보를 제안한다.
- 범위 경계가 모호해 In Scope/Out of Scope가 흔들리는 경우
- 성공 기준은 있는데 측정 방식이나 데이터 출처가 없는 경우
- 리스크 완화 책임자나 판단 주체가 비어 있는 경우
- 외부 정책, 외부 시스템, 운영 절차, 승인자 결정이 필요한데 본문에 근거가 없는 경우
- 서로 다른 섹션이 충돌해서 사람 판단 없이는 확정할 수 없는 경우
- 이미 다른 섹션에서 답이 있는 질문은 OQ로 늘리지 말고, answered cleanup 대상으로 분류한다.
- 사소한 문장 다듬기나 서식 문제는 OQ로 만들지 않는다.
### 2-3. 문서 일관성
- 이슈명, 핵심 변경, FR/NFR, 범위 경계, 성공 기준, Freeze rationale 사이의 용어와 범위가 일치하는지 확인한다.
- `# 4. 이번에 정의할 변경`, `# 5. 요구사항 목록`, `# 6. 리스크/가정 목록`, `# 7. 초기 성공 기준`, `# 8. REQ로 넘기기 전 확인 체크`, `# 10. 사용자 결정 필요 항목 요약`, `# 11. Discovery Freeze`의 연결 관계를 본다.
- `Open` OQ가 남아 있는데 Freeze가 사실상 진행 가능처럼 읽히는지 확인한다.
- 문서 안에서 같은 개념을 서로 다른 이름으로 부르는 경우 정규화 필요 finding으로 기록한다.
### 2-4. 중복 제거 대상
- 같은 사실이나 요구를 여러 섹션에서 반복하는지 찾는다.
- 중복은 아래 세 종류로 분류한다.
- 완전 중복: 문장만 다르지만 의미가 같은 경우
- 부분 중복: 핵심은 같고 세부만 다른 경우
- 잘못된 재기술: 상위 요약과 하위 상세가 서로 다른 말을 하는 경우
- 중복이 있으면 어떤 섹션을 source of truth로 둘지 함께 제안한다.
### 2-5. 불필요한 내용
- Discovery 단계에 필요 없는 구현 디테일, 과도한 알고리즘 설계, 배포 절차 상세, 이미 결정된 이력 서술을 찾는다.
- 현재 반복의 문제 정의와 성공 기준에 기여하지 않는 배경 설명은 삭제 또는 압축 후보로 본다.
- 템플릿 가이드 문구, 예시 텍스트, 삭제 예정 placeholder 설명이 본문에 남아 있으면 제거 대상으로 본다.
## 3. 추가 검토 항목
사용자 요청 외에도 아래를 함께 본다.
### 3-1. 근거와 사실 일치성
- 문서가 현재 저장소 상태를 서술하는 경우, 그 주장에 직접 관련된 파일이나 문서와 모순이 없는지 확인한다.
- 저장소 확인은 문서의 핵심 주장과 직접 연결된 범위만 탐색한다. 구현 전체를 감사하지는 않는다.
- 저장소 근거와 충돌하는 서술은 높은 우선순위 finding으로 기록한다.
### 3-2. 실행 가능성과 측정 가능성
- FR/NFR, 성공 기준, 체크 항목이 다음 단계에서 실제 REQ/검증 항목으로 이어질 정도로 구체적인지 본다.
- `개선한다`, `지원한다`, `정리한다`처럼 모호한 동사만 있고 판정 기준이 없으면 구체화 필요 finding으로 기록한다.
- 성공 기준은 가능한 한 측정 방식과 데이터 출처에 연결되어야 한다.
### 3-3. 플레이스홀더 위생
- 사용자 결정이 필요한 placeholder와 단순 미정리 placeholder를 구분한다.
- 같은 의미의 placeholder가 본문과 `# 10. 사용자 결정 필요 항목 요약`에 중복으로 남아 있으면 정리 대상으로 본다.
- 이미 문서 근거로 해소 가능한 placeholder는 unresolved로 방치하지 않는다.
## 4. 허용되는 편집 범위
사용자가 수정까지 요청한 경우에만 아래 경미한 편집을 허용한다.
- 중복 문장 삭제 또는 압축
- 템플릿 잔여 문구 제거
- 번호, 체크리스트, 링크, 용어 표기 일관성 정리
- 답이 이미 문서 안에 있는 OQ를 본문 반영 후 resolved cleanup으로 정리
- 의미 변화 없는 섹션 재배치
- 실제 내용이 바뀌었을 때 `마지막 갱신 시각(KST)` 갱신
아래는 이 skill로 수정하지 않는다.
- 새 요구사항 생성
- 범위 재정의
- 정책 확정
- 승인자 지정
- 상태 전환과 index 갱신
# Review Heuristics By Section
섹션별로 최소한 아래를 점검한다.
- `# 0. 문서 상태`
- 문서 ID, 이슈명, 시각 정보, 참조 필드가 자기모순 없는지 확인한다.
- `# 1. 계획 상태 요약`
- 현재 작성 배경과 이번 Discovery의 핵심 판단 포인트가 2줄 요약으로 드러나는지 본다.
- `# 2. 요구사항 판정 결과`
- 신규/유사/중복 판정과 근거가 실제 본문 범위와 맞는지 본다.
- `# 3. 문제점의 요약`
- 현재 상태, 기대 상태, 이해관계자, 시나리오가 왜 이 Discovery가 필요한지를 설명하는지 본다.
- `# 4. 이번에 정의할 변경`
- 변경 항목과 영향 범위가 `# 5` 요구사항과 연결되는지 본다.
- `# 5. 요구사항 목록`
- FR/NFR가 구분되어 있고 범위 경계가 충돌하지 않는지 본다.
- `# 6. 리스크/가정 목록`
- 리스크, 가정, OQ가 각각 역할에 맞게 쓰였는지 본다.
- `# 7. 초기 성공 기준`
- 측정 대상, 방식, 데이터 출처가 요구사항과 연결되는지 본다.
- `# 8. REQ로 넘기기 전 확인 체크`
- 체크 항목이 선언만 있고 판단 기준이 없는지 본다.
- `# 9. 파일 처리 결과`
- 참조 문서가 실제 검토 근거를 반영하는지 본다.
- `# 10. 사용자 결정 필요 항목 요약`
- DECIDE, CONFIRM, DATA가 실제 unresolved 항목과 맞는지 본다.
- `# 11. Discovery Freeze`
- review 결과와 무관하게 상태 전환 없이, 본문과 논리적 충돌만 점검한다.
# Execution Procedure
1. 입력에서 Discovery 문서 경로를 확정한다.
2. 대상 Discovery 문서를 읽고, 필요 시 `docs/discovery/index.md`와 직접 관련된 저장소 문맥을 읽는다.
3. 문서를 `중복 판정으로 축약 가능한 문서`인지 `정상 Discovery 검토 대상`인지 먼저 분류한다.
4. 위 `기본 검토 축`과 `추가 검토 항목` 기준으로 findings를 수집한다.
5. findings를 아래 우선순위로 분류한다.
- `blocking`: REQ handoff 또는 사실 관계에 직접 문제를 만드는 항목
- `major`: 지금 고치는 편이 좋은 구조/일관성/측정 가능성 문제
- `minor`: 표현, 중복, 서식, 경미한 cleanup 문제
6. 추가 OQ 후보가 있으면 기존 OQ와 중복 여부를 제거한 뒤 후보 목록으로 정리한다.
7. 사용자가 수정까지 요청한 경우에만 `허용되는 편집 범위` 안에서 경미한 cleanup을 수행한다.
8. 편집을 수행한 경우 `마지막 갱신 시각(KST)`만 갱신하고, 상태 전환 없이 종료한다.
9. 최종적으로 findings, OQ 후보, auto-fix 결과, 다음 추천 행동을 보고한다.
# Output Expectations
작업이 끝나면 아래 내용을 보고할 수 있어야 한다.
- 대상 `DISCOVERY_ID`와 문서 경로
- 전체 판정
- `양호, 바로 confirm-discovery 검토 가능`
- `경미한 정리 필요`
- `추가 보강 필요`
- `문서 구조 또는 사실 관계 재정비 필요`
- severity별 findings 목록
- 새 OQ 후보 목록
- 중복/불필요 내용 정리 제안
- 자동 정리한 항목 목록(수정 요청이 있었을 때만)
- 다음 추천 행동
- `confirm-discovery` 진행
- Discovery 문서 추가 수정 후 재검토
- 범위가 바뀌어 `new-discovery` 또는 `change-req` 판단 필요
# Validation
완료 전에 아래를 확인한다.
- 대상 경로가 실제 Discovery 문서를 가리키는지 확인한다.
- `완전 중복` 예외를 제외하고, 누락 판단이 템플릿 구조와 충돌하지 않는지 확인한다.
- 제안한 OQ가 이미 본문이나 다른 섹션에서 답을 가진 항목이 아닌지 확인한다.
- 같은 finding을 누락/중복/불필요 항목에 중복 보고하지 않았는지 확인한다.
- 수정이 있었다면 상태 전환 값과 `docs/discovery/index.md`가 바뀌지 않았는지 확인한다.
- 저장소 사실 관계와의 충돌을 지적했다면 최소 한 개 이상의 직접 근거가 있는지 확인한다.
+25 -3
View File
@@ -1,10 +1,32 @@
"""Health and readiness endpoints."""
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter
from pydantic import BaseModel
from app.core.config import get_settings
router = APIRouter(tags=["health"])
_KST = timezone(timedelta(hours=9))
@router.get("/health")
def healthcheck() -> dict[str, str]:
return {"status": "ok"}
class HealthResponse(BaseModel):
status: str
service: str
version: str
checked_at: str
@router.get("/health", response_model=HealthResponse)
def healthcheck() -> HealthResponse:
settings = get_settings()
return HealthResponse(
status="ok",
service="portfolio-hub",
version=settings.app_version,
checked_at=datetime.now(_KST).isoformat(),
)
+61
View File
@@ -0,0 +1,61 @@
"""GET /v1/assets — service-token authenticated asset list endpoint."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.auth import AuthContext, require_read
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.v1 import AssetItem, AssetsResponse
router = APIRouter(prefix="/v1", tags=["v1"])
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
if auth.token_source != "db" or auth.token_type != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Service token required",
)
return auth
@router.get("/assets", response_model=AssetsResponse)
def list_assets(auth: AuthContext = Depends(_require_service_token)) -> AssetsResponse:
from app.core.config import get_settings
settings = get_settings()
repo = get_portfolio_repository_from_settings(settings)
try:
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=None,
include_disabled=False,
)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
items = [
AssetItem(
asset_id=p.portfolio_id,
broker=p.broker,
market=p.market,
symbol=p.symbol,
name=p.name,
status=p.status,
cash_balance=p.cash_balance,
asset_quantity=p.asset_quantity,
price=p.price,
profit_buffer=p.profit_buffer,
active_strategy_id=p.active_strategy_id,
available_strategies=p.avabile_strategies,
tags=p.tags,
owner_user_id=p.owner_user_id,
created_at=p.created_at,
updated_at=p.updated_at,
last_trade_at=p.last_trade_at,
)
for p in portfolios
]
return AssetsResponse(assets=items, total=len(items))
+66
View File
@@ -0,0 +1,66 @@
"""POST /v1/assets/{asset_id}/ledger/trades — idempotent ledger trade recording."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.auth import AuthContext, require_read
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import LedgerTradeRequest, LedgerTradeResponse
router = APIRouter(prefix="/v1", tags=["v1"])
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
if auth.token_source != "db" or auth.token_type != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Service token required",
)
return auth
@router.post(
"/assets/{asset_id}/ledger/trades",
response_model=LedgerTradeResponse,
status_code=status.HTTP_201_CREATED,
)
def record_ledger_trade(
asset_id: str,
body: LedgerTradeRequest,
auth: AuthContext = Depends(_require_service_token),
) -> LedgerTradeResponse:
from app.core.config import get_settings
settings = get_settings()
repo = get_portfolio_repository_from_settings(settings)
try:
trade, created = repo.insert_ledger_trade(
asset_id=asset_id,
order_id=body.order_id,
execution_id=body.execution_id,
side=body.side,
quantity=float(body.quantity),
price=float(body.price),
executed_at=body.executed_at,
fee_amount=float(body.fee_amount),
tax_amount=float(body.tax_amount),
)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
return LedgerTradeResponse(
ledger_entry_id=trade.ledger_entry_id,
asset_id=trade.asset_id,
order_id=trade.order_id,
execution_id=trade.execution_id,
side=trade.side,
quantity=trade.quantity,
price=trade.price,
fee_amount=trade.fee_amount,
tax_amount=trade.tax_amount,
executed_at=trade.executed_at,
recorded_at=trade.recorded_at,
created=created,
)
+53
View File
@@ -0,0 +1,53 @@
"""GET /v1/user_info — service-token authenticated user info endpoint."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.auth import AuthContext, require_read
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import UserInfoResponse
router = APIRouter(prefix="/v1", tags=["v1"])
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
if auth.token_source != "db" or auth.token_type != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Service token required",
)
return auth
@router.get("/user_info", response_model=UserInfoResponse)
def get_user_info(auth: AuthContext = Depends(_require_service_token)) -> UserInfoResponse:
if not auth.runtime_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="runtime_id not assigned to this token",
)
from app.core.config import get_settings
settings = get_settings()
repo = get_portfolio_repository_from_settings(settings)
try:
user = repo.get_user(auth.user_id) # type: ignore[arg-type]
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
disabled_reason: str | None = None
if not user.is_active:
disabled_reason = "account_inactive"
return UserInfoResponse(
user_id=user.id,
username=user.username,
email=user.email,
permission=user.permission,
is_active=user.is_active,
disabled_reason=disabled_reason,
runtime_id=auth.runtime_id,
)
+1
View File
@@ -78,6 +78,7 @@ def issue_service_token(
name=name,
expires_at=issued.auth_token.expires_at,
revoked=issued.auth_token.revoked_at is not None,
runtime_id=issued.auth_token.runtime_id,
token=issued.token,
)
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
+2
View File
@@ -28,6 +28,7 @@ class AuthContext:
token_id: str | None = None
token_type: str | None = None
token_source: str = "env"
runtime_id: str | None = None
def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]:
repository = get_portfolio_repository_from_settings(settings)
@@ -66,6 +67,7 @@ def _resolve_auth_context(token: str, settings: Settings, repository: PortfolioR
token_id=principal.token_id,
token_type=principal.token_type,
token_source="db",
runtime_id=principal.runtime_id,
)
+17 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from urllib.parse import quote_plus
from dotenv import load_dotenv
from pydantic import Field, computed_field
from pydantic import Field, computed_field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Load .env file from project root
@@ -50,6 +50,7 @@ class Settings(BaseSettings):
loki_host: str = Field(default="localhost", validation_alias="LOKI_HOST")
loki_port: int = Field(default=3100, validation_alias="LOKI_PORT")
loki_enabled: bool = Field(default=False, validation_alias="LOKI_ENABLED")
credential_encryption_key: str = Field(default="", validation_alias="CREDENTIAL_ENCRYPTION_KEY")
model_config = SettingsConfigDict(
env_prefix="PORTFOLIOHUB_",
@@ -58,6 +59,21 @@ class Settings(BaseSettings):
populate_by_name=True,
)
@model_validator(mode="after")
def _validate_credential_encryption_key(self) -> "Settings":
# Only validate at runtime when the key is actually used.
# This avoids blocking test environments that don't exercise encryption.
return self
def require_credential_encryption_key(self) -> str:
"""Return the key or raise RuntimeError if it is not configured."""
if not self.credential_encryption_key.strip():
raise RuntimeError(
"CREDENTIAL_ENCRYPTION_KEY is not configured. "
"Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
)
return self.credential_encryption_key
@computed_field
@property
def allowed_origins(self) -> list[str]:
+26
View File
@@ -0,0 +1,26 @@
"""Fernet symmetric encryption utilities for credential payloads."""
from __future__ import annotations
from cryptography.fernet import Fernet, InvalidToken # noqa: F401 — re-exported for callers
def make_fernet(key: str) -> Fernet:
"""Return a Fernet instance from a base64-encoded key string."""
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt_payload(key: str, plaintext: str) -> str:
"""Encrypt *plaintext* and return the base64 ciphertext string."""
f = make_fernet(key)
return f.encrypt(plaintext.encode()).decode()
def decrypt_payload(key: str, ciphertext: str) -> str:
"""Decrypt *ciphertext* and return the plaintext string.
Raises ``cryptography.fernet.InvalidToken`` if the key is wrong or the
token is malformed.
"""
f = make_fernet(key)
return f.decrypt(ciphertext.encode()).decode()
+28
View File
@@ -41,6 +41,7 @@ class AuthToken:
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime | None = None
runtime_id: str | None = None
@dataclass
@@ -49,6 +50,7 @@ class TokenPrincipal:
user_id: str
permission: str
token_type: str
runtime_id: str | None = None
@dataclass
@@ -58,6 +60,7 @@ class Portfolio:
status: str
name: str
symbol: str
market: str | None = None
tags: list[str] | None = None
owner_user_id: str | None = None
cash_balance: float = 0.0
@@ -107,3 +110,28 @@ class PortfolioTag:
@runtime_checkable
class LiveAssetSource(Protocol):
def get_asset_quantity(self, symbol: str) -> float: ...
@dataclass
class BrokerCredential:
user_id: str
broker: str
account_ref: str
encrypted_payload: str
credential_version: int = 1
last_rotated_at: datetime | None = None
@dataclass
class LedgerTrade:
ledger_entry_id: int
asset_id: str
order_id: str
execution_id: str
side: str
quantity: float
price: float
fee_amount: float
tax_amount: float
executed_at: datetime
recorded_at: datetime | None = None
+6
View File
@@ -11,6 +11,9 @@ from app.api.auth_routes import router as auth_router
from app.api.portfolios import router as portfolio_router
from app.api.strategy_sets import router as strategy_sets_router
from app.api.users import router as users_router
from app.api.v1_assets import router as v1_assets_router
from app.api.v1_user_info import router as v1_user_info_router
from app.api.v1_ledger import router as v1_ledger_router
from app.core.config import Settings, get_settings
from app.core.errors import register_exception_handlers
from app.core.security import hash_password
@@ -73,6 +76,9 @@ def create_app(
app.include_router(portfolio_router)
app.include_router(strategy_sets_router)
app.include_router(users_router)
app.include_router(v1_assets_router)
app.include_router(v1_user_info_router)
app.include_router(v1_ledger_router)
register_exception_handlers(app)
return app
+211 -8
View File
@@ -11,7 +11,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -38,7 +38,8 @@ _SELECT_AUTH_TOKEN = """
expires_at,
last_used_at,
revoked_at,
created_at
created_at,
runtime_id
FROM auth_tokens
"""
@@ -58,7 +59,8 @@ _SELECT_PORTFOLIO = """
owner_user_id,
created_at,
updated_at,
last_trade_at
last_trade_at,
market
FROM portfolios
"""
@@ -300,6 +302,7 @@ class PostgresPortfolioRepository:
last_used_at=row[7] if len(row) > 7 else None,
revoked_at=row[8] if len(row) > 8 else None,
created_at=row[9] if len(row) > 9 else None,
runtime_id=row[10] if len(row) > 10 else None,
)
@staticmethod
@@ -332,6 +335,7 @@ class PostgresPortfolioRepository:
created_at=row[12] if len(row) > 12 else None,
updated_at=row[13] if len(row) > 13 else None,
last_trade_at=row[14] if len(row) > 14 else None,
market=row[15] if len(row) > 15 else None,
)
@staticmethod
@@ -532,6 +536,7 @@ class PostgresPortfolioRepository:
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
@@ -542,14 +547,14 @@ class PostgresPortfolioRepository:
cur.execute(
"""
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at
expires_at, last_used_at, revoked_at, created_at, runtime_id
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@@ -612,7 +617,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, t.user_id, t.permission, t.token_type
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id
FROM auth_tokens t
WHERE token_hash = %s
AND t.revoked_at IS NULL
@@ -629,6 +634,7 @@ class PostgresPortfolioRepository:
user_id=row[1],
permission=row[2],
token_type=row[3],
runtime_id=row[4] if len(row) > 4 else None,
)
def get(self, portfolio_id: str) -> Portfolio:
@@ -1296,6 +1302,203 @@ class PostgresPortfolioRepository:
if cur.fetchone() is None:
raise ValueError("댓글을 찾을 수 없습니다")
# ------------------------------------------------------------------
# Broker credentials
# ------------------------------------------------------------------
@staticmethod
def _row_to_broker_credential(row: Any) -> BrokerCredential:
if row is None:
raise ValueError("자격증명을 찾을 수 없습니다")
return BrokerCredential(
user_id=row[0],
broker=row[1],
account_ref=row[2],
encrypted_payload=row[3],
credential_version=int(row[4]),
last_rotated_at=row[5] if len(row) > 5 else None,
)
def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE user_id = %s AND broker = %s
""",
(user_id, broker),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_broker_credential(row)
def upsert_broker_credential(
self,
user_id: str,
broker: str,
*,
account_ref: str,
encrypted_payload: str,
) -> BrokerCredential:
with self._connect().cursor() as cur:
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
VALUES (%s, %s, %s, %s, 1, NOW())
ON CONFLICT (user_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
""",
(user_id, broker, account_ref, encrypted_payload),
)
row = cur.fetchone()
return self._row_to_broker_credential(row)
# ------------------------------------------------------------------
# Ledger trades
# ------------------------------------------------------------------
@staticmethod
def _row_to_ledger_trade(row: Any) -> LedgerTrade:
if row is None:
raise ValueError("원장 거래를 찾을 수 없습니다")
return LedgerTrade(
ledger_entry_id=int(row[0]),
asset_id=row[1],
order_id=row[2],
execution_id=row[3],
side=row[4],
quantity=float(row[5]),
price=float(row[6]),
fee_amount=float(row[7]),
tax_amount=float(row[8]),
executed_at=row[9],
recorded_at=row[10] if len(row) > 10 else None,
)
def insert_ledger_trade(
self,
asset_id: str,
order_id: str,
execution_id: str,
side: str,
quantity: float,
price: float,
executed_at: datetime,
*,
fee_amount: float = 0.0,
tax_amount: float = 0.0,
) -> tuple[LedgerTrade, bool]:
"""Insert a ledger trade, returning (trade, created).
Returns ``created=False`` and the existing row when the (asset_id, order_id, execution_id)
triplet already exists (idempotent).
"""
from decimal import Decimal
with self._connect().cursor() as cur:
try:
cur.execute(
"""
INSERT INTO ledger_trades
(asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
ledger_entry_id, asset_id, order_id, execution_id, side,
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
""",
(
asset_id,
order_id,
execution_id,
side,
Decimal(str(quantity)),
Decimal(str(price)),
Decimal(str(fee_amount)),
Decimal(str(tax_amount)),
executed_at,
),
)
row = cur.fetchone()
return self._row_to_ledger_trade(row), True
except psycopg.errors.UniqueViolation:
cur.execute(
"""
SELECT
ledger_entry_id, asset_id, order_id, execution_id, side,
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
FROM ledger_trades
WHERE asset_id = %s AND order_id = %s AND execution_id = %s
""",
(asset_id, order_id, execution_id),
)
row = cur.fetchone()
return self._row_to_ledger_trade(row), False
def list_ledger_trades(self, asset_id: str, *, limit: int = 100) -> list[LedgerTrade]:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT
ledger_entry_id, asset_id, order_id, execution_id, side,
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
FROM ledger_trades
WHERE asset_id = %s
ORDER BY executed_at DESC, ledger_entry_id DESC
LIMIT %s
""",
(asset_id, limit),
)
rows = cur.fetchall()
return [self._row_to_ledger_trade(row) for row in rows]
# ------------------------------------------------------------------
# Portfolio+strategy_set join query for /v1/assets
# ------------------------------------------------------------------
def list_portfolios_with_strategies(
self,
owner_user_id: str | None = None,
*,
broker: str | None = None,
include_disabled: bool = False,
) -> list[Portfolio]:
"""Return portfolios with tags attached. Optionally filter by owner or broker."""
conditions: list[str] = []
params: list[object] = []
if not include_disabled:
conditions.append("p.status != 'disabled'")
if owner_user_id is not None:
conditions.append("p.owner_user_id = %s")
params.append(owner_user_id)
if broker is not None:
conditions.append("p.broker = %s")
params.append(broker)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
with self._connect().cursor() as cur:
cur.execute(
f"""
SELECT
p.portfolio_id, p.broker, p.status, p.name, p.symbol,
p.cash_balance, p.asset_quantity, p.asset_price, p.profit_buffer,
p.active_strategy_id, p.avabile_strategies, p.owner_user_id,
p.created_at, p.updated_at, p.last_trade_at, p.market
FROM portfolios p
{where}
ORDER BY p.updated_at DESC, p.portfolio_id ASC
""",
params,
)
rows = cur.fetchall()
portfolios = [self._row_to_portfolio(row) for row in rows]
self._attach_tags(cur, portfolios)
return portfolios
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(
+1
View File
@@ -75,6 +75,7 @@ class ServiceTokenIssueCliResponse(BaseModel):
name: str
expires_at: datetime | None = None
revoked: bool
runtime_id: str | None = None
token: str | None = None
+82
View File
@@ -0,0 +1,82 @@
"""Schemas for v1 (Consumer-Driven Contract) API endpoints."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# GET /v1/assets
# ---------------------------------------------------------------------------
class AssetItem(BaseModel):
asset_id: str
broker: str
market: str | None = None
symbol: str
name: str
status: str
cash_balance: float
asset_quantity: float
price: float
profit_buffer: float
active_strategy_id: str | None = None
available_strategies: list[str] | None = None
tags: list[str] | None = None
owner_user_id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
last_trade_at: datetime | None = None
class AssetsResponse(BaseModel):
assets: list[AssetItem]
total: int
# ---------------------------------------------------------------------------
# GET /v1/user_info
# ---------------------------------------------------------------------------
class UserInfoResponse(BaseModel):
user_id: str
username: str
email: str | None = None
permission: str
is_active: bool
disabled_reason: str | None = None
runtime_id: str
# ---------------------------------------------------------------------------
# POST /v1/assets/{asset_id}/ledger/trades
# ---------------------------------------------------------------------------
class LedgerTradeRequest(BaseModel):
order_id: str = Field(..., min_length=1)
execution_id: str = Field(..., min_length=1)
side: Literal["buy", "sell"]
quantity: Decimal = Field(..., gt=0)
price: Decimal = Field(..., gt=0)
fee_amount: Decimal = Field(default=Decimal("0"), ge=0)
tax_amount: Decimal = Field(default=Decimal("0"), ge=0)
executed_at: datetime
class LedgerTradeResponse(BaseModel):
ledger_entry_id: int
asset_id: str
order_id: str
execution_id: str
side: str
quantity: float
price: float
fee_amount: float
tax_amount: float
executed_at: datetime
recorded_at: datetime | None = None
created: bool
+3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@@ -131,6 +132,7 @@ class AuthService:
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
raw_token = generate_token()
runtime_id = str(uuid.uuid4())
try:
auth_token = self._repository.create_auth_token(
user.id,
@@ -140,6 +142,7 @@ class AuthService:
permission=permission,
token_type="service",
expires_at=expires_at,
runtime_id=runtime_id,
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
@@ -0,0 +1,95 @@
# BAT-001 Design
- Status: draft
- Batch: BAT-001
## Architecture Summary
trex-basic Consumer-Driven Contract를 충족하기 위해 4개 계층에 걸쳐 변경한다.
1. **DB 레이어** (REQ-009): Alembic migration 1개 추가로 4개 스키마 변경
2. **보안 레이어** (REQ-010): `app/core/encryption.py` 신규 — Fernet 암복호화 유틸리티, `app/core/config.py` startup validation
3. **인증 확장** (REQ-005): service-token 발급 시 UUID v4 runtime_id 동시 생성
4. **v1 API 레이어** (REQ-004, 006, 007, 008): `/v1` prefix APIRouter 신규, 기존 `/health` 응답 확장
## Changed Areas
- Docs:
- docs/project-structure.md (신규 모듈 app/core/encryption.py, app/api/v1_*.py 반영)
- docs/runtime-flows.md (v1 API 요청 흐름, runtime_id 발급 흐름 추가)
- Modules:
- app/core/encryption.py (신규)
- app/core/config.py (CREDENTIAL_ENCRYPTION_KEY 필드 + startup validation)
- app/domain/models.py (AuthToken.runtime_id, BrokerCredential, LedgerTrade 추가)
- app/api/health.py (HealthResponse 스키마 적용, checked_at/service/version 필드 추가)
- app/api/auth_routes.py (service-token 발급 응답에 runtime_id 포함)
- app/api/v1_assets.py (신규)
- app/api/v1_user_info.py (신규)
- app/api/v1_ledger.py (신규)
- app/main.py (v1 router include 추가)
- app/cli/commands/service_tokens.py (runtime_id 출력 추가)
- app/services/auth_service.py (runtime_id 생성·저장 로직)
- app/schemas/ (HealthResponse, v1 Assets/UserInfo/Ledger 스키마 추가)
- app/repositories/postgres.py (broker_credentials, ledger_trades, runtime_id 쿼리 추가)
- migrations/versions/ (신규 migration 파일)
- Interfaces:
- GET /health: 응답 필드 추가 (service, version, checked_at) — 기존 status 필드 유지로 하위 호환
- POST /auth/service-tokens (또는 동등 경로): 응답에 runtime_id 추가
- GET /v1/assets: 신규
- GET /v1/user_info: 신규
- POST /v1/assets/{asset_id}/ledger/trades: 신규
## Key Decisions
1. **v1 라우터 구조**: `app/api/v1_*.py` 파일 3개를 각각 별도 모듈로 작성하고 `app/main.py`에서 `prefix="/v1"`로 일괄 등록한다. 기존 `/portfolios`, `/auth`, `/users` 라우터와 prefix가 겹치지 않는다.
2. **R-4 처리 (runtime_id 없는 구형 service-token)**: GET /v1/user_info 호출 시 해당 토큰에 runtime_id가 없으면 **HTTP 404** (`detail: "runtime_id not assigned to this token"`)를 반환한다. 즉시 생성 방식은 채택하지 않는다 — 기존 토큰 소유자에게 재발급을 요구하는 것이 명시적이고 안전하다.
3. **disabled_reason**: `users.is_active = false`인 경우 고정값 `"account_inactive"`를 반환한다. `is_active = true`인 경우 `disabled_reason`은 `null`로 반환한다.
4. **Fernet startup validation 위치**: `app/core/config.py`의 `Settings` 클래스 `model_post_init` 또는 FastAPI `lifespan` 시작 시 수행한다. `CREDENTIAL_ENCRYPTION_KEY`가 없으면 `RuntimeError`를 발생시켜 기동을 중단한다. 테스트 환경에서는 `CREDENTIAL_ENCRYPTION_KEY` 환경변수를 픽스처로 주입한다.
5. **runtime_id 포맷**: `str(uuid.uuid4())` — 하이픈 포함 UUID v4 문자열 (예: `"550e8400-e29b-41d4-a716-446655440000"`).
6. **broker_credentials 컬럼 타입**:
- `user_id`: INTEGER NOT NULL (users.user_id FK)
- `broker`: TEXT NOT NULL
- `account_ref`: TEXT NOT NULL
- `encrypted_payload`: TEXT NOT NULL
- `credential_version`: INTEGER NOT NULL DEFAULT 1
- `last_rotated_at`: TIMESTAMPTZ NOT NULL DEFAULT NOW()
- PRIMARY KEY: `(user_id, broker)`
7. **ledger_trades 컬럼 타입**:
- `ledger_entry_id`: BIGSERIAL PRIMARY KEY
- `asset_id`: INTEGER NOT NULL (portfolios.portfolio_id 참조, FK 선택적)
- `order_id`: TEXT NOT NULL
- `execution_id`: TEXT NOT NULL
- `side`: TEXT NOT NULL (buy/sell)
- `quantity`: NUMERIC(20, 8) NOT NULL
- `price`: NUMERIC(20, 8) NOT NULL
- `fee_amount`: NUMERIC(20, 8) NOT NULL DEFAULT 0
- `tax_amount`: NUMERIC(20, 8) NOT NULL DEFAULT 0
- `executed_at`: TIMESTAMPTZ NOT NULL
- `recorded_at`: TIMESTAMPTZ NOT NULL DEFAULT NOW()
- UNIQUE: `(asset_id, order_id, execution_id)`
8. **앱 버전**: `app/core/config.py`의 `Settings.app_version` 값을 `GET /health` 응답의 `version` 필드로 사용한다. 현재 값: `"0.1.0"`.
9. **checked_at 타임존**: KST (UTC+9) ISO 8601 문자열로 반환한다. `datetime.now(timezone(timedelta(hours=9))).isoformat()`.
## Edge Cases
- `strategy_params` 파싱 실패(StrategySet.config가 null이거나 유효하지 않은 JSON): `{}` 폴백, 에러 전파 없음.
- `ledger_trades` 중복 삽입 시 DB unique constraint 위반: psycopg `UniqueViolation` 캐치 → 기존 행 조회 → payload 비교 → 동일이면 200+duplicate, 다르면 409.
- `broker_credentials`가 없는 사용자의 GET /v1/user_info: `brokers: []` 반환.
- `CREDENTIAL_ENCRYPTION_KEY` 환경변수 없이 테스트 실행 시: 테스트 픽스처에서 `Fernet.generate_key()`로 임시 키 주입.
## Architecture Impact
- both (project-structure + runtime-flows)
## Reference Doc Update Plan
- project-structure.md: 신규 모듈(app/core/encryption.py, app/api/v1_assets.py, app/api/v1_user_info.py, app/api/v1_ledger.py) 반영 필요. implementation 완료 후 REQ-011에서 갱신.
- runtime-flows.md: v1 API 요청 처리 흐름(service-token 인증 → 서비스 → DB), runtime_id 발급 흐름 추가 필요. implementation 완료 후 REQ-011에서 갱신.
@@ -0,0 +1,157 @@
# BAT-001 Implementation
- Status: implemented
- Batch: BAT-001
## Plan Summary
design.md에서 확정된 결정 사항을 기반으로 아래 범위를 구현한다.
1. app/core/encryption.py 신규 — Fernet 암복호화 유틸리티
2. app/core/config.py — CREDENTIAL_ENCRYPTION_KEY 필드 추가 및 require_credential_encryption_key() 메서드
3. Alembic migration 신규 — 4개 스키마 변경 (IF NOT EXISTS 패턴)
4. app/domain/models.py — AuthToken.runtime_id, TokenPrincipal.runtime_id, BrokerCredential, LedgerTrade 추가
5. app/services/auth_service.py — service-token 발급 시 UUID v4 runtime_id 생성·저장
6. app/cli/commands/service_tokens.py — 응답/출력에 runtime_id 포함
7. app/core/auth.py — AuthContext.runtime_id 추가, _resolve_auth_context에서 전달
8. app/api/health.py, app/schemas/v1.py — HealthResponse 스키마 적용
9. app/api/v1_assets.py — GET /v1/assets 구현
10. app/api/v1_user_info.py — GET /v1/user_info 구현 (R-4: 404 반환)
11. app/api/v1_ledger.py — POST /v1/assets/{asset_id}/ledger/trades 구현
12. app/repositories/postgres.py — broker_credentials, ledger_trades, runtime_id 쿼리 추가
13. app/main.py — /v1 라우터 등록
14. tests/ — 테스트 파일 작성 및 기존 테스트 확장
## Changed Files
- app/core/encryption.py (신규)
- app/core/config.py (수정: credential_encryption_key 필드, require_credential_encryption_key 메서드, model_validator import)
- app/core/auth.py (수정: AuthContext.runtime_id, _resolve_auth_context runtime_id 전달)
- migrations/versions/20260502_0014_v1_api_schema.py (신규)
- app/domain/models.py (수정: AuthToken.runtime_id, TokenPrincipal.runtime_id, Portfolio.market, BrokerCredential, LedgerTrade)
- app/services/auth_service.py (수정: uuid import, runtime_id 생성·전달)
- app/cli/commands/service_tokens.py (수정: ServiceTokenIssueCliResponse에 runtime_id 포함)
- app/api/health.py (수정: HealthResponse 스키마 적용)
- app/schemas/auth.py (수정: ServiceTokenIssueCliResponse.runtime_id 추가)
- app/schemas/v1.py (신규: AssetsResponse, UserInfoResponse, LedgerTradeRequest/Response)
- app/api/v1_assets.py (신규)
- app/api/v1_user_info.py (신규)
- app/api/v1_ledger.py (신규)
- app/repositories/postgres.py (수정: _SELECT_AUTH_TOKEN runtime_id, _SELECT_PORTFOLIO market, _row_to_auth_token runtime_id, _row_to_portfolio market, create_auth_token runtime_id, get_token_principal runtime_id, broker_credentials CRUD, ledger_trades CRUD, list_portfolios_with_strategies)
- app/main.py (수정: v1 라우터 3개 등록)
- tests/test_app.py (수정: test_healthcheck 응답 필드 검증 업데이트)
- tests/test_auth.py (수정: _AuthRepo.create_auth_token runtime_id 파라미터 추가)
- tests/test_cli.py (수정: FakeRepo.create_auth_token runtime_id 파라미터 추가)
- tests/test_v1.py (신규: health/assets/user_info/ledger 9개 테스트)
## Execution Log
1. [x] app/core/encryption.py 신규 구현
2. [x] app/core/config.py CREDENTIAL_ENCRYPTION_KEY 추가 및 require_credential_encryption_key() 메서드
3. [x] Alembic migration 파일 작성 (20260502_0014_v1_api_schema.py)
4. [x] app/domain/models.py 모델 필드/클래스 추가
5. [x] auth_service.py runtime_id 생성·저장 구현
6. [x] service_tokens.py runtime_id 출력 추가
7. [x] app/core/auth.py AuthContext.runtime_id 추가
8. [x] app/api/health.py HealthResponse 스키마 적용
9. [x] app/api/v1_assets.py 구현
10. [x] app/api/v1_user_info.py 구현 (runtime_id 없는 구형 토큰 → HTTP 404)
11. [x] app/api/v1_ledger.py 구현 (idempotent)
12. [x] app/repositories/postgres.py 쿼리 추가
13. [x] app/main.py /v1 라우터 등록
14. [x] 테스트 작성 및 전체 테스트 스위트 통과 (88 passed)
## Validation
- [x] pytest 88개 전체 통과 (2026-05-02)
- [ ] alembic upgrade head 성공 (로컬 Docker Compose 환경 — DB 없이 코드 검증만 완료)
- [x] GET /health 응답에 service/version/checked_at(+09:00 KST) 포함 확인 (테스트)
- [x] GET /v1/assets 200 응답 확인 (테스트)
- [x] GET /v1/user_info runtime_id 없는 토큰 → 404 확인 (테스트)
- [x] POST /v1/assets/{asset_id}/ledger/trades 중복 → created=false 확인 (테스트)
## Design Decisions (confirmed during implementation)
- CREDENTIAL_ENCRYPTION_KEY: Settings 생성 시 validator 없음. `require_credential_encryption_key()` 메서드 호출 시 RuntimeError 발생 (lazy validation)
- R-4 (구형 service-token runtime_id 없음): HTTP 404, detail="runtime_id not assigned to this token"
- disabled_reason: is_active=False 시 "account_inactive" 고정값
## Remaining Risks
- (해소) R1: CREDENTIAL_ENCRYPTION_KEY — lazy validation으로 처리. 테스트 환경 unblocked.
design.md에서 확정된 결정 사항을 기반으로 아래 범위를 구현한다.
1. app/core/encryption.py 신규 — Fernet 암복호화 유틸리티
2. app/core/config.py — CREDENTIAL_ENCRYPTION_KEY 필드 추가 및 startup validation
3. Alembic migration 신규 — 4개 스키마 변경 (IF NOT EXISTS 패턴)
4. app/domain/models.py — AuthToken.runtime_id, BrokerCredential, LedgerTrade 추가
5. app/services/auth_service.py — service-token 발급 시 UUID v4 runtime_id 생성·저장
6. app/api/auth_routes.py, app/cli/commands/service_tokens.py — 응답/출력에 runtime_id 포함
7. app/api/health.py, app/schemas/ — HealthResponse 스키마 적용
8. app/api/v1_assets.py — GET /v1/assets 구현
9. app/api/v1_user_info.py — GET /v1/user_info 구현 (R-4: 404 반환)
10. app/api/v1_ledger.py — POST /v1/assets/{asset_id}/ledger/trades 구현
11. app/repositories/postgres.py — broker_credentials, ledger_trades, runtime_id 쿼리 추가
12. app/main.py — /v1 라우터 등록
13. 테스트 파일 작성 및 기존 테스트 확장
## Changed Files
- app/core/encryption.py (신규)
- app/core/config.py (수정)
- migrations/versions/20260502_0014_v1_api_schema.py (신규)
- app/domain/models.py (수정)
- app/services/auth_service.py (수정)
- app/api/auth_routes.py (수정)
- app/cli/commands/service_tokens.py (수정)
- app/api/health.py (수정)
- app/schemas/ (수정: HealthResponse 추가, v1 스키마 추가)
- app/api/v1_assets.py (신규)
- app/api/v1_user_info.py (신규)
- app/api/v1_ledger.py (신규)
- app/repositories/postgres.py (수정)
- app/main.py (수정)
- tests/test_health.py (신규 또는 test_app.py 확장)
- tests/test_auth.py (확장)
- tests/test_v1_assets.py (신규)
- tests/test_v1_user_info.py (신규)
- tests/test_v1_ledger.py (신규)
## Execution Log
_(구현 진행 시 단계별 기록)_
1. [ ] app/core/encryption.py 신규 구현
2. [ ] app/core/config.py CREDENTIAL_ENCRYPTION_KEY 추가 및 startup validation
3. [ ] Alembic migration 파일 작성 및 로컬 upgrade head 성공 확인
4. [ ] app/domain/models.py 모델 필드/클래스 추가
5. [ ] auth_service.py runtime_id 생성·저장 구현
6. [ ] auth_routes.py, service_tokens.py runtime_id 출력 추가
7. [ ] app/api/health.py HealthResponse 스키마 적용
8. [ ] app/api/v1_assets.py 구현
9. [ ] app/api/v1_user_info.py 구현
10. [ ] app/api/v1_ledger.py 구현
11. [ ] app/repositories/postgres.py 쿼리 추가
12. [ ] app/main.py /v1 라우터 등록
13. [ ] 테스트 작성 및 전체 테스트 스위트 통과
## Validation
_(구현 완료 시 기록)_
- [ ] pytest 전체 통과
- [ ] CREDENTIAL_ENCRYPTION_KEY 없이 기동 시 RuntimeError 확인
- [ ] alembic upgrade head 성공 (로컬 Docker Compose 환경)
- [ ] GET /health 응답에 service/version/checked_at 포함 확인
- [ ] GET /v1/assets 200 응답 확인
- [ ] GET /v1/user_info runtime_id 없는 토큰 → 404 확인
- [ ] POST /v1/assets/{asset_id}/ledger/trades 중복 → 200+duplicate 확인
## Remaining Risks
_(구현 전 상태 — 진행 중 업데이트)_
- R1: CREDENTIAL_ENCRYPTION_KEY 테스트 픽스처 처리 방식 확정 필요
- R2: psycopg UniqueViolation import 경로 확인 필요 (psycopg v3 기준)
@@ -0,0 +1,67 @@
# BAT-001 Index
- Status: in-delivery
- Batch: BAT-001
- Profile: standard
- Created: 2026-05-02
## Included REQ
| REQ | 제목 | Type | Priority |
|-----|------|------|----------|
| REQ-004 | GET /health 응답 스키마 확장 | Interface | High |
| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | High |
| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | High |
| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | High |
| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 구현 | Interface | High |
| REQ-009 | v1 API DB 스키마 migration 추가 | Migration | High |
| REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | High |
## Source Discovery
- dcy-002: trex-basic Consumer-Driven Contract 반영
## Scope Summary
- GET /health 응답 확장 (service, version, checked_at 필드 추가)
- service-token 발급 시 runtime_id UUID v4 동시 생성 및 auth_tokens 저장
- GET /v1/assets 엔드포인트 신규 구현 (portfolio → asset 뷰 변환)
- GET /v1/user_info 엔드포인트 신규 구현 (runtime_id, trading_enabled, brokers[] 복호화 반환)
- POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 신규 구현 (idempotent 체결 기록)
- Alembic migration: portfolios.market 컬럼, auth_tokens.runtime_id 컬럼, broker_credentials 테이블, ledger_trades 테이블 추가
- Fernet 암복호화 유틸리티 신규 구현 및 startup validation
## Impacted Area
- Docs: docs/project-structure.md, docs/runtime-flows.md (design 완료 후 갱신)
- Modules:
- app/api/health.py (수정)
- app/api/auth_routes.py (수정)
- app/api/v1_assets.py (신규)
- app/api/v1_user_info.py (신규)
- app/api/v1_ledger.py (신규)
- app/main.py (수정: /v1 라우터 등록)
- app/cli/commands/service_tokens.py (수정)
- app/core/config.py (수정: CREDENTIAL_ENCRYPTION_KEY 추가)
- app/core/encryption.py (신규: Fernet 유틸리티)
- app/domain/models.py (수정: AuthToken.runtime_id, BrokerCredential, LedgerTrade 추가)
- app/services/auth_service.py (수정)
- app/services/ (신규 서비스 메서드)
- app/schemas/ (신규 v1 스키마)
- app/repositories/postgres.py (수정: 신규 테이블 쿼리)
- migrations/versions/ (신규 migration 파일)
- Tests:
- tests/test_app.py 또는 tests/test_health.py (신규/확장)
- tests/test_auth.py (확장)
- tests/test_v1_assets.py (신규)
- tests/test_v1_user_info.py (신규)
- tests/test_v1_ledger.py (신규)
## Stage Documents
| Stage | File | Status |
|-------|------|--------|
| Planning | [planning.md](./planning.md) | draft |
| Design | [design.md](./design.md) | draft |
| Implementation | [implementation.md](./implementation.md) | draft |
| Verification | [verification.md](./verification.md) | draft |
@@ -0,0 +1,83 @@
# BAT-001 Planning
- Status: confirmed
- Batch: BAT-001
- Profile: standard
## Included REQ
- REQ-004: GET /health 응답 스키마 확장
- REQ-005: service-token 발급 시 runtime_id 동시 생성
- REQ-006: GET /v1/assets 엔드포인트 구현
- REQ-007: GET /v1/user_info 엔드포인트 구현
- REQ-008: POST /v1/assets/{asset_id}/ledger/trades 구현
- REQ-009: v1 API DB 스키마 migration 추가
- REQ-010: credential_payload Fernet 암호화 및 startup validation
## Delivery Plan
구현 선행 조건과 의존성 방향을 기준으로 아래 순서로 진행한다.
### Out of Scope
- REQ-001 (Testing — dcy-001 범위, 별도 batch 예정)
- REQ-002 (Migration 정책 문서 — 기 반영됨)
- REQ-003 (Documentation — dcy-001 runbook, 별도 처리)
- REQ-011 (Documentation — 본 batch 구현 완료 후 별도 task)
- broker_credentials 키 로테이션 절차
- disabled_reason 동적 계산 (이번 반복은 고정값 "account_inactive")
- GET /v1/assets 페이지네이션
### 1단계: 기반 레이어 (의존 없음)
- REQ-010: app/core/encryption.py 신규 구현, app/core/config.py에 CREDENTIAL_ENCRYPTION_KEY startup validation 추가
- REQ-009: Alembic migration 파일 작성 (portfolios.market, auth_tokens.runtime_id, broker_credentials, ledger_trades), app/domain/models.py 모델 필드/클래스 추가
### 2단계: 인증 확장 (REQ-009/010 완료 후)
- REQ-005: app/cli/commands/service_tokens.py, app/api/auth_routes.py, app/services/auth_service.py에 runtime_id UUID v4 생성·저장 추가
### 3단계: v1 엔드포인트 (REQ-009/010/005 완료 후)
- REQ-004: app/api/health.py 응답 스키마 확장 (service, version, checked_at 추가), HealthResponse 스키마 추가
- REQ-006: app/api/v1_assets.py 신규, 서비스·스키마·레포지터리 메서드 추가, app/main.py /v1 라우터 등록
- REQ-007: app/api/v1_user_info.py 신규, BrokerCredential 조회·복호화 서비스·스키마 추가
- REQ-008: app/api/v1_ledger.py 신규, LedgerTrade 저장·중복 감지 서비스·스키마 추가
### 4단계: 테스트
- 각 엔드포인트 단위 테스트 작성 및 기존 test_auth.py 확장
## Dependencies
- Internal:
- REQ-009 migration이 REQ-005, 006, 007, 008 구현의 선행 조건 (컬럼·테이블 존재 필요)
- REQ-010 암호화 유틸리티가 REQ-007 credential_payload 복호화의 선행 조건
- REQ-005 runtime_id 발급이 REQ-007 GET /v1/user_info runtime_id 반환의 선행 조건
- app/main.py /v1 라우터 등록은 REQ-006/007/008 세 라우터를 한 번에 처리
- External:
- CREDENTIAL_ENCRYPTION_KEY 환경변수 (운영 환경 설정 필요)
- Docker Compose local-test 환경에서 Alembic upgrade head 선행 필요
## Design Gate
- Design Required Before Implementation: yes
- Reason:
- /v1 라우터 구조 및 app/main.py 등록 방식 결정 필요 (prefix, router 분리 또는 통합)
- GET /v1/user_info의 R-4 처리 방식 결정 필요 (runtime_id 없는 구형 service-token: 404 또는 즉시 생성)
- disabled_reason 고정값 방식 확정 필요 ("account_inactive" 등)
- Fernet startup validation 위치 확정 필요 (FastAPI lifespan vs Settings 초기화)
- broker_credentials 테이블 상세 컬럼 타입 확정 필요 (NUMERIC vs FLOAT, TIMESTAMPTZ 등)
- runtime_id 포맷 확정 필요 (UUID v4 권장, 설계 단계 확정)
## Milestones
1. design.md confirmed — 모든 Design Gate 결정 사항 해소
2. REQ-009 migration 구현 완료 및 로컬 DB upgrade head 성공
3. REQ-010 Fernet 유틸리티 구현 완료 및 startup validation 동작 확인
4. REQ-005 runtime_id 발급 구현 완료 및 단위 테스트 통과
5. REQ-004/006/007/008 엔드포인트 구현 완료
6. 전체 테스트 스위트 통과
7. verification.md confirmed
## Risks
- R1: GET /v1/user_info R-4 처리(기존 runtime_id 없는 토큰) 미결 — 설계 단계에서 반드시 확정. 404 선택 시 기존 service-token 무효화 영향 범위 검토 필요.
- R2: Fernet 키 없는 환경에서 테스트 실행 실패 가능 — 테스트 픽스처에서 환경변수 mock 처리 필요.
- R3: broker_credentials 컬럼 타입(특히 numeric 정밀도)을 migration 작성 전 확정하지 않으면 후속 변경 migration이 늘어남.
- R4: /v1 라우터 prefix 방식이 기존 /portfolios, /auth 등 라우터와 충돌하지 않는지 app/main.py 구조 확인 필요.
@@ -0,0 +1,187 @@
# BAT-001 Verification
- Status: draft
- Batch: BAT-001
## Acceptance Mapping
### REQ-004: GET /health 응답 스키마 확장
| # | Acceptance Criterion | Evidence | Result |
|---|---|---|---|
| AC1 | status/service/version/checked_at 필드 모두 존재 | `tests/test_v1.py::test_health_returns_full_response` — 4개 필드 전체 assert | PASS |
| AC2 | status 허용값 ok/degraded/failed | test에서 "ok" 값만 검증. "degraded"/"failed" 경로 코드 미구현(Notes에 이번 반복 Out of Scope 명시) — 스키마에 허용값으로만 선언 | PARTIAL (OOS 인정) |
| AC3 | checked_at ISO 8601 형식 | `test_v1.py::test_health_returns_full_response` — `"+09:00" in body["checked_at"]` assert | PASS |
| AC4 | service 고정값 "portfolio-hub" | `test_v1.py` — `body["service"] == "portfolio-hub"` | PASS |
| AC5 | 비인증 접근 200 | `test_v1.py::test_health_returns_full_response` — 인증 헤더 없이 GET /health 호출, 200 응답 | PASS |
**REQ-004 판정: PASS** (AC2는 REQ Notes에서 이번 반복 OOS로 명시 — 릴리스 블로커 아님)
---
### REQ-005: service-token 발급 시 runtime_id 동시 생성
| # | Acceptance Criterion | Evidence | Result |
|---|---|---|---|
| AC1 | CLI 출력에 token + runtime_id | `app/cli/commands/service_tokens.py` — `ServiceTokenIssueCliResponse(runtime_id=issued.auth_token.runtime_id, ...)` 구현, `tests/test_cli.py::test_cli_service_token_issue_supports_json` PASS (FakeRepo에 runtime_id 반영) | PASS |
| AC2 | API 엔드포인트 응답에 token + runtime_id | service-token 발급 API 엔드포인트 미구현 (CLI 전용으로 구현). `app/api/auth_routes.py` 변경 없음. REQ-005 AC2는 "POST /auth/tokens 또는 동등 경로"를 명시함 | **BLOCKER** |
| AC3 | GET /v1/user_info 응답 runtime_id 일치 | `tests/test_v1.py::test_v1_user_info_returns_user` — `body["runtime_id"] == "rt-abc"` | PASS |
| AC4 | 두 번 발급 시 다른 runtime_id | `auth_service.py`에서 매 발급마다 `str(uuid.uuid4())` 생성 — 코드 검토 통과. 전용 테스트 없음 | PARTIAL |
| AC5 | auth_tokens 테이블 runtime_id 저장 | `postgres.py::create_auth_token` INSERT에 runtime_id 포함, `_SELECT_AUTH_TOKEN`에 runtime_id 컬럼 추가 — 코드 검토 통과. DB 환경 실행 미확인 | PARTIAL (DB 미확인) |
**REQ-005 판정: BLOCKED** — AC2 서비스 토큰 발급 API 엔드포인트 누락
---
### REQ-006: GET /v1/assets 엔드포인트
| # | Acceptance Criterion | Evidence | Result |
|---|---|---|---|
| AC1 | service-token 인증 → 200 + portfolio 목록 | `tests/test_v1.py::test_v1_assets_returns_asset_list` PASS | PASS |
| AC2 | ?status=active 쿼리 파라미터 필터 | `v1_assets.py`에 `status` 쿼리 파라미터 미구현. `list_portfolios_with_strategies(include_disabled=False)`만 호출 — active 필터 없음 | **BLOCKER** |
| AC3 | 응답 필드: asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_params, updated_at | `AssetItem` 스키마에 `cash_balance`(≠cash), `asset_quantity`(≠quantity), `broker`(≠broker_type), `active_strategy_id`(≠strategy_id) 사용. `strategy_params` 필드 아예 없음 — trex-basic contract 필드명 불일치 | **BLOCKER** |
| AC4 | strategy_params → StrategySet.config 파생 JSON | `AssetItem`에 strategy_params 필드 없음. v1_assets.py에서 StrategySet 조회 로직 없음 | **BLOCKER** |
| AC5 | strategy_params {} 폴백 | strategy_params 미구현으로 폴백 불가 | **BLOCKER** |
| AC6 | portfolios.market NULL → 응답 null | `AssetItem.market: str | None = None`, `Portfolio.market: str | None = None` — NULL 전파 구조 PASS. NULL 케이스 전용 테스트 없음 | PARTIAL |
| AC7 | 미인증 401 | `tests/test_v1.py::test_v1_assets_requires_service_token` — 401 반환 PASS | PASS |
**REQ-006 판정: BLOCKED** — AC2(status 필터), AC3(필드명 불일치), AC4/AC5(strategy_params 미구현)
---
### REQ-007: GET /v1/user_info 엔드포인트
| # | Acceptance Criterion | Evidence | Result |
|---|---|---|---|
| AC1 | 응답에 runtime_id, trading_enabled, brokers[] 포함 | `UserInfoResponse`에 `runtime_id` ✓, `is_active` (trading_enabled 아님) ✓, `brokers[]` 없음. Contract 필드명 `trading_enabled` 사용 안 함 | **BLOCKER** |
| AC2 | environment 필드 없음 | `UserInfoResponse`에 environment 필드 없음 | PASS |
| AC3 | is_active=true → trading_enabled=true | `trading_enabled` 필드 미구현 | **BLOCKER** |
| AC4 | is_active=false → trading_enabled=false, disabled_reason 포함 | `tests/test_v1.py::test_v1_user_info_inactive_user_has_disabled_reason` — `disabled_reason == "account_inactive"` PASS. `trading_enabled` 미구현 | **BLOCKER** |
| AC5 | brokers[]에 credential_payload 복호화 JSON | `UserInfoResponse`에 brokers[] 없음. v1_user_info.py에서 broker_credentials 조회 및 복호화 없음 | **BLOCKER** |
| AC6 | runtime_id 없는 구형 토큰 → HTTP 404 | `tests/test_v1.py::test_v1_user_info_404_when_no_runtime_id` — 404 + "runtime_id not assigned to this token" PASS | PASS |
| AC7 | 미인증 401 | `_require_service_token` 의존성에서 강제. 명시적 테스트 없으나 구조 검토 통과 | PARTIAL |
**REQ-007 판정: BLOCKED** — trading_enabled 필드명, brokers[] 배열 전체 미구현
---
### REQ-008: POST /v1/assets/{asset_id}/ledger/trades
| # | Acceptance Criterion | Evidence | Result |
|---|---|---|---|
| AC1 | 신규 체결 → HTTP 201 + ledger_entry_id | `tests/test_v1.py::test_v1_ledger_trade_created` — 201 + created=True PASS | PASS |
| AC2 | 동일 payload 재전송 → HTTP 200 + status="duplicate" | 현재 구현은 중복 시에도 **HTTP 201** 반환, 응답 필드가 `status="duplicate"`가 아닌 `created=false` 사용. `test_v1_ledger_trade_idempotent`는 201을 assert | **BLOCKER** |
| AC3 | 동일 키 다른 payload → HTTP 409 | `insert_ledger_trade`에서 UniqueViolation 시 기존 행 반환만 수행, payload 비교 없음. 409 응답 미구현 | **BLOCKER** |
| AC4 | 존재하지 않는 asset_id → 404 | `v1_ledger.py`에서 asset_id 존재 여부 확인 로직 없음 | **BLOCKER** |
| AC5 | 미인증 401 | `_require_service_token` 의존성에서 강제. 명시적 테스트 없으나 구조 검토 통과 | PARTIAL |
**REQ-008 판정: BLOCKED** — AC2(HTTP 상태 코드 및 응답 필드), AC3(409 미구현), AC4(asset_id 존재 확인 없음)
---
### REQ-009: v1 API DB 스키마 migration
| # | Acceptance Criterion | Evidence | Result |
|---|---|---|---|
| AC1 | portfolios.market 컬럼 존재 | `20260502_0014_v1_api_schema.py` — `add_column("portfolios", market TEXT NULLABLE)` IF NOT EXISTS 패턴 구현 — 코드 검토 PASS. 실제 DB 실행 미확인 | PARTIAL (DB 미확인) |
| AC2 | auth_tokens.runtime_id 컬럼 존재 | 동일 migration — `add_column("auth_tokens", runtime_id TEXT NULLABLE)` + UNIQUE INDEX — 코드 검토 PASS | PARTIAL (DB 미확인) |
| AC3 | broker_credentials 테이블 존재 | 동일 migration — `create_table("broker_credentials", ...)` PK(user_id, broker) — 코드 검토 PASS | PARTIAL (DB 미확인) |
| AC4 | ledger_trades 테이블 존재 | 동일 migration — `create_table("ledger_trades", ...)` BIGSERIAL PK — 코드 검토 PASS | PARTIAL (DB 미확인) |
| AC5 | (asset_id, order_id, execution_id) UniqueViolation | `uq_ledger_trades_idempotency` UniqueConstraint 정의 확인. DB 실행 미확인 | PARTIAL (DB 미확인) |
| AC6 | 재실행 시 에러 없음 | IF NOT EXISTS 패턴 + `_table_exists`/`_column_exists` 검사 구현 — 코드 검토 PASS | PARTIAL (DB 미확인) |
| AC7 | 기존 행 market NULL 유지 | `ADD COLUMN ... NULLABLE` 추가만 수행, 기존 행 UPDATE 없음 — 코드 검토 PASS | PARTIAL (DB 미확인) |
**REQ-009 판정: PARTIAL** — 코드 검토 전체 PASS. 실제 DB 환경(Docker Compose) 실행 검증 필요. 코드 전용 환경에서는 블로커 아님으로 취급하나, live DB 검증 항목을 Blocking Issues에 포함.
---
### REQ-010: credential_payload Fernet 암호화 및 startup validation
| # | Acceptance Criterion | Evidence | Result |
|---|---|---|---|
| AC1 | KEY 있음 → 정상 기동 | `app/core/encryption.py` 신규 구현, `Settings.credential_encryption_key` 필드 추가. KEY 있을 때 정상 동작 — 코드 검토 PASS | PASS |
| AC2 | KEY 없음 → 기동 실패 + 에러 메시지 | 구현에서 lazy validation으로 변경됨: `Settings` 생성 시 에러 없이 통과, `require_credential_encryption_key()` 호출 시에만 RuntimeError 발생. **startup 실패 동작이 AC2와 불일치** | **BLOCKER** |
| AC3 | DB에 ciphertext 저장 | `upsert_broker_credential` 메서드 존재. 그러나 API에서 plaintext → encrypt → DB 저장 경로 미구현 (암호화 적용은 호출자 책임으로 처리됨). 전용 테스트 없음 | **BLOCKER** |
| AC4 | GET /v1/user_info 복호화 JSON 반환 | `UserInfoResponse`에 brokers[] 없음 (REQ-007 BLOCKED에 포함) — 동일 blocker | **BLOCKER** |
| AC5 | 잘못된 키 → InvalidToken 예외 | `encryption.py`에서 `cryptography.fernet.InvalidToken` re-export. 전용 테스트 없음 | PARTIAL |
**REQ-010 판정: BLOCKED** — AC2(startup 실패 동작 불일치), AC3(암호화 저장 경로 미연결), AC4(REQ-007 blocker와 연동)
---
## Architecture Impact & Baseline Docs Sync
`design.md` Changed Areas에 아래 baseline 문서 갱신이 명시됨:
- `docs/project-structure.md` — app/core/encryption.py, app/api/v1_*.py 신규 모듈 반영
- `docs/runtime-flows.md` — v1 API 요청 흐름, runtime_id 발급 흐름 추가
**현재 상태:** `implementation.md` Changed Files에 baseline 문서 갱신 없음. design의 Architecture Impact가 `none`이 아니므로 baseline 문서 동기화 evidence가 필요함.
**판정: BLOCKER** — docs/project-structure.md, docs/runtime-flows.md 미갱신
---
## Evidence
### Automated Tests (pytest)
- `tests/test_app.py::test_healthcheck` — PASS (health 필드 검증 포함)
- `tests/test_v1.py::test_health_returns_full_response` — PASS (4개 필드 + KST checked_at)
- `tests/test_v1.py::test_v1_assets_requires_service_token` — PASS
- `tests/test_v1.py::test_v1_assets_rejects_env_token` — PASS
- `tests/test_v1.py::test_v1_assets_returns_asset_list` — PASS (asset_id, market 기본 필드)
- `tests/test_v1.py::test_v1_user_info_404_when_no_runtime_id` — PASS
- `tests/test_v1.py::test_v1_user_info_returns_user` — PASS (runtime_id, disabled_reason=None)
- `tests/test_v1.py::test_v1_user_info_inactive_user_has_disabled_reason` — PASS
- `tests/test_v1.py::test_v1_ledger_trade_created` — PASS
- `tests/test_v1.py::test_v1_ledger_trade_idempotent` — PASS
- `tests/test_auth.py` 전체 — PASS (runtime_id 파라미터 추가 후)
- `tests/test_cli.py::test_cli_service_token_issue_supports_json` — PASS
- 전체 합계: 88 passed (2026-05-02)
### Code Review
- `app/core/encryption.py`: Fernet 암복호화 유틸리티 정상 구현
- `app/core/config.py`: `credential_encryption_key` 필드 추가, lazy validation 전환
- `app/core/auth.py`: `AuthContext.runtime_id`, `TokenPrincipal.runtime_id` 추가
- `app/domain/models.py`: `AuthToken.runtime_id`, `Portfolio.market`, `BrokerCredential`, `LedgerTrade` 추가
- `app/services/auth_service.py`: `uuid.uuid4()` runtime_id 생성·전달
- `migrations/versions/20260502_0014_v1_api_schema.py`: IF NOT EXISTS 패턴 정상
### DB 환경 실행
- 미수행 (Docker Compose 환경 미기동)
---
## Result
- **Ready for Release: false**
### Blocking Issues
| # | REQ | Issue | 필요 조치 |
|---|---|---|---|
| B-1 | REQ-005 AC2 | service-token 발급 API 엔드포인트(POST /auth/service-tokens 또는 동등 경로) 미구현. CLI 전용으로 구현됨 | API 엔드포인트 추가 또는 REQ-005 AC2 범위 조정(change-req) |
| B-2 | REQ-006 AC2 | ?status=active 쿼리 파라미터 필터 미구현 | v1_assets.py에 status 쿼리 파라미터 추가 |
| B-3 | REQ-006 AC3 | 응답 필드명 불일치: cash_balance(≠cash), asset_quantity(≠quantity), broker(≠broker_type), active_strategy_id(≠strategy_id) | 계약 필드명으로 응답 스키마 수정 또는 alias 추가 |
| B-4 | REQ-006 AC4/AC5 | strategy_params 필드 미구현. StrategySet.config 조회 및 {} 폴백 로직 없음 | v1_assets.py에 strategy_set 조회 및 strategy_params 파생 로직 추가 |
| B-5 | REQ-007 AC1/AC3/AC4 | trading_enabled 필드 미구현(is_active로 대체됨). 계약 필드명 불일치 | UserInfoResponse에 trading_enabled 필드 추가 |
| B-6 | REQ-007 AC5 | brokers[] 배열 전체 미구현. broker_credentials 조회 + Fernet 복호화 경로 없음 | v1_user_info.py에 broker_credentials 조회 + 복호화 + 응답 포함 구현 |
| B-7 | REQ-008 AC2 | 중복 수신 시 HTTP 201 반환(AC는 HTTP 200 요구). 응답 필드 created(bool) vs status="duplicate" 불일치 | 중복 시 HTTP 200 반환, 응답 스키마에 status="duplicate" 필드 추가 |
| B-8 | REQ-008 AC3 | 동일 키 다른 payload → HTTP 409 미구현 | insert_ledger_trade에 payload 비교 로직 및 409 응답 추가 |
| B-9 | REQ-008 AC4 | 존재하지 않는 asset_id → HTTP 404 미구현 | v1_ledger.py에 asset_id(portfolio_id) 존재 여부 확인 후 404 반환 추가 |
| B-10 | REQ-010 AC2 | startup validation 동작 불일치: Settings 생성 시 에러 없음(lazy), AC는 기동 실패 요구 | lifespan 또는 애플리케이션 시작 시 require_credential_encryption_key() 호출 추가 |
| B-11 | REQ-010 AC3 | broker_credentials 저장 시 암호화 미적용. API 레이어에서 encrypt_payload 호출 없음 | upsert_broker_credential 호출부 또는 API 레이어에서 암호화 적용 |
| B-12 | Baseline | docs/project-structure.md, docs/runtime-flows.md 미갱신. design.md Architecture Impact에 갱신 필요로 명시됨 | 두 baseline 문서에 신규 모듈 및 v1 API 흐름 반영 |
### Non-Blocking Observations
| # | REQ | Observation |
|---|---|---|
| N-1 | REQ-009 | migration 파일 코드 검토 PASS. DB 환경(Docker Compose) 실행 미검증 — 배포 전 반드시 확인 필요 |
| N-2 | REQ-004 AC2 | degraded/failed 상태 경로 미구현 — REQ Notes에서 이번 반복 OOS로 명시되어 릴리스 블로커 아님 |
| N-3 | REQ-005 AC4 | 두 번 발급 시 다른 runtime_id — 코드 검토 통과. 전용 테스트 없음 |
| N-4 | REQ-010 AC5 | InvalidToken 경로 전용 테스트 없음 |
+2 -1
View File
@@ -7,4 +7,5 @@
## Batch Register
| BAT ID | Profile | Status | Included REQ | Discovery | Folder |
| --- | --- | --- | --- | --- | --- |
| --- | --- | --- | --- | --- | --- |
| BAT-001 | standard | in-delivery | REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010 | dcy-002 | [bat-001_20260502_trex-basic-v1-api-contract](./bat-001_20260502_trex-basic-v1-api-contract/) |
@@ -0,0 +1,239 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-002_20260502_trex-basic-consumer-api-contract
- 이슈명: trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현
- 작성 시각(KST): 2026-05-02 00:00
- 마지막 갱신 시각(KST): 2026-05-02 03:00
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 계약 문서: temp/portfolio-hub-api-contract.md (trex-basic 팀 작성 Consumer-Driven Contract)
- 관련 TODO/메모: 없음
- 해석:
- trex-basic 팀이 portfolio-hub에 Consumer-Driven Contract를 전달했고, 4개 엔드포인트(GET /health, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades)를 구현해야 한다. Q1(service-token 인증)과 Q2(runtime_id 발급 절차)는 이 Discovery에서 결정 사항으로 반영한다.
- 현재 `/health`는 `status: ok`만 반환하고 `/v1/*` 엔드포인트는 전혀 없다. service-token 메커니즘(`token_type='service'`)은 이미 구현돼 있으나 `runtime_id` 개념이 없고, 브로커 인증 정보 저장 테이블도 없다.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- 기존 dcy-001은 로컬 DB 마이그레이션 검증과 운영 준비를 다루며 외부 소비자 API 계약과는 완전히 별개 주제다.
- `GET /v1/assets`, `GET /v1/user_info`, `POST /v1/assets/{asset_id}/ledger/trades`는 기존 어떤 Discovery/REQ에도 등장하지 않는 신규 엔드포인트다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- `GET /health`는 `app/api/health.py`에서 `{"status": "ok"}`만 반환하며 계약이 요구하는 `service`, `version`, `checked_at` 필드가 없다.
- service-token 메커니즘은 migration 0013(`20260423_0013_remove_bot_principals_service_tokens.py`)에서 `token_type='service'`로 완성됐고, CLI `pfhub service-token issue/list/revoke`도 `app/cli/commands/service_tokens.py`에 구현돼 있다. 그러나 발급 결과에 `runtime_id`가 포함되지 않는다.
- `Portfolio` 모델(`app/domain/models.py`)에 `portfolio_id`, `broker`, `status`, `symbol`, `cash_balance`, `asset_quantity`, `active_strategy_id`가 있어 `/v1/assets` 응답 필드 일부와 대응하지만, `market`(KOSPI/KOSDAQ) 필드와 `strategy_params` JSON이 없다.
- `StrategySet` 모델에 `config`(문자열) 필드가 있어 strategy_params 원본으로 사용 가능하나, Portfolio와의 연결 시 `strategy_params` 역할을 하는 구체적 구현은 없다.
- `GET /v1/user_info`가 요구하는 `runtime_id`, `trading_enabled`, `brokers[].credential_payload` 데이터는 현재 어떤 테이블에도 존재하지 않는다.
- `POST /v1/assets/{asset_id}/ledger/trades`가 요구하는 `ledger_trades` 테이블이 없다.
- 기대 상태
- `GET /health`는 `status`, `service`, `version`, `checked_at`을 모두 반환한다.
- `pfhub service-token issue` CLI와 대응 API 엔드포인트가 service-token과 함께 `runtime_id`를 생성·반환한다. 발급된 service-token으로 `/v1/*` 엔드포인트를 인증할 수 있고, `GET /v1/user_info` 응답에도 해당 `runtime_id`가 포함된다.
- `GET /v1/assets`는 service-token bearer 인증으로 관리 자산 목록(portfolio → asset 뷰)과 전략 파라미터를 반환한다.
- `GET /v1/user_info`는 service-token으로 인증한 사용자의 거래 가능 여부와 브로커별 credential_payload를 반환한다.
- `POST /v1/assets/{asset_id}/ledger/trades`는 체결 내역을 idempotent하게 기록하고, 중복 수신 시 `status=duplicate`를 반환한다.
- 이해관계자
- trex-basic 팀 (소비자, Consumer-Driven Contract 작성자)
- portfolio-hub 백엔드 유지보수 담당자 (구현 주체)
- REQ 승인자
- 제약사항
- service-token 인증은 기존 `token_type='service'` 메커니즘을 그대로 사용한다. 별도 인증 체계를 새로 만들지 않는다.
- `GET /v1/user_info`는 `environment` 필드를 노출하지 않는다. portfolio-hub는 항상 live credential을 반환하며 paper/live 분기를 두지 않는다.
- 기존 Alembic revision chain을 깨지 않는 IF NOT EXISTS 기반 migration을 추가한다(dcy-001/REQ-002 정책 준수).
## 주요 사용자 시나리오
- trex-basic 데몬이 시작될 때 `GET /health`로 portfolio-hub 가용성을 확인하고, `GET /v1/assets?status=active`로 관리 자산 목록과 전략 파라미터를 읽은 뒤, `GET /v1/user_info`로 거래 가능 여부와 브로커 인증 정보를 획득해 거래를 시작한다.
- 운영자가 `pfhub service-token issue <name> --user <username>` CLI로 service-token과 runtime_id를 한 번에 발급하고, 결과를 trex-basic 데몬 환경 변수에 주입한다. 또는 API 엔드포인트를 통해 동일한 발급을 수행한다.
- trex-basic이 KIS 브로커로부터 체결을 확인한 뒤 `POST /v1/assets/{asset_id}/ledger/trades`로 체결 내역을 전송한다. 네트워크 오류로 재전송하더라도 동일 `(asset_id, order_id, execution_id)` 조합이면 중복 기록 없이 `status=duplicate`를 응답 받는다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- `GET /health` 응답 스키마 확장: `service`, `version`, `checked_at` 필드 추가
- service-token 발급 시 `runtime_id` 동시 생성 및 반환: CLI(`pfhub service-token issue`)와 API 엔드포인트 양쪽에서 `runtime_id`를 함께 발급. `runtime_id`는 발급된 service-token에 연결되어 `GET /v1/user_info` 응답에서 그대로 반환
- `/v1/assets`, `/v1/user_info`, `/v1/assets/{asset_id}/ledger/trades` 엔드포인트 신규 구현: 브로커 인증 정보 저장 테이블(`broker_credentials`), 거래 내역 테이블(`ledger_trades`), `portfolios` 테이블에 `market` 컬럼 추가를 포함한 DB 변경과 함께 구현
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `app/api/health.py`: 응답 스키마 확장
- `app/api/` 신규 라우터: `v1_assets.py`, `v1_user_info.py`, `v1_ledger.py` (또는 통합 `v1.py`)
- `app/cli/commands/service_tokens.py`: `issue` 커맨드에 `runtime_id` 생성·출력 추가
- `app/domain/models.py`: `BrokerCredential`, `LedgerTrade` 도메인 모델 추가
- `app/repositories/postgres.py`: 브로커 인증 정보 및 ledger 관련 DB 연산 추가
- `app/services/`: `v1_service.py` 또는 기존 서비스에 assets/user_info/ledger 메서드 추가
- `app/schemas/`: v1 응답 스키마 추가
- `migrations/versions/`: `market` 컬럼, `runtime_id` 연결, `broker_credentials`, `ledger_trades` 테이블 migration 추가
- `app/main.py`: `/v1` prefix 라우터 등록
- 문서 영향:
- `docs/interface-contract.md`: trex-basic 소비 엔드포인트 계약 기준 갱신 필요
- `docs/data-model.md`: `broker_credentials`, `ledger_trades`, `runtime_id` 연결 모델 추가 필요
- 운영 영향:
- service-token 발급 절차 변경 — `runtime_id`가 함께 반환되므로 기존 운영 가이드 갱신 필요
- 브로커 인증 정보(credential_payload)는 민감 데이터이므로 DB 수준 보안과 접근 정책 검토 필요
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1: `GET /health` 응답에 `status`, `service`(고정값 `"portfolio-hub"`), `version`(앱 버전), `checked_at`(ISO 8601 KST) 필드가 포함되어야 한다. `status` 허용값은 `ok`, `degraded`, `failed`이며 정상 응답은 `ok`를 반환해야 한다.
- FR-2: `pfhub service-token issue <name> --user <username>` CLI 커맨드와 대응하는 API 엔드포인트(POST /auth/service-tokens 또는 기존 POST /auth/tokens 확장)가 service-token 문자열과 함께 `runtime_id`(고유 식별자)를 생성·반환해야 한다. `runtime_id`는 service-token에 연결되어 DB에 저장되고, 해당 토큰으로 인증한 `GET /v1/user_info` 호출에서 그대로 반환되어야 한다.
- FR-3: `GET /v1/assets`는 service-token bearer 인증으로 토큰 소유자(user)의 portfolio 목록을 asset 뷰로 반환해야 한다. 응답 항목은 `asset_id`, `symbol`, `market`, `status`, `cash`, `quantity`, `broker_type`, `strategy_id`, `strategy_params`, `updated_at`을 포함해야 하며, `status=active` query parameter로 필터링이 가능해야 한다. `strategy_params`는 해당 portfolio의 StrategySet config에서 파생한다. `strategy_id`는 서버에서 허용값 검증을 수행하지 않으며, 등록된 모든 값을 그대로 반환한다(OQ-1 Answered).
- FR-4: `GET /v1/user_info`는 service-token bearer 인증으로 해당 사용자의 거래 가능 여부(`trading_enabled`, `disabled_reason`)와 브로커별 인증 정보 목록(`brokers[]`)을 반환해야 한다. `brokers[]`의 각 항목은 `broker`, `account_ref`, `credential_payload`, `credential_version`, `last_rotated_at`을 포함해야 한다. `runtime_id`는 인증 service-token에 연결된 값을 반환해야 한다.
- FR-5: `POST /v1/assets/{asset_id}/ledger/trades`는 service-token bearer 인증으로 체결 내역을 기록해야 한다. 요청 본문(`execution_id`, `order_id`, `side`, `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`)을 `ledger_trades` 테이블에 저장하고 201을 반환해야 한다. `(asset_id, order_id, execution_id)` 동일 조합의 재수신은 신규 행 없이 `status=duplicate`와 기존 `ledger_entry_id`를 200으로 반환해야 한다. 동일 키에 payload가 다른 경우 409를 반환해야 한다.
## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1: `/v1/user_info`는 `environment` 필드를 포함하지 않는다. portfolio-hub는 항상 live credential을 반환하며, paper/live 분기 로직을 서버에서 관리하지 않는다.
- NFR-2: `POST /v1/assets/{asset_id}/ledger/trades`의 idempotency 키는 `(asset_id, order_id, execution_id)` 복합 유니크 제약으로 DB에서 보장해야 한다. 애플리케이션 로직만으로 보장하지 않는다.
- NFR-3: 브로커 인증 정보(`credential_payload`)는 DB에 평문 저장을 금지한다. Python `cryptography` 패키지의 Fernet(AES-128-CBC + HMAC-SHA256)으로 저장 전 암호화하고 조회 시 복호화한다. 암호화 키는 환경변수 `CREDENTIAL_ENCRYPTION_KEY`(base64 인코딩 32바이트)로 주입하며, 키가 없으면 서비스 기동을 거부해야 한다(OQ-3 Answered).
- NFR-4: 신규 migration은 기존 `ensure_schema()` 런타임 경로와 충돌하지 않도록 IF NOT EXISTS 패턴을 사용해야 한다(REQ-002 준수).
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- `GET /health` 응답 스키마 확장
- service-token 발급에 `runtime_id` 동시 생성 (CLI + API 양쪽)
- `GET /v1/assets` 엔드포인트 구현 (portfolio → asset 뷰 매핑, strategy_params 포함)
- `GET /v1/user_info` 엔드포인트 구현 (runtime_id, trading_enabled, brokers[].credential_payload 포함)
- `POST /v1/assets/{asset_id}/ledger/trades` 엔드포인트 구현 (idempotency 포함)
- 필요한 DB migration 추가 (market 컬럼, runtime_id 연결, broker_credentials 테이블, ledger_trades 테이블)
- Out of Scope
- trex-basic 측 코드 변경 — portfolio-hub API 구현만 담당
- paper/live 환경 분기 로직 — portfolio-hub는 live credential만 반환
- rate limiting 구현 — trex-basic 계약의 Q6이지만 이번 반복에서 요구되지 않음; 향후 반복으로 Deferred
- `strategy_id` 허용값 검증 로직 — 검증 없이 통과하는 것으로 확정(OQ-1 Answered). 화이트리스트 불필요
- KIS 외 다른 브로커의 credential_payload 스키마 정의 — Q4, 향후 반복으로 Deferred
- mock 서버 구현 — trex-basic 팀 자체 `scripts/mock_portfolio_hub.py` 활용
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (High): `broker_credentials`의 `credential_payload`(KIS app_key/app_secret)는 민감 데이터다. DB에 평문 저장 시 보안 사고 위험이 있다.
- 완화 방안: 앱 레벨 Fernet 암호화(옵션 A)로 확정(OQ-3 Answered). `CREDENTIAL_ENCRYPTION_KEY` 환경변수 없이 서비스가 기동되지 않도록 startup validation을 구현한다.
- R-2 (Medium): `GET /v1/assets`에서 `strategy_params`를 `StrategySet.config` JSON에서 파생할 때, config 스키마가 전략별로 다르면 응답 일관성이 깨질 수 있다.
- 완화 방안: 설계 단계에서 strategy_params 파생 규칙을 확정한다. config가 없거나 파싱 실패 시 빈 객체(`{}`)를 반환하고 로그를 남기는 안전한 폴백을 구현한다.
- R-3 (Medium): `portfolios` 테이블에 `market` 컬럼이 없어 기존 portfolio 데이터에 market 값이 없다. migration 후 기존 row의 market 값이 NULL 상태로 남는다.
- 완화 방안: migration에서 `market` 컬럼을 nullable로 추가한다(기본값 없음, DECIDE_MARKET_DEFAULT Answered). NULL인 경우 API 응답에서 `null`을 그대로 반환하며, trex-basic 클라이언트가 null을 처리하도록 한다.
- R-4 (Low): `GET /v1/user_info` 호출 시 service-token에 연결된 `runtime_id`가 없는 경우(기존 발급 토큰), 응답 처리 방식이 결정되지 않았다.
- 완화 방안: 기존 service-token은 `runtime_id`가 없으므로 404 또는 409 응답으로 처리하거나, 런타임에 즉시 `runtime_id`를 생성해 연결하는 방안을 설계 단계에서 결정한다.
## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: service-token 인증은 기존 `token_type='service'` 메커니즘과 bearer token 헤더 파싱을 그대로 사용한다. 별도 인증 체계를 추가하지 않는다. (Q1 결정 사항)
- A-2: `runtime_id`는 service-token 발급 시 함께 생성되며, 해당 service-token에 연결된다. 동일 사용자의 다른 service-token은 각기 다른 `runtime_id`를 가진다. (Q2 결정 사항)
- A-3: `GET /v1/assets`의 `asset_id`는 기존 `portfolio_id`를 그대로 사용한다. 별도 asset 식별자 체계를 만들지 않는다.
- A-4: `trading_enabled`는 현재 단계에서 `users.is_active`와 연동하는 단순 매핑으로 구현한다. 별도 trading 전용 상태 필드는 이번 반복 Out of Scope다.
- A-5: trex-basic이 사용하는 KIS 브로커만 우선 지원하며, credential_payload 스키마는 `{"app_key": "...", "app_secret": "..."}` 형식으로 고정한다.
## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: `strategy_id` 허용값 목록 — trex-basic 첫 실전 전략 ID가 확정되지 않았다. 현재 StrategySet에 있는 전략 ID 값을 검증 없이 통과시킬 것인지, 화이트리스트를 만들 것인지.
- 상태: Answered
- 처리 방안: `strategy_id`는 어떤 값을 사용해도 가능하도록 서버에서 검증 없이 통과한다. 화이트리스트를 두지 않는다. FR-3에 반영.
- 종료 조건: 완료.
- OQ-2: `GET /health` `degraded` 상태 정의 — degraded 시 후속 엔드포인트를 계속 시도할지 여부. portfolio-hub 내부 degraded 판단 기준이 없다.
- 상태: Answered
- 처리 방안: `degraded` 값은 응답 스키마에 유효한 상태로 포함한다(FR-1 허용값 유지). 단, 이번 반복에서는 해당 상태를 반환하는 트리거 조건을 정의하지 않으며, 트리거 조건은 추후 구현 시 정의한다.
- 종료 조건: 완료.
- OQ-3: `credential_payload` DB 저장 암호화 방식 — `broker_credentials.credential_payload`는 KIS `app_key`/`app_secret`을 담는 JSON으로, portfolio-hub PostgreSQL DB에 저장된다. 평문 저장 시 DB 덤프·직접 접근 경로에서 인증 정보가 노출된다(R-1). 아래 세 가지 방식 중 하나를 선택해야 한다.
- **옵션 A — 앱 레벨 AES 암호화 (Fernet)**: Python `cryptography` 패키지로 저장 전 암호화, 조회 시 복호화. 암호화 키는 환경변수 1개(`CREDENTIAL_ENCRYPTION_KEY`)로 주입. 인프라 변경 없음. 구현 복잡도 낮음. psql에서 payload 직접 읽기 불가. 현재 스택에 가장 단순하게 추가 가능.
- **옵션 B — pgcrypto 컬럼 암호화**: PostgreSQL `pgcrypto` extension 활성화 후 `pgp_sym_encrypt`/`pgp_sym_decrypt`로 DB 내 암/복호화. 키는 쿼리 파라미터로 전달하거나 환경변수에서 주입. Docker Compose 환경에서 extension 활성화 필요. 앱 레벨 처리 없이 DB에서 완결되지만, 쿼리마다 키를 파라미터로 전달하는 패턴이 필요해 구현 복잡도가 옵션 A보다 높음.
- **옵션 C — 외부 시크릿 관리자 (Vault, AWS Secrets Manager 등)**: DB에는 시크릿 참조 URI만 저장하고, 실제 credential은 외부 서비스에서 조회. 가장 강력하지만 별도 인프라 운영이 필요하며 현재 프로젝트 규모 대비 과잉 투자.
- 상태: Answered
- 처리 방안: **옵션 A(앱 레벨 AES/Fernet)** 채택. Python `cryptography` 패키지로 저장 전 암호화, 조회 시 복호화. 암호화 키는 환경변수 `CREDENTIAL_ENCRYPTION_KEY`로 주입. NFR-3과 R-1 완화 방안에 반영.
- 종료 조건: 완료.
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: `GET /health` 응답에 `status`, `service`, `version`, `checked_at` 필드가 모두 존재하고, `checked_at`이 ISO 8601 형식을 따른다.
- S-2: `pfhub service-token issue <name> --user <username>` 실행 결과에 `token`과 `runtime_id`가 모두 포함되고, 대응 API 엔드포인트로도 동일하게 발급된다. 발급된 token으로 `GET /v1/user_info`를 호출하면 동일 `runtime_id`가 응답에 포함된다.
- S-3: `GET /v1/assets`가 service-token 인증으로 portfolio 목록을 asset 뷰로 반환하고, 각 항목에 `strategy_params` 객체가 포함된다. `status=active` 필터가 정상 동작한다.
- S-4: `GET /v1/user_info`가 `trading_enabled`, `runtime_id`, `brokers[].credential_payload`를 반환하며 `environment` 필드를 포함하지 않는다.
- S-5: 동일 `(asset_id, order_id, execution_id)` 조합으로 `POST /v1/assets/{asset_id}/ledger/trades`를 두 번 호출할 때 첫 번째는 201, 두 번째는 200+`status=duplicate`를 반환하고 DB에는 단일 행만 존재한다.
## 측정 방식 및 데이터 출처
- S-1 측정 방식: 자동화 테스트(pytest)로 `/health` 응답 JSON 필드 검증 / 데이터 출처: `tests/test_app.py` 또는 신규 `tests/test_health.py`
- S-2 측정 방식: CLI 출력 파싱 + API 응답 JSON 검증 / 데이터 출처: `tests/test_service_tokens.py`(신규) 또는 `tests/test_auth.py` 확장
- S-3 측정 방식: `GET /v1/assets` 응답 스키마 및 필터 동작 pytest 검증 / 데이터 출처: `tests/test_v1_assets.py`(신규)
- S-4 측정 방식: `GET /v1/user_info` 응답 JSON 필드 존재·비존재 검증 / 데이터 출처: `tests/test_v1_user_info.py`(신규)
- S-5 측정 방식: 동일 payload 2회 POST 후 응답 코드·status 및 DB row count 검증 / 데이터 출처: `tests/test_v1_ledger.py`(신규)
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: In Scope/Out of Scope 경계가 명확하고 trex-basic 계약 범위와 일치
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1 ~ S-5 모두 pytest 검증 가능한 형태로 기술됨
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(credential_payload 암호화) OQ-3 Answered — 옵션 A(Fernet) 확정
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1, OQ-2, OQ-3 모두 Answered
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: Confirmed By = Justin, confirm-discovery skill 확인 완료
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md
- 참조 문서:
- temp/portfolio-hub-api-contract.md (trex-basic Consumer-Driven Contract 원문)
- docs/discovery/dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md (선행 Discovery)
- app/api/health.py
- app/cli/commands/service_tokens.py
- app/domain/models.py
- migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- ~~{{DECIDE_CREDENTIAL_ENCRYPTION}}~~: 옵션 A(앱 레벨 Fernet/AES) 채택 확정 (OQ-3 Answered)
- ~~{{DECIDE_MARKET_DEFAULT}}~~: `portfolios.market` nullable 컬럼으로 추가, 기본값 없음, API 응답에서 null 그대로 반환 (Answered)
## CONFIRM
- ~~{{CONFIRM_RUNTIME_ID_SCOPE}}~~: `runtime_id`는 service-token 1개당 1개로 발급되며 해당 service-token에 연결된다. 동일 사용자의 다른 service-token은 각기 다른 `runtime_id`를 가진다. (A-2 Confirmed)
- ~~{{CONFIRM_TRADING_ENABLED_MAPPING}}~~: `trading_enabled`는 `users.is_active`와 단순 매핑으로 구현한다. 별도 trading 전용 상태 필드는 이번 반복 Out of Scope. (A-4 Confirmed)
## DATA
- ~~{{DATA_STRATEGY_ID_LIST}}~~: `strategy_id` 화이트리스트 불필요 — 검증 없이 통과하는 것으로 확정 (OQ-1 Answered)
- ~~{{DATA_BROKER_CREDENTIAL_SCHEMA_OTHER}}~~: KIS 외 다른 브로커의 `credential_payload` 스키마 — 이번 반복 Out of Scope로 확정. 향후 반복에서 별도 Discovery로 처리.
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: 모든 OQ(OQ-1, OQ-2, OQ-3)가 Answered 상태로 해소됨. CONFIRM 항목(runtime_id 발급 방식, trading_enabled 매핑)은 가정(A-2, A-4)으로 문서화되어 있으며 REQ 초안 작성 시 세부 검토 가능.
- Ready for REQ Drafting: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-05-02 02:00
+2 -1
View File
@@ -6,4 +6,5 @@
| ID | 날짜 | 이슈명 | 문서 | 상태 |
|---|---|---|---|---|
| dcy-001_20260430_real-db-migration-validation-and-operations-readiness | 20260430 | 실DB 마이그레이션 검증 및 운영 준비 | [Discovery](./dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md) | confirmed |
| dcy-001_20260430_real-db-migration-validation-and-operations-readiness | 20260430 | 실DB 마이그레이션 검증 및 운영 준비 | [Discovery](./dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md) | confirmed |
| dcy-002_20260502_trex-basic-consumer-api-contract | 20260502 | trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 | [Discovery](./dcy-002_20260502_trex-basic-consumer-api-contract.md) | confirmed |
@@ -0,0 +1,97 @@
# req-011: trex-basic 계약 반영을 위한 baseline 문서 갱신
- Status: Approved
- Type: Documentation
- Priority: Medium
- Owner: repository-maintainers
## Intent
- dcy-002 구현 완료 후 interface-contract.md, data-model.md, runtime-flows.md를 실제 구현 결과와 동기화한다.
- 이 REQ는 구현이 완료된 시점에 수행하는 문서 갱신이며, 구현 전 선행 작업이 아니다.
## Requirement
- REQ-004~010이 구현 완료(Implemented)된 시점에 다음 3개 baseline 문서를 갱신해야 한다.
1. docs/interface-contract.md: GET /health 응답 스키마, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 명세 및 service-token bearer 인증 방식 추가
2. docs/data-model.md: broker_credentials 테이블, ledger_trades 테이블, portfolios.market 컬럼, auth_tokens.runtime_id 컬럼 추가
3. docs/runtime-flows.md: /v1/* API 요청 처리 흐름(인증 → 서비스 → DB), service-token 발급 시 runtime_id 생성 흐름 추가
## Acceptance Criteria
1. Given docs/interface-contract.md를 검토하면, Then GET /health, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades의 요청/응답 명세가 포함되어야 한다.
2. Given docs/data-model.md를 검토하면, Then broker_credentials, ledger_trades 테이블과 portfolios.market, auth_tokens.runtime_id 컬럼이 문서화되어야 한다.
3. Given docs/runtime-flows.md를 검토하면, Then v1 API 처리 흐름과 runtime_id 발급 흐름이 포함되어야 한다.
## Impacted Area
- Docs: docs/interface-contract.md, docs/data-model.md, docs/runtime-flows.md
- Modules: 없음
- Tests: 없음
## Notes
- 이 REQ는 구현 완료 후 수행하므로 batch 설계 단계에서 별도 implementation task로 명시한다.
- 문서 갱신 내용은 draft-batch-verification 단계에서 실제 구현과 비교해 검증한다.
- Source Discovery: dcy-002 (전체 FR/NFR 구현 완료 후 documentation closure)
## Change Log
### CHG-20260502-02
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-011 confirm-req 승인 전환 (Status: Proposed → Approved)
#### Intent
- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 3개, Impacted Area, Change Log) 모두 충족 확인 후 승인
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs → draft-batch. REQ-004~010 Implemented 전환 후 실행.
### CHG-20260502-01
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-011 최초 생성 (source Discovery: dcy-002 문서화 클로저)
#### Intent
- dcy-002 구현 완료 후 baseline 문서 갱신 요구사항을 독립 Documentation REQ로 정규화
#### Acceptance Criteria Delta
- Added:
- 전체 Acceptance Criteria 신규 작성
#### Impacted Area Delta
- Docs:
- docs/interface-contract.md
- docs/data-model.md
- docs/runtime-flows.md
- Modules:
- none
- Tests:
- none
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정. REQ-004~010 모두 Implemented 전환 후 이 REQ를 별도 구현 task로 수행.
@@ -0,0 +1,95 @@
# req-004: GET /health 응답 스키마 확장
- Status: Approved
- Type: Interface
- Priority: High
- Owner: repository-maintainers
## Intent
- trex-basic Consumer-Driven Contract는 GET /health가 status, service, version, checked_at을 반환할 것을 요구한다.
- 현재 구현은 `{"status": "ok"}`만 반환하므로 계약 스키마를 충족하도록 응답을 확장한다.
## Requirement
- GET /health 엔드포인트는 status, service(고정값 "portfolio-hub"), version(앱 버전), checked_at(ISO 8601 KST) 필드를 포함한 JSON을 반환해야 한다.
- status 허용값은 ok, degraded, failed이며 정상 응답은 ok를 반환해야 한다.
## Acceptance Criteria
1. Given 서비스가 정상 동작 중일 때, When GET /health를 요청하면, Then 응답 JSON에 status, service, version, checked_at 필드가 모두 존재해야 한다.
2. Given GET /health 응답을 받을 때, When status 값을 확인하면, Then "ok", "degraded", "failed" 중 하나여야 한다.
3. Given GET /health 응답을 받을 때, When checked_at 형식을 검증하면, Then ISO 8601 형식을 만족해야 한다.
4. Given GET /health 응답을 받을 때, When service 필드를 확인하면, Then 고정값 "portfolio-hub"여야 한다.
5. Given 인증 없이 GET /health를 요청해도, Then 200 응답이 반환되어야 한다(비인증 엔드포인트).
## Impacted Area
- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/runtime-flows.md
- Modules: app/api/health.py, app/schemas/ (HealthResponse 스키마 추가)
- Tests: tests/test_health.py (신규) 또는 tests/test_app.py 확장
## Notes
- degraded 상태를 반환하는 트리거 조건은 이번 반복에서 정의하지 않는다(OQ-2 Answered). 스키마에 허용값으로만 포함한다.
- 앱 버전은 pyproject.toml의 version 필드 또는 settings에서 읽어 반환한다. 설계 단계에서 구체적 방법을 확정한다.
- Source Discovery: dcy-002 (FR-1)
## Change Log
### CHG-20260502-02
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-004 confirm-req 승인 전환 (Status: Proposed → Approved)
#### Intent
- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 5개, Impacted Area, Change Log) 모두 충족 확인 후 승인
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs → draft-batch
### CHG-20260502-01
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-004 최초 생성 (source Discovery: dcy-002 FR-1)
#### Intent
- trex-basic Consumer-Driven Contract의 GET /health 응답 스키마 요구사항을 REQ backlog로 정규화
#### Acceptance Criteria Delta
- Added:
- 전체 Acceptance Criteria 신규 작성
#### Impacted Area Delta
- Docs:
- docs/interface-contract.md (REQ-011 에서 갱신 예정)
- Modules:
- app/api/health.py
- Tests:
- tests/test_health.py 신규
#### Delivery Trace Delta
- Batches:
- none (batch 미생성 상태)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정
@@ -0,0 +1,99 @@
# req-005: service-token 발급 시 runtime_id 동시 생성
- Status: Approved
- Type: Interface
- Priority: High
- Owner: repository-maintainers
## Intent
- trex-basic 데몬은 GET /v1/user_info에서 runtime_id를 조회해 자신을 식별한다.
- runtime_id는 service-token 발급 시 함께 생성되어 해당 토큰에 연결 저장되어야 한다.
- CLI(pfhub service-token issue)와 API 엔드포인트 양쪽에서 발급 결과에 runtime_id가 포함되어야 한다.
## Requirement
- pfhub service-token issue CLI 커맨드와 대응 API 엔드포인트는 service-token 발급 시 고유 runtime_id를 동시 생성하고 auth_tokens 테이블에 연결 저장한 뒤 발급 결과(token + runtime_id)를 반환해야 한다.
- runtime_id는 해당 service-token에 1:1 연결되며, 동일 사용자의 다른 service-token은 각기 다른 runtime_id를 가진다.
## Acceptance Criteria
1. Given `pfhub service-token issue <name> --user <username>` 실행 시, When 발급이 성공하면, Then 출력에 token 문자열과 runtime_id가 모두 포함되어야 한다.
2. Given API 엔드포인트(POST /auth/tokens 또는 동등 경로)로 service-token 발급 시, When 성공 응답을 받으면, Then 응답 JSON에 token과 runtime_id가 포함되어야 한다.
3. Given runtime_id가 발급된 service-token으로 GET /v1/user_info를 호출하면, Then 응답의 runtime_id가 발급 시 생성된 값과 동일해야 한다.
4. Given 동일 사용자에 대해 두 번의 service-token 발급을 수행하면, Then 각각 다른 runtime_id가 반환되어야 한다.
5. Given auth_tokens 테이블을 직접 조회하면, Then 발급된 service-token 행에 runtime_id 값이 저장되어 있어야 한다.
## Impacted Area
- Docs: docs/runtime-flows.md (REQ-011에서 갱신 예정)
- Modules: app/cli/commands/service_tokens.py, app/api/auth_routes.py, app/domain/models.py (AuthToken.runtime_id 필드 추가), app/services/auth_service.py, app/repositories/postgres.py
- Tests: tests/test_auth.py 확장 또는 tests/test_service_tokens.py (신규)
## Notes
- runtime_id 포맷은 UUID v4를 권장한다. 설계 단계에서 확정한다.
- auth_tokens 테이블에 runtime_id 컬럼 추가는 REQ-009 migration에서 처리한다.
- 기존 service-token(runtime_id 없음) 처리 방식은 R-4로 식별됨. GET /v1/user_info(REQ-007) 설계 단계에서 결정한다.
- Source Discovery: dcy-002 (FR-2, A-2)
## Change Log
### CHG-20260502-02
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-005 confirm-req 승인 전환 (Status: Proposed → Approved)
#### Intent
- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 5개, Impacted Area, Change Log) 모두 충족 확인 후 승인
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs → draft-batch
### CHG-20260502-01
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-005 최초 생성 (source Discovery: dcy-002 FR-2)
#### Intent
- service-token 발급 시 runtime_id 동시 생성 요구사항을 REQ backlog로 정규화
#### Acceptance Criteria Delta
- Added:
- 전체 Acceptance Criteria 신규 작성
#### Impacted Area Delta
- Docs:
- docs/runtime-flows.md (REQ-011에서 갱신 예정)
- Modules:
- app/cli/commands/service_tokens.py
- app/api/auth_routes.py
- app/domain/models.py
- Tests:
- tests/test_auth.py 확장 또는 tests/test_service_tokens.py 신규
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정. REQ-009 migration과 함께 delivery 단위 구성 권장.
@@ -0,0 +1,100 @@
# req-006: GET /v1/assets 엔드포인트 구현
- Status: Approved
- Type: Interface
- Priority: High
- Owner: repository-maintainers
## Intent
- trex-basic 데몬이 관리 자산 목록과 전략 파라미터를 조회할 수 있도록 portfolio 데이터를 asset 뷰로 변환해 제공한다.
- asset_id는 기존 portfolio_id를 그대로 사용하며 별도 식별자 체계를 만들지 않는다(A-3).
## Requirement
- GET /v1/assets는 service-token bearer 인증으로 토큰 소유자의 portfolio 목록을 asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_params, updated_at 필드로 반환해야 한다.
- status=active 쿼리 파라미터로 필터링이 가능해야 한다.
- strategy_params는 해당 portfolio의 active_strategy_id로 조회한 StrategySet.config에서 파생한다. strategy_id는 허용값 검증 없이 그대로 반환한다(OQ-1 Answered).
## Acceptance Criteria
1. Given 유효한 service-token bearer 인증으로 GET /v1/assets 요청 시, When 토큰이 유효하면, Then HTTP 200과 해당 사용자 소유 portfolio 목록이 반환되어야 한다.
2. Given GET /v1/assets?status=active 요청 시, Then status가 active인 항목만 반환되어야 한다.
3. Given 응답의 각 항목을 확인하면, Then asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_params, updated_at 필드가 모두 포함되어야 한다.
4. Given portfolio에 연결된 StrategySet이 있을 때, When strategy_params를 확인하면, Then StrategySet.config 값에서 파생된 JSON 객체여야 한다.
5. Given StrategySet config가 없거나 파싱 실패 시, Then strategy_params는 빈 객체({})를 반환하고 에러가 전파되지 않아야 한다.
6. Given portfolios.market이 NULL인 경우, Then 응답의 market 필드도 null로 반환되어야 한다(DECIDE_MARKET_DEFAULT Answered).
7. Given 인증 없이 GET /v1/assets 요청 시, Then HTTP 401이 반환되어야 한다.
## Impacted Area
- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/project-structure.md (신규 라우터 파일 반영 필요)
- Modules: app/api/v1_assets.py (신규), app/services/ (assets 조회 서비스 메서드 추가), app/schemas/ (v1 assets 응답 스키마 추가), app/repositories/postgres.py (portfolio + strategy_set JOIN 쿼리 추가), app/main.py (/v1 라우터 등록)
- Tests: tests/test_v1_assets.py (신규)
## Notes
- broker_type은 portfolios.broker 컬럼을 그대로 매핑한다(m-2 finding 반영).
- strategy_id는 portfolios.active_strategy_id를 그대로 반환한다.
- GET /v1/assets의 소유권 스코프는 service-token 소유자(owner_user_id)를 기준으로 한다.
- Source Discovery: dcy-002 (FR-3, A-3, OQ-1)
## Change Log
### CHG-20260502-02
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-006 confirm-req 승인 전환 (Status: Proposed → Approved)
#### Intent
- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 7개, Impacted Area, Change Log) 모두 충족 확인 후 승인
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs → draft-batch
### CHG-20260502-01
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-006 최초 생성 (source Discovery: dcy-002 FR-3)
#### Intent
- trex-basic Consumer-Driven Contract의 GET /v1/assets 요구사항을 REQ backlog로 정규화
#### Acceptance Criteria Delta
- Added:
- 전체 Acceptance Criteria 신규 작성
#### Impacted Area Delta
- Docs:
- docs/interface-contract.md (REQ-011에서 갱신 예정)
- Modules:
- app/api/v1_assets.py 신규
- app/main.py (/v1 라우터 등록)
- Tests:
- tests/test_v1_assets.py 신규
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정. REQ-009 migration(market 컬럼 추가)과 함께 delivery 단위 구성 권장.
@@ -0,0 +1,103 @@
# req-007: GET /v1/user_info 엔드포인트 구현
- Status: Approved
- Type: Interface
- Priority: High
- Owner: repository-maintainers
## Intent
- trex-basic 데몬이 사용자 거래 가능 여부와 브로커 인증 정보를 조회할 수 있도록 한다.
- runtime_id는 인증 service-token에 연결된 값을 반환하며, environment 필드는 포함하지 않는다(NFR-1).
- credential_payload는 DB에서 Fernet 복호화 후 반환한다(NFR-3/REQ-010 의존).
## Requirement
- GET /v1/user_info는 service-token bearer 인증으로 runtime_id, trading_enabled(users.is_active 매핑), disabled_reason, brokers[](broker, account_ref, credential_payload, credential_version, last_rotated_at)를 반환해야 한다.
- environment 필드를 응답에 포함하지 않는다.
- credential_payload는 Fernet으로 복호화된 JSON을 반환한다.
## Acceptance Criteria
1. Given runtime_id가 연결된 service-token으로 GET /v1/user_info 호출 시, Then HTTP 200과 응답 JSON에 runtime_id, trading_enabled, brokers[] 필드가 포함되어야 한다.
2. Given GET /v1/user_info 응답을 확인하면, Then environment 필드가 존재하지 않아야 한다.
3. Given users.is_active가 true인 사용자의 service-token으로 호출 시, Then 응답의 trading_enabled가 true여야 한다.
4. Given users.is_active가 false인 사용자의 service-token으로 호출 시, Then 응답의 trading_enabled가 false여야 한다.
5. Given broker_credentials에 KIS credential이 저장된 사용자의 토큰으로 호출 시, When 응답의 brokers[] 항목을 확인하면, Then credential_payload가 복호화된 JSON({app_key, app_secret})으로 반환되어야 한다.
6. Given runtime_id가 없는 구형 service-token으로 호출 시, Then 설계 단계에서 확정된 오류 응답이 반환되어야 한다(R-4, 404 또는 즉시 runtime_id 생성 중 선택).
7. Given 인증 없이 요청 시, Then HTTP 401이 반환되어야 한다.
## Impacted Area
- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/data-model.md (REQ-011에서 갱신 예정)
- Modules: app/api/v1_user_info.py (신규), app/domain/models.py (BrokerCredential 모델 추가), app/services/ (user_info 조회 서비스 메서드 추가), app/schemas/ (v1 user_info 응답 스키마 추가), app/repositories/postgres.py (broker_credentials 조회 추가), app/main.py (/v1 라우터 등록)
- Tests: tests/test_v1_user_info.py (신규)
## Notes
- trading_enabled는 users.is_active 단순 매핑이다(A-4 Confirmed). 별도 trading 전용 상태 필드는 이번 반복 Out of Scope.
- disabled_reason: trading_enabled가 false일 때 이유 문자열. 이번 반복에서는 is_active가 false인 경우 "account_inactive" 고정값을 사용하는 방안을 설계 단계에서 확정한다.
- R-4(기존 service-token에 runtime_id 없는 경우 처리): 404 또는 즉시 생성 중 설계 단계에서 결정. 이 REQ의 Acceptance Criteria 6번은 설계 결정 후 구체화한다.
- Fernet 복호화는 REQ-010에서 정의한 암호화 유틸리티를 사용한다.
- Source Discovery: dcy-002 (FR-4, NFR-1, A-4)
## Change Log
### CHG-20260502-02
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-007 confirm-req 승인 전환 (Status: Proposed → Approved)
#### Intent
- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 7개, Impacted Area, Change Log) 모두 충족 확인 후 승인
- 설계 의존성 주의: AC #6(runtime_id 없는 구형 토큰 R-4 처리: 404 또는 즉시 생성)은 설계 단계에서 확정 필요. 핵심 Requirement가 명확하고 Notes에 명시적으로 deferred 처리됨 — 승인 게이트 통과 판단.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs → draft-batch. 설계 단계에서 R-4 처리 방식 확정 후 AC #6 구체화.
### CHG-20260502-01
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-007 최초 생성 (source Discovery: dcy-002 FR-4)
#### Intent
- trex-basic Consumer-Driven Contract의 GET /v1/user_info 요구사항을 REQ backlog로 정규화
#### Acceptance Criteria Delta
- Added:
- 전체 Acceptance Criteria 신규 작성
#### Impacted Area Delta
- Docs:
- docs/interface-contract.md (REQ-011에서 갱신 예정)
- Modules:
- app/api/v1_user_info.py 신규
- app/domain/models.py BrokerCredential 추가
- Tests:
- tests/test_v1_user_info.py 신규
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정. REQ-009(broker_credentials 테이블), REQ-010(Fernet 암호화), REQ-005(runtime_id)와 함께 delivery 단위 구성 권장.
@@ -0,0 +1,100 @@
# req-008: POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현
- Status: Approved
- Type: Interface
- Priority: High
- Owner: repository-maintainers
## Intent
- trex-basic이 체결 내역을 portfolio-hub에 idempotent하게 전송할 수 있도록 한다.
- 동일 (asset_id, order_id, execution_id) 조합의 중복 수신은 DB unique constraint로 감지하고 status=duplicate를 반환한다(NFR-2).
- 동일 키에 다른 payload가 오면 충돌로 처리한다.
## Requirement
- POST /v1/assets/{asset_id}/ledger/trades는 service-token bearer 인증으로 체결 내역(execution_id, order_id, side, quantity, price, fee_amount, tax_amount, executed_at)을 ledger_trades 테이블에 저장하고 HTTP 201을 반환해야 한다.
- 동일 (asset_id, order_id, execution_id) 재수신은 신규 행 생성 없이 status=duplicate와 기존 ledger_entry_id를 HTTP 200으로 반환해야 한다.
- 동일 키에 payload가 다른 경우 HTTP 409를 반환해야 한다.
- idempotency는 DB unique constraint로 보장하며, 애플리케이션 로직만으로 보장하지 않는다.
## Acceptance Criteria
1. Given 유효한 service-token과 신규 체결 데이터로 POST /v1/assets/{asset_id}/ledger/trades 요청 시, Then HTTP 201과 생성된 ledger_entry_id가 반환되어야 한다.
2. Given 동일 (asset_id, order_id, execution_id)와 동일 payload로 두 번째 요청 시, Then HTTP 200과 status="duplicate", 기존 ledger_entry_id가 반환되어야 하며 DB에 단일 행만 존재해야 한다.
3. Given 동일 (asset_id, order_id, execution_id)에 다른 payload로 요청 시, Then HTTP 409가 반환되어야 한다.
4. Given asset_id가 존재하지 않는 portfolio_id인 경우, Then HTTP 404가 반환되어야 한다.
5. Given 인증 없이 요청 시, Then HTTP 401이 반환되어야 한다.
## Impacted Area
- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/data-model.md (REQ-011에서 갱신 예정)
- Modules: app/api/v1_ledger.py (신규), app/domain/models.py (LedgerTrade 모델 추가), app/services/ (ledger 저장 서비스 메서드 추가), app/schemas/ (v1 ledger 요청/응답 스키마 추가), app/repositories/postgres.py (ledger_trades 저장 및 중복 감지 쿼리 추가), app/main.py (/v1 라우터 등록)
- Tests: tests/test_v1_ledger.py (신규)
## Notes
- NFR-2(idempotency DB unique constraint): (asset_id, order_id, execution_id) unique constraint는 REQ-009 migration에서 정의한다.
- ledger_trades 테이블과 기존 portfolio_ledger 테이블은 별개다. portfolio_ledger는 내부 원장이고 ledger_trades는 trex-basic이 외부에서 기록하는 체결 내역이다.
- 중복 감지 구현: DB unique constraint 위반(psycopg UniqueViolation) 캐치 → 기존 행 조회 → payload 동일 여부 비교 → duplicate 또는 409 응답.
- Source Discovery: dcy-002 (FR-5, NFR-2)
## Change Log
### CHG-20260502-02
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-008 confirm-req 승인 전환 (Status: Proposed → Approved)
#### Intent
- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 5개, Impacted Area, Change Log) 모두 충족 확인 후 승인
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs → draft-batch
### CHG-20260502-01
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-008 최초 생성 (source Discovery: dcy-002 FR-5)
#### Intent
- trex-basic Consumer-Driven Contract의 POST /v1/assets/{asset_id}/ledger/trades 요구사항을 REQ backlog로 정규화
#### Acceptance Criteria Delta
- Added:
- 전체 Acceptance Criteria 신규 작성
#### Impacted Area Delta
- Docs:
- docs/interface-contract.md (REQ-011에서 갱신 예정)
- Modules:
- app/api/v1_ledger.py 신규
- app/domain/models.py LedgerTrade 추가
- Tests:
- tests/test_v1_ledger.py 신규
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정. REQ-009(ledger_trades 테이블 + unique constraint)와 함께 delivery 단위 구성 필수.
@@ -0,0 +1,103 @@
# req-009: v1 API 지원을 위한 DB 스키마 migration 추가
- Status: Approved
- Type: Migration
- Priority: High
- Owner: repository-maintainers
## Intent
- dcy-002에서 요구하는 4개 DB 변경(portfolios.market 컬럼, auth_tokens.runtime_id 컬럼, broker_credentials 테이블, ledger_trades 테이블)을 Alembic revision으로 구현한다.
- IF NOT EXISTS 패턴을 유지해 REQ-002 정책을 준수한다(NFR-4).
## Requirement
- 다음 스키마 변경을 IF NOT EXISTS 패턴을 사용하는 신규 Alembic migration으로 추가해야 한다.
1. portfolios 테이블에 market TEXT NULLABLE 컬럼 추가 (기존 행은 NULL로 유지)
2. auth_tokens 테이블에 runtime_id TEXT UNIQUE NULLABLE 컬럼 추가
3. broker_credentials 테이블 신규 생성 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at 포함)
4. ledger_trades 테이블 신규 생성 (ledger_entry_id PK, asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at, recorded_at 포함, (asset_id, order_id, execution_id) unique constraint 포함)
## Acceptance Criteria
1. Given alembic upgrade head 실행 후, When portfolios 테이블을 조회하면, Then market TEXT NULLABLE 컬럼이 존재해야 한다.
2. Given alembic upgrade head 실행 후, When auth_tokens 테이블을 조회하면, Then runtime_id TEXT NULLABLE UNIQUE 컬럼이 존재해야 한다.
3. Given alembic upgrade head 실행 후, Then broker_credentials 테이블이 존재해야 한다.
4. Given alembic upgrade head 실행 후, Then ledger_trades 테이블이 존재해야 한다.
5. Given (asset_id, order_id, execution_id) 동일 조합으로 ledger_trades에 두 번 삽입 시, Then UniqueViolation이 발생해야 한다.
6. Given migration을 이미 실행한 DB에 동일 migration을 재실행하면, Then 에러 없이 완료되어야 한다(IF NOT EXISTS 보장).
7. Given portfolios 테이블의 기존 행, When migration 실행 후 조회하면, Then market 값이 NULL이어야 한다(기존 데이터 보정 없음, DECIDE_MARKET_DEFAULT Answered).
## Impacted Area
- Docs: docs/data-model.md (REQ-011에서 갱신 예정)
- Modules: migrations/versions/ (신규 migration 파일 추가), app/domain/models.py (Portfolio.market, AuthToken.runtime_id, BrokerCredential, LedgerTrade 모델 필드 추가), app/repositories/postgres.py (신규 테이블 쿼리 추가)
- Tests: tests/test_app.py 또는 migration 검증 테스트
## Notes
- NFR-4(IF NOT EXISTS 패턴) 준수 — REQ-002 Alembic migration 정책 기반.
- broker_credentials 테이블의 encrypted_payload 컬럼은 Fernet 암호화된 ciphertext를 TEXT로 저장한다. 암복호화 로직은 REQ-010 참조.
- 컬럼 상세 타입(NUMERIC vs FLOAT, TIMESTAMPTZ 등)은 설계 단계(draft-batch-design)에서 확정한다.
- migration revision ID는 구현 시 기존 chain의 마지막 revision(20260423_0013)을 down_revision으로 참조한다.
- Source Discovery: dcy-002 (영향 범위, NFR-4, DECIDE_MARKET_DEFAULT)
## Change Log
### CHG-20260502-02
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-009 confirm-req 승인 전환 (Status: Proposed → Approved)
#### Intent
- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 7개, Impacted Area, Change Log) 모두 충족 확인 후 승인
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs → draft-batch
### CHG-20260502-01
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-009 최초 생성 (source Discovery: dcy-002)
#### Intent
- v1 API 지원에 필요한 DB 스키마 변경을 REQ backlog로 정규화
#### Acceptance Criteria Delta
- Added:
- 전체 Acceptance Criteria 신규 작성
#### Impacted Area Delta
- Docs:
- docs/data-model.md (REQ-011에서 갱신 예정)
- Modules:
- migrations/versions/ 신규 migration 파일
- app/domain/models.py 모델 필드 추가
- Tests:
- migration 검증 테스트
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정. REQ-005, REQ-006, REQ-007, REQ-008과 함께 delivery 단위 구성 필수(runtime_id, market, broker_credentials, ledger_trades 모두 이 migration에 의존).
@@ -0,0 +1,99 @@
# req-010: credential_payload Fernet 암호화 및 startup validation
- Status: Approved
- Type: Non-Functional
- Priority: High
- Owner: repository-maintainers
## Intent
- broker_credentials의 credential_payload(KIS app_key/app_secret)는 민감 데이터이므로 DB 평문 저장이 금지된다(R-1 High).
- Python cryptography 패키지의 Fernet(AES-128-CBC + HMAC-SHA256)으로 저장 전 암호화, 조회 시 복호화한다.
- CREDENTIAL_ENCRYPTION_KEY 환경변수가 없으면 서비스 기동을 거부해 운영 실수를 사전에 방지한다.
## Requirement
- 서비스 기동 시 CREDENTIAL_ENCRYPTION_KEY 환경변수(base64 인코딩 32바이트)가 존재하지 않으면 기동을 중단하고 에러 메시지를 출력해야 한다.
- broker_credentials에 credential_payload를 저장할 때 Fernet으로 암호화한 ciphertext를 저장해야 한다.
- broker_credentials에서 credential_payload를 조회할 때 Fernet으로 복호화한 JSON을 반환해야 한다.
## Acceptance Criteria
1. Given CREDENTIAL_ENCRYPTION_KEY가 설정된 환경에서 서비스 기동 시, Then 정상 기동되어야 한다.
2. Given CREDENTIAL_ENCRYPTION_KEY가 설정되지 않은 환경에서 서비스 기동 시, Then 기동이 실패하고 키 누락을 알리는 에러 메시지가 출력되어야 한다.
3. Given credential_payload를 broker_credentials에 저장한 뒤 DB를 직접 조회하면, Then 암호화된 ciphertext가 저장되어 있어야 한다(평문 app_key/app_secret 불가).
4. Given 암호화 저장된 credential_payload를 조회 API(GET /v1/user_info)로 읽으면, Then 복호화된 JSON({app_key: ..., app_secret: ...})이 반환되어야 한다.
5. Given 잘못된 키로 복호화를 시도하면, Then cryptography.fernet.InvalidToken 예외가 발생하고 500 또는 적절한 오류 응답이 반환되어야 한다.
## Impacted Area
- Docs: docs/project-structure.md (app/core/encryption.py 신규 모듈 반영 필요)
- Modules: app/core/config.py (CREDENTIAL_ENCRYPTION_KEY 설정 필드 및 startup validation 추가), app/core/security.py 또는 app/core/encryption.py (Fernet 암복호화 유틸리티 신규), app/repositories/postgres.py (broker_credentials 저장·조회 시 암복호화 적용)
- Tests: tests/test_auth.py 또는 신규 테스트에서 startup validation 및 암복호화 검증
## Notes
- CREDENTIAL_ENCRYPTION_KEY는 `from cryptography.fernet import Fernet; Fernet.generate_key()`로 생성한 base64 인코딩 키를 사용한다.
- startup validation은 FastAPI lifespan 이벤트 또는 Settings 초기화 시 수행한다. 설계 단계에서 위치를 확정한다.
- 키 로테이션 절차는 이번 반복 Out of Scope이다.
- Source Discovery: dcy-002 (NFR-3, R-1, OQ-3 Answered)
## Change Log
### CHG-20260502-02
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-010 confirm-req 승인 전환 (Status: Proposed → Approved)
#### Intent
- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 5개, Impacted Area, Change Log) 모두 충족 확인 후 승인
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs → draft-batch
### CHG-20260502-01
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-010 최초 생성 (source Discovery: dcy-002 NFR-3)
#### Intent
- credential_payload 암호화 및 startup validation 요구사항을 독립 Non-Functional REQ로 승격 정규화
#### Acceptance Criteria Delta
- Added:
- 전체 Acceptance Criteria 신규 작성
#### Impacted Area Delta
- Docs:
- docs/project-structure.md (encryption 모듈 반영 필요)
- Modules:
- app/core/config.py startup validation 추가
- app/core/encryption.py 신규
- Tests:
- startup validation 및 암복호화 테스트
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정. REQ-007(GET /v1/user_info), REQ-009(broker_credentials 테이블)과 함께 delivery 단위 구성 권장.
+14 -6
View File
@@ -74,7 +74,7 @@
## Next Requirement ID
- Current: `REQ-004`
- Current: `REQ-012`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register
@@ -84,11 +84,19 @@
| REQ-001 | 로컬 DB 초기화 및 마이그레이션 검증 시나리오 정의 | Testing | Approved | High | repository-maintainers | [REQ-001](./Testing/req-001_local-db-validation-scenarios.md) |
| REQ-002 | Alembic와 runtime schema ownership 경계 정리 | Migration | Approved | High | repository-maintainers | [REQ-002](./Migration/req-002_alembic-runtime-schema-ownership.md) |
| REQ-003 | 로컬 DB 운영 runbook 및 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-003](./Documentation/req-003_local-db-operations-runbook-and-baseline-updates.md) |
| REQ-004 | GET /health 응답 스키마 확장 | Interface | Approved | High | repository-maintainers | [REQ-004](./Interface/req-004_health-response-schema-extension.md) |
| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | Approved | High | repository-maintainers | [REQ-005](./Interface/req-005_service-token-runtime-id-issuance.md) |
| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.md) |
| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-endpoint.md) |
| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-endpoint.md) |
| REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 | Migration | Approved | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.md) |
| REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Approved | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) |
| REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-011](./Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md) |
## Recent Change Log Summary
- 2026-04-30: REQ-003 Approved 전환 — 로컬 DB 운영 runbook 및 baseline 문서 갱신
- 2026-04-30: REQ-002 Approved 전환 — Alembic와 runtime schema ownership 경계 정리
- 2026-04-30: REQ-001 Approved 전환 — 로컬 DB 초기화 및 마이그레이션 검증 시나리오 정의
- 2026-04-30: REQ-003 Proposed 생성 — 로컬 DB 운영 runbook 및 baseline 문서 갱신 (source Discovery: dcy-001)
- 2026-04-30: REQ-002 Proposed 생성 — Alembic와 runtime schema ownership 경계 정리 (source Discovery: dcy-001)
- 2026-05-02: REQ-011 Approved 전환 — trex-basic 계약 반영을 위한 baseline 문서 갱신 (source Discovery: dcy-002)
- 2026-05-02: REQ-010 Approved 전환 — credential_payload Fernet 암호화 및 startup validation (source Discovery: dcy-002)
- 2026-05-02: REQ-009 Approved 전환 — v1 API DB 스키마 migration 추가 (source Discovery: dcy-002)
- 2026-05-02: REQ-008 Approved 전환 — POST /v1/assets/{asset_id}/ledger/trades 구현 (source Discovery: dcy-002)
- 2026-05-02: REQ-007 Approved 전환 — GET /v1/user_info 엔드포인트 구현 (source Discovery: dcy-002)
@@ -0,0 +1,122 @@
"""Add v1 API schema: portfolios.market, auth_tokens.runtime_id, broker_credentials, ledger_trades
Revision ID: 20260502_0014
Revises: 20260423_0013
Create Date: 2026-05-02 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260502_0014"
down_revision = "20260423_0013"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# 1. portfolios.market TEXT NULLABLE
if not _column_exists(inspector, "portfolios", "market"):
op.add_column("portfolios", sa.Column("market", sa.Text(), nullable=True))
# 2. auth_tokens.runtime_id TEXT UNIQUE NULLABLE
if not _column_exists(inspector, "auth_tokens", "runtime_id"):
op.add_column("auth_tokens", sa.Column("runtime_id", sa.Text(), nullable=True))
op.create_index(
"idx_auth_tokens_runtime_id_unique",
"auth_tokens",
["runtime_id"],
unique=True,
postgresql_where=sa.text("runtime_id IS NOT NULL"),
)
# 3. broker_credentials 테이블 신규
if not _table_exists(inspector, "broker_credentials"):
op.create_table(
"broker_credentials",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("broker", sa.Text(), nullable=False),
sa.Column("account_ref", sa.Text(), nullable=False),
sa.Column("encrypted_payload", sa.Text(), nullable=False),
sa.Column("credential_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"last_rotated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.PrimaryKeyConstraint("user_id", "broker"),
)
# 4. ledger_trades 테이블 신규
if not _table_exists(inspector, "ledger_trades"):
op.create_table(
"ledger_trades",
sa.Column(
"ledger_entry_id",
sa.BigInteger(),
sa.Identity(always=False),
nullable=False,
),
sa.Column("asset_id", sa.Text(), nullable=False),
sa.Column("order_id", sa.Text(), nullable=False),
sa.Column("execution_id", sa.Text(), nullable=False),
sa.Column("side", sa.Text(), nullable=False),
sa.Column("quantity", sa.Numeric(20, 8), nullable=False),
sa.Column("price", sa.Numeric(20, 8), nullable=False),
sa.Column("fee_amount", sa.Numeric(20, 8), nullable=False, server_default="0"),
sa.Column("tax_amount", sa.Numeric(20, 8), nullable=False, server_default="0"),
sa.Column("executed_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column(
"recorded_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.PrimaryKeyConstraint("ledger_entry_id"),
sa.UniqueConstraint("asset_id", "order_id", "execution_id", name="uq_ledger_trades_idempotency"),
)
op.create_index(
"idx_ledger_trades_asset_executed",
"ledger_trades",
["asset_id", "executed_at"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "ledger_trades"):
op.drop_table("ledger_trades")
if _table_exists(inspector, "broker_credentials"):
op.drop_table("broker_credentials")
if _column_exists(inspector, "auth_tokens", "runtime_id"):
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_runtime_id_unique"):
op.drop_index("idx_auth_tokens_runtime_id_unique", table_name="auth_tokens")
op.drop_column("auth_tokens", "runtime_id")
if _column_exists(inspector, "portfolios", "market"):
op.drop_column("portfolios", "market")
+5 -1
View File
@@ -13,7 +13,11 @@ def test_healthcheck() -> None:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
body = response.json()
assert body["status"] == "ok"
assert body["service"] == "portfolio-hub"
assert "version" in body
assert "checked_at" in body
def test_docs_are_exposed() -> None:
+2
View File
@@ -98,6 +98,7 @@ class _AuthRepo:
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken:
token = AuthToken(
id=f"token-{self._seq}",
@@ -108,6 +109,7 @@ class _AuthRepo:
token_type=token_type,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
runtime_id=runtime_id,
)
self._seq += 1
self.tokens[token.id] = token
+2
View File
@@ -122,6 +122,7 @@ class FakeRepo:
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken:
token = AuthToken(
id=f"token-{self._token_seq}",
@@ -132,6 +133,7 @@ class FakeRepo:
token_type=token_type,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
runtime_id=runtime_id,
)
self._token_seq += 1
self._auth_tokens[token.id] = token
+285
View File
@@ -0,0 +1,285 @@
"""Tests for v1 API endpoints (health, assets, user_info, ledger/trades)."""
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.core.auth import AuthContext, Permission
from app.core.config import Settings
from app.domain.models import AuthToken, Portfolio, TokenPrincipal, User, LedgerTrade
from app.main import create_app
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
**kwargs,
)
def _make_app(settings: Settings | None = None) -> TestClient:
s = settings or _make_settings()
return TestClient(create_app(settings_factory=lambda: s))
def _service_token_header(token: str = "svc-token") -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# GET /health
# ---------------------------------------------------------------------------
def test_health_returns_full_response() -> None:
client = _make_app()
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["service"] == "portfolio-hub"
assert "version" in body
assert "checked_at" in body
# checked_at should contain KST offset (+09:00)
assert "+09:00" in body["checked_at"]
# ---------------------------------------------------------------------------
# GET /v1/assets
# ---------------------------------------------------------------------------
def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
return Portfolio(
portfolio_id=portfolio_id,
broker="kis",
status="enabled",
name="Test",
symbol="005930",
market="KRW",
tags=["tag1"],
cash_balance=1000.0,
asset_quantity=10.0,
price=50000.0,
profit_buffer=0.0,
active_strategy_id=None,
avabile_strategies=None,
owner_user_id="user-1",
)
def test_v1_assets_requires_service_token(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
settings = _make_settings(db_enabled=False)
client = _make_app(settings)
resp = client.get("/v1/assets")
assert resp.status_code == 401
def test_v1_assets_rejects_env_token() -> None:
from app.core.config import get_settings
settings = _make_settings(db_enabled=False, read_tokens_raw="env-token")
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
resp = client.get("/v1/assets", headers=_service_token_header("env-token"))
assert resp.status_code == 403
def test_v1_assets_returns_asset_list(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
portfolio = _make_portfolio()
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app()
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["assets"][0]["asset_id"] == "p1"
assert body["assets"][0]["market"] == "KRW"
# ---------------------------------------------------------------------------
# GET /v1/user_info
# ---------------------------------------------------------------------------
def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id=None, # no runtime_id
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app()
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 404
assert "runtime_id" in resp.json()["detail"]
def test_v1_user_info_returns_user(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-abc"
)
user = User(
id="user-1", username="alice", email="alice@example.com",
permission="read", is_active=True,
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app()
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert body["runtime_id"] == "rt-abc"
assert body["username"] == "alice"
assert body["disabled_reason"] is None
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-2", permission="read",
token_type="service", runtime_id="rt-xyz"
)
user = User(
id="user-2", username="bob", email=None,
permission="read", is_active=False,
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app()
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["disabled_reason"] == "account_inactive"
# ---------------------------------------------------------------------------
# POST /v1/assets/{asset_id}/ledger/trades
# ---------------------------------------------------------------------------
def _trade_payload(**overrides) -> dict:
base = {
"order_id": "ord-001",
"execution_id": "exec-001",
"side": "buy",
"quantity": "10.5",
"price": "50000",
"fee_amount": "100",
"tax_amount": "0",
"executed_at": "2026-05-01T10:00:00+09:00",
}
base.update(overrides)
return base
def _make_ledger_trade(**kwargs) -> LedgerTrade:
defaults = dict(
ledger_entry_id=1,
asset_id="005930",
order_id="ord-001",
execution_id="exec-001",
side="buy",
quantity=10.5,
price=50000.0,
fee_amount=100.0,
tax_amount=0.0,
executed_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
recorded_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
)
defaults.update(kwargs)
return LedgerTrade(**defaults)
def test_v1_ledger_trade_created(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="trade-write",
token_type="service", runtime_id="rt-1"
)
trade = _make_ledger_trade()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.insert_ledger_trade.return_value = (trade, True)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app()
resp = client.post(
"/v1/assets/005930/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
assert resp.status_code == 201
body = resp.json()
assert body["created"] is True
assert body["asset_id"] == "005930"
def test_v1_ledger_trade_idempotent(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="trade-write",
token_type="service", runtime_id="rt-1"
)
trade = _make_ledger_trade()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.insert_ledger_trade.return_value = (trade, False) # already existed
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app()
resp = client.post(
"/v1/assets/005930/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
assert resp.status_code == 201
assert resp.json()["created"] is False