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

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