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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user