diff --git a/alembic/env.py b/alembic/env.py index 93567d3..dc94325 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from app.config import get_settings from app.database import Base -from app.models import CardKey, DesktopConfig, User # noqa: F401 — 注册元数据 +from app.models import CardKey, DesktopConfig, Oem, User # noqa: F401 — 注册元数据 config = context.config settings = get_settings() diff --git a/alembic/versions/006_add_user_oem_agent.py b/alembic/versions/006_add_user_oem_agent.py new file mode 100644 index 0000000..cd4efe2 --- /dev/null +++ b/alembic/versions/006_add_user_oem_agent.py @@ -0,0 +1,49 @@ +"""add users oem_id and agent_id + +Revision ID: 006 +Revises: 005 +Create Date: 2026-05-21 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "006" +down_revision: Union[str, None] = "005" +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("oem_id", sa.Integer(), nullable=True)) + op.add_column("users", sa.Column("agent_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_users_oem_id_users", + "users", + "users", + ["oem_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_users_agent_id_users", + "users", + "users", + ["agent_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index("ix_users_oem_id", "users", ["oem_id"]) + op.create_index("ix_users_agent_id", "users", ["agent_id"]) + + +def downgrade() -> None: + op.drop_index("ix_users_agent_id", table_name="users") + op.drop_index("ix_users_oem_id", table_name="users") + op.drop_constraint("fk_users_agent_id_users", "users", type_="foreignkey") + op.drop_constraint("fk_users_oem_id_users", "users", type_="foreignkey") + op.drop_column("users", "agent_id") + op.drop_column("users", "oem_id") diff --git a/alembic/versions/007_add_card_key_oem_agent.py b/alembic/versions/007_add_card_key_oem_agent.py new file mode 100644 index 0000000..1c538ac --- /dev/null +++ b/alembic/versions/007_add_card_key_oem_agent.py @@ -0,0 +1,55 @@ +"""add oem_id and agent_id to card_keys + +Revision ID: 007 +Revises: 006 +Create Date: 2026-05-21 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "007" +down_revision: Union[str, None] = "006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "card_keys", + sa.Column("oem_id", sa.Integer(), nullable=True), + ) + op.add_column( + "card_keys", + sa.Column("agent_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_card_keys_oem_id_users", + "card_keys", + "users", + ["oem_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_card_keys_agent_id_users", + "card_keys", + "users", + ["agent_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index(op.f("ix_card_keys_oem_id"), "card_keys", ["oem_id"], unique=False) + op.create_index(op.f("ix_card_keys_agent_id"), "card_keys", ["agent_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_card_keys_agent_id"), table_name="card_keys") + op.drop_index(op.f("ix_card_keys_oem_id"), table_name="card_keys") + op.drop_constraint("fk_card_keys_agent_id_users", "card_keys", type_="foreignkey") + op.drop_constraint("fk_card_keys_oem_id_users", "card_keys", type_="foreignkey") + op.drop_column("card_keys", "agent_id") + op.drop_column("card_keys", "oem_id") diff --git a/alembic/versions/008_create_oem_table.py b/alembic/versions/008_create_oem_table.py new file mode 100644 index 0000000..d8842cc --- /dev/null +++ b/alembic/versions/008_create_oem_table.py @@ -0,0 +1,52 @@ +"""create oem table + +Revision ID: 008 +Revises: 007 +Create Date: 2026-05-21 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "008" +down_revision: Union[str, None] = "007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "oem", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("software_name", sa.String(length=128), server_default="", nullable=False), + sa.Column("logo_path", sa.Text(), nullable=True), + sa.Column("wechat", sa.String(length=128), nullable=True), + sa.Column("qq", sa.String(length=128), nullable=True), + sa.Column("wechat_qrcode", sa.Text(), nullable=True), + sa.Column("qq_qrcode", sa.Text(), nullable=True), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_oem_user_id"), "oem", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_oem_user_id"), table_name="oem") + op.drop_table("oem") diff --git a/alembic/versions/009_oem_image_path_varchar.py b/alembic/versions/009_oem_image_path_varchar.py new file mode 100644 index 0000000..3138d45 --- /dev/null +++ b/alembic/versions/009_oem_image_path_varchar.py @@ -0,0 +1,65 @@ +"""oem image columns store file paths only + +Revision ID: 009 +Revises: 008 +Create Date: 2026-05-21 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "009" +down_revision: Union[str, None] = "008" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "oem", + "logo_path", + existing_type=sa.Text(), + type_=sa.String(length=512), + existing_nullable=True, + ) + op.alter_column( + "oem", + "wechat_qrcode", + existing_type=sa.Text(), + type_=sa.String(length=512), + existing_nullable=True, + ) + op.alter_column( + "oem", + "qq_qrcode", + existing_type=sa.Text(), + type_=sa.String(length=512), + existing_nullable=True, + ) + + +def downgrade() -> None: + op.alter_column( + "oem", + "qq_qrcode", + existing_type=sa.String(length=512), + type_=sa.Text(), + existing_nullable=True, + ) + op.alter_column( + "oem", + "wechat_qrcode", + existing_type=sa.String(length=512), + type_=sa.Text(), + existing_nullable=True, + ) + op.alter_column( + "oem", + "logo_path", + existing_type=sa.String(length=512), + type_=sa.Text(), + existing_nullable=True, + ) diff --git a/app/api/v1/admin_card_keys.py b/app/api/v1/admin_card_keys.py index 7587745..38af9d4 100644 --- a/app/api/v1/admin_card_keys.py +++ b/app/api/v1/admin_card_keys.py @@ -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, diff --git a/app/api/v1/admin_oem_branding.py b/app/api/v1/admin_oem_branding.py new file mode 100644 index 0000000..0d32b8f --- /dev/null +++ b/app/api/v1/admin_oem_branding.py @@ -0,0 +1,49 @@ +"""管理员:默认 OEM(id=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), + ) diff --git a/app/api/v1/admin_users.py b/app/api/v1/admin_users.py index eb73bd0..24c4ef7 100644 --- a/app/api/v1/admin_users.py +++ b/app/api/v1/admin_users.py @@ -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="", diff --git a/app/api/v1/oem_public.py b/app/api/v1/oem_public.py new file mode 100644 index 0000000..66fe1b4 --- /dev/null +++ b/app/api/v1/oem_public.py @@ -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) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 527ea9b..94bf6fb 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -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) diff --git a/app/config.py b/app/config.py index 3976ebd..9a888c2 100644 --- a/app/config.py +++ b/app/config.py @@ -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 ( diff --git a/app/core/branding_images.py b/app/core/branding_images.py new file mode 100644 index 0000000..9c2ec48 --- /dev/null +++ b/app/core/branding_images.py @@ -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 diff --git a/app/main.py b/app/main.py index 462af73..b8fac1f 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 370961c..627c98b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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"] diff --git a/app/models/card_key.py b/app/models/card_key.py index 25d3ebb..f733e29 100644 --- a/app/models/card_key.py +++ b/app/models/card_key.py @@ -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) diff --git a/app/models/oem.py b/app/models/oem.py new file mode 100644 index 0000000..c8ed4a1 --- /dev/null +++ b/app/models/oem.py @@ -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() + ) diff --git a/app/models/user.py b/app/models/user.py index ef5a2bf..9e2db56 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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 ) diff --git a/app/schemas/admin_card_key.py b/app/schemas/admin_card_key.py index dc8d1df..342fadb 100644 --- a/app/schemas/admin_card_key.py +++ b/app/schemas/admin_card_key.py @@ -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 diff --git a/app/schemas/admin_oem_branding.py b/app/schemas/admin_oem_branding.py new file mode 100644 index 0000000..2a61a00 --- /dev/null +++ b/app/schemas/admin_oem_branding.py @@ -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 diff --git a/app/schemas/admin_user.py b/app/schemas/admin_user.py index 14a9ca8..d7d4ba4 100644 --- a/app/schemas/admin_user.py +++ b/app/schemas/admin_user.py @@ -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 diff --git a/app/schemas/oem_public.py b/app/schemas/oem_public.py new file mode 100644 index 0000000..56fbe3a --- /dev/null +++ b/app/schemas/oem_public.py @@ -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 diff --git a/app/services/admin_card_key.py b/app/services/admin_card_key.py index d53426a..89f882e 100644 --- a/app/services/admin_card_key.py +++ b/app/services/admin_card_key.py @@ -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) diff --git a/app/services/admin_desktop_config.py b/app/services/admin_desktop_config.py index 9266d27..8ebb796 100644 --- a/app/services/admin_desktop_config.py +++ b/app/services/admin_desktop_config.py @@ -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): diff --git a/app/services/admin_oem_branding.py b/app/services/admin_oem_branding.py new file mode 100644 index 0000000..213d16f --- /dev/null +++ b/app/services/admin_oem_branding.py @@ -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 diff --git a/app/services/admin_user.py b/app/services/admin_user.py index e252d6a..09a67eb 100644 --- a/app/services/admin_user.py +++ b/app/services/admin_user.py @@ -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) diff --git a/app/services/oem_public.py b/app/services/oem_public.py new file mode 100644 index 0000000..835d098 --- /dev/null +++ b/app/services/oem_public.py @@ -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, + ) diff --git a/images/.gitkeep b/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/images/oem_logo_ae6242620db1.png b/images/oem_logo_ae6242620db1.png new file mode 100644 index 0000000..5caaeba Binary files /dev/null and b/images/oem_logo_ae6242620db1.png differ diff --git a/images/oem_wechat_qr_b7845cff5d3f.png b/images/oem_wechat_qr_b7845cff5d3f.png new file mode 100644 index 0000000..0a10e19 Binary files /dev/null and b/images/oem_wechat_qr_b7845cff5d3f.png differ