11
This commit is contained in:
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import Base
|
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
|
config = context.config
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
49
alembic/versions/006_add_user_oem_agent.py
Normal file
49
alembic/versions/006_add_user_oem_agent.py
Normal file
@@ -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")
|
||||||
55
alembic/versions/007_add_card_key_oem_agent.py
Normal file
55
alembic/versions/007_add_card_key_oem_agent.py
Normal file
@@ -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")
|
||||||
52
alembic/versions/008_create_oem_table.py
Normal file
52
alembic/versions/008_create_oem_table.py
Normal file
@@ -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")
|
||||||
65
alembic/versions/009_oem_image_path_varchar.py
Normal file
65
alembic/versions/009_oem_image_path_varchar.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -31,6 +31,9 @@ async def list_card_keys(
|
|||||||
default="all",
|
default="all",
|
||||||
description="筛选:all 全部 / unused 未使用 / used 已激活",
|
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]]:
|
) -> ApiResponse[PaginatedData[CardKeyOut]]:
|
||||||
filter_status = None if status == "all" else status
|
filter_status = None if status == "all" else status
|
||||||
cards, total = await card_key_service.list_card_keys(
|
cards, total = await card_key_service.list_card_keys(
|
||||||
@@ -38,6 +41,9 @@ async def list_card_keys(
|
|||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
status=filter_status,
|
status=filter_status,
|
||||||
|
username=username,
|
||||||
|
oem_id=oem_id,
|
||||||
|
agent_id=agent_id,
|
||||||
)
|
)
|
||||||
return ApiResponse(
|
return ApiResponse(
|
||||||
ok=True,
|
ok=True,
|
||||||
|
|||||||
49
app/api/v1/admin_oem_branding.py
Normal file
49
app/api/v1/admin_oem_branding.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
@@ -21,8 +21,20 @@ async def list_users(
|
|||||||
le=admin_user_service.MAX_PAGE_SIZE,
|
le=admin_user_service.MAX_PAGE_SIZE,
|
||||||
description="每页条数",
|
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]]:
|
) -> 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(
|
return ApiResponse(
|
||||||
ok=True,
|
ok=True,
|
||||||
message="",
|
message="",
|
||||||
|
|||||||
19
app/api/v1/oem_public.py
Normal file
19
app/api/v1/oem_public.py
Normal 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)
|
||||||
@@ -3,18 +3,22 @@ from fastapi import APIRouter
|
|||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
admin_card_keys,
|
admin_card_keys,
|
||||||
admin_desktop_configs,
|
admin_desktop_configs,
|
||||||
|
admin_oem_branding,
|
||||||
admin_users,
|
admin_users,
|
||||||
app_config,
|
app_config,
|
||||||
|
oem_public,
|
||||||
auth,
|
auth,
|
||||||
nodejs_scripts,
|
nodejs_scripts,
|
||||||
quickjs_scripts,
|
quickjs_scripts,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(oem_public.router)
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(admin_users.router)
|
api_router.include_router(admin_users.router)
|
||||||
api_router.include_router(admin_card_keys.router)
|
api_router.include_router(admin_card_keys.router)
|
||||||
api_router.include_router(admin_desktop_configs.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(app_config.router)
|
||||||
api_router.include_router(quickjs_scripts.router)
|
api_router.include_router(quickjs_scripts.router)
|
||||||
api_router.include_router(nodejs_scripts.router)
|
api_router.include_router(nodejs_scripts.router)
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
cors_origins: str = "http://localhost:1420"
|
cors_origins: str = "http://localhost:1420"
|
||||||
|
|
||||||
|
"""对外图片/品牌资源基址,用于拼接 logo_url。"""
|
||||||
|
api_public_base: str = "http://127.0.0.1:8001"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
return (
|
return (
|
||||||
|
|||||||
119
app/core/branding_images.py
Normal file
119
app/core/branding_images.py
Normal 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
|
||||||
@@ -2,9 +2,11 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.config import get_settings
|
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.middleware.api_crypto import ApiCryptoMiddleware
|
||||||
from app.redis_client import close_redis, init_redis
|
from app.redis_client import close_redis, init_redis
|
||||||
from app.schemas.common import ApiResponse
|
from app.schemas.common import ApiResponse
|
||||||
@@ -39,6 +41,13 @@ def create_app() -> FastAPI:
|
|||||||
async def health() -> ApiResponse[dict]:
|
async def health() -> ApiResponse[dict]:
|
||||||
return ApiResponse(ok=True, message="服务正常", data={"status": "up"})
|
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")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from app.models.card_key import CardKey
|
from app.models.card_key import CardKey
|
||||||
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
|
||||||
|
|
||||||
__all__ = ["User", "DesktopConfig", "CardKey"]
|
__all__ = ["User", "DesktopConfig", "CardKey", "Oem"]
|
||||||
|
|||||||
@@ -22,4 +22,10 @@ class CardKey(Base):
|
|||||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
username: Mapped[str | None] = mapped_column(String(64), 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)
|
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|||||||
28
app/models/oem.py
Normal file
28
app/models/oem.py
Normal 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()
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -14,6 +14,12 @@ class User(Base):
|
|||||||
phone: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
|
phone: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255))
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
role_id: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
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(
|
vip_end_time: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True, default=None
|
DateTime(timezone=True), nullable=True, default=None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class CardKeyOut(BaseModel):
|
|||||||
activated_at: datetime | None = None
|
activated_at: datetime | None = None
|
||||||
user_id: int | None = None
|
user_id: int | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
|
oem_id: int | None = None
|
||||||
|
agent_id: int | None = None
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ class CardKeyCreate(BaseModel):
|
|||||||
remark: str | None = Field(default=None, max_length=255)
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
count: int = Field(default=1, ge=1, le=100, description="批量生成数量")
|
count: int = Field(default=1, ge=1, le=100, description="批量生成数量")
|
||||||
serial_number: str | None = Field(default=None, max_length=64)
|
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")
|
@field_validator("remark")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -43,6 +47,8 @@ class CardKeyCreate(BaseModel):
|
|||||||
def serial_only_for_single(self) -> "CardKeyCreate":
|
def serial_only_for_single(self) -> "CardKeyCreate":
|
||||||
if self.serial_number and self.count != 1:
|
if self.serial_number and self.count != 1:
|
||||||
raise ValueError("指定序列号时仅可生成 1 张卡密")
|
raise ValueError("指定序列号时仅可生成 1 张卡密")
|
||||||
|
if self.oem_id is not None and self.agent_id is not None:
|
||||||
|
raise ValueError("OEM 与代理只能指定其一")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
51
app/schemas/admin_oem_branding.py
Normal file
51
app/schemas/admin_oem_branding.py
Normal 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
|
||||||
@@ -10,6 +10,8 @@ class AdminUserOut(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
role_id: int
|
role_id: int
|
||||||
|
oem_id: int | None = None
|
||||||
|
agent_id: int | None = None
|
||||||
vip_end_time: datetime | None = None
|
vip_end_time: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -20,6 +22,8 @@ class AdminUserCreate(BaseModel):
|
|||||||
password: str = Field(min_length=6, max_length=128)
|
password: str = Field(min_length=6, max_length=128)
|
||||||
phone: str | None = Field(default=None, max_length=64)
|
phone: str | None = Field(default=None, max_length=64)
|
||||||
role_id: int = Field(default=0, ge=0, le=3)
|
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
|
vip_end_time: datetime | None = None
|
||||||
|
|
||||||
@field_validator("username")
|
@field_validator("username")
|
||||||
@@ -40,7 +44,9 @@ class AdminUserUpdate(BaseModel):
|
|||||||
username: str | None = Field(default=None, min_length=1, max_length=64)
|
username: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
password: str | None = Field(default=None, min_length=6, max_length=128)
|
password: str | None = Field(default=None, min_length=6, max_length=128)
|
||||||
phone: str | None = Field(default=None, max_length=64)
|
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
|
vip_end_time: datetime | None = None
|
||||||
clear_vip_end_time: bool = False
|
clear_vip_end_time: bool = False
|
||||||
|
|
||||||
|
|||||||
12
app/schemas/oem_public.py
Normal file
12
app/schemas/oem_public.py
Normal 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
|
||||||
@@ -4,11 +4,13 @@ import string
|
|||||||
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.roles import ROLE_AGENT, ROLE_OEM
|
||||||
from app.models.card_key import CardKey
|
from app.models.card_key import CardKey
|
||||||
|
from app.models.user import User
|
||||||
from app.schemas.admin_card_key import CardKeyCreate, CardKeyUpdate
|
from app.schemas.admin_card_key import CardKeyCreate, CardKeyUpdate
|
||||||
|
|
||||||
DEFAULT_PAGE_SIZE = 20
|
DEFAULT_PAGE_SIZE = 20
|
||||||
MAX_PAGE_SIZE = 100
|
MAX_PAGE_SIZE = 200
|
||||||
|
|
||||||
_SERIAL_ALPHABET = string.ascii_uppercase + string.digits
|
_SERIAL_ALPHABET = string.ascii_uppercase + string.digits
|
||||||
_SERIAL_LENGTH = 16
|
_SERIAL_LENGTH = 16
|
||||||
@@ -43,22 +45,65 @@ async def _unique_serial(db: AsyncSession, preferred: str | None = None) -> str:
|
|||||||
raise AdminCardKeyError("生成序列号失败,请重试")
|
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(
|
async def list_card_keys(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = DEFAULT_PAGE_SIZE,
|
page_size: int = DEFAULT_PAGE_SIZE,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
username: str | None = None,
|
||||||
|
oem_id: int | None = None,
|
||||||
|
agent_id: int | None = None,
|
||||||
) -> tuple[list[CardKey], int]:
|
) -> tuple[list[CardKey], int]:
|
||||||
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
|
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
|
||||||
page = max(page, 1)
|
page = max(page, 1)
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
filters = []
|
filters = _list_card_keys_filters(
|
||||||
if status == "unused":
|
status=status,
|
||||||
filters.append(CardKey.activated_at.is_(None))
|
username=username,
|
||||||
elif status == "used":
|
oem_id=oem_id,
|
||||||
filters.append(CardKey.activated_at.is_not(None))
|
agent_id=agent_id,
|
||||||
|
)
|
||||||
|
|
||||||
count_stmt = select(func.count()).select_from(CardKey)
|
count_stmt = select(func.count()).select_from(CardKey)
|
||||||
list_stmt = select(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]:
|
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] = []
|
created: list[CardKey] = []
|
||||||
|
|
||||||
for index in range(body.count):
|
for index in range(body.count):
|
||||||
@@ -85,6 +131,8 @@ async def create_card_keys(db: AsyncSession, body: CardKeyCreate) -> list[CardKe
|
|||||||
serial_number=serial,
|
serial_number=serial,
|
||||||
duration_days=body.duration_days,
|
duration_days=body.duration_days,
|
||||||
remark=body.remark,
|
remark=body.remark,
|
||||||
|
oem_id=body.oem_id,
|
||||||
|
agent_id=body.agent_id,
|
||||||
)
|
)
|
||||||
db.add(card)
|
db.add(card)
|
||||||
created.append(card)
|
created.append(card)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from app.models.desktopConfig import DesktopConfig
|
|||||||
from app.schemas.admin_desktop_config import DesktopConfigCreate, DesktopConfigUpdate
|
from app.schemas.admin_desktop_config import DesktopConfigCreate, DesktopConfigUpdate
|
||||||
|
|
||||||
DEFAULT_PAGE_SIZE = 20
|
DEFAULT_PAGE_SIZE = 20
|
||||||
MAX_PAGE_SIZE = 100
|
MAX_PAGE_SIZE = 200
|
||||||
|
|
||||||
|
|
||||||
class AdminDesktopConfigError(Exception):
|
class AdminDesktopConfigError(Exception):
|
||||||
|
|||||||
75
app/services/admin_oem_branding.py
Normal file
75
app/services/admin_oem_branding.py
Normal 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
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
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.roles import ROLE_ADMIN
|
from app.core.roles import ROLE_ADMIN, ROLE_AGENT, ROLE_OEM
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.admin_user import AdminUserCreate, AdminUserUpdate
|
from app.schemas.admin_user import AdminUserCreate, AdminUserUpdate
|
||||||
|
|
||||||
DEFAULT_PAGE_SIZE = 20
|
DEFAULT_PAGE_SIZE = 20
|
||||||
MAX_PAGE_SIZE = 100
|
MAX_PAGE_SIZE = 200
|
||||||
|
|
||||||
|
|
||||||
class AdminUserError(Exception):
|
class AdminUserError(Exception):
|
||||||
@@ -16,23 +16,72 @@ class AdminUserError(Exception):
|
|||||||
super().__init__(message)
|
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(
|
async def list_users(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = DEFAULT_PAGE_SIZE,
|
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]:
|
) -> tuple[list[User], int]:
|
||||||
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
|
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
|
||||||
page = max(page, 1)
|
page = max(page, 1)
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
conditions = _list_users_filters(
|
||||||
total = await db.scalar(select(func.count()).select_from(User)) or 0
|
username=username,
|
||||||
result = await db.scalars(
|
oem_id=oem_id,
|
||||||
select(User).order_by(User.id.desc()).offset(offset).limit(page_size)
|
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
|
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:
|
async def create_user(db: AsyncSession, body: AdminUserCreate) -> User:
|
||||||
existing = await db.scalar(select(User).where(User.username == body.username))
|
existing = await db.scalar(select(User).where(User.username == body.username))
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
@@ -43,11 +92,15 @@ async def create_user(db: AsyncSession, body: AdminUserCreate) -> User:
|
|||||||
if phone_taken is not None:
|
if phone_taken is not None:
|
||||||
raise AdminUserError("手机号已被使用")
|
raise AdminUserError("手机号已被使用")
|
||||||
|
|
||||||
|
await _validate_owner_ids(db, oem_id=body.oem_id, agent_id=body.agent_id)
|
||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
username=body.username,
|
username=body.username,
|
||||||
password_hash=hash_password(body.password),
|
password_hash=hash_password(body.password),
|
||||||
phone=body.phone,
|
phone=body.phone,
|
||||||
role_id=body.role_id,
|
role_id=body.role_id,
|
||||||
|
oem_id=body.oem_id,
|
||||||
|
agent_id=body.agent_id,
|
||||||
vip_end_time=body.vip_end_time,
|
vip_end_time=body.vip_end_time,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
@@ -85,6 +138,17 @@ async def update_user(
|
|||||||
raise AdminUserError("不能修改自己的管理员角色")
|
raise AdminUserError("不能修改自己的管理员角色")
|
||||||
user.role_id = body.role_id
|
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:
|
if body.password:
|
||||||
user.password_hash = hash_password(body.password)
|
user.password_hash = hash_password(body.password)
|
||||||
|
|
||||||
|
|||||||
31
app/services/oem_public.py
Normal file
31
app/services/oem_public.py
Normal 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,
|
||||||
|
)
|
||||||
0
images/.gitkeep
Normal file
0
images/.gitkeep
Normal file
BIN
images/oem_logo_ae6242620db1.png
Normal file
BIN
images/oem_logo_ae6242620db1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
images/oem_wechat_qr_b7845cff5d3f.png
Normal file
BIN
images/oem_wechat_qr_b7845cff5d3f.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user