refactor: simplify data model by removing unused fields and constraints

- Remove runtime_id from AuthToken model
- Remove market field from Portfolio model
- Remove BrokerCredential and LedgerTrade unused models
- Remove unique constraint on (owner_user_id, name)
- Simplify postgres repository schema and queries
- Clean up auth_service implementation
- Update test suite for simplified model
This commit is contained in:
2026-05-13 06:48:26 +00:00
parent 03bc3dcd8f
commit 3255d096f1
6 changed files with 149 additions and 980 deletions
+55 -345
View File
@@ -11,7 +11,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -38,8 +38,7 @@ _SELECT_AUTH_TOKEN = """
expires_at,
last_used_at,
revoked_at,
created_at,
runtime_id
created_at
FROM auth_tokens
"""
@@ -59,8 +58,7 @@ _SELECT_PORTFOLIO = """
owner_user_id,
created_at,
updated_at,
last_trade_at,
market
last_trade_at
FROM portfolios
"""
@@ -198,21 +196,6 @@ class PostgresPortfolioRepository:
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
cur.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'uq_portfolios_owner_name'
AND table_name = 'portfolios'
) THEN
ALTER TABLE portfolios
ADD CONSTRAINT uq_portfolios_owner_name UNIQUE (owner_user_id, name);
END IF;
END $$;
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_ledger (
@@ -317,7 +300,6 @@ 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
@@ -350,7 +332,6 @@ 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
@@ -551,7 +532,6 @@ 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:
@@ -562,14 +542,14 @@ class PostgresPortfolioRepository:
cur.execute(
"""
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%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, runtime_id
expires_at, last_used_at, revoked_at, created_at
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id),
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@@ -615,31 +595,6 @@ class PostgresPortfolioRepository:
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
def rotate_service_token(
self,
token_id: str,
token_hash: str,
token_prefix: str,
runtime_id: str,
) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE auth_tokens
SET token_hash = %s, token_prefix = %s, runtime_id = %s
WHERE id = %s AND token_type = 'service' AND revoked_at IS NULL
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at, runtime_id
""",
(token_hash, token_prefix, runtime_id, token_id),
)
row = cur.fetchone()
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
return self._row_to_auth_token(row)
def get_service_token_by_name(self, name: str) -> AuthToken | None:
self.ensure_schema()
with self._connect().cursor() as cur:
@@ -657,7 +612,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id
SELECT t.id, t.user_id, t.permission, t.token_type
FROM auth_tokens t
WHERE token_hash = %s
AND t.revoked_at IS NULL
@@ -674,7 +629,6 @@ 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:
@@ -732,11 +686,12 @@ class PostgresPortfolioRepository:
def create_portfolio(
self,
name: str,
portfolio_id: str,
*,
owner_user_id: str,
broker: str,
status: str,
name: str,
symbol: str,
cash_balance: float,
asset_quantity: float,
@@ -745,58 +700,42 @@ class PostgresPortfolioRepository:
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
) -> Portfolio:
self.ensure_schema()
portfolio_id = str(uuid.uuid4())
try:
with self._connect().cursor() as cur:
cur.execute(
"""
INSERT INTO portfolios (
portfolio_id, broker, status, name, symbol,
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(
portfolio_id,
broker,
status,
name,
symbol,
owner_user_id,
float(cash_balance),
float(asset_quantity),
float(asset_price),
float(profit_buffer),
active_strategy_id,
avabile_strategies,
),
)
row = cur.fetchone()
portfolio = self._row_to_portfolio(row)
portfolio.tags = []
except Exception as exc:
msg = str(exc)
if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower():
raise ValueError(f"이미 사용 중인 이름입니다: {name}") from exc
raise
return portfolio
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}")
cur.execute(
_SELECT_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s",
(owner_user_id, name),
"""
INSERT INTO portfolios (
portfolio_id, broker, status, name, symbol,
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(
portfolio_id,
broker,
status,
name,
symbol,
owner_user_id,
float(cash_balance),
float(asset_quantity),
float(asset_price),
float(profit_buffer),
active_strategy_id,
avabile_strategies,
),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_portfolio(row)
portfolio = self._row_to_portfolio(row)
portfolio.tags = []
return portfolio
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
self.ensure_schema()
@@ -824,27 +763,20 @@ class PostgresPortfolioRepository:
return self.get(portfolio_id)
assignments.append("updated_at = NOW()")
try:
with self._connect().cursor() as cur:
cur.execute(
f"""
UPDATE portfolios
SET {', '.join(assignments)}
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(*params, portfolio_id),
)
row = cur.fetchone()
except Exception as exc:
msg = str(exc)
if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower():
new_name = fields.get("name", portfolio_id)
raise ValueError(f"이미 사용 중인 이름입니다: {new_name}") from exc
raise
with self._connect().cursor() as cur:
cur.execute(
f"""
UPDATE portfolios
SET {', '.join(assignments)}
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(*params, portfolio_id),
)
row = cur.fetchone()
return self._row_to_portfolio(row)
def list_portfolios(
@@ -1364,228 +1296,6 @@ 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 list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
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
ORDER BY broker ASC
""",
(user_id,),
)
rows = cur.fetchall()
return [self._row_to_broker_credential(row) for row in rows]
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)
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
with self._connect().cursor() as cur:
cur.execute(
"""
DELETE FROM broker_credentials
WHERE user_id = %s AND broker = %s
""",
(user_id, broker),
)
return cur.rowcount > 0
# ------------------------------------------------------------------
# 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(