feat: add admin permissions voice input analytics and feedback
This commit is contained in:
@@ -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