581 lines
20 KiB
Python
581 lines
20 KiB
Python
# 新增的依赖项和工具函数
|
||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response,Query,Header
|
||
from fastapi.responses import StreamingResponse, JSONResponse
|
||
import hmac
|
||
import hashlib
|
||
import time,logging,json,requests
|
||
from typing import Dict, Any, Optional
|
||
|
||
# WPS应用配置 - 请替换为你的实际配置
|
||
WPS_APP_ID = "SX20251002WTFLCP"
|
||
WPS_APP_SECRET = "hoAGAXMTWXpkDxKFbTnSzjkckdFNNiSC"
|
||
|
||
class CustomJSONResponse(JSONResponse):
|
||
"""
|
||
自定义 JSON 响应类,处理特殊类型:
|
||
- datetime: 转换为 ISO 8601 字符串
|
||
- date: 转换为 ISO 8601 字符串
|
||
- Decimal: 转换为 float
|
||
"""
|
||
|
||
def render(self, content: any) -> bytes:
|
||
"""
|
||
重写渲染方法,使用自定义编码器
|
||
"""
|
||
class EnhancedJSONEncoder(json.JSONEncoder):
|
||
def default(self, obj):
|
||
"""
|
||
增强型 JSON 编码器,处理多种特殊类型:
|
||
- datetime: 转换为 ISO 8601 字符串
|
||
- date: 转换为 ISO 8601 字符串
|
||
- time: 转换为 ISO 8601 字符串
|
||
- Decimal: 转换为 float
|
||
- UUID: 转换为字符串
|
||
- numpy 类型: 转换为 Python 原生类型
|
||
"""
|
||
# 处理日期时间类型
|
||
if isinstance(obj, datetime):
|
||
return obj.isoformat()
|
||
|
||
if isinstance(obj, date):
|
||
return obj.isoformat()
|
||
|
||
# 处理 Decimal 类型
|
||
if isinstance(obj, Decimal):
|
||
return float(obj)
|
||
|
||
# 处理 UUID 类型
|
||
if isinstance(obj, UUID):
|
||
return str(obj)
|
||
"""
|
||
# 处理 numpy 类型
|
||
if isinstance(obj, np.integer):
|
||
return int(obj)
|
||
if isinstance(obj, np.floating):
|
||
return float(obj)
|
||
if isinstance(obj, np.ndarray):
|
||
return obj.tolist()
|
||
"""
|
||
# 处理其他自定义类型
|
||
if hasattr(obj, '__json__'):
|
||
return obj.__json__()
|
||
|
||
# 默认处理
|
||
return super().default(obj)
|
||
|
||
return json.dumps(
|
||
content,
|
||
ensure_ascii=False,
|
||
allow_nan=False,
|
||
indent=None,
|
||
separators=(",", ":"),
|
||
cls=EnhancedJSONEncoder
|
||
).encode("utf-8")
|
||
|
||
|
||
|
||
def verify_wps_signature(
|
||
authorization: str = Header(...),
|
||
date: str = Header(...),
|
||
content_md5: str = Header(...),
|
||
content_type: str = Header(...),
|
||
x_app_id: str = Header(..., alias="X-App-Id"),
|
||
x_weboffice_token: str = Header(..., alias="X-WebOffice-Token")
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
验证WPS请求签名:cite[1]:cite[6]
|
||
"""
|
||
try:
|
||
# 检查AppId是否匹配
|
||
if x_app_id != WPS_APP_ID:
|
||
raise HTTPException(status_code=401, detail="Invalid AppId")
|
||
|
||
# 解析Authorization头
|
||
if not authorization.startswith("WPS-2:"):
|
||
raise HTTPException(status_code=401, detail="Invalid signature format")
|
||
|
||
parts = authorization.split(":")
|
||
if len(parts) != 3:
|
||
raise HTTPException(status_code=401, detail="Invalid authorization header")
|
||
|
||
_, app_id, signature = parts
|
||
|
||
# 计算期望签名
|
||
string_to_sign = WPS_APP_SECRET + content_md5 + content_type + date
|
||
expected_signature = hashlib.sha1(string_to_sign.encode()).hexdigest()
|
||
|
||
# 验证签名
|
||
if not hmac.compare_digest(signature, expected_signature):
|
||
raise HTTPException(status_code=401, detail="Signature verification failed")
|
||
|
||
return {
|
||
"app_id": app_id,
|
||
"token": x_weboffice_token
|
||
}
|
||
|
||
except Exception as e:
|
||
if isinstance(e, HTTPException):
|
||
raise e
|
||
raise HTTPException(status_code=401, detail="Signature verification error")
|
||
|
||
|
||
def parse_user_token(token: str) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
解析用户Token - 根据你的业务逻辑实现
|
||
"""
|
||
try:
|
||
# 这里可以根据你的业务逻辑解析token
|
||
# 例如JWT解码或其他验证方式
|
||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[ALGORITHM])
|
||
return {
|
||
"user_id": payload.get("sub"),
|
||
"permissions": payload.get("permissions", [])
|
||
}
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# WPS回调路由
|
||
wps_router = APIRouter()
|
||
|
||
#三阶段保存的第一步主要用于 WebOffice 与接入方进行参数协商,目前主要协商摘要算法。
|
||
@wps_router.get("/v3/3rd/files/{file_id}/upload/prepare", response_class=CustomJSONResponse)
|
||
async def upload_prepare_v3(
|
||
file_id: str,
|
||
):
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"digest_types": ["md5"]
|
||
},
|
||
"message": ""
|
||
}
|
||
|
||
@wps_router.post("/v3/3rd/files/{file_id}/upload/address", response_class=CustomJSONResponse)
|
||
async def upload_address_v3(
|
||
file_id: str,
|
||
):
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"method": "PUT",
|
||
"url": f"https://ragflow.szzysztech.com/apitest2/wps/v3/3rd/files/{file_id}/upload"
|
||
},
|
||
"message": ""
|
||
}
|
||
|
||
BASE_API_URL= "http://1.13.185.116:9380/api/v1"
|
||
|
||
@wps_router.put("/v3/3rd/files/{file_id}/upload", response_class=CustomJSONResponse)
|
||
async def receive_upload_file_v3(
|
||
file_id: str,
|
||
request: Request
|
||
):
|
||
"""
|
||
接收WPS服务器通过PUT请求传来的文件流。
|
||
WPS服务器在收到您第一个接口返回的url后,会将文件实体放在请求体(Body)中PUT到此接口。
|
||
"""
|
||
# 从请求头中获取文件大小(如果提供了)
|
||
content_length = request.headers.get("content-length")
|
||
file_size = 0
|
||
if content_length:
|
||
try:
|
||
file_size = int(content_length)
|
||
# 这里可以添加文件大小校验逻辑,例如限制文件不能过大
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid Content-Length header")
|
||
|
||
# 获取请求体中的原始文件流数据
|
||
file_data = await request.body()
|
||
file_name = ""
|
||
if file_id == "demo_file_001":
|
||
file_name = "gv9014-bom.xlsx"
|
||
if file_id == "demo_file_002":
|
||
file_name = "GCDS100900040001.xlsx"
|
||
# 调用MINIO中转API上传文件
|
||
try:
|
||
# 准备表单数据
|
||
files = {
|
||
'file': (file_id, file_data) # 使用file_id作为文件名
|
||
}
|
||
|
||
data = {
|
||
'bucket': 'wps-web-office-files', # 替换为实际的bucket名称
|
||
'file_name': file_name
|
||
}
|
||
|
||
headers = {
|
||
"Authorization": "Bearer ragflow-NhZTY5Y2M4YWQ1MzExZWY4Zjc3MDI0Mm"
|
||
}
|
||
|
||
# 发送请求到MINIO中转API
|
||
minio_api_url = f"{BASE_API_URL}/minio/put" # 确保BASE_API_URL已定义
|
||
|
||
response = requests.post(
|
||
minio_api_url,
|
||
files=files,
|
||
data=data,
|
||
headers=headers
|
||
)
|
||
|
||
# 检查响应状态
|
||
if response.status_code == 200:
|
||
logging.info(f"File {file_id} successfully uploaded to MINIO")
|
||
return {
|
||
"code": 0,
|
||
"message": "File uploaded and processed successfully"
|
||
}
|
||
else:
|
||
logging.error(f"MINIO upload failed for {file_id}: {response.status_code} - {response.text}")
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"Failed to upload file to storage: {response.text}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logging.error(f"Error uploading file {file_id} to MINIO: {str(e)}")
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"Internal server error during file upload: {str(e)}"
|
||
)
|
||
|
||
@wps_router.post("/v3/3rd/files/{file_id}/upload/complete", response_class=CustomJSONResponse)
|
||
async def upload_complete_v3(
|
||
file_id: str,
|
||
):
|
||
file_name = "GCDS100900040001.xlsx"
|
||
if file_id == "demo_file_001":
|
||
file_name = "GV9014-BOM.xlsx"
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"create_time": 1670218748,
|
||
"creator_id": "404",
|
||
"id": "9",
|
||
"modifier_id": "404",
|
||
"modify_time": 1670328304,
|
||
"name": file_name,
|
||
"size": 18961,
|
||
"version": 180
|
||
}
|
||
}
|
||
|
||
|
||
@wps_router.get("/v3/3rd/files/{file_id}/permission", response_class=CustomJSONResponse)
|
||
async def get_file_id_permission_v3(
|
||
file_id: str,
|
||
):
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"comment": 1,
|
||
"copy": 1,
|
||
"download": 1,
|
||
"history": 0,
|
||
"print": 1,
|
||
"read": 1,
|
||
"rename": 0,
|
||
"saveas": 1,
|
||
"update": 1,
|
||
"user_id": "404"
|
||
}
|
||
}
|
||
#GET
|
||
#
|
||
@wps_router.get("/v3/3rd/users", response_class=CustomJSONResponse)
|
||
async def get_users_info_v3(
|
||
user_ids: list[str] = Query(..., description="多个用户ID", alias="user_ids")
|
||
):
|
||
"""
|
||
批量获取用户信息 - 调试版本
|
||
直接返回示例数据,不进行验证
|
||
"""
|
||
logging.info(f"批量获取用户信息调试: user_ids={user_ids}")
|
||
|
||
# 去重处理
|
||
unique_user_ids = list(set(user_ids))
|
||
|
||
# 构建用户信息列表
|
||
users_info = []
|
||
|
||
for user_id in unique_user_ids:
|
||
# 为每个用户ID生成对应的示例数据
|
||
user_info = {
|
||
"id": user_id,
|
||
"name": f"用户{user_id}",
|
||
# "avatar_url": f"https://example.com/avatars/{user_id}.jpg"
|
||
}
|
||
users_info.append(user_info)
|
||
|
||
logging.info(f"返回用户信息: {len(users_info)}个用户")
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": users_info
|
||
}
|
||
|
||
|
||
@wps_router.get("/v3/3rd/files/{file_id}", response_class=CustomJSONResponse)
|
||
async def get_file_info_v3(
|
||
file_id: str,
|
||
):
|
||
logging.info(f"获取文件信息 /v3/3rd/files/{file_id}")
|
||
"""
|
||
获取文件基本信息 - V3版本接口
|
||
遵循WPS WebOffice文件ID一致性原则
|
||
"""
|
||
try:
|
||
# 验证WPS签名
|
||
"""
|
||
signature_data = verify_wps_signature_direct(
|
||
authorization, date, content_md5 or "", content_type or "", x_app_id, x_weboffice_token
|
||
)
|
||
|
||
# 解析用户token验证权限
|
||
user_info = parse_user_token(signature_data["token"])
|
||
if not user_info:
|
||
raise HTTPException(status_code=401, detail="Invalid user token")
|
||
"""
|
||
#logging.info(f"获取文件信息: file_id={file_id}, user_id={user_info['user_id']}")
|
||
logging.info(f"获取文件信息: file_id={file_id}")
|
||
# 获取文件信息 - 替换为你的实际数据库查询逻辑
|
||
file_info = get_file_by_id_v3(file_id, None)
|
||
if not file_info:
|
||
logging.warning(f"文件不存在: file_id={file_id}")
|
||
raise HTTPException(status_code=404, detail="File not found")
|
||
logging.info(f"获取文件信息 /v3/3rd/files/{file_info}")
|
||
# 检查用户对文件的访问权限
|
||
if not check_file_permission_v3(file_id, None): # user_info["user_id"]):
|
||
logging.warning(f"用户无权限访问文件: file_id={file_id}")
|
||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||
|
||
# 构建响应数据,严格遵循WPS规范
|
||
response_data = {
|
||
"id": file_info["id"], # 必须与传入的file_id一致
|
||
"name": file_info["name"],
|
||
"version": file_info["version"],
|
||
"size": file_info["size"],
|
||
"create_time": file_info["create_time"],
|
||
"modify_time": file_info["modify_time"],
|
||
"creator_id": "404", #file_info["creator_id"],
|
||
"modifier_id": file_info["modifier_id"]
|
||
}
|
||
|
||
# 验证响应数据格式
|
||
validation_error = validate_file_info_response(response_data)
|
||
if validation_error:
|
||
logging.error(f"文件信息响应数据验证失败: {validation_error}")
|
||
raise HTTPException(status_code=500, detail="Internal server error: invalid file data format")
|
||
|
||
logging.info(f"成功获取文件信息: file_id={file_id}")
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": response_data
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logging.error(f"获取文件信息异常: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="Internal server error")
|
||
|
||
|
||
@wps_router.get("/v3/3rd/files/{file_id}/download", response_class=CustomJSONResponse)
|
||
async def get_file_download_url(
|
||
file_id: str
|
||
):
|
||
"""
|
||
获取文件下载地址 - 调试版本
|
||
返回文件的下载URL,供WPS在线协同服务使用
|
||
"""
|
||
logging.info(f"获取文件下载地址: file_id={file_id}")
|
||
|
||
# 构建下载URL - 这里使用示例URL,实际使用时替换为你的真实文件下载地址
|
||
if file_id == "demo_file_001":
|
||
download_url = f"http://1.13.185.116:9000/wps-web-office-files/gv9014-bom.xlsx"
|
||
elif file_id == "demo_file_002":
|
||
download_url = f"http://1.13.185.116:9000/wps-web-office-files/GCDS100900040001.xlsx"
|
||
elif file_id == "hanjie_sop":
|
||
download_url = f"http://1.13.185.116:9000/wps-web-office-files/hanjie_sop.xls"
|
||
else:
|
||
download_url = f"http://1.13.185.116:9000/wps-web-office-files/GCDS100900040001.xlsx"
|
||
# 构建响应数据
|
||
response_data = {
|
||
"url": download_url
|
||
# digest 和 digest_type 可选,用于文件校验
|
||
# "digest": "a1b2c3d4e5f6...", # 文件的MD5或SHA1值
|
||
# "digest_type": "md5", # 校验算法: md5 或 sha1
|
||
|
||
# headers 可选,用于需要额外请求头的场景(如防盗链)
|
||
# "headers": {
|
||
# "Referer": "https://your-domain.com",
|
||
# "Authorization": "Bearer your-token"
|
||
# }
|
||
}
|
||
|
||
logging.info(f"返回文件下载地址: {download_url}")
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": response_data
|
||
}
|
||
|
||
|
||
def get_file_by_id_v3(file_id: str, user_id: str) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
根据文件ID获取文件信息 - V3版本
|
||
需要你根据实际业务逻辑实现
|
||
"""
|
||
try:
|
||
# 这里替换为你的实际数据库查询逻辑
|
||
# 示例实现:
|
||
|
||
# 1. 查询数据库获取文件基本信息
|
||
# file_record = query_file_from_database(file_id)
|
||
|
||
# 2. 如果文件不存在,返回None
|
||
# if not file_record:
|
||
# return None
|
||
|
||
# 3. 返回符合WPS规范的数据结构
|
||
# return {
|
||
# "id": file_record["file_id"], # 必须与传入的file_id一致
|
||
# "name": file_record["file_name"],
|
||
# "version": file_record["version"],
|
||
# "size": file_record["file_size"],
|
||
# "create_time": int(file_record["create_time"].timestamp()), # 转换为纪元秒
|
||
# "modify_time": int(file_record["update_time"].timestamp()), # 转换为纪元秒
|
||
# "creator_id": file_record["creator_id"],
|
||
# "modifier_id": file_record["last_modifier_id"]
|
||
# }
|
||
|
||
# 临时示例数据 - 请替换为实际实现
|
||
if file_id in ["example_file_123","dale_123","demo_file_001","demo_file_002","hanjie_sop"]:
|
||
return {
|
||
"id": file_id, # 必须与传入的file_id一致
|
||
"name": "统计月报.xlsx",
|
||
"version": 201,
|
||
"size": 18961,
|
||
"create_time": 1670218748, # 纪元秒
|
||
"modify_time": 1759478858, # 纪元秒
|
||
"creator_id": "user_404",
|
||
"modifier_id": "user_404"
|
||
}
|
||
else:
|
||
# 文件不存在
|
||
return None
|
||
|
||
except Exception as e:
|
||
logging.error(f"查询文件信息失败: {str(e)}")
|
||
return None
|
||
|
||
|
||
def check_file_permission_v3(file_id: str, user_id: str) -> bool:
|
||
"""
|
||
检查用户对文件的访问权限 - V3版本
|
||
需要你根据实际业务逻辑实现
|
||
"""
|
||
try:
|
||
# 这里替换为你的实际权限检查逻辑
|
||
# 示例实现:
|
||
|
||
# 1. 查询用户对文件的权限
|
||
# permission = query_file_permission(file_id, user_id)
|
||
|
||
# 2. 返回是否有访问权限
|
||
# return permission.get("can_read", False)
|
||
|
||
# 临时示例 - 请替换为实际实现
|
||
return True
|
||
|
||
except Exception as e:
|
||
logging.error(f"检查文件权限失败: {str(e)}")
|
||
return False
|
||
|
||
|
||
def validate_file_info_response(file_data: Dict[str, Any]) -> Optional[str]:
|
||
"""
|
||
验证文件信息响应数据是否符合WPS规范
|
||
"""
|
||
# 检查必需字段
|
||
required_fields = ["id", "name", "version", "size", "create_time", "modify_time", "creator_id", "modifier_id"]
|
||
for field in required_fields:
|
||
if field not in file_data:
|
||
return f"Missing required field: {field}"
|
||
|
||
# 验证文件ID长度
|
||
if len(file_data["id"]) > 47:
|
||
return "File ID exceeds maximum length of 47 characters"
|
||
|
||
# 验证文件名长度和特殊字符
|
||
if len(file_data["name"]) > 240:
|
||
return "File name exceeds maximum length of 240 characters"
|
||
|
||
invalid_chars = ['\\', '/', '|', '"', ':', '*', '?', '<', '>']
|
||
for char in invalid_chars:
|
||
if char in file_data["name"]:
|
||
return f"File name contains invalid character: {char}"
|
||
|
||
# 验证版本号
|
||
if not isinstance(file_data["version"], int) or file_data["version"] < 1:
|
||
return "Version must be a positive integer"
|
||
|
||
# 验证文件大小
|
||
if not isinstance(file_data["size"], int) or file_data["size"] < 0:
|
||
return "Size must be a non-negative integer"
|
||
|
||
# 验证时间戳
|
||
if not isinstance(file_data["create_time"], int) or file_data["create_time"] < 0:
|
||
return "Create time must be a non-negative integer"
|
||
|
||
if not isinstance(file_data["modify_time"], int) or file_data["modify_time"] < 0:
|
||
return "Modify time must be a non-negative integer"
|
||
|
||
return None
|
||
|
||
|
||
def verify_wps_signature_direct(
|
||
authorization: str,
|
||
date: str,
|
||
content_md5: str,
|
||
content_type: str,
|
||
x_app_id: str,
|
||
x_weboffice_token: str
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
直接验证WPS请求签名
|
||
"""
|
||
try:
|
||
# 检查AppId是否匹配
|
||
if x_app_id != WPS_APP_ID:
|
||
raise HTTPException(status_code=401, detail="Invalid AppId")
|
||
|
||
# 解析Authorization头
|
||
if not authorization.startswith("WPS-2:"):
|
||
raise HTTPException(status_code=401, detail="Invalid signature format")
|
||
|
||
parts = authorization.split(":")
|
||
if len(parts) != 3:
|
||
raise HTTPException(status_code=401, detail="Invalid authorization header")
|
||
|
||
_, app_id, signature = parts
|
||
|
||
# 计算期望签名
|
||
string_to_sign = WPS_APP_SECRET + content_md5 + content_type + date
|
||
expected_signature = hashlib.sha1(string_to_sign.encode()).hexdigest()
|
||
|
||
# 验证签名
|
||
if not hmac.compare_digest(signature, expected_signature):
|
||
raise HTTPException(status_code=401, detail="Signature verification failed")
|
||
|
||
return {
|
||
"app_id": app_id,
|
||
"token": x_weboffice_token
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logging.error(f"WPS签名验证异常: {str(e)}")
|
||
raise HTTPException(status_code=401, detail="Signature verification error") |