This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 01:47:21 +08:00
parent 7f57dfa9ed
commit 5ee6a57dd1
29 changed files with 794 additions and 18 deletions

View File

@@ -31,6 +31,9 @@ async def list_card_keys(
default="all",
description="筛选all 全部 / unused 未使用 / used 已激活",
),
username: str | None = Query(default=None, description="按激活用户名模糊筛选"),
oem_id: int | None = Query(default=None, description="按预分配 OEM 筛选"),
agent_id: int | None = Query(default=None, description="按预分配代理筛选"),
) -> ApiResponse[PaginatedData[CardKeyOut]]:
filter_status = None if status == "all" else status
cards, total = await card_key_service.list_card_keys(
@@ -38,6 +41,9 @@ async def list_card_keys(
page=page,
page_size=page_size,
status=filter_status,
username=username,
oem_id=oem_id,
agent_id=agent_id,
)
return ApiResponse(
ok=True,

View File

@@ -0,0 +1,49 @@
"""管理员:默认 OEMid=1软件品牌配置。"""
from fastapi import APIRouter
from app.dependencies import AdminUser, DbSession
from app.schemas.admin_oem_branding import OemBrandingOut, OemBrandingUpdate
from app.schemas.common import ApiResponse
from app.services import admin_oem_branding as branding_service
router = APIRouter(prefix="/admin/oem-branding", tags=["管理-OEM品牌"])
@router.get("", response_model=ApiResponse[OemBrandingOut])
async def get_oem_branding(
admin: AdminUser,
db: DbSession,
) -> ApiResponse[OemBrandingOut]:
try:
oem = await branding_service.get_default_oem_branding(
db, owner_user_id=admin.id
)
except branding_service.AdminOemBrandingError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(
ok=True,
message="",
data=OemBrandingOut.model_validate(oem),
)
@router.patch("", response_model=ApiResponse[OemBrandingOut])
async def update_oem_branding(
body: OemBrandingUpdate,
admin: AdminUser,
db: DbSession,
) -> ApiResponse[OemBrandingOut]:
try:
oem = await branding_service.update_default_oem_branding(
db, body, owner_user_id=admin.id
)
except branding_service.AdminOemBrandingError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(
ok=True,
message="软件配置已保存",
data=OemBrandingOut.model_validate(oem),
)

View File

@@ -21,8 +21,20 @@ async def list_users(
le=admin_user_service.MAX_PAGE_SIZE,
description="每页条数",
),
username: str | None = Query(None, description="用户名模糊搜索"),
oem_id: int | None = Query(None, description="按所属 OEM 用户 ID 筛选"),
agent_id: int | None = Query(None, description="按所属代理用户 ID 筛选"),
role_id: int | None = Query(None, ge=0, le=3, description="按角色筛选"),
) -> ApiResponse[PaginatedData[AdminUserOut]]:
users, total = await admin_user_service.list_users(db, page=page, page_size=page_size)
users, total = await admin_user_service.list_users(
db,
page=page,
page_size=page_size,
username=username,
oem_id=oem_id,
agent_id=agent_id,
role_id=role_id,
)
return ApiResponse(
ok=True,
message="",

19
app/api/v1/oem_public.py Normal file
View File

@@ -0,0 +1,19 @@
"""公开 OEM 品牌信息(无需鉴权)。"""
from fastapi import APIRouter, Path
from app.dependencies import DbSession
from app.schemas.common import ApiResponse
from app.schemas.oem_public import OemPublicBrandingOut
from app.services import oem_public as oem_public_service
router = APIRouter(prefix="/public/oem-branding", tags=["公开-OEM品牌"])
@router.get("/{oem_id}", response_model=ApiResponse[OemPublicBrandingOut])
async def get_oem_branding_public(
db: DbSession,
oem_id: int = Path(ge=1, description="OEM 记录 ID"),
) -> ApiResponse[OemPublicBrandingOut]:
data = await oem_public_service.get_oem_public_branding(db, oem_id)
return ApiResponse(ok=True, message="", data=data)

View File

@@ -3,18 +3,22 @@ from fastapi import APIRouter
from app.api.v1 import (
admin_card_keys,
admin_desktop_configs,
admin_oem_branding,
admin_users,
app_config,
oem_public,
auth,
nodejs_scripts,
quickjs_scripts,
)
api_router = APIRouter()
api_router.include_router(oem_public.router)
api_router.include_router(auth.router)
api_router.include_router(admin_users.router)
api_router.include_router(admin_card_keys.router)
api_router.include_router(admin_desktop_configs.router)
api_router.include_router(admin_oem_branding.router)
api_router.include_router(app_config.router)
api_router.include_router(quickjs_scripts.router)
api_router.include_router(nodejs_scripts.router)

View File

@@ -30,6 +30,9 @@ class Settings(BaseSettings):
cors_origins: str = "http://localhost:1420"
"""对外图片/品牌资源基址,用于拼接 logo_url。"""
api_public_base: str = "http://127.0.0.1:8001"
@property
def database_url(self) -> str:
return (

119
app/core/branding_images.py Normal file
View File

@@ -0,0 +1,119 @@
"""OEM 品牌图:落盘至项目根目录 images/,库内仅存相对路径。"""
from __future__ import annotations
import base64
import re
import uuid
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
IMAGES_DIR = PROJECT_ROOT / "images"
DATA_URL_PATTERN = re.compile(r"^data:image/([\w+.-]+);base64,(.+)$", re.DOTALL)
MAX_IMAGE_BYTES = 5 * 1024 * 1024
EXT_ALIASES = {"jpeg": "jpg", "svg+xml": "svg"}
class BrandingImageError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
def ensure_images_dir() -> None:
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
def is_data_url(value: str) -> bool:
return value.strip().startswith("data:image/")
def normalize_relative_path(value: str | None) -> str | None:
"""从完整 URL 或相对路径得到 images/xxx 形式。"""
if not value:
return None
text = value.strip()
if not text or is_data_url(text):
return None
marker = "/images/"
if marker in text:
suffix = text.split(marker, 1)[1].split("?")[0]
return f"images/{suffix}"
normalized = text.replace("\\", "/").lstrip("/")
if normalized.startswith("images/"):
return normalized
return None
def absolute_path(relative_path: str | None) -> Path | None:
rel = normalize_relative_path(relative_path)
if not rel:
return None
name = rel.removeprefix("images/").lstrip("/")
if not name or ".." in Path(name).parts:
return None
return IMAGES_DIR / name
def delete_local_image(relative_path: str | None) -> None:
path = absolute_path(relative_path)
if path is not None and path.is_file():
path.unlink()
def save_data_url(data_url: str, *, file_prefix: str) -> str:
match = DATA_URL_PATTERN.match(data_url.strip())
if not match:
raise BrandingImageError("无效的图片数据")
ext = match.group(1).lower()
ext = EXT_ALIASES.get(ext, ext)
if ext not in {"png", "jpg", "gif", "webp", "svg"}:
raise BrandingImageError(f"不支持的图片格式: {ext}")
try:
raw = base64.b64decode(match.group(2), validate=True)
except Exception as exc:
raise BrandingImageError("图片 Base64 解码失败") from exc
if len(raw) > MAX_IMAGE_BYTES:
raise BrandingImageError("单张图片不能超过 5MB")
ensure_images_dir()
filename = f"oem_{file_prefix}_{uuid.uuid4().hex[:12]}.{ext}"
target = IMAGES_DIR / filename
target.write_bytes(raw)
return f"images/{filename}"
def resolve_image_field(
new_value: str | None,
old_path: str | None,
*,
file_prefix: str,
) -> str | None:
"""
- 空:删除旧文件,返回 None
- data URL写入新文件删除旧文件
- 已是 images/ 路径(或与旧路径相同):原样保留
"""
if new_value is None or not str(new_value).strip():
delete_local_image(old_path)
return None
text = str(new_value).strip()
if is_data_url(text):
delete_local_image(old_path)
return save_data_url(text, file_prefix=file_prefix)
rel = normalize_relative_path(text)
if rel is None:
raise BrandingImageError("图片须为上传数据或 images/ 下的路径")
old_rel = normalize_relative_path(old_path)
if rel == old_rel:
return rel
return rel

View File

@@ -2,9 +2,11 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.v1.router import api_router
from app.config import get_settings
from app.core.branding_images import IMAGES_DIR, ensure_images_dir
from app.middleware.api_crypto import ApiCryptoMiddleware
from app.redis_client import close_redis, init_redis
from app.schemas.common import ApiResponse
@@ -39,6 +41,13 @@ def create_app() -> FastAPI:
async def health() -> ApiResponse[dict]:
return ApiResponse(ok=True, message="服务正常", data={"status": "up"})
ensure_images_dir()
app.mount(
"/images",
StaticFiles(directory=str(IMAGES_DIR)),
name="branding-images",
)
app.include_router(api_router, prefix="/api/v1")
return app

View File

@@ -1,5 +1,6 @@
from app.models.card_key import CardKey
from app.models.desktopConfig import DesktopConfig
from app.models.oem import Oem
from app.models.user import User
__all__ = ["User", "DesktopConfig", "CardKey"]
__all__ = ["User", "DesktopConfig", "CardKey", "Oem"]

View File

@@ -22,4 +22,10 @@ class CardKey(Base):
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
oem_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
agent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)

28
app/models/oem.py Normal file
View File

@@ -0,0 +1,28 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Oem(Base):
__tablename__ = "oem"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
)
software_name: Mapped[str] = mapped_column(String(128), default="", server_default="")
logo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
wechat: Mapped[str | None] = mapped_column(String(128), nullable=True)
qq: Mapped[str | None] = mapped_column(String(128), nullable=True)
wechat_qrcode: Mapped[str | None] = mapped_column(String(512), nullable=True)
qq_qrcode: Mapped[str | None] = mapped_column(String(512), nullable=True)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
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()
)

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -14,6 +14,12 @@ class User(Base):
phone: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
password_hash: Mapped[str] = mapped_column(String(255))
role_id: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
oem_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
agent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
vip_end_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)

