fix: complete admin knowledge audit improvements

This commit is contained in:
2026-07-13 11:40:34 +08:00
parent 82e0c02bac
commit 120fe185f9
16 changed files with 502 additions and 41 deletions

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -18,6 +19,8 @@ from app.models.knowledge import (
)
from app.schemas.knowledge import AttentionUpdateRequest
from app.services.admin_service import OperationLogService
from app.models.logs import LogRetentionPolicy
from app.services.maintenance_service import MaintenanceService
router = APIRouter()
@@ -29,10 +32,12 @@ def retrieval_logs(
knowledgeCalled: bool | None = Query(default=None),
statusValue: str = Query(default="", alias="status"),
attentionCreated: bool | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc()).limit(300)
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc())
if sessionId is not None:
query = query.where(KnowledgeRetrievalLog.session_id == sessionId)
if knowledgeId is not None:
@@ -43,9 +48,55 @@ def retrieval_logs(
query = query.where(KnowledgeRetrievalLog.status == statusValue)
if attentionCreated is not None:
query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0))
query = query.offset((page - 1) * pageSize).limit(pageSize)
return api_success([_retrieval_dict(item, detail=False) for item in db.scalars(query).all()])
@router.post("/retrieval-log/cleanup/estimate")
def estimate_cleanup(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
before = _cleanup_before(payload)
count = db.scalar(select(func.count()).select_from(KnowledgeRetrievalLog).where(KnowledgeRetrievalLog.created_at < before)) or 0
return api_success({"before": before, "estimatedCount": count})
@router.post("/retrieval-log/cleanup")
def cleanup_logs(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
before = _cleanup_before(payload)
total = MaintenanceService.delete_retrieval_logs(db, before)
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action=f"cleanup:{total}", target_id=None)
db.commit()
return api_success({"deleted": total, "before": before})
@router.get("/retrieval-log/retention")
def get_retention(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
return api_success({"enabled": bool(policy and policy.enabled), "retentionDays": policy.retention_days if policy else None, "lastRunAt": policy.last_run_at if policy else None, "lastResult": policy.last_result if policy else None})
@router.put("/retrieval-log/retention")
def set_retention(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
days = payload.get("retentionDays")
if days is not None and (not isinstance(days, int) or days < 1):
raise HTTPException(status_code=400, detail="保留天数必须为正整数,永久保留请传 null")
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1)) or LogRetentionPolicy()
policy.retention_days, policy.enabled, policy.updated_by = days, 0 if days is None else 1, current_admin.id
db.add(policy)
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action="retention_update", target_id=None)
db.commit()
return api_success({"enabled": bool(policy.enabled), "retentionDays": policy.retention_days})
def _cleanup_before(payload: dict) -> datetime:
raw = str(payload.get("before") or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="必须指定清理日期")
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError as exc:
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
@router.get("/retrieval-log/{log_id}")
def retrieval_detail(
log_id: int,

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
import json
from pathlib import Path
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -11,6 +14,8 @@ from app.core.responses import api_success
from app.models.admin import Admin
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
router = APIRouter()
@@ -30,3 +35,53 @@ def dashboard(
end_dt = None
stats = AdminDashboardService.stats(db, start_dt, end_dt)
return api_success(DashboardStats.model_validate(stats).model_dump())
@router.get("/dashboard/storage")
def storage_stats(
refresh: bool = Query(default=False),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
latest = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == "project").order_by(StorageSnapshot.id.desc()).limit(1))
if latest is not None and not refresh:
return api_success(_snapshot_dict(latest, db))
detail: dict = {"database": {"status": "unavailable", "bytes": None, "tables": []}, "redis": {"status": "unavailable", "bytes": None}, "files": {"status": "unavailable", "bytes": None}}
errors: list[str] = []
try:
if db.bind and db.bind.dialect.name == "mysql":
rows = db.execute(text("SELECT table_name, data_length + index_length AS bytes FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY bytes DESC")).all()
tables = [{"name": row[0], "bytes": int(row[1] or 0)} for row in rows]
detail["database"] = {"status": "ok", "bytes": sum(x["bytes"] for x in tables), "tables": tables}
else:
detail["database"] = {"status": "unavailable", "bytes": None, "tables": [], "message": "当前数据库不支持精确表占用统计"}
except Exception as exc:
errors.append(f"database: {exc}")
try:
redis = get_sync_redis_client()
if redis is not None:
info = redis.info("memory")
detail["redis"] = {"status": "ok", "bytes": int(info.get("used_memory", 0))}
except Exception as exc:
errors.append(f"redis: {exc}")
try:
root = Path(".uploads")
if root.exists():
detail["files"] = {"status": "ok", "bytes": sum(p.stat().st_size for p in root.rglob("*") if p.is_file())}
else:
detail["files"] = {"status": "unavailable", "bytes": None, "message": "项目未配置本地附件目录"}
except Exception as exc:
errors.append(f"files: {exc}")
known = [x.get("bytes") for x in detail.values() if x.get("status") == "ok"]
snapshot = StorageSnapshot(scope="project", total_bytes=sum(known) if known else None, detail_json=json.dumps(detail, ensure_ascii=False), status="partial" if errors or any(x.get("status") != "ok" for x in detail.values()) else "ok", error_message="; ".join(errors) or None)
db.add(snapshot); db.commit(); db.refresh(snapshot)
return api_success(_snapshot_dict(snapshot, db))
def _snapshot_dict(item: StorageSnapshot, db: Session) -> dict:
with_growth = {"totalBytes": item.total_bytes, "detail": json.loads(item.detail_json), "status": item.status, "errorMessage": item.error_message, "createdAt": item.created_at}
# Growth is intentionally unknown until enough snapshots exist; never report a misleading zero.
for days, key in ((7, "growth7DaysBytes"), (30, "growth30DaysBytes")):
previous = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == item.scope, StorageSnapshot.created_at <= item.created_at - timedelta(days=days)).order_by(StorageSnapshot.created_at.desc()).limit(1))
with_growth[key] = item.total_bytes - previous.total_bytes if previous and item.total_bytes is not None and previous.total_bytes is not None else None
return with_growth

