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

@@ -58,3 +58,5 @@ TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=admin123456
BOOTSTRAP_ADMIN_NAME=系统管理员
ALIYUN_NLS_APP_KEY=
ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr

View File

@@ -0,0 +1,30 @@
"""add administrator permission management
Revision ID: 0031_admin_permissions
Revises: 0030_sso_chat_source
"""
from alembic import op
import sqlalchemy as sa
revision = "0031_admin_permissions"
down_revision = "0030_sso_chat_source"
branch_labels = None
depends_on = None
def upgrade() -> None:
# MySQL does not permit a DEFAULT on TEXT on all supported versions.
op.add_column("sys_role", sa.Column("permissions", sa.Text(), nullable=True))
op.execute("UPDATE sys_role SET permissions = '[]' WHERE permissions IS NULL")
op.alter_column("sys_role", "permissions", existing_type=sa.Text(), nullable=False)
op.add_column("sys_admin", sa.Column("must_change_password", sa.Integer(), nullable=False, server_default="1"))
op.add_column("sys_admin", sa.Column("is_super_admin", sa.Integer(), nullable=False, server_default="0"))
# Existing installations have one bootstrap administrator. Preserve it as the immutable super administrator.
op.execute("UPDATE sys_admin SET is_super_admin = 1, must_change_password = 0 WHERE id = (SELECT id FROM (SELECT MIN(id) AS id FROM sys_admin) AS first_admin)")
def downgrade() -> None:
op.drop_column("sys_admin", "is_super_admin")
op.drop_column("sys_admin", "must_change_password")
op.drop_column("sys_role", "permissions")

View File

@@ -0,0 +1,36 @@
"""add message feedback management
Revision ID: 0032_message_feedback
Revises: 0031_admin_permissions
"""
from alembic import op
import sqlalchemy as sa
revision = "0032_message_feedback"
down_revision = "0031_admin_permissions"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_message_feedback",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("session_id", sa.BigInteger(), sa.ForeignKey("sys_chat_session.id"), nullable=False),
sa.Column("message_id", sa.BigInteger(), sa.ForeignKey("sys_chat_message.id"), nullable=False),
sa.Column("content", sa.String(200), nullable=False),
sa.Column("is_read", sa.Integer(), nullable=False, server_default="0"),
sa.Column("read_by", sa.BigInteger(), sa.ForeignKey("sys_admin.id"), nullable=True),
sa.Column("read_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "message_id", name="uq_message_feedback_user_message"),
)
op.create_index("ix_message_feedback_user_id", "sys_message_feedback", ["user_id"])
op.create_index("ix_message_feedback_session_id", "sys_message_feedback", ["session_id"])
op.create_index("ix_message_feedback_message_id", "sys_message_feedback", ["message_id"])
op.create_index("ix_message_feedback_read_created", "sys_message_feedback", ["is_read", "created_at"])
def downgrade() -> None:
op.drop_table("sys_message_feedback")

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))

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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",

View File

@@ -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")

View 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)

View File

@@ -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):

View File

@@ -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="当前管理员无此操作权限")

View File

@@ -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()

View File

@@ -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

View File

@@ -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))

View File

