feat(bat-021): REQ-047 Step2 동반 코드 수정 + 실 DB 통합검증 + 런북

- ensure_schema() broker_credentials: daemon_id NOT NULL, PK=(daemon_id,broker)로 갱신
- _row_to_broker_credential: Step2 컬럼 순서(daemon_id 선두)로 갱신
- upsert_broker_credential: ON CONFLICT (daemon_id,broker)로 수정, fallback try/except 제거
- list_broker_credentials: fallback 제거, SELECT 컬럼 순서 갱신
- test_bat020_broker_credential_domain.py: AC-3/AC-4 행 순서를 Step2 포맷으로 갱신
- tests/test_bat021_step2_ensure_schema_and_upsert.py: 12개 신규 테스트
- docs/runbooks/bat-021-req047-step2-migration-runbook.md: 운영 적용 런북
- docs/batches/bat-021: batch 문서(index/planning/design/implementation/verification)

실 PostgreSQL 통합검증(AC-1~7 전건 PASS):
  Step1 upgrade → backfill → Step2 upgrade → downgrade 시나리오 포함
  NULL 사전검증(upgrade 거부) 및 downgrade 안전장치(중복 시 RuntimeError) 확인

521 passed, 0 failed (기존 509 + 신규 12)
This commit is contained in:
2026-06-28 10:19:27 +09:00
parent 7e3b34287f
commit 3e1b571906
10 changed files with 931 additions and 84 deletions
+46 -71
View File
@@ -406,16 +406,20 @@ class PostgresPortfolioRepository:
ON daemons (owner_user_id)
"""
)
# NOTE(REQ-047 Step2 / BAT-021): daemon-scoped PK 전환 이후 최종 스키마.
# 이 CREATE TABLE IF NOT EXISTS는 Step2 적용 환경에서 테이블이 이미 존재해 스킵된다.
# Step2 미적용 환경에서는 alembic revision(Step1→Step2)을 먼저 적용해야 한다.
cur.execute(
"""
CREATE TABLE IF NOT EXISTS broker_credentials (
user_id TEXT NOT NULL,
daemon_id TEXT NOT NULL,
broker TEXT NOT NULL,
user_id 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)
PRIMARY KEY (daemon_id, broker)
)
"""
)
@@ -465,16 +469,17 @@ class PostgresPortfolioRepository:
@staticmethod
def _row_to_broker_credential(row: Any) -> BrokerCredential:
"""Step2(REQ-047) 이후 컬럼 순서: daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at."""
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]) if len(row) > 4 else 1,
last_rotated_at=row[5] if len(row) > 5 else None,
daemon_id=row[6] if len(row) > 6 else None,
daemon_id=row[0],
user_id=row[1],
broker=row[2],
account_ref=row[3],
encrypted_payload=row[4],
credential_version=int(row[5]) if len(row) > 5 else 1,
last_rotated_at=row[6] if len(row) > 6 else None,
)
@staticmethod
@@ -747,40 +752,23 @@ class PostgresPortfolioRepository:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
# NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 마이그레이션 이후 존재.
# Step1 적용 전 환경에서는 daemon_id 컬럼이 없어 INSERT 실패 가능.
# Step2(PK 전환) 이후에는 ON CONFLICT 절도 (daemon_id, broker)로 변경 필요.
# 현재는 기존 PK (user_id, broker) ON CONFLICT 유지하고 daemon_id를 컬럼에 포함.
try:
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, daemon_id)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (user_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
daemon_id = EXCLUDED.daemon_id,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
""",
(user_id, broker, account_ref, encrypted_payload, daemon_id),
)
except Exception:
# Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용)
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload)
VALUES (%s, %s, %s, %s)
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),
)
# NOTE(REQ-047 Step2 / BAT-021): PK 전환 이후 ON CONFLICT (daemon_id, broker).
# daemon_id는 NOT NULL(Step2 제약). Step2 미적용 환경에서는 이 코드를 배포하지 마라.
# 배포 순서: Step1 alembic upgrade → backfill CLI → Step2 alembic upgrade → 코드 배포.
cur.execute(
"""
INSERT INTO broker_credentials (daemon_id, user_id, broker, account_ref, encrypted_payload)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (daemon_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
user_id = EXCLUDED.user_id,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
""",
(daemon_id, user_id, broker, account_ref, encrypted_payload),
)
row = cur.fetchone()
return self._row_to_broker_credential(row)
@@ -790,43 +778,30 @@ class PostgresPortfolioRepository:
daemon_id: str | None = None,
) -> list[BrokerCredential]:
self.ensure_schema()
# NOTE(REQ-047 Step2 / BAT-021): daemon_id 컬럼은 Step2 이후 항상 존재(NOT NULL).
# Step2 미적용 환경에서는 이 코드를 배포하지 마라.
with self._connect().cursor() as cur:
# NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 이후 존재.
# daemon_id 컬럼이 없는 환경(Step1 미적용)에서는 6컬럼 조회 fallback.
try:
if daemon_id is not None:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
FROM broker_credentials
WHERE user_id = %s AND daemon_id = %s
ORDER BY broker ASC
""",
(user_id, daemon_id),
)
else:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
FROM broker_credentials
WHERE user_id = %s
ORDER BY broker ASC
""",
(user_id,),
)
rows = cur.fetchall()
except Exception:
# Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용)
if daemon_id is not None:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE user_id = %s AND daemon_id = %s
ORDER BY broker ASC
""",
(user_id, daemon_id),
)
else:
cur.execute(
"""
SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE user_id = %s
ORDER BY broker ASC
""",
(user_id,),
)
rows = cur.fetchall()
rows = cur.fetchall()
return [self._row_to_broker_credential(row) for row in rows]
def update_broker_credential_daemon_id(