View File

@@ -36,6 +36,31 @@ def list_knowledge(db: Session = Depends(get_db), current_admin: Admin = Depends
return api_success([_knowledge_dict(item) for item in items])
@router.post("/knowledge/resolve-node")
def resolve_knowledge_node(
payload: dict,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
node_id = str(payload.get("nodeId") or "").strip()
if not node_id or len(node_id) > 100 or not node_id.replace("_", "").replace("-", "").isalnum():
raise HTTPException(status_code=400, detail="Node ID 格式错误")
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == node_id))
if existing is not None:
raise HTTPException(status_code=409, detail=f"该 Node ID 已添加(知识库:{existing.name}")
try:
info = FeishuKnowledgeService.get_node_info(node_id, db)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"飞书节点信息获取失败:{exc}") from exc
space_id, title = str(info.get("space_id") or "").strip(), str(info.get("title") or "").strip()
if not space_id:
raise HTTPException(status_code=422, detail="飞书接口未返回 Space ID请检查节点是否移动或当前应用权限")
if not title:
raise HTTPException(status_code=422, detail="飞书接口未返回文章标题,请检查文章是否已删除")
return api_success({"nodeId": node_id, "spaceId": space_id, "sourceTitle": title, "name": title, "remark": str(info.get("description") or ""), "objType": info.get("obj_type")})
@router.post("/knowledge/import")
def import_knowledge_from_space(
payload: dict,
@@ -91,12 +116,7 @@ def create_knowledge(
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
existing = db.scalar(
select(Knowledge).where(
Knowledge.feishu_space_id == payload.feishuSpaceId,
Knowledge.feishu_node_id == payload.feishuNodeId,
)
)
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == payload.feishuNodeId))
if existing is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库")
knowledge = Knowledge(