feat: 统一生产初始化流程并保护系统管理员
This commit is contained in:
@@ -2,16 +2,18 @@
|
||||
|
||||
## 生产部署
|
||||
- 生产入口:`docker-compose.yaml`
|
||||
- 初始化方式:`docker compose run --rm backend-init`
|
||||
- 启动方式:`docker compose up -d --build`
|
||||
- 对外入口:Nginx 提供前端静态资源,并反代后端 API
|
||||
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
|
||||
- 默认无任何 demo 数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
|
||||
- 验证方式:
|
||||
- `docker compose config`
|
||||
- `curl -i http://127.0.0.1/`
|
||||
- `curl -i http://127.0.0.1/health`
|
||||
|
||||
## 账号与注册
|
||||
- 初始化管理员:`admin@example.com / admin123`(已修正邮箱域名,后端启动时自动创建)
|
||||
- 演示账号:PM `pm@example.com / pm123456`,CRA `cra@example.com / cra123456`(仅用于体验,正式环境请修改密码或禁用)
|
||||
- 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建)
|
||||
- 自助注册:前端 `/register` 提交邮箱、密码、姓名、角色(CRA/PV/IMP/PM)、部门,状态为 PENDING。
|
||||
- 管理员审核:`/admin/user-approval` 列表查看待审核,支持通过/拒绝;仅 ACTIVE 用户可登录。
|
||||
- 更多账号:管理员也可在 `/admin/users` 直接创建 ACTIVE 账号,再在项目成员中赋予角色。
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+14
-2
@@ -10,8 +10,6 @@ services:
|
||||
volumes:
|
||||
# 持久化数据到 Linux 本地,防止重启丢失
|
||||
- ./pg_data:/var/lib/postgresql/data
|
||||
# 自动建表脚本
|
||||
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
@@ -38,6 +36,20 @@ services:
|
||||
networks:
|
||||
- ctms_net
|
||||
|
||||
backend-init:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
command: python scripts/init_production.py
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
profiles: ["init"]
|
||||
networks:
|
||||
- ctms_net
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Production Initialization Design
|
||||
|
||||
**Goal:** Replace the current production bootstrap approach with a single, explicit initialization flow that creates an empty schema via Alembic and seeds exactly one protected administrator account.
|
||||
|
||||
**Problem**
|
||||
- Production currently mixes multiple initialization sources: `database/init.sql`, Alembic migrations, and development-only startup seed behavior.
|
||||
- This creates schema drift risk and has already caused production startup failures when application models expected columns that the SQL bootstrap script did not create.
|
||||
- The default administrator identity is also not aligned with the required production account.
|
||||
|
||||
**Decisions**
|
||||
- Production schema source of truth becomes Alembic only.
|
||||
- New production environments must start from an empty database.
|
||||
- Production initialization explicitly runs schema migrations first, then ensures a fixed administrator account exists.
|
||||
- No demo users, demo projects, demo fees, or any other seed business data are created in production initialization.
|
||||
- The fixed administrator is `admin@huapont.cn` with initial password `admin123`.
|
||||
- The fixed administrator cannot be deleted, disabled, downgraded from `ADMIN`, or have its email changed.
|
||||
- The fixed administrator may change its password after first login.
|
||||
|
||||
**Architecture**
|
||||
- A dedicated backend initialization command handles production bootstrap responsibilities.
|
||||
- The command runs `alembic upgrade head`, then creates or repairs the protected administrator account if needed.
|
||||
- The API layer enforces immutability rules for the protected administrator so UI or API requests cannot remove or weaken that account.
|
||||
- The development environment may still keep development-only conveniences, but production deployment no longer depends on app startup side effects for schema creation.
|
||||
|
||||
**Operational Flow**
|
||||
1. Start PostgreSQL with an empty data directory.
|
||||
2. Run the one-shot backend initialization command.
|
||||
3. Start long-lived backend and Nginx services.
|
||||
4. Log in with `admin@huapont.cn / admin123`, then change the password.
|
||||
|
||||
**Non-Goals**
|
||||
- No automatic demo data import.
|
||||
- No runtime auto-migration inside the long-lived backend container.
|
||||
- No second schema definition maintained in `database/init.sql`.
|
||||
|
||||
**Verification Targets**
|
||||
- Empty production database initializes successfully through Alembic.
|
||||
- After initialization, the only seeded account is the fixed administrator.
|
||||
- Attempts to delete, disable, change role, or change email for the protected administrator fail.
|
||||
- Password change for the protected administrator succeeds.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Production Initialization Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Make production initialization use Alembic as the only schema source and seed exactly one protected administrator account with no demo data.
|
||||
|
||||
**Architecture:** A dedicated initialization entrypoint will run migrations and seed the protected administrator. The runtime backend container will stop relying on production startup side effects for database structure, and the user-management API will enforce immutability rules for the protected administrator account.
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy AsyncSession, Alembic, PostgreSQL, Docker Compose, pytest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Protected Admin Constants And Seed Logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/user.py`
|
||||
- Create or Modify: `backend/app/core/config.py`
|
||||
- Test: `backend/tests/...` for protected admin behavior
|
||||
|
||||
**Step 1: Write a failing test**
|
||||
|
||||
Add a backend test that proves the seed helper creates `admin@huapont.cn` with `ADMIN` role and `ACTIVE` status when the account is missing.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && pytest <new-test-path> -v`
|
||||
Expected: FAIL because the current seed helper still creates `admin@example.com`.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Introduce protected admin constants and replace the current seed helper with logic that ensures the fixed admin exists with the required immutable identity fields while allowing password updates through normal user flows.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd backend && pytest <new-test-path> -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/crud/user.py backend/app/core/config.py <test-path>
|
||||
git commit -m "feat: seed protected production admin"
|
||||
```
|
||||
|
||||
### Task 2: Block Destructive Changes To The Protected Admin
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/v1/users.py`
|
||||
- Modify: `backend/app/crud/user.py` if helper methods are needed
|
||||
- Test: `backend/tests/...` covering update/delete constraints
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Add tests showing the protected admin cannot be deleted, disabled, downgraded from `ADMIN`, or have its email changed, while password change remains allowed.
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && pytest <user-api-test-path> -v`
|
||||
Expected: FAIL because the current API only guarantees at least one admin remains.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add explicit checks in the user update and delete endpoints so the protected admin is immutable for identity and role/status fields but still allows password updates.
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && pytest <user-api-test-path> -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/v1/users.py backend/app/crud/user.py <test-path>
|
||||
git commit -m "feat: protect system admin account"
|
||||
```
|
||||
|
||||
### Task 3: Add Production Initialization Entrypoint
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/scripts/init_production.py`
|
||||
- Modify: `backend/app/main.py`
|
||||
- Modify: `backend/Dockerfile` if command support is needed
|
||||
- Test: script-level verification or targeted unit test
|
||||
|
||||
**Step 1: Write a failing verification**
|
||||
|
||||
Define a verification path that proves the initialization script performs migrations and then seeds the protected admin.
|
||||
|
||||
**Step 2: Run verification to confirm current flow is insufficient**
|
||||
|
||||
Run: `cd backend && python scripts/init_production.py --help` or targeted tests
|
||||
Expected: FAIL because no dedicated production init command exists.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Create a one-shot initialization script that runs `alembic upgrade head` and then invokes the protected-admin seed helper. Remove production reliance on app-startup schema or admin side effects.
|
||||
|
||||
**Step 4: Run verification to confirm it passes**
|
||||
|
||||
Run: `cd backend && python scripts/init_production.py --help` and relevant tests
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/scripts/init_production.py backend/app/main.py backend/Dockerfile
|
||||
git commit -m "feat: add production init entrypoint"
|
||||
```
|
||||
|
||||
### Task 4: Align Compose And Docs With The New Production Flow
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.yaml`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/release-checklist.md`
|
||||
- Optionally modify: `database/init.sql`
|
||||
|
||||
**Step 1: Write failing verification**
|
||||
|
||||
Document or verify that the current compose/release instructions still imply schema bootstrap from the wrong place.
|
||||
|
||||
**Step 2: Run verification to confirm mismatch**
|
||||
|
||||
Run: `docker compose config`
|
||||
Expected: configuration still lacks an explicit production init workflow.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add a one-shot compose service or documented command for production initialization. Update docs so production deploy always runs migrations and protected-admin seeding explicitly. Remove `database/init.sql` from the production path or reduce it so it is no longer treated as the schema source of truth.
|
||||
|
||||
**Step 4: Run verification to confirm it passes**
|
||||
|
||||
Run: `docker compose config`
|
||||
Expected: PASS with the new init workflow represented in configuration or documentation.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yaml README.md docs/release-checklist.md database/init.sql
|
||||
git commit -m "docs: define production database init flow"
|
||||
```
|
||||
|
||||
### Task 5: End-To-End Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
**Step 1: Validate backend tests**
|
||||
|
||||
Run: `cd backend && pytest <affected-test-selection> -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Validate compose configuration**
|
||||
|
||||
Run: `docker compose config`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Validate production init command**
|
||||
|
||||
Run: `docker compose run --rm backend python scripts/init_production.py`
|
||||
Expected: migrations run successfully and the protected admin is ensured.
|
||||
|
||||
**Step 4: Validate service health**
|
||||
|
||||
Run: `docker compose up -d`
|
||||
Expected: `db`, `backend`, and `nginx` start successfully.
|
||||
|
||||
Run: `curl -i http://127.0.0.1/health`
|
||||
Expected: `200 OK`
|
||||
@@ -1,7 +1,7 @@
|
||||
# Release Checklist(立项配置)
|
||||
|
||||
## 1. 构建与迁移
|
||||
- [ ] `cd backend && docker compose run --rm backend python -m alembic upgrade head`
|
||||
- [ ] `docker compose run --rm backend-init`
|
||||
- [ ] `cd backend && python3 -m compileall app scripts`
|
||||
- [ ] `cd frontend && npm run build`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user