This commit is contained in:
fengchuanhn@gmail.com
2026-05-17 12:54:13 +08:00
parent 1b8ca10ec9
commit 3db327d93b
38 changed files with 2820 additions and 14 deletions

View File

@@ -0,0 +1,103 @@
"""管理员卡密 CRUD。"""
from typing import Literal
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, DbSession
from app.schemas.admin_card_key import (
CardKeyBatchCreateResult,
CardKeyCreate,
CardKeyOut,
CardKeyUpdate,
)
from app.schemas.common import ApiResponse, PaginatedData
from app.services import admin_card_key as card_key_service
router = APIRouter(prefix="/admin/card-keys", tags=["管理-卡密"])
@router.get("", response_model=ApiResponse[PaginatedData[CardKeyOut]])
async def list_card_keys(
_admin: AdminUser,
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(
card_key_service.DEFAULT_PAGE_SIZE,
ge=1,
le=card_key_service.MAX_PAGE_SIZE,
),
status: Literal["all", "unused", "used"] | None = Query(
default="all",
description="筛选all 全部 / unused 未使用 / used 已激活",
),
) -> ApiResponse[PaginatedData[CardKeyOut]]:
filter_status = None if status == "all" else status
cards, total = await card_key_service.list_card_keys(
db,
page=page,
page_size=page_size,
status=filter_status,
)
return ApiResponse(
ok=True,
message="",
data=PaginatedData.build(
[CardKeyOut.model_validate(c) for c in cards],
total=total,
page=page,
page_size=page_size,
),
)
@router.post("", response_model=ApiResponse[CardKeyBatchCreateResult])
async def create_card_keys(
body: CardKeyCreate,
_admin: AdminUser,
db: DbSession,
) -> ApiResponse[CardKeyBatchCreateResult]:
try:
created = await card_key_service.create_card_keys(db, body)
except card_key_service.AdminCardKeyError as exc:
return ApiResponse(ok=False, message=exc.message)
items = [CardKeyOut.model_validate(c) for c in created]
return ApiResponse(
ok=True,
message=f"已生成 {len(items)} 张卡密",
data=CardKeyBatchCreateResult(items=items, created_count=len(items)),
)
@router.patch("/{card_id}", response_model=ApiResponse[CardKeyOut])
async def update_card_key(
card_id: int,
body: CardKeyUpdate,
_admin: AdminUser,
db: DbSession,
) -> ApiResponse[CardKeyOut]:
try:
card = await card_key_service.update_card_key(db, card_id, body)
except card_key_service.AdminCardKeyError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(
ok=True,
message="卡密已更新",
data=CardKeyOut.model_validate(card),
)
@router.delete("/{card_id}", response_model=ApiResponse[None])
async def delete_card_key(
card_id: int,
_admin: AdminUser,
db: DbSession,
) -> ApiResponse[None]:
try:
await card_key_service.delete_card_key(db, card_id)
except card_key_service.AdminCardKeyError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(ok=True, message="卡密已删除")

View File

@@ -0,0 +1,95 @@
"""管理员 desktop_configs 键值 CRUD。"""
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, DbSession
from app.schemas.admin_desktop_config import (
DesktopConfigCreate,
DesktopConfigOut,
DesktopConfigUpdate,
)
from app.schemas.common import ApiResponse, PaginatedData
from app.services import admin_desktop_config as config_service
router = APIRouter(prefix="/admin/desktop-configs", tags=["管理-桌面配置"])
@router.get("", response_model=ApiResponse[PaginatedData[DesktopConfigOut]])
async def list_configs(
_admin: AdminUser,
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(
config_service.DEFAULT_PAGE_SIZE,
ge=1,
le=config_service.MAX_PAGE_SIZE,
),
keyword: str | None = Query(default=None, max_length=64, description="按键名模糊搜索"),
) -> ApiResponse[PaginatedData[DesktopConfigOut]]:
rows, total = await config_service.list_configs(
db,
page=page,
page_size=page_size,
keyword=keyword,
)
return ApiResponse(
ok=True,
message="",
data=PaginatedData.build(
[DesktopConfigOut.model_validate(r) for r in rows],
total=total,
page=page,
page_size=page_size,
),
)
@router.post("", response_model=ApiResponse[DesktopConfigOut])
async def create_config(
body: DesktopConfigCreate,
_admin: AdminUser,
db: DbSession,
) -> ApiResponse[DesktopConfigOut]:
try:
row = await config_service.create_config(db, body)
except config_service.AdminDesktopConfigError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(
ok=True,
message="配置已创建",
data=DesktopConfigOut.model_validate(row),
)
@router.patch("/{config_id}", response_model=ApiResponse[DesktopConfigOut])
async def update_config(
config_id: int,
body: DesktopConfigUpdate,
_admin: AdminUser,
db: DbSession,
) -> ApiResponse[DesktopConfigOut]:
try:
row = await config_service.update_config(db, config_id, body)
except config_service.AdminDesktopConfigError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(
ok=True,
message="配置已更新",
data=DesktopConfigOut.model_validate(row),
)
@router.delete("/{config_id}", response_model=ApiResponse[None])
async def delete_config(
config_id: int,
_admin: AdminUser,
db: DbSession,
) -> ApiResponse[None]:
try:
await config_service.delete_config(db, config_id)
except config_service.AdminDesktopConfigError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(ok=True, message="配置已删除")

