feat: add admin permissions voice input analytics and feedback
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
120
ai_knowledge_base_v2/apps/backend/app/api/admin_management.py
Normal file
120
ai_knowledge_base_v2/apps/backend/app/api/admin_management.py
Normal 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()
|
||||
92
ai_knowledge_base_v2/apps/backend/app/api/feedback.py
Normal file
92
ai_knowledge_base_v2/apps/backend/app/api/feedback.py
Normal 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}
|
||||
@@ -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)
|
||||
|
||||
27
ai_knowledge_base_v2/apps/backend/app/api/voice.py
Normal file
27
ai_knowledge_base_v2/apps/backend/app/api/voice.py
Normal 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))
|
||||
@@ -48,6 +48,8 @@ class Settings(BaseSettings):
|
||||
aliyun_sms_template_code: str = ""
|
||||
aliyun_sms_template_param_key: str = "code"
|
||||
aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com"
|
||||
aliyun_nls_app_key: str = ""
|
||||
aliyun_nls_endpoint: str = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr"
|
||||
mock_rag_enabled: bool = False
|
||||
mock_model_enabled: bool = True
|
||||
feishu_mock_enabled: bool = False
|
||||
@@ -63,6 +65,8 @@ class Settings(BaseSettings):
|
||||
chat_max_queue_size: int = 90
|
||||
chat_queue_timeout_seconds: int = 90
|
||||
chat_active_lease_seconds: int = 900
|
||||
voice_input_enabled: bool = False
|
||||
voice_max_duration_seconds: int = 60
|
||||
periodic_report_worker_enabled: bool = True
|
||||
periodic_report_poll_seconds: int = 5
|
||||
periodic_report_stale_minutes: int = 30
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -77,3 +77,44 @@ def get_current_admin(
|
||||
if admin.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
|
||||
return admin
|
||||
|
||||
|
||||
def enforce_admin_access(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
) -> Admin:
|
||||
"""Block first-login accounts and enforce permissions on every existing admin API."""
|
||||
from app.services.admin_permission_service import require_permission
|
||||
|
||||
if admin.must_change_password:
|
||||
raise HTTPException(status_code=status.HTTP_428_PRECONDITION_REQUIRED, detail="首次登录请先修改初始密码")
|
||||
path = request.url.path.split("/admin/", 1)[-1].strip("/")
|
||||
method = request.method.upper()
|
||||
if path.startswith("dashboard"):
|
||||
permission = "dashboard.view"
|
||||
elif path.startswith("user/") and ("/entitlement" in path or path.startswith("user/entitlement")):
|
||||
permission = "entitlements.edit"
|
||||
elif path.startswith("entitlement/"):
|
||||
permission = "entitlements.view" if method == "GET" else "entitlements.edit"
|
||||
elif path.startswith("user/"):
|
||||
permission = "users.delete" if method == "DELETE" else ("users.view" if method == "GET" else ("users.create" if method == "POST" and (path in {"user", "user/import", "user/import/excel"}) else "users.edit"))
|
||||
elif path.startswith("knowledge"):
|
||||
permission = "knowledge.delete" if method == "DELETE" else ("knowledge.view" if method == "GET" else ("knowledge.publish" if path.endswith("open-status") or path.endswith("lifecycle") else "knowledge.edit"))
|
||||
elif path.startswith("prompt") or path.startswith("agent/"):
|
||||
permission = "prompt.view" if method == "GET" else "prompt.edit"
|
||||
elif path.startswith("model"):
|
||||
permission = "models.delete" if method == "DELETE" else ("models.view" if method == "GET" else "models.edit")
|
||||
elif path.startswith("content-generation"):
|
||||
permission = "content-generation.view" if method == "GET" else "content-generation.edit"
|
||||
elif path.startswith("config") or path.startswith("feishu/cache"):
|
||||
permission = "configs.view" if method == "GET" else "configs.edit"
|
||||
elif path.startswith("sso/"):
|
||||
permission = "sso.view" if method == "GET" else "sso.edit"
|
||||
elif path.startswith("retrieval-log"):
|
||||
permission = "retrievals.view" if method == "GET" else "configs.edit"
|
||||
elif path.startswith("attention"):
|
||||
permission = "attention.view" if method == "GET" else "attention.edit"
|
||||
else:
|
||||
permission = "records.view"
|
||||
require_permission(admin, permission)
|
||||
return admin
|
||||
|
||||
@@ -80,6 +80,13 @@ class RequestObservabilityMiddleware:
|
||||
finally:
|
||||
if response_finished:
|
||||
self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed")
|
||||
from app.services.request_traffic_service import RequestTrafficService
|
||||
|
||||
await RequestTrafficService.record(
|
||||
scope.get("path", ""),
|
||||
status_code,
|
||||
(perf_counter() - started_at) * 1000,
|
||||
)
|
||||
request_id_context.reset(token)
|
||||
|
||||
def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@ from app.models.ai_config import ContentGenerationConfig, ModelConfig, Prompt, S
|
||||
from app.models.base import Base
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
||||
from app.models.feedback import MessageFeedback
|
||||
from app.models.growth import GrowthProfileRevision, PeriodicReport, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile
|
||||
from app.models.insight import QuestionInsightCleanedQuestion
|
||||
from app.models.knowledge import (
|
||||
@@ -50,6 +51,7 @@ __all__ = [
|
||||
"HumanAttentionHistory",
|
||||
"HumanAttentionRecord",
|
||||
"ModelConfig",
|
||||
"MessageFeedback",
|
||||
"OperationLog",
|
||||
"PeriodicReport",
|
||||
"LogRetentionPolicy",
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, String
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
@@ -15,6 +15,7 @@ class Role(Base, TimestampMixin):
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
permissions: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
|
||||
admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role")
|
||||
|
||||
@@ -29,5 +30,7 @@ class Admin(Base, TimestampMixin):
|
||||
role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True)
|
||||
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
must_change_password: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
is_super_admin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
role: Mapped[Role | None] = relationship("Role", back_populates="admins")
|
||||
|
||||
26
ai_knowledge_base_v2/apps/backend/app/models/feedback.py
Normal file
26
ai_knowledge_base_v2/apps/backend/app/models/feedback.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class MessageFeedback(Base):
|
||||
__tablename__ = "sys_message_feedback"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "message_id", name="uq_message_feedback_user_message"),
|
||||
Index("ix_message_feedback_read_created", "is_read", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), nullable=False, index=True)
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), nullable=False, index=True)
|
||||
message_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_message.id"), nullable=False, index=True)
|
||||
content: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
is_read: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
read_by: Mapped[int | None] = mapped_column(ForeignKey("sys_admin.id"), nullable=True)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
@@ -24,6 +24,29 @@ class AdminRead(ORMModel):
|
||||
username: str
|
||||
name: str
|
||||
status: int
|
||||
isSuperAdmin: bool = False
|
||||
mustChangePassword: bool = False
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ManagedAdminCreateRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||
name: str = Field(min_length=1, max_length=50)
|
||||
initialPassword: str = Field(min_length=8, max_length=100)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
permissions: list[str] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
class ManagedAdminUpdateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=50)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
permissions: list[str] = Field(default_factory=list, max_length=100)
|
||||
resetPassword: str | None = Field(default=None, min_length=8, max_length=100)
|
||||
|
||||
|
||||
class AdminPasswordChangeRequest(BaseModel):
|
||||
currentPassword: str = Field(min_length=1, max_length=100)
|
||||
newPassword: str = Field(min_length=8, max_length=100)
|
||||
|
||||
|
||||
class CostBreakdownItem(BaseModel):
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models.admin import Admin
|
||||
|
||||
|
||||
PERMISSION_TREE = [
|
||||
{"code": "dashboard", "name": "数据看板", "children": [{"code": "dashboard.view", "name": "查看看板"}]},
|
||||
{"code": "users", "name": "用户管理", "children": [{"code": "users.view", "name": "查看用户"}, {"code": "users.create", "name": "新增/导入"}, {"code": "users.edit", "name": "编辑/权益续期"}, {"code": "users.delete", "name": "删除用户"}]},
|
||||
{"code": "entitlements", "name": "权益管理", "children": [{"code": "entitlements.view", "name": "查看权益"}, {"code": "entitlements.edit", "name": "编辑权益"}]},
|
||||
{"code": "knowledge", "name": "知识库管理", "children": [{"code": "knowledge.view", "name": "查看知识库"}, {"code": "knowledge.edit", "name": "新增/编辑/同步"}, {"code": "knowledge.publish", "name": "开放/归档"}, {"code": "knowledge.delete", "name": "删除知识库"}]},
|
||||
{"code": "prompt", "name": "Agent 管理", "children": [{"code": "prompt.view", "name": "查看 Agent"}, {"code": "prompt.edit", "name": "编辑/测试 Agent"}]},
|
||||
{"code": "models", "name": "模型管理", "children": [{"code": "models.view", "name": "查看模型"}, {"code": "models.edit", "name": "新增/编辑/测试"}, {"code": "models.delete", "name": "删除模型"}]},
|
||||
{"code": "content-generation", "name": "内容生成", "children": [{"code": "content-generation.view", "name": "查看配置"}, {"code": "content-generation.edit", "name": "编辑/测试配置"}]},
|
||||
{"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]},
|
||||
{"code": "sso", "name": "应用接入", "children": [{"code": "sso.view", "name": "查看应用"}, {"code": "sso.edit", "name": "管理应用"}]},
|
||||
{"code": "records", "name": "记录审计", "children": [{"code": "records.view", "name": "查看/导出记录"}]},
|
||||
{"code": "retrievals", "name": "检索日志", "children": [{"code": "retrievals.view", "name": "查看检索日志"}]},
|
||||
{"code": "attention", "name": "人工关注", "children": [{"code": "attention.view", "name": "查看关注项"}, {"code": "attention.edit", "name": "处理关注项"}]},
|
||||
{"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]},
|
||||
{"code": "admins", "name": "管理员与权限", "superOnly": True, "children": [{"code": "admins.view", "name": "查看管理员"}, {"code": "admins.edit", "name": "新增/编辑管理员"}, {"code": "admins.delete", "name": "删除管理员"}]},
|
||||
]
|
||||
|
||||
ALL_PERMISSION_CODES = {child["code"] for group in PERMISSION_TREE for child in group["children"]}
|
||||
|
||||
|
||||
def permissions_for(admin: Admin) -> set[str]:
|
||||
if admin.is_super_admin:
|
||||
return set(ALL_PERMISSION_CODES)
|
||||
try:
|
||||
return set(json.loads(admin.role.permissions if admin.role else "[]")) & ALL_PERMISSION_CODES
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return set()
|
||||
|
||||
|
||||
def require_permission(admin: Admin, permission: str) -> None:
|
||||
if permission not in permissions_for(admin):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前管理员无此操作权限")
|
||||
@@ -14,6 +14,7 @@ from app.models.knowledge import Knowledge
|
||||
from app.models.logs import AiRequestLog, OperationLog
|
||||
from app.models.user import User
|
||||
from app.services.security_state_service import SecurityStateService
|
||||
from app.services.admin_permission_service import permissions_for
|
||||
|
||||
|
||||
DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"}
|
||||
@@ -66,7 +67,14 @@ class AdminAuthService:
|
||||
)
|
||||
cls.ensure_bootstrap_admin(db)
|
||||
admin = db.scalar(select(Admin).where(Admin.username == username))
|
||||
if admin is None or not verify_password(password, admin.password):
|
||||
password_valid = False
|
||||
if admin is not None:
|
||||
password_valid = (
|
||||
password == get_settings().bootstrap_admin_password
|
||||
if admin.is_super_admin
|
||||
else verify_password(password, admin.password)
|
||||
)
|
||||
if admin is None or not password_valid:
|
||||
SecurityStateService.record_failure(
|
||||
failure_key,
|
||||
limit=5,
|
||||
@@ -92,6 +100,9 @@ class AdminAuthService:
|
||||
"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)),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,6 +117,8 @@ class AdminAuthService:
|
||||
password=hash_password(password),
|
||||
name=name,
|
||||
status=1,
|
||||
must_change_password=0,
|
||||
is_super_admin=1,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.services.redis_client import get_redis_client
|
||||
|
||||
|
||||
logger = logging.getLogger("app.traffic")
|
||||
LOCAL_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
GRAIN_FORMATS = {
|
||||
"minute": "%Y%m%d%H%M",
|
||||
"hour": "%Y%m%d%H",
|
||||
"day": "%Y%m%d",
|
||||
"week": "%G-W%V",
|
||||
}
|
||||
GRAIN_LABELS = {
|
||||
"minute": "%Y-%m-%d %H:%M",
|
||||
"hour": "%Y-%m-%d %H:00",
|
||||
"day": "%Y-%m-%d",
|
||||
"week": "%G 年第 %V 周",
|
||||
}
|
||||
RETENTION_SECONDS = 60 * 60 * 24 * 8
|
||||
HISTORY_DAYS = 7
|
||||
EXCLUDED_PATHS = {"/api/health", "/api/ready", "/api/admin/dashboard/traffic"}
|
||||
|
||||
|
||||
class RequestTrafficService:
|
||||
@staticmethod
|
||||
async def record(path: str, status_code: int, duration_ms: float, now: datetime | None = None) -> None:
|
||||
if not path.startswith("/api/") or path in EXCLUDED_PATHS:
|
||||
return
|
||||
redis = get_redis_client()
|
||||
if redis is None:
|
||||
return
|
||||
current = (now or datetime.now(LOCAL_TIMEZONE)).astimezone(LOCAL_TIMEZONE)
|
||||
try:
|
||||
key = f"metrics:http:minute:{current.strftime(GRAIN_FORMATS['minute'])}"
|
||||
async with redis.pipeline(transaction=False) as pipeline:
|
||||
pipeline.hincrby(key, "requests", 1)
|
||||
pipeline.hincrby(key, "errors", 1 if status_code >= 500 else 0)
|
||||
pipeline.hincrbyfloat(key, "duration_ms", max(duration_ms, 0))
|
||||
pipeline.expire(key, RETENTION_SECONDS)
|
||||
await pipeline.execute()
|
||||
except Exception:
|
||||
logger.warning("request_traffic_record_failed", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
async def peak_traffic(grain: str) -> dict:
|
||||
if grain not in GRAIN_FORMATS:
|
||||
raise ValueError("不支持的时间粒度")
|
||||
redis = get_redis_client()
|
||||
empty = {"grain": grain, "peakPeriod": None, "peakRequests": 0, "totalRequests": 0, "rows": []}
|
||||
if redis is None:
|
||||
return empty
|
||||
try:
|
||||
keys = [key async for key in redis.scan_iter(match="metrics:http:minute:*", count=500)]
|
||||
if not keys:
|
||||
return empty
|
||||
cutoff = datetime.now(LOCAL_TIMEZONE) - timedelta(days=HISTORY_DAYS)
|
||||
prefix = "metrics:http:minute:"
|
||||
parsed_keys = []
|
||||
expired_keys = []
|
||||
for key in keys:
|
||||
try:
|
||||
minute = datetime.strptime(key.removeprefix(prefix), GRAIN_FORMATS["minute"]).replace(tzinfo=LOCAL_TIMEZONE)
|
||||
except ValueError:
|
||||
continue
|
||||
if minute >= cutoff:
|
||||
parsed_keys.append((key, minute))
|
||||
else:
|
||||
expired_keys.append(key)
|
||||
parsed_keys.sort(key=lambda item: item[1])
|
||||
async with redis.pipeline(transaction=False) as retention_pipeline:
|
||||
for key in expired_keys:
|
||||
retention_pipeline.unlink(key)
|
||||
for key, minute in parsed_keys:
|
||||
retention_pipeline.expireat(key, int((minute + timedelta(seconds=RETENTION_SECONDS)).timestamp()))
|
||||
await retention_pipeline.execute()
|
||||
if not parsed_keys:
|
||||
return empty
|
||||
async with redis.pipeline(transaction=False) as pipeline:
|
||||
for key, _minute in parsed_keys:
|
||||
pipeline.hgetall(key)
|
||||
values = await pipeline.execute()
|
||||
except Exception:
|
||||
logger.warning("request_traffic_query_failed", exc_info=True)
|
||||
return empty
|
||||
|
||||
buckets: dict[str, dict[str, float]] = {}
|
||||
for (_key, minute), value in zip(parsed_keys, values, strict=False):
|
||||
request_count = int(float(value.get("requests", 0)))
|
||||
if request_count <= 0:
|
||||
continue
|
||||
error_count = int(float(value.get("errors", 0)))
|
||||
duration_ms = float(value.get("duration_ms", 0))
|
||||
period = minute.strftime(GRAIN_FORMATS[grain])
|
||||
bucket = buckets.setdefault(period, {"requests": 0, "errors": 0, "duration_ms": 0})
|
||||
bucket["requests"] += request_count
|
||||
bucket["errors"] += error_count
|
||||
bucket["duration_ms"] += duration_ms
|
||||
|
||||
rows = []
|
||||
for period in _period_sequence(grain, cutoff, datetime.now(LOCAL_TIMEZONE)):
|
||||
bucket = buckets.get(period, {"requests": 0, "errors": 0, "duration_ms": 0})
|
||||
request_count = int(bucket["requests"])
|
||||
error_count = int(bucket["errors"])
|
||||
rows.append(
|
||||
{
|
||||
"period": period,
|
||||
"periodLabel": _format_period(grain, period),
|
||||
"requestCount": request_count,
|
||||
"errorCount": error_count,
|
||||
"errorRate": round(error_count / request_count * 100, 2) if request_count else 0,
|
||||
"avgResponseMs": round(bucket["duration_ms"] / request_count, 2) if request_count else 0,
|
||||
}
|
||||
)
|
||||
peak = max(rows, key=lambda item: item["requestCount"], default=None)
|
||||
return {
|
||||
"grain": grain,
|
||||
"peakPeriod": peak["periodLabel"] if peak else None,
|
||||
"peakRequests": peak["requestCount"] if peak else 0,
|
||||
"totalRequests": sum(row["requestCount"] for row in rows),
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def _format_period(grain: str, value: str) -> str:
|
||||
if grain == "week":
|
||||
year, week = value.split("-W", 1)
|
||||
return f"{year} 年第 {week} 周"
|
||||
parsed = datetime.strptime(value, GRAIN_FORMATS[grain])
|
||||
return parsed.strftime(GRAIN_LABELS[grain])
|
||||
|
||||
|
||||
def _period_sequence(grain: str, start: datetime, end: datetime) -> list[str]:
|
||||
if grain == "minute":
|
||||
current, step = start.replace(second=0, microsecond=0), timedelta(minutes=1)
|
||||
elif grain == "hour":
|
||||
current, step = start.replace(minute=0, second=0, microsecond=0), timedelta(hours=1)
|
||||
elif grain == "day":
|
||||
current, step = start.replace(hour=0, minute=0, second=0, microsecond=0), timedelta(days=1)
|
||||
elif grain == "week":
|
||||
current = start.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=start.weekday())
|
||||
step = timedelta(days=7)
|
||||
else:
|
||||
raise ValueError("不支持的时间粒度")
|
||||
periods = []
|
||||
while current <= end:
|
||||
periods.append(current.strftime(GRAIN_FORMATS[grain]))
|
||||
current += step
|
||||
return periods
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import io
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.services.secret_service import SecretService
|
||||
from app.services.security_state_service import SecurityStateService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_UPLOAD_BYTES = 8 * 1024 * 1024
|
||||
ALIYUN_MAX_AUDIO_BYTES = 2 * 1024 * 1024
|
||||
_token_cache: dict[str, tuple[str, float]] = {}
|
||||
_token_lock = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceInputConfig:
|
||||
enabled: bool
|
||||
max_duration_seconds: int
|
||||
app_key: str
|
||||
access_key_id: str
|
||||
access_key_secret: str
|
||||
endpoint: str
|
||||
|
||||
|
||||
class VoiceInputService:
|
||||
@staticmethod
|
||||
def public_config(db: Session) -> dict:
|
||||
config = load_voice_config(db)
|
||||
return {"enabled": config.enabled, "maxDurationSeconds": config.max_duration_seconds}
|
||||
|
||||
@staticmethod
|
||||
def transcribe(db: Session, user_id: int, audio: bytes, content_type: str | None) -> dict:
|
||||
config = load_voice_config(db)
|
||||
if not config.enabled:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="语音输入功能未开启")
|
||||
if not audio:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="录音内容为空")
|
||||
if len(audio) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="录音文件过大")
|
||||
SecurityStateService.enforce_limit(
|
||||
f"voice:transcribe:{user_id}",
|
||||
limit=10,
|
||||
window_seconds=60,
|
||||
message="语音识别请求过于频繁,请稍后再试",
|
||||
)
|
||||
missing = [name for name, value in (("AppKey", config.app_key), ("AccessKey ID", config.access_key_id), ("AccessKey Secret", config.access_key_secret)) if not value]
|
||||
if missing:
|
||||
raise HTTPException(status_code=503, detail=f"语音识别配置不完整:{', '.join(missing)}")
|
||||
|
||||
wav = _normalize_audio(audio, content_type)
|
||||
duration = _wav_duration(wav)
|
||||
if duration < 0.2:
|
||||
raise HTTPException(status_code=400, detail="录音时间太短,请重新录制")
|
||||
if duration > config.max_duration_seconds + 0.5 or duration > 60.5:
|
||||
raise HTTPException(status_code=400, detail=f"单条语音不能超过 {config.max_duration_seconds} 秒")
|
||||
if len(wav) > ALIYUN_MAX_AUDIO_BYTES:
|
||||
raise HTTPException(status_code=413, detail="转码后的录音文件过大")
|
||||
|
||||
token = _create_aliyun_token(config)
|
||||
params = {
|
||||
"appkey": config.app_key,
|
||||
"format": "wav",
|
||||
"sample_rate": 16000,
|
||||
"enable_punctuation_prediction": "true",
|
||||
"enable_inverse_text_normalization": "true",
|
||||
"enable_voice_detection": "true",
|
||||
}
|
||||
try:
|
||||
response = httpx.post(
|
||||
config.endpoint,
|
||||
params=params,
|
||||
headers={"X-NLS-Token": token, "Content-Type": "application/octet-stream"},
|
||||
content=wav,
|
||||
timeout=httpx.Timeout(20.0, connect=5.0),
|
||||
)
|
||||
payload = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning("Aliyun NLS request failed: %s", exc.__class__.__name__)
|
||||
raise HTTPException(status_code=502, detail="语音识别服务暂不可用,请稍后重试") from exc
|
||||
if response.status_code != 200 or int(payload.get("status", 0)) != 20000000:
|
||||
logger.warning("Aliyun NLS rejected request: status=%s code=%s", response.status_code, payload.get("status"))
|
||||
raise HTTPException(status_code=502, detail=_safe_provider_message(payload))
|
||||
text = str(payload.get("result") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=422, detail="没有识别到有效语音,请重新录制")
|
||||
return {"text": text, "durationSeconds": round(duration, 1)}
|
||||
|
||||
|
||||
def load_voice_config(db: Session) -> VoiceInputConfig:
|
||||
settings = get_settings()
|
||||
rows = db.scalars(select(SystemConfig)).all()
|
||||
values = {row.config_key: row.config_value for row in rows}
|
||||
enabled = _bool(values.get("voice_input_enabled"), settings.voice_input_enabled)
|
||||
duration = _int(values.get("voice_max_duration_seconds"), settings.voice_max_duration_seconds, 5, 60)
|
||||
access_key_id = str(values.get("aliyun_sms_access_key_id") or settings.aliyun_sms_access_key_id).strip()
|
||||
encrypted_secret = str(values.get("aliyun_sms_access_key_secret") or settings.aliyun_sms_access_key_secret).strip()
|
||||
return VoiceInputConfig(
|
||||
enabled=enabled,
|
||||
max_duration_seconds=duration,
|
||||
app_key=settings.aliyun_nls_app_key.strip(),
|
||||
access_key_id=access_key_id,
|
||||
access_key_secret=SecretService.decrypt(encrypted_secret),
|
||||
endpoint=settings.aliyun_nls_endpoint.strip(),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_audio(audio: bytes, content_type: str | None) -> bytes:
|
||||
if audio[:4] != b"RIFF" or audio[8:12] != b"WAVE":
|
||||
raise HTTPException(status_code=422, detail="当前录音格式无法处理,请更换浏览器后重试")
|
||||
return audio
|
||||
|
||||
|
||||
def _wav_duration(audio: bytes) -> float:
|
||||
try:
|
||||
with wave.open(io.BytesIO(audio), "rb") as wav_file:
|
||||
if wav_file.getnchannels() != 1 or wav_file.getsampwidth() != 2 or wav_file.getframerate() != 16000:
|
||||
raise HTTPException(status_code=422, detail="录音参数不正确,请重新录制")
|
||||
return wav_file.getnframes() / float(wav_file.getframerate())
|
||||
except wave.Error as exc:
|
||||
raise HTTPException(status_code=422, detail="录音文件已损坏,请重新录制") from exc
|
||||
|
||||
|
||||
def _create_aliyun_token(config: VoiceInputConfig) -> str:
|
||||
cached = _token_cache.get(config.access_key_id)
|
||||
if cached and cached[1] - 300 > time.time():
|
||||
return cached[0]
|
||||
try:
|
||||
from aliyunsdkcore.client import AcsClient
|
||||
from aliyunsdkcore.request import CommonRequest
|
||||
|
||||
client = AcsClient(config.access_key_id, config.access_key_secret, "cn-shanghai")
|
||||
request = CommonRequest()
|
||||
request.set_method("POST")
|
||||
request.set_domain("nls-meta.cn-shanghai.aliyuncs.com")
|
||||
request.set_version("2019-02-28")
|
||||
request.set_action_name("CreateToken")
|
||||
with _token_lock:
|
||||
cached = _token_cache.get(config.access_key_id)
|
||||
if cached and cached[1] - 300 > time.time():
|
||||
return cached[0]
|
||||
payload = json.loads(client.do_action_with_exception(request))
|
||||
token = str(payload["Token"]["Id"])
|
||||
expires_at = float(payload["Token"]["ExpireTime"])
|
||||
_token_cache[config.access_key_id] = (token, expires_at)
|
||||
return token
|
||||
except Exception as exc:
|
||||
logger.warning("Aliyun NLS token creation failed: %s", exc.__class__.__name__)
|
||||
raise HTTPException(status_code=502, detail="语音识别鉴权失败,请联系管理员检查阿里云权限") from exc
|
||||
|
||||
|
||||
def _safe_provider_message(payload: dict) -> str:
|
||||
code = str(payload.get("status") or "")
|
||||
if code in {"40070001", "40070002", "40070004"}:
|
||||
return "没有识别到有效语音,请重新录制"
|
||||
if code in {"40000001", "40000002", "40020503"}:
|
||||
return "语音识别鉴权失败,请联系管理员检查阿里云权限"
|
||||
return "语音识别失败,请稍后重试"
|
||||
|
||||
|
||||
def _bool(value: str | None, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _int(value: str | None, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
parsed = int(value) if value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
return max(minimum, min(maximum, parsed))
|
||||
Reference in New Issue
Block a user