@@ -13,4 +13,5 @@ redis==7.4.1
openpyxl==3.1.5
python-multipart==0.0.32
alibabacloud-dysmsapi20170525==4.6.0
aliyun-python-sdk-core==2.16.0
Pillow==12.3.0

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import json
import pytest
from fastapi import HTTPException
from app.models.admin import Admin, Role
from app.services.admin_permission_service import ALL_PERMISSION_CODES, permissions_for, require_permission
def test_super_admin_has_all_permissions() -> None:
admin = Admin(id=1, username="root", password="unused", name="root", status=1, is_super_admin=1)
assert permissions_for(admin) == ALL_PERMISSION_CODES
def test_role_permissions_are_restricted_to_catalog() -> None:
role = Role(code="operator", name="operator", permissions=json.dumps(["users.view", "unknown.permission"]))
admin = Admin(id=2, username="operator", password="hash", name="operator", status=1, is_super_admin=0, role=role)
assert permissions_for(admin) == {"users.view"}
require_permission(admin, "users.view")
with pytest.raises(HTTPException) as exc:
require_permission(admin, "users.delete")
assert exc.value.status_code == 403

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.feedback import FeedbackCreate, create_feedback, delete_feedback, feedback_detail, feedback_list
from app.models import Base
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.user import User
def test_feedback_binds_target_answer_and_exposes_context() -> None:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
with Session(engine) as db:
user = User(id=1, phone="13800000000", name="测试用户")
admin = Admin(id=1, username="admin", password="hash", name="管理员", is_super_admin=1, must_change_password=0)
db.add_all([user, admin]); db.flush()
session = ChatSession(id=1, user_id=user.id, title="测试会话", message_count=2)
db.add(session); db.flush()
question = ChatMessage(id=1, session_id=session.id, user_id=user.id, role="user", content="问题", message_status="FINISHED")
answer = ChatMessage(id=2, session_id=session.id, user_id=user.id, role="assistant", content="回答", message_status="FINISHED")
db.add_all([question, answer]); db.commit()
created = create_feedback(FeedbackCreate(messageId=answer.id, content="这条回答不准确"), db=db, user=user)["data"]
listed = feedback_list(readStatus="unread", page=1, pageSize=20, db=db, _admin=admin)["data"]
assert listed["total"] == 1
detail = feedback_detail(created["id"], db=db, admin=admin)["data"]
assert detail["messageId"] == answer.id
assert [item["content"] for item in detail["messages"]] == ["问题", "回答"]
assert detail["messages"][-1]["isTarget"] is True
delete_feedback(created["id"], db=db, admin=admin)
assert feedback_list(readStatus="all", page=1, pageSize=20, db=db, _admin=admin)["data"]["total"] == 0

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from app.services.request_traffic_service import _format_period, _period_sequence
def test_peak_traffic_period_labels_cover_all_grains() -> None:
assert _format_period("minute", "202608111405") == "2026-08-11 14:05"
assert _format_period("hour", "2026081114") == "2026-08-11 14:00"
assert _format_period("day", "20260811") == "2026-08-11"
assert _format_period("week", "2026-W33") == "2026 年第 33 周"
def test_period_sequence_includes_empty_time_buckets() -> None:
timezone = ZoneInfo("Asia/Shanghai")
start = datetime(2026, 8, 11, 14, 58, tzinfo=timezone)
end = datetime(2026, 8, 11, 15, 1, tzinfo=timezone)
assert _period_sequence("minute", start, end) == ["202608111458", "202608111459", "202608111500", "202608111501"]
assert _period_sequence("hour", start, end) == ["2026081114", "2026081115"]

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.models import Base
from app.models.ai_config import SystemConfig
from app.services.voice_input_service import VoiceInputService, load_voice_config
def database() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def settings(**overrides):
values = {
"voice_input_enabled": False,
"voice_max_duration_seconds": 60,
"aliyun_nls_app_key": "app-key",
"aliyun_nls_endpoint": "https://nls.example/asr",
"aliyun_sms_access_key_id": "access-id",
"aliyun_sms_access_key_secret": "access-secret",
}
values.update(overrides)
return SimpleNamespace(**values)
def test_public_config_is_hidden_by_default_and_duration_is_clamped(monkeypatch):
monkeypatch.setattr("app.services.voice_input_service.get_settings", lambda: settings())
with database() as db:
db.add_all([
SystemConfig(config_key="voice_input_enabled", config_value="true"),
SystemConfig(config_key="voice_max_duration_seconds", config_value="999"),
])
db.commit()
assert VoiceInputService.public_config(db) == {"enabled": True, "maxDurationSeconds": 60}
def test_transcription_endpoint_is_rejected_when_switch_is_off(monkeypatch):
monkeypatch.setattr("app.services.voice_input_service.get_settings", lambda: settings())
with database() as db, pytest.raises(HTTPException) as exc:
VoiceInputService.transcribe(db, 1, b"audio", "audio/webm")
assert exc.value.status_code == 404
def test_transcription_returns_provider_text(monkeypatch):
monkeypatch.setattr("app.services.voice_input_service.get_settings", lambda: settings(voice_input_enabled=True))
monkeypatch.setattr("app.services.voice_input_service._normalize_audio", lambda *_: b"wav")
monkeypatch.setattr("app.services.voice_input_service._wav_duration", lambda *_: 8.4)
monkeypatch.setattr("app.services.voice_input_service._create_aliyun_token", lambda *_: "token")
monkeypatch.setattr("app.services.voice_input_service.SecurityStateService.enforce_limit", lambda *_, **__: None)
class Response:
status_code = 200
@staticmethod
def json():
return {"status": 20000000, "result": "这是转写结果。"}
monkeypatch.setattr("app.services.voice_input_service.httpx.post", lambda *_, **__: Response())
with database() as db:
result = VoiceInputService.transcribe(db, 1, b"audio", "audio/webm")
assert result == {"text": "这是转写结果。", "durationSeconds": 8.4}