This commit is contained in:
fengchuanhn@gmail.com
2026-05-22 14:40:37 +08:00
parent 79b657941f
commit 3c91a52aff
12 changed files with 296 additions and 116 deletions

View File

@@ -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")

View File

@@ -1,7 +1,14 @@
from fastapi import APIRouter, Header from fastapi import APIRouter, Header
from app.dependencies import CurrentUser, DbSession, RedisClient 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.schemas.common import ApiResponse
from app.services import auth as auth_service from app.services import auth as auth_service
@@ -13,12 +20,14 @@ async def register(
body: RegisterRequest, body: RegisterRequest,
db: DbSession, db: DbSession,
redis: RedisClient, redis: RedisClient,
device_serial: str | None = Header(default=None, convert_underscores=False),
) -> ApiResponse[TokenResponse]: ) -> ApiResponse[TokenResponse]:
try: try:
result = await auth_service.register_user( result = await auth_service.register_user(
db, db,
username=body.username, username=body.username,
password=body.password, password=body.password,
device_serial=device_serial,
) )
except auth_service.AuthError as exc: except auth_service.AuthError as exc:
return ApiResponse(ok=False, message=exc.message) return ApiResponse(ok=False, message=exc.message)
@@ -32,12 +41,14 @@ async def login(
body: LoginRequest, body: LoginRequest,
db: DbSession, db: DbSession,
redis: RedisClient, redis: RedisClient,
device_serial: str | None = Header(default=None, convert_underscores=False),
) -> ApiResponse[TokenResponse]: ) -> ApiResponse[TokenResponse]:
try: try:
result = await auth_service.login_user( result = await auth_service.login_user(
db, db,
username=body.username, username=body.username,
password=body.password, password=body.password,
device_serial=device_serial,
) )
except auth_service.AuthError as exc: except auth_service.AuthError as exc:
return ApiResponse(ok=False, message=exc.message) return ApiResponse(ok=False, message=exc.message)
@@ -46,6 +57,25 @@ async def login(
return ApiResponse(ok=True, message="登录成功", data=result) 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]) @router.post("/logout", response_model=ApiResponse[None])
async def logout( async def logout(
redis: RedisClient, redis: RedisClient,

View File

@@ -16,9 +16,7 @@ async def get_oem_branding(
db: DbSession, db: DbSession,
) -> ApiResponse[OemBrandingOut]: ) -> ApiResponse[OemBrandingOut]:
try: try:
oem = await branding_service.get_default_oem_branding( oem = await branding_service.get_default_oem_branding(db, user_id=admin.id)
db, oem_id=1,user_id=admin.id
)
except branding_service.AdminOemBrandingError as exc: except branding_service.AdminOemBrandingError as exc:
return ApiResponse(ok=False, message=exc.message) return ApiResponse(ok=False, message=exc.message)
@@ -36,9 +34,7 @@ async def update_oem_branding(
db: DbSession, db: DbSession,
) -> ApiResponse[OemBrandingOut]: ) -> ApiResponse[OemBrandingOut]:
try: try:
oem = await branding_service.update_default_oem_branding( oem = await branding_service.update_default_oem_branding(db, body, user_id=admin.id)
db, body, oem_id=admin.id,user_id=admin.id
)
except branding_service.AdminOemBrandingError as exc: except branding_service.AdminOemBrandingError as exc:
return ApiResponse(ok=False, message=exc.message) return ApiResponse(ok=False, message=exc.message)

View File

@@ -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()

57
app/core/oem_resolve.py Normal file
View File

@@ -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_idoem 表主键)。
若尚无 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

View File

@@ -25,3 +25,4 @@ class User(Base):
) )
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) 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()) 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="")

View File

@@ -47,3 +47,15 @@ class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
user: UserPublic 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

View File

