From 3c91a52aff807b3c4f1e451392eaca855616bbe0 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Fri, 22 May 2026 14:40:37 +0800 Subject: [PATCH] 11 --- .../versions/011_add_user_device_serial.py | 28 ++++ app/api/v1/auth.py | 32 +++- app/api/v1/oem_oem_branding.py | 8 +- app/core/heartbeat_verify.py | 20 +++ app/core/oem_resolve.py | 57 +++++++ app/models/user.py | 1 + app/schemas/auth.py | 12 ++ app/services/admin_oem_branding.py | 20 ++- app/services/auth.py | 68 ++++++++- app/services/oem_desktop_config.py | 27 +--- app/services/oem_oem_branding.py | 139 ++++++++---------- images/oem_logo_edf78a4935d7.png | Bin 0 -> 7211 bytes 12 files changed, 296 insertions(+), 116 deletions(-) create mode 100644 alembic/versions/011_add_user_device_serial.py create mode 100644 app/core/heartbeat_verify.py create mode 100644 app/core/oem_resolve.py create mode 100644 images/oem_logo_edf78a4935d7.png diff --git a/alembic/versions/011_add_user_device_serial.py b/alembic/versions/011_add_user_device_serial.py new file mode 100644 index 0000000..0c60edd --- /dev/null +++ b/alembic/versions/011_add_user_device_serial.py @@ -0,0 +1,28 @@ +"""add users.device_serial + +Revision ID: 011 +Revises: 010 +Create Date: 2026-05-22 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "011" +down_revision: Union[str, None] = "010" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("device_serial", sa.String(255), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + op.drop_column("users", "device_serial") diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index cc3e5d6..dfb9307 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -1,7 +1,14 @@ from fastapi import APIRouter, Header from app.dependencies import CurrentUser, DbSession, RedisClient -from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserPublic +from app.schemas.auth import ( + HeartbeatRequest, + HeartbeatResponse, + LoginRequest, + RegisterRequest, + TokenResponse, + UserPublic, +) from app.schemas.common import ApiResponse from app.services import auth as auth_service @@ -13,12 +20,14 @@ async def register( body: RegisterRequest, db: DbSession, redis: RedisClient, + device_serial: str | None = Header(default=None, convert_underscores=False), ) -> ApiResponse[TokenResponse]: try: result = await auth_service.register_user( db, username=body.username, password=body.password, + device_serial=device_serial, ) except auth_service.AuthError as exc: return ApiResponse(ok=False, message=exc.message) @@ -32,12 +41,14 @@ async def login( body: LoginRequest, db: DbSession, redis: RedisClient, + device_serial: str | None = Header(default=None, convert_underscores=False), ) -> ApiResponse[TokenResponse]: try: result = await auth_service.login_user( db, username=body.username, password=body.password, + device_serial=device_serial, ) except auth_service.AuthError as exc: return ApiResponse(ok=False, message=exc.message) @@ -46,6 +57,25 @@ async def login( return ApiResponse(ok=True, message="登录成功", data=result) +@router.post("/heartbeat", response_model=ApiResponse[HeartbeatResponse]) +async def heartbeat( + body: HeartbeatRequest, + current_user: CurrentUser, + db: DbSession, + device_serial: str | None = Header(default=None, convert_underscores=False), +) -> ApiResponse[HeartbeatResponse]: + try: + data = await auth_service.process_heartbeat( + db, + current_user, + device_serial, + body.verify_code, + ) + except auth_service.AuthError as exc: + return ApiResponse(ok=False, message=exc.message) + return ApiResponse(ok=True, message="", data=data) + + @router.post("/logout", response_model=ApiResponse[None]) async def logout( redis: RedisClient, diff --git a/app/api/v1/oem_oem_branding.py b/app/api/v1/oem_oem_branding.py index 9819ed6..57c4dbe 100644 --- a/app/api/v1/oem_oem_branding.py +++ b/app/api/v1/oem_oem_branding.py @@ -16,9 +16,7 @@ async def get_oem_branding( db: DbSession, ) -> ApiResponse[OemBrandingOut]: try: - oem = await branding_service.get_default_oem_branding( - db, oem_id=1,user_id=admin.id - ) + oem = await branding_service.get_default_oem_branding(db, user_id=admin.id) except branding_service.AdminOemBrandingError as exc: return ApiResponse(ok=False, message=exc.message) @@ -36,9 +34,7 @@ async def update_oem_branding( db: DbSession, ) -> ApiResponse[OemBrandingOut]: try: - oem = await branding_service.update_default_oem_branding( - db, body, oem_id=admin.id,user_id=admin.id - ) + oem = await branding_service.update_default_oem_branding(db, body, user_id=admin.id) except branding_service.AdminOemBrandingError as exc: return ApiResponse(ok=False, message=exc.message) diff --git a/app/core/heartbeat_verify.py b/app/core/heartbeat_verify.py new file mode 100644 index 0000000..f39b689 --- /dev/null +++ b/app/core/heartbeat_verify.py @@ -0,0 +1,20 @@ +"""心跳响应防伪:MD5(时间桶 + verify_code)。""" + +import hashlib +from datetime import datetime, timezone + + +def heartbeat_time_bucket(dt: datetime | None = None) -> str: + """UTC 时间桶,格式如 ``2026-05-20 15``(精确到小时)。""" + if dt is None: + dt = datetime.now(timezone.utc) + elif dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.strftime("%Y-%m-%d %H") + + +def compute_server_verify_code(verify_code: str, dt: datetime | None = None) -> str: + raw = f"{heartbeat_time_bucket(dt)}{verify_code}" + return hashlib.md5(raw.encode("utf-8")).hexdigest() diff --git a/app/core/oem_resolve.py b/app/core/oem_resolve.py new file mode 100644 index 0000000..23d0289 --- /dev/null +++ b/app/core/oem_resolve.py @@ -0,0 +1,57 @@ +"""OEM 用户与 oem 表主键解析(全局缓存,多模块共用)。""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.oem import Oem +from app.models.user import User + +# user_id -> oem 表主键(desktop_configs.oem_id 等同此值) +_oem_id_by_user_id: dict[int, int] = {} + + +def invalidate_oem_id_cache(user_id: int | None = None) -> None: + """清除 resolve 缓存;user_id 为 None 时清空全部。""" + if user_id is None: + _oem_id_by_user_id.clear() + else: + _oem_id_by_user_id.pop(user_id, None) + + +async def resolve_oem_id_for_user_id(db: AsyncSession, user_id: int) -> int: + """ + 解析 OEM 用户在业务表中的 oem_id(oem 表主键)。 + 若尚无 oem 记录,回退为 user_id(与 desktop_configs 历史逻辑一致)。 + """ + cached = _oem_id_by_user_id.get(user_id) + if cached is not None: + return cached + + oem_pk = await db.scalar(select(Oem.id).where(Oem.user_id == user_id)) + resolved = int(oem_pk) if oem_pk is not None else user_id + _oem_id_by_user_id[user_id] = resolved + return resolved + + +async def resolve_oem_id(db: AsyncSession, oem_user: User) -> int: + """根据 User 模型解析 oem_id。""" + return await resolve_oem_id_for_user_id(db, oem_user.id) + + +async def get_or_create_oem_for_user(db: AsyncSession, user_id: int) -> Oem: + """按 user_id 获取 oem 行;不存在则创建并刷新缓存。""" + existing = await db.scalar(select(Oem).where(Oem.user_id == user_id)) + if existing is not None: + _oem_id_by_user_id[user_id] = existing.id + return existing + + owner = await db.get(User, user_id) + if owner is None: + raise ValueError("关联用户不存在") + + oem = Oem(user_id=user_id, software_name="") + db.add(oem) + await db.flush() + await db.refresh(oem) + _oem_id_by_user_id[user_id] = oem.id + return oem diff --git a/app/models/user.py b/app/models/user.py index 9e2db56..e5b8538 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -25,3 +25,4 @@ class User(Base): ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + device_serial: Mapped[str] = mapped_column(String(255), default="", server_default="") \ No newline at end of file diff --git a/app/schemas/auth.py b/app/schemas/auth.py index daae87a..38d2ed2 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -47,3 +47,15 @@ class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" user: UserPublic + + +class HeartbeatRequest(BaseModel): + verify_code: str = Field(min_length=1, max_length=128) + + +class HeartbeatResponse(BaseModel): + """心跳成功时的状态快照。""" + + vip_end_time: datetime | None = None + vip_active: bool + server_verify_code: str diff --git a/app/services/admin_oem_branding.py b/app/services/admin_oem_branding.py index 2898102..90a4bb5 100644 --- a/app/services/admin_oem_branding.py +++ b/app/services/admin_oem_branding.py @@ -18,7 +18,12 @@ def _map_image_error(exc: BrandingImageError) -> AdminOemBrandingError: return AdminOemBrandingError(exc.message) -async def get_or_create_default_oem(db: AsyncSession, *, oem_id: int,user_id:int) -> Oem: +async def get_or_create_default_oem( + db: AsyncSession, + *, + oem_id: int = DEFAULT_OEM_ID, + user_id: int, +) -> Oem: oem = await db.get(Oem, oem_id) if oem is not None: return oem @@ -38,18 +43,23 @@ async def get_or_create_default_oem(db: AsyncSession, *, oem_id: int,user_id:int return oem -async def get_default_oem_branding(db: AsyncSession, *, oem_id: int,user_id:int) -> Oem: - return await get_or_create_default_oem(db, oem_id=oem_id,user_id=user_id) +async def get_default_oem_branding( + db: AsyncSession, + *, + oem_id: int = DEFAULT_OEM_ID, + user_id: int, +) -> Oem: + return await get_or_create_default_oem(db, oem_id=oem_id, user_id=user_id) async def update_default_oem_branding( db: AsyncSession, body: OemBrandingUpdate, *, - oem_id: int, + oem_id: int = DEFAULT_OEM_ID, user_id: int, ) -> Oem: - oem = await get_or_create_default_oem(db, oem_id=oem_id,user_id=user_id) + oem = await get_or_create_default_oem(db, oem_id=oem_id, user_id=user_id) try: logo_path = resolve_image_field( diff --git a/app/services/auth.py b/app/services/auth.py index d32a4af..f5ac44b 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,7 +1,10 @@ +from datetime import datetime, timezone + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings +from app.core.heartbeat_verify import compute_server_verify_code from app.core.security import ( create_access_token, hash_password, @@ -9,7 +12,7 @@ from app.core.security import ( verify_password, ) from app.models.user import User -from app.schemas.auth import TokenResponse, UserPublic +from app.schemas.auth import HeartbeatResponse, TokenResponse, UserPublic settings = get_settings() @@ -20,11 +23,44 @@ class AuthError(Exception): super().__init__(message) +def _normalize_device_serial(value: str | None) -> str: + if not value: + return "" + return value.strip()[:255] + + +def _vip_is_active(vip_end_time: datetime | None) -> bool: + if vip_end_time is None: + return False + now = datetime.now(timezone.utc) + end = vip_end_time + if end.tzinfo is None: + end = end.replace(tzinfo=timezone.utc) + return end > now + + +async def _sync_device_serial( + db: AsyncSession, + user: User, + incoming: str, + *, + mismatch_message: str, +) -> None: + stored = user.device_serial or "" + if not stored: + if incoming: + user.device_serial = incoming + await db.flush() + elif incoming != stored: + raise AuthError(mismatch_message) + + async def register_user( db: AsyncSession, *, username: str, password: str, + device_serial: str | None = None, ) -> TokenResponse: existing = await db.scalar(select(User).where(User.username == username)) if existing is not None: @@ -35,6 +71,7 @@ async def register_user( password_hash=hash_password(password), role_id=0, phone=None, + device_serial=_normalize_device_serial(device_serial), ) db.add(user) await db.flush() @@ -52,11 +89,17 @@ async def login_user( *, username: str, password: str, + device_serial: str | None = None, ) -> TokenResponse: user = await db.scalar(select(User).where(User.username == username)) if user is None or not verify_password(password, user.password_hash): raise AuthError("用户名或密码错误") + incoming = _normalize_device_serial(device_serial) + await _sync_device_serial( + db, user, incoming, mismatch_message="设备不匹配,无法登录" + ) + token = create_access_token(str(user.id)) return TokenResponse( access_token=token, @@ -64,6 +107,29 @@ async def login_user( ) +async def process_heartbeat( + db: AsyncSession, + user: User, + device_serial: str | None, + verify_code: str, +) -> HeartbeatResponse: + incoming = _normalize_device_serial(device_serial) + await _sync_device_serial(db, user, incoming, mismatch_message="设备不匹配") + + if not _vip_is_active(user.vip_end_time): + raise AuthError("会员已过期或未开通") + + code = verify_code.strip() + if not code: + raise AuthError("verify_code 无效") + + return HeartbeatResponse( + vip_end_time=user.vip_end_time, + vip_active=True, + server_verify_code=compute_server_verify_code(code), + ) + + async def store_session(redis_client, token: str, user_id: int) -> None: ttl = settings.access_token_expire_minutes * 60 await redis_client.setex(session_redis_key(token), ttl, str(user_id)) diff --git a/app/services/oem_desktop_config.py b/app/services/oem_desktop_config.py index 2f5b10d..9a4e491 100644 --- a/app/services/oem_desktop_config.py +++ b/app/services/oem_desktop_config.py @@ -3,8 +3,8 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.oem_resolve import resolve_oem_id from app.models.desktopConfig import DesktopConfig -from app.models.oem import Oem from app.models.user import User from app.schemas.admin_desktop_config import DesktopConfigCreate, DesktopConfigUpdate @@ -39,31 +39,6 @@ class OemDesktopConfigError(Exception): # 路由层沿用 AdminDesktopConfigError 名称 AdminDesktopConfigError = OemDesktopConfigError -# user_id -> desktop_configs.oem_id(进程内缓存,OEM 绑定变更时需 invalidate) -_oem_id_by_user_id: dict[int, int] = {} - - -def invalidate_oem_id_cache(user_id: int | None = None) -> None: - """清除 resolve_oem_id 缓存;user_id 为 None 时清空全部。""" - if user_id is None: - _oem_id_by_user_id.clear() - else: - _oem_id_by_user_id.pop(user_id, None) - - -async def resolve_oem_id(db: AsyncSession, oem_user: User) -> int: - """desktop_configs.oem_id 对应 oem 表主键;无记录时回退为用户 id。""" - user_id = oem_user.id - cached = _oem_id_by_user_id.get(user_id) - if cached is not None: - return cached - - oem_pk = await db.scalar(select(Oem.id).where(Oem.user_id == user_id)) - resolved = int(oem_pk) if oem_pk is not None else user_id - _oem_id_by_user_id[user_id] = resolved - return resolved - - async def _count_by_oem(db: AsyncSession, oem_id: int) -> int: stmt = select(func.count()).select_from(DesktopConfig).where(DesktopConfig.oem_id == oem_id) return await db.scalar(stmt) or 0 diff --git a/app/services/oem_oem_branding.py b/app/services/oem_oem_branding.py index 31a8a8c..ced31e9 100644 --- a/app/services/oem_oem_branding.py +++ b/app/services/oem_oem_branding.py @@ -1,77 +1,62 @@ -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.branding_images import BrandingImageError, resolve_image_field -from app.models.oem import Oem -from app.models.user import User -from app.schemas.admin_oem_branding import OemBrandingUpdate - -DEFAULT_OEM_ID = 1 - - -class AdminOemBrandingError(Exception): - def __init__(self, message: str) -> None: - self.message = message - super().__init__(message) - - -def _map_image_error(exc: BrandingImageError) -> AdminOemBrandingError: - return AdminOemBrandingError(exc.message) - - -async def get_or_create_default_oem(db: AsyncSession, *, user_id:int) -> Oem: - oem_id = await db.scalar(select(Oem.id).where(Oem.user_id == user_id)) - oem = await db.get(Oem, oem_id) - if oem is not None: - return oem - - owner = await db.get(User, user_id) - if owner is None: - raise AdminOemBrandingError("关联用户不存在") - - oem = Oem( - id=oem_id, - user_id=user_id, - software_name="", - ) - db.add(oem) - await db.flush() - await db.refresh(oem) - return oem - - -async def get_default_oem_branding(db: AsyncSession, *, oem_id: int,user_id:int) -> Oem: - return await get_or_create_default_oem(db, user_id=user_id) - - -async def update_default_oem_branding( - db: AsyncSession, - body: OemBrandingUpdate, - *, - oem_id: int, - user_id: int, -) -> Oem: - oem = await get_or_create_default_oem(db, user_id=user_id) - - try: - logo_path = resolve_image_field( - body.logo_path, oem.logo_path, file_prefix="logo" - ) - wechat_qrcode = resolve_image_field( - body.wechat_qrcode, oem.wechat_qrcode, file_prefix="wechat_qr" - ) - qq_qrcode = resolve_image_field( - body.qq_qrcode, oem.qq_qrcode, file_prefix="qq_qr" - ) - except BrandingImageError as exc: - raise _map_image_error(exc) from exc - - oem.software_name = body.software_name - oem.logo_path = logo_path - oem.wechat = body.wechat - oem.qq = body.qq - oem.wechat_qrcode = wechat_qrcode - oem.qq_qrcode = qq_qrcode - - await db.flush() - await db.refresh(oem) - return oem +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.branding_images import BrandingImageError, resolve_image_field +from app.core.oem_resolve import get_or_create_oem_for_user, resolve_oem_id_for_user_id +from app.models.oem import Oem +from app.schemas.admin_oem_branding import OemBrandingUpdate + + +class AdminOemBrandingError(Exception): + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + +def _map_image_error(exc: BrandingImageError) -> AdminOemBrandingError: + return AdminOemBrandingError(exc.message) + + +async def get_or_create_default_oem(db: AsyncSession, *, user_id: int) -> Oem: + try: + return await get_or_create_oem_for_user(db, user_id) + except ValueError as exc: + raise AdminOemBrandingError(str(exc)) from exc + + +async def get_default_oem_branding(db: AsyncSession, *, user_id: int) -> Oem: + return await get_or_create_default_oem(db, user_id=user_id) + + +async def update_default_oem_branding( + db: AsyncSession, + body: OemBrandingUpdate, + *, + user_id: int, +) -> Oem: + await resolve_oem_id_for_user_id(db, user_id) + oem = await get_or_create_default_oem(db, user_id=user_id) + + try: + logo_path = resolve_image_field( + body.logo_path, oem.logo_path, file_prefix="logo" + ) + wechat_qrcode = resolve_image_field( + body.wechat_qrcode, oem.wechat_qrcode, file_prefix="wechat_qr" + ) + qq_qrcode = resolve_image_field( + body.qq_qrcode, oem.qq_qrcode, file_prefix="qq_qr" + ) + except BrandingImageError as exc: + raise _map_image_error(exc) from exc + + oem.software_name = body.software_name + oem.logo_path = logo_path + oem.wechat = body.wechat + oem.qq = body.qq + oem.wechat_qrcode = wechat_qrcode + oem.qq_qrcode = qq_qrcode + + await db.flush() + await db.refresh(oem) + return oem + \ No newline at end of file diff --git a/images/oem_logo_edf78a4935d7.png b/images/oem_logo_edf78a4935d7.png new file mode 100644 index 0000000000000000000000000000000000000000..da5ae92f298a2ef572dd28915bebb872fd2681e6 GIT binary patch literal 7211 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