feat: 添加后台登录验证码、记住密码功能,优化媒体资源与前端规范

- 新增后台登录图形验证码功能,完善登录安全防护
- 新增登录rememberMe参数,控制Refresh Token的会话持久化策略
- 实现OSS私有桶媒体URL自动签名,统一处理图片资源的临时访问签名
- 新增素材库数据库表与上传API,规范媒体资源管理流程
- 统一前端UI图标使用@element-plus/icons-vue,重构布局图标组件
- 登录页新增验证码输入、刷新功能,添加账号记忆与记住密码逻辑
- 更新全套文档,补充API契约、技术决策记录与集成流程说明
- 修复多个业务页面的图标展示问题,新增认证流程相关测试用例
This commit is contained in:
duanshuwen committed 2026-08-27 07:50:59 +08:00
1 parent 2c8c327de7
commit 6245159e7c
35 files changed
+914 -79

No files matched your search

+33 -15
View File
@@ -12,6 +12,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from ..auth import get_actor_id, issue_admin_session, require_admin, rotate_admin_session, verify_password
from ..api_response import success_response
from ..captcha import create_captcha, verify_captcha
from ..config import get_settings
from ..database import get_db
from ..models import (
@@ -57,7 +58,8 @@ from ..schemas import (
DetailPatch,
)
from ..seed import create_media
from ..serializers import concierge_advisor_dict, detail_record_dict, encode_value, lead_dict, model_dict
from ..media_urls import resolve_media_url
from ..serializers import concierge_advisor_dict, detail_record_dict, encode_value, lead_dict, media_model_dict, model_dict
from .shared import site_config
@@ -291,7 +293,7 @@ def hero_slide_admin_dict(item: HeroSlide) -> dict:
"id": item.id,
"title": item.title,
"kicker": item.kicker or None,
"image": item.image or None,
"image": resolve_media_url(item.image) or None,
"isActive": item.isActive,
"sortOrder": item.sortOrder,
"createdAt": encode_value(item.createdAt),
@@ -302,7 +304,7 @@ def hero_slide_admin_dict(item: HeroSlide) -> dict:
def site_item_dict(module: str, item) -> dict:
if module == "heroSlides":
return hero_slide_admin_dict(item)
return model_dict(item)
return media_model_dict(item)
def module_items(db: Session, config: dict) -> list:
@@ -353,7 +355,7 @@ def wanfa_route_dict(route: WanfaRoute) -> dict:
"id": route.id,
"title": route.title,
"subtitle": route.subtitle,
"image": route.image,
"image": resolve_media_url(route.image),
"routeCount": route.routeCount,
"demandKeyword": route.demandKeyword,
}
@@ -464,7 +466,7 @@ def create_home_item(db: Session, request: Request, body, model, entity: str):
after = model_dict(item)
audit(db, get_actor_id(request), "create", entity, item.id, after=after)
db.commit()
return success_response(after, status_code=status.HTTP_201_CREATED)
return success_response(media_model_dict(item), status_code=status.HTTP_201_CREATED)
def update_home_item(db: Session, request: Request, item_id: str, body, model, entity: str, label: str, code: str):
item = home_item_or_error(db, model, item_id, label, code)
before = model_dict(item)
@@ -474,7 +476,7 @@ def update_home_item(db: Session, request: Request, item_id: str, body, model, e
after = model_dict(item)
audit(db, get_actor_id(request), "update", entity, item.id, after=after, before=before)
db.commit()
return success_response(after)
return success_response(media_model_dict(item))
def delete_home_item(db: Session, request: Request, item_id: str, model, entity: str, label: str, code: str):
@@ -498,12 +500,12 @@ def reorder_home_items(db: Session, request: Request, body: HomeReorderIn, model
after = [model_dict(item) for item in ordered]
audit(db, get_actor_id(request), "reorder", entity, after=after)
db.commit()
return success_response({"items": after})
return success_response({"items": [media_model_dict(item) for item in ordered]})
@router.get("/home/team-buildings")
def list_home_team_buildings(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
return success_response({"items": [model_dict(item) for item in home_items(db, HomeTeamBuilding)]})
return success_response({"items": [media_model_dict(item) for item in home_items(db, HomeTeamBuilding)]})
@router.post("/home/team-buildings", status_code=status.HTTP_201_CREATED)
@@ -549,7 +551,7 @@ def delete_home_team_building(
@router.get("/home/wild-archives")
def list_home_wild_archives(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
return success_response({"items": [model_dict(item) for item in home_items(db, HomeWildArchive)]})
return success_response({"items": [media_model_dict(item) for item in home_items(db, HomeWildArchive)]})
@router.post("/home/wild-archives", status_code=status.HTTP_201_CREATED)
@@ -1145,6 +1147,17 @@ def delete_concierge_advisor(
return success_response(result)
@router.get("/auth/captcha")
def get_login_captcha(
store: AdminSessionStore = Depends(get_admin_session_store),
):
settings = get_settings()
try:
return success_response(create_captcha(store, settings.admin_captcha_expires_seconds))
except RedisUnavailableError as exc:
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
@router.post("/auth/login")
def login(
body: LoginIn,
@@ -1160,11 +1173,16 @@ def login(
raise HTTPException(status_code=429, detail="登录尝试过于频繁,请稍后再试", headers={"Retry-After": str(settings.admin_login_rate_window_seconds)})
except RedisUnavailableError as exc:
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
try:
if not verify_captcha(store, body.captchaId, body.captchaCode):
raise HTTPException(status_code=401, detail="图形验证码错误或已过期")
except RedisUnavailableError as exc:
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
user = db.scalar(select(AdminUser).where(AdminUser.email == body.email))
if not user or not user.isActive or not verify_password(body.password, user.passwordHash):
raise HTTPException(status_code=401, detail="账号或密码错误")
try:
access_token, refresh_token, expires_in = issue_admin_session(user, store)
access_token, refresh_token, expires_in = issue_admin_session(user, store, remember_me=body.rememberMe)
except RedisUnavailableError as exc:
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
result = success_response(
@@ -1182,7 +1200,7 @@ def login(
secure=settings.admin_refresh_cookie_secure,
samesite="lax",
path=settings.admin_refresh_cookie_path,
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60,
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60 if body.rememberMe else None,
)
return result
@@ -1226,7 +1244,7 @@ def refresh_admin_session(
secure=settings.admin_refresh_cookie_secure,
samesite="lax",
path=settings.admin_refresh_cookie_path,
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60,
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60 if record.remember_me else None,
)
return result
@@ -1292,7 +1310,7 @@ def admin_site_config(_user: AdminUser = Depends(require_admin_permission("admin
for item in scoped_items(db, HeroSlide, _user, HeroSlide.sortOrder.asc())
],
"vehicleOptions": [
model_dict(item)
media_model_dict(item)
for item in scoped_items(db, VehicleOption, _user, VehicleOption.sortOrder.asc())
],
}
@@ -1484,7 +1502,7 @@ def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user
@router.get("/media-assets")
def list_media_assets(_user: AdminUser = Depends(require_admin_permission("admin:media:read")), db: Session = Depends(get_db)):
assets = db.scalars(apply_data_scope(select(MediaAsset), MediaAsset, _user, db).order_by(MediaAsset.createdAt.desc()).limit(200)).all()
return success_response({"items": [model_dict(asset) for asset in assets]})
return success_response({"items": [media_model_dict(asset) for asset in assets]})
@router.post("/media-assets/upload", status_code=status.HTTP_201_CREATED)
@@ -1512,6 +1530,6 @@ def upload_media_asset(
after = model_dict(asset)
audit(db, get_actor_id(request), "upload", "media_asset", asset.id, after)
db.commit()
return success_response(after, status_code=status.HTTP_201_CREATED)
return success_response(media_model_dict(asset), status_code=status.HTTP_201_CREATED)