91
app/api/v1/admin_users.py Normal file
View File

@@ -0,0 +1,91 @@
"""管理员用户 CRUD。"""
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, DbSession
from app.schemas.admin_user import AdminUserCreate, AdminUserOut, AdminUserUpdate
from app.schemas.common import ApiResponse, PaginatedData
from app.services import admin_user as admin_user_service
router = APIRouter(prefix="/admin/users", tags=["管理-用户"])
@router.get("", response_model=ApiResponse[PaginatedData[AdminUserOut]])
async def list_users(
_admin: AdminUser,
db: DbSession,
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(
admin_user_service.DEFAULT_PAGE_SIZE,
ge=1,
le=admin_user_service.MAX_PAGE_SIZE,
description="每页条数",
),
) -> ApiResponse[PaginatedData[AdminUserOut]]:
users, total = await admin_user_service.list_users(db, page=page, page_size=page_size)
return ApiResponse(
ok=True,
message="",
data=PaginatedData.build(
[AdminUserOut.model_validate(u) for u in users],
total=total,
page=page,
page_size=page_size,
),
)
@router.post("", response_model=ApiResponse[AdminUserOut])
async def create_user(
body: AdminUserCreate,
admin: AdminUser,
db: DbSession,
) -> ApiResponse[AdminUserOut]:
try:
user = await admin_user_service.create_user(db, body)
except admin_user_service.AdminUserError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(
ok=True,
message="用户已创建",
data=AdminUserOut.model_validate(user),
)
@router.patch("/{user_id}", response_model=ApiResponse[AdminUserOut])
async def update_user(
user_id: int,
body: AdminUserUpdate,
admin: AdminUser,
db: DbSession,
) -> ApiResponse[AdminUserOut]:
try:
user = await admin_user_service.update_user(
db,
user_id,
body,
actor_id=admin.id,
)
except admin_user_service.AdminUserError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(
ok=True,
message="用户已更新",
data=AdminUserOut.model_validate(user),
)
@router.delete("/{user_id}", response_model=ApiResponse[None])
async def delete_user(
user_id: int,
admin: AdminUser,
db: DbSession,
) -> ApiResponse[None]:
try:
await admin_user_service.delete_user(db, user_id, actor_id=admin.id)
except admin_user_service.AdminUserError as exc:
return ApiResponse(ok=False, message=exc.message)
return ApiResponse(ok=True, message="用户已删除")

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

@@ -0,0 +1,19 @@
"""已登录用户拉取 desktop_configs 表全部键值。"""
from fastapi import APIRouter
from app.dependencies import CurrentUser, DbSession
from app.schemas.app_config import AppConfigMap
from app.schemas.common import ApiResponse
from app.services import desktop_config as desktop_config_service
router = APIRouter(prefix="/appConfig", tags=["appConfig"])
@router.get("", response_model=ApiResponse[AppConfigMap])
async def get_app_config(
_user: CurrentUser,
db: DbSession,
) -> ApiResponse[AppConfigMap]:
items = await desktop_config_service.list_all_as_map(db)
return ApiResponse(ok=True, message="", data=items)

View File

