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

+2 -1
View File
@@ -64,7 +64,7 @@ def decode_admin_access_token(token: str) -> dict:
return jwt.decode(token, get_settings().jwt_secret, algorithms=["HS256"], options={"verify_aud": False})
def issue_admin_session(user: AdminUser, store: AdminSessionStore) -> tuple[str, str, int]:
def issue_admin_session(user: AdminUser, store: AdminSessionStore, *, remember_me: bool = False) -> tuple[str, str, int]:
settings = get_settings()
now = datetime.now(timezone.utc)
session_id = str(uuid4())
@@ -79,6 +79,7 @@ def issue_admin_session(user: AdminUser, store: AdminSessionStore) -> tuple[str,
refresh_hash=hash_refresh_token(refresh_token),
access_expires_at=access_expires_at,
refresh_expires_at=refresh_expires_at,
remember_me=remember_me,
)
return (
create_access_token(user, session_id=session_id, access_jti=access_jti),
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import base64
import html
import secrets
from uuid import uuid4
from .redis_session import AdminSessionStore
CAPTCHA_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
def _captcha_code(length: int = 4) -> str:
return "".join(secrets.choice(CAPTCHA_ALPHABET) for _ in range(length))
def _captcha_image(code: str) -> str:
lines = "".join(
f'<path d="M{secrets.randbelow(150) + 5},{secrets.randbelow(42) + 3} '
f'L{secrets.randbelow(150) + 5},{secrets.randbelow(42) + 3}" />'
for _ in range(5)
)
safe_code = html.escape(code)
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="48" viewBox="0 0 160 48">'
'<rect width="160" height="48" rx="4" fill="#f5f7fa"/>'
f'<g stroke="#c0c4cc" stroke-width="1" opacity=".8">{lines}</g>'
f'<text x="80" y="32" text-anchor="middle" fill="#303133" '
'font-family="Arial,sans-serif" font-size="24" font-weight="700" letter-spacing="5">'
f"{safe_code}</text></svg>"
)
encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"
def create_captcha(store: AdminSessionStore, ttl_seconds: int) -> dict[str, object]:
captcha_id = uuid4().hex
code = _captcha_code()
store.create_captcha(captcha_id, code, ttl_seconds)
return {
"captchaEnabled": True,
"captchaId": captcha_id,
"image": _captcha_image(code),
"expiresIn": ttl_seconds,
}
def verify_captcha(store: AdminSessionStore, captcha_id: str, code: str) -> bool:
return store.consume_captcha(captcha_id, code)
+1
View File
@@ -17,6 +17,7 @@ class Settings(BaseSettings):
admin_permission_cache_seconds: int = Field(default=300)
admin_login_rate_limit: int = Field(default=5)
admin_login_rate_window_seconds: int = Field(default=60)
admin_captcha_expires_seconds: int = Field(default=120)
log_level: str = Field(default="info")
port: int = Field(default=4000)
cors_origins: str = Field(default="*")
+90
View File
@@ -0,0 +1,90 @@
import base64
import hashlib
import hmac
from time import time
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from .config import get_settings
SIGNED_QUERY_KEYS = {"OSSAccessKeyId", "Expires", "Signature"}
MEDIA_URL_EXPIRES_SECONDS = 3600
def normalized_oss_host(endpoint: str, bucket: str) -> tuple[str, str]:
raw = endpoint.strip().rstrip("/")
if "://" not in raw:
raw = f"https://{raw}"
parsed = urlsplit(raw)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("invalid OSS endpoint")
host = parsed.netloc
if not host.lower().startswith(f"{bucket.lower()}."):
host = f"{bucket}.{host}"
return parsed.scheme, host
def sign_oss_get_url(
url: str,
*,
access_key_id: str,
access_key_secret: str,
endpoint: str,
bucket: str,
expires_at: int,
) -> str:
scheme, expected_host = normalized_oss_host(endpoint, bucket)
parsed = urlsplit(url)
if parsed.scheme != scheme or parsed.netloc.lower() != expected_host.lower():
return url
path = parsed.path or "/"
canonical_resource = f"/{bucket}{path}"
string_to_sign = f"GET\n\n\n{expires_at}\n{canonical_resource}"
signature = base64.b64encode(
hmac.new(
access_key_secret.strip().encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
).decode("ascii")
query = [
(key, value)
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
if key not in SIGNED_QUERY_KEYS
]
query.extend(
[
("OSSAccessKeyId", access_key_id.strip()),
("Expires", str(expires_at)),
("Signature", signature),
]
)
return urlunsplit((scheme, expected_host, path, urlencode(query), ""))
def resolve_media_url(url: str | None) -> str | None:
if not url or not isinstance(url, str):
return url
settings = get_settings()
values = (
settings.oss_access_key_id,
settings.oss_access_key_secret,
settings.oss_endpoint,
settings.oss_bucket_name,
)
if not all(value and value.strip() for value in values):
return url
try:
return sign_oss_get_url(
url,
access_key_id=settings.oss_access_key_id,
access_key_secret=settings.oss_access_key_secret,
endpoint=settings.oss_endpoint,
bucket=settings.oss_bucket_name,
expires_at=int(time()) + MEDIA_URL_EXPIRES_SECONDS,
)
except (TypeError, ValueError):
return url
+50
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
from dataclasses import dataclass
@@ -22,6 +23,11 @@ def new_refresh_token() -> str:
return secrets.token_urlsafe(48)
def hash_captcha_answer(captcha_id: str, answer: str) -> str:
normalized = answer.strip().upper()
return hashlib.sha256(f"{captcha_id}:{normalized}".encode("utf-8")).hexdigest()
@dataclass(frozen=True)
class SessionRecord:
session_id: str
@@ -30,6 +36,7 @@ class SessionRecord:
refresh_hash: str
access_expires_at: datetime
refresh_expires_at: datetime
remember_me: bool = False
class AdminSessionStore(Protocol):
@@ -69,6 +76,10 @@ class AdminSessionStore(Protocol):
def allow_login_attempt(self, identity: str, limit: int, window_seconds: int) -> bool: ...
def create_captcha(self, captcha_id: str, answer: str, ttl_seconds: int) -> None: ...
def consume_captcha(self, captcha_id: str, answer: str) -> bool: ...
def _now() -> datetime:
return datetime.now(timezone.utc)
@@ -80,6 +91,7 @@ class InMemoryAdminSessionStore:
self._refresh_index: dict[str, str] = {}
self._permission_cache: dict[str, tuple[dict, datetime]] = {}
self._login_attempts: dict[str, tuple[int, datetime]] = {}
self._captchas: dict[str, tuple[str, datetime]] = {}
def create(self, **kwargs) -> None:
record = SessionRecord(**kwargs)
@@ -118,6 +130,7 @@ class InMemoryAdminSessionStore:
refresh_hash=refresh_hash_next,
access_expires_at=access_expires_at,
refresh_expires_at=refresh_expires_at,
remember_me=record.remember_me,
)
self._sessions[session_id] = next_record
self._refresh_index[refresh_hash_next] = session_id
@@ -157,6 +170,15 @@ class InMemoryAdminSessionStore:
self._login_attempts[identity] = (attempts, expires_at)
return attempts <= limit
def create_captcha(self, captcha_id: str, answer: str, ttl_seconds: int) -> None:
self._captchas[captcha_id] = (hash_captcha_answer(captcha_id, answer), _now() + timedelta(seconds=max(1, ttl_seconds)))
def consume_captcha(self, captcha_id: str, answer: str) -> bool:
record = self._captchas.pop(captcha_id, None)
if not record or record[1] <= _now():
return False
return hmac.compare_digest(record[0], hash_captcha_answer(captcha_id, answer))
class RedisAdminSessionStore:
prefix = "wonderq:admin"
@@ -183,6 +205,9 @@ class RedisAdminSessionStore:
def _login_limit_key(self, identity: str) -> str:
return f"{self.prefix}:login-limit:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}"
def _captcha_key(self, captcha_id: str) -> str:
return f"{self.prefix}:captcha:{captcha_id}"
@staticmethod
def _serialize(record: SessionRecord) -> str:
return json.dumps(
@@ -193,6 +218,7 @@ class RedisAdminSessionStore:
"refreshHash": record.refresh_hash,
"accessExpiresAt": record.access_expires_at.isoformat(),
"refreshExpiresAt": record.refresh_expires_at.isoformat(),
"rememberMe": record.remember_me,
}
)
@@ -209,6 +235,7 @@ class RedisAdminSessionStore:
refresh_hash=payload["refreshHash"],
access_expires_at=datetime.fromisoformat(payload["accessExpiresAt"]),
refresh_expires_at=datetime.fromisoformat(payload["refreshExpiresAt"]),
remember_me=bool(payload.get("rememberMe", False)),
)
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise RedisUnavailableError("Redis 会话数据无效") from exc
@@ -273,6 +300,7 @@ class RedisAdminSessionStore:
refresh_hash=refresh_hash_next,
access_expires_at=access_expires_at,
refresh_expires_at=refresh_expires_at,
remember_me=record.remember_me,
)
pipe.multi()
pipe.delete(refresh_key)
@@ -337,6 +365,28 @@ class RedisAdminSessionStore:
except Exception as exc:
raise RedisUnavailableError("Redis 登录限流不可用") from exc
def create_captcha(self, captcha_id: str, answer: str, ttl_seconds: int) -> None:
self._ensure_available()
try:
self.client.set(self._captcha_key(captcha_id), hash_captcha_answer(captcha_id, answer), ex=max(1, ttl_seconds))
except Exception as exc:
raise RedisUnavailableError("Redis 验证码写入失败") from exc
def consume_captcha(self, captcha_id: str, answer: str) -> bool:
self._ensure_available()
script = """
local value = redis.call('GET', KEYS[1])
if value then redis.call('DEL', KEYS[1]) end
return value
"""
try:
stored = self.client.eval(script, 1, self._captcha_key(captcha_id))
except Exception as exc:
raise RedisUnavailableError("Redis 验证码校验不可用") from exc
if not stored:
return False
return hmac.compare_digest(str(stored), hash_captcha_answer(captcha_id, answer))
def get_admin_session_store() -> AdminSessionStore:
return RedisAdminSessionStore()
+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)
+2 -2
View File
@@ -1,11 +1,11 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import HeroSlide, VehicleOption
from ..serializers import model_dict
from ..serializers import media_model_dict
def public_model_dict(item) -> dict:
result = model_dict(item)
result = media_model_dict(item)
result.pop("deptId", None)
result.pop("createdById", None)
return result
+19
View File
@@ -13,6 +13,25 @@ CharterDuration = Literal["halfDay", "fullDay"]
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(min_length=6)
captchaId: str = Field(min_length=1, max_length=64)
captchaCode: str = Field(min_length=4, max_length=8)
rememberMe: bool = False
@field_validator("captchaId")
@classmethod
def normalize_captcha_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("验证码不能为空")
return normalized
@field_validator("captchaCode")
@classmethod
def normalize_captcha_code(cls, value: str) -> str:
normalized = value.strip().upper()
if not normalized:
raise ValueError("验证码不能为空")
return normalized
class PhoneLoginIn(BaseModel):
+27 -10
View File
@@ -1,6 +1,8 @@
from datetime import datetime
from sqlalchemy.inspection import inspect
from .media_urls import resolve_media_url
def encode_value(value):
if isinstance(value, datetime):
@@ -20,6 +22,21 @@ def model_dict(instance, include: dict[str, object] | None = None) -> dict:
return data
def media_model_dict(instance) -> dict:
return resolve_media_fields(model_dict(instance))
def resolve_media_fields(data: dict) -> dict:
result = dict(data)
for field in ("image", "avatar", "qrImage"):
if field in result:
result[field] = resolve_media_url(result[field])
for field in ("gallery", "images"):
if field in result and isinstance(result[field], list):
result[field] = [resolve_media_url(value) for value in result[field]]
return result
def lead_dict(lead) -> dict:
return model_dict(
lead,
@@ -32,7 +49,7 @@ def public_wanfa_route_dict(route) -> 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,
}
@@ -47,7 +64,7 @@ def public_wanfa_category_dict(category) -> dict:
def detail_record_dict(detail) -> dict:
return model_dict(detail)
return media_model_dict(detail)
def public_detail_dict(detail, concierge_advisor=None) -> dict:
@@ -62,7 +79,7 @@ def public_detail_dict(detail, concierge_advisor=None) -> dict:
"included": detail.included or [],
"excluded": detail.excluded or [],
"notes": detail.notes or [],
"gallery": detail.gallery or [],
"gallery": [resolve_media_url(image) for image in (detail.gallery or [])],
"conciergeAdvisor": public_concierge_advisor_dict(concierge_advisor) if concierge_advisor else None,
}
@@ -77,16 +94,16 @@ def public_home_wanfa_recommendation_dict(item) -> dict:
def concierge_advisor_dict(advisor) -> dict:
return model_dict(advisor)
return media_model_dict(advisor)
def public_concierge_advisor_dict(advisor) -> dict:
return {
"avatar": advisor.avatar,
"avatar": resolve_media_url(advisor.avatar),
"name": advisor.name,
"role": advisor.role,
"details": advisor.details or [],
"qrImage": advisor.qrImage,
"qrImage": resolve_media_url(advisor.qrImage),
}
@@ -97,7 +114,7 @@ def public_home_experience_dict(item) -> dict:
"category": item.category,
"title": item.title,
"englishTitle": item.englishTitle,
"image": item.image,
"image": resolve_media_url(item.image),
"demandKeyword": item.demandKeyword,
}
@@ -108,7 +125,7 @@ def public_home_team_building_dict(item) -> dict:
"tag": item.tag,
"title": item.title,
"description": item.description,
"image": item.image,
"image": resolve_media_url(item.image),
"demandKeyword": item.demandKeyword,
}
@@ -132,7 +149,7 @@ def public_home_wild_archive_dict(item) -> dict:
return {
"id": item.id,
"title": item.title,
"image": item.image,
"image": resolve_media_url(item.image),
"demandKeyword": item.demandKeyword,
"photoCount": len(item.images or [item.image]),
}
@@ -144,5 +161,5 @@ def public_home_wild_archive_detail_dict(item) -> dict:
images = [item.image]
return {
**public_home_wild_archive_dict(item),
"images": images,
"images": [resolve_media_url(image) for image in images],
}