feat: add FastAPI manager API compatibility baseline

This commit is contained in:
Tyke Chen
2026-07-20 17:00:13 +08:00
parent 7c58fa37b2
commit 804ddb51f2
140 changed files with 47169 additions and 1 deletions
@@ -0,0 +1,30 @@
"""HTTP routers grouped by the Java business domains."""
from fastapi import APIRouter
def application_routers() -> list[APIRouter]:
"""Return every migrated business router; imports stay explicit for coverage auditing."""
from app.routers.agent import router as agent_router
from app.routers.config import config_router
from app.routers.correctword import correctword_router
from app.routers.device import device_router
from app.routers.knowledge import knowledge_router
from app.routers.model import model_router
from app.routers.security import security_router
from app.routers.sys import sys_router
from app.routers.timbre import timbre_router
from app.routers.voiceclone import voiceclone_router
return [
security_router,
sys_router,
config_router,
agent_router,
device_router,
voiceclone_router,
model_router,
timbre_router,
correctword_router,
knowledge_router,
]
@@ -0,0 +1,386 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from app.core.database import get_db
from app.core.errors import ErrorCode
from app.core.responses import JavaJSONResponse, error_response, ok
from app.core.security import AuthUser, require_normal, require_super_admin
from app.schemas.agent import (
AgentChatHistoryReport,
AgentCreate,
AgentMemory,
AgentSnapshotPage,
AgentSnapshotRestore,
AgentTagAssignment,
AgentTemplate,
AgentUpdate,
AgentVoicePrintSave,
AgentVoicePrintUpdate,
)
from app.services.agent import AgentService, run_chat_summary_task
router = APIRouter(tags=["agent"])
DbSession = Annotated[AsyncSession, Depends(get_db)]
NormalUser = Annotated[AuthUser, Depends(require_normal)]
SuperUser = Annotated[AuthUser, Depends(require_super_admin)]
def _service(session: AsyncSession, user: AuthUser | None, request: Request) -> AgentService:
return AgentService(session, user, language=request.headers.get("Accept-Language"))
@router.post("/agent/chat-history/report")
async def report_chat_history(report: AgentChatHistoryReport, request: Request, session: DbSession) -> JavaJSONResponse:
return ok(await _service(session, None, request).report_chat(report))
@router.post("/agent/chat-history/getDownloadUrl/{agentId}/{sessionId}")
async def issue_chat_history_download(
agentId: str, sessionId: str, request: Request, session: DbSession, user: NormalUser
) -> JavaJSONResponse:
service = _service(session, user, request)
if not await service.has_agent_permission(agentId):
return error_response(request, 10132)
return ok(await service.issue_history_token(agentId, sessionId))
@router.get("/agent/chat-history/download/{uuid}/current")
async def download_current_chat_history(uuid: str, request: Request, session: DbSession) -> Response:
content = await _service(session, None, request).consume_history_download(uuid, previous=False)
return Response(
content.encode("utf-8"),
media_type="text/plain;charset=UTF-8",
headers={"Content-Disposition": "attachment;filename=history.txt"},
)
@router.get("/agent/chat-history/download/{uuid}/previous")
async def download_previous_chat_history(uuid: str, request: Request, session: DbSession) -> Response:
content = await _service(session, None, request).consume_history_download(uuid, previous=True)
return Response(
content.encode("utf-8"),
media_type="text/plain;charset=UTF-8",
headers={"Content-Disposition": "attachment;filename=history.txt"},
)
# Static paths are deliberately registered before /agent/{id}; Starlette resolves in declaration order.
@router.get("/agent/template/page")
async def template_page(
request: Request,
session: DbSession,
user: SuperUser,
page: int = Query(default=1),
limit: int = Query(default=10),
agentName: str | None = Query(default=None),
) -> JavaJSONResponse:
return ok(await _service(session, user, request).template_page(page, limit, agentName))
@router.post("/agent/template/batch-remove")
async def batch_delete_templates(
ids: list[str], request: Request, session: DbSession, user: SuperUser
) -> JavaJSONResponse:
deleted = await _service(session, user, request).batch_delete_templates(ids)
return (
ok("批量删除成功") if deleted else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "批量删除模板失败")
)
@router.get("/agent/template/{id}")
async def template_detail(id: str, request: Request, session: DbSession, user: SuperUser) -> JavaJSONResponse:
result = await _service(session, user, request).template_detail(id)
return ok(result) if result is not None else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "模板不存在")
@router.post("/agent/template")
async def create_template(
template: AgentTemplate, request: Request, session: DbSession, user: SuperUser
) -> JavaJSONResponse:
return ok(await _service(session, user, request).create_template(template))
@router.put("/agent/template")
async def update_template(
template: AgentTemplate, request: Request, session: DbSession, user: SuperUser
) -> JavaJSONResponse:
# MyBatis-Plus raises before returning a boolean when updateById receives
# an entity without its @TableId. Keep Java's generic error envelope for
# that exact input; an unknown but non-empty id still returns the controller's
# explicit "更新模板失败" message below.
if template.id is None:
return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR)
updated = await _service(session, user, request).update_template(template)
return ok(template) if updated else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "更新模板失败")
@router.delete("/agent/template/{id}")
async def delete_template(id: str, request: Request, session: DbSession, user: SuperUser) -> JavaJSONResponse:
service = _service(session, user, request)
if await service.template_detail(id) is None:
return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "模板不存在")
return (
ok("删除模板成功")
if await service.delete_template(id)
else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "删除模板失败")
)
@router.post("/agent/voice-print")
async def create_voiceprint(
dto: AgentVoicePrintSave, request: Request, session: DbSession, user: NormalUser
) -> JavaJSONResponse:
created = await _service(session, user, request).create_voiceprint(dto)
return ok() if created else error_response(request, 10057)
@router.put("/agent/voice-print")
async def update_voiceprint(
dto: AgentVoicePrintUpdate, request: Request, session: DbSession, user: NormalUser
) -> JavaJSONResponse:
updated = await _service(session, user, request).update_voiceprint(dto)
return ok() if updated else error_response(request, 10058)
@router.delete("/agent/voice-print/{id}")
async def delete_voiceprint(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
deleted = await _service(session, user, request).delete_voiceprint(id)
return ok() if deleted else error_response(request, 10059)
@router.get("/agent/voice-print/list/{id}")
async def list_voiceprints(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
return ok(await _service(session, user, request).voiceprint_list(id))
@router.get("/agent/mcp/address/{agentId}")
async def mcp_address(agentId: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
service = _service(session, user, request)
if not await service.has_agent_permission(agentId):
return error_response(request, 10200)
address = await service.mcp_address(agentId)
return ok(address) if address is not None else error_response(request, 10201)
@router.get("/agent/mcp/tools/{agentId}")
async def mcp_tools(agentId: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
service = _service(session, user, request)
if not await service.has_agent_permission(agentId):
return error_response(request, 10202)
return ok(await service.mcp_tools(agentId))
@router.get("/agent/tag/list")
async def all_tags(request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
return ok(await _service(session, user, request).all_tags())
@router.post("/agent/tag")
async def create_tag(
request: Request,
session: DbSession,
user: NormalUser,
params: dict[str, str] = Body(...),
) -> JavaJSONResponse:
tag_name = params.get("tagName")
if tag_name is None or not tag_name.strip():
return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "标签名称不能为空")
return ok(await _service(session, user, request).save_tag(tag_name))
@router.delete("/agent/tag/{id}")
async def delete_tag(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
await _service(session, user, request).delete_tag(id)
return ok()
@router.post("/agent/audio/{audioId}")
async def issue_audio_token(audioId: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
token = await _service(session, user, request).issue_audio_token(audioId)
return ok(token) if token is not None else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "音频不存在")
@router.get("/agent/play/{uuid}")
async def play_agent_audio(uuid: str, request: Request, session: DbSession) -> Response:
audio = await _service(session, None, request).consume_audio_token(uuid)
if audio is None:
return Response(status_code=404)
return Response(
audio,
media_type="application/octet-stream",
headers={"Content-Disposition": 'attachment; filename="play.wav"'},
)
@router.put("/agent/saveMemory/{macAddress}")
async def update_memory(
macAddress: str, dto: AgentMemory, request: Request, session: DbSession, user: NormalUser
) -> JavaJSONResponse:
await _service(session, user, request).update_memory_by_mac(macAddress, dto)
return ok()
@router.post("/agent/chat-summary/{sessionId}/save")
async def save_chat_summary(
sessionId: str, background_tasks: BackgroundTasks, request: Request, session: DbSession
) -> JavaJSONResponse:
await _service(session, None, request).session_agent(sessionId)
background_tasks.add_task(run_chat_summary_task, sessionId)
return ok()
@router.post("/agent/chat-title/{sessionId}/generate")
async def generate_chat_title(sessionId: str, request: Request, session: DbSession) -> JavaJSONResponse:
service = _service(session, None, request)
await service.session_agent(sessionId)
await service.generate_chat_title(sessionId)
return ok()
@router.get("/agent/all")
async def admin_agent_list(
request: Request,
session: DbSession,
user: SuperUser,
page: int = Query(default=1),
limit: int = Query(default=10),
orderField: str | None = Query(default=None),
order: str | None = Query(default=None),
) -> JavaJSONResponse:
return ok(await _service(session, user, request).admin_agents(page, limit, orderField, order))
@router.get("/agent/list")
async def user_agent_list(
request: Request,
session: DbSession,
user: NormalUser,
keyword: str | None = Query(default=None),
searchType: str = Query(default="name"),
) -> JavaJSONResponse:
del searchType # Java accepts the parameter but the consolidated implementation ignores it.
return ok(await _service(session, user, request).user_agents(keyword))
@router.get("/agent/template")
async def template_list(request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
return ok(await _service(session, user, request).templates())
@router.get("/agent/{agentId}/snapshots")
async def snapshot_page(
agentId: str,
request: Request,
session: DbSession,
user: NormalUser,
page: int | None = Query(default=1),
limit: int | None = Query(default=10),
maxVersionNo: int | None = Query(default=None),
) -> JavaJSONResponse:
params = AgentSnapshotPage(page=page, limit=limit, max_version_no=maxVersionNo)
return ok(await _service(session, user, request).snapshot_page(agentId, params))
@router.get("/agent/{agentId}/snapshots/{snapshotId}")
async def snapshot_detail(
agentId: str, snapshotId: str, request: Request, session: DbSession, user: NormalUser
) -> JavaJSONResponse:
return ok(await _service(session, user, request).snapshot_detail(agentId, snapshotId))
@router.post("/agent/{agentId}/snapshots/{snapshotId}/restore")
async def restore_snapshot(
agentId: str,
snapshotId: str,
dto: AgentSnapshotRestore,
request: Request,
session: DbSession,
user: NormalUser,
) -> JavaJSONResponse:
await _service(session, user, request).restore_snapshot(agentId, snapshotId, dto.current_state_token)
return ok()
@router.delete("/agent/{agentId}/snapshots/{snapshotId}")
async def delete_snapshot(
agentId: str, snapshotId: str, request: Request, session: DbSession, user: NormalUser
) -> JavaJSONResponse:
await _service(session, user, request).delete_snapshot(agentId, snapshotId)
return ok()
@router.get("/agent/{id}/sessions")
async def agent_sessions(
id: str,
request: Request,
session: DbSession,
user: NormalUser,
page: str | None = Query(default=None),
limit: str | None = Query(default=None),
) -> JavaJSONResponse:
return ok(await _service(session, user, request).sessions(id, page, limit))
@router.get("/agent/{id}/chat-history/user")
async def recent_agent_history(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
service = _service(session, user, request)
if not await service.has_agent_permission(id):
return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "没有权限查看该智能体的聊天记录")
return ok(await service.recent_user_history(id))
@router.get("/agent/{id}/chat-history/audio")
async def agent_audio_content(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
return ok(await _service(session, user, request).audio_content(id))
@router.get("/agent/{id}/chat-history/{sessionId}")
async def agent_history(
id: str, sessionId: str, request: Request, session: DbSession, user: NormalUser
) -> JavaJSONResponse:
service = _service(session, user, request)
if not await service.has_agent_permission(id):
return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "没有权限查看该智能体的聊天记录")
return ok(await service.history(id, sessionId))
@router.get("/agent/{id}/tags")
async def agent_tags(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
return ok(await _service(session, user, request).agent_tags(id))
@router.put("/agent/{id}/tags")
async def save_agent_tags(
id: str, dto: AgentTagAssignment, request: Request, session: DbSession, user: NormalUser
) -> JavaJSONResponse:
await _service(session, user, request).save_agent_tags(id, dto.tag_ids, dto.tag_names)
return ok()
@router.post("/agent")
async def create_agent(dto: AgentCreate, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
return ok(await _service(session, user, request).create_agent(dto))
@router.put("/agent/{id}")
async def update_agent(
id: str, dto: AgentUpdate, request: Request, session: DbSession, user: NormalUser
) -> JavaJSONResponse:
await _service(session, user, request).update_agent(id, dto)
return ok()
@router.delete("/agent/{id}")
async def delete_agent(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
await _service(session, user, request).delete_agent(id)
return ok()
@router.get("/agent/{id}")
async def agent_detail(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse:
return ok(await _service(session, user, request).agent_detail(id))
@@ -0,0 +1,34 @@
# ruff: noqa: B008
# FastAPI evaluates dependency marker defaults intentionally when registering routes.
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.responses import JavaJSONResponse, ok
from app.core.serialization import preserve_java_map_keys
from app.repositories.config import ConfigRepository
from app.schemas.config import AgentModelsRequest, CorrectWordsRequest
from app.services.config import ConfigService
config_router = APIRouter()
def _service(session: AsyncSession) -> ConfigService:
return ConfigService(ConfigRepository(session))
@config_router.post("/config/server-base")
async def server_base(session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
return ok(preserve_java_map_keys(await _service(session).get_config(use_cache=True)))
@config_router.post("/config/agent-models")
async def agent_models(dto: AgentModelsRequest, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
return ok(preserve_java_map_keys(await _service(session).get_agent_models(dto.mac_address, dto.selected_module)))
@config_router.post("/config/correct-words")
async def correct_words(dto: CorrectWordsRequest, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
return ok(await _service(session).get_correct_words(dto.mac_address))
@@ -0,0 +1,97 @@
from __future__ import annotations
from urllib.parse import quote
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from app.core.database import get_db
from app.core.responses import JavaJSONResponse, ok
from app.core.security import require_normal
from app.repositories.correctword import CorrectWordRepository
from app.schemas.correctword import CorrectWordFileBody
from app.services.correctword import CorrectWordService
correctword_router = APIRouter()
def _java_urlencode(value: str) -> str:
# java.net.URLEncoder leaves alphanumerics plus .-*_ unescaped, encodes
# spaces as '+', and encodes '~'. The controller then replaces '+' with
# '%20'. urllib always leaves '~', so handle that final difference here.
return quote(value, safe="*.-_").replace("~", "%7E")
def _service(session: AsyncSession) -> CorrectWordService:
return CorrectWordService(CorrectWordRepository(session))
@correctword_router.post("/correct-word/file")
async def create_file(
body: CorrectWordFileBody, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
return ok(await _service(session).create(body, require_normal(request)))
@correctword_router.put("/correct-word/file/{file_id}")
async def update_file(
file_id: str,
body: CorrectWordFileBody,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _service(session).update(file_id, body, require_normal(request))
return ok()
@correctword_router.get("/correct-word/file/list")
async def list_files(
request: Request,
page: str | None = None,
limit: str | None = None,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
return ok(await _service(session).page(require_normal(request), page, limit))
@correctword_router.get("/correct-word/file/select")
async def select_files(request: Request, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
return ok(await _service(session).all(require_normal(request)))
@correctword_router.get("/correct-word/file/download/{file_id}")
async def download_file(
file_id: str, request: Request, session: AsyncSession = Depends(get_db)
) -> Response:
require_normal(request)
item = await _service(session).get(file_id)
if item is None or not item["content"]:
return Response(status_code=404)
body = "\n".join(item["content"]).encode("utf-8")
file_name = str(item["fileName"])
ascii_name = "".join(character if ord(character) < 128 else "_" for character in file_name)
disposition = f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{_java_urlencode(file_name)}"
return Response(
body,
media_type="application/octet-stream",
headers={"Content-Disposition": disposition, "Content-Length": str(len(body))},
)
@correctword_router.delete("/correct-word/file/{file_id}")
async def delete_file(
file_id: str, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
require_normal(request)
await _service(session).delete([file_id])
return ok()
@correctword_router.post("/correct-word/file/batch-delete")
async def batch_delete_files(
file_ids: list[str], request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
require_normal(request)
await _service(session).delete(file_ids)
return ok()
@@ -0,0 +1,505 @@
from __future__ import annotations
import json
from typing import Annotated, Any
from fastapi import APIRouter, BackgroundTasks, Depends, File, Header, Query, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from app.core.database import get_db
from app.core.i18n import resolve_language
from app.core.responses import JavaJSONResponse, envelope, error_response, ok
from app.core.security import require_normal, require_super_admin
from app.schemas.device import (
DeviceAddressBookAliasRequest,
DeviceAddressBookPermissionRequest,
DeviceManualAddRequest,
DeviceRegisterRequest,
DeviceReportRequest,
DeviceToolCallRequest,
DeviceUnbindRequest,
DeviceUpdateRequest,
OtaRecord,
)
from app.services.device import MAC_PATTERN, DeviceService, is_blank
device_router = APIRouter()
SessionDep = Annotated[AsyncSession, Depends(get_db)]
FirmwareUpload = Annotated[UploadFile, File()]
CallerMacQuery = Annotated[str, Query(alias="callerMac")]
DeviceIdHeader = Annotated[str | None, Header(alias="Device-Id")]
ClientIdHeader = Annotated[str | None, Header(alias="Client-Id")]
def _query_map(request: Request) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in request.query_params.multi_items():
if key in result:
previous = result[key]
result[key] = [*previous, value] if isinstance(previous, list) else [previous, value]
else:
result[key] = value
return result
def _raw_ota(payload: dict[str, Any]) -> Response:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return Response(
body,
status_code=200,
media_type="application/json",
headers={"Content-Length": str(len(body))},
)
@device_router.post("/device/bind/{agent_id}/{device_code}")
async def bind_device(
agent_id: str,
device_code: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
await DeviceService(session).activate_bound_device(agent_id=agent_id, activation_code=device_code, user=user)
return ok()
@device_router.post("/device/register")
async def register_device(
body: DeviceRegisterRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_normal(request)
if is_blank(body.mac_address):
return error_response(request, 10175)
return ok(await DeviceService(session).register_device(body.mac_address or ""))
@device_router.get("/device/bind/{agent_id}")
async def get_bound_devices(
agent_id: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
return ok(await DeviceService(session).list_user_devices(user.id, agent_id))
@device_router.post("/device/bind/{agent_id}")
async def device_online(
agent_id: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
await request.body()
try:
return ok(await DeviceService(session).get_online_data(agent_id, user))
except Exception as exc:
return error_response(request, 500, f"转发请求失败: {exc}")
@device_router.post("/device/unbind")
async def unbind_device(
body: DeviceUnbindRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
# DeviceController does not apply @Valid to DeviceUnBindDTO. An empty
# object reaches the service with a null id and is a successful no-op.
await DeviceService(session).unbind(user_id=user.id, device_id=body.device_id or "")
return ok()
@device_router.put("/device/update/{device_id}")
async def update_device(
device_id: str,
body: DeviceUpdateRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
validation = _validate_device_update(body, request.headers.get("Accept-Language"))
if validation is not None:
return error_response(request, 10034, validation)
if not await DeviceService(session).update_device(device_id=device_id, request=body, user=user):
return error_response(request, 500, "设备不存在")
return ok()
@device_router.put("/user/configDevice/{device_id}")
async def configure_device(
device_id: str,
body: DeviceUpdateRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
validation = _validate_device_update(body, request.headers.get("Accept-Language"))
if validation is not None:
return error_response(request, 10034, validation)
if not await DeviceService(session).update_device(device_id=device_id, request=body, user=user):
return error_response(request, 500, "设备不存在")
return ok()
@device_router.post("/device/manual-add")
async def manual_add_device(
body: DeviceManualAddRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
await DeviceService(session).manual_add(request=body, user=user)
return ok()
@device_router.post("/device/tools/list/{device_id}")
async def list_device_tools(
device_id: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
tools = await DeviceService(session).get_tools(device_id=device_id, user=user)
if tools is None:
return error_response(request, 10194)
return ok(tools)
@device_router.post("/device/tools/call/{device_id}")
async def call_device_tool(
device_id: str,
body: DeviceToolCallRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
if is_blank(body.name):
return error_response(request, 10034, "工具名称不能为空")
result = await DeviceService(session).call_tool(
device_id=device_id,
tool_name=body.name or "",
arguments=body.arguments,
user=user,
)
if result is None:
return error_response(request, 10194)
return JavaJSONResponse(envelope(result, msg="Tools called successfully"))
# Static address-book paths deliberately precede /address-book/{mac_address}.
@device_router.get("/device/address-book/call")
async def call_address_book(
request: Request,
session: SessionDep,
caller_mac: CallerMacQuery,
nickname: str,
answer: bool = False,
) -> JavaJSONResponse:
result = await DeviceService(session).call_by_nickname(
caller_mac=caller_mac,
nickname=nickname,
answer=answer,
)
return ok(result)
@device_router.get("/device/address-book/lookup")
async def lookup_address_book(
request: Request,
session: SessionDep,
caller_mac: CallerMacQuery,
nickname: str,
) -> JavaJSONResponse:
result = await DeviceService(session).lookup_address_book(caller_mac=caller_mac, nickname=nickname)
if result is None:
return error_response(request, 500, "未找到对应设备")
return ok(result)
@device_router.put("/device/address-book/alias")
async def update_address_alias(
body: DeviceAddressBookAliasRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
if is_blank(body.target_mac):
return error_response(request, 10034, "目标MAC地址不能为空")
if is_blank(body.mac_address):
return error_response(request, 10034, "MAC地址不能为空")
service = DeviceService(session)
caller = await service.repository.get_device_by_mac(body.mac_address or "")
if caller is None or int(caller.get("user_id") or -1) != user.id:
return error_response(request, 500, "无权限操作该设备")
await service.save_address_book(
mac_address=body.mac_address or "",
target_mac=body.target_mac or "",
alias=body.alias,
has_permission=None,
actor=user.id,
)
return ok()
@device_router.put("/device/address-book/permission")
async def update_address_permission(
body: DeviceAddressBookPermissionRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
if is_blank(body.mac_address):
return error_response(request, 10034, "MAC地址不能为空")
if is_blank(body.target_mac):
return error_response(request, 10034, "目标MAC地址不能为空")
service = DeviceService(session)
caller = await service.repository.get_device_by_mac(body.mac_address or "")
if caller is None or int(caller.get("user_id") or -1) != user.id:
return error_response(request, 500, "无权限操作该设备")
await service.save_address_book(
mac_address=body.mac_address or "",
target_mac=body.target_mac or "",
alias=None,
has_permission=body.has_permission,
actor=user.id,
)
return ok()
@device_router.get("/device/address-book/{mac_address}")
async def get_address_book(
mac_address: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_normal(request)
return ok(await DeviceService(session).address_book(mac_address))
@device_router.post("/ota/")
async def check_ota_version(
report: DeviceReportRequest,
request: Request,
session: SessionDep,
background_tasks: BackgroundTasks,
device_id: DeviceIdHeader = None,
client_id: ClientIdHeader = None,
) -> Response:
if is_blank(device_id):
# Java's required @RequestHeader fails before the controller's blank
# guard and is translated by its global handler into this envelope.
return error_response(request, 500)
if MAC_PATTERN.fullmatch(device_id or "") is None:
return _raw_ota({"error": "Invalid device ID"})
selected_client = device_id if is_blank(client_id) else client_id
client_ip = request.client.host if request.client is not None else "unknown"
service = DeviceService(session)
def defer_connection_update(device: str, agent: str | None, version: str | None) -> None:
background_tasks.add_task(
DeviceService.persist_connection_update_background,
device,
agent,
version,
)
payload = await service.check_ota(
device_id=device_id or "",
client_id=selected_client or device_id or "",
report=report,
request_url=str(request.url),
client_ip=client_ip,
defer_connection_update=defer_connection_update,
)
return _raw_ota(payload)
@device_router.post("/ota/activate")
async def activate_ota_device(
request: Request,
session: SessionDep,
device_id: DeviceIdHeader = None,
client_id: ClientIdHeader = None,
) -> Response:
del client_id
if is_blank(device_id):
return error_response(request, 500)
if await DeviceService(session).repository.get_device_by_mac(device_id or "") is None:
return Response(status_code=202)
return Response("success", media_type="text/plain;charset=UTF-8")
@device_router.get("/ota/")
async def ota_health(session: SessionDep) -> Response:
return Response(
await DeviceService(session).ota_health_text(),
media_type="text/plain;charset=UTF-8",
)
# Static otaMag paths deliberately precede /otaMag/{id}.
@device_router.get("/otaMag/getDownloadUrl/{ota_id}")
async def get_ota_download_url(
ota_id: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await DeviceService(session).create_ota_download_id(ota_id))
@device_router.get("/otaMag/download/{download_id}")
async def download_ota(download_id: str, session: SessionDep) -> Response:
resolved = await DeviceService(session).resolve_ota_download(download_id)
if resolved is None:
return Response(status_code=404)
path, filename = resolved
try:
content = path.read_bytes()
except OSError:
return Response(status_code=500)
return Response(
content,
media_type="application/octet-stream",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(content)),
},
)
@device_router.post("/otaMag/upload")
async def upload_firmware(
request: Request,
file: FirmwareUpload,
session: SessionDep,
) -> JavaJSONResponse:
require_super_admin(request)
service = DeviceService(session)
try:
content = await file.read()
return ok(await service.save_firmware_file(filename=file.filename, content=content))
except ValueError as exc:
return error_response(request, 500, str(exc))
except OSError as exc:
return error_response(request, 500, f"文件上传失败:{exc}")
@device_router.post("/otaMag/uploadAssetsBin")
async def upload_assets_firmware(
request: Request,
file: FirmwareUpload,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
service = DeviceService(session)
try:
content = await file.read()
return ok(await service.save_assets_file(filename=file.filename, content=content, user=user))
except ValueError as exc:
return error_response(request, 500, str(exc))
except OSError as exc:
return error_response(request, 500, f"文件上传失败:{exc}")
@device_router.get("/otaMag")
async def page_ota(
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await DeviceService(session).ota_page(_query_map(request)))
@device_router.get("/otaMag/{ota_id}")
async def get_ota(
ota_id: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await DeviceService(session).get_ota_record(ota_id))
@device_router.post("/otaMag")
async def save_ota(
request: Request,
session: SessionDep,
record: OtaRecord | None = None,
) -> JavaJSONResponse:
user = require_super_admin(request)
if record is None:
return error_response(request, 500, "固件信息不能为空")
if is_blank(record.firmware_name):
return error_response(request, 500, "固件名称不能为空")
if is_blank(record.type):
return error_response(request, 500, "固件类型不能为空")
if is_blank(record.version):
return error_response(request, 500, "版本号不能为空")
try:
await DeviceService(session).save_ota(record, user)
return ok()
except RuntimeError as exc:
return error_response(request, 500, str(exc))
@device_router.delete("/otaMag/{ota_id}")
async def delete_ota(
ota_id: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_super_admin(request)
ids = ota_id.split(",") if ota_id else []
if not ids:
return error_response(request, 500, "删除的固件ID不能为空")
await DeviceService(session).delete_ota(ids)
return ok()
@device_router.put("/otaMag/{ota_id}")
async def update_ota(
ota_id: str,
request: Request,
session: SessionDep,
record: OtaRecord | None = None,
) -> JavaJSONResponse:
user = require_super_admin(request)
if record is None:
return error_response(request, 500, "固件信息不能为空")
try:
await DeviceService(session).update_ota(ota_id, record, user)
return ok()
except RuntimeError as exc:
return error_response(request, 500, str(exc))
def _validate_device_update(body: DeviceUpdateRequest, accept_language: str | None) -> str | None:
language = resolve_language(accept_language)
if body.auto_update is not None and body.auto_update < 0:
return {
"zh-CN": "最小不能小于0",
"zh-TW": "必須大於或等於 0",
"de-DE": "muss größer-gleich 0 sein",
"pt-BR": "deve ser maior que ou igual à 0",
}.get(language, "must be greater than or equal to 0")
if body.auto_update is not None and body.auto_update > 1:
return {
"zh-CN": "最大不能超过1",
"zh-TW": "必須小於或等於 1",
"de-DE": "muss kleiner-gleich 1 sein",
"pt-BR": "deve ser menor que ou igual à 1",
}.get(language, "must be less than or equal to 1")
if body.alias is not None and len(body.alias.encode("utf-16-le")) // 2 > 64:
return {
"zh-CN": "个数必须在0和64之间",
"zh-TW": "大小必須在 0 和 64 之間",
"de-DE": "Größe muss zwischen 0 und 64 sein",
"pt-BR": "tamanho deve ser entre 0 e 64",
}.get(language, "size must be between 0 and 64")
return None
@@ -0,0 +1,267 @@
from __future__ import annotations
import json
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.errors import AppError
from app.core.responses import JavaJSONResponse, envelope, ok
from app.core.security import require_normal
from app.repositories.knowledge import KnowledgeRepository
from app.schemas.knowledge import DocumentBatchBody, KnowledgeBaseBody, RetrievalBody
from app.services.knowledge import KnowledgeBaseService, KnowledgeDocumentService, dataset_dto
knowledge_router = APIRouter()
def _base(session: AsyncSession) -> KnowledgeBaseService:
return KnowledgeBaseService(KnowledgeRepository(session))
def _documents(session: AsyncSession) -> KnowledgeDocumentService:
return KnowledgeDocumentService(KnowledgeRepository(session))
@knowledge_router.get("/datasets/rag-models")
async def rag_models(request: Request, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
require_normal(request)
return ok(await _base(session).rag_models())
@knowledge_router.delete("/datasets/batch")
async def delete_datasets_batch(
request: Request, ids: str = Query(), session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
user = require_normal(request)
if not ids.strip():
raise AppError(10003)
await _base(session).batch_delete(
ids.split(","), user, request.headers.get("Accept-Language")
)
return ok()
@knowledge_router.get("/datasets")
async def datasets_page(
request: Request,
name: str | None = None,
page: int = 1,
page_size: int = 10,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
return ok(
await _base(session).page(
require_normal(request),
name,
page,
page_size,
request.headers.get("Accept-Language"),
)
)
@knowledge_router.post("/datasets")
async def create_dataset(
body: KnowledgeBaseBody, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
return ok(await _base(session).create(body, require_normal(request)))
@knowledge_router.get("/datasets/{dataset_id}")
async def get_dataset(
dataset_id: str, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
return ok(dataset_dto(await _base(session).get_owned(dataset_id, require_normal(request))))
@knowledge_router.put("/datasets/{dataset_id}")
async def update_dataset(
dataset_id: str,
body: KnowledgeBaseBody,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
return ok(await _base(session).update(dataset_id, body, require_normal(request)))
@knowledge_router.delete("/datasets/{dataset_id}")
async def delete_dataset(
dataset_id: str, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
await _base(session).delete(
dataset_id, require_normal(request), request.headers.get("Accept-Language")
)
return ok()
@knowledge_router.get("/datasets/{dataset_id}/documents/status/{status}")
async def documents_by_status(
dataset_id: str,
status: str,
request: Request,
page: int = 1,
page_size: int = 10,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
return ok(
await _documents(session).page(
dataset_id,
require_normal(request),
name=None,
status=status,
page=page,
page_size=page_size,
)
)
@knowledge_router.get("/datasets/{dataset_id}/documents")
async def documents_page(
dataset_id: str,
request: Request,
name: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 10,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
return ok(
await _documents(session).page(
dataset_id,
require_normal(request),
name=name,
status=status,
page=page,
page_size=page_size,
)
)
@knowledge_router.post("/datasets/{dataset_id}/documents")
async def upload_document(
dataset_id: str,
request: Request,
file: Annotated[UploadFile, File()],
name: Annotated[str | None, Form()] = None,
chunk_method: Annotated[str | None, Form(alias="chunkMethod")] = None,
meta_fields: Annotated[str | None, Form(alias="metaFields")] = None,
parser_config: Annotated[str | None, Form(alias="parserConfig")] = None,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
return ok(
await _documents(session).upload(
dataset_id,
require_normal(request),
file,
name=name,
meta_fields=_parse_form_json(meta_fields),
chunk_method=chunk_method,
parser_config=_parse_form_json(parser_config),
)
)
@knowledge_router.delete("/datasets/{dataset_id}/documents")
async def delete_documents(
dataset_id: str,
body: DocumentBatchBody,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _documents(session).delete(
dataset_id,
body.ids,
require_normal(request),
request.headers.get("Accept-Language"),
)
return ok()
@knowledge_router.delete("/datasets/{dataset_id}/documents/{document_id}")
async def delete_document(
dataset_id: str,
document_id: str,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _documents(session).delete(
dataset_id,
[document_id],
require_normal(request),
request.headers.get("Accept-Language"),
)
return ok()
@knowledge_router.post("/datasets/{dataset_id}/chunks")
async def parse_documents(
dataset_id: str,
body: dict[str, Any],
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
user = require_normal(request)
# Java validates dataset existence/ownership before it reads document_ids.
# A missing dataset must therefore win over the controller's empty-body
# business error.
await _base(session).get_owned(dataset_id, user)
document_ids = body.get("document_ids")
if document_ids is not None and not isinstance(document_ids, list):
# Spring fails Map<String,List<String>> deserialization before entering
# the controller, which is handled as the generic code-500 envelope.
raise RuntimeError("document_ids must be an array")
if not document_ids:
return JavaJSONResponse(envelope(None, code=500, msg="document_ids参数不能为空"))
success = await _documents(session).parse(dataset_id, document_ids, user)
return ok() if success else JavaJSONResponse(
envelope(None, code=500, msg="文档解析失败,文档可能正在处理中")
)
@knowledge_router.get("/datasets/{dataset_id}/documents/{document_id}/chunks")
async def list_chunks(
dataset_id: str,
document_id: str,
request: Request,
page: int = 1,
page_size: int = 10,
keywords: str | None = None,
id: str | None = None, # noqa: A002 - exact Java query parameter
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
return ok(
await _documents(session).chunks(
dataset_id,
document_id,
require_normal(request),
page=page,
page_size=page_size,
keywords=keywords,
chunk_id=id,
)
)
@knowledge_router.post("/datasets/{dataset_id}/retrieval-test")
async def retrieval_test(
dataset_id: str,
body: RetrievalBody,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
return ok(await _documents(session).retrieval(dataset_id, body, require_normal(request)))
def _parse_form_json(value: str | None) -> dict[str, Any] | None:
if value is None:
return None
try:
result = json.loads(value)
except json.JSONDecodeError as exc:
raise RuntimeError(f"解析JSON字符串失败: {value}") from exc
if not isinstance(result, dict):
raise RuntimeError(f"解析JSON字符串失败: {value}")
return dict(result)
@@ -0,0 +1,178 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.responses import JavaJSONResponse, envelope, ok
from app.core.security import require_normal, require_super_admin
from app.repositories.config import ConfigRepository
from app.repositories.model import ModelRepository
from app.schemas.model import ModelConfigBody, ModelProviderBody
from app.services.config import ConfigService
from app.services.model import ModelProviderService, ModelService
model_router = APIRouter()
def _models(session: AsyncSession) -> ModelService:
return ModelService(ModelRepository(session))
def _providers(session: AsyncSession) -> ModelProviderService:
return ModelProviderService(ModelRepository(session))
async def _refresh_server_config(session: AsyncSession) -> None:
await ConfigService(ConfigRepository(session)).get_config(use_cache=False)
@model_router.get("/models/names")
async def model_names(
request: Request,
model_type: str = Query(alias="modelType"),
model_name: str | None = Query(default=None, alias="modelName"),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_normal(request)
return ok(await _models(session).names(model_type, model_name))
@model_router.get("/models/llm/names")
async def llm_names(
request: Request,
model_name: str | None = Query(default=None, alias="modelName"),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_normal(request)
return ok(await _models(session).llm_names(model_name))
@model_router.get("/models/list")
async def model_list(
request: Request,
model_type: str = Query(alias="modelType"),
model_name: str | None = Query(default=None, alias="modelName"),
page: str = "0",
limit: str = "10",
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await _models(session).model_page(model_type, model_name, page, limit))
@model_router.get("/models/provider/plugin/names")
async def plugin_names(request: Request, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
user = require_normal(request)
return ok(await ModelRepository(session).list_plugins_for_user(user.id))
@model_router.get("/models/provider")
async def provider_list(
request: Request,
model_type: str | None = Query(default=None, alias="modelType"),
name: str | None = None,
page: str = "0",
limit: str = "10",
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await _providers(session).page(model_type, name, page, limit))
@model_router.post("/models/provider")
async def provider_add(
body: ModelProviderBody, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
return ok(await _providers(session).add(body, require_super_admin(request)))
@model_router.put("/models/provider")
async def provider_edit(
body: ModelProviderBody, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
return ok(await _providers(session).edit(body, require_super_admin(request)))
@model_router.post("/models/provider/delete")
async def provider_delete(
ids: list[str], request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
require_super_admin(request)
await _providers(session).delete(ids)
return ok()
@model_router.get("/models/{model_type}/provideTypes")
async def provider_types(
model_type: str, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await ModelRepository(session).list_providers_by_type(model_type))
@model_router.post("/models/{model_type}/{provide_code}")
async def model_add(
model_type: str,
provide_code: str,
body: ModelConfigBody,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
result = await _models(session).add(model_type, provide_code, body)
await _refresh_server_config(session)
return ok(result)
@model_router.put("/models/enable/{model_id}/{status}")
async def model_enable(
model_id: str, status: int, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
require_super_admin(request)
message = await _models(session).enable(model_id, status)
return JavaJSONResponse(envelope(None, code=500, msg=message)) if message else ok()
@model_router.put("/models/{model_type}/{provide_code}/{model_id}")
async def model_edit(
model_type: str,
provide_code: str,
model_id: str,
body: ModelConfigBody,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
result = await _models(session).edit(model_type, provide_code, model_id, body)
await _refresh_server_config(session)
return ok(result)
@model_router.put("/models/default/{model_id}")
async def model_default(
model_id: str, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
require_super_admin(request)
message = await _models(session).set_default(model_id)
if message:
return JavaJSONResponse(envelope(None, code=500, msg=message))
await _refresh_server_config(session)
return ok()
@model_router.get("/models/{model_id}")
async def model_get(
model_id: str, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await _models(session).get_model(model_id))
@model_router.delete("/models/{model_id}")
async def model_delete(
model_id: str, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
require_super_admin(request)
await _models(session).delete(model_id)
return ok()
@@ -0,0 +1,111 @@
# ruff: noqa: B008
# FastAPI evaluates dependency marker defaults intentionally when registering routes.
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from app.core.database import get_db
from app.core.errors import AppError
from app.core.responses import JavaJSONResponse, ok
from app.core.security import require_normal
from app.repositories.security import SecurityRepository
from app.schemas.security import (
LoginRequest,
PasswordChangeRequest,
RetrievePasswordRequest,
SmsVerificationRequest,
)
from app.services.security import CaptchaService, SecurityService
security_router = APIRouter()
def _service(session: AsyncSession) -> SecurityService:
return SecurityService(SecurityRepository(session))
@security_router.get("/user/captcha")
async def captcha(uuid: str | None = Query(default=None)) -> Response:
if uuid is None or not uuid.strip():
raise AppError(10006)
content = await CaptchaService().create(uuid)
return Response(
content,
media_type="image/gif",
headers={
"Pragma": "No-cache",
"Cache-Control": "no-cache",
"Expires": "Thu, 01 Jan 1970 00:00:00 GMT",
},
)
@security_router.post("/user/smsVerification")
async def sms_verification(dto: SmsVerificationRequest, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
await _service(session).send_sms_verification(dto)
return ok()
@security_router.post("/user/login")
async def login(
dto: LoginRequest,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
return ok(await _service(session).login(dto, request))
@security_router.post("/user/register")
async def register(dto: LoginRequest, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
await _service(session).register(dto)
return ok()
@security_router.get("/user/info")
async def info(request: Request) -> JavaJSONResponse:
user = require_normal(request)
return ok(
{
"id": user.id,
"username": user.username,
"superAdmin": user.super_admin,
"token": user.token,
"status": user.status,
}
)
@security_router.put("/user/change-password")
async def change_password(
dto: PasswordChangeRequest,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _service(session).change_password(
require_normal(request),
dto,
request.headers.get("Accept-Language"),
)
return ok()
@security_router.put("/user/retrieve-password")
async def retrieve_password(
dto: RetrievePasswordRequest,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _service(session).retrieve_password(dto, request.headers.get("Accept-Language"))
return ok()
@security_router.get("/user/pub-config")
async def public_config(session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
return ok(await _service(session).public_config())
@security_router.get("/api/ping")
async def api_ping() -> JavaJSONResponse:
return ok("pong")
+334
View File
@@ -0,0 +1,334 @@
# ruff: noqa: B008
# FastAPI evaluates dependency and body marker defaults intentionally when registering routes.
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.errors import AppError
from app.core.responses import JavaJSONResponse, envelope, ok
from app.core.security import require_normal, require_super_admin
from app.repositories.sys import SysRepository
from app.schemas.sys import DictDataPayload, DictTypePayload, EmitServerActionRequest, SysParamPayload
from app.services.sys import AdminService, DictService, ServerActionService, SysParamService
sys_router = APIRouter()
def _repository(session: AsyncSession) -> SysRepository:
return SysRepository(session)
def _admin(session: AsyncSession) -> AdminService:
return AdminService(_repository(session))
def _params(session: AsyncSession) -> SysParamService:
return SysParamService(_repository(session))
def _dict(session: AsyncSession) -> DictService:
return DictService(_repository(session))
async def _refresh_server_config(session: AsyncSession) -> None:
from app.repositories.config import ConfigRepository
from app.services.config import ConfigService
await ConfigService(ConfigRepository(session)).get_config(use_cache=False)
@sys_router.get("/admin/users")
async def page_users(
request: Request,
mobile: str | None = None,
page: str = Query(default="1"),
limit: str = Query(default="10"),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
try:
current, size = int(page), int(limit)
except ValueError as exc:
# Java parses these Map-backed values inside the service; malformed
# numbers therefore reach its generic code=500 handler rather than
# Bean Validation.
raise AppError(500, "排序值不能小于0") from exc
return ok(await _admin(session).page_users(mobile=mobile, page=current, limit=size))
@sys_router.put("/admin/users/{user_id}")
async def reset_user_password(
user_id: int,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
user = require_super_admin(request)
return ok(await _admin(session).reset_password(user_id, user))
@sys_router.delete("/admin/users/{user_id}")
async def delete_user(
user_id: int,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
await _admin(session).delete_user(user_id)
return ok()
@sys_router.put("/admin/users/changeStatus/{status}")
async def change_user_status(
status: int,
request: Request,
user_ids: list[str] = Body(),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
user = require_super_admin(request)
await _admin(session).change_status(status, user_ids, user)
return ok()
@sys_router.get("/admin/device/all")
async def page_all_devices(
request: Request,
keywords: str | None = None,
page: int = Query(default=1, ge=0),
limit: int = Query(default=10, ge=0),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await _admin(session).page_devices(keywords=keywords, page=page, limit=limit))
@sys_router.get("/admin/server/server-list")
async def websocket_server_list(request: Request, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse:
require_super_admin(request)
params = _params(session)
return ok(await ServerActionService(params).server_list())
@sys_router.post("/admin/server/emit-action")
async def emit_server_action(
dto: EmitServerActionRequest,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await ServerActionService(_params(session)).emit(dto))
@sys_router.get("/admin/params/page")
async def page_params(
request: Request,
page: int = Query(default=1, ge=0),
limit: int = Query(default=10, ge=0),
order_field: str | None = Query(default=None, alias="orderField"),
order: str | None = None,
param_code: str | None = Query(default=None, alias="paramCode"),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(
await _params(session).page(
param_code=param_code,
page=page,
limit=limit,
order_field=order_field,
order=order,
)
)
@sys_router.get("/admin/params/{param_id}")
async def get_param(
param_id: int,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await _params(session).get(param_id))
@sys_router.post("/admin/params")
async def save_param(
dto: SysParamPayload,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _params(session).save(
dto,
require_super_admin(request),
request.headers.get("Accept-Language"),
)
await _refresh_server_config(session)
return ok()
@sys_router.put("/admin/params")
async def update_param(
dto: SysParamPayload,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _params(session).update(
dto,
require_super_admin(request),
request.headers.get("Accept-Language"),
)
await _refresh_server_config(session)
return ok()
@sys_router.post("/admin/params/delete")
async def delete_params(
request: Request,
ids: list[str] = Body(),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
await _params(session).delete(ids)
await _refresh_server_config(session)
return ok()
@sys_router.get("/admin/dict/type/page")
async def page_dict_types(
request: Request,
dict_type: str | None = Query(default=None, alias="dictType"),
dict_name: str | None = Query(default=None, alias="dictName"),
page: int = Query(default=1, ge=0),
limit: int = Query(default=10, ge=0),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(
await _dict(session).page_types(
dict_type=dict_type,
dict_name=dict_name,
page=page,
limit=limit,
)
)
@sys_router.get("/admin/dict/type/{type_id}")
async def get_dict_type(
type_id: int,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await _dict(session).get_type(type_id))
@sys_router.post("/admin/dict/type/save")
async def save_dict_type(
dto: DictTypePayload,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _dict(session).save_type(dto, require_super_admin(request))
return ok()
@sys_router.put("/admin/dict/type/update")
async def update_dict_type(
dto: DictTypePayload,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _dict(session).update_type(dto, require_super_admin(request))
return ok()
@sys_router.post("/admin/dict/type/delete")
async def delete_dict_types(
request: Request,
ids: list[int] = Body(),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
await _dict(session).delete_types(ids)
return ok()
@sys_router.get("/admin/dict/data/page")
async def page_dict_data(
request: Request,
dict_type_id: str | None = Query(default=None, alias="dictTypeId"),
dict_label: str | None = Query(default=None, alias="dictLabel"),
dict_value: str | None = Query(default=None, alias="dictValue"),
page: int = Query(default=1, ge=0),
limit: int = Query(default=10, ge=0),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
if dict_type_id is None or not dict_type_id:
return JavaJSONResponse(envelope(None, code=500, msg="dictTypeId不能为空"))
try:
parsed_type_id = int(dict_type_id)
except ValueError as exc:
raise AppError(500) from exc
return ok(
await _dict(session).page_data(
dict_type_id=parsed_type_id,
dict_label=dict_label,
dict_value=dict_value,
page=page,
limit=limit,
)
)
@sys_router.get("/admin/dict/data/type/{dict_type}")
async def dict_items(
dict_type: str,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_normal(request)
return ok(await _dict(session).items(dict_type))
@sys_router.get("/admin/dict/data/{data_id}")
async def get_dict_data(
data_id: int,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await _dict(session).get_data(data_id))
@sys_router.post("/admin/dict/data/save")
async def save_dict_data(
dto: DictDataPayload,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _dict(session).save_data(dto, require_super_admin(request))
return ok()
@sys_router.put("/admin/dict/data/update")
async def update_dict_data(
dto: DictDataPayload,
request: Request,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
await _dict(session).update_data(dto, require_super_admin(request))
return ok()
@sys_router.post("/admin/dict/data/delete")
async def delete_dict_data(
request: Request,
ids: list[int] = Body(),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
await _dict(session).delete_data(ids)
return ok()
@@ -0,0 +1,74 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.responses import JavaJSONResponse, ok
from app.core.security import require_normal, require_super_admin
from app.repositories.timbre import TimbreRepository
from app.schemas.timbre import TimbreBody
from app.services.timbre import TimbreService
timbre_router = APIRouter()
def _service(session: AsyncSession) -> TimbreService:
return TimbreService(TimbreRepository(session))
@timbre_router.get("/ttsVoice")
async def timbre_page(
request: Request,
tts_model_id: str | None = Query(default=None, alias="ttsModelId"),
name: str | None = None,
page: str | None = None,
limit: str | None = None,
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
require_super_admin(request)
return ok(
await _service(session).page(
tts_model_id, name, page, limit, request.headers.get("Accept-Language")
)
)
@timbre_router.post("/ttsVoice")
async def timbre_save(
body: TimbreBody, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
await _service(session).save(
body, require_super_admin(request), request.headers.get("Accept-Language")
)
return ok()
@timbre_router.put("/ttsVoice/{timbre_id}")
async def timbre_update(
timbre_id: str, body: TimbreBody, request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
await _service(session).update(
timbre_id, body, require_super_admin(request), request.headers.get("Accept-Language")
)
return ok()
@timbre_router.post("/ttsVoice/delete")
async def timbre_delete(
ids: list[str], request: Request, session: AsyncSession = Depends(get_db)
) -> JavaJSONResponse:
require_super_admin(request)
await _service(session).delete(ids)
return ok()
@timbre_router.get("/models/{model_id}/voices")
async def model_voices(
model_id: str,
request: Request,
voice_name: str | None = Query(default=None, alias="voiceName"),
session: AsyncSession = Depends(get_db),
) -> JavaJSONResponse:
user = require_normal(request)
return ok(await _service(session).voices(model_id, voice_name, user, request.headers.get("Accept-Language")))
@@ -0,0 +1,222 @@
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from app.core.database import get_db
from app.core.errors import AppError
from app.core.i18n import message_for
from app.core.responses import JavaJSONResponse, error_response, ok
from app.core.security import require_normal, require_super_admin
from app.schemas.voiceclone import VoiceCloneRenameRequest, VoiceCloneTrainRequest, VoiceResourceCreateRequest
from app.services.voiceclone import VoiceCloneService
voiceclone_router = APIRouter()
SessionDep = Annotated[AsyncSession, Depends(get_db)]
VoiceFile = Annotated[UploadFile, File(alias="voiceFile")]
VoiceIdForm = Annotated[str, Form(alias="id")]
def _query_map(request: Request) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in request.query_params.multi_items():
if key in result:
previous = result[key]
result[key] = [*previous, value] if isinstance(previous, list) else [previous, value]
else:
result[key] = value
return result
# Static voiceResource paths deliberately precede /voiceResource/{id}.
@voiceclone_router.get("/voiceResource/ttsPlatforms")
async def tts_platforms(
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await VoiceCloneService(session).tts_platforms())
@voiceclone_router.get("/voiceResource/user/{user_id}")
async def voice_resources_by_user(
user_id: int,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_normal(request)
return ok(await VoiceCloneService(session).get_by_user(user_id))
@voiceclone_router.get("/voiceResource")
async def page_voice_resources(
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await VoiceCloneService(session).page(_query_map(request)))
@voiceclone_router.get("/voiceResource/{voice_id}")
async def get_voice_resource(
voice_id: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_super_admin(request)
return ok(await VoiceCloneService(session).get_detail(voice_id))
@voiceclone_router.post("/voiceResource")
async def create_voice_resource(
request: Request,
session: SessionDep,
body: VoiceResourceCreateRequest | None = None,
) -> JavaJSONResponse:
user = require_super_admin(request)
if body is None:
return error_response(request, 10145)
if body.model_id is None or body.model_id == "":
return error_response(request, 10146)
if not body.voice_ids:
return error_response(request, 10147)
if body.user_id is None:
return error_response(request, 10148)
try:
await VoiceCloneService(session).create_resources(body, actor=user)
return ok()
except AppError:
raise
except RuntimeError as exc:
return error_response(request, 10065, str(exc))
@voiceclone_router.delete("/voiceResource/{voice_id}")
async def delete_voice_resource(
voice_id: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
require_super_admin(request)
ids = voice_id.split(",") if voice_id else []
if not ids:
return error_response(request, 10149)
await VoiceCloneService(session).delete(ids)
return ok()
@voiceclone_router.get("/voiceClone")
async def page_voice_clones(
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
return ok(await VoiceCloneService(session).page(_query_map(request), user_id=user.id))
@voiceclone_router.post("/voiceClone/upload")
async def upload_voice_clone(
request: Request,
session: SessionDep,
voice_file: VoiceFile,
voice_id: VoiceIdForm = "",
) -> JavaJSONResponse:
user = require_normal(request)
service = VoiceCloneService(session)
try:
content = await voice_file.read()
if not content:
return error_response(request, 10140)
content_type = voice_file.content_type
if content_type is None or not content_type.startswith("audio/"):
return error_response(request, 10141)
filename = voice_file.filename
if filename is None or "." not in filename:
raise RuntimeError("文件名缺少扩展名")
extension = filename[filename.rfind(".") :].lower()
if extension not in {".mp3", ".wav"}:
return error_response(request, 500, "只允许上传.mp3和.wav格式的文件")
if len(content) > 10 * 1024 * 1024:
return error_response(request, 10142)
await service.check_permission(voice_id, user)
await service.upload_voice(voice_id, content)
return ok()
except Exception as exc:
if isinstance(exc, AppError):
message = exc.message or message_for(exc.code, request.headers.get("Accept-Language"))
else:
message = str(exc)
return error_response(request, 10143, message)
@voiceclone_router.post("/voiceClone/updateName")
async def update_voice_clone_name(
body: VoiceCloneRenameRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
if body.id is None or body.id == "":
return error_response(request, 10006)
if body.name is None or body.name == "":
return error_response(request, 10181)
service = VoiceCloneService(session)
try:
await service.check_permission(body.id, user)
await service.rename(body.id or "", body.name or "")
return ok()
except Exception as exc:
if isinstance(exc, AppError):
message = exc.message or message_for(exc.code, request.headers.get("Accept-Language"))
else:
message = str(exc)
return error_response(request, 10066, message)
@voiceclone_router.post("/voiceClone/audio/{voice_id}")
async def get_voice_clone_audio_id(
voice_id: str,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
service = VoiceCloneService(session)
await service.check_permission(voice_id, user)
return ok(await service.create_audio_id(voice_id))
@voiceclone_router.get("/voiceClone/play/{download_id}")
async def play_voice_clone(download_id: str, session: SessionDep) -> Response:
try:
content = await VoiceCloneService(session).consume_audio(download_id)
if content is None:
return Response(status_code=404)
return Response(
content,
media_type="audio/wav",
headers={
"Content-Length": str(len(content)),
"Content-Disposition": "inline; filename=voice.wav",
},
)
except Exception:
return Response(status_code=500)
@voiceclone_router.post("/voiceClone/cloneAudio")
async def train_voice_clone(
body: VoiceCloneTrainRequest,
request: Request,
session: SessionDep,
) -> JavaJSONResponse:
user = require_normal(request)
service = VoiceCloneService(session)
await service.check_permission(body.clone_id, user)
await service.clone_audio(
body.clone_id or "",
accept_language=request.headers.get("Accept-Language"),
)
return ok()