feat: 统一生产初始化流程并保护系统管理员

This commit is contained in:
Cheng Zhou
2026-03-27 17:17:32 +08:00
parent f2f856ea24
commit a67991740e
14 changed files with 492 additions and 10 deletions
+3
View File
@@ -9,6 +9,9 @@ WORKDIR /code
COPY requirements.txt /code/requirements.txt
RUN pip install --upgrade pip && pip install -r /code/requirements.txt
COPY alembic.ini /code/alembic.ini
COPY alembic /code/alembic
COPY app /code/app
COPY scripts /code/scripts
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+12
View File
@@ -51,6 +51,16 @@ async def update_user(
db_user = await user_crud.get_by_id(db, user_id)
if not db_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user_crud.is_protected_admin_user(db_user):
if user_in.email is not None and user_in.email != db_user.email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员邮箱不允许修改")
if user_in.role is not None and user_in.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员角色不允许修改")
requested_status = user_in.status
if user_in.is_active is not None:
requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
if requested_status is not None and requested_status != "ACTIVE":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员不允许停用")
if db_user.role.value == "ADMIN":
requested_role = user_in.role
requested_status = user_in.status
@@ -75,6 +85,8 @@ async def delete_user(
db_user = await user_crud.get_by_id(db, user_id)
if not db_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user_crud.is_protected_admin_user(db_user):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员账号不允许删除")
if db_user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己")
if db_user.role.value == "ADMIN" and db_user.status.value == "ACTIVE":
+5
View File
@@ -4,6 +4,11 @@ from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
PROTECTED_ADMIN_EMAIL = "admin@huapont.cn"
PROTECTED_ADMIN_DEFAULT_PASSWORD = "admin123"
PROTECTED_ADMIN_FULL_NAME = "System Admin"
PROTECTED_ADMIN_DEPARTMENT = "SYSTEM"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+8
View File
@@ -107,6 +107,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.contract_fee import ContractFee
from app.models.contract_fee_payment import ContractFeePayment
from app.models.special_expense import SpecialExpense
from app.models.milestone import Milestone
from app.models.document import Document
@@ -157,6 +158,13 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
# 9. 删除财务相关
await db.execute(sa_delete(SpecialExpense).where(SpecialExpense.project_id == study_id))
await db.execute(
sa_delete(ContractFeePayment).where(
ContractFeePayment.contract_fee_id.in_(
select(ContractFee.id).where(ContractFee.project_id == study_id)
)
)
)
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
await db.execute(sa_delete(FinanceSpecial).where(FinanceSpecial.study_id == study_id))
await db.execute(sa_delete(FinanceContract).where(FinanceContract.study_id == study_id))
+19 -5
View File
@@ -4,6 +4,12 @@ from typing import Sequence
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import (
PROTECTED_ADMIN_DEFAULT_PASSWORD,
PROTECTED_ADMIN_DEPARTMENT,
PROTECTED_ADMIN_EMAIL,
PROTECTED_ADMIN_FULL_NAME,
)
from app.core.security import hash_password
from app.models.study_member import StudyMember
from app.models.user import User, UserRole, UserStatus
@@ -15,6 +21,14 @@ async def get_by_email(db: AsyncSession, email: str) -> User | None:
return result.scalar_one_or_none()
def is_protected_admin_email(email: str | None) -> bool:
return (email or "").strip().lower() == PROTECTED_ADMIN_EMAIL
def is_protected_admin_user(user: User | None) -> bool:
return user is not None and is_protected_admin_email(user.email)
async def get_by_id(db: AsyncSession, user_id: uuid.UUID) -> User | None:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
@@ -90,17 +104,17 @@ async def list_users_by_status(
return result.scalars().all()
async def ensure_admin_exists(db: AsyncSession, *, default_password: str = "admin123") -> None:
result = await db.execute(select(User).where(User.role == UserRole.ADMIN))
async def ensure_admin_exists(db: AsyncSession, *, default_password: str = PROTECTED_ADMIN_DEFAULT_PASSWORD) -> None:
result = await db.execute(select(User).where(User.email == PROTECTED_ADMIN_EMAIL))
admin = result.scalar_one_or_none()
if admin:
return
new_admin = User(
email="admin@example.com",
email=PROTECTED_ADMIN_EMAIL,
password_hash=hash_password(default_password),
full_name="System Admin",
full_name=PROTECTED_ADMIN_FULL_NAME,
role=UserRole.ADMIN,
department="SYSTEM",
department=PROTECTED_ADMIN_DEPARTMENT,
status=UserStatus.ACTIVE,
)
db.add(new_admin)
+37
View File
@@ -0,0 +1,37 @@
import asyncio
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from app.core.config import PROTECTED_ADMIN_EMAIL # noqa: E402
from app.crud.user import ensure_admin_exists # noqa: E402
from app.db.session import SessionLocal # noqa: E402
def run_migrations() -> None:
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
check=True,
cwd=PROJECT_ROOT,
)
async def seed_protected_admin() -> None:
async with SessionLocal() as session:
await ensure_admin_exists(session)
def main() -> None:
print("Running Alembic migrations...")
run_migrations()
print(f"Ensuring protected admin exists: {PROTECTED_ADMIN_EMAIL}")
asyncio.run(seed_protected_admin())
print("Production initialization complete.")
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
import pytest
from app.crud import user as user_crud
from app.models.user import UserRole, UserStatus
class _ScalarResult:
def __init__(self, value):
self._value = value
def scalar_one_or_none(self):
return self._value
class _FakeSession:
def __init__(self, existing_admin=None):
self.existing_admin = existing_admin
self.added = []
self.commit_count = 0
async def execute(self, _stmt):
return _ScalarResult(self.existing_admin)
def add(self, obj):
self.added.append(obj)
async def commit(self):
self.commit_count += 1
@pytest.mark.asyncio
async def test_ensure_admin_exists_creates_protected_admin():
session = _FakeSession()
await user_crud.ensure_admin_exists(session)
assert len(session.added) == 1
admin = session.added[0]
assert admin.email == "admin@huapont.cn"
assert admin.role == UserRole.ADMIN
assert admin.status == UserStatus.ACTIVE
assert session.commit_count == 1
+107
View File
@@ -0,0 +1,107 @@
import uuid
import pytest
from fastapi import HTTPException
from app.api.v1 import users as users_api
from app.models.user import User, UserRole, UserStatus
from app.schemas.user import UserRead, UserUpdate
def _make_user(
*,
email: str,
role: UserRole = UserRole.ADMIN,
status: UserStatus = UserStatus.ACTIVE,
) -> User:
return User(
id=uuid.uuid4(),
email=email,
password_hash="hashed",
full_name="User",
department="SYSTEM",
role=role,
status=status,
)
@pytest.mark.asyncio
async def test_delete_user_blocks_protected_admin(monkeypatch):
current_user = _make_user(email="other-admin@huapont.cn")
protected_admin = _make_user(email="admin@huapont.cn")
async def fake_get_by_id(_db, _user_id):
return protected_admin
async def fake_count_active_admins(_db):
return 2
async def fake_user_has_memberships(_db, _user_id):
return False
async def fake_delete_user(_db, _user):
raise AssertionError("protected admin should not be deleted")
monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id)
monkeypatch.setattr(users_api.user_crud, "count_active_admins", fake_count_active_admins)
monkeypatch.setattr(users_api.member_crud, "user_has_memberships", fake_user_has_memberships)
monkeypatch.setattr(users_api.user_crud, "delete_user", fake_delete_user)
with pytest.raises(HTTPException) as exc_info:
await users_api.delete_user(protected_admin.id, db=object(), current_user=current_user)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_update_user_blocks_protected_admin_email_change(monkeypatch):
protected_admin = _make_user(email="admin@huapont.cn")
async def fake_get_by_id(_db, _user_id):
return protected_admin
async def fake_count_active_admins(_db):
return 2
async def fake_update_user(_db, _user, _payload):
raise AssertionError("protected admin email should not be changed")
monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id)
monkeypatch.setattr(users_api.user_crud, "count_active_admins", fake_count_active_admins)
monkeypatch.setattr(users_api.user_crud, "update_user", fake_update_user)
with pytest.raises(HTTPException) as exc_info:
await users_api.update_user(
protected_admin.id,
UserUpdate(email="renamed@huapont.cn"),
db=object(),
current_user=protected_admin,
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_update_user_allows_protected_admin_password_change(monkeypatch):
protected_admin = _make_user(email="admin@huapont.cn")
captured_payload = {}
async def fake_get_by_id(_db, _user_id):
return protected_admin
async def fake_update_user(_db, _user, payload):
captured_payload["password"] = payload.password
return _user
monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id)
monkeypatch.setattr(users_api.user_crud, "update_user", fake_update_user)
result = await users_api.update_user(
protected_admin.id,
UserUpdate(password="Password123"),
db=object(),
current_user=protected_admin,
)
assert isinstance(result, UserRead | User)
assert captured_payload["password"] == "Password123"
+31
View File
@@ -0,0 +1,31 @@
import uuid
import pytest
from app.crud import study as study_crud
class _FakeDb:
def __init__(self):
self.tables = []
self.committed = False
async def execute(self, stmt):
table = getattr(getattr(stmt, "table", None), "name", None)
if table:
self.tables.append(table)
async def commit(self):
self.committed = True
@pytest.mark.asyncio
async def test_delete_study_removes_contract_fee_payments_before_contract_fees():
db = _FakeDb()
await study_crud.delete(db, uuid.uuid4())
assert "contract_fee_payments" in db.tables
assert "contract_fees" in db.tables
assert db.tables.index("contract_fee_payments") < db.tables.index("contract_fees")
assert db.committed is True