@@ -0,0 +1,47 @@
"""Node.js 流水线脚本:按文件名下发源码,供桌面端拉取后在 Node 子进程中执行。"""
import re
from pathlib import Path
from fastapi import APIRouter
from app.schemas.common import ApiResponse
router = APIRouter(prefix="/nodejs-scripts", tags=["nodejs"])
# pythonbackend/scripts/nodejs/*.js
_SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts" / "nodejs"
_SAFE_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*\.js$")
def _validate_name(name: str) -> str | None:
if not name or not _SAFE_NAME.fullmatch(name.strip()):
return None
return name.strip()
@router.get("", response_model=ApiResponse[dict])
async def get_nodejs_script(name: str) -> ApiResponse[dict]:
safe = _validate_name(name)
if not safe:
return ApiResponse(ok=False, message="无效的脚本名称", data=None)
path = _SCRIPTS_DIR / safe
try:
base_resolved = _SCRIPTS_DIR.resolve()
resolved = path.resolve()
resolved.relative_to(base_resolved)
except (OSError, ValueError):
return ApiResponse(ok=False, message="无效的脚本路径", data=None)
if not path.is_file():
return ApiResponse(ok=False, message=f"脚本不存在: {safe}", data=None)
source = path.read_text(encoding="utf-8")
return ApiResponse(ok=True, message="", data={"source": source})
@router.get("/list", response_model=ApiResponse[dict])
async def list_nodejs_scripts() -> ApiResponse[dict]:
if not _SCRIPTS_DIR.is_dir():
return ApiResponse(ok=True, message="", data={"names": []})
names = sorted(p.name for p in _SCRIPTS_DIR.glob("*.js") if p.is_file())
return ApiResponse(ok=True, message="", data={"names": names})

View File

@@ -1,7 +1,20 @@
from fastapi import APIRouter
from app.api.v1 import auth, quickjs_scripts
from app.api.v1 import (
admin_card_keys,
admin_desktop_configs,
admin_users,
app_config,
auth,
nodejs_scripts,
quickjs_scripts,
)
api_router = APIRouter()
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(app_config.router)
api_router.include_router(quickjs_scripts.router)
api_router.include_router(nodejs_scripts.router)

8
app/core/roles.py Normal file
View File

@@ -0,0 +1,8 @@
ROLE_ADMIN = 1
ROLE_AGENT = 2
ROLE_LABELS: dict[int, str] = {
0: "普通用户",
ROLE_ADMIN: "管理员",
ROLE_AGENT: "代理",
}

View File

@@ -4,6 +4,7 @@ import redis.asyncio as redis
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import ROLE_ADMIN
from app.core.security import decode_access_token
from app.database import get_db
from app.models.user import User
@@ -52,3 +53,15 @@ async def get_current_user(
CurrentUser = Annotated[User, Depends(get_current_user)]
async def require_admin(current_user: CurrentUser) -> User:
if current_user.role_id != ROLE_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限",
)
return current_user
AdminUser = Annotated[User, Depends(require_admin)]

View File

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

25
app/models/card_key.py Normal file
View File

@@ -0,0 +1,25 @@
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 CardKey(Base):
__tablename__ = "card_keys"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
serial_number: Mapped[str] = mapped_column(String(64), unique=True, index=True)
duration_days: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
activated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)

View File

@@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DesktopConfig(Base):
__tablename__ = "desktop_configs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
value: Mapped[str] = mapped_column(Text)
mark: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
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, String, func
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -10,14 +10,12 @@ class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
username: Mapped[str] = mapped_column(String(64), unique=True)
phone: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
password_hash: Mapped[str] = mapped_column(String(255))
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(),
role_id: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
vip_end_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
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

