fix: 修复会话鉴权「登录后 token 无效」并补齐 NAS 部署流程
- deps.py: _create_redis_client 增加同步 ping 校验,Redis 不可达时正确降级为内存模式 - main.py: lifespan 复用 deps 的 UserStore 单例,避免 admin 与 API 请求实例不一致 - 前端: 强制改密弹窗(must_change_password 用户)、兼容新旧登录返回格式、markPasswordChanged - 新增 NAS SSH 部署脚本与批处理测试;gitignore 前端构建产物
This commit is contained in:
+12
-2
@@ -8,6 +8,7 @@ UserStore/SessionStore 为模块级懒加载单例:Redis 客户端创建失败
|
||||
import structlog
|
||||
from fastapi import Depends, Header
|
||||
from redis import asyncio as redis_async
|
||||
from redis import Redis as RedisSync
|
||||
|
||||
from app.api.response import ApiError
|
||||
from app.config import settings
|
||||
@@ -22,11 +23,20 @@ _session_store: SessionStore | None = None
|
||||
|
||||
|
||||
def _create_redis_client() -> redis_async.Redis | None:
|
||||
"""创建 redis.asyncio 客户端(decode_responses=True);失败返回 None 走内存降级"""
|
||||
"""创建 redis.asyncio 客户端(decode_responses=True);连接失败返回 None 走内存降级
|
||||
|
||||
注意:redis.asyncio.from_url() 仅构造客户端对象,不会实际连接 Redis,
|
||||
因此需要用同步客户端 ping 一次确认连接可用,否则后续操作才会报错,
|
||||
导致 UserStore/SessionStore 无法降级为内存模式。
|
||||
"""
|
||||
try:
|
||||
# 同步 ping 确认 Redis 可达(from_url 不会实际连接)
|
||||
sync_client = RedisSync.from_url(settings.redis_url, decode_responses=True)
|
||||
sync_client.ping()
|
||||
sync_client.close()
|
||||
return redis_async.from_url(settings.redis_url, decode_responses=True)
|
||||
except Exception:
|
||||
logger.warning("Redis 客户端创建失败,认证存储降级为内存模式", exc_info=True)
|
||||
logger.warning("Redis 连接失败,认证存储降级为内存模式", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -183,6 +183,149 @@ async def upload_document(
|
||||
)
|
||||
|
||||
|
||||
async def _process_single_upload(file: UploadFile) -> dict[str, Any]:
|
||||
"""处理单个上传文件:校验 → 提取文本 → 落盘 → 提交入库
|
||||
|
||||
供批量上传复用:返回成功 {"filename", "task_id"} 或失败 {"filename", "error"}。
|
||||
title/source 使用文件名默认值,不接受用户传入的额外表单字段。
|
||||
"""
|
||||
original_filename = file.filename or "unnamed"
|
||||
ext = Path(original_filename).suffix.lower()
|
||||
|
||||
# 1. 扩展名校验
|
||||
allowed = _allowed_extensions()
|
||||
if ext not in allowed:
|
||||
return {"filename": original_filename, "error": f"不支持的文件类型: {ext or '(无扩展名)'}"}
|
||||
|
||||
# 2. 读取字节并校验大小
|
||||
try:
|
||||
content = await file.read()
|
||||
except Exception as exc:
|
||||
return {"filename": original_filename, "error": f"文件读取失败: {exc}"}
|
||||
max_bytes = settings.upload_max_size_mb * 1024 * 1024
|
||||
if len(content) > max_bytes:
|
||||
return {"filename": original_filename, "error": f"文件超过大小上限: {settings.upload_max_size_mb}MB"}
|
||||
|
||||
# 3. 提取文本
|
||||
try:
|
||||
text = parse_file(original_filename, content)
|
||||
except ValueError as exc:
|
||||
return {"filename": original_filename, "error": str(exc)}
|
||||
if not text.strip():
|
||||
return {"filename": original_filename, "error": "无法从文件提取文本"}
|
||||
|
||||
# 4. 落盘(按 YYYY/MM 日期分片;失败仅 warning,不阻塞入库)
|
||||
doc_id = uuid.uuid4().hex
|
||||
metadata_dict: dict[str, str] = {}
|
||||
try:
|
||||
upload_dir = Path(settings.upload_dir).resolve()
|
||||
shard_subdir = datetime.now(UTC).strftime("%Y/%m")
|
||||
target_dir = upload_dir / shard_subdir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = target_dir / f"{doc_id}_{original_filename}"
|
||||
target.write_bytes(content)
|
||||
metadata_dict.update(
|
||||
{
|
||||
"raw_file_path": str(target),
|
||||
"original_filename": original_filename,
|
||||
"original_size_bytes": str(len(content)),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"上传文件已落盘", doc_id=doc_id, saved_path=str(target), size=len(content)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"上传文件落盘失败,仅做文本入库",
|
||||
doc_id=doc_id,
|
||||
filename=original_filename,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 5. 默认 title / source
|
||||
title = Path(original_filename).stem
|
||||
source = f"file:{original_filename}"
|
||||
|
||||
# 6. 提交入库流水线
|
||||
doc_input = DocumentInput(text=text, title=title, source=source, metadata=metadata_dict)
|
||||
task_id = await _get_task_manager().submit(doc_input)
|
||||
return {"filename": original_filename, "task_id": task_id}
|
||||
|
||||
|
||||
@router.post("/documents/upload-batch")
|
||||
async def upload_batch(
|
||||
files: list[UploadFile] = File(...),
|
||||
user: UserRecord = Depends(get_current_user),
|
||||
) -> JSONResponse:
|
||||
"""批量文件上传入库:逐文件复用单文件逻辑,收集成功与失败结果
|
||||
|
||||
返回 202 + {tasks: [{filename, task_id}], failed: [{filename, error}]};
|
||||
空文件列表返回 1001。
|
||||
"""
|
||||
if not files:
|
||||
raise ApiError(1001, "未提供任何文件")
|
||||
tasks: list[dict[str, Any]] = []
|
||||
failed: list[dict[str, Any]] = []
|
||||
for file in files:
|
||||
result = await _process_single_upload(file)
|
||||
if "task_id" in result:
|
||||
tasks.append({"filename": result["filename"], "task_id": result["task_id"]})
|
||||
else:
|
||||
failed.append({"filename": result["filename"], "error": result["error"]})
|
||||
return JSONResponse(status_code=202, content=ok({"tasks": tasks, "failed": failed}))
|
||||
|
||||
|
||||
@router.get("/documents/tasks")
|
||||
async def list_ingest_tasks(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
user: UserRecord = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""列出近期入库任务(合并内存与 Redis 镜像,按 updated_at 降序)"""
|
||||
tasks = await _get_task_manager().list_tasks(limit=limit)
|
||||
return ok({"items": tasks, "total": len(tasks)})
|
||||
|
||||
|
||||
@router.post("/documents/{doc_id}/reingest")
|
||||
async def reingest_document(
|
||||
doc_id: str, user: UserRecord = Depends(get_current_user)
|
||||
) -> JSONResponse:
|
||||
"""重新入库:读原始文件 → 删旧数据 → 提交新入库任务
|
||||
|
||||
仅对有原始文件落盘记录的文档可重新入库;重新入库会生成新 doc_id。
|
||||
"""
|
||||
meta = await _get_qdrant().get_l1_metadata(doc_id)
|
||||
if meta is None:
|
||||
raise ApiError(1004, "文档不存在")
|
||||
raw_file_path = meta.get("raw_file_path", "")
|
||||
if not raw_file_path:
|
||||
raise ApiError(1001, "该文档无原始文件,无法重新入库")
|
||||
path = Path(raw_file_path)
|
||||
if not path.is_file():
|
||||
raise ApiError(1004, "文件不存在")
|
||||
|
||||
# 读原文件并重新提取文本
|
||||
try:
|
||||
text = parse_file(meta.get("original_filename", path.name), path.read_bytes())
|
||||
except ValueError as exc:
|
||||
raise ApiError(1001, str(exc)) from exc
|
||||
if not text.strip():
|
||||
raise ApiError(1001, "无法从文件提取文本")
|
||||
|
||||
# 删旧数据(四层集合按 doc_id 清除,幂等)
|
||||
await _get_qdrant().delete_by_doc_id(doc_id)
|
||||
|
||||
# 构造新文档输入(保留原 metadata,title/source 从 meta 取或文件名回退)
|
||||
original_filename = meta.get("original_filename", path.name)
|
||||
title = meta.get("title") or Path(original_filename).stem
|
||||
source = meta.get("source") or f"file:{original_filename}"
|
||||
doc_input = DocumentInput(text=text, title=title, source=source, metadata=dict(meta))
|
||||
|
||||
task_id = await _get_task_manager().submit(doc_input)
|
||||
return JSONResponse(
|
||||
status_code=202, content=ok({"task_id": task_id, "status": "pending"})
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents/tasks/{task_id}")
|
||||
async def get_ingest_task(task_id: str) -> dict[str, Any]:
|
||||
"""查询入库任务状态:含 task_id/status/created_at/updated_at,done 附 result,failed 附 error"""
|
||||
|
||||
@@ -11,7 +11,8 @@ from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.response import ApiError, ok
|
||||
from app.core.auth import AuthUser, get_current_user, require_admin
|
||||
from app.api.deps import get_current_user, require_admin
|
||||
from app.core.users import UserRecord
|
||||
from app.core.dedup import invalidate_dedup_strategy_cache
|
||||
from app.core.file_parser import (
|
||||
list_docx_plugins,
|
||||
@@ -46,7 +47,7 @@ class SettingsUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
|
||||
async def get_settings(user: UserRecord = Depends(get_current_user)) -> dict[str, Any]:
|
||||
"""返回当前 RuntimeSettings(任何登录用户可读)"""
|
||||
cfg = get_runtime_settings()
|
||||
return ok(cfg.model_dump(mode="json"))
|
||||
@@ -55,7 +56,7 @@ async def get_settings(user: AuthUser = Depends(get_current_user)) -> dict[str,
|
||||
@router.put("/settings")
|
||||
async def update_settings(
|
||||
body: SettingsUpdateRequest,
|
||||
user: AuthUser = Depends(require_admin),
|
||||
user: UserRecord = Depends(require_admin),
|
||||
) -> dict[str, Any]:
|
||||
"""部分更新 RuntimeSettings(仅 admin)
|
||||
|
||||
@@ -82,7 +83,7 @@ async def update_settings(
|
||||
|
||||
@router.get("/settings/schema")
|
||||
async def get_settings_schema(
|
||||
user: AuthUser = Depends(get_current_user),
|
||||
user: UserRecord = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""返回可选插件与策略列表(前端 Settings 页渲染选项用)"""
|
||||
return ok(
|
||||
@@ -98,7 +99,7 @@ async def get_settings_schema(
|
||||
|
||||
@router.post("/settings/reset")
|
||||
async def reset_settings(
|
||||
user: AuthUser = Depends(require_admin),
|
||||
user: UserRecord = Depends(require_admin),
|
||||
) -> dict[str, Any]:
|
||||
"""重置 RuntimeSettings 为默认值(仅 admin),同时清缓存"""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user