feat: add admin permissions voice input analytics and feedback

This commit is contained in:
2026-08-11 17:17:59 +08:00
parent 3b9215cc3d
commit fd2da26d10
46 changed files with 1836 additions and 192 deletions

View File

@@ -7,7 +7,8 @@ from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse
from app.services.admin_permission_service import permissions_for
from app.services.admin_service import AdminAuthService
from app.services.auth_service import AuthService
from app.services.security_state_service import client_ip
@@ -24,7 +25,12 @@ def login(payload: AdminLoginRequest, request: Request, db: Session = Depends(ge
@router.get("/profile")
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
return api_success(AdminRead.model_validate(current_admin).model_dump())
return api_success({
"id": current_admin.id, "username": current_admin.username, "name": current_admin.name,
"status": current_admin.status, "isSuperAdmin": bool(current_admin.is_super_admin),
"mustChangePassword": bool(current_admin.must_change_password),
"permissions": sorted(permissions_for(current_admin)),
})
@router.post("/logout")

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
import json
from pathlib import Path
from typing import Literal
from sqlalchemy import select, text
from sqlalchemy.orm import Session
@@ -16,6 +17,7 @@ from app.schemas.admin import DashboardStats
from app.services.admin_service import AdminDashboardService
from app.models.logs import StorageSnapshot
from app.services.redis_client import get_sync_redis_client
from app.services.request_traffic_service import RequestTrafficService
router = APIRouter()
@@ -37,6 +39,14 @@ def dashboard(
return api_success(DashboardStats.model_validate(stats).model_dump())
@router.get("/dashboard/traffic")
async def peak_traffic(
grain: Literal["minute", "hour", "day", "week"] = Query(default="hour"),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(await RequestTrafficService.peak_traffic(grain))
@router.get("/dashboard/storage")
def storage_stats(
refresh: bool = Query(default=False),

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.core.security import hash_password, verify_password
from app.models.admin import Admin, Role
from app.schemas.admin import AdminPasswordChangeRequest, ManagedAdminCreateRequest, ManagedAdminUpdateRequest
from app.services.admin_permission_service import ALL_PERMISSION_CODES, PERMISSION_TREE, permissions_for, require_permission
router = APIRouter()
def _serialize(admin: Admin) -> dict:
return {
"id": admin.id, "username": admin.username, "name": admin.name, "status": admin.status,
"isSuperAdmin": bool(admin.is_super_admin), "mustChangePassword": bool(admin.must_change_password),
"permissions": sorted(permissions_for(admin)), "lastLoginAt": admin.last_login_at,
"createdAt": admin.created_at,
}
def _validated_permissions(values: list[str]) -> list[str]:
unknown = set(values) - ALL_PERMISSION_CODES
if unknown:
raise HTTPException(status_code=422, detail=f"包含未知权限:{', '.join(sorted(unknown))}")
return sorted(set(values))
def _require_super(admin: Admin) -> None:
if not admin.is_super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可管理管理员账号")
@router.get("/permission-tree")
def permission_tree(current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
return api_success(PERMISSION_TREE)
@router.get("/administrator/list")
def list_administrators(db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
rows = db.scalars(select(Admin).options(selectinload(Admin.role)).order_by(Admin.is_super_admin.desc(), Admin.id)).all()
return api_success([_serialize(item) for item in rows])
@router.post("/administrator")
def create_administrator(payload: ManagedAdminCreateRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
if db.scalar(select(Admin.id).where(Admin.username == payload.username.strip())):
raise HTTPException(status_code=409, detail="管理员账号已存在")
permissions = _validated_permissions(payload.permissions)
role = Role(code=f"admin_{payload.username.strip().lower()}", name=f"{payload.name.strip()}的权限", permissions=json.dumps(permissions))
db.add(role)
db.flush()
item = Admin(username=payload.username.strip(), name=payload.name.strip(), password=hash_password(payload.initialPassword), role_id=role.id, status=payload.status, must_change_password=1, is_super_admin=0)
db.add(item)
db.commit()
db.refresh(item)
item.role = role
return api_success(_serialize(item))
@router.put("/administrator/{admin_id}")
def update_administrator(admin_id: int, payload: ManagedAdminUpdateRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
item = db.scalar(select(Admin).options(selectinload(Admin.role)).where(Admin.id == admin_id))
if item is None:
raise HTTPException(status_code=404, detail="管理员不存在")
if item.is_super_admin:
raise HTTPException(status_code=403, detail="超级管理员账号由环境变量管理,不可修改")
item.name, item.status = payload.name.strip(), payload.status
if item.role is None:
item.role = Role(code=f"admin_{item.username.lower()}", name=f"{item.name}的权限")
item.role.name = f"{item.name}的权限"
item.role.permissions = json.dumps(_validated_permissions(payload.permissions))
if payload.resetPassword:
item.password = hash_password(payload.resetPassword)
item.must_change_password = 1
db.commit()
db.refresh(item)
return api_success(_serialize(item))
@router.delete("/administrator/{admin_id}")
def delete_administrator(admin_id: int, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
item = db.scalar(select(Admin).options(selectinload(Admin.role)).where(Admin.id == admin_id))
if item is None:
raise HTTPException(status_code=404, detail="管理员不存在")
if item.is_super_admin:
raise HTTPException(status_code=403, detail="超级管理员不可删除")
role = item.role
db.delete(item)
db.flush()
if role is not None:
db.delete(role)
db.commit()
return api_success()
@router.post("/password")
def change_password(payload: AdminPasswordChangeRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
if current.is_super_admin:
raise HTTPException(status_code=403, detail="超级管理员密码由环境变量管理,不可在后台修改")
if not verify_password(payload.currentPassword, current.password):
raise HTTPException(status_code=400, detail="当前密码不正确")
if payload.currentPassword == payload.newPassword:
raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
current.password = hash_password(payload.newPassword)
current.must_change_password = 0
db.commit()
return api_success()

View File

@@ -0,0 +1,92 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin, get_current_user
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.feedback import MessageFeedback
from app.models.user import User
from app.services.admin_service import OperationLogService
from app.services.admin_permission_service import require_permission
router = APIRouter()
class FeedbackCreate(BaseModel):
messageId: int = Field(gt=0)
content: str = Field(min_length=1, max_length=200)
@router.post("")
def create_feedback(payload: FeedbackCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
message = db.scalar(select(ChatMessage).where(ChatMessage.id == payload.messageId, ChatMessage.user_id == user.id))
if message is None or message.role != "assistant" or message.message_status != "FINISHED":
raise HTTPException(status_code=404, detail="反馈的回答不存在")
content = payload.content.strip()
if not content:
raise HTTPException(status_code=400, detail="请填写反馈内容")
existing = db.scalar(select(MessageFeedback).where(MessageFeedback.user_id == user.id, MessageFeedback.message_id == message.id))
if existing:
raise HTTPException(status_code=409, detail="这条回答已经反馈过了")
item = MessageFeedback(user_id=user.id, session_id=message.session_id, message_id=message.id, content=content)
db.add(item)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="这条回答已经反馈过了") from exc
db.refresh(item)
return api_success({"id": item.id})
@router.get("/admin/list")
def feedback_list(readStatus: str = Query(default="all", pattern="^(all|read|unread)$"), page: int = Query(default=1, ge=1), pageSize: int = Query(default=20, ge=10, le=100), db: Session = Depends(get_db), _admin: Admin = Depends(get_current_admin)) -> dict:
require_permission(_admin, "feedback.view")
query = select(MessageFeedback, User, ChatMessage).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id)
if readStatus != "all":
query = query.where(MessageFeedback.is_read == (1 if readStatus == "read" else 0))
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.execute(query.order_by(MessageFeedback.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_summary(*row) for row in rows], total=total, page=page, page_size=pageSize))
@router.get("/admin/{feedback_id}")
def feedback_detail(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict:
row = db.execute(select(MessageFeedback, User, ChatMessage, ChatSession).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id).join(ChatSession, ChatSession.id == MessageFeedback.session_id).where(MessageFeedback.id == feedback_id)).first()
if row is None:
raise HTTPException(status_code=404, detail="反馈不存在")
require_permission(admin, "feedback.view")
feedback, user, target, session = row
if not feedback.is_read:
feedback.is_read = 1
feedback.read_by = admin.id
feedback.read_at = datetime.now(UTC).replace(tzinfo=None)
db.commit()
messages = db.scalars(select(ChatMessage).where(ChatMessage.session_id == feedback.session_id, ChatMessage.id <= target.id).order_by(ChatMessage.id.asc()).limit(200)).all()
return api_success({**_summary(feedback, user, target), "sessionTitle": session.title, "messages": [{"id": m.id, "role": m.role, "content": m.content, "createdAt": m.created_at, "isTarget": m.id == target.id} for m in messages]})
@router.delete("/admin/{feedback_id}")
def delete_feedback(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict:
require_permission(admin, "feedback.delete")
item = db.get(MessageFeedback, feedback_id)
if item is None:
raise HTTPException(status_code=404, detail="反馈不存在")
db.delete(item)
OperationLogService.write(db, admin_id=admin.id, module="feedback", action="delete", target_id=feedback_id)
db.commit()
return api_success()
def _summary(item: MessageFeedback, user: User, message: ChatMessage) -> dict:
return {"id": item.id, "userId": user.id, "userName": user.name, "userPhone": user.phone, "messageId": message.id, "messageContent": message.content, "content": item.content, "isRead": bool(item.is_read), "createdAt": item.created_at}

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from app.core.dependencies import enforce_admin_access
from app.api import (
admin_auth,
@@ -10,15 +11,18 @@ from app.api import (
admin_entitlements,
admin_knowledge,
admin_knowledge_lifecycle,
admin_management,
admin_records,
admin_settings,
admin_sso,
admin_users,
auth,
feedback,
chat,
health,
integration_sso,
user,
voice,
)
api_router = APIRouter()
@@ -26,15 +30,19 @@ api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"])
api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(voice.router, prefix="/voice", tags=["voice"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"])
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"])
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"])
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_management.router, prefix="/admin", tags=["admin-management"])
guard = [Depends(enforce_admin_access)]
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard)
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard)
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard)
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"], dependencies=guard)
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"], dependencies=guard)
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"], dependencies=guard)

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, UploadFile
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.responses import api_success
from app.models.user import User
from app.services.voice_input_service import MAX_UPLOAD_BYTES, VoiceInputService
router = APIRouter()
@router.get("/config")
def voice_config(db: Session = Depends(get_db), _user: User = Depends(get_current_user)) -> dict:
return api_success(VoiceInputService.public_config(db))
@router.post("/transcribe")
def transcribe(
audio: UploadFile = File(...),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
content = audio.file.read(MAX_UPLOAD_BYTES + 1)
return api_success(VoiceInputService.transcribe(db, user.id, content, audio.content_type))