diff --git a/.env.example b/.env.example index 957f980..5b574b2 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,18 @@ SENTRY_DSN= LOG_LEVEL=INFO # 是否输出 JSON 格式日志。 LOG_JSON=false +# 是否在后端启动时自动创建默认管理员。 +DEFAULT_ADMIN_ENABLED=true +# 默认管理员手机号,可用于登录管理端。 +DEFAULT_ADMIN_PHONE=13900000001 +# 默认管理员邮箱,也可用于登录管理端。 +DEFAULT_ADMIN_EMAIL=admin@ciyuan.local +# 默认管理员密码,生产环境必须修改。 +DEFAULT_ADMIN_PASSWORD=admin123456 +# 默认管理员昵称。 +DEFAULT_ADMIN_NICKNAME=系统管理员 +# 启动时是否把已有默认管理员密码同步为 DEFAULT_ADMIN_PASSWORD。 +DEFAULT_ADMIN_SYNC_PASSWORD=true # 管理端前端构建配置 # 管理端构建阶段 Node 基础镜像。 diff --git a/docker-compose.yml b/docker-compose.yml index 800661a..6fab80a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,12 @@ services: SENTRY_DSN: "${SENTRY_DSN}" LOG_LEVEL: "${LOG_LEVEL}" LOG_JSON: "${LOG_JSON}" + DEFAULT_ADMIN_ENABLED: "${DEFAULT_ADMIN_ENABLED}" + DEFAULT_ADMIN_PHONE: "${DEFAULT_ADMIN_PHONE}" + DEFAULT_ADMIN_EMAIL: "${DEFAULT_ADMIN_EMAIL}" + DEFAULT_ADMIN_PASSWORD: "${DEFAULT_ADMIN_PASSWORD}" + DEFAULT_ADMIN_NICKNAME: "${DEFAULT_ADMIN_NICKNAME}" + DEFAULT_ADMIN_SYNC_PASSWORD: "${DEFAULT_ADMIN_SYNC_PASSWORD}" expose: - "${SERVER_INTERNAL_PORT}" volumes: diff --git a/server/README.md b/server/README.md index 44ace6a..0a74ae2 100644 --- a/server/README.md +++ b/server/README.md @@ -1,6 +1,8 @@ # 后端默认管理员账号 -- 管理后台登录账号(手机号):`13900000001` -- 管理后台登录密码:`demo123456` +后端启动时会根据根目录 `.env` 自动确保默认管理员存在。 -> 说明:以上为 `seed_demo_data.py` 初始化的演示管理员账号(role=`admin`)。 +- 管理后台登录账号:`DEFAULT_ADMIN_PHONE` 或 `DEFAULT_ADMIN_EMAIL` +- 管理后台登录密码:`DEFAULT_ADMIN_PASSWORD` + +默认模板值为 `13900000001 / admin123456`。生产环境请修改根目录 `.env`。 diff --git a/server/app/core/config.py b/server/app/core/config.py index 0c4a517..74f70d9 100644 --- a/server/app/core/config.py +++ b/server/app/core/config.py @@ -34,6 +34,13 @@ class Settings(BaseSettings): LOG_JSON: bool = False LOG_LEVEL: str = "INFO" + DEFAULT_ADMIN_ENABLED: bool = True + DEFAULT_ADMIN_PHONE: str = "13900000001" + DEFAULT_ADMIN_EMAIL: str = "admin@ciyuan.local" + DEFAULT_ADMIN_PASSWORD: str = "admin123456" + DEFAULT_ADMIN_NICKNAME: str = "系统管理员" + DEFAULT_ADMIN_SYNC_PASSWORD: bool = True + class Config: env_file = _env_file diff --git a/server/app/db/bootstrap.py b/server/app/db/bootstrap.py new file mode 100644 index 0000000..14dbadf --- /dev/null +++ b/server/app/db/bootstrap.py @@ -0,0 +1,65 @@ +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security import get_password_hash, verify_password +from app.db.session import sync_engine +from app.models.user import User + + +def ensure_default_admin() -> None: + if not settings.DEFAULT_ADMIN_ENABLED: + return + + phone = settings.DEFAULT_ADMIN_PHONE.strip() or None + email = settings.DEFAULT_ADMIN_EMAIL.strip() or None + if not phone and not email: + raise RuntimeError("DEFAULT_ADMIN_PHONE or DEFAULT_ADMIN_EMAIL is required") + + filters = [] + if phone: + filters.append(User.phone == phone) + if email: + filters.append(User.email == email) + + with Session(sync_engine) as session: + user = session.execute(select(User).where(or_(*filters))).scalar_one_or_none() + + if user is None: + user = User( + phone=phone, + email=email, + password_hash=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD), + nickname=settings.DEFAULT_ADMIN_NICKNAME, + identity="both", + role="admin", + is_active=True, + ) + session.add(user) + session.commit() + return + + changed = False + expected = { + "phone": phone, + "email": email, + "nickname": settings.DEFAULT_ADMIN_NICKNAME, + "identity": "both", + "role": "admin", + "is_active": True, + } + for field, value in expected.items(): + if value is not None and getattr(user, field) != value: + setattr(user, field, value) + changed = True + + if settings.DEFAULT_ADMIN_SYNC_PASSWORD and not verify_password( + settings.DEFAULT_ADMIN_PASSWORD, + user.password_hash, + ): + user.password_hash = get_password_hash(settings.DEFAULT_ADMIN_PASSWORD) + changed = True + + if changed: + session.add(user) + session.commit() diff --git a/server/app/main.py b/server/app/main.py index 464e341..903cf23 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -14,6 +14,7 @@ from app.api.v1.router import v1_router from app.core.config import settings from app.core.deps import get_current_active_user, get_db from app.core.storage import init_storage +from app.db.bootstrap import ensure_default_admin from app.db.migrations import run_startup_migrations from app.models.spot import Spot from app.models.tag import Tag @@ -44,6 +45,7 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): await asyncio.to_thread(run_startup_migrations) + await asyncio.to_thread(ensure_default_admin) app.state.redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True) app.state.storage = init_storage() yield