전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -20,6 +20,7 @@ from .auth_types import AccountStatus, RoleName
from .config import settings
from .db import get_pool
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .runtime_schema import runtime_schema_bootstrap_required
from .services import notifications
logger = logging.getLogger(__name__)
@ -191,7 +192,11 @@ def user_id_from_email(email: str) -> str:
def user_id_from_external_id(external_id: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user-external:{external_id.strip().lower()}"))
return str(
uuid.uuid5(
uuid.NAMESPACE_URL, f"vignette:user-external:{external_id.strip().lower()}"
)
)
def _normalize_email(email: str) -> str:
@ -344,10 +349,11 @@ async def _runtime_tables_ready(conn) -> bool:
'persona_code',
'persona_display_name',
'persona_difficulty',
'prev_rapport_credit'
'prev_rapport_credit',
'session_goals'
)
GROUP BY table_schema, table_name
HAVING count(*) = 5
HAVING count(*) = 6
) AS has_session_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
@ -517,13 +523,9 @@ async def ensure_runtime_tables() -> None:
"""Ensure DB-backed auth/user runtime tables exist when a pool is available."""
pool = get_pool()
async with pool.acquire() as conn:
if await _runtime_tables_ready(conn):
ready = await _runtime_tables_ready(conn)
if not runtime_schema_bootstrap_required("auth/user", ready=ready):
return
if settings.environment != "dev":
raise RuntimeError(
"runtime DB schema is incomplete; run owner migration/init and "
"scripts/check-deploy-preflight.py before starting the API"
)
await conn.execute(
"""
DO $$
@ -904,7 +906,8 @@ async def ensure_runtime_tables() -> None:
ADD COLUMN IF NOT EXISTS persona_code TEXT,
ADD COLUMN IF NOT EXISTS persona_display_name TEXT,
ADD COLUMN IF NOT EXISTS persona_difficulty TEXT,
ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0
ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0,
ADD COLUMN IF NOT EXISTS session_goals JSONB NOT NULL DEFAULT '[]'::jsonb
"""
)
await conn.execute(
@ -1054,6 +1057,10 @@ async def ensure_runtime_tables() -> None:
)
"""
)
if not await _runtime_tables_ready(conn):
raise RuntimeError(
"auth/user development schema bootstrap did not satisfy readiness"
)
def _managed_user_from_row(row) -> ManagedUser:
@ -1092,15 +1099,23 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
raise InactiveUserError("user is inactive")
if data.reactivate:
_inactive_emails.discard(normalized_email)
uid = data.user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email)
uid = (
data.user_id
or _email_index.get(normalized_email)
or user_id_from_email(normalized_email)
)
current = _users.get(uid)
user = ManagedUser(
user_id=uid,
email=normalized_email,
display_name=(data.display_name.strip() if data.display_name else "") or normalized_email,
display_name=(data.display_name.strip() if data.display_name else "")
or normalized_email,
role=data.role,
admin_access=data.admin_access if data.admin_access is not None else (current.admin_access if current else False),
account_status=data.account_status or (current.account_status if current else "approved"),
admin_access=data.admin_access
if data.admin_access is not None
else (current.admin_access if current else False),
account_status=data.account_status
or (current.account_status if current else "approved"),
cohort_ids=(
list(data.cohort_ids)
if data.cohort_ids is not None
@ -1126,20 +1141,28 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
if data.grade_level is not None
else (current.grade_level if current else "")
),
phone=data.phone.strip() if data.phone is not None else (current.phone if current else ""),
phone=data.phone.strip()
if data.phone is not None
else (current.phone if current else ""),
contact_address=(
data.contact_address.strip()
if data.contact_address is not None
else (current.contact_address if current else "")
),
nickname=data.nickname.strip() if data.nickname is not None else (current.nickname if current else ""),
nickname=data.nickname.strip()
if data.nickname is not None
else (current.nickname if current else ""),
self_introduction=(
data.self_introduction.strip()
if data.self_introduction is not None
else (current.self_introduction if current else "")
),
avatar_url=data.avatar_url.strip() if data.avatar_url is not None else (current.avatar_url if current else ""),
consent_at=data.consent_at if data.consent_at is not None else (current.consent_at if current else None),
avatar_url=data.avatar_url.strip()
if data.avatar_url is not None
else (current.avatar_url if current else ""),
consent_at=data.consent_at
if data.consent_at is not None
else (current.consent_at if current else None),
profile_completed_at=(
data.profile_completed_at
if data.profile_completed_at is not None
@ -1173,6 +1196,25 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
return user
def _sync_managed_user_sessions(user: ManagedUser) -> None:
"""관리 사용자 변경을 현재 로그인 세션의 권한 스냅샷에 반영한다."""
for session in _sessions.values():
if session.user_id != user.user_id:
continue
session.display_name = user.display_name
session.role = user.role
session.admin_access = has_admin_access(
user.email,
user.role,
user.admin_access,
)
session.super_admin = is_super_admin_email(user.email)
session.account_status = user.account_status
session.cohort_ids = list(user.cohort_ids)
session.consent_at = user.consent_at
session.profile_completed_at = user.profile_completed_at
async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
normalized_email = _normalize_email(data.email)
normalized_external_id = _normalize_external_id(data.external_id, normalized_email)
@ -1183,11 +1225,11 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
reactivate=data.reactivate,
)
try:
pool = get_pool()
async with pool.acquire() as conn:
if data.user_id is None and normalized_external_id != manual_external_id:
row = await conn.fetchrow(
"""
pool = get_pool()
async with pool.acquire() as conn:
if data.user_id is None and normalized_external_id != manual_external_id:
row = await conn.fetchrow(
"""
UPDATE app.app_user SET
external_id = $1,
display_name = COALESCE(NULLIF(display_name, ''), $3),
@ -1234,19 +1276,25 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
created_at,
last_seen_at
""",
normalized_external_id,
normalized_email,
(data.display_name.strip() if data.display_name else normalized_email),
_cohort_value(data.cohort_ids),
data.affiliation or DEFAULT_AFFILIATION,
manual_external_id,
)
if row is not None:
user = _managed_user_from_row(row)
_memory_upsert_managed_user(ManagedUserMemoryInput.from_user(user, reactivate=True))
return user
row = await conn.fetchrow(
"""
normalized_external_id,
normalized_email,
(
data.display_name.strip()
if data.display_name
else normalized_email
),
_cohort_value(data.cohort_ids),
data.affiliation or DEFAULT_AFFILIATION,
manual_external_id,
)
if row is not None:
user = _managed_user_from_row(row)
_memory_upsert_managed_user(
ManagedUserMemoryInput.from_user(user, reactivate=True)
)
return user
row = await conn.fetchrow(
"""
INSERT INTO app.app_user (
external_id,
email,
@ -1306,35 +1354,44 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
created_at,
last_seen_at
""",
normalized_external_id,
normalized_email,
(data.display_name.strip() if data.display_name else normalized_email),
_db_role(data.role),
_cohort_value(data.cohort_ids),
data.affiliation or DEFAULT_AFFILIATION,
data.reactivate,
desired_account_status,
data.admin_access,
)
if row is None:
_inactive_emails.add(normalized_email)
raise InactiveUserError("user is inactive")
user = _managed_user_from_row(row)
_memory_upsert_managed_user(ManagedUserMemoryInput.from_user(user, reactivate=True))
return user
normalized_external_id,
normalized_email,
(data.display_name.strip() if data.display_name else normalized_email),
_db_role(data.role),
_cohort_value(data.cohort_ids),
data.affiliation or DEFAULT_AFFILIATION,
data.reactivate,
desired_account_status,
data.admin_access,
)
if row is None:
_inactive_emails.add(normalized_email)
raise InactiveUserError("user is inactive")
user = _managed_user_from_row(row)
_memory_upsert_managed_user(
ManagedUserMemoryInput.from_user(user, reactivate=True)
)
return user
except InactiveUserError:
raise
except Exception:
require_runtime_fallback_allowed("managed user")
current = _users.get(data.user_id or "") or _users.get(_email_index.get(normalized_email, ""))
current = _users.get(data.user_id or "") or _users.get(
_email_index.get(normalized_email, "")
)
fallback_uid = data.user_id or (
current.user_id if current is not None else user_id_from_external_id(normalized_external_id)
current.user_id
if current is not None
else user_id_from_external_id(normalized_external_id)
)
fallback_account_status = desired_account_status
if current is not None:
if current.account_status == "suspended":
fallback_account_status = "suspended"
elif current.account_status == "approved" and desired_account_status == "pending":
elif (
current.account_status == "approved"
and desired_account_status == "pending"
):
fallback_account_status = "approved"
return _memory_upsert_managed_user(
ManagedUserMemoryInput(
@ -1561,33 +1618,28 @@ async def update_managed_user(
patch.department.strip() if patch.department is not None else None,
patch.grade_level.strip() if patch.grade_level is not None else None,
patch.phone.strip() if patch.phone is not None else None,
patch.contact_address.strip() if patch.contact_address is not None else None,
patch.contact_address.strip()
if patch.contact_address is not None
else None,
patch.nickname.strip() if patch.nickname is not None else None,
patch.self_introduction.strip() if patch.self_introduction is not None else None,
patch.self_introduction.strip()
if patch.self_introduction is not None
else None,
patch.avatar_url.strip() if patch.avatar_url is not None else None,
patch.complete_onboarding,
patch.terms_version.strip() if patch.terms_version is not None else None,
patch.privacy_version.strip() if patch.privacy_version is not None else None,
patch.terms_version.strip()
if patch.terms_version is not None
else None,
patch.privacy_version.strip()
if patch.privacy_version is not None
else None,
patch.account_status,
patch.admin_access,
)
if row is not None:
next_user = _managed_user_from_row(row)
_memory_upsert_managed_user(ManagedUserMemoryInput.from_user(next_user))
for session in _sessions.values():
if session.user_id == user_id:
session.display_name = next_user.display_name
session.role = next_user.role
session.admin_access = has_admin_access(
next_user.email,
next_user.role,
next_user.admin_access,
)
session.super_admin = is_super_admin_email(next_user.email)
session.account_status = next_user.account_status
session.cohort_ids = list(next_user.cohort_ids)
session.consent_at = next_user.consent_at
session.profile_completed_at = next_user.profile_completed_at
_sync_managed_user_sessions(next_user)
return next_user
except Exception:
require_runtime_fallback_allowed("managed user update")
@ -1601,32 +1653,58 @@ async def update_managed_user(
next_user = ManagedUser(
user_id=current.user_id,
email=current.email,
display_name=patch.display_name.strip() if patch.display_name is not None else current.display_name,
display_name=patch.display_name.strip()
if patch.display_name is not None
else current.display_name,
role=patch.role if patch.role is not None else current.role,
admin_access=patch.admin_access if patch.admin_access is not None else current.admin_access,
account_status=patch.account_status if patch.account_status is not None else current.account_status,
cohort_ids=list(patch.cohort_ids) if patch.cohort_ids is not None else current.cohort_ids,
affiliation=patch.affiliation.strip() if patch.affiliation is not None else current.affiliation,
legal_name=patch.legal_name.strip() if patch.legal_name is not None else current.legal_name,
department=patch.department.strip() if patch.department is not None else current.department,
grade_level=patch.grade_level.strip() if patch.grade_level is not None else current.grade_level,
admin_access=patch.admin_access
if patch.admin_access is not None
else current.admin_access,
account_status=patch.account_status
if patch.account_status is not None
else current.account_status,
cohort_ids=list(patch.cohort_ids)
if patch.cohort_ids is not None
else current.cohort_ids,
affiliation=patch.affiliation.strip()
if patch.affiliation is not None
else current.affiliation,
legal_name=patch.legal_name.strip()
if patch.legal_name is not None
else current.legal_name,
department=patch.department.strip()
if patch.department is not None
else current.department,
grade_level=patch.grade_level.strip()
if patch.grade_level is not None
else current.grade_level,
phone=patch.phone.strip() if patch.phone is not None else current.phone,
contact_address=(
patch.contact_address.strip()
if patch.contact_address is not None
else current.contact_address
),
nickname=patch.nickname.strip() if patch.nickname is not None else current.nickname,
nickname=patch.nickname.strip()
if patch.nickname is not None
else current.nickname,
self_introduction=(
patch.self_introduction.strip()
if patch.self_introduction is not None
else current.self_introduction
),
avatar_url=patch.avatar_url.strip() if patch.avatar_url is not None else current.avatar_url,
avatar_url=patch.avatar_url.strip()
if patch.avatar_url is not None
else current.avatar_url,
consent_at=current.consent_at,
profile_completed_at=time.time() if patch.complete_onboarding else current.profile_completed_at,
terms_agreed_at=time.time() if patch.complete_onboarding else current.terms_agreed_at,
privacy_agreed_at=time.time() if patch.complete_onboarding else current.privacy_agreed_at,
profile_completed_at=time.time()
if patch.complete_onboarding
else current.profile_completed_at,
terms_agreed_at=time.time()
if patch.complete_onboarding
else current.terms_agreed_at,
privacy_agreed_at=time.time()
if patch.complete_onboarding
else current.privacy_agreed_at,
terms_version=(
patch.terms_version.strip()
if patch.complete_onboarding and patch.terms_version is not None
@ -1641,21 +1719,7 @@ async def update_managed_user(
last_seen_at=time.time(),
)
_users[user_id] = next_user
for session in _sessions.values():
if session.user_id == user_id:
session.display_name = next_user.display_name
session.role = next_user.role
session.admin_access = has_admin_access(
next_user.email,
next_user.role,
next_user.admin_access,
)
session.super_admin = is_super_admin_email(next_user.email)
session.account_status = next_user.account_status
session.cohort_ids = list(next_user.cohort_ids)
session.consent_at = next_user.consent_at
session.profile_completed_at = next_user.profile_completed_at
_sync_managed_user_sessions(next_user)
return next_user
@ -1865,7 +1929,9 @@ async def create_session(
email=normalized_email,
display_name=managed.display_name,
role=managed.role,
admin_access=has_admin_access(managed.email, managed.role, managed.admin_access),
admin_access=has_admin_access(
managed.email, managed.role, managed.admin_access
),
super_admin=is_super_admin_email(managed.email),
account_status=managed.account_status,
cohort_ids=list(managed.cohort_ids),
@ -1976,7 +2042,9 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
managed.last_seen_at = time.time()
user.display_name = managed.display_name
user.role = managed.role
user.admin_access = has_admin_access(managed.email, managed.role, managed.admin_access)
user.admin_access = has_admin_access(
managed.email, managed.role, managed.admin_access
)
user.super_admin = is_super_admin_email(managed.email)
user.account_status = managed.account_status
user.cohort_ids = list(managed.cohort_ids)