feat(server): 添加默认管理员自动创建功能

- 在 .env.example 中添加默认管理员相关配置项
- 在 docker-compose.yml 中添加默认管理员环境变量映射
- 在 server/app/core/config.py 中定义默认管理员配置
- 创建 server/app/db/bootstrap.py 文件实现默认管理员创建逻辑
- 在 server/app/main.py 的生命周期中集成默认管理员确保功能
- 更新 README.md 文档说明新的管理员配置方式

新配置项包括:DEFAULT_ADMIN_ENABLED、DEFAULT_ADMIN_PHONE、
DEFAULT_ADMIN_EMAIL、DEFAULT_ADMIN_PASSWORD、DEFAULT_ADMIN_NICKNAME
和 DEFAULT_ADMIN_SYNC_PASSWORD。
This commit is contained in:
2026-05-09 19:00:02 +08:00
parent 3f3acf834d
commit f1ce992d69
6 changed files with 97 additions and 3 deletions
+12
View File
@@ -69,6 +69,18 @@ SENTRY_DSN=
LOG_LEVEL=INFO LOG_LEVEL=INFO
# 是否输出 JSON 格式日志。 # 是否输出 JSON 格式日志。
LOG_JSON=false 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 基础镜像。 # 管理端构建阶段 Node 基础镜像。
+6
View File
@@ -86,6 +86,12 @@ services:
SENTRY_DSN: "${SENTRY_DSN}" SENTRY_DSN: "${SENTRY_DSN}"
LOG_LEVEL: "${LOG_LEVEL}" LOG_LEVEL: "${LOG_LEVEL}"
LOG_JSON: "${LOG_JSON}" 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: expose:
- "${SERVER_INTERNAL_PORT}" - "${SERVER_INTERNAL_PORT}"
volumes: volumes:
+5 -3
View File
@@ -1,6 +1,8 @@
# 后端默认管理员账号 # 后端默认管理员账号
- 管理后台登录账号(手机号):`13900000001` 后端启动时会根据根目录 `.env` 自动确保默认管理员存在。
- 管理后台登录密码:`demo123456`
> 说明:以上为 `seed_demo_data.py` 初始化的演示管理员账号(role=`admin`)。 - 管理后台登录账号:`DEFAULT_ADMIN_PHONE``DEFAULT_ADMIN_EMAIL`
- 管理后台登录密码:`DEFAULT_ADMIN_PASSWORD`
默认模板值为 `13900000001 / admin123456`。生产环境请修改根目录 `.env`
+7
View File
@@ -34,6 +34,13 @@ class Settings(BaseSettings):
LOG_JSON: bool = False LOG_JSON: bool = False
LOG_LEVEL: str = "INFO" 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: class Config:
env_file = _env_file env_file = _env_file
+65
View File
@@ -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()
+2
View File
@@ -14,6 +14,7 @@ from app.api.v1.router import v1_router
from app.core.config import settings from app.core.config import settings
from app.core.deps import get_current_active_user, get_db from app.core.deps import get_current_active_user, get_db
from app.core.storage import init_storage 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.db.migrations import run_startup_migrations
from app.models.spot import Spot from app.models.spot import Spot
from app.models.tag import Tag from app.models.tag import Tag
@@ -44,6 +45,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await asyncio.to_thread(run_startup_migrations) 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.redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
app.state.storage = init_storage() app.state.storage = init_storage()
yield yield