refactor(api): 统一响应格式迁移 + 异步任务系统 + 状态码体系完善
- 将全部路由文件(12个)的 jsonify 响应迁移至 success_response/error_response 统一格式 - 修复 sync_routes.py error_response 参数错误(P0) - 新增异步任务系统:task_registry + task_routes - 新增状态码:TASK_NOT_FOUND(4014)、TASK_CONFLICT(4015)、REINDEX_ERROR(5040) - 修正 task_routes/exam_pkg 中语义不匹配的状态码 - 更新 curl 测试手册、后端对接规范文档 - 添加缓存性能报告和 Redis 迁移计划
This commit is contained in:
@@ -132,6 +132,10 @@ def create_app() -> 'Flask':
|
||||
from api.image_routes import image_bp
|
||||
app.register_blueprint(image_bp)
|
||||
|
||||
# 异步任务查询
|
||||
from api.task_routes import task_bp
|
||||
app.register_blueprint(task_bp)
|
||||
|
||||
# 健康检查
|
||||
from api.auth_routes import auth_bp
|
||||
app.register_blueprint(auth_bp)
|
||||
@@ -254,4 +258,5 @@ def _print_startup_info(app: 'Flask') -> None:
|
||||
logger.info(" 切片管理: /chunks/*")
|
||||
logger.info(" 同步服务: /sync, /sync/status")
|
||||
logger.info(" 图片服务: /images/*")
|
||||
logger.info(" 任务查询: /tasks, /tasks/<id>, /tasks/<id>/progress")
|
||||
logger.info(" 健康检查: /health")
|
||||
|
||||
@@ -11,6 +11,8 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from auth.gateway import require_gateway_auth
|
||||
from data.db import get_connection
|
||||
from core.status_codes import SUCCESS, INTERNAL_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
audit_bp = Blueprint('audit', __name__)
|
||||
|
||||
@@ -94,11 +96,11 @@ def get_audit_logs():
|
||||
"timestamp": row[10]
|
||||
})
|
||||
|
||||
return jsonify({"logs": logs, "total": total})
|
||||
return success_response(data={"logs": logs, "total": total})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"审计查询异常: {e}")
|
||||
return jsonify({"error": "查询失败", "logs": [], "total": 0}), 500
|
||||
return error_response("QUERY_FAILED", INTERNAL_ERROR, "查询失败", http_status=500)
|
||||
|
||||
|
||||
def log_audit_event(user_id: str, username: str, action: str,
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from auth.gateway import require_gateway_auth, require_role, get_user_permissions, MOCK_USERS
|
||||
from core.status_codes import SUCCESS, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, PERMISSION_DENIED, INTERNAL_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
@@ -52,7 +54,7 @@ def mock_login():
|
||||
"""
|
||||
# 默认开启开发模式(生产环境需设置 DEV_MODE=false)
|
||||
if os.environ.get('DEV_MODE', 'true').lower() == 'false':
|
||||
return jsonify({"error": "仅开发环境可用,请设置 DEV_MODE=true"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用,请设置 DEV_MODE=true", http_status=403)
|
||||
|
||||
data = request.json or {}
|
||||
username = data.get('username')
|
||||
@@ -60,9 +62,9 @@ def mock_login():
|
||||
|
||||
user = MOCK_USERS.get(username)
|
||||
if not user or user['password'] != password:
|
||||
return jsonify({"error": "用户名或密码错误"}), 401
|
||||
return error_response("UNAUTHORIZED", UNAUTHORIZED, "用户名或密码错误", http_status=401)
|
||||
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
"token": f"mock-token-{username}",
|
||||
"user": {
|
||||
"user_id": user['user_id'],
|
||||
@@ -80,7 +82,7 @@ def get_stats():
|
||||
"""获取系统统计信息(仅管理员)"""
|
||||
from flask import current_app
|
||||
session_manager = current_app.config['SESSION_MANAGER']
|
||||
return jsonify(session_manager.get_stats())
|
||||
return success_response(data=session_manager.get_stats())
|
||||
|
||||
|
||||
@auth_bp.route('/health', methods=['GET'])
|
||||
@@ -103,7 +105,7 @@ def get_current_user():
|
||||
开发模式下支持模拟用户,生产模式下用户信息由后端控制。
|
||||
"""
|
||||
user = request.current_user
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
"user_id": user["user_id"],
|
||||
"username": user["username"],
|
||||
"role": user["role"],
|
||||
@@ -133,7 +135,7 @@ def get_users():
|
||||
"""
|
||||
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false'
|
||||
if not dev_mode:
|
||||
return jsonify({"error": "仅开发环境可用"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用", http_status=403)
|
||||
|
||||
users = []
|
||||
for username, info in MOCK_USERS.items():
|
||||
@@ -145,7 +147,7 @@ def get_users():
|
||||
"is_active": True # 模拟用户默认都是活跃状态
|
||||
})
|
||||
|
||||
return jsonify({"users": users})
|
||||
return success_response(data={"users": users})
|
||||
|
||||
|
||||
@auth_bp.route('/auth/users/<user_id>', methods=['PUT'])
|
||||
@@ -161,10 +163,10 @@ def update_user(user_id):
|
||||
"""
|
||||
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false'
|
||||
if not dev_mode:
|
||||
return jsonify({"error": "仅开发环境可用"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用", http_status=403)
|
||||
|
||||
# 模拟用户不支持真正的状态切换,直接返回成功
|
||||
return jsonify({"message": "操作成功(模拟)", "user_id": user_id})
|
||||
return success_response(data={"message": "操作成功(模拟)", "user_id": user_id})
|
||||
|
||||
|
||||
@auth_bp.route('/auth/change-password', methods=['POST'])
|
||||
@@ -181,17 +183,17 @@ def change_password():
|
||||
"""
|
||||
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false'
|
||||
if not dev_mode:
|
||||
return jsonify({"error": "仅开发环境可用"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用", http_status=403)
|
||||
|
||||
data = request.json or {}
|
||||
old_password = data.get('old_password')
|
||||
new_password = data.get('new_password')
|
||||
|
||||
if not old_password or not new_password:
|
||||
return jsonify({"error": "请提供旧密码和新密码"}), 400
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "请提供旧密码和新密码", http_status=400)
|
||||
|
||||
if len(new_password) < 6:
|
||||
return jsonify({"error": "新密码至少6位"}), 400
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, "新密码至少6位", http_status=400)
|
||||
|
||||
# 模拟环境直接返回成功
|
||||
return jsonify({"message": "密码修改成功(模拟)"})
|
||||
return success_response(message="密码修改成功(模拟)")
|
||||
|
||||
@@ -37,6 +37,8 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from auth.gateway import require_gateway_auth
|
||||
from core.status_codes import SUCCESS, BAD_REQUEST, NOT_FOUND, INTERNAL_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
from auth.security import validate_query, filter_response
|
||||
from config import RAG_CHAT_MODEL
|
||||
@@ -1238,12 +1240,12 @@ def chat():
|
||||
history = data.get('history', [])
|
||||
|
||||
if not message:
|
||||
return jsonify({"error": "缺少 message"}), 400
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 message", http_status=400)
|
||||
|
||||
# 输入安全验证
|
||||
is_valid, reason = validate_query(message)
|
||||
if not is_valid:
|
||||
return jsonify({"error": reason}), 400
|
||||
return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
|
||||
|
||||
# 智能聊天
|
||||
result = chat_with_llm(message, history)
|
||||
@@ -1251,7 +1253,7 @@ def chat():
|
||||
# 过滤敏感信息
|
||||
answer = filter_response(result["answer"])
|
||||
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
"answer": answer,
|
||||
"mode": "chat",
|
||||
"sources": result.get("sources", []),
|
||||
@@ -1309,19 +1311,16 @@ def rag():
|
||||
session_id = data.get('session_id')
|
||||
|
||||
if not message:
|
||||
return jsonify({"error": "缺少 message"}), 400
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 message", http_status=400)
|
||||
|
||||
# 生产环境强制校验 chat_history
|
||||
if IS_PROD and history is None:
|
||||
return jsonify({
|
||||
"error": "chat_history is required in production",
|
||||
"code": "MISSING_HISTORY"
|
||||
}), 400
|
||||
return error_response("MISSING_HISTORY", BAD_REQUEST, "chat_history is required in production", http_status=400)
|
||||
|
||||
# 输入安全验证
|
||||
is_valid, reason = validate_query(message)
|
||||
if not is_valid:
|
||||
return jsonify({"error": reason}), 400
|
||||
return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
|
||||
|
||||
# 如果没有指定 collections,使用默认的公开库
|
||||
if not collections:
|
||||
@@ -2048,12 +2047,12 @@ def search():
|
||||
collections = data.get('collections') # 后端传入的知识库列表
|
||||
|
||||
if not query:
|
||||
return jsonify({'error': 'query is required'}), 400
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "query is required", http_status=400)
|
||||
|
||||
# 输入安全校验(注入检测、违禁词、长度限制)
|
||||
is_valid, reason = validate_query(query)
|
||||
if not is_valid:
|
||||
return jsonify({'error': reason}), 400
|
||||
return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
|
||||
|
||||
# top_k 范围校验
|
||||
try:
|
||||
@@ -2067,7 +2066,7 @@ def search():
|
||||
|
||||
results = search_hybrid(query, top_k=top_k, allowed_collections=collections)
|
||||
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
'contexts': results['documents'][0],
|
||||
'metadatas': results['metadatas'][0],
|
||||
'scores': results['scores'][0]
|
||||
|
||||
@@ -39,16 +39,18 @@ import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple, Any, List, Dict
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from werkzeug.utils import secure_filename
|
||||
from auth.gateway import require_gateway_auth
|
||||
from core.status_codes import (
|
||||
UPLOAD_SUCCESS, BATCH_UPLOAD_SUCCESS, BAD_REQUEST,
|
||||
NO_FILE, NO_FILE_SELECTED, NO_COLLECTION,
|
||||
FILE_TOO_LARGE, UNSUPPORTED_FORMAT, INTERNAL_ERROR
|
||||
UPLOAD_SUCCESS, BATCH_UPLOAD_SUCCESS, BAD_REQUEST, SUCCESS,
|
||||
NO_FILE, NO_FILE_SELECTED, NO_COLLECTION, NOT_FOUND,
|
||||
FILE_TOO_LARGE, UNSUPPORTED_FORMAT, INTERNAL_ERROR,
|
||||
SERVICE_UNAVAILABLE, DELETE_SUCCESS, UPDATE_SUCCESS, NO_CONTENT,
|
||||
FORBIDDEN
|
||||
)
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
@@ -172,7 +174,7 @@ def serve_document_file(doc_path: str) -> Tuple[Any, int]:
|
||||
仅在 DEV_MODE=true 时可用
|
||||
"""
|
||||
if os.environ.get('DEV_MODE', 'true').lower() == 'false':
|
||||
return jsonify({"error": "仅开发环境可用"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用", http_status=403)
|
||||
|
||||
from config import DOCUMENTS_PATH
|
||||
from flask import send_from_directory
|
||||
@@ -181,9 +183,9 @@ def serve_document_file(doc_path: str) -> Tuple[Any, int]:
|
||||
try:
|
||||
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
|
||||
except ValueError:
|
||||
return jsonify({"error": "非法路径"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "非法路径", http_status=403)
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "文件不存在", http_status=404)
|
||||
|
||||
directory = os.path.dirname(filepath)
|
||||
filename = os.path.basename(filepath)
|
||||
@@ -334,10 +336,24 @@ def upload_document() -> Tuple[Any, int]:
|
||||
except Exception as e:
|
||||
logger.warning(f"标记旧版本失败: {e}")
|
||||
|
||||
# 6. 触发向量化
|
||||
# 6. 触发向量化(异步任务)
|
||||
sync_status = "已保存,等待手动同步"
|
||||
sync_service = _get_sync_service()
|
||||
task_id = None
|
||||
|
||||
if sync_service:
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
task = registry.create_task('upload', f"向量化: {filename}")
|
||||
task_id = task.id
|
||||
|
||||
def _do_vectorize(task, svc, change_obj, fname):
|
||||
"""后台执行向量化"""
|
||||
registry.update_progress(task.id, stage='向量化', message=f"正在处理: {fname}")
|
||||
svc.process_change(change_obj)
|
||||
return {'filename': fname, 'sync_status': '已添加到向量库'}
|
||||
|
||||
try:
|
||||
from knowledge.sync import DocumentChange, ChangeType
|
||||
change = DocumentChange(
|
||||
@@ -348,11 +364,12 @@ def upload_document() -> Tuple[Any, int]:
|
||||
new_hash=sync_service.calculate_file_hash(filepath),
|
||||
change_time=datetime.now()
|
||||
)
|
||||
sync_service.process_change(change)
|
||||
sync_status = "已保存并添加到向量库"
|
||||
registry.start_task(task.id, _do_vectorize, sync_service, change, filename)
|
||||
sync_status = "已保存,向量化任务已启动"
|
||||
except Exception as e:
|
||||
logger.warning(f"向量化失败: {e}")
|
||||
sync_status = "已保存,向量化失败"
|
||||
logger.warning(f"创建向量化任务失败: {e}")
|
||||
registry.fail_task(task.id, str(e))
|
||||
sync_status = "已保存,向量化任务创建失败"
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -363,7 +380,8 @@ def upload_document() -> Tuple[Any, int]:
|
||||
"size": file_size,
|
||||
"replaced": replaced
|
||||
},
|
||||
"sync_status": sync_status
|
||||
"sync_status": sync_status,
|
||||
"task_id": task_id
|
||||
},
|
||||
status_code=UPLOAD_SUCCESS,
|
||||
message=f"文件上传成功,{sync_status}"
|
||||
@@ -506,14 +524,55 @@ def batch_upload_documents() -> Tuple[Any, int]:
|
||||
"message": "上传处理失败"
|
||||
})
|
||||
|
||||
success_count = len([r for r in results if r["status"] == "success"])
|
||||
task_id = None
|
||||
|
||||
# 批量上传完成后自动触发向量化(异步任务)
|
||||
sync_service = _get_sync_service()
|
||||
if sync_service and success_count > 0:
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
running = registry.list_tasks(status='running', task_type='batch_upload', limit=1)
|
||||
if not running:
|
||||
task = registry.create_task('batch_upload', f"批量向量化: {success_count} 个文件", total=success_count)
|
||||
task_id = task.id
|
||||
|
||||
def _do_batch_vectorize(task, svc, count):
|
||||
processed = [0]
|
||||
|
||||
def on_change(change):
|
||||
processed[0] += 1
|
||||
registry.update_progress(
|
||||
task.id, current=processed[0], total=count,
|
||||
stage='批量向量化',
|
||||
message=f"已处理: {change.document_name if hasattr(change, 'document_name') else change.document_id}"
|
||||
)
|
||||
|
||||
old_cb = svc.on_change_callback
|
||||
svc.on_change_callback = on_change
|
||||
try:
|
||||
registry.update_progress(task.id, stage='扫描文档', message='正在检测变更...')
|
||||
result = svc.sync_now()
|
||||
return {
|
||||
'synced': result.documents_processed,
|
||||
'added': result.documents_added,
|
||||
'errors': result.errors,
|
||||
}
|
||||
finally:
|
||||
svc.on_change_callback = old_cb
|
||||
|
||||
registry.start_task(task.id, _do_batch_vectorize, sync_service, success_count)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"total": len(results),
|
||||
"success_count": len([r for r in results if r["status"] == "success"]),
|
||||
"results": results
|
||||
"success_count": success_count,
|
||||
"results": results,
|
||||
"task_id": task_id
|
||||
},
|
||||
status_code=BATCH_UPLOAD_SUCCESS,
|
||||
message=f"批量上传完成,成功 {len([r for r in results if r['status'] == 'success'])}/{len(results)} 个文件"
|
||||
message=f"批量上传完成,成功 {success_count}/{len(results)} 个文件"
|
||||
)
|
||||
|
||||
|
||||
@@ -590,10 +649,7 @@ def list_documents() -> Tuple[Any, int]:
|
||||
# 按修改时间倒序
|
||||
documents.sort(key=lambda x: x['last_modified'], reverse=True)
|
||||
|
||||
return jsonify({
|
||||
"documents": documents,
|
||||
"total": len(documents)
|
||||
})
|
||||
return success_response(data={"documents": documents, "total": len(documents)})
|
||||
|
||||
|
||||
@document_bp.route('/documents/<path:doc_path>/status', methods=['GET'])
|
||||
@@ -617,12 +673,12 @@ def get_document_status(doc_path: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return jsonify({"error": "无效的文档路径"}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径", http_status=400)
|
||||
|
||||
subdir = parts[0]
|
||||
filename = '/'.join(parts[1:])
|
||||
@@ -638,16 +694,14 @@ def get_document_status(doc_path: str) -> Tuple[Any, int]:
|
||||
|
||||
if not doc_info:
|
||||
if file_on_disk:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"status": "unprocessed",
|
||||
"chunk_count": 0,
|
||||
"last_processed": None
|
||||
})
|
||||
return jsonify({"error": "文档不存在"}), 404
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "文档不存在", http_status=404)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"status": doc_info.get("status", "unknown"),
|
||||
"chunk_count": doc_info.get("total_chunks", 0),
|
||||
"last_processed": doc_info.get("effective_date") or doc_info.get("version")
|
||||
@@ -674,35 +728,35 @@ def update_document(doc_path: str) -> Tuple[Any, int]:
|
||||
from config import DOCUMENTS_PATH
|
||||
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"error": "没有上传文件"}), 400
|
||||
return error_response("NO_FILE", NO_FILE, "没有上传文件", http_status=400)
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({"error": "没有选择文件"}), 400
|
||||
return error_response("NO_FILE_SELECTED", NO_FILE_SELECTED, "没有选择文件", http_status=400)
|
||||
|
||||
# 文件类型校验
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
return jsonify({"error": f"不支持的文件类型: {ext}"}), 400
|
||||
return error_response("UNSUPPORTED_FORMAT", UNSUPPORTED_FORMAT, f"不支持的文件类型: {ext}", http_status=400)
|
||||
|
||||
# 文件大小校验
|
||||
file.seek(0, 2) # 跳到文件末尾获取大小
|
||||
file_size = file.tell()
|
||||
file.seek(0) # 回到文件开头
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
return jsonify({"error": f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB)"}), 400
|
||||
return error_response("FILE_TOO_LARGE", FILE_TOO_LARGE, f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB)", http_status=400)
|
||||
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return jsonify({"error": "无效的文档路径"}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径", http_status=400)
|
||||
|
||||
try:
|
||||
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
|
||||
except ValueError:
|
||||
return jsonify({"error": "非法路径"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "非法路径", http_status=403)
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "文件不存在", http_status=404)
|
||||
|
||||
# 覆盖文件
|
||||
file.save(filepath)
|
||||
@@ -726,10 +780,7 @@ def update_document(doc_path: str) -> Tuple[Any, int]:
|
||||
except Exception as e:
|
||||
logger.warning(f"重新向量化失败: {e}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "文件已更新"
|
||||
})
|
||||
return success_response(message="文件已更新")
|
||||
|
||||
|
||||
@document_bp.route('/documents/<path:doc_path>', methods=['DELETE'])
|
||||
@@ -754,7 +805,7 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return jsonify({"error": "无效的文档路径"}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径", http_status=400)
|
||||
|
||||
subdir = parts[0]
|
||||
filename = '/'.join(parts[1:])
|
||||
@@ -765,9 +816,9 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
|
||||
try:
|
||||
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
|
||||
except ValueError:
|
||||
return jsonify({"error": "非法路径"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "非法路径", http_status=403)
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "文件不存在", http_status=404)
|
||||
|
||||
try:
|
||||
# 1. 从向量库删除(source 存的是文件名,不是完整路径)
|
||||
@@ -778,14 +829,11 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
|
||||
# 2. 删除文件
|
||||
os.remove(filepath)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "文档已删除"
|
||||
})
|
||||
return success_response(status_code=DELETE_SUCCESS, message="文档已删除")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除文档异常: {e}")
|
||||
return jsonify({"error": "删除失败,请稍后重试"}), 500
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "删除失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@document_bp.route('/documents/<path:doc_path>/chunks', methods=['GET'])
|
||||
@@ -810,12 +858,12 @@ def list_document_chunks(doc_path: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return jsonify({"error": "无效的文档路径"}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径", http_status=400)
|
||||
|
||||
subdir = parts[0]
|
||||
# 目录名即向量库名
|
||||
@@ -823,8 +871,7 @@ def list_document_chunks(doc_path: str) -> Tuple[Any, int]:
|
||||
|
||||
chunks = kb_manager.get_document_chunks(collection, os.path.basename(doc_path))
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"document_id": doc_path,
|
||||
"collection": collection,
|
||||
"chunks": chunks,
|
||||
@@ -860,12 +907,12 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return jsonify({"error": "无效的文档路径,格式: collection/filename"}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径,格式: collection/filename", http_status=400)
|
||||
|
||||
collection = parts[0]
|
||||
filename = os.path.basename(doc_path)
|
||||
@@ -880,7 +927,7 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
# 获取所有切片
|
||||
all_chunks = kb_manager.get_document_chunks(collection, filename)
|
||||
if not all_chunks:
|
||||
return jsonify({"error": f"文档 '{filename}' 不存在或无切片"}), 404
|
||||
return error_response("NOT_FOUND", NOT_FOUND, f"文档 '{filename}' 不存在或无切片", http_status=404)
|
||||
|
||||
total = len(all_chunks)
|
||||
|
||||
@@ -889,8 +936,7 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
preview_chunks = all_chunks[:5]
|
||||
for c in preview_chunks:
|
||||
c['is_target'] = False
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"collection": collection,
|
||||
"source": filename,
|
||||
"total_chunks": total,
|
||||
@@ -902,7 +948,7 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
try:
|
||||
target_chunk_index = int(chunk_index_str)
|
||||
except ValueError:
|
||||
return jsonify({"error": "chunk_index 必须为整数"}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "chunk_index 必须为整数", http_status=400)
|
||||
|
||||
# 按 chunk_index 排序(Chroma 返回顺序不保证有序)
|
||||
all_chunks.sort(key=lambda c: c.get('metadata', {}).get('chunk_index', 0))
|
||||
@@ -923,9 +969,7 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
(c.get('metadata', {}).get('chunk_index', 0) for c in all_chunks),
|
||||
default=total - 1
|
||||
)
|
||||
return jsonify({
|
||||
"error": f"chunk_index={target_chunk_index} 未找到对应切片 (可用范围 0-{max_idx})"
|
||||
}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, f"chunk_index={target_chunk_index} 未找到对应切片 (可用范围 0-{max_idx})", http_status=400)
|
||||
|
||||
# 截取上下文窗口
|
||||
start = max(0, target_pos - context_count)
|
||||
@@ -936,8 +980,7 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
for i, c in enumerate(window):
|
||||
c['is_target'] = (start + i == target_pos)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"collection": collection,
|
||||
"source": filename,
|
||||
"total_chunks": total,
|
||||
@@ -968,7 +1011,7 @@ def create_chunk() -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
|
||||
data = request.json or {}
|
||||
collection = data.get('collection')
|
||||
@@ -976,17 +1019,13 @@ def create_chunk() -> Tuple[Any, int]:
|
||||
metadata = data.get('metadata', {})
|
||||
|
||||
if not collection:
|
||||
return jsonify({"error": "请指定向量库 (collection)"}), 400
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定向量库 (collection)", http_status=400)
|
||||
if not content:
|
||||
return jsonify({"error": "切片内容不能为空"}), 400
|
||||
return error_response("NO_CONTENT", NO_CONTENT, "切片内容不能为空", http_status=400)
|
||||
|
||||
chunk_id = kb_manager.add_chunk(collection, content, metadata)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"chunk_id": chunk_id,
|
||||
"message": "切片已添加"
|
||||
})
|
||||
return success_response(data={"chunk_id": chunk_id}, message="切片已添加")
|
||||
|
||||
|
||||
@document_bp.route('/chunks/<chunk_id>', methods=['PUT'])
|
||||
@@ -1012,7 +1051,7 @@ def update_chunk(chunk_id: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
|
||||
data = request.json or {}
|
||||
collection = data.get('collection')
|
||||
@@ -1020,13 +1059,13 @@ def update_chunk(chunk_id: str) -> Tuple[Any, int]:
|
||||
metadata = data.get('metadata')
|
||||
|
||||
if not collection:
|
||||
return jsonify({"error": "请指定向量库 (collection)"}), 400
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定向量库 (collection)", http_status=400)
|
||||
|
||||
success = kb_manager.update_chunk(collection, chunk_id, content=content, metadata=metadata)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": "切片已更新"})
|
||||
return jsonify({"error": "更新失败"}), 500
|
||||
return success_response(message="切片已更新")
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "更新失败", http_status=500)
|
||||
|
||||
|
||||
@document_bp.route('/chunks/<chunk_id>', methods=['DELETE'])
|
||||
@@ -1054,11 +1093,11 @@ def delete_chunk(chunk_id: str) -> Tuple[Any, int]:
|
||||
collection = request.args.get('collection')
|
||||
|
||||
if not collection:
|
||||
return jsonify({"error": "请指定向量库 (collection)"}), 400
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定向量库 (collection)", http_status=400)
|
||||
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
|
||||
# 删除切片,返回 (success, source_file)
|
||||
success, source_file = kb_manager.delete_chunk(collection, chunk_id)
|
||||
@@ -1083,8 +1122,8 @@ def delete_chunk(chunk_id: str) -> Tuple[Any, int]:
|
||||
logger.warning(f"清理哈希记录失败: {e}")
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": "切片已删除"})
|
||||
return jsonify({"error": "删除失败"}), 500
|
||||
return success_response(status_code=DELETE_SUCCESS, message="切片已删除")
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "删除失败", http_status=500)
|
||||
|
||||
|
||||
@document_bp.route('/chunks/batch', methods=['DELETE'])
|
||||
@@ -1110,13 +1149,13 @@ def delete_chunks_by_source() -> Tuple[Any, int]:
|
||||
source = data.get('source')
|
||||
|
||||
if not collection:
|
||||
return jsonify({"error": "请指定向量库 (collection)"}), 400
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定向量库 (collection)", http_status=400)
|
||||
if not source:
|
||||
return jsonify({"error": "请指定文件名 (source)"}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "请指定文件名 (source)", http_status=400)
|
||||
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
|
||||
# 批量删除该文件的所有切片
|
||||
try:
|
||||
@@ -1134,11 +1173,7 @@ def delete_chunks_by_source() -> Tuple[Any, int]:
|
||||
except Exception as e:
|
||||
logger.warning(f"清理哈希记录失败: {e}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"deleted_count": deleted_count,
|
||||
"message": f"已删除 {deleted_count} 个切片"
|
||||
})
|
||||
return success_response(data={"deleted_count": deleted_count}, message=f"已删除 {deleted_count} 个切片")
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from auth.gateway import require_gateway_auth, require_role
|
||||
from core.status_codes import (
|
||||
SUCCESS, CREATED, DELETE_SUCCESS, UPDATE_SUCCESS,
|
||||
BAD_REQUEST, NOT_FOUND, INTERNAL_ERROR
|
||||
)
|
||||
from api.response_utils import success_response, error_response
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -75,10 +80,10 @@ def submit_feedback():
|
||||
user_id = data.get('user_id', '')
|
||||
|
||||
if not session_id or not query or rating is None:
|
||||
return jsonify({"error": "缺少必要参数"}), 400
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少必要参数", http_status=400)
|
||||
|
||||
if rating not in [1, -1]:
|
||||
return jsonify({"error": "rating 必须是 1 或 -1"}), 400
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, "rating 必须是 1 或 -1", http_status=400)
|
||||
|
||||
try:
|
||||
feedback_service = _get_feedback_service()
|
||||
@@ -91,15 +96,14 @@ def submit_feedback():
|
||||
reason=reason,
|
||||
user_id=user_id
|
||||
)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"feedback_id": result['feedback_id'],
|
||||
"faq_suggested": result.get('faq_suggested', False),
|
||||
"suggestion_id": result.get('suggestion_id')
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/feedback/stats', methods=['GET'])
|
||||
@@ -112,13 +116,12 @@ def get_feedback_stats():
|
||||
try:
|
||||
feedback_db = _get_feedback_db()
|
||||
stats = feedback_db.get_feedback_stats(start_date, end_date)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"stats": stats
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/feedback/list', methods=['GET'])
|
||||
@@ -140,14 +143,13 @@ def get_feedback_list():
|
||||
end_date=end_date,
|
||||
limit=limit
|
||||
)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"feedbacks": feedbacks,
|
||||
"total": len(feedbacks)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/reports/weekly', methods=['GET'])
|
||||
@@ -157,13 +159,12 @@ def get_weekly_report():
|
||||
try:
|
||||
feedback_service = _get_feedback_service()
|
||||
report = feedback_service.generate_report("weekly")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"report": report.to_dict()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/reports/monthly', methods=['GET'])
|
||||
@@ -173,13 +174,12 @@ def get_monthly_report():
|
||||
try:
|
||||
feedback_service = _get_feedback_service()
|
||||
report = feedback_service.generate_report("monthly")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"report": report.to_dict()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/faq', methods=['GET'])
|
||||
@@ -192,14 +192,13 @@ def get_faq_list():
|
||||
try:
|
||||
feedback_db = _get_feedback_db()
|
||||
faqs = feedback_db.get_faqs(status=status, limit=limit)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"faqs": faqs,
|
||||
"total": len(faqs)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/faq', methods=['POST'])
|
||||
@@ -220,7 +219,7 @@ def create_faq():
|
||||
answer = data.get('answer')
|
||||
|
||||
if not question or not answer:
|
||||
return jsonify({"error": "缺少问题或答案"}), 400
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少问题或答案", http_status=400)
|
||||
|
||||
try:
|
||||
from services.feedback import FAQ
|
||||
@@ -235,15 +234,14 @@ def create_faq():
|
||||
)
|
||||
faq_id = feedback_db.add_faq(faq)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"faq_id": faq_id,
|
||||
"status": "draft",
|
||||
"message": "FAQ已创建,请通过 /faq/<id>/approve 接口确认后生效"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/<int:faq_id>/approve', methods=['POST'])
|
||||
@@ -263,10 +261,10 @@ def approve_faq(faq_id):
|
||||
# 检查 FAQ 状态
|
||||
faq = feedback_db.get_faq(faq_id)
|
||||
if not faq:
|
||||
return jsonify({"error": "FAQ不存在"}), 404
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "FAQ不存在", http_status=404)
|
||||
|
||||
if faq.get('status') == 'approved':
|
||||
return jsonify({"success": True, "message": "FAQ已经是批准状态"})
|
||||
return success_response(message="FAQ已经是批准状态")
|
||||
|
||||
# 更新状态为 approved
|
||||
feedback_db.update_faq(faq_id, {"status": "approved"})
|
||||
@@ -279,15 +277,14 @@ def approve_faq(faq_id):
|
||||
answer=faq['answer']
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"faq_id": faq_id,
|
||||
"sync_status": "synced" if sync_success else "sync_failed",
|
||||
"message": "FAQ已批准并同步到知识库"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/<int:faq_id>', methods=['PUT'])
|
||||
@@ -303,11 +300,11 @@ def update_faq(faq_id):
|
||||
# 获取更新前的 FAQ 信息(用于判断是否需要重新同步)
|
||||
old_faq = feedback_db.get_faq(faq_id)
|
||||
if not old_faq:
|
||||
return jsonify({"error": "FAQ不存在"}), 404
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "FAQ不存在", http_status=404)
|
||||
|
||||
updated = feedback_db.update_faq(faq_id, data)
|
||||
if not updated:
|
||||
return jsonify({"error": "FAQ更新失败"}), 500
|
||||
return error_response("UPDATE_FAILED", INTERNAL_ERROR, "FAQ更新失败", http_status=500)
|
||||
|
||||
# 检查是否需要重新同步向量库(question 或 answer 变更时)
|
||||
need_sync = False
|
||||
@@ -331,14 +328,13 @@ def update_faq(faq_id):
|
||||
)
|
||||
sync_status = "synced" if sync_success else "sync_failed"
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"message": "FAQ更新成功",
|
||||
"sync_status": sync_status
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/<int:faq_id>', methods=['DELETE'])
|
||||
@@ -365,13 +361,10 @@ def delete_faq(faq_id):
|
||||
feedback_service = _get_feedback_service()
|
||||
feedback_service._delete_faq_vectors(faq_id)
|
||||
|
||||
return jsonify({
|
||||
"success": deleted,
|
||||
"message": "FAQ删除成功" if deleted else "FAQ不存在"
|
||||
})
|
||||
return success_response(data={"deleted": deleted}, message="FAQ删除成功" if deleted else "FAQ不存在")
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/suggestions', methods=['GET'])
|
||||
@@ -385,14 +378,13 @@ def get_faq_suggestions():
|
||||
try:
|
||||
feedback_db = _get_feedback_db()
|
||||
suggestions = feedback_db.get_faq_suggestions(status=status, limit=limit)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"suggestions": suggestions,
|
||||
"total": len(suggestions)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/approve', methods=['POST'])
|
||||
@@ -418,17 +410,16 @@ def approve_faq_suggestion(suggestion_id):
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"faq_id": result['faq_id'],
|
||||
"sync_status": result.get('sync_status'),
|
||||
"message": "FAQ建议已批准并同步到知识库"
|
||||
})
|
||||
else:
|
||||
return jsonify({"error": result.get('error', '批准失败')}), 400
|
||||
return error_response("APPROVE_FAILED", BAD_REQUEST, result.get('error', '批准失败'), http_status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/reject', methods=['POST'])
|
||||
@@ -439,13 +430,13 @@ def reject_faq_suggestion(suggestion_id):
|
||||
try:
|
||||
feedback_db = _get_feedback_db()
|
||||
rejected = feedback_db.reject_faq_suggestion(suggestion_id)
|
||||
return jsonify({
|
||||
"success": rejected,
|
||||
return success_response(data={
|
||||
"rejected": rejected,
|
||||
"message": "FAQ建议已拒绝" if rejected else "建议不存在"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
# ==================== Bad Case 分析接口 ====================
|
||||
@@ -477,8 +468,7 @@ def get_bad_cases():
|
||||
for case in bad_cases:
|
||||
case['status'] = 'pending' # pending/resolved/ignored
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"bad_cases": bad_cases,
|
||||
"blacklisted_sources": blacklisted_sources,
|
||||
"suggestions": [
|
||||
@@ -489,7 +479,7 @@ def get_bad_cases():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@feedback_bp.route('/feedback/blacklist', methods=['GET'])
|
||||
@@ -507,12 +497,11 @@ def get_chunk_blacklist():
|
||||
feedback_service = _get_feedback_service()
|
||||
blacklist = feedback_service.get_chunk_blacklist(min_dislikes=min_dislikes)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"blacklist": list(blacklist),
|
||||
"count": len(blacklist),
|
||||
"usage": "在检索时过滤这些来源以提升回答质量"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import os
|
||||
import logging
|
||||
from flask import Blueprint, send_file, jsonify, current_app
|
||||
from core.status_codes import SUCCESS, BAD_REQUEST, NOT_FOUND, INTERNAL_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,7 +53,7 @@ def get_image(image_id: str):
|
||||
|
||||
# 安全检查:防止路径遍历攻击
|
||||
if '..' in image_id or '/' in image_id or '\\' in image_id:
|
||||
return jsonify({"error": "无效的图片 ID"}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的图片 ID", http_status=400)
|
||||
|
||||
images_path = get_images_base_path()
|
||||
|
||||
@@ -74,9 +76,9 @@ def get_image(image_id: str):
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
except Exception as e:
|
||||
logger.error(f"读取图片异常: {e}")
|
||||
return jsonify({"error": "读取图片失败"}), 500
|
||||
return error_response("READ_ERROR", INTERNAL_ERROR, "读取图片失败", http_status=500)
|
||||
|
||||
return jsonify({"error": "图片不存在", "image_id": image_id}), 404
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "图片不存在", http_status=404, image_id=image_id)
|
||||
|
||||
|
||||
@image_bp.route('/images/<image_id>/info', methods=['GET'])
|
||||
@@ -96,7 +98,7 @@ def get_image_info(image_id: str):
|
||||
|
||||
# 安全检查
|
||||
if '..' in image_id or '/' in image_id or '\\' in image_id:
|
||||
return jsonify({"error": "无效的图片 ID"}), 400
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的图片 ID", http_status=400)
|
||||
|
||||
images_path = get_images_base_path()
|
||||
supported_formats = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']
|
||||
@@ -109,7 +111,7 @@ def get_image_info(image_id: str):
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
"image_id": image_id,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
@@ -120,16 +122,16 @@ def get_image_info(image_id: str):
|
||||
})
|
||||
except ImportError:
|
||||
# PIL 未安装,返回基本信息
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
"image_id": image_id,
|
||||
"size_bytes": os.path.getsize(image_path),
|
||||
"url": f"/images/{image_id}"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"读取图片信息异常: {e}")
|
||||
return jsonify({"error": "读取图片信息失败"}), 500
|
||||
return error_response("READ_ERROR", INTERNAL_ERROR, "读取图片信息失败", http_status=500)
|
||||
|
||||
return jsonify({"error": "图片不存在", "image_id": image_id}), 404
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "图片不存在", http_status=404, image_id=image_id)
|
||||
|
||||
|
||||
@image_bp.route('/images/list', methods=['GET'])
|
||||
@@ -152,7 +154,7 @@ def list_images():
|
||||
images_path = get_images_base_path()
|
||||
|
||||
if not os.path.exists(images_path):
|
||||
return jsonify({"images": [], "total": 0})
|
||||
return success_response(data={"images": [], "total": 0})
|
||||
|
||||
supported_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
|
||||
images = []
|
||||
@@ -176,7 +178,7 @@ def list_images():
|
||||
total = len(images)
|
||||
images = images[offset:offset + limit]
|
||||
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
"images": images,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
@@ -185,7 +187,7 @@ def list_images():
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"列出图片异常: {e}")
|
||||
return jsonify({"error": "列出图片失败"}), 500
|
||||
return error_response("LIST_ERROR", INTERNAL_ERROR, "列出图片失败", http_status=500)
|
||||
|
||||
|
||||
@image_bp.route('/images/stats', methods=['GET'])
|
||||
@@ -199,7 +201,7 @@ def image_stats():
|
||||
images_path = get_images_base_path()
|
||||
|
||||
if not os.path.exists(images_path):
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
"total_images": 0,
|
||||
"total_size_bytes": 0,
|
||||
"supported_formats": ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']
|
||||
@@ -219,7 +221,7 @@ def image_stats():
|
||||
total_size += os.path.getsize(filepath)
|
||||
format_counts[ext] = format_counts.get(ext, 0) + 1
|
||||
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
"total_images": total_count,
|
||||
"total_size_bytes": total_size,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||
@@ -229,4 +231,4 @@ def image_stats():
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计信息异常: {e}")
|
||||
return jsonify({"error": "获取统计信息失败"}), 500
|
||||
return error_response("STATS_ERROR", INTERNAL_ERROR, "获取统计信息失败", http_status=500)
|
||||
|
||||
255
api/kb_routes.py
255
api/kb_routes.py
@@ -37,6 +37,13 @@ from typing import Tuple, Optional, Any
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
import logging
|
||||
|
||||
from core.status_codes import (
|
||||
SUCCESS, CREATED, DELETE_SUCCESS, UPDATE_SUCCESS, SYNC_SUCCESS,
|
||||
BAD_REQUEST, NOT_FOUND, COLLECTION_NOT_FOUND, NO_COLLECTION, TASK_CONFLICT,
|
||||
INTERNAL_ERROR, SERVICE_UNAVAILABLE, SYNC_ERROR, REINDEX_ERROR
|
||||
)
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from auth.gateway import require_gateway_auth
|
||||
|
||||
@@ -133,10 +140,7 @@ def list_collections() -> Tuple[Any, int]:
|
||||
"description": coll.description
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"collections": result,
|
||||
"total": len(result)
|
||||
})
|
||||
return success_response(data={"collections": result, "total": len(result)})
|
||||
|
||||
|
||||
@kb_bp.route('/collections', methods=['POST'])
|
||||
@@ -172,22 +176,19 @@ def create_collection() -> Tuple[Any, int]:
|
||||
description = data.get('description', '')
|
||||
|
||||
if not name:
|
||||
return jsonify({"error": "向量库名称不能为空"}), 400
|
||||
return error_response("INVALID_NAME", BAD_REQUEST, "向量库名称不能为空", http_status=400)
|
||||
|
||||
# 验证名称格式(ChromaDB 限制)
|
||||
if not name.replace('_', '').replace('-', '').isalnum():
|
||||
return jsonify({
|
||||
"error": "名称格式错误",
|
||||
"message": "向量库名称只能包含字母、数字、下划线和连字符"
|
||||
}), 400
|
||||
return error_response("INVALID_NAME_FORMAT", BAD_REQUEST, "向量库名称只能包含字母、数字、下划线和连字符", http_status=400)
|
||||
|
||||
success, message = kb_manager.create_collection(
|
||||
name, display_name, department, description
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": message, "name": name}), 201
|
||||
return jsonify({"error": message}), 400
|
||||
return success_response(data={"name": name}, status_code=CREATED, message=message, http_status=201)
|
||||
return error_response("CREATE_FAILED", BAD_REQUEST, message, http_status=400)
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>', methods=['PUT'])
|
||||
@@ -220,7 +221,7 @@ def update_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
# 检查向量库是否存在
|
||||
collections = kb_manager.list_collections()
|
||||
if not any(c.name == kb_name for c in collections):
|
||||
return jsonify({"error": f"向量库 '{kb_name}' 不存在"}), 404
|
||||
return error_response("COLLECTION_NOT_FOUND", COLLECTION_NOT_FOUND, f"向量库 '{kb_name}' 不存在", http_status=404)
|
||||
|
||||
# 更新元数据
|
||||
success = kb_manager.update_collection_metadata(
|
||||
@@ -230,8 +231,8 @@ def update_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": "向量库信息已更新"})
|
||||
return jsonify({"error": "更新失败"}), 500
|
||||
return success_response(data=None, status_code=UPDATE_SUCCESS, message="向量库信息已更新")
|
||||
return error_response("UPDATE_FAILED", INTERNAL_ERROR, "更新失败", http_status=500)
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>', methods=['DELETE'])
|
||||
@@ -260,12 +261,8 @@ def delete_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
success, message = kb_manager.delete_collection(kb_name, delete_documents)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": message,
|
||||
"deleted_documents": delete_documents
|
||||
})
|
||||
return jsonify({"error": message}), 400
|
||||
return success_response(data={"deleted_documents": delete_documents}, status_code=DELETE_SUCCESS, message=message)
|
||||
return error_response("DELETE_FAILED", BAD_REQUEST, message, http_status=400)
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/documents', methods=['GET'])
|
||||
@@ -286,11 +283,7 @@ def list_collection_documents(kb_name: str) -> Tuple[Any, int]:
|
||||
|
||||
documents = kb_manager.list_documents(kb_name)
|
||||
|
||||
return jsonify({
|
||||
"collection": kb_name,
|
||||
"documents": documents,
|
||||
"total": len(documents)
|
||||
})
|
||||
return success_response(data={"collection": kb_name, "documents": documents, "total": len(documents)})
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/chunks', methods=['GET'])
|
||||
@@ -321,21 +314,16 @@ def list_collection_chunks(kb_name: str) -> Tuple[Any, int]:
|
||||
|
||||
chunks = kb_manager.list_chunks(kb_name, document_id=document_id, limit=limit, offset=offset)
|
||||
|
||||
return jsonify({
|
||||
"collection": kb_name,
|
||||
"chunks": chunks,
|
||||
"total": len(chunks)
|
||||
})
|
||||
return success_response(data={"collection": kb_name, "chunks": chunks, "total": len(chunks)})
|
||||
|
||||
|
||||
@kb_bp.route('/documents/sync', methods=['POST'])
|
||||
@require_gateway_auth
|
||||
def sync_documents() -> Tuple[Any, int]:
|
||||
"""
|
||||
触发文档向量化同步
|
||||
触发文档向量化同步(异步任务)
|
||||
|
||||
扫描文档目录,检测新增、修改、删除的文件,
|
||||
自动更新向量库索引。
|
||||
立即返回 task_id,后台线程执行同步。
|
||||
|
||||
请求体:
|
||||
{
|
||||
@@ -343,11 +331,7 @@ def sync_documents() -> Tuple[Any, int]:
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"results": [{"collection": "...", "status": "...", ...}],
|
||||
"synced_count": N
|
||||
}
|
||||
{"success": true, "data": {"task_id": "xxx", "message": "..."}}
|
||||
"""
|
||||
kb_manager, _, err = _require_multi_kb()
|
||||
if err:
|
||||
@@ -355,7 +339,6 @@ def sync_documents() -> Tuple[Any, int]:
|
||||
|
||||
from config import DOCUMENTS_PATH
|
||||
|
||||
user = request.current_user
|
||||
data = request.json or {}
|
||||
target_collection = data.get('collection')
|
||||
|
||||
@@ -363,54 +346,67 @@ def sync_documents() -> Tuple[Any, int]:
|
||||
if target_collection:
|
||||
collections_to_sync = [target_collection]
|
||||
else:
|
||||
# 同步所有向量库
|
||||
all_collections = kb_manager.list_collections()
|
||||
collections_to_sync = [c.name for c in all_collections]
|
||||
|
||||
if not collections_to_sync:
|
||||
return jsonify({"error": "没有可同步的向量库"}), 400
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "没有可同步的向量库", http_status=400)
|
||||
|
||||
# 执行同步
|
||||
results = []
|
||||
|
||||
# 使用 sync_service 执行同步
|
||||
sync_service = current_app.config.get('SYNC_SERVICE')
|
||||
if not sync_service:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "同步服务不可用", http_status=503,
|
||||
results=[{"collection": c, "status": "warning", "message": "同步服务不可用"} for c in collections_to_sync],
|
||||
synced_count=0
|
||||
)
|
||||
|
||||
if sync_service:
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
# 检查是否有正在运行的同步任务
|
||||
running = registry.list_tasks(status='running', task_type='sync', limit=1)
|
||||
if running:
|
||||
return error_response("TASK_RUNNING", TASK_CONFLICT, f"同步任务正在执行中 (task_id: {running[0].id})", http_status=409)
|
||||
|
||||
desc = f"文档同步: {target_collection or '所有向量库'}"
|
||||
task = registry.create_task('sync', desc)
|
||||
|
||||
def _do_sync(task, sync_svc):
|
||||
processed = [0]
|
||||
|
||||
def on_change(change):
|
||||
processed[0] += 1
|
||||
registry.update_progress(
|
||||
task.id, current=processed[0],
|
||||
stage='处理文件',
|
||||
message=f"已处理: {change.document_name if hasattr(change, 'document_name') else change.document_id}"
|
||||
)
|
||||
|
||||
old_callback = sync_svc.on_change_callback
|
||||
sync_svc.on_change_callback = on_change
|
||||
try:
|
||||
sync_result = sync_service.sync_now()
|
||||
results.append({
|
||||
"collection": "all",
|
||||
"status": "success",
|
||||
"message": f"同步完成: 处理 {sync_result.documents_processed} 个文档",
|
||||
"details": {
|
||||
"added": sync_result.documents_added,
|
||||
"modified": sync_result.documents_modified,
|
||||
"deleted": sync_result.documents_deleted,
|
||||
"errors": sync_result.errors
|
||||
registry.update_progress(task.id, stage='扫描文档', message='正在检测变更...')
|
||||
sync_result = sync_svc.sync_now()
|
||||
return {
|
||||
'collection': target_collection or 'all',
|
||||
'status': 'success',
|
||||
'message': f"同步完成: 处理 {sync_result.documents_processed} 个文档",
|
||||
'details': {
|
||||
'added': sync_result.documents_added,
|
||||
'modified': sync_result.documents_modified,
|
||||
'deleted': sync_result.documents_deleted,
|
||||
'errors': sync_result.errors,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"知识库操作异常: {e}")
|
||||
results.append({
|
||||
"collection": "all",
|
||||
"status": "error",
|
||||
"message": "操作失败"
|
||||
})
|
||||
else:
|
||||
# 没有 sync_service,返回提示
|
||||
for coll_name in collections_to_sync:
|
||||
results.append({
|
||||
"collection": coll_name,
|
||||
"status": "warning",
|
||||
"message": "同步服务不可用,请使用 POST /sync 端点"
|
||||
})
|
||||
}
|
||||
finally:
|
||||
sync_svc.on_change_callback = old_callback
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"results": results,
|
||||
"synced_count": len([r for r in results if r["status"] == "success"])
|
||||
})
|
||||
registry.start_task(task.id, _do_sync, sync_service)
|
||||
|
||||
return success_response(
|
||||
data={"task_id": task.id, "message": f"同步任务已启动,通过 GET /tasks/{task.id} 查询进度"},
|
||||
status_code=SYNC_SUCCESS,
|
||||
message="同步任务已启动"
|
||||
)
|
||||
|
||||
|
||||
@kb_bp.route('/debug/scan', methods=['GET'])
|
||||
@@ -473,22 +469,16 @@ def debug_scan() -> Tuple[Any, int]:
|
||||
@require_gateway_auth
|
||||
def reindex_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
强制重新向量化指定集合的所有文档
|
||||
强制重新向量化指定集合的所有文档(异步任务)
|
||||
|
||||
清除该集合的文档哈希记录,触发完整重新索引。
|
||||
适用于文档内容更新后需要重建索引的场景。
|
||||
立即返回 task_id,后台线程执行重建。
|
||||
|
||||
Args:
|
||||
kb_name: 向量库名称
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"message": "...",
|
||||
"documents_processed": N,
|
||||
"documents_added": N,
|
||||
"errors": [...]
|
||||
}
|
||||
{"success": true, "data": {"task_id": "xxx", "message": "..."}}
|
||||
"""
|
||||
kb_manager, _, err = _require_multi_kb()
|
||||
if err:
|
||||
@@ -496,14 +486,12 @@ def reindex_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
|
||||
from config import DOCUMENTS_PATH
|
||||
|
||||
# 清除该集合的文档哈希记录
|
||||
# 清除该集合的文档哈希记录(同步完成,很快)
|
||||
try:
|
||||
from data.db import get_connection
|
||||
with get_connection("knowledge") as conn:
|
||||
cursor = conn.cursor()
|
||||
# 转义 LIKE 通配符,防止 kb_name 中的 % 或 _ 导致非预期匹配
|
||||
escaped_kb = kb_name.replace('%', '\\%').replace('_', '\\_')
|
||||
# 删除以 "{kb_name}/" 或 "{kb_name}\" 开头的文档哈希(兼容 Windows 和 Linux)
|
||||
cursor.execute("DELETE FROM document_hashes WHERE document_id LIKE ? ESCAPE '\\' OR document_id LIKE ? ESCAPE '\\'",
|
||||
(f"{escaped_kb}/%", f"{escaped_kb}\\%"))
|
||||
deleted = cursor.rowcount
|
||||
@@ -511,23 +499,54 @@ def reindex_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
except Exception as e:
|
||||
logger.warning(f"清除哈希记录失败: {e}")
|
||||
|
||||
# 触发同步
|
||||
sync_service = current_app.config.get('SYNC_SERVICE')
|
||||
if sync_service:
|
||||
if not sync_service:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "同步服务不可用", http_status=503)
|
||||
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
# 检查是否有正在运行的重建任务
|
||||
running = registry.list_tasks(status='running', task_type='reindex', limit=1)
|
||||
if running:
|
||||
return error_response("TASK_RUNNING", TASK_CONFLICT, f"重建任务正在执行中 (task_id: {running[0].id})", http_status=409)
|
||||
|
||||
task = registry.create_task('reindex', f'重建索引: {kb_name}')
|
||||
|
||||
def _do_reindex(task, sync_svc, kb):
|
||||
"""后台执行重建索引"""
|
||||
processed = [0]
|
||||
|
||||
def on_change(change):
|
||||
processed[0] += 1
|
||||
registry.update_progress(
|
||||
task.id,
|
||||
current=processed[0],
|
||||
stage='重新索引',
|
||||
message=f"已处理: {change.document_name if hasattr(change, 'document_name') else change.document_id}"
|
||||
)
|
||||
|
||||
old_callback = sync_svc.on_change_callback
|
||||
sync_svc.on_change_callback = on_change
|
||||
try:
|
||||
result = sync_service.sync_now()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"重新索引完成: 处理 {result.documents_processed} 个文档",
|
||||
"documents_processed": result.documents_processed,
|
||||
"documents_added": result.documents_added,
|
||||
"errors": result.errors
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"reindex 异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
else:
|
||||
return jsonify({"error": "同步服务不可用"}), 503
|
||||
registry.update_progress(task.id, stage='扫描文档', message='正在检测变更...')
|
||||
result = sync_svc.sync_now()
|
||||
return {
|
||||
'message': f"重新索引完成: 处理 {result.documents_processed} 个文档",
|
||||
'documents_processed': result.documents_processed,
|
||||
'documents_added': result.documents_added,
|
||||
'errors': result.errors,
|
||||
}
|
||||
finally:
|
||||
sync_svc.on_change_callback = old_callback
|
||||
|
||||
registry.start_task(task.id, _do_reindex, sync_service, kb_name)
|
||||
|
||||
return success_response(
|
||||
data={"task_id": task.id, "message": f"重建索引任务已启动: {kb_name},通过 GET /tasks/{task.id} 查询进度"},
|
||||
status_code=SYNC_SUCCESS,
|
||||
message="重建索引任务已启动"
|
||||
)
|
||||
|
||||
|
||||
@kb_bp.route('/kb/route', methods=['POST'])
|
||||
@@ -567,7 +586,7 @@ def test_routing() -> Tuple[Any, int]:
|
||||
query = data.get('query', '')
|
||||
|
||||
if not query:
|
||||
return jsonify({"error": "请提供查询内容"}), 400
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "请提供查询内容", http_status=400)
|
||||
|
||||
# 获取路由结果
|
||||
target_kbs = route_query(
|
||||
@@ -579,7 +598,7 @@ def test_routing() -> Tuple[Any, int]:
|
||||
# 获取意图分析
|
||||
intent = kb_router.analyze_intent(query)
|
||||
|
||||
return jsonify({
|
||||
return success_response(data={
|
||||
"query": query,
|
||||
"user_role": user.get("role"),
|
||||
"user_department": user.get("department", ""),
|
||||
@@ -636,10 +655,10 @@ def deprecate_document(kb_name: str, filename: str) -> Tuple[Any, int]:
|
||||
reason,
|
||||
deprecated_by=user.get('user_id', 'unknown')
|
||||
)
|
||||
return jsonify(result)
|
||||
return success_response(data=result)
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/restore', methods=['POST'])
|
||||
@@ -668,10 +687,10 @@ def restore_document(kb_name: str, filename: str) -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
result = kb_manager.restore_document(kb_name, filename)
|
||||
return jsonify(result)
|
||||
return success_response(data=result)
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/versions', methods=['GET'])
|
||||
@@ -719,8 +738,7 @@ def get_document_versions(kb_name: str, filename: str) -> Tuple[Any, int]:
|
||||
versions = version_query.get_document_history(kb_name, filename, limit)
|
||||
versions_data = [v.to_dict() for v in versions]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"document_id": filename,
|
||||
"collection": kb_name,
|
||||
"versions": versions_data,
|
||||
@@ -728,7 +746,7 @@ def get_document_versions(kb_name: str, filename: str) -> Tuple[Any, int]:
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/update-image-descriptions', methods=['POST'])
|
||||
@@ -760,10 +778,10 @@ def update_image_descriptions(kb_name: str) -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
result = kb_manager.update_image_descriptions(kb_name)
|
||||
return jsonify(result)
|
||||
return success_response(data=result)
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
|
||||
|
||||
@kb_bp.route('/collections/sync-vlm-cache', methods=['POST'])
|
||||
@@ -804,12 +822,12 @@ def sync_vlm_cache() -> Tuple[Any, int]:
|
||||
images_dir = Path(".data/images")
|
||||
|
||||
if not vlm_cache_dir.exists():
|
||||
return jsonify({"success": False, "error": "VLM 缓存目录不存在"}), 400
|
||||
return error_response("VLM_CACHE_NOT_FOUND", BAD_REQUEST, "VLM 缓存目录不存在", http_status=400)
|
||||
|
||||
# 获取所有 VLM 缓存文件
|
||||
cache_files = list(vlm_cache_dir.glob("*.txt"))
|
||||
if not cache_files:
|
||||
return jsonify({"success": True, "total_cache_files": 0, "synced_count": 0, "message": "无 VLM 缓存文件"})
|
||||
return success_response(data={"total_cache_files": 0, "synced_count": 0}, message="无 VLM 缓存文件")
|
||||
|
||||
# 构建图片 MD5 → 文件名 的映射
|
||||
image_hash_map = {}
|
||||
@@ -879,8 +897,7 @@ def sync_vlm_cache() -> Tuple[Any, int]:
|
||||
skipped_count += 1
|
||||
details.append({"cache": cache_file.name, "image": image_filename, "status": "skipped", "reason": "向量库中未找到对应切片"})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
return success_response(data={
|
||||
"total_cache_files": len(cache_files),
|
||||
"synced_count": synced_count,
|
||||
"skipped_count": skipped_count,
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from auth.gateway import require_gateway_auth
|
||||
from core.status_codes import SUCCESS, BAD_REQUEST, FORBIDDEN, NOT_FOUND, INTERNAL_ERROR, SERVICE_UNAVAILABLE, DELETE_SUCCESS
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
session_bp = Blueprint('session', __name__)
|
||||
|
||||
@@ -34,7 +36,7 @@ def get_sessions():
|
||||
"""
|
||||
session_manager = current_app.config['SESSION_MANAGER']
|
||||
if session_manager is None:
|
||||
return jsonify({"error": "会话服务不可用"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "会话服务不可用", http_status=503)
|
||||
user_id = request.current_user["user_id"]
|
||||
|
||||
sessions = session_manager.get_user_sessions(user_id, limit=20)
|
||||
@@ -47,7 +49,7 @@ def get_sessions():
|
||||
else:
|
||||
s["preview"] = "空会话"
|
||||
|
||||
return jsonify({"sessions": sessions})
|
||||
return success_response(data={"sessions": sessions})
|
||||
|
||||
|
||||
@session_bp.route('/history/<session_id>', methods=['GET'])
|
||||
@@ -65,7 +67,7 @@ def get_history(session_id):
|
||||
"""
|
||||
session_manager = current_app.config['SESSION_MANAGER']
|
||||
if session_manager is None:
|
||||
return jsonify({"error": "会话服务不可用"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "会话服务不可用", http_status=503)
|
||||
user_id = request.current_user["user_id"]
|
||||
|
||||
# 验证会话归属
|
||||
@@ -73,11 +75,11 @@ def get_history(session_id):
|
||||
session_ids = [s["session_id"] for s in sessions]
|
||||
|
||||
if session_id not in session_ids:
|
||||
return jsonify({"error": "无权访问此会话"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "无权访问此会话", http_status=403)
|
||||
|
||||
history = session_manager.get_history(session_id, limit=100)
|
||||
|
||||
return jsonify({"history": history})
|
||||
return success_response(data={"history": history})
|
||||
|
||||
|
||||
@session_bp.route('/session/<session_id>', methods=['DELETE'])
|
||||
@@ -86,7 +88,7 @@ def delete_session(session_id):
|
||||
"""删除会话"""
|
||||
session_manager = current_app.config['SESSION_MANAGER']
|
||||
if session_manager is None:
|
||||
return jsonify({"error": "会话服务不可用"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "会话服务不可用", http_status=503)
|
||||
user_id = request.current_user["user_id"]
|
||||
|
||||
# 验证会话归属
|
||||
@@ -94,11 +96,11 @@ def delete_session(session_id):
|
||||
session_ids = [s["session_id"] for s in sessions]
|
||||
|
||||
if session_id not in session_ids:
|
||||
return jsonify({"error": "无权删除此会话"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "无权访问此会话", http_status=403)
|
||||
|
||||
session_manager.delete_session(session_id)
|
||||
|
||||
return jsonify({"success": True, "message": "会话已删除"})
|
||||
return success_response(status_code=DELETE_SUCCESS, message="会话已删除")
|
||||
|
||||
|
||||
@session_bp.route('/clear/<session_id>', methods=['POST'])
|
||||
@@ -107,7 +109,7 @@ def clear_history(session_id):
|
||||
"""清空会话历史(保留会话)"""
|
||||
session_manager = current_app.config['SESSION_MANAGER']
|
||||
if session_manager is None:
|
||||
return jsonify({"error": "会话服务不可用"}), 503
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "会话服务不可用", http_status=503)
|
||||
user_id = request.current_user["user_id"]
|
||||
|
||||
# 验证会话归属
|
||||
@@ -115,8 +117,8 @@ def clear_history(session_id):
|
||||
session_ids = [s["session_id"] for s in sessions]
|
||||
|
||||
if session_id not in session_ids:
|
||||
return jsonify({"error": "无权操作此会话"}), 403
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "无权访问此会话", http_status=403)
|
||||
|
||||
session_manager.clear_history(session_id)
|
||||
|
||||
return jsonify({"success": True, "message": "历史已清空"})
|
||||
return success_response(message="历史已清空")
|
||||
|
||||
@@ -31,12 +31,12 @@ Example:
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple, Any
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask import Blueprint, request, current_app
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from auth.gateway import require_gateway_auth
|
||||
from core.status_codes import SYNC_SUCCESS, SYNC_ERROR, INTERNAL_ERROR
|
||||
from core.status_codes import SUCCESS, SYNC_SUCCESS, SYNC_ERROR, INTERNAL_ERROR, SERVICE_UNAVAILABLE
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
sync_bp = Blueprint('sync', __name__)
|
||||
@@ -68,9 +68,7 @@ def _require_sync_service() -> Tuple[Optional[Any], Optional[Tuple]]:
|
||||
service = _get_sync_service()
|
||||
if not service:
|
||||
return None, error_response(
|
||||
error="SERVICE_UNAVAILABLE",
|
||||
error_code=INTERNAL_ERROR,
|
||||
message="同步服务未启用",
|
||||
"SERVICE_UNAVAILABLE", INTERNAL_ERROR, "同步服务未启用",
|
||||
http_status=503
|
||||
)
|
||||
return service, None
|
||||
@@ -82,9 +80,10 @@ def _require_sync_service() -> Tuple[Optional[Any], Optional[Tuple]]:
|
||||
@require_gateway_auth
|
||||
def trigger_sync() -> Tuple[Any, int]:
|
||||
"""
|
||||
手动触发知识库同步
|
||||
手动触发知识库同步(异步任务)
|
||||
|
||||
扫描文档目录,检测变更并执行向量化处理。
|
||||
立即返回 task_id,后台线程执行同步。
|
||||
客户端通过 GET /tasks/<task_id> 轮询进度。
|
||||
|
||||
请求体 (可选):
|
||||
{
|
||||
@@ -93,7 +92,7 @@ def trigger_sync() -> Tuple[Any, int]:
|
||||
}
|
||||
|
||||
Returns:
|
||||
成功: {"success": true, "data": {"result": {...}}}
|
||||
成功: {"success": true, "data": {"task_id": "xxx", "message": "同步任务已启动"}}
|
||||
失败: {"error": "...", "error_code": "..."}
|
||||
|
||||
Example:
|
||||
@@ -104,22 +103,65 @@ def trigger_sync() -> Tuple[Any, int]:
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
result = service.sync_now()
|
||||
return success_response(
|
||||
data={"result": result.to_dict() if hasattr(result, 'to_dict') else result},
|
||||
status_code=SYNC_SUCCESS,
|
||||
message="同步完成"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
# 检查是否有正在运行的同步任务
|
||||
running = registry.list_tasks(status='running', task_type='sync', limit=1)
|
||||
if running:
|
||||
return error_response(
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
http_status=500
|
||||
"TASK_RUNNING", SYNC_ERROR,
|
||||
f"同步任务正在执行中 (task_id: {running[0].id}),请等待完成",
|
||||
http_status=409
|
||||
)
|
||||
|
||||
# 创建异步任务
|
||||
task = registry.create_task('sync', '文档同步')
|
||||
|
||||
def _do_sync(task, sync_service):
|
||||
"""后台执行同步"""
|
||||
# 注册进度回调
|
||||
processed = [0]
|
||||
|
||||
def on_change(change):
|
||||
processed[0] += 1
|
||||
registry.update_progress(
|
||||
task.id,
|
||||
current=processed[0],
|
||||
stage='处理文件',
|
||||
message=f"已处理: {change.document_name if hasattr(change, 'document_name') else change.document_id}"
|
||||
)
|
||||
|
||||
old_callback = sync_service.on_change_callback
|
||||
sync_service.on_change_callback = on_change
|
||||
|
||||
try:
|
||||
registry.update_progress(task.id, stage='扫描文档', message='正在检测变更...')
|
||||
result = sync_service.sync_now()
|
||||
|
||||
result_dict = result.to_dict() if hasattr(result, 'to_dict') else {
|
||||
'documents_processed': result.documents_processed,
|
||||
'documents_added': result.documents_added,
|
||||
'documents_modified': result.documents_modified,
|
||||
'documents_deleted': result.documents_deleted,
|
||||
'errors': result.errors,
|
||||
}
|
||||
return result_dict
|
||||
|
||||
finally:
|
||||
sync_service.on_change_callback = old_callback
|
||||
|
||||
registry.start_task(task.id, _do_sync, service)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'task_id': task.id,
|
||||
'message': '同步任务已启动,通过 GET /tasks/' + task.id + ' 查询进度'
|
||||
},
|
||||
status_code=SYNC_SUCCESS,
|
||||
message="同步任务已启动"
|
||||
)
|
||||
|
||||
|
||||
@sync_bp.route('/sync/status', methods=['GET'])
|
||||
@require_gateway_auth
|
||||
@@ -143,12 +185,8 @@ def get_sync_status() -> Tuple[Any, int]:
|
||||
"""
|
||||
service, err = _require_sync_service()
|
||||
if err:
|
||||
return jsonify({
|
||||
"status": "failed",
|
||||
"status_code": INTERNAL_ERROR,
|
||||
"enabled": False,
|
||||
"message": "同步服务未启用"
|
||||
})
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "同步服务未启用", http_status=503,
|
||||
enabled=False)
|
||||
|
||||
try:
|
||||
# 获取状态信息
|
||||
@@ -163,15 +201,11 @@ def get_sync_status() -> Tuple[Any, int]:
|
||||
if hasattr(service, 'get_status'):
|
||||
status.update(service.get_status())
|
||||
|
||||
return jsonify(status)
|
||||
return success_response(data=status)
|
||||
except Exception as e:
|
||||
logger.error(f"状态查询异常: {e}")
|
||||
return jsonify({
|
||||
"status": "failed",
|
||||
"status_code": INTERNAL_ERROR,
|
||||
"enabled": True,
|
||||
"error": "操作失败"
|
||||
})
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "操作失败", http_status=500,
|
||||
enabled=True)
|
||||
|
||||
|
||||
@sync_bp.route('/sync/history', methods=['GET'])
|
||||
@@ -196,13 +230,11 @@ def get_sync_history() -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
history = service.get_sync_history(limit=limit) if hasattr(service, 'get_sync_history') else []
|
||||
return jsonify({"history": history})
|
||||
return success_response(data={"history": history})
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
return error_response(
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
"SYNC_ERROR", SYNC_ERROR, "操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
@@ -231,13 +263,11 @@ def get_change_logs() -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
changes = service.get_change_logs(limit=limit, collection=collection) if hasattr(service, 'get_change_logs') else []
|
||||
return jsonify({"changes": changes})
|
||||
return success_response(data={"changes": changes})
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
return error_response(
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
"SYNC_ERROR", SYNC_ERROR, "操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
@@ -263,27 +293,23 @@ def start_sync_monitor() -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
if hasattr(service, 'is_running') and service.is_running():
|
||||
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已在运行"})
|
||||
return success_response(status_code=SYNC_SUCCESS, message="文件监控已在运行")
|
||||
|
||||
if hasattr(service, 'start'):
|
||||
success = service.start()
|
||||
if success:
|
||||
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已启动"})
|
||||
return success_response(status_code=SYNC_SUCCESS, message="文件监控已启动")
|
||||
else:
|
||||
return error_response(
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="启动文件监控失败",
|
||||
"SYNC_ERROR", SYNC_ERROR, "启动文件监控失败",
|
||||
http_status=500
|
||||
)
|
||||
else:
|
||||
return jsonify({"status": "success", "message": "文件监控功能不可用"})
|
||||
return success_response(message="文件监控功能不可用")
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
return error_response(
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
"SYNC_ERROR", SYNC_ERROR, "操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
@@ -306,12 +332,10 @@ def stop_sync_monitor() -> Tuple[Any, int]:
|
||||
try:
|
||||
if hasattr(service, 'stop'):
|
||||
service.stop()
|
||||
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已停止"})
|
||||
return success_response(status_code=SYNC_SUCCESS, message="文件监控已停止")
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
return error_response(
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
"SYNC_ERROR", SYNC_ERROR, "操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
191
api/task_routes.py
Normal file
191
api/task_routes.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
异步任务查询 API
|
||||
|
||||
提供任务状态的 JSON 轮询和 SSE 流式两种查询方式。
|
||||
|
||||
路由列表:
|
||||
GET /tasks : 任务列表
|
||||
GET /tasks/<id> : 任务状态(JSON)
|
||||
GET /tasks/<id>/progress: 任务进度(SSE 流式)
|
||||
GET /tasks/stats : 任务统计
|
||||
|
||||
后端组调用方式:
|
||||
1. POST /sync → 返回 {"task_id": "xxx", ...}
|
||||
2. GET /tasks/xxx → 轮询状态,直到 status 为 completed 或 failed
|
||||
|
||||
dev-ui 调用方式:
|
||||
1. POST /sync → 返回 task_id
|
||||
2. GET /tasks/xxx/progress → SSE 流式接收进度事件
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import Tuple, Any
|
||||
from flask import Blueprint, request, Response, stream_with_context
|
||||
from auth.gateway import require_gateway_auth
|
||||
from api.response_utils import success_response, error_response
|
||||
from core.status_codes import SUCCESS, NOT_FOUND
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
task_bp = Blueprint('tasks', __name__)
|
||||
|
||||
|
||||
def _get_registry():
|
||||
"""获取任务注册表"""
|
||||
from core.task_registry import get_registry
|
||||
return get_registry()
|
||||
|
||||
|
||||
# ==================== 任务查询 API ====================
|
||||
|
||||
@task_bp.route('/tasks', methods=['GET'])
|
||||
@require_gateway_auth
|
||||
def list_tasks() -> Tuple[Any, int]:
|
||||
"""
|
||||
获取任务列表
|
||||
|
||||
查询参数:
|
||||
status: 过滤状态(running / completed / failed / pending)
|
||||
type: 过滤类型(sync / reindex / upload / batch_upload / exam_generate / exam_grade)
|
||||
limit: 返回数量限制(默认 50)
|
||||
|
||||
Returns:
|
||||
{"success": true, "data": {"tasks": [...], "total": N}}
|
||||
"""
|
||||
registry = _get_registry()
|
||||
registry.maybe_cleanup()
|
||||
|
||||
status = request.args.get('status')
|
||||
task_type = request.args.get('type')
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
|
||||
tasks = registry.list_tasks(status=status, task_type=task_type, limit=limit)
|
||||
return success_response(
|
||||
data={
|
||||
'tasks': [t.to_dict() for t in tasks],
|
||||
'total': len(tasks)
|
||||
},
|
||||
status_code=SUCCESS,
|
||||
message="查询成功"
|
||||
)
|
||||
|
||||
|
||||
@task_bp.route('/tasks/stats', methods=['GET'])
|
||||
@require_gateway_auth
|
||||
def task_stats() -> Tuple[Any, int]:
|
||||
"""
|
||||
获取任务统计信息
|
||||
|
||||
Returns:
|
||||
{"success": true, "data": {"total": N, "by_status": {...}, "by_type": {...}}}
|
||||
"""
|
||||
registry = _get_registry()
|
||||
return success_response(
|
||||
data=registry.get_stats(),
|
||||
status_code=SUCCESS,
|
||||
message="查询成功"
|
||||
)
|
||||
|
||||
|
||||
@task_bp.route('/tasks/<task_id>', methods=['GET'])
|
||||
@require_gateway_auth
|
||||
def get_task(task_id: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
获取单个任务状态(JSON 轮询接口)
|
||||
|
||||
后端组推荐使用此接口轮询任务进度。
|
||||
|
||||
轮询建议:
|
||||
- 间隔 1-2 秒
|
||||
- 当 status 为 completed 或 failed 时停止轮询
|
||||
|
||||
Returns:
|
||||
成功: {"success": true, "data": {task详情}}
|
||||
未找到: {"error": "任务不存在"}
|
||||
"""
|
||||
registry = _get_registry()
|
||||
task = registry.get_task(task_id)
|
||||
|
||||
if not task:
|
||||
return error_response(
|
||||
"TASK_NOT_FOUND", NOT_FOUND,
|
||||
f"任务不存在: {task_id}",
|
||||
http_status=404
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data=task.to_dict(),
|
||||
status_code=SUCCESS,
|
||||
message="查询成功"
|
||||
)
|
||||
|
||||
|
||||
@task_bp.route('/tasks/<task_id>/progress', methods=['GET'])
|
||||
@require_gateway_auth
|
||||
def task_progress_stream(task_id: str):
|
||||
"""
|
||||
SSE 流式任务进度推送
|
||||
|
||||
dev-ui 前端推荐使用此接口,实时接收任务进度事件。
|
||||
|
||||
SSE 事件类型:
|
||||
- start: 任务开始
|
||||
- progress: 进度更新(含 progress/current/total/stage/message)
|
||||
- complete: 任务完成(含完整结果)
|
||||
- error: 任务失败(含错误信息)
|
||||
- heartbeat: 每 15 秒发送一次保活
|
||||
|
||||
Returns:
|
||||
text/event-stream
|
||||
"""
|
||||
registry = _get_registry()
|
||||
task = registry.get_task(task_id)
|
||||
|
||||
if not task:
|
||||
return error_response(
|
||||
"TASK_NOT_FOUND", NOT_FOUND,
|
||||
f"任务不存在: {task_id}",
|
||||
http_status=404
|
||||
)
|
||||
|
||||
def generate_sse():
|
||||
"""SSE 生成器"""
|
||||
sent_complete = False
|
||||
|
||||
while True:
|
||||
# 取出缓冲区事件
|
||||
events = task.drain_events()
|
||||
|
||||
for event in events:
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
if event.get('type') in ('complete', 'error'):
|
||||
sent_complete = True
|
||||
|
||||
# 如果任务已结束且事件已发送完毕,退出
|
||||
if sent_complete:
|
||||
break
|
||||
|
||||
# 如果任务已完成但没有事件,发送最终状态后退出
|
||||
if task.status in ('completed', 'failed') and not events:
|
||||
final_event = {
|
||||
'type': 'complete' if task.status == 'completed' else 'error',
|
||||
'data': task.to_dict()
|
||||
}
|
||||
yield f"data: {json.dumps(final_event, ensure_ascii=False)}\n\n"
|
||||
break
|
||||
|
||||
# 心跳保活
|
||||
yield f": heartbeat\n\n"
|
||||
time.sleep(1)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate_sse()),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user