@@ -0,0 +1,64 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class CardKeyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
serial_number: str
duration_days: int
created_at: datetime
activated_at: datetime | None = None
user_id: int | None = None
username: str | None = None
remark: str | None = None
class CardKeyCreate(BaseModel):
duration_days: int = Field(ge=1, le=3650, description="有效天数")
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)
@field_validator("remark")
@classmethod
def normalize_remark(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
@field_validator("serial_number")
@classmethod
def normalize_serial(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip().upper()
return stripped or None
@model_validator(mode="after")
def serial_only_for_single(self) -> "CardKeyCreate":
if self.serial_number and self.count != 1:
raise ValueError("指定序列号时仅可生成 1 张卡密")
return self
class CardKeyUpdate(BaseModel):
duration_days: int | None = Field(default=None, ge=1, le=3650)
remark: str | None = Field(default=None, max_length=255)
@field_validator("remark")
@classmethod
def normalize_remark(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
class CardKeyBatchCreateResult(BaseModel):
items: list[CardKeyOut]
created_count: int

View File

@@ -0,0 +1,54 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class DesktopConfigOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
value: str
mark: str | None = None
created_at: datetime
updated_at: datetime
class DesktopConfigCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
value: str = Field(min_length=0)
mark: str | None = Field(default=None, max_length=255)
@field_validator("name")
@classmethod
def strip_name(cls, value: str) -> str:
return value.strip()
@field_validator("mark")
@classmethod
def normalize_mark(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
class DesktopConfigUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
value: str | None = None
mark: str | None = Field(default=None, max_length=255)
@field_validator("name")
@classmethod
def strip_name(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()
@field_validator("mark")
@classmethod
def normalize_mark(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None

60
app/schemas/admin_user.py Normal file
View File

@@ -0,0 +1,60 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class AdminUserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
phone: str | None = None
role_id: int
vip_end_time: datetime | None = None
created_at: datetime
updated_at: datetime
class AdminUserCreate(BaseModel):
username: str = Field(min_length=1, max_length=64)
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=2)
vip_end_time: datetime | None = None
@field_validator("username")
@classmethod
def strip_username(cls, value: str) -> str:
return value.strip()
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
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)
vip_end_time: datetime | None = None
clear_vip_end_time: bool = False
@field_validator("username")
@classmethod
def strip_username(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None

View File

@@ -0,0 +1,6 @@
"""桌面端应用配置name -> value 字典。"""
from typing import Dict
# GET /api/v1/appConfig 的 data 载荷类型
AppConfigMap = Dict[str, str]

View File

@@ -37,6 +37,7 @@ class UserPublic(BaseModel):
id: int
username: str
role_id: int
class TokenResponse(BaseModel):

View File

@@ -13,3 +13,31 @@ class ApiResponse(BaseModel, Generic[T]):
ok: bool
message: str = ""
data: T | None = None
class PaginatedData(BaseModel, Generic[T]):
"""分页列表载荷。"""
items: list[T]
total: int
page: int
page_size: int
total_pages: int
@classmethod
def build(
cls,
items: list[T],
*,
total: int,
page: int,
page_size: int,
) -> "PaginatedData[T]":
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
return cls(
items=items,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
)

View File

@@ -0,0 +1,128 @@
import secrets
import string
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.card_key import CardKey
from app.schemas.admin_card_key import CardKeyCreate, CardKeyUpdate
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
_SERIAL_ALPHABET = string.ascii_uppercase + string.digits
_SERIAL_LENGTH = 16
class AdminCardKeyError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
def _generate_serial() -> str:
return "".join(secrets.choice(_SERIAL_ALPHABET) for _ in range(_SERIAL_LENGTH))
async def _unique_serial(db: AsyncSession, preferred: str | None = None) -> str:
if preferred:
existing = await db.scalar(
select(CardKey.id).where(CardKey.serial_number == preferred)
)
if existing is not None:
raise AdminCardKeyError("序列号已存在")
return preferred
for _ in range(20):
candidate = _generate_serial()
existing = await db.scalar(
select(CardKey.id).where(CardKey.serial_number == candidate)
)
if existing is None:
return candidate
raise AdminCardKeyError("生成序列号失败,请重试")
async def list_card_keys(
db: AsyncSession,
*,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
status: str | 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))
count_stmt = select(func.count()).select_from(CardKey)
list_stmt = select(CardKey)
for clause in filters:
count_stmt = count_stmt.where(clause)
list_stmt = list_stmt.where(clause)
total = await db.scalar(count_stmt) or 0
result = await db.scalars(
list_stmt.order_by(CardKey.id.desc()).offset(offset).limit(page_size)
)
return list(result.all()), total
async def create_card_keys(db: AsyncSession, body: CardKeyCreate) -> list[CardKey]:
created: list[CardKey] = []
for index in range(body.count):
serial = await _unique_serial(
db,
body.serial_number if index == 0 else None,
)
card = CardKey(
serial_number=serial,
duration_days=body.duration_days,
remark=body.remark,
)
db.add(card)
created.append(card)
await db.flush()
for card in created:
await db.refresh(card)
return created
async def update_card_key(
db: AsyncSession,
card_id: int,
body: CardKeyUpdate,
) -> CardKey:
card = await db.get(CardKey, card_id)
if card is None:
raise AdminCardKeyError("卡密不存在")
if card.activated_at is not None:
if body.duration_days is not None:
raise AdminCardKeyError("已激活卡密不能修改时长")
elif body.duration_days is not None:
card.duration_days = body.duration_days
if body.remark is not None:
card.remark = body.remark
await db.flush()
await db.refresh(card)
return card
async def delete_card_key(db: AsyncSession, card_id: int) -> None:
card = await db.get(CardKey, card_id)
if card is None:
raise AdminCardKeyError("卡密不存在")
if card.activated_at is not None:
raise AdminCardKeyError("已激活卡密不能删除")
await db.delete(card)

View File

@@ -0,0 +1,88 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.desktopConfig import DesktopConfig
from app.schemas.admin_desktop_config import DesktopConfigCreate, DesktopConfigUpdate
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
class AdminDesktopConfigError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
async def list_configs(
db: AsyncSession,
*,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
keyword: str | None = None,
) -> tuple[list[DesktopConfig], int]:
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
page = max(page, 1)
offset = (page - 1) * page_size
filters = []
if keyword:
like = f"%{keyword.strip()}%"
filters.append(DesktopConfig.name.ilike(like))
count_stmt = select(func.count()).select_from(DesktopConfig)
list_stmt = select(DesktopConfig)
for clause in filters:
count_stmt = count_stmt.where(clause)
list_stmt = list_stmt.where(clause)
total = await db.scalar(count_stmt) or 0
result = await db.scalars(
list_stmt.order_by(DesktopConfig.name.asc()).offset(offset).limit(page_size)
)
return list(result.all()), total
async def create_config(db: AsyncSession, body: DesktopConfigCreate) -> DesktopConfig:
existing = await db.scalar(select(DesktopConfig).where(DesktopConfig.name == body.name))
if existing is not None:
raise AdminDesktopConfigError("配置键名已存在")
row = DesktopConfig(name=body.name, value=body.value, mark=body.mark)
db.add(row)
await db.flush()
await db.refresh(row)
return row
async def update_config(
db: AsyncSession,
config_id: int,
body: DesktopConfigUpdate,
) -> DesktopConfig:
row = await db.get(DesktopConfig, config_id)
if row is None:
raise AdminDesktopConfigError("配置不存在")
if body.name is not None and body.name != row.name:
taken = await db.scalar(select(DesktopConfig).where(DesktopConfig.name == body.name))
if taken is not None:
raise AdminDesktopConfigError("配置键名已存在")
row.name = body.name
if body.value is not None:
row.value = body.value
if body.mark is not None:
row.mark = body.mark
await db.flush()
await db.refresh(row)
return row
async def delete_config(db: AsyncSession, config_id: int) -> None:
row = await db.get(DesktopConfig, config_id)
if row is None:
raise AdminDesktopConfigError("配置不存在")
await db.delete(row)

109
app/services/admin_user.py Normal file
View File

@@ -0,0 +1,109 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import ROLE_ADMIN
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
class AdminUserError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
async def list_users(
db: AsyncSession,
*,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
) -> 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)
)
return list(result.all()), total
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:
raise AdminUserError("用户名已存在")
if body.phone:
phone_taken = await db.scalar(select(User).where(User.phone == body.phone))
if phone_taken is not None:
raise AdminUserError("手机号已被使用")
user = User(
username=body.username,
password_hash=hash_password(body.password),
phone=body.phone,
role_id=body.role_id,
vip_end_time=body.vip_end_time,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
async def update_user(
db: AsyncSession,
user_id: int,
body: AdminUserUpdate,
*,
actor_id: int,
) -> User:
user = await db.get(User, user_id)
if user is None:
raise AdminUserError("用户不存在")
if body.username is not None and body.username != user.username:
taken = await db.scalar(select(User).where(User.username == body.username))
if taken is not None:
raise AdminUserError("用户名已存在")
user.username = body.username
if body.phone is not None:
if body.phone != user.phone:
taken = await db.scalar(select(User).where(User.phone == body.phone))
if taken is not None:
raise AdminUserError("手机号已被使用")
user.phone = body.phone
if body.role_id is not None:
if user_id == actor_id and body.role_id != ROLE_ADMIN:
raise AdminUserError("不能修改自己的管理员角色")
user.role_id = body.role_id
if body.password:
user.password_hash = hash_password(body.password)
if body.clear_vip_end_time:
user.vip_end_time = None
elif body.vip_end_time is not None:
user.vip_end_time = body.vip_end_time
await db.flush()
await db.refresh(user)
return user
async def delete_user(db: AsyncSession, user_id: int, *, actor_id: int) -> None:
if user_id == actor_id:
raise AdminUserError("不能删除当前登录账号")
user = await db.get(User, user_id)
if user is None:
raise AdminUserError("用户不存在")
await db.delete(user)

View File

@@ -30,7 +30,12 @@ async def register_user(
if existing is not None:
raise AuthError("用户名已存在")
user = User(username=username, password_hash=hash_password(password))
user = User(
username=username,
password_hash=hash_password(password),
role_id=0,
phone=None,
)
db.add(user)
await db.flush()
await db.refresh(user)

View File

@@ -0,0 +1,12 @@
"""desktop_configs 表:键值配置。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.desktopConfig import DesktopConfig
async def list_all_as_map(db: AsyncSession) -> dict[str, str]:
result = await db.execute(select(DesktopConfig))
rows = result.scalars().all()
return {row.name: row.value for row in rows}