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