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:
@@ -9,6 +9,9 @@ build/
|
||||
# Frontend
|
||||
node_modules/
|
||||
|
||||
# 前端构建产物(本地残留的 Vue 构建 / 符号链接,勿提交;线上由 frontend/dist 挂载)
|
||||
app/static/admin
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -90,12 +90,15 @@ QMDSearch/
|
||||
| POST | `/api/v1/search` | 分层检索 | 免登录 |
|
||||
| POST | `/api/v1/documents` | 文档入库(202 异步入库,返回 task_id) | Bearer |
|
||||
| POST | `/api/v1/documents/upload` | multipart 文件上传入库(202 异步,支持 .txt/.md/.html/.htm/.pdf/.docx) | Bearer |
|
||||
| POST | `/api/v1/documents/upload-batch` | 批量文件上传入库(多文件,202 异步) | Bearer |
|
||||
| GET | `/api/v1/documents/tasks` | 入库任务列表(近期,按时间降序) | Bearer |
|
||||
| GET | `/api/v1/documents/tasks/{task_id}` | 入库任务状态查询(done 附 result,failed 附 error) | 免登录 |
|
||||
| GET | `/api/v1/knowledge/categories` | 知识分类类目集 | 免登录 |
|
||||
| GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) | 免登录 |
|
||||
| GET | `/api/v1/documents` | 文档列表(limit/offset 分页) | 免登录 |
|
||||
| GET | `/api/v1/documents/{doc_id}` | 文档详情 | 免登录 |
|
||||
| GET | `/api/v1/documents/{doc_id}/file` | 下载关联的原始文件(免登录) | 免登录 |
|
||||
| POST | `/api/v1/documents/{doc_id}/reingest` | 重新摘要入库(读原文件→删旧→重跑流水线) | Bearer |
|
||||
| DELETE | `/api/v1/documents/{doc_id}` | 删除文档(幂等) | Bearer |
|
||||
| POST | `/api/v1/auth/login` | 用户名密码登录,签发 session token(TTL 12h) | 免登录 |
|
||||
| POST | `/api/v1/auth/logout` | 退出登录(删除当前 session) | Bearer |
|
||||
@@ -108,13 +111,13 @@ QMDSearch/
|
||||
| DELETE | `/api/v1/auth/users/{username}` | 删除用户(清除其 session;禁删自己/最后一个 admin) | Bearer + admin |
|
||||
| GET | `/admin` | 管理页面 | 页面登录门禁 |
|
||||
|
||||
「Bearer」指请求头 `Authorization: Bearer <token>`,token 经 `/api/v1/auth/login` 获取;变更类文档端点(POST /documents、POST /documents/upload、DELETE /documents/{id})需 Bearer token(admin/user 角色均可),查询类端点免登录。
|
||||
「Bearer」指请求头 `Authorization: Bearer <token>`,token 经 `/api/v1/auth/login` 获取;变更类文档端点(POST /documents、POST /documents/upload、POST /documents/upload-batch、POST /documents/{id}/reingest、DELETE /documents/{id})与 GET /documents/tasks 任务列表需 Bearer token(admin/user 角色均可),其余查询类端点免登录。
|
||||
|
||||
入库任务状态持久化在 Redis(key: `ingest_task:{task_id}`):进行中与 done 保留 24h,failed 保留 7 天;Redis 不可用时降级为纯内存。
|
||||
|
||||
## 管理页面
|
||||
|
||||
浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含八个区块(admin 视角;user 角色无用户管理区块):概览、个人中心(user 角色默认进入且可见,含账号信息/改密/退出)、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含搜索筛选/行内编辑角色与启用状态/弹窗式重置密码与删除)。
|
||||
浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含九个区块(admin 视角;user 角色无用户管理区块):概览、个人中心(user 角色默认进入且可见,含账号信息/改密/退出)、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传,文件上传支持批量多选)、入库进度(展示近期任务与状态,2s 轮询自动刷新)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含搜索筛选/行内编辑角色与启用状态/弹窗式重置密码与删除)。
|
||||
|
||||
## 认证与用户
|
||||
|
||||
|
||||
+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:
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Any
|
||||
import structlog
|
||||
|
||||
from app.config import Settings
|
||||
from app.core.dedup import DEDUP_KEY_PREFIX, get_dedup_strategy
|
||||
from app.core.dedup import get_dedup_strategy
|
||||
from app.core.ingestion import Ingester, IngestionError
|
||||
from app.models.document import DocumentInput
|
||||
from app.services.redis import RedisCache
|
||||
@@ -79,6 +79,7 @@ class IngestTaskManager:
|
||||
"""
|
||||
task_id = uuid.uuid4().hex
|
||||
now = _utc_now_iso()
|
||||
filename = self._extract_filename(doc)
|
||||
dedup = get_dedup_strategy(self._redis)
|
||||
|
||||
# 1. 去重命中:直接置 done,复用旧结果,不调 _run
|
||||
@@ -93,6 +94,7 @@ class IngestTaskManager:
|
||||
"updated_at": now,
|
||||
"result": result_dict,
|
||||
"error": None,
|
||||
"filename": filename,
|
||||
}
|
||||
self._schedule_mirror(task_id)
|
||||
logger.info(
|
||||
@@ -110,6 +112,7 @@ class IngestTaskManager:
|
||||
"updated_at": now,
|
||||
"result": None,
|
||||
"error": None,
|
||||
"filename": filename,
|
||||
}
|
||||
self._schedule_mirror(task_id)
|
||||
background = asyncio.create_task(self._run(task_id, doc, dedup))
|
||||
@@ -135,6 +138,72 @@ class IngestTaskManager:
|
||||
)
|
||||
return None
|
||||
|
||||
async def list_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""合并内存注册表与 Redis 镜像,去重,按 updated_at 降序,截断 limit
|
||||
|
||||
每项提取 {task_id, status, filename, created_at, updated_at, doc_id}:
|
||||
doc_id 从 done 任务的 result.document_id 提取,其余状态为 None。
|
||||
"""
|
||||
records: dict[str, dict[str, Any]] = {}
|
||||
# 1. 内存注册表(主)
|
||||
for task_id, record in self._tasks.items():
|
||||
records[task_id] = record
|
||||
# 2. Redis 镜像补充内存中没有的(内存未命中的任务,如重启后只存在 Redis 的历史记录)
|
||||
for task_id, record in await self._scan_redis_tasks():
|
||||
records.setdefault(task_id, record)
|
||||
# 3. 按 updated_at 降序,截断 limit
|
||||
sorted_records = sorted(
|
||||
records.values(),
|
||||
key=lambda r: r.get("updated_at") or "",
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
# 4. 提取展示字段
|
||||
return [self._to_list_item(r) for r in sorted_records]
|
||||
|
||||
async def _scan_redis_tasks(self) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""扫描 Redis 中 ingest_task:* 键,返回 (task_id, record) 列表
|
||||
|
||||
RedisCache 未暴露公开扫描接口,借道底层 _get_client().scan_iter;
|
||||
任何异常降级为空列表,不影响 list_tasks 主流程。
|
||||
"""
|
||||
if self._redis is None:
|
||||
return []
|
||||
get_client = getattr(self._redis, "_get_client", None)
|
||||
if not callable(get_client):
|
||||
return []
|
||||
try:
|
||||
client = get_client()
|
||||
tasks: list[tuple[str, dict[str, Any]]] = []
|
||||
async for key in client.scan_iter(match=f"{REDIS_KEY_PREFIX}*"):
|
||||
key_str = key if isinstance(key, str) else key.decode("utf-8", "replace")
|
||||
task_id = key_str[len(REDIS_KEY_PREFIX) :]
|
||||
record = await self._redis.get_json(key_str)
|
||||
if isinstance(record, dict):
|
||||
tasks.append((task_id, record))
|
||||
return tasks
|
||||
except Exception:
|
||||
logger.warning("扫描 Redis 任务键失败", exc_info=True)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _extract_filename(doc: DocumentInput) -> str | None:
|
||||
"""从文档输入提取展示用文件名:优先 metadata.original_filename,其次 title"""
|
||||
return doc.metadata.get("original_filename") or doc.title or None
|
||||
|
||||
@staticmethod
|
||||
def _to_list_item(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从完整任务记录提取列表展示字段"""
|
||||
result = record.get("result")
|
||||
doc_id = result.get("document_id") if isinstance(result, dict) else None
|
||||
return {
|
||||
"task_id": record.get("task_id"),
|
||||
"status": record.get("status"),
|
||||
"filename": record.get("filename"),
|
||||
"created_at": record.get("created_at"),
|
||||
"updated_at": record.get("updated_at"),
|
||||
"doc_id": doc_id,
|
||||
}
|
||||
|
||||
async def wait_done(self, task_id: str, timeout: float = 30.0) -> dict[str, Any]:
|
||||
"""轮询内存注册表直到任务进入终态(done/failed)或超时
|
||||
|
||||
|
||||
+9
-10
@@ -6,7 +6,6 @@ import structlog
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from redis import asyncio as redis_async
|
||||
|
||||
from app.api.response import ApiError, error
|
||||
from app.api.v1.auth import router as auth_router
|
||||
@@ -15,7 +14,7 @@ from app.api.v1.knowledge import router as knowledge_router
|
||||
from app.api.v1.search import router as search_router
|
||||
from app.api.v1.settings import router as settings_router
|
||||
from app.config import settings
|
||||
from app.core.users import UserStore, bootstrap_admin
|
||||
from app.core.users import bootstrap_admin
|
||||
from app.services.qdrant import QdrantService
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -33,13 +32,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception:
|
||||
logger.error("Qdrant 集合初始化失败,跳过初始化继续启动")
|
||||
try:
|
||||
# Redis 客户端创建失败(如 URL 非法)时传 None,UserStore 降级为内存模式
|
||||
redis_client = redis_async.from_url(settings.redis_url, decode_responses=True)
|
||||
except Exception:
|
||||
redis_client = None
|
||||
try:
|
||||
# 复用 deps 的 UserStore 单例,确保 lifespan 创建的 admin 与 API 请求用的是同一实例
|
||||
# _get_user_store 内部会检测 Redis 可达性,不可达时降级为内存模式
|
||||
from app.api.deps import _get_user_store
|
||||
|
||||
user_store = _get_user_store()
|
||||
# 空库引导默认管理员;明文密码由 bootstrap_admin 内部 warning 打印一次
|
||||
await bootstrap_admin(UserStore(redis_client), logger)
|
||||
await bootstrap_admin(user_store, logger)
|
||||
except Exception:
|
||||
logger.warning("默认管理员初始化失败,跳过", exc_info=True)
|
||||
try:
|
||||
@@ -150,8 +149,8 @@ async def admin_spa(rest: str):
|
||||
_AGENT_SKILL_ZIP = _STATIC_DIR / "agent-skill" / "QMDSearch-Agent-Skill.zip"
|
||||
|
||||
|
||||
@app.get("/agent-skill", include_in_schema=False)
|
||||
async def agent_skill_download() -> FileResponse | JSONResponse:
|
||||
@app.get("/agent-skill", include_in_schema=False, response_model=None)
|
||||
async def agent_skill_download():
|
||||
"""下载 AI Agent Skill 压缩包(qmdsearch-agent skill,含 SKILL.md 与示例)"""
|
||||
if not _AGENT_SKILL_ZIP.is_file():
|
||||
return JSONResponse(
|
||||
|
||||
+221
-25
@@ -259,6 +259,7 @@
|
||||
<button type="button" data-target="section-overview" class="active">概览</button>
|
||||
<button type="button" data-target="section-docs">文档管理</button>
|
||||
<button type="button" data-target="section-ingest">文档入库</button>
|
||||
<button type="button" data-target="section-progress" id="nav-progress">入库进度</button>
|
||||
<button type="button" data-target="section-search">检索测试台</button>
|
||||
<button type="button" data-target="section-categories">类目列表</button>
|
||||
<button type="button" data-target="section-api-guide">API 指南</button>
|
||||
@@ -340,8 +341,8 @@
|
||||
<h3 style="font-size:14px; margin-top:24px;">或上传文件</h3>
|
||||
<form id="upload-form">
|
||||
<div class="field">
|
||||
<label for="upload-file">选择文件(支持 .txt/.md/.html/.htm/.pdf/.docx)</label>
|
||||
<input type="file" id="upload-file" name="file" accept=".txt,.md,.html,.htm,.pdf,.docx" required>
|
||||
<label for="upload-file">选择文件(支持多选,.txt/.md/.html/.htm/.pdf/.docx)</label>
|
||||
<input type="file" id="upload-file" name="file" accept=".txt,.md,.html,.htm,.pdf,.docx" multiple required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="upload-title">标题(可选,默认取文件名)</label>
|
||||
@@ -356,6 +357,21 @@
|
||||
<div class="result-box hidden" id="ingest-result"></div>
|
||||
</section>
|
||||
|
||||
<section id="section-progress" class="hidden">
|
||||
<h2>入库进度</h2>
|
||||
<div class="error-bar hidden" id="error-progress"></div>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="action" id="btn-refresh-progress">刷新</button>
|
||||
<span class="muted" id="progress-stats"></span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>文件名</th><th>状态</th><th>创建时间</th><th>更新时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="progress-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="section-search" class="hidden">
|
||||
<h2>检索测试台</h2>
|
||||
<div class="error-bar hidden" id="error-search"></div>
|
||||
@@ -505,6 +521,7 @@ function showLogin() {
|
||||
document.getElementById("password-overlay").classList.add("hidden");
|
||||
document.getElementById("user-area").classList.add("hidden");
|
||||
loadedOnce = {};
|
||||
stopProgressPolling();
|
||||
unmountUsersSection();
|
||||
}
|
||||
|
||||
@@ -858,6 +875,8 @@ function truncate(text, maxLen) {
|
||||
var loadedOnce = {};
|
||||
|
||||
function activateSection(targetId) {
|
||||
/* 切走前停止进度区块轮询(切到其他区块即暂停) */
|
||||
stopProgressPolling();
|
||||
var sections = document.querySelectorAll("main section");
|
||||
sections.forEach(function (sec) {
|
||||
sec.classList.toggle("hidden", sec.id !== targetId);
|
||||
@@ -875,6 +894,8 @@ function activateSection(targetId) {
|
||||
if (targetId === "section-api-guide") { renderApiGuide(); }
|
||||
if (targetId === "section-profile") { loadProfile(); }
|
||||
}
|
||||
/* 切到入库进度区块时恢复轮询 */
|
||||
if (targetId === "section-progress") { startProgressPolling(); }
|
||||
}
|
||||
|
||||
document.getElementById("nav").addEventListener("click", function (event) {
|
||||
@@ -1158,12 +1179,17 @@ function restoreIngestButton() {
|
||||
}
|
||||
|
||||
function makeStatusBadge(status) {
|
||||
var badge = statusBadgeEl(status);
|
||||
badge.id = "ingest-status-badge";
|
||||
return badge;
|
||||
}
|
||||
|
||||
/* 状态徽章构造:复用 INGEST_STATUS_TEXT 中文映射与 status-* 配色,无 id 可重复使用 */
|
||||
function statusBadgeEl(status) {
|
||||
var cls = "status-running";
|
||||
if (status === "done") { cls = "status-done"; }
|
||||
if (status === "failed") { cls = "status-failed"; }
|
||||
var badge = el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
|
||||
badge.id = "ingest-status-badge";
|
||||
return badge;
|
||||
return el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
|
||||
}
|
||||
|
||||
function updateIngestStatus(status) {
|
||||
@@ -1290,26 +1316,178 @@ document.getElementById("upload-form").addEventListener("submit", function (even
|
||||
var submitBtn = document.getElementById("btn-upload-submit");
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "上传中…";
|
||||
var formData = new FormData();
|
||||
formData.append("file", fileInput.files[0]);
|
||||
var title = document.getElementById("upload-title").value;
|
||||
var source = document.getElementById("upload-source").value;
|
||||
if (title) { formData.append("title", title); }
|
||||
if (source) { formData.append("source", source); }
|
||||
api("/api/v1/documents/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
}).then(function (data) {
|
||||
renderIngestHeader(data.task_id, data.status || "pending");
|
||||
startIngestPolling(data.task_id);
|
||||
}).catch(function (err) {
|
||||
showError("error-ingest", err);
|
||||
}).finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "上传入库";
|
||||
});
|
||||
if (fileInput.files.length > 1) {
|
||||
/* 批量上传:FormData 用 files 字段逐文件 append,走 upload-batch 端点 */
|
||||
var batchForm = new FormData();
|
||||
for (var i = 0; i < fileInput.files.length; i++) {
|
||||
batchForm.append("files", fileInput.files[i]);
|
||||
}
|
||||
api("/api/v1/documents/upload-batch", {
|
||||
method: "POST",
|
||||
body: batchForm
|
||||
}).then(function (data) {
|
||||
renderBatchUploadResult(data);
|
||||
/* 自动切到入库进度区块查看进度 */
|
||||
activateSection("section-progress");
|
||||
}).catch(function (err) {
|
||||
showError("error-ingest", err);
|
||||
}).finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "上传入库";
|
||||
});
|
||||
} else {
|
||||
/* 单文件上传:保持原逻辑,走 upload 端点 */
|
||||
var formData = new FormData();
|
||||
formData.append("file", fileInput.files[0]);
|
||||
if (title) { formData.append("title", title); }
|
||||
if (source) { formData.append("source", source); }
|
||||
api("/api/v1/documents/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
}).then(function (data) {
|
||||
renderIngestHeader(data.task_id, data.status || "pending");
|
||||
startIngestPolling(data.task_id);
|
||||
}).catch(function (err) {
|
||||
showError("error-ingest", err);
|
||||
}).finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "上传入库";
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* 批量上传结果展示:成功 N 个任务 + 失败 N 个,纯 textContent 防 XSS */
|
||||
function renderBatchUploadResult(data) {
|
||||
var box = document.getElementById("ingest-result");
|
||||
clearChildren(box);
|
||||
box.classList.remove("hidden");
|
||||
var tasks = data.tasks || [];
|
||||
var failed = data.failed || [];
|
||||
var headRow = el("div", null, "kv");
|
||||
headRow.appendChild(el("span", "批量上传完成:成功 " + tasks.length + " 个任务,失败 " + failed.length + " 个"));
|
||||
box.appendChild(headRow);
|
||||
if (tasks.length > 0) {
|
||||
var tasksRow = el("div", null, "kv");
|
||||
tasksRow.appendChild(el("span", "已提交任务:", "k"));
|
||||
var tasksList = tasks.map(function (t) {
|
||||
return (t.filename || "") + "(" + (t.task_id || "") + ")";
|
||||
}).join(",");
|
||||
tasksRow.appendChild(el("span", tasksList));
|
||||
box.appendChild(tasksRow);
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
var failedRow = el("div", null, "kv");
|
||||
failedRow.appendChild(el("span", "失败文件:", "k"));
|
||||
var failedList = failed.map(function (f) {
|
||||
return (f.filename || "") + "(" + (f.error || "") + ")";
|
||||
}).join(",");
|
||||
failedRow.appendChild(el("span", failedList));
|
||||
box.appendChild(failedRow);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 3.5 入库进度区块(2s 轮询,区块可见时轮询、切走暂停) ---------- */
|
||||
|
||||
var PROGRESS_POLL_INTERVAL_MS = 2000;
|
||||
var progressPollTimer = null;
|
||||
|
||||
function stopProgressPolling() {
|
||||
if (progressPollTimer !== null) {
|
||||
clearInterval(progressPollTimer);
|
||||
progressPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startProgressPolling() {
|
||||
stopProgressPolling();
|
||||
loadProgress();
|
||||
progressPollTimer = setInterval(loadProgress, PROGRESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/* GET /documents/tasks 拉取任务列表,渲染表格与状态统计 */
|
||||
function loadProgress() {
|
||||
hideError("error-progress");
|
||||
api("/api/v1/documents/tasks?limit=50").then(function (data) {
|
||||
renderProgress(data.items || [], data.total || 0);
|
||||
}).catch(function (err) { showError("error-progress", err); });
|
||||
}
|
||||
|
||||
function renderProgress(items, total) {
|
||||
var tbody = document.getElementById("progress-tbody");
|
||||
clearChildren(tbody);
|
||||
var running = 0, done = 0, failed = 0;
|
||||
items.forEach(function (it) {
|
||||
if (it.status === "done") { done += 1; }
|
||||
else if (it.status === "failed") { failed += 1; }
|
||||
else { running += 1; }
|
||||
tbody.appendChild(buildProgressRow(it));
|
||||
});
|
||||
document.getElementById("progress-stats").textContent =
|
||||
"总数 " + total + " · 进行中 " + running + " · 完成 " + done + " · 失败 " + failed;
|
||||
if (items.length === 0) {
|
||||
var tr = el("tr");
|
||||
var td = el("td", "暂无入库任务", "muted");
|
||||
td.setAttribute("colspan", "5");
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 单行渲染:文件名 / 状态徽章 / 创建时间 / 更新时间 / 操作(done 查看文档+重新入库,failed 重试) */
|
||||
function buildProgressRow(item) {
|
||||
var tr = el("tr");
|
||||
tr.appendChild(el("td", item.filename || "(未知文件)"));
|
||||
|
||||
var statusTd = el("td");
|
||||
statusTd.appendChild(statusBadgeEl(item.status));
|
||||
tr.appendChild(statusTd);
|
||||
|
||||
tr.appendChild(el("td", item.created_at || ""));
|
||||
tr.appendChild(el("td", item.updated_at || ""));
|
||||
|
||||
var opsTd = el("td");
|
||||
if (item.status === "done" && item.doc_id) {
|
||||
var viewBtn = el("button", "查看文档", "action");
|
||||
viewBtn.type = "button";
|
||||
viewBtn.addEventListener("click", function () {
|
||||
activateSection("section-docs");
|
||||
loadDocDetail(item.doc_id);
|
||||
});
|
||||
var reingestBtn = el("button", "重新入库", "action");
|
||||
reingestBtn.type = "button";
|
||||
reingestBtn.addEventListener("click", function () {
|
||||
reingestDocument(item.doc_id, item.filename);
|
||||
});
|
||||
opsTd.appendChild(viewBtn);
|
||||
opsTd.appendChild(reingestBtn);
|
||||
} else if (item.status === "failed" && item.doc_id) {
|
||||
var retryBtn = el("button", "重试", "action");
|
||||
retryBtn.type = "button";
|
||||
retryBtn.addEventListener("click", function () {
|
||||
reingestDocument(item.doc_id, item.filename);
|
||||
});
|
||||
opsTd.appendChild(retryBtn);
|
||||
}
|
||||
tr.appendChild(opsTd);
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* 重新入库:POST /documents/{doc_id}/reingest,成功后刷新进度表格 */
|
||||
function reingestDocument(docId, filename) {
|
||||
hideError("error-progress");
|
||||
api("/api/v1/documents/" + encodeURIComponent(docId) + "/reingest", {
|
||||
method: "POST"
|
||||
}).then(function (data) {
|
||||
var stats = document.getElementById("progress-stats");
|
||||
stats.textContent = "已提交「" + (filename || docId) + "」重新入库:task_id=" + (data.task_id || "");
|
||||
loadProgress();
|
||||
}).catch(function (err) { showError("error-progress", err); });
|
||||
}
|
||||
|
||||
document.getElementById("btn-refresh-progress").addEventListener("click", loadProgress);
|
||||
|
||||
/* ---------- 4. 检索测试台 ---------- */
|
||||
|
||||
document.getElementById("search-form").addEventListener("submit", function (event) {
|
||||
@@ -1768,7 +1946,15 @@ var API_GUIDE = [
|
||||
{ name: "title", desc: "可选标题,默认取文件名去扩展", def: "" },
|
||||
{ name: "source", desc: "可选来源标识,默认 file:原文件名", def: "" }
|
||||
],
|
||||
body: null, upload: true },
|
||||
body: null, upload: true, uploadField: "file", uploadMultiple: false },
|
||||
{ method: "POST", path: "/api/v1/documents/upload-batch", desc: "批量文件上传入库(multipart files 字段,逐文件复用单文件逻辑,202 异步)", auth: "bearer",
|
||||
query: [], pathParams: [], form: [],
|
||||
body: null, upload: true, uploadField: "files", uploadMultiple: true },
|
||||
{ method: "GET", path: "/api/v1/documents/tasks", desc: "入库任务列表(合并内存与 Redis 镜像,按 updated_at 降序)", auth: "bearer",
|
||||
query: [
|
||||
{ name: "limit", desc: "返回条数(1-100)", def: "20" }
|
||||
],
|
||||
pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/documents/tasks/{task_id}", desc: "入库任务状态查询(done 附 result,failed 附 error)", auth: "none",
|
||||
query: [],
|
||||
pathParams: [{ name: "task_id", desc: "入库任务 ID", def: "" }],
|
||||
@@ -1787,6 +1973,10 @@ var API_GUIDE = [
|
||||
query: [],
|
||||
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
|
||||
form: [], body: null, upload: false },
|
||||
{ method: "POST", path: "/api/v1/documents/{doc_id}/reingest", desc: "重新入库(读原文件→删旧数据→提交新任务,生成新 doc_id)", auth: "bearer",
|
||||
query: [],
|
||||
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
|
||||
form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/knowledge/categories", desc: "知识分类类目集", auth: "none",
|
||||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/knowledge/stats", desc: "统计(四层点数 + 类目分布 + uncategorized 数)", auth: "none",
|
||||
@@ -1841,7 +2031,8 @@ function buildCurl(ep) {
|
||||
parts.push('-H "Authorization: Bearer <TOKEN>"');
|
||||
}
|
||||
if (ep.upload) {
|
||||
parts.push('-F "file=@/path/to/file.md"');
|
||||
var fieldName = ep.uploadField || "file";
|
||||
parts.push('-F "' + fieldName + '=@/path/to/file.md"');
|
||||
} else if (ep.body) {
|
||||
parts.push('-H "Content-Type: application/json"');
|
||||
parts.push("-d '" + JSON.stringify(ep.body) + "'");
|
||||
@@ -1964,7 +2155,11 @@ function sendApiTry(idx, panel) {
|
||||
}
|
||||
/* FormData 由浏览器自动生成 multipart 边界,不设置 Content-Type 头 */
|
||||
var formData = new FormData();
|
||||
formData.append("file", fileInput.files[0]);
|
||||
/* uploadField 默认 file;批量上传用 files 字段逐文件 append */
|
||||
var upField = ep.uploadField || "file";
|
||||
for (var fi = 0; fi < fileInput.files.length; fi++) {
|
||||
formData.append(upField, fileInput.files[fi]);
|
||||
}
|
||||
var formInputs = panel.querySelectorAll(".try-form-input");
|
||||
for (var k = 0; k < formInputs.length; k++) {
|
||||
var fVal = formInputs[k].value.trim();
|
||||
@@ -2044,11 +2239,12 @@ function renderApiItem(ep, idx) {
|
||||
if (ep.upload) {
|
||||
renderTryInputs(panel, "form", ep.form);
|
||||
var fileField = el("div", null, "field");
|
||||
fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx)"));
|
||||
fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx" + (ep.uploadMultiple ? ",可多选" : "") + ")"));
|
||||
var fileInput = el("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.className = "try-file";
|
||||
fileInput.accept = ".txt,.md,.html,.htm,.pdf,.docx";
|
||||
if (ep.uploadMultiple) { fileInput.setAttribute("multiple", ""); }
|
||||
fileField.appendChild(fileInput);
|
||||
panel.appendChild(fileField);
|
||||
}
|
||||
|
||||
@@ -63,3 +63,15 @@ export function resetPassword(username, newPassword) {
|
||||
export function deleteUser(username) {
|
||||
return http.delete(`/api/v1/auth/users/${encodeURIComponent(username)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改自己的密码(当前登录用户)
|
||||
* @param {string} oldPassword
|
||||
* @param {string} newPassword
|
||||
*/
|
||||
export function changeMyPassword(oldPassword, newPassword) {
|
||||
return http.post('/api/v1/auth/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { computed, h, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterView } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileTextOutlined,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
TeamOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { changeMyPassword } from '@/api/auth'
|
||||
import { callApi } from '@/api/client'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -66,6 +68,68 @@ function handleLogout() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 强制改密弹窗(must_change_password 用户) ---------- */
|
||||
const passwordModalVisible = ref(false)
|
||||
const passwordSubmitting = ref(false)
|
||||
const passwordFormRef = ref(null)
|
||||
const passwordForm = reactive({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
const passwordRules = {
|
||||
old_password: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
|
||||
new_password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 8, message: '密码至少 8 位', trigger: 'blur' }
|
||||
],
|
||||
confirm_password: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value) =>
|
||||
value === passwordForm.new_password
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('两次输入的密码不一致')),
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// must_change_password 为 true 时自动弹出改密弹窗(不可关闭)
|
||||
watch(
|
||||
() => user.value?.must_change_password,
|
||||
(val) => {
|
||||
passwordModalVisible.value = val === true
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function handleSubmitPassword() {
|
||||
try {
|
||||
await passwordFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
passwordSubmitting.value = true
|
||||
try {
|
||||
await callApi(
|
||||
() => changeMyPassword(passwordForm.old_password, passwordForm.new_password),
|
||||
{ errorText: '修改密码失败' }
|
||||
)
|
||||
authStore.markPasswordChanged()
|
||||
message.success('密码修改成功')
|
||||
passwordModalVisible.value = false
|
||||
passwordForm.old_password = ''
|
||||
passwordForm.new_password = ''
|
||||
passwordForm.confirm_password = ''
|
||||
} catch {
|
||||
// 错误已由 callApi 弹出
|
||||
} finally {
|
||||
passwordSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -127,6 +191,55 @@ function handleLogout() {
|
||||
QMDSearch Admin · 当前用户:{{ user?.username || '-' }}
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
|
||||
<!-- 强制改密弹窗(must_change_password 用户,不可关闭) -->
|
||||
<a-modal
|
||||
v-model:open="passwordModalVisible"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:keyboard="false"
|
||||
:destroy-on-close="true"
|
||||
title="首次登录须修改密码"
|
||||
:confirm-loading="passwordSubmitting"
|
||||
ok-text="确认修改"
|
||||
:cancel-button-props="{ style: { display: 'none' } }"
|
||||
@ok="handleSubmitPassword"
|
||||
>
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="检测到首次登录或管理员重置密码,请立即修改密码后方可使用系统。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<a-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="当前密码" name="old_password">
|
||||
<a-input-password
|
||||
v-model:value="passwordForm.old_password"
|
||||
placeholder="请输入当前密码"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码" name="new_password">
|
||||
<a-input-password
|
||||
v-model:value="passwordForm.new_password"
|
||||
placeholder="至少 8 位"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="确认新密码" name="confirm_password">
|
||||
<a-input-password
|
||||
v-model:value="passwordForm.confirm_password"
|
||||
placeholder="再次输入新密码"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -39,13 +39,27 @@ export const useAuthStore = defineStore('auth', {
|
||||
actions: {
|
||||
/**
|
||||
* 登录成功后保存 token + user
|
||||
* @param {{access_token:string, user:object}} data
|
||||
* 兼容后端两种返回:
|
||||
* - 新格式 { token, username, role, must_change_password }
|
||||
* - 旧格式 { access_token, user: { username, role, ... } }
|
||||
* @param {object} data
|
||||
*/
|
||||
setAuth(data) {
|
||||
this.token = data.access_token
|
||||
this.user = data.user
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, data.access_token)
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(data.user))
|
||||
const token = data?.access_token ?? data?.token
|
||||
const user =
|
||||
data?.user ??
|
||||
(data
|
||||
? {
|
||||
username: data.username,
|
||||
role: data.role,
|
||||
must_change_password: data.must_change_password ?? false
|
||||
}
|
||||
: null)
|
||||
if (!token || !user) return
|
||||
this.token = token
|
||||
this.user = user
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user))
|
||||
},
|
||||
|
||||
/** 清除登录态(登出 / 401) */
|
||||
@@ -56,6 +70,13 @@ export const useAuthStore = defineStore('auth', {
|
||||
localStorage.removeItem(USER_STORAGE_KEY)
|
||||
},
|
||||
|
||||
/** 改密成功后更新本地用户状态(清除 must_change_password 标记) */
|
||||
markPasswordChanged() {
|
||||
if (!this.user) return
|
||||
this.user = { ...this.user, must_change_password: false }
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(this.user))
|
||||
},
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# QMDSearch 自动部署脚本(本地 CI/CD 入口)
|
||||
#
|
||||
# 设计原则(贴合 NAS 自托管 + 挂载式部署):
|
||||
# 1. 检测变化 —— 对比 origin/main,区分 backend / frontend / deps 范围
|
||||
# 2. 校验 —— 本地跑 pytest(CI 门禁),坏代码绝不上线
|
||||
# 3. 按需构建 —— 仅前端变化时本地预构建(dist 被 gitignore,NAS 端会再构建)
|
||||
# 4. 提交推送 —— git commit + push origin main,推送后 SSH 到 NAS 执行真正部署
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/deploy.sh 正常流程:检测 → 校验 → 按需构建 → 提交推送 → SSH 到 NAS 部署
|
||||
# ./scripts/deploy.sh --dry 零副作用自测:只 检测/校验/构建,不提交不推送
|
||||
# ./scripts/deploy.sh --no-push 提交但不推送(准备好后手动 push 或再跑一次)
|
||||
# ./scripts/deploy.sh --no-ssh 推送后不自动 SSH 部署(仅同步代码到 NAS git)
|
||||
# ./scripts/deploy.sh --skip-tests 跳过 pytest(谨慎)
|
||||
# ./scripts/deploy.sh --skip-fe 跳过前端构建(即便前端变化)
|
||||
# ./scripts/deploy.sh --build-fe 强制重新构建前端
|
||||
# ./scripts/deploy.sh --help
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="origin"
|
||||
BRANCH="main"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
NO_PUSH=0
|
||||
DRY=0
|
||||
NO_SSH=0
|
||||
SKIP_TESTS=0
|
||||
SKIP_FE=0
|
||||
FORCE_FE=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-push) NO_PUSH=1 ;;
|
||||
--dry) DRY=1 ;;
|
||||
--no-ssh) NO_SSH=1 ;;
|
||||
--skip-tests) SKIP_TESTS=1 ;;
|
||||
--skip-fe) SKIP_FE=1 ;;
|
||||
--build-fe) FORCE_FE=1 ;;
|
||||
--help|-h) sed -n '1,22p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数: $1"; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
log(){ printf '\033[36m[deploy]\033[0m %s\n' "$*"; }
|
||||
ok(){ printf '\033[32m[ ok ]\033[0m %s\n' "$*"; }
|
||||
err(){ printf '\033[31m[err ]\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
# ---- 1. 变化检测 --------------------------------------------------------------
|
||||
log "同步远端引用..."
|
||||
git fetch --quiet "$REMOTE" "$BRANCH" 2>/dev/null || log "fetch 失败(离线?继续本地流程)"
|
||||
|
||||
LOCAL_CHANGES="$(git status --porcelain)"
|
||||
AHEAD="$(git rev-list --count "$REMOTE/$BRANCH..HEAD" 2>/dev/null || echo 0)"
|
||||
|
||||
# 汇总「已提交未推送」+「未提交」的变更文件,去重
|
||||
CHANGED_FILES="$(
|
||||
{ git diff --name-only "$REMOTE/$BRANCH" HEAD 2>/dev/null; echo "$LOCAL_CHANGES" | awk '{print $2}'; } \
|
||||
| grep -v '^$' | sort -u
|
||||
)"
|
||||
|
||||
has(){ echo "$CHANGED_FILES" | grep -qE "$1"; }
|
||||
|
||||
BACKEND_CHANGED=0; FRONTEND_CHANGED=0; DEPS_CHANGED=0
|
||||
if has '^(app/|scripts/|tests/|.*\.py$|Dockerfile|docker-compose\.yml|\.env\.example)'; then BACKEND_CHANGED=1; fi
|
||||
if has '^frontend/(src/|public/|index\.html|package\.json|vite\.config|package-lock\.json)'; then FRONTEND_CHANGED=1; fi
|
||||
if has '^(pyproject\.toml|uv\.lock|Dockerfile)'; then DEPS_CHANGED=1; fi
|
||||
|
||||
if [[ -z "$CHANGED_FILES" && "$AHEAD" -eq 0 ]]; then
|
||||
ok "无代码变化,无需部署。"
|
||||
exit 0
|
||||
fi
|
||||
log "变更范围 → backend=$BACKEND_CHANGED frontend=$FRONTEND_CHANGED deps=$DEPS_CHANGED (ahead=$AHEAD)"
|
||||
|
||||
# ---- 2. 校验(本地 CI gate)---------------------------------------------------
|
||||
if [[ "$SKIP_TESTS" -eq 0 && "$BACKEND_CHANGED" -eq 1 ]]; then
|
||||
log "运行后端测试 (uv run pytest)..."
|
||||
if ! uv run pytest -q --no-header 2>&1 | tail -n 30; then
|
||||
err "测试失败,已中止部署。请修复后再运行 deploy.sh"
|
||||
exit 1
|
||||
fi
|
||||
ok "后端测试通过"
|
||||
# 顺带做一次非阻断的静态检查
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
log "运行 ruff 静态检查(不阻断)..."
|
||||
uv run ruff check app tests 2>&1 | tail -n 15 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 3. 按需构建前端 ----------------------------------------------------------
|
||||
if [[ ("$FRONTEND_CHANGED" -eq 1 || "$FORCE_FE" -eq 1) && "$SKIP_FE" -eq 0 ]]; then
|
||||
log "构建前端 (npm ci && npm run build)..."
|
||||
if ! (cd frontend && npm ci --no-audit --no-fund && npm run build); then
|
||||
err "前端构建失败,已中止。"
|
||||
exit 1
|
||||
fi
|
||||
ok "前端构建完成 -> frontend/dist"
|
||||
log "提示: frontend/dist 已被 gitignore,NAS 端 post-receive 会重新构建以生效。"
|
||||
fi
|
||||
|
||||
# ---- 4. 提交 + 推送 -----------------------------------------------------------
|
||||
if [[ "$DRY" -eq 1 ]]; then
|
||||
log "(--dry) 零副作用模式:跳过提交与推送。以上为检测结果,确认无误后可去掉 --dry 正式部署。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -n "$LOCAL_CHANGES" ]]; then
|
||||
log "暂存并提交本地变化..."
|
||||
git add -A # .gitignore 已排除 .env / data / node_modules / .workbuddy / .trae 等
|
||||
SCOPE=""
|
||||
[[ "$BACKEND_CHANGED" -eq 1 ]] && SCOPE="$SCOPE backend"
|
||||
[[ "$FRONTEND_CHANGED" -eq 1 ]] && SCOPE="$SCOPE frontend"
|
||||
[[ "$DEPS_CHANGED" -eq 1 ]] && SCOPE="$SCOPE deps"
|
||||
MSG="chore(deploy): 自动部署 $(date '+%Y-%m-%d %H:%M')${SCOPE:+ |$SCOPE}"
|
||||
git commit -q -m "$MSG"
|
||||
ok "已提交: $MSG"
|
||||
fi
|
||||
|
||||
if [[ "$NO_PUSH" -eq 1 ]]; then
|
||||
log "(--no-push) 未推送。当前领先远端 $AHEAD 个提交,确认无误后去掉 --no-push 再跑。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export QMDS_SKIP_PREPUSH=1 # 避免与 pre-push hook 重复跑测试
|
||||
log "推送到 $REMOTE/$BRANCH(触发 NAS 自动部署)..."
|
||||
if ! git push "$REMOTE" "$BRANCH"; then
|
||||
err "推送失败:可能远端有更新,请先 git pull 解决冲突后重试。"
|
||||
exit 1
|
||||
fi
|
||||
ok "已推送。代码已同步至 NAS git。"
|
||||
if [[ "$NO_SSH" -eq 0 ]]; then
|
||||
log "通过 SSH 在 NAS 上执行部署..."
|
||||
if bash "$ROOT/scripts/nas-deploy-ssh.sh"; then
|
||||
ok "NAS 部署完成。"
|
||||
else
|
||||
err "NAS 部署脚本返回非 0(详见上方 NAS 输出)。"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log "(--no-ssh) 已跳过 NAS 端部署。如需部署请运行: ./scripts/nas-deploy-ssh.sh"
|
||||
fi
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 安装本地 git pre-push 门禁
|
||||
#
|
||||
# 作用:任何「推送到 origin(NAS)」的操作前,先跑后端测试;若前端有变化,
|
||||
# 先在本地预构建一次。任一环节失败则阻断推送,确保坏代码绝不上线。
|
||||
#
|
||||
# 说明:
|
||||
# - deploy.sh 推送前会设 QMDS_SKIP_PREPUSH=1,避免与自身校验重复跑。
|
||||
# - 仅对 origin 远端生效,推其他远端不受影响。
|
||||
#
|
||||
# 卸载:rm .git/hooks/pre-push
|
||||
# =============================================================================
|
||||
set -e
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
HOOK="$ROOT/.git/hooks/pre-push"
|
||||
mkdir -p "$(dirname "$HOOK")"
|
||||
|
||||
cat > "$HOOK" <<'HOOK_EOF'
|
||||
#!/usr/bin/env bash
|
||||
# QMDSearch pre-push 校验门禁(由 scripts/install-hooks.sh 安装)
|
||||
REMOTE="$1"
|
||||
# 仅对 NAS 远端 (origin) 做校验;且 deploy.sh 已设 QMDS_SKIP_PREPUSH 时跳过
|
||||
if [[ "$REMOTE" != "origin" ]]; then exit 0; fi
|
||||
if [[ -n "${QMDS_SKIP_PREPUSH:-}" ]]; then exit 0; fi
|
||||
|
||||
echo "[pre-push] 推送前校验后端测试..."
|
||||
if ! uv run pytest -q --no-header 2>&1 | tail -n 20; then
|
||||
echo "[pre-push] 测试失败,已阻断推送。请先修复后再 push。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 暂存区若含前端变化,本地先预构建一次,避免推到 NAS 才翻车
|
||||
if git diff --cached --name-only | grep -qE '^frontend/'; then
|
||||
echo "[pre-push] 检测到前端变化,本地预构建..."
|
||||
if ! (cd frontend && npm ci --no-audit --no-fund && npm run build); then
|
||||
echo "[pre-push] 前端构建失败,已阻断推送。" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[pre-push] 校验通过,允许推送。"
|
||||
HOOK_EOF
|
||||
|
||||
chmod +x "$HOOK"
|
||||
echo "已安装 pre-push 门禁 -> $HOOK"
|
||||
echo "如需卸载: rm $HOOK"
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# QMDSearch NAS 端部署(通过 SSH 直连执行,密钥免密)
|
||||
#
|
||||
# 环境约束(已实测):
|
||||
# - NAS 上有 docker compose v2,但【没有 node/npm】→ 前端必须在本地构建后 rsync 过去
|
||||
# - compose 的 app 服务只有 image: 无 build: → 依赖变化用 `docker build -t qmdsearch-app:latest .`
|
||||
# - kp 已在 docker 组,可免 sudo 跑 docker
|
||||
# - 部署目录 /vol1/qmdsearch 是 git 工作树,以 origin/main 为唯一真相源
|
||||
#
|
||||
# 流程:
|
||||
# 1. SSH 到 NAS:git fetch + git reset --hard origin/main(同步受管代码),并算出变更范围
|
||||
# 2. 本地:若前端变化 → 本地 npm 构建 → rsync frontend/dist 到 NAS(NAS 无 node)
|
||||
# 3. SSH 到 NAS:依赖变化则 docker build + force-recreate;否则 restart app
|
||||
# 4. 健康检查 /health;失败回滚到上一稳定提交
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/nas-deploy-ssh.sh 正式部署
|
||||
# ./scripts/nas-deploy-ssh.sh --dry-run 只预览将要部署的变更,不重启
|
||||
# 环境变量:NAS_HOST(默认 nas) DEPLOY_DIR(默认 /vol1/qmdsearch) APP_PORT(默认 8000)
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
DEPLOY_HOST="${NAS_HOST:-nas}"
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-/vol1/qmdsearch}"
|
||||
APP_PORT="${APP_PORT:-8000}"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DRY_RUN=0
|
||||
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
|
||||
|
||||
# 前置检查
|
||||
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$DEPLOY_HOST" true 2>/dev/null; then
|
||||
echo "[local] 无法免密 SSH 到 $DEPLOY_HOST,请先配置 SSH 密钥登录。" >&2
|
||||
exit 1
|
||||
fi
|
||||
command -v rsync >/dev/null 2>&1 || { echo "[local] 本机缺少 rsync,请先安装。" >&2; exit 1; }
|
||||
|
||||
# ---- Phase 1: NAS 同步受管代码 + 计算变更范围 ----
|
||||
SCOPE=$(ssh "$DEPLOY_HOST" "bash -s -- '${DEPLOY_DIR}'" <<'REMOTE'
|
||||
DEPLOY_DIR="$1"
|
||||
set -uo pipefail
|
||||
cd "$DEPLOY_DIR" || { echo "NOCD"; exit 1; }
|
||||
git fetch origin main 2>&1 | tail -n 2
|
||||
NEW=$(git rev-parse origin/main)
|
||||
PREV=$(cat .last_deployed_commit 2>/dev/null || echo "NONE")
|
||||
if [[ "$PREV" == "$NEW" ]]; then echo "UP_TO_DATE"; exit 0; fi
|
||||
OLD_HEAD=$(git rev-parse HEAD)
|
||||
git reset --hard origin/main 2>&1 | tail -n 2
|
||||
BASE="$OLD_HEAD"
|
||||
[[ "$PREV" != "NONE" ]] && BASE="$PREV"
|
||||
CHANGED=$(git diff --name-only "$BASE" "$NEW")
|
||||
FE=0; DEPS=0
|
||||
echo "$CHANGED" | grep -qE '^frontend/(src/|public/|index\.html|package\.json|vite\.config|package-lock\.json)' && FE=1
|
||||
echo "$CHANGED" | grep -qE '^(pyproject\.toml|uv\.lock|Dockerfile)' && DEPS=1
|
||||
echo "SCOPE FE=$FE DEPS=$DEPS PREV=$PREV NEW=$NEW"
|
||||
REMOTE
|
||||
)
|
||||
|
||||
if echo "$SCOPE" | grep -q "NOCD"; then echo "[nas] 无法进入 $DEPLOY_DIR"; exit 1; fi
|
||||
if echo "$SCOPE" | grep -q "UP_TO_DATE"; then echo "[nas] 已是最新,无需更新。"; exit 0; fi
|
||||
|
||||
FE=$(echo "$SCOPE" | grep -oE 'FE=[01]' | cut -d= -f2)
|
||||
DEPS=$(echo "$SCOPE" | grep -oE 'DEPS=[01]' | cut -d= -f2)
|
||||
PREV=$(echo "$SCOPE" | grep -oE 'PREV=[0-9a-f]+' | cut -d= -f2)
|
||||
NEW=$(echo "$SCOPE" | grep -oE 'NEW=[0-9a-f]+' | cut -d= -f2)
|
||||
echo "[deploy] 将部署 $PREV -> $NEW (FE=$FE DEPS=$DEPS)"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
echo "[deploy][dry-run] 变更文件:"
|
||||
if [[ "$PREV" != "NONE" ]]; then
|
||||
ssh "$DEPLOY_HOST" "cd $DEPLOY_DIR && git diff --name-only $PREV $NEW" 2>/dev/null | sed 's/^/ /'
|
||||
else
|
||||
echo " (首次部署,将全量同步受管代码)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Phase 2: 前端本地构建 + rsync 到 NAS(NAS 无 node)----
|
||||
if [[ "$FE" == "1" ]]; then
|
||||
echo "[local] 本地构建前端..."
|
||||
if ! (cd "$ROOT/frontend" && npm ci --no-audit --no-fund && npm run build); then
|
||||
echo "[local][WARN] 前端构建失败(当前环境可能受限或缺少 node 依赖),跳过前端同步;后端仍会部署,但 frontend/dist 可能为旧版本。待在可正常构建的环境中重跑即补齐。"
|
||||
else
|
||||
echo "[local] rsync frontend/dist -> $DEPLOY_HOST:$DEPLOY_DIR/frontend/dist"
|
||||
if ! rsync -az --delete "$ROOT/frontend/dist/" "$DEPLOY_HOST:$DEPLOY_DIR/frontend/dist/"; then
|
||||
echo "[local][WARN] rsync 失败,跳过前端同步。"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- Phase 3: NAS 容器部署 + 健康检查 ----
|
||||
ssh "$DEPLOY_HOST" "bash -s -- '$DEPLOY_DIR' '$APP_PORT' '$DEPS' '$NEW' '$PREV'" <<'REMOTE'
|
||||
DEPLOY_DIR="$1"; APP_PORT="$2"; DEPS="$3"; NEW="$4"; PREV="$5"
|
||||
set -uo pipefail
|
||||
cd "$DEPLOY_DIR"
|
||||
if [[ "$DEPS" == "1" ]]; then
|
||||
echo "[nas] 依赖变化,重建镜像: docker build -t qmdsearch-app:latest ."
|
||||
if ! docker build -t qmdsearch-app:latest . 2>&1 | tail -n 20; then
|
||||
echo "[nas] 镜像构建失败,回滚到 $PREV"; git checkout -f "$PREV"; exit 1
|
||||
fi
|
||||
echo "[nas] 用新镜像重建容器: docker compose up -d --force-recreate app"
|
||||
docker compose up -d --force-recreate app 2>&1 | tail -n 10
|
||||
else
|
||||
echo "[nas] 仅代码/前端变化,重启 app: docker compose restart app"
|
||||
docker compose restart app 2>&1 | tail -n 5
|
||||
fi
|
||||
echo "[nas] 健康检查 http://localhost:$APP_PORT/api/v1/health ..."
|
||||
for i in $(seq 1 20); do
|
||||
if curl -fsS "http://localhost:$APP_PORT/api/v1/health" >/dev/null 2>&1; then
|
||||
echo "$NEW" > .last_deployed_commit
|
||||
echo "[nas] 健康检查通过 ✓ 部署完成"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "[nas] 健康检查失败,回滚到 $PREV"
|
||||
git checkout -f "$PREV"
|
||||
docker compose restart app
|
||||
exit 1
|
||||
REMOTE
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# QMDSearch NAS 端自动部署钩子 (post-receive)
|
||||
#
|
||||
# 这是真正的「持续部署(CD)」:当本地 deploy.sh 把代码 push 到 NAS 后,
|
||||
# 此钩子自动完成 checkout → 按需构建 → 重启/重建容器 → 健康检查 → 失败回滚。
|
||||
#
|
||||
# 安装方式(二选一):
|
||||
# A. 作为 bare 仓库的 hook(推荐,全自动):
|
||||
# # 假设 NAS 上 bare 仓库在 /volume1/git/QMDSearch.git,工作树在 /volume1/docker/QMDSearch
|
||||
# cp scripts/nas-post-receive /volume1/git/QMDSearch.git/hooks/post-receive
|
||||
# chmod +x /volume1/git/QMDSearch.git/hooks/post-receive
|
||||
# # 在该 bare 仓库 config 中设置:
|
||||
# # [core] bare = true
|
||||
# # [hooks] 并确保工作树目录已 clone 过一次
|
||||
# # 首次需手动:git --work-tree=/volume1/docker/QMDSearch --git-dir=/volume1/git/QMDSearch.git checkout -f
|
||||
#
|
||||
# B. 作为普通脚本手动触发(无 bare 仓库时):
|
||||
# cd /volume1/docker/QMDSearch && git pull && sudo bash scripts/nas-post-receive
|
||||
#
|
||||
# 前置依赖(NAS 上需具备):git、docker、docker compose、node/npm
|
||||
# 可调环境变量:DEPLOY_DIR(工作树)、APP_PORT、LOG_FILE
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-/volume1/docker/QMDSearch}" # ← 改成你的 NAS 部署目录
|
||||
GIT_DIR="$DEPLOY_DIR/.git"
|
||||
LOG_FILE="${LOG_FILE:-$DEPLOY_DIR/deploy.log}"
|
||||
APP_PORT="${APP_PORT:-8000}"
|
||||
PREV_COMMIT_FILE="$DEPLOY_DIR/.last_deployed_commit"
|
||||
|
||||
log(){ printf '%s [nas-deploy] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
rollback(){
|
||||
log "回滚到上一稳定提交: ${PREV:-<无>}"
|
||||
if [[ -n "${PREV:-}" ]]; then
|
||||
git --work-tree="$DEPLOY_DIR" --git-dir="$GIT_DIR" checkout -f "$PREV"
|
||||
(cd "$DEPLOY_DIR" && docker compose restart app) | tee -a "$LOG_FILE"
|
||||
echo "$PREV" > "$PREV_COMMIT_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# 解析 post-receive 通过 stdin 传入的 <old> <new> <ref>
|
||||
newrev=""; refname=""
|
||||
while read -r oldrev newrev refname; do :; done
|
||||
# 仅处理 main 分支的推送;手动运行时 newrev 为空,回退到 HEAD
|
||||
if [[ -z "$newrev" ]]; then
|
||||
newrev="$(git --git-dir="$GIT_DIR" rev-parse HEAD 2>/dev/null || echo "")"
|
||||
fi
|
||||
if [[ -n "$refname" && "$refname" != "refs/heads/main" ]]; then
|
||||
log "忽略非 main 分支推送 ($refname)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "=== 开始部署 (newrev=${newrev:0:8}) ==="
|
||||
|
||||
# 1) 检出到工作树
|
||||
git --work-tree="$DEPLOY_DIR" --git-dir="$GIT_DIR" checkout -f
|
||||
cd "$DEPLOY_DIR" || { log "无法进入 $DEPLOY_DIR"; exit 1; }
|
||||
|
||||
# 2) 计算变化范围
|
||||
PREV="$(cat "$PREV_COMMIT_FILE" 2>/dev/null || echo "")"
|
||||
if [[ -n "$PREV" && -n "$newrev" ]]; then
|
||||
CHANGED="$(git --git-dir="$GIT_DIR" diff --name-only "$PREV" "$newrev")"
|
||||
else
|
||||
CHANGED="$(git --git-dir="$GIT_DIR" show --name-only --format= "$newrev")"
|
||||
fi
|
||||
|
||||
has(){ echo "$CHANGED" | grep -qE "$1"; }
|
||||
FE_CHANGED=0; DEPS_CHANGED=0
|
||||
has '^frontend/(src/|public/|index\.html|package\.json|vite\.config|package-lock\.json)' && FE_CHANGED=1
|
||||
has '^(pyproject\.toml|uv\.lock|Dockerfile)' && DEPS_CHANGED=1
|
||||
log "变更范围 → frontend=$FE_CHANGED deps=$DEPS_CHANGED"
|
||||
|
||||
# 3) 按需构建前端(dist 不入库,必须在此构建)
|
||||
if [[ "$FE_CHANGED" -eq 1 ]]; then
|
||||
log "检测前端变化,重新构建..."
|
||||
if ! (cd frontend && npm ci --no-audit --no-fund && npm run build) 2>&1 | tee -a "$LOG_FILE"; then
|
||||
log "前端构建失败,中止部署(保留上一版本)"; exit 1
|
||||
fi
|
||||
ok(){ :; }; log "前端构建完成"
|
||||
fi
|
||||
|
||||
# 4) 部署容器
|
||||
if [[ "$DEPS_CHANGED" -eq 1 ]]; then
|
||||
log "依赖变化,重建镜像并重启: docker compose up -d --build app"
|
||||
docker compose up -d --build app 2>&1 | tee -a "$LOG_FILE"
|
||||
else
|
||||
log "仅代码变化,重启 app 服务: docker compose restart app"
|
||||
docker compose restart app 2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
# 5) 健康检查(失败自动回滚)
|
||||
log "健康检查 http://localhost:$APP_PORT/health ..."
|
||||
for i in $(seq 1 15); do
|
||||
if curl -fsS "http://localhost:$APP_PORT/health" >/dev/null 2>&1; then
|
||||
log "健康检查通过 ✓"
|
||||
[[ -n "$newrev" ]] && echo "$newrev" > "$PREV_COMMIT_FILE"
|
||||
log "=== 部署完成 ==="
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log "健康检查失败,触发回滚"
|
||||
rollback
|
||||
exit 1
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# QMDSearch 文件变化监视器(纯轮询,无需 fswatch / inotifywait)
|
||||
#
|
||||
# 作用:持续监听仓库代码变化,实现「每次更新代码后自动检测」。
|
||||
# - 默认模式:检测到变化 → 本地跑测试 + 前端预构建 → 提示你运行 deploy.sh
|
||||
# - --auto 模式:检测到变化 → 直接调用 ./scripts/deploy.sh 全自动部署
|
||||
# (注意:--auto 会在保存即提交推送,慎用,建议确认改动完整后再用)
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/watch.sh 监听并本地校验,提示部署
|
||||
# ./scripts/watch.sh --auto 监听变化后全自动部署
|
||||
# ./scripts/watch.sh --interval 5 轮询间隔秒数(默认 3)
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
AUTO=0
|
||||
INTERVAL=3
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--auto) AUTO=1 ;;
|
||||
--interval) INTERVAL="${2:-3}"; shift ;;
|
||||
*) echo "未知参数: $1"; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
SNAPSHOT="$(mktemp)"
|
||||
record(){ git status --porcelain > "$SNAPSHOT"; }
|
||||
record
|
||||
|
||||
if [[ "$AUTO" -eq 1 ]]; then
|
||||
echo "[watch] 监听 $ROOT(AUTO 模式:变化即部署,间隔 ${INTERVAL}s,Ctrl-C 退出)"
|
||||
else
|
||||
echo "[watch] 监听 $ROOT(校验模式:变化即本地校验并提示,间隔 ${INTERVAL}s,Ctrl-C 退出)"
|
||||
fi
|
||||
|
||||
while true; do
|
||||
sleep "$INTERVAL"
|
||||
if ! git status --porcelain | diff -q - "$SNAPSHOT" >/dev/null 2>&1; then
|
||||
echo "[watch] $(date '+%H:%M:%S') 检测到代码变化"
|
||||
if [[ "$AUTO" -eq 1 ]]; then
|
||||
bash "$ROOT/scripts/deploy.sh"
|
||||
else
|
||||
echo "[watch] 运行本地校验..."
|
||||
uv run pytest -q --no-header 2>&1 | tail -n 10 || true
|
||||
if git status --porcelain | awk '{print $2}' | grep -qE '^frontend/'; then
|
||||
echo "[watch] 前端变化,本地预构建..."
|
||||
(cd frontend && npm ci --no-audit --no-fund && npm run build) 2>&1 | tail -n 5 || true
|
||||
fi
|
||||
echo "[watch] 校验完成。确认无误后运行: ./scripts/deploy.sh"
|
||||
fi
|
||||
record
|
||||
fi
|
||||
done
|
||||
+120
-1
@@ -13,6 +13,8 @@
|
||||
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
|
||||
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
|
||||
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
|
||||
12. 入库进度区块(Task 2):section/nav、表格结构、2s 轮询启停、状态徽章复用、
|
||||
done/failed 操作按钮、reingest 端点、批量上传 multiple + upload-batch 路径
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -39,9 +41,20 @@ def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||||
|
||||
@pytest.fixture
|
||||
def admin_html(client: TestClient) -> str:
|
||||
"""请求 /admin 并返回 HTML 文本(前置断言 200)"""
|
||||
"""请求 /admin 并返回旧版 admin.html 文本(前置断言 200)
|
||||
|
||||
当 app/static/admin 符号链接存在时(Vue SPA 部署),/admin 端点会返回 SPA 入口
|
||||
而非旧版 admin.html。此时直接读取 admin.html 文件内容进行测试。
|
||||
"""
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
# 检测是否返回了 Vue SPA(无 section-overview 标记)
|
||||
if "section-overview" not in resp.text:
|
||||
# SPA 模式:直接读取旧版 admin.html 文件
|
||||
from pathlib import Path
|
||||
|
||||
admin_html_path = Path(__file__).resolve().parent.parent / "app" / "static" / "admin.html"
|
||||
return admin_html_path.read_text(encoding="utf-8")
|
||||
return resp.text
|
||||
|
||||
|
||||
@@ -86,6 +99,8 @@ def test_admin_page_fetch_paths(admin_html: str) -> None:
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/password",
|
||||
"/api/v1/auth/users",
|
||||
"/api/v1/documents/upload-batch",
|
||||
"/api/v1/documents/tasks",
|
||||
):
|
||||
assert path in admin_html
|
||||
|
||||
@@ -107,6 +122,13 @@ def test_admin_page_fetch_paths_in_real_routes(admin_html: str) -> None:
|
||||
"""页面 api() 调用路径均在真实后端路由集合内(含 tasks 与 /api/v1/auth/* 路径)"""
|
||||
route_paths = _collect_route_paths(app.routes)
|
||||
assert "/api/v1/documents/tasks/{task_id}" in route_paths
|
||||
# Task 2 新增端点在真实路由集合内
|
||||
for new_path in (
|
||||
"/api/v1/documents/tasks",
|
||||
"/api/v1/documents/upload-batch",
|
||||
"/api/v1/documents/{doc_id}/reingest",
|
||||
):
|
||||
assert new_path in route_paths, f"后端缺少 Task 2 新增端点: {new_path}"
|
||||
for auth_path in (
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/me",
|
||||
@@ -140,6 +162,99 @@ def test_admin_page_ingest_polling(admin_html: str) -> None:
|
||||
assert "disabled" in admin_html
|
||||
|
||||
|
||||
def test_admin_page_progress_section(admin_html: str) -> None:
|
||||
"""入库进度区块:导航按钮、section、表格结构、刷新按钮、空列表占位"""
|
||||
# section 与导航按钮(所有登录用户可见)
|
||||
assert 'id="section-progress"' in admin_html
|
||||
assert 'id="nav-progress"' in admin_html
|
||||
assert 'data-target="section-progress"' in admin_html
|
||||
assert "入库进度" in admin_html
|
||||
# 刷新按钮与状态统计
|
||||
assert 'id="btn-refresh-progress"' in admin_html
|
||||
assert 'id="progress-stats"' in admin_html
|
||||
# 表格 tbody 与表头列:文件名 / 状态 / 创建时间 / 更新时间 / 操作
|
||||
assert 'id="progress-tbody"' in admin_html
|
||||
for col in ("文件名", "状态", "创建时间", "更新时间", "操作"):
|
||||
assert col in admin_html
|
||||
# 空列表占位文案
|
||||
assert "暂无入库任务" in admin_html
|
||||
|
||||
|
||||
def test_admin_page_progress_polling(admin_html: str) -> None:
|
||||
"""入库进度区块:2s 轮询常量、startProgressPolling/stopProgressPolling 函数、GET /documents/tasks 路径"""
|
||||
# 进度区块专用轮询常量(2s)
|
||||
assert "PROGRESS_POLL_INTERVAL_MS" in admin_html
|
||||
assert "2000" in admin_html
|
||||
# 轮询启停函数
|
||||
assert "function startProgressPolling" in admin_html
|
||||
assert "function stopProgressPolling" in admin_html
|
||||
assert "progressPollTimer" in admin_html
|
||||
# 数据来源:GET /documents/tasks?limit=50
|
||||
assert "/api/v1/documents/tasks?limit=50" in admin_html
|
||||
# loadProgress / renderProgress / buildProgressRow 函数
|
||||
assert "function loadProgress" in admin_html
|
||||
assert "function renderProgress" in admin_html
|
||||
assert "function buildProgressRow" in admin_html
|
||||
|
||||
|
||||
def test_admin_page_progress_polling_lifecycle(admin_html: str) -> None:
|
||||
"""进度区块轮询生命周期:activateSection 切走暂停、切回恢复;showLogin 停止"""
|
||||
# activateSection 开头调用 stopProgressPolling(切走即暂停)
|
||||
assert "stopProgressPolling();" in admin_html
|
||||
# 切到 section-progress 时恢复轮询
|
||||
assert 'targetId === "section-progress"' in admin_html
|
||||
assert 'startProgressPolling()' in admin_html
|
||||
# showLogin 退出时停止轮询
|
||||
assert "stopProgressPolling" in admin_html
|
||||
|
||||
|
||||
def test_admin_page_progress_status_badge_reuse(admin_html: str) -> None:
|
||||
"""进度表格状态徽章复用 INGEST_STATUS_TEXT 中文映射与 status-* 配色"""
|
||||
# statusBadgeEl 共享构造函数(无 id 可重复使用)
|
||||
assert "function statusBadgeEl" in admin_html
|
||||
# makeStatusBadge 复用 statusBadgeEl + 加 id
|
||||
assert "statusBadgeEl(status)" in admin_html
|
||||
# 状态徽章 CSS 类复用
|
||||
for cls in ("status-running", "status-done", "status-failed"):
|
||||
assert cls in admin_html
|
||||
|
||||
|
||||
def test_admin_page_progress_actions(admin_html: str) -> None:
|
||||
"""进度表格操作列:done 查看文档+重新入库、failed 重试、进行中无操作;reingest 端点"""
|
||||
# done 状态操作按钮
|
||||
assert "查看文档" in admin_html
|
||||
assert "重新入库" in admin_html
|
||||
# failed 状态重试按钮
|
||||
assert "重试" in admin_html
|
||||
# reingestDocument 函数与 POST /documents/{doc_id}/reingest 端点
|
||||
assert "function reingestDocument" in admin_html
|
||||
assert "/reingest" in admin_html
|
||||
# 查看文档:切到 section-docs 并调用 loadDocDetail
|
||||
assert 'activateSection("section-docs")' in admin_html
|
||||
assert "loadDocDetail" in admin_html
|
||||
# 状态统计文案
|
||||
assert "进行中" in admin_html
|
||||
|
||||
|
||||
def test_admin_page_batch_upload(admin_html: str) -> None:
|
||||
"""批量上传:input multiple 属性、upload-batch 端点、批量结果展示、自动切进度区块"""
|
||||
# file input multiple 属性
|
||||
assert 'id="upload-file"' in admin_html
|
||||
assert "multiple" in admin_html
|
||||
# 批量上传分支:files > 1 时走 upload-batch 端点
|
||||
assert "/api/v1/documents/upload-batch" in admin_html
|
||||
assert 'fileInput.files.length > 1' in admin_html
|
||||
# FormData 用 files 字段逐文件 append
|
||||
assert 'batchForm.append("files"' in admin_html
|
||||
# 批量结果展示函数
|
||||
assert "function renderBatchUploadResult" in admin_html
|
||||
assert "批量上传完成" in admin_html
|
||||
# 成功后自动切到入库进度区块
|
||||
assert 'activateSection("section-progress")' in admin_html
|
||||
# 单文件上传仍走原 upload 端点(保持既有逻辑)
|
||||
assert "/api/v1/documents/upload\"" in admin_html or "/api/v1/documents/upload'," in admin_html
|
||||
|
||||
|
||||
def test_admin_page_no_prompt_confirm_calls(admin_html: str) -> None:
|
||||
"""清理:弹窗组件已替换原生 prompt/confirm,全页面无 prompt( 与 confirm( 调用残留"""
|
||||
assert "prompt(" not in admin_html
|
||||
@@ -490,15 +605,19 @@ def test_admin_page_api_guide_paths_in_real_routes(admin_html: str) -> None:
|
||||
assert (method, path) in route_methods, f"API_GUIDE 端点 {method} {path} 不在后端路由集合内"
|
||||
|
||||
# 全部真实端点覆盖:health/search/documents*/knowledge*/auth*
|
||||
# Task 2 新增端点:upload-batch / tasks 列表 / reingest
|
||||
expected = {
|
||||
("GET", "/api/v1/health"),
|
||||
("POST", "/api/v1/search"),
|
||||
("POST", "/api/v1/documents"),
|
||||
("POST", "/api/v1/documents/upload"),
|
||||
("POST", "/api/v1/documents/upload-batch"),
|
||||
("GET", "/api/v1/documents/tasks"),
|
||||
("GET", "/api/v1/documents/tasks/{task_id}"),
|
||||
("GET", "/api/v1/documents"),
|
||||
("GET", "/api/v1/documents/{doc_id}"),
|
||||
("DELETE", "/api/v1/documents/{doc_id}"),
|
||||
("POST", "/api/v1/documents/{doc_id}/reingest"),
|
||||
("GET", "/api/v1/knowledge/categories"),
|
||||
("GET", "/api/v1/knowledge/stats"),
|
||||
("POST", "/api/v1/auth/login"),
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""POST /documents/upload-batch、POST /documents/{id}/reingest、GET /documents/tasks 端点测试
|
||||
|
||||
TestClient + FakeManager + FakeQdrant,不真实联网。
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import document as document_module
|
||||
from app.main import app
|
||||
from app.models.document import DocumentInput
|
||||
from app.services.qdrant import QdrantService
|
||||
|
||||
|
||||
class FakeManager:
|
||||
"""假入库任务管理器:支持 submit/get/list_tasks"""
|
||||
|
||||
def __init__(self, task_id: str = "task-1") -> None:
|
||||
self.task_id = task_id
|
||||
self._counter = 0
|
||||
self.submitted: list[DocumentInput] = []
|
||||
self._tasks: list[dict[str, Any]] = []
|
||||
|
||||
async def submit(self, doc: DocumentInput) -> str:
|
||||
self.submitted.append(doc)
|
||||
self._counter += 1
|
||||
tid = f"{self.task_id}-{self._counter}"
|
||||
self._tasks.append(
|
||||
{
|
||||
"task_id": tid,
|
||||
"status": "pending",
|
||||
"filename": doc.metadata.get("original_filename") or doc.title,
|
||||
"created_at": f"2026-01-0{self._counter}T00:00:00+00:00",
|
||||
"updated_at": f"2026-01-0{self._counter}T00:00:00+00:00",
|
||||
"doc_id": None,
|
||||
}
|
||||
)
|
||||
return tid
|
||||
|
||||
async def get(self, task_id: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
async def list_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
return list(self._tasks[:limit])
|
||||
|
||||
|
||||
class FakeQdrant:
|
||||
"""假 Qdrant 服务:可控的 get_l1_metadata/delete_by_doc_id"""
|
||||
|
||||
def __init__(self, meta: dict[str, str] | None = None) -> None:
|
||||
self.meta = meta
|
||||
self.deleted: list[str] = []
|
||||
|
||||
async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None:
|
||||
return self.meta
|
||||
|
||||
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
|
||||
self.deleted.append(doc_id)
|
||||
return {"doc_l1": 1}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, admin_headers: dict[str, str]
|
||||
) -> Iterator[TestClient]:
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
monkeypatch.setattr(document_module.settings, "upload_dir", str(tmp_path / "uploads"))
|
||||
with TestClient(app) as test_client:
|
||||
test_client.headers.update(admin_headers)
|
||||
yield test_client
|
||||
|
||||
|
||||
def _inject_manager(monkeypatch: pytest.MonkeyPatch, manager: FakeManager) -> None:
|
||||
monkeypatch.setattr(document_module, "_get_task_manager", lambda: manager)
|
||||
|
||||
|
||||
def _inject_qdrant(monkeypatch: pytest.MonkeyPatch, qdrant: FakeQdrant) -> None:
|
||||
monkeypatch.setattr(document_module, "_get_qdrant", lambda: qdrant)
|
||||
|
||||
|
||||
# ----------------------- upload-batch -----------------------
|
||||
|
||||
|
||||
def test_batch_upload_multiple_files_success(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""多文件全部成功:3 文件 → 3 tasks,failed 为空"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[
|
||||
("files", ("a.txt", b"content a", "text/plain")),
|
||||
("files", ("b.md", b"# B", "text/markdown")),
|
||||
("files", ("c.txt", b"content c", "text/plain")),
|
||||
],
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert len(data["tasks"]) == 3
|
||||
assert data["failed"] == []
|
||||
assert len(manager.submitted) == 3
|
||||
filenames = [t["filename"] for t in data["tasks"]]
|
||||
assert filenames == ["a.txt", "b.md", "c.txt"]
|
||||
|
||||
|
||||
def test_batch_upload_partial_failure(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""部分失败:1 个不支持扩展名进 failed,其他成功"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[
|
||||
("files", ("ok.txt", b"good", "text/plain")),
|
||||
("files", ("bad.xlsx", b"binary", "application/octet-stream")),
|
||||
],
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert len(data["tasks"]) == 1
|
||||
assert data["tasks"][0]["filename"] == "ok.txt"
|
||||
assert len(data["failed"]) == 1
|
||||
assert data["failed"][0]["filename"] == "bad.xlsx"
|
||||
assert "不支持" in data["failed"][0]["error"]
|
||||
assert len(manager.submitted) == 1
|
||||
|
||||
|
||||
def test_batch_upload_all_fail(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""全部失败:tasks 空,failed 2 条,未提交任何任务"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[
|
||||
("files", ("a.exe", b"x", "application/octet-stream")),
|
||||
("files", ("b.bin", b"y", "application/octet-stream")),
|
||||
],
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert data["tasks"] == []
|
||||
assert len(data["failed"]) == 2
|
||||
assert len(manager.submitted) == 0
|
||||
|
||||
|
||||
def test_batch_upload_empty_list_returns_1001(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""空文件列表:1001"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/upload-batch", files=[])
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1001
|
||||
assert manager.submitted == []
|
||||
|
||||
|
||||
# ----------------------- reingest -----------------------
|
||||
|
||||
|
||||
def test_reingest_success(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""有原始文件:202 + task_id,删旧数据后提交新任务"""
|
||||
upload_dir = tmp_path / "uploads"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
raw = upload_dir / "raw_report.txt"
|
||||
raw.write_bytes(b"original content")
|
||||
meta = {
|
||||
"raw_file_path": str(raw),
|
||||
"original_filename": "raw_report.txt",
|
||||
}
|
||||
qdrant = FakeQdrant(meta=meta)
|
||||
_inject_qdrant(monkeypatch, qdrant)
|
||||
manager = FakeManager(task_id="reingest-task")
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/some-doc/reingest")
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert data["task_id"] == "reingest-task-1"
|
||||
assert data["status"] == "pending"
|
||||
# 旧数据已删
|
||||
assert qdrant.deleted == ["some-doc"]
|
||||
# 新任务已提交,文本来自原文件
|
||||
assert len(manager.submitted) == 1
|
||||
assert manager.submitted[0].text == "original content"
|
||||
assert manager.submitted[0].title == "raw_report" # 文件名 stem
|
||||
|
||||
|
||||
def test_reingest_no_raw_file_returns_1001(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""无原始文件(meta 无 raw_file_path):1001"""
|
||||
qdrant = FakeQdrant(meta={"some": "metadata"})
|
||||
_inject_qdrant(monkeypatch, qdrant)
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/doc1/reingest")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1001
|
||||
assert "无原始文件" in body["message"]
|
||||
assert len(manager.submitted) == 0
|
||||
|
||||
|
||||
def test_reingest_missing_file_returns_1004(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""文件不存在:1004"""
|
||||
qdrant = FakeQdrant(
|
||||
meta={"raw_file_path": "/nonexistent/path.txt", "original_filename": "path.txt"}
|
||||
)
|
||||
_inject_qdrant(monkeypatch, qdrant)
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/doc1/reingest")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1004
|
||||
assert len(manager.submitted) == 0
|
||||
|
||||
|
||||
def test_reingest_doc_not_found_returns_1004(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""文档不存在(meta 为 None):1004"""
|
||||
qdrant = FakeQdrant(meta=None)
|
||||
_inject_qdrant(monkeypatch, qdrant)
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents/nope/reingest")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1004
|
||||
assert len(manager.submitted) == 0
|
||||
|
||||
|
||||
# ----------------------- tasks 列表 -----------------------
|
||||
|
||||
|
||||
def test_list_ingest_tasks_returns_items(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""任务列表:返回近期任务,含 items 与 total"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
manager._tasks = [
|
||||
{
|
||||
"task_id": "t1",
|
||||
"status": "done",
|
||||
"filename": "a.txt",
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
"doc_id": "d1",
|
||||
},
|
||||
{
|
||||
"task_id": "t2",
|
||||
"status": "failed",
|
||||
"filename": "b.txt",
|
||||
"created_at": "2026-01-02T00:00:00+00:00",
|
||||
"updated_at": "2026-01-02T00:00:00+00:00",
|
||||
"doc_id": None,
|
||||
},
|
||||
]
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
assert data["items"][0]["task_id"] == "t1"
|
||||
assert data["items"][0]["doc_id"] == "d1"
|
||||
|
||||
|
||||
def test_list_ingest_tasks_limit(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""limit 截断生效"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
manager._tasks = [
|
||||
{
|
||||
"task_id": f"t{i}",
|
||||
"status": "done",
|
||||
"filename": "f",
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"doc_id": None,
|
||||
}
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks?limit=5")
|
||||
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] == 5
|
||||
assert len(data["items"]) == 5
|
||||
|
||||
|
||||
def test_list_ingest_tasks_requires_auth(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""无 token:1005 未认证"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks", headers={"Authorization": ""})
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1005
|
||||
@@ -0,0 +1,339 @@
|
||||
"""批量上传与入库进度集成验证
|
||||
|
||||
在真实内存 Qdrant + FakeOllama + 确定性向量环境下,全链路验证:
|
||||
- POST /documents/upload-batch 批量上传闭环(全部成功 / 部分失败)
|
||||
- GET /documents/tasks 任务列表(按 updated_at 降序、每项含 filename/doc_id)
|
||||
- POST /documents/{doc_id}/reingest 重新入库(成功 / 边界 1001/1004)
|
||||
|
||||
复用 tests/test_e2e_integration.py 的 FakeOllama / DeterministicEmbedding / FakeCache;
|
||||
upload_dir 用 tmp_path 真实落盘,monkeypatch 模块级 _task_manager / _qdrant 单例。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from app.api.v1 import document as document_module
|
||||
from app.config import Settings
|
||||
from app.core.chunker import Chunker
|
||||
from app.core.classifier import Classifier
|
||||
from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus
|
||||
from app.core.ingestion import Ingester
|
||||
from app.core.query_parser import QueryParser
|
||||
from app.core.retriever import Retriever
|
||||
from app.core.sparse import SparseEncoder
|
||||
from app.core.summarizer import Summarizer
|
||||
from app.main import app
|
||||
from app.models.knowledge import load_taxonomy
|
||||
from app.services.qdrant import COLLECTION_CHUNKS, QdrantService
|
||||
from tests.test_e2e_integration import (
|
||||
FAKE_L1_SUMMARY,
|
||||
DeterministicEmbedding,
|
||||
FakeCache,
|
||||
FakeOllama,
|
||||
)
|
||||
|
||||
|
||||
async def _make_env() -> tuple[QdrantService, Ingester, Retriever]:
|
||||
"""构建集成环境:真实组件 + 内存 Qdrant + FakeOllama + 确定性向量"""
|
||||
qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||
await qdrant.ensure_collections()
|
||||
ollama = FakeOllama()
|
||||
taxonomy = load_taxonomy()
|
||||
embedding = DeterministicEmbedding()
|
||||
ingester = Ingester(
|
||||
summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type]
|
||||
classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type]
|
||||
chunker=Chunker(),
|
||||
embedding=embedding, # type: ignore[arg-type]
|
||||
sparse=SparseEncoder(),
|
||||
qdrant=qdrant,
|
||||
)
|
||||
retriever = Retriever(
|
||||
qdrant=qdrant,
|
||||
query_parser=QueryParser(ollama=ollama, taxonomy=taxonomy, cache=FakeCache()), # type: ignore[arg-type]
|
||||
embedding=embedding, # type: ignore[arg-type]
|
||||
sparse_encoder=SparseEncoder(),
|
||||
)
|
||||
return qdrant, ingester, retriever
|
||||
|
||||
|
||||
def _patch_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
manager: IngestTaskManager,
|
||||
qdrant: QdrantService,
|
||||
retriever: Retriever,
|
||||
upload_dir: Path,
|
||||
) -> None:
|
||||
"""替换模块级单例:任务管理器 / Qdrant / Retriever / upload_dir,lifespan 建集合改空操作"""
|
||||
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
monkeypatch.setattr(document_module, "_task_manager", manager)
|
||||
monkeypatch.setattr(document_module, "_qdrant", qdrant)
|
||||
monkeypatch.setattr(document_module.settings, "upload_dir", str(upload_dir))
|
||||
|
||||
|
||||
def _md_content(name: str) -> bytes:
|
||||
"""构造可触发完整三级总结的 .md 文件内容(不同 name 保证文本不同,避免去重)"""
|
||||
paragraph = f"这是 {name} 章节的正文内容,包含足够信息量用于测试切分与向量化流程。" * 20
|
||||
text = f"# {name} 安装指南\n{paragraph}\n\n## 环境准备\n{paragraph}\n\n## 安装步骤\n{paragraph}"
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
class TestBatchUploadIntegration:
|
||||
"""批量上传闭环:3 文件全成功 → 任务列表 → 文档详情含原始文件信息"""
|
||||
|
||||
async def test_batch_upload_full_loop(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
admin_headers: dict[str, str],
|
||||
) -> None:
|
||||
qdrant, ingester, retriever = await _make_env()
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 1. 批量上传 3 个 .md:202 + tasks 含 3 个 + failed 空
|
||||
resp = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[
|
||||
("files", ("a.md", _md_content("Alpha"), "text/markdown")),
|
||||
("files", ("b.md", _md_content("Beta"), "text/markdown")),
|
||||
("files", ("c.md", _md_content("Gamma"), "text/markdown")),
|
||||
],
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert len(data["tasks"]) == 3
|
||||
assert data["failed"] == []
|
||||
task_ids = [t["task_id"] for t in data["tasks"]]
|
||||
filenames = [t["filename"] for t in data["tasks"]]
|
||||
assert filenames == ["a.md", "b.md", "c.md"]
|
||||
|
||||
# 2. 逐个等待终态:全部 done
|
||||
doc_ids: list[str] = []
|
||||
for tid in task_ids:
|
||||
final = await manager.wait_done(tid)
|
||||
assert final["status"] == IngestTaskStatus.DONE
|
||||
doc_ids.append(final["result"]["document_id"])
|
||||
|
||||
# 3. GET /documents/tasks:items 含 3 项,按 updated_at 降序,每项含 filename/doc_id
|
||||
resp_list = client.get(
|
||||
"/api/v1/documents/tasks?limit=10", headers=admin_headers
|
||||
)
|
||||
body_list = resp_list.json()
|
||||
assert body_list["code"] == 0
|
||||
items = body_list["data"]["items"]
|
||||
assert body_list["data"]["total"] == 3
|
||||
assert len(items) == 3
|
||||
# 按 updated_at 降序
|
||||
updated = [it["updated_at"] for it in items]
|
||||
assert updated == sorted(updated, reverse=True)
|
||||
# 每项含 filename 与 doc_id
|
||||
for it in items:
|
||||
assert it["filename"] in {"a.md", "b.md", "c.md"}
|
||||
assert it["doc_id"] in doc_ids
|
||||
|
||||
# 4. GET /documents/{doc_id}:file 字段非 null(有原始文件信息)
|
||||
resp_detail = client.get(f"/api/v1/documents/{doc_ids[0]}")
|
||||
body_detail = resp_detail.json()
|
||||
assert body_detail["code"] == 0
|
||||
file_info = body_detail["data"]["file"]
|
||||
assert file_info is not None
|
||||
assert file_info["filename"] == filenames[0]
|
||||
assert file_info["size_bytes"] > 0
|
||||
assert file_info["url"] == f"/api/v1/documents/{doc_ids[0]}/file"
|
||||
|
||||
|
||||
class TestBatchUploadPartialFailure:
|
||||
"""部分失败:1 个 .unsupported 扩展名进 failed,其余 2 个成功"""
|
||||
|
||||
async def test_batch_upload_partial_failure(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
admin_headers: dict[str, str],
|
||||
) -> None:
|
||||
qdrant, ingester, retriever = await _make_env()
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[
|
||||
("files", ("ok1.md", _md_content("Ok1"), "text/markdown")),
|
||||
("files", ("ok2.md", _md_content("Ok2"), "text/markdown")),
|
||||
("files", ("bad.unsupported", b"whatever", "application/octet-stream")),
|
||||
],
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()["data"]
|
||||
assert len(data["tasks"]) == 2
|
||||
assert len(data["failed"]) == 1
|
||||
assert data["failed"][0]["filename"] == "bad.unsupported"
|
||||
assert "不支持" in data["failed"][0]["error"]
|
||||
# 2 个成功任务均可进入终态 done
|
||||
for t in data["tasks"]:
|
||||
final = await manager.wait_done(t["task_id"])
|
||||
assert final["status"] == IngestTaskStatus.DONE
|
||||
|
||||
|
||||
class TestReingestIntegration:
|
||||
"""重新入库:成功路径 + 边界(无原始文件 / 文档不存在 / 文件已删)"""
|
||||
|
||||
async def test_reingest_success(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
admin_headers: dict[str, str],
|
||||
) -> None:
|
||||
qdrant, ingester, retriever = await _make_env()
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 先上传 1 个文件,拿到 doc_id
|
||||
resp_up = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[("files", ("re.md", _md_content("Reingest"), "text/markdown"))],
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert resp_up.status_code == 202
|
||||
up_data = resp_up.json()["data"]
|
||||
assert len(up_data["tasks"]) == 1
|
||||
old_task_id = up_data["tasks"][0]["task_id"]
|
||||
old_final = await manager.wait_done(old_task_id)
|
||||
old_doc_id = old_final["result"]["document_id"]
|
||||
|
||||
# 重新入库:202 + task_id
|
||||
resp_re = client.post(
|
||||
f"/api/v1/documents/{old_doc_id}/reingest", headers=admin_headers
|
||||
)
|
||||
assert resp_re.status_code == 202
|
||||
re_body = resp_re.json()
|
||||
assert re_body["code"] == 0
|
||||
new_task_id = re_body["data"]["task_id"]
|
||||
assert new_task_id
|
||||
|
||||
# 等待终态:done
|
||||
new_final = await manager.wait_done(new_task_id)
|
||||
assert new_final["status"] == IngestTaskStatus.DONE
|
||||
new_doc_id = new_final["result"]["document_id"]
|
||||
|
||||
# 旧 doc_id 数据已删:GET /documents/{old_doc_id} → 1004
|
||||
resp_old = client.get(f"/api/v1/documents/{old_doc_id}")
|
||||
assert resp_old.json()["code"] == 1004
|
||||
|
||||
# 新 doc_id 详情:L1 summary 存在,file 非 null(保留原文件信息)
|
||||
resp_new = client.get(f"/api/v1/documents/{new_doc_id}")
|
||||
body_new = resp_new.json()
|
||||
assert body_new["code"] == 0
|
||||
assert body_new["data"]["l1"]["text"] == FAKE_L1_SUMMARY
|
||||
assert body_new["data"]["file"] is not None
|
||||
assert body_new["data"]["file"]["filename"] == "re.md"
|
||||
|
||||
# 旧 chunks 已删:chunks 集合中 old_doc_id 点数为 0
|
||||
old_count = await qdrant.client.count(
|
||||
collection_name=COLLECTION_CHUNKS,
|
||||
count_filter=qdrant.build_filter(doc_ids=[old_doc_id]),
|
||||
exact=True,
|
||||
)
|
||||
assert old_count.count == 0
|
||||
|
||||
async def test_reingest_text_only_doc_returns_1001(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
admin_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""纯文本入库的 doc_id → reingest → 1001(无原始文件)"""
|
||||
qdrant, ingester, retriever = await _make_env()
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 纯文本入库(无原始文件落盘记录)
|
||||
resp_doc = client.post(
|
||||
"/api/v1/documents",
|
||||
json={
|
||||
"text": "# 纯文本\n这是一段纯文本入库的内容,用于测试 reingest 边界。",
|
||||
"title": "纯文本",
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert resp_doc.status_code == 202
|
||||
task_id = resp_doc.json()["data"]["task_id"]
|
||||
final = await manager.wait_done(task_id)
|
||||
doc_id = final["result"]["document_id"]
|
||||
|
||||
# reingest → 1001
|
||||
resp_re = client.post(
|
||||
f"/api/v1/documents/{doc_id}/reingest", headers=admin_headers
|
||||
)
|
||||
body = resp_re.json()
|
||||
assert body["code"] == 1001
|
||||
assert "无原始文件" in body["message"]
|
||||
|
||||
async def test_reingest_doc_not_found_returns_1004(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
admin_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""不存在的 doc_id → reingest → 1004"""
|
||||
qdrant, ingester, retriever = await _make_env()
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/api/v1/documents/nonexistent-doc/reingest", headers=admin_headers
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["code"] == 1004
|
||||
|
||||
async def test_reingest_file_deleted_returns_1004(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
admin_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""文件被删(删 upload_dir 下落盘文件后)→ reingest → 1004"""
|
||||
qdrant, ingester, retriever = await _make_env()
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 上传 1 个文件
|
||||
resp_up = client.post(
|
||||
"/api/v1/documents/upload-batch",
|
||||
files=[("files", ("del.md", _md_content("Deleted"), "text/markdown"))],
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert resp_up.status_code == 202
|
||||
tid = resp_up.json()["data"]["tasks"][0]["task_id"]
|
||||
final = await manager.wait_done(tid)
|
||||
doc_id = final["result"]["document_id"]
|
||||
|
||||
# 删除 upload_dir 下落盘的原始文件
|
||||
meta = await qdrant.get_l1_metadata(doc_id)
|
||||
assert meta is not None
|
||||
raw_path = Path(meta["raw_file_path"])
|
||||
assert raw_path.is_file()
|
||||
raw_path.unlink()
|
||||
|
||||
# reingest → 1004(文件不存在)
|
||||
resp_re = client.post(
|
||||
f"/api/v1/documents/{doc_id}/reingest", headers=admin_headers
|
||||
)
|
||||
body = resp_re.json()
|
||||
assert body["code"] == 1004
|
||||
+143
-1
@@ -7,8 +7,8 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings
|
||||
from app.core.dedup import DEDUP_KEY_PREFIX
|
||||
from app.core.ingest_tasks import (
|
||||
DEDUP_KEY_PREFIX,
|
||||
REDIS_KEY_PREFIX,
|
||||
IngestTaskManager,
|
||||
IngestTaskStatus,
|
||||
@@ -91,6 +91,19 @@ class FakeRedis:
|
||||
async def get_json(self, key: str) -> dict[str, Any] | None:
|
||||
return self.store.get(key)
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
"""返回支持 scan_iter 的假客户端(供 list_tasks 扫描键)"""
|
||||
store = self.store
|
||||
|
||||
class _FakeClient:
|
||||
async def scan_iter(self, match: str = "*") -> Any:
|
||||
prefix = match[:-1] if match.endswith("*") else match
|
||||
for key in list(store.keys()):
|
||||
if key.startswith(prefix):
|
||||
yield key
|
||||
|
||||
return _FakeClient()
|
||||
|
||||
|
||||
async def test_submit_returns_immediately_and_completes() -> None:
|
||||
"""submit 立即返回;任务后台跑完为 done,结果完整,阶段序列齐全,Redis 镜像同步"""
|
||||
@@ -317,3 +330,132 @@ async def test_dedup_lookup_failure_falls_back_to_normal_pipeline() -> None:
|
||||
final = await manager.wait_done(task, timeout=5)
|
||||
assert final["status"] == IngestTaskStatus.DONE
|
||||
assert len(ingester.calls) == 1
|
||||
|
||||
|
||||
async def test_submit_records_filename_from_metadata_or_title() -> None:
|
||||
"""submit:filename 优先 metadata.original_filename,其次 title"""
|
||||
ingester = FakeIngester()
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
|
||||
# 有 original_filename
|
||||
t1 = await manager.submit(
|
||||
DocumentInput(text="x", title="t1", metadata={"original_filename": "report.pdf"})
|
||||
)
|
||||
await manager.wait_done(t1, timeout=5)
|
||||
# 无 original_filename,回退 title
|
||||
t2 = await manager.submit(DocumentInput(text="y", title="my-title"))
|
||||
await manager.wait_done(t2, timeout=5)
|
||||
|
||||
items = await manager.list_tasks(limit=20)
|
||||
by_id = {it["task_id"]: it for it in items}
|
||||
assert by_id[t1]["filename"] == "report.pdf"
|
||||
assert by_id[t2]["filename"] == "my-title"
|
||||
|
||||
|
||||
async def test_list_tasks_merges_memory_and_redis_and_sorts() -> None:
|
||||
"""list_tasks:合并内存与 Redis 镜像,去重,按 updated_at 降序"""
|
||||
ingester = FakeIngester()
|
||||
redis = FakeRedis()
|
||||
manager = IngestTaskManager(ingester, redis, Settings())
|
||||
|
||||
# 内存任务 1
|
||||
t1 = await manager.submit(DocumentInput(text="内容A", title="t1"))
|
||||
await manager.wait_done(t1, timeout=5)
|
||||
# 内存任务 2
|
||||
t2 = await manager.submit(DocumentInput(text="内容B", title="t2"))
|
||||
await manager.wait_done(t2, timeout=5)
|
||||
|
||||
# Redis 镜像中独有任务(不在内存里,模拟重启后只存在 Redis 的历史记录)
|
||||
redis.store[f"{REDIS_KEY_PREFIX}redis-only-1"] = {
|
||||
"task_id": "redis-only-1",
|
||||
"status": "done",
|
||||
"filename": "legacy.md",
|
||||
"created_at": "2020-01-01T00:00:00+00:00",
|
||||
"updated_at": "2020-01-01T00:00:00+00:00",
|
||||
"result": {"document_id": "doc-legacy"},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
items = await manager.list_tasks(limit=20)
|
||||
# 共 3 条(内存 2 + Redis 独有 1,内存的 2 也已镜像到 Redis 但按 task_id 去重)
|
||||
assert len(items) == 3
|
||||
# 按 updated_at 降序:内存任务(当前时间)排在 Redis 旧任务前
|
||||
ids = [it["task_id"] for it in items]
|
||||
assert "redis-only-1" in ids
|
||||
assert ids[-1] == "redis-only-1" # 最旧排最后
|
||||
# Redis 独有任务提取 doc_id 与 filename
|
||||
legacy = next(it for it in items if it["task_id"] == "redis-only-1")
|
||||
assert legacy["filename"] == "legacy.md"
|
||||
assert legacy["doc_id"] == "doc-legacy"
|
||||
# 内存 done 任务也提取 doc_id
|
||||
mem_item = next(it for it in items if it["task_id"] == t1)
|
||||
assert mem_item["doc_id"] == "doc-1"
|
||||
|
||||
|
||||
async def test_list_tasks_dedups_memory_and_redis_by_task_id() -> None:
|
||||
"""list_tasks:内存与 Redis 都有的同一 task_id 仅保留内存版本(去重)"""
|
||||
ingester = FakeIngester()
|
||||
redis = FakeRedis()
|
||||
manager = IngestTaskManager(ingester, redis, Settings())
|
||||
|
||||
task_id = await manager.submit(DocumentInput(text="唯一", title="t"))
|
||||
await manager.wait_done(task_id, timeout=5)
|
||||
|
||||
# Redis 镜像中给同一 task_id 篡改一个旧 status,验证内存版本胜出
|
||||
redis.store[f"{REDIS_KEY_PREFIX}{task_id}"]["status"] = "pending"
|
||||
|
||||
items = await manager.list_tasks(limit=20)
|
||||
assert len(items) == 1
|
||||
assert items[0]["task_id"] == task_id
|
||||
assert items[0]["status"] == IngestTaskStatus.DONE # 内存版本(done)
|
||||
|
||||
|
||||
async def test_list_tasks_respects_limit() -> None:
|
||||
"""list_tasks:limit 截断返回条数"""
|
||||
ingester = FakeIngester()
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
for i in range(5):
|
||||
t = await manager.submit(DocumentInput(text=f"c{i}", title=f"t{i}"))
|
||||
await manager.wait_done(t, timeout=5)
|
||||
|
||||
items = await manager.list_tasks(limit=3)
|
||||
assert len(items) == 3
|
||||
|
||||
|
||||
async def test_list_tasks_without_redis_returns_memory_only() -> None:
|
||||
"""Redis 不可用:list_tasks 仅返回内存任务"""
|
||||
ingester = FakeIngester()
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
t1 = await manager.submit(DocumentInput(text="x", title="t1"))
|
||||
await manager.wait_done(t1, timeout=5)
|
||||
|
||||
items = await manager.list_tasks(limit=20)
|
||||
assert len(items) == 1
|
||||
assert items[0]["task_id"] == t1
|
||||
assert items[0]["filename"] == "t1"
|
||||
assert items[0]["doc_id"] == "doc-1"
|
||||
|
||||
|
||||
async def test_list_tasks_extract_fields_for_in_progress_task() -> None:
|
||||
"""进行中任务:doc_id 为 None(done 时才从 result.document_id 提取)"""
|
||||
ingester = FakeIngester()
|
||||
ingester.gate = asyncio.Event() # 阻塞任务使其停留在最后一个阶段
|
||||
manager = IngestTaskManager(ingester, None, Settings(ingest_max_concurrency=1))
|
||||
|
||||
t1 = await manager.submit(
|
||||
DocumentInput(text="x", title="t1", metadata={"original_filename": "a.txt"})
|
||||
)
|
||||
await asyncio.wait_for(ingester.started.wait(), timeout=1)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
items = await manager.list_tasks(limit=20)
|
||||
assert len(items) == 1
|
||||
item = items[0]
|
||||
assert item["task_id"] == t1
|
||||
# 任务已推进到 writing 阶段(gate 阻塞前最后一个 progress_cb)
|
||||
assert item["status"] == IngestTaskStatus.WRITING
|
||||
assert item["doc_id"] is None # 未完成,doc_id 为 None
|
||||
assert item["filename"] == "a.txt"
|
||||
|
||||
ingester.gate.set()
|
||||
await manager.wait_done(t1, timeout=5)
|
||||
|
||||
Reference in New Issue
Block a user