@@ -18,7 +18,12 @@ def _map_image_error(exc: BrandingImageError) -> AdminOemBrandingError:
return AdminOemBrandingError(exc.message) 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) oem = await db.get(Oem, oem_id)
if oem is not None: if oem is not None:
return oem return oem
@@ -38,18 +43,23 @@ async def get_or_create_default_oem(db: AsyncSession, *, oem_id: int,user_id:int
return oem return oem
async def get_default_oem_branding(db: AsyncSession, *, oem_id: int,user_id:int) -> Oem: async def get_default_oem_branding(
return await get_or_create_default_oem(db, oem_id=oem_id,user_id=user_id) 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( async def update_default_oem_branding(
db: AsyncSession, db: AsyncSession,
body: OemBrandingUpdate, body: OemBrandingUpdate,
*, *,
oem_id: int, oem_id: int = DEFAULT_OEM_ID,
user_id: int, user_id: int,
) -> Oem: ) -> 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: try:
logo_path = resolve_image_field( logo_path = resolve_image_field(

View File

@@ -1,7 +1,10 @@
from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings from app.config import get_settings
from app.core.heartbeat_verify import compute_server_verify_code
from app.core.security import ( from app.core.security import (
create_access_token, create_access_token,
hash_password, hash_password,
@@ -9,7 +12,7 @@ from app.core.security import (
verify_password, verify_password,
) )
from app.models.user import User from app.models.user import User
from app.schemas.auth import TokenResponse, UserPublic from app.schemas.auth import HeartbeatResponse, TokenResponse, UserPublic
settings = get_settings() settings = get_settings()
@@ -20,11 +23,44 @@ class AuthError(Exception):
super().__init__(message) 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( async def register_user(
db: AsyncSession, db: AsyncSession,
*, *,
username: str, username: str,
password: str, password: str,
device_serial: str | None = None,
) -> TokenResponse: ) -> TokenResponse:
existing = await db.scalar(select(User).where(User.username == username)) existing = await db.scalar(select(User).where(User.username == username))
if existing is not None: if existing is not None:
@@ -35,6 +71,7 @@ async def register_user(
password_hash=hash_password(password), password_hash=hash_password(password),
role_id=0, role_id=0,
phone=None, phone=None,
device_serial=_normalize_device_serial(device_serial),
) )
db.add(user) db.add(user)
await db.flush() await db.flush()
@@ -52,11 +89,17 @@ async def login_user(
*, *,
username: str, username: str,
password: str, password: str,
device_serial: str | None = None,
) -> TokenResponse: ) -> TokenResponse:
user = await db.scalar(select(User).where(User.username == username)) user = await db.scalar(select(User).where(User.username == username))
if user is None or not verify_password(password, user.password_hash): if user is None or not verify_password(password, user.password_hash):
raise AuthError("用户名或密码错误") raise AuthError("用户名或密码错误")
incoming = _normalize_device_serial(device_serial)
await _sync_device_serial(
db, user, incoming, mismatch_message="设备不匹配,无法登录"
)
token = create_access_token(str(user.id)) token = create_access_token(str(user.id))
return TokenResponse( return TokenResponse(
access_token=token, 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: async def store_session(redis_client, token: str, user_id: int) -> None:
ttl = settings.access_token_expire_minutes * 60 ttl = settings.access_token_expire_minutes * 60
await redis_client.setex(session_redis_key(token), ttl, str(user_id)) await redis_client.setex(session_redis_key(token), ttl, str(user_id))

View File

@@ -3,8 +3,8 @@
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.oem_resolve import resolve_oem_id
from app.models.desktopConfig import DesktopConfig from app.models.desktopConfig import DesktopConfig
from app.models.oem import Oem
from app.models.user import User from app.models.user import User
from app.schemas.admin_desktop_config import DesktopConfigCreate, DesktopConfigUpdate from app.schemas.admin_desktop_config import DesktopConfigCreate, DesktopConfigUpdate
@@ -39,31 +39,6 @@ class OemDesktopConfigError(Exception):
# 路由层沿用 AdminDesktopConfigError 名称 # 路由层沿用 AdminDesktopConfigError 名称
AdminDesktopConfigError = OemDesktopConfigError 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: async def _count_by_oem(db: AsyncSession, oem_id: int) -> int:
stmt = select(func.count()).select_from(DesktopConfig).where(DesktopConfig.oem_id == oem_id) stmt = select(func.count()).select_from(DesktopConfig).where(DesktopConfig.oem_id == oem_id)
return await db.scalar(stmt) or 0 return await db.scalar(stmt) or 0

View File

@@ -1,77 +1,62 @@
from sqlalchemy.ext.asyncio import AsyncSession 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.core.branding_images import BrandingImageError, resolve_image_field
from app.schemas.admin_oem_branding import OemBrandingUpdate
from app.core.oem_resolve import get_or_create_oem_for_user, resolve_oem_id_for_user_id
DEFAULT_OEM_ID = 1
from app.models.oem import Oem
class AdminOemBrandingError(Exception): from app.schemas.admin_oem_branding import OemBrandingUpdate
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
def _map_image_error(exc: BrandingImageError) -> AdminOemBrandingError: class AdminOemBrandingError(Exception):
return AdminOemBrandingError(exc.message)
def __init__(self, message: str) -> None:
async def get_or_create_default_oem(db: AsyncSession, *, user_id:int) -> Oem: self.message = message
oem_id = await db.scalar(select(Oem.id).where(Oem.user_id == user_id))
oem = await db.get(Oem, oem_id) super().__init__(message)
if oem is not None:
return oem
owner = await db.get(User, user_id)
if owner is None:
raise AdminOemBrandingError("关联用户不存在") def _map_image_error(exc: BrandingImageError) -> AdminOemBrandingError:
oem = Oem( return AdminOemBrandingError(exc.message)
id=oem_id,
user_id=user_id,
software_name="",
)
db.add(oem)
await db.flush() async def get_or_create_default_oem(db: AsyncSession, *, user_id: int) -> Oem:
await db.refresh(oem)
return oem try:
return await get_or_create_oem_for_user(db, user_id)
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) except ValueError as exc:
raise AdminOemBrandingError(str(exc)) from exc
async def update_default_oem_branding(
db: AsyncSession,
body: OemBrandingUpdate,
*,
oem_id: int,
user_id: int, async def get_default_oem_branding(db: AsyncSession, *, user_id: int) -> Oem:
) -> Oem:
oem = await get_or_create_default_oem(db, user_id=user_id) return 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( async def update_default_oem_branding(
body.wechat_qrcode, oem.wechat_qrcode, file_prefix="wechat_qr"
) db: AsyncSession,
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB