22
This commit is contained in:
103
app/api/v1/admin_card_keys.py
Normal file
103
app/api/v1/admin_card_keys.py
Normal 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="卡密已删除")
|
||||
95
app/api/v1/admin_desktop_configs.py
Normal file
95
app/api/v1/admin_desktop_configs.py
Normal 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
91
app/api/v1/admin_users.py
Normal 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
19
app/api/v1/app_config.py
Normal 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)
|
||||
47
app/api/v1/nodejs_scripts.py
Normal file
47
app/api/v1/nodejs_scripts.py
Normal 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})
|
||||
@@ -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
8
app/core/roles.py
Normal file
@@ -0,0 +1,8 @@
|
||||
ROLE_ADMIN = 1
|
||||
ROLE_AGENT = 2
|
||||
|
||||
ROLE_LABELS: dict[int, str] = {
|
||||
0: "普通用户",
|
||||
ROLE_ADMIN: "管理员",
|
||||
ROLE_AGENT: "代理",
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
25
app/models/card_key.py
Normal 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)
|
||||
17
app/models/desktopConfig.py
Normal file
17
app/models/desktopConfig.py
Normal 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())
|
||||
@@ -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())
|
||||
|
||||
64
app/schemas/admin_card_key.py
Normal file
64
app/schemas/admin_card_key.py
Normal 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
|
||||
54
app/schemas/admin_desktop_config.py
Normal file
54
app/schemas/admin_desktop_config.py
Normal 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
60
app/schemas/admin_user.py
Normal 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
|
||||
6
app/schemas/app_config.py
Normal file
6
app/schemas/app_config.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""桌面端应用配置:name -> value 字典。"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
# GET /api/v1/appConfig 的 data 载荷类型
|
||||
AppConfigMap = Dict[str, str]
|
||||
@@ -37,6 +37,7 @@ class UserPublic(BaseModel):
|
||||
|
||||
id: int
|
||||
username: str
|
||||
role_id: int
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
128
app/services/admin_card_key.py
Normal file
128
app/services/admin_card_key.py
Normal 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)
|
||||
88
app/services/admin_desktop_config.py
Normal file
88
app/services/admin_desktop_config.py
Normal 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
109
app/services/admin_user.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
12
app/services/desktop_config.py
Normal file
12
app/services/desktop_config.py
Normal 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}
|
||||
Reference in New Issue
Block a user