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
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
@@ -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="*")
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
Reference in new issue
Block a user