View File

@@ -14,6 +14,8 @@ class CardKeyOut(BaseModel):
activated_at: datetime | None = None
user_id: int | None = None
username: str | None = None
oem_id: int | None = None
agent_id: int | None = None
remark: str | None = None
@@ -22,6 +24,8 @@ class CardKeyCreate(BaseModel):
remark: str | None = Field(default=None, max_length=255)
count: int = Field(default=1, ge=1, le=100, description="批量生成数量")
serial_number: str | None = Field(default=None, max_length=64)
oem_id: int | None = Field(default=None, description="预分配给 OEM")
agent_id: int | None = Field(default=None, description="预分配给代理")
@field_validator("remark")
@classmethod
@@ -43,6 +47,8 @@ class CardKeyCreate(BaseModel):
def serial_only_for_single(self) -> "CardKeyCreate":
if self.serial_number and self.count != 1:
raise ValueError("指定序列号时仅可生成 1 张卡密")
if self.oem_id is not None and self.agent_id is not None:
raise ValueError("OEM 与代理只能指定其一")
return self

View File

@@ -0,0 +1,51 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class OemBrandingOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
software_name: str = ""
logo_path: str | None = None
wechat: str | None = None
qq: str | None = None
wechat_qrcode: str | None = None
qq_qrcode: str | None = None
description: str | None = None
created_at: datetime
updated_at: datetime
class OemBrandingUpdate(BaseModel):
"""整表保存默认 OEM 品牌配置(允许置空图片与联系方式)。"""
software_name: str = Field(default="", max_length=128)
logo_path: str | None = None
wechat: str | None = Field(default=None, max_length=128)
qq: str | None = Field(default=None, max_length=128)
wechat_qrcode: str | None = None
qq_qrcode: str | None = None
@field_validator("software_name")
@classmethod
def strip_software_name(cls, value: str) -> str:
return value.strip()
@field_validator("wechat", "qq")
@classmethod
def strip_optional_text(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
@field_validator("logo_path", "wechat_qrcode", "qq_qrcode")
@classmethod
def normalize_image_value(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None

View File

@@ -10,6 +10,8 @@ class AdminUserOut(BaseModel):
username: str
phone: str | None = None
role_id: int
oem_id: int | None = None
agent_id: int | None = None
vip_end_time: datetime | None = None
created_at: datetime
updated_at: datetime
@@ -20,6 +22,8 @@ class AdminUserCreate(BaseModel):
password: str = Field(min_length=6, max_length=128)
phone: str | None = Field(default=None, max_length=64)
role_id: int = Field(default=0, ge=0, le=3)
oem_id: int | None = None
agent_id: int | None = None
vip_end_time: datetime | None = None
@field_validator("username")
@@ -40,7 +44,9 @@ class AdminUserUpdate(BaseModel):
username: str | None = Field(default=None, min_length=1, max_length=64)
password: str | None = Field(default=None, min_length=6, max_length=128)
phone: str | None = Field(default=None, max_length=64)
role_id: int | None = Field(default=None, ge=0, le=2)
role_id: int | None = Field(default=None, ge=0, le=3)
oem_id: int | None = None
agent_id: int | None = None
vip_end_time: datetime | None = None
clear_vip_end_time: bool = False

12
app/schemas/oem_public.py Normal file
View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel, ConfigDict
class OemPublicBrandingOut(BaseModel):
"""对外公开的品牌信息(无需登录)。"""
model_config = ConfigDict(from_attributes=True)
oem_id: int
software_name: str = ""
logo_url: str | None = None
wechat: str | None = None

View File

@@ -4,11 +4,13 @@ import string
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import ROLE_AGENT, ROLE_OEM
from app.models.card_key import CardKey
from app.models.user import User
from app.schemas.admin_card_key import CardKeyCreate, CardKeyUpdate
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
MAX_PAGE_SIZE = 200
_SERIAL_ALPHABET = string.ascii_uppercase + string.digits
_SERIAL_LENGTH = 16
@@ -43,22 +45,65 @@ async def _unique_serial(db: AsyncSession, preferred: str | None = None) -> str:
raise AdminCardKeyError("生成序列号失败,请重试")
async def _validate_assign_ids(
db: AsyncSession,
*,
oem_id: int | None,
agent_id: int | None,
) -> None:
if oem_id is not None and agent_id is not None:
raise AdminCardKeyError("OEM 与代理只能指定其一")
if oem_id is not None:
oem = await db.get(User, oem_id)
if oem is None or oem.role_id != ROLE_OEM:
raise AdminCardKeyError("所选 OEM 不存在")
if agent_id is not None:
agent = await db.get(User, agent_id)
if agent is None or agent.role_id != ROLE_AGENT:
raise AdminCardKeyError("所选代理不存在")
def _list_card_keys_filters(
*,
status: str | None = None,
username: str | None = None,
oem_id: int | None = None,
agent_id: int | None = None,
) -> list:
filters = []
if status == "unused":
filters.append(CardKey.activated_at.is_(None))
elif status == "used":
filters.append(CardKey.activated_at.is_not(None))
if username and username.strip():
filters.append(CardKey.username.ilike(f"%{username.strip()}%"))
if oem_id is not None:
filters.append(CardKey.oem_id == oem_id)
if agent_id is not None:
filters.append(CardKey.agent_id == agent_id)
return filters
async def list_card_keys(
db: AsyncSession,
*,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
status: str | None = None,
username: str | None = None,
oem_id: int | None = None,
agent_id: int | None = None,
) -> tuple[list[CardKey], int]:
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
page = max(page, 1)
offset = (page - 1) * page_size
filters = []
if status == "unused":
filters.append(CardKey.activated_at.is_(None))
elif status == "used":
filters.append(CardKey.activated_at.is_not(None))
filters = _list_card_keys_filters(
status=status,
username=username,
oem_id=oem_id,
agent_id=agent_id,
)
count_stmt = select(func.count()).select_from(CardKey)
list_stmt = select(CardKey)
@@ -74,6 +119,7 @@ async def list_card_keys(
async def create_card_keys(db: AsyncSession, body: CardKeyCreate) -> list[CardKey]:
await _validate_assign_ids(db, oem_id=body.oem_id, agent_id=body.agent_id)
created: list[CardKey] = []
for index in range(body.count):
@@ -85,6 +131,8 @@ async def create_card_keys(db: AsyncSession, body: CardKeyCreate) -> list[CardKe
serial_number=serial,
duration_days=body.duration_days,
remark=body.remark,
oem_id=body.oem_id,
agent_id=body.agent_id,
)
db.add(card)
created.append(card)

View File

@@ -5,7 +5,7 @@ from app.models.desktopConfig import DesktopConfig
from app.schemas.admin_desktop_config import DesktopConfigCreate, DesktopConfigUpdate
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
MAX_PAGE_SIZE = 200
class AdminDesktopConfigError(Exception):

View File

@@ -0,0 +1,75 @@
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, *, owner_user_id: int) -> Oem:
oem = await db.get(Oem, DEFAULT_OEM_ID)
if oem is not None:
return oem
owner = await db.get(User, owner_user_id)
if owner is None:
raise AdminOemBrandingError("关联用户不存在")
oem = Oem(
id=DEFAULT_OEM_ID,
user_id=owner_user_id,
software_name="",
)
db.add(oem)
await db.flush()
await db.refresh(oem)
return oem
async def get_default_oem_branding(db: AsyncSession, *, owner_user_id: int) -> Oem:
return await get_or_create_default_oem(db, owner_user_id=owner_user_id)
async def update_default_oem_branding(
db: AsyncSession,
body: OemBrandingUpdate,
*,
owner_user_id: int,
) -> Oem:
oem = await get_or_create_default_oem(db, owner_user_id=owner_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

View File

@@ -1,13 +1,13 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import ROLE_ADMIN
from app.core.roles import ROLE_ADMIN, ROLE_AGENT, ROLE_OEM
from app.core.security import hash_password
from app.models.user import User
from app.schemas.admin_user import AdminUserCreate, AdminUserUpdate
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
MAX_PAGE_SIZE = 200
class AdminUserError(Exception):
@@ -16,23 +16,72 @@ class AdminUserError(Exception):
super().__init__(message)
def _list_users_filters(
*,
username: str | None = None,
oem_id: int | None = None,
agent_id: int | None = None,
role_id: int | None = None,
) -> list:
conditions = []
if username and username.strip():
conditions.append(User.username.ilike(f"%{username.strip()}%"))
if oem_id is not None:
conditions.append(User.oem_id == oem_id)
if agent_id is not None:
conditions.append(User.agent_id == agent_id)
if role_id is not None:
conditions.append(User.role_id == role_id)
return conditions
async def list_users(
db: AsyncSession,
*,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
username: str | None = None,
oem_id: int | None = None,
agent_id: int | None = None,
role_id: int | None = None,
) -> tuple[list[User], int]:
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
page = max(page, 1)
offset = (page - 1) * page_size
total = await db.scalar(select(func.count()).select_from(User)) or 0
result = await db.scalars(
select(User).order_by(User.id.desc()).offset(offset).limit(page_size)
conditions = _list_users_filters(
username=username,
oem_id=oem_id,
agent_id=agent_id,
role_id=role_id,
)
count_stmt = select(func.count()).select_from(User)
list_stmt = select(User).order_by(User.id.desc())
if conditions:
count_stmt = count_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = await db.scalar(count_stmt) or 0
result = await db.scalars(list_stmt.offset(offset).limit(page_size))
return list(result.all()), total
async def _validate_owner_ids(
db: AsyncSession,
*,
oem_id: int | None,
agent_id: int | None,
) -> None:
if oem_id is not None:
oem = await db.get(User, oem_id)
if oem is None or oem.role_id != ROLE_OEM:
raise AdminUserError("所选 OEM 不存在")
if agent_id is not None:
agent = await db.get(User, agent_id)
if agent is None or agent.role_id != ROLE_AGENT:
raise AdminUserError("所选代理不存在")
async def create_user(db: AsyncSession, body: AdminUserCreate) -> User:
existing = await db.scalar(select(User).where(User.username == body.username))
if existing is not None:
@@ -43,11 +92,15 @@ async def create_user(db: AsyncSession, body: AdminUserCreate) -> User:
if phone_taken is not None:
raise AdminUserError("手机号已被使用")
await _validate_owner_ids(db, oem_id=body.oem_id, agent_id=body.agent_id)
user = User(
username=body.username,
password_hash=hash_password(body.password),
phone=body.phone,
role_id=body.role_id,
oem_id=body.oem_id,
agent_id=body.agent_id,
vip_end_time=body.vip_end_time,
)
db.add(user)
@@ -85,6 +138,17 @@ async def update_user(
raise AdminUserError("不能修改自己的管理员角色")
user.role_id = body.role_id
if body.oem_id is not None or body.agent_id is not None:
await _validate_owner_ids(
db,
oem_id=body.oem_id if body.oem_id is not None else user.oem_id,
agent_id=body.agent_id if body.agent_id is not None else user.agent_id,
)
if body.oem_id is not None:
user.oem_id = body.oem_id
if body.agent_id is not None:
user.agent_id = body.agent_id
if body.password:
user.password_hash = hash_password(body.password)

View File

@@ -0,0 +1,31 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.oem import Oem
from app.schemas.oem_public import OemPublicBrandingOut
def _logo_url(logo_path: str | None, base_url: str) -> str | None:
if not logo_path or not logo_path.strip():
return None
path = logo_path.strip().replace("\\", "/").lstrip("/")
if path.startswith("http://") or path.startswith("https://"):
return path
if not path.startswith("images/"):
path = f"images/{path.removeprefix('images/')}"
return f"{base_url.rstrip('/')}/{path}"
async def get_oem_public_branding(db: AsyncSession, oem_id: int) -> OemPublicBrandingOut:
settings = get_settings()
base_url = settings.api_public_base.rstrip("/")
oem = await db.get(Oem, oem_id)
if oem is None:
return OemPublicBrandingOut(oem_id=oem_id, software_name="", logo_url=None, wechat=None)
return OemPublicBrandingOut(
oem_id=oem.id,
software_name=oem.software_name or "",
logo_url=_logo_url(oem.logo_path, base_url),
wechat=oem.wechat,
)