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:
@@ -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
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 경로 전용 테스트 없음 |
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user