提交 94824d5a authored 作者: 陈泽健's avatar 陈泽健

feat(gui): 添加ERP协作文档上传功能

- 在GUI界面新增协作文档上传复选框,支持创建OnlyOffice在线协作文档
- 扩展上传对话框功能,增加协作文档上传选项和相关参数传递
- 新增upload_file_to_cooperation函数处理协作文档上传逻辑
- 添加ask_cooperation_upload_confirmation_cli函数处理命令行交互
- 更新配置文件增加ERP_UPLOAD_COOPERATION_URL接口路径
- 修改上传流程同时支持项目资料和协作文档两种上传模式
- 优化界面布局,实现项目ID和文件名称输入框的共享使用
上级 96d9fa7c
...@@ -387,6 +387,19 @@ def generate_report_main( ...@@ -387,6 +387,19 @@ def generate_report_main(
print(f" [X] 项目资料上传失败,请查看日志") print(f" [X] 项目资料上传失败,请查看日志")
else: else:
print(f" [!] 跳过项目资料上传") print(f" [!] 跳过项目资料上传")
# 询问是否上传到协作文档(创建OnlyOffice在线协作文档)
from src.erp_uploader import ask_cooperation_upload_confirmation_cli, upload_file_to_cooperation
coop_confirmed, coop_project_id, coop_file_name = ask_cooperation_upload_confirmation_cli(report_path)
if coop_confirmed:
print(f"\n正在上传报告到ERP协作文档(项目ID: {coop_project_id})...")
coop_info = upload_file_to_cooperation(report_path, coop_project_id, coop_file_name, logger)
if coop_info:
print(f" [OK] 报告已成功上传到ERP协作文档(协作文档ID: {coop_info.get('id')})")
else:
print(f" [X] 协作文档上传失败,请查看日志")
else:
print(f" [!] 跳过协作文档上传")
else: else:
print(f" [X] 报告上传失败,请查看日志") print(f" [X] 报告上传失败,请查看日志")
else: else:
......
...@@ -333,7 +333,8 @@ ERP_MAX_RETRIES = 3 ...@@ -333,7 +333,8 @@ ERP_MAX_RETRIES = 3
ERP_UPLOAD_IMAGE_URL = "/openclaw/upload/richtext" # 上传图片接口路径 ERP_UPLOAD_IMAGE_URL = "/openclaw/upload/richtext" # 上传图片接口路径
ERP_CREATE_REPORT_URL = "/openclaw/report" # 创建报告接口路径 ERP_CREATE_REPORT_URL = "/openclaw/report" # 创建报告接口路径
ERP_STUFF_URL = "/openclaw/stuff" # 获取人员列表接口路径 ERP_STUFF_URL = "/openclaw/stuff" # 获取人员列表接口路径
ERP_UPLOAD_PROJECT_URL = "/openclaw/upload/project" # 项目资料上传接口路径 ERP_UPLOAD_PROJECT_URL = "/openclaw/upload/project" # 项目资料上传接口路径(fileGroup_id=4,仅归档,不创建协作)
ERP_UPLOAD_COOPERATION_URL = "/openclaw/upload/cooperation" # 协作文档上传接口路径(fileGroup_id=1,创建OnlyOffice在线协作文档)
# 请求超时时间(秒) # 请求超时时间(秒)
ERP_REQUEST_TIMEOUT = 30 ERP_REQUEST_TIMEOUT = 30
......
...@@ -29,6 +29,7 @@ from src.config import ( ...@@ -29,6 +29,7 @@ from src.config import (
ERP_CREATE_REPORT_URL, ERP_CREATE_REPORT_URL,
ERP_STUFF_URL, ERP_STUFF_URL,
ERP_UPLOAD_PROJECT_URL, ERP_UPLOAD_PROJECT_URL,
ERP_UPLOAD_COOPERATION_URL,
ERP_REQUEST_TIMEOUT, ERP_REQUEST_TIMEOUT,
ERP_SAVE_IMAGES_TEMP, ERP_SAVE_IMAGES_TEMP,
ERP_VALIDATE_IMAGES, ERP_VALIDATE_IMAGES,
...@@ -669,6 +670,196 @@ def upload_file_to_project(file_path: str, project_id: int, name: str, logger: l ...@@ -669,6 +670,196 @@ def upload_file_to_project(file_path: str, project_id: int, name: str, logger: l
return None return None
def upload_file_to_cooperation(file_path: str, project_id: int, name: str, logger: logging.Logger = None, group_id: Optional[int] = None) -> Optional[dict]:
"""
上传文件到ERP协作文档(创建OnlyOffice在线协作文档),含重试机制
与项目资料上传不同,协作文档上传会在ERP中创建OnlyOffice在线协作文档,
返回结果中包含 fileKey/filePath/sharePath 等协作信息,支持多人在线协作编辑。
Args:
file_path: 要上传的文件路径(推荐 .docx/.xlsx/.pptx/.pdf)
project_id: 关联的项目ID
name: 文件名称(留空使用原文件名)
logger: 日志记录器(可选,默认创建新的logger)
group_id: 分组ID(可选,可将文件归类到指定分组)
Returns:
上传成功返回协作文档信息字典,失败返回None
文件信息格式: {'id': int, 'file_id': int, 'name': str, 'fileKey': str,
'filePath': str, 'fileType': str, 'fileSize': int,
'sharePath': str, 'project_id': int}
"""
# 如果没有提供logger,创建新的logger
if logger is None:
logger = setup_logger()
url = f'{ERP_BASE_URL}{ERP_UPLOAD_COOPERATION_URL}'
logger.info("=" * 50)
logger.info("协作文档上传流程开始")
logger.info(f"文件路径: {file_path}")
logger.info(f"项目ID: {project_id}")
logger.info(f"文件名称: {name}")
if group_id is not None:
logger.info(f"分组ID: {group_id}")
logger.info("=" * 50)
# 验证文件存在
if not os.path.exists(file_path):
logger.error(f"✗ 文件不存在: {file_path}")
return None
# 获取文件大小
file_size = os.path.getsize(file_path)
logger.info(f"文件大小: {file_size} bytes ({file_size / 1024:.2f} KB)")
for attempt in range(ERP_MAX_RETRIES):
try:
# 准备请求参数(multipart/form-data)
data = {
'project_id': project_id,
'name': name
}
# 分组ID为可选参数,仅在传入时提交
if group_id is not None:
data['group_id'] = group_id
headers = {'X-Api-Key': ERP_API_KEY}
# 打印请求信息
logger.info(f"=== 协作文档上传请求 ===")
logger.info(f"请求URL: {url}")
logger.info(f"请求Headers: X-Api-Key={ERP_API_KEY[:10]}...")
logger.info(f"请求参数:")
logger.info(f" - project_id: {project_id}")
logger.info(f" - name: {name}")
if group_id is not None:
logger.info(f" - group_id: {group_id}")
logger.info(f" - file: {file_path}")
# 读取文件并上传
with open(file_path, 'rb') as f:
files = {'file': (os.path.basename(file_path), f, 'application/octet-stream')}
# 发送请求
resp = requests.post(
url,
headers=headers,
files=files,
data=data,
timeout=ERP_REQUEST_TIMEOUT
)
# 打印响应信息
logger.info(f"响应状态码: {resp.status_code}")
logger.info(f"响应内容: {resp.text}")
result = resp.json()
if result.get('success') == 1:
file_info = result['data']
logger.info(f"✓ 协作文档上传成功!")
logger.info(f" - 协作文档ID: {file_info.get('id')}")
logger.info(f" - 源文件ID: {file_info.get('file_id')}")
logger.info(f" - 文件名称: {file_info.get('name')}")
logger.info(f" - 协作密钥(fileKey): {file_info.get('fileKey')}")
logger.info(f" - 协作路径(filePath): {file_info.get('filePath')}")
logger.info(f" - 文件类型: {file_info.get('fileType')}")
logger.info(f" - 文件大小: {file_info.get('fileSize')} bytes")
logger.info(f" - 分享路径(sharePath): {file_info.get('sharePath')}")
logger.info(f" - 项目ID: {file_info.get('project_id')}")
return file_info
else:
error_code = result.get('error', 0)
# 4xx错误(客户端错误,如权限不足、Key无效)不重试
if 400 <= error_code < 500:
logger.error(f"✗ 协作文档上传失败(客户端错误 {error_code}): {result.get('msg')}")
# 针对权限不足(40000015)给出明确提示
if error_code == 40000015:
logger.error(" → 提示:请确认 API Key 已授权 upload_cooperation 权限")
return None
if attempt < ERP_MAX_RETRIES - 1:
logger.warning(f"协作文档上传失败,{ERP_RETRY_INTERVAL}秒后重试({attempt+1}/{ERP_MAX_RETRIES}): {result.get('msg')}")
time.sleep(ERP_RETRY_INTERVAL)
else:
logger.error(f"✗ 协作文档上传失败(已达最大重试次数): {result.get('msg')}")
except requests.exceptions.Timeout:
logger.warning(f"协作文档上传超时,{ERP_RETRY_INTERVAL}秒后重试({attempt+1}/{ERP_MAX_RETRIES})")
if attempt < ERP_MAX_RETRIES - 1:
time.sleep(ERP_RETRY_INTERVAL)
else:
logger.error(f"✗ 协作文档上传超时(已达最大重试次数)")
except Exception as e:
logger.error(f"✗ 协作文档上传异常: {str(e)}")
break
return None
def ask_cooperation_upload_confirmation_cli(report_path: str) -> Tuple[bool, Optional[int], Optional[str]]:
"""
控制台交互:询问用户是否上传报告到ERP协作文档
Args:
report_path: 报告文件路径
Returns:
元组 (用户选择, 项目ID, 文件名称)
- (True, int, str) 用户确认上传
- (False, None, None) 用户选择不上传
"""
while True:
print(f"\n{'='*50}")
print(f"是否将报告上传到ERP协作文档?(将创建OnlyOffice在线协作文档)")
print(f"{'='*50}")
print(f"报告文件: {report_path}")
choice = input("请输入 Y/N (默认=N): ").strip().upper()
if not choice:
choice = 'N'
if choice == 'Y':
# 输入项目ID
project_id = None
while True:
id_input = input("请输入项目ID: ").strip()
if not id_input:
print("项目ID不能为空,请重新输入")
continue
try:
project_id = int(id_input)
if project_id > 0:
break
else:
print("项目ID必须为正整数,请重新输入")
except ValueError:
print("输入无效,请输入数字")
# 输入文件名称
file_name = None
while True:
name_input = input("请输入文件名称(直接回车使用原文件名): ").strip()
if not name_input:
# 使用原文件名(去掉扩展名)
import os
file_name = os.path.splitext(os.path.basename(report_path))[0]
print(f"使用原文件名: {file_name}")
break
else:
file_name = name_input
break
return True, project_id, file_name
elif choice == 'N':
return False, None, None
else:
print("输入错误,请输入 Y 或 N")
def ask_upload_confirmation_cli(report_path: str) -> Tuple[bool, Optional[int], Optional[list], Optional[int]]: def ask_upload_confirmation_cli(report_path: str) -> Tuple[bool, Optional[int], Optional[list], Optional[int]]:
""" """
控制台交互:询问用户是否上传报告到ERP,输入测试单ID、抄送人和创建人 控制台交互:询问用户是否上传报告到ERP,输入测试单ID、抄送人和创建人
......
...@@ -500,16 +500,16 @@ class ReportGeneratorGUI: ...@@ -500,16 +500,16 @@ class ReportGeneratorGUI:
def _ask_upload_to_erp(self, report_path: str): def _ask_upload_to_erp(self, report_path: str):
""" """
询问用户是否上传报告到ERP,并获取测试单ID、抄送人、创建人和项目资料上传信息 询问用户是否上传报告到ERP,并获取测试单ID、抄送人、创建人和项目资料/协作文档上传信息
Args: Args:
report_path: 报告文件路径 report_path: 报告文件路径
""" """
# 使用自定义对话框替代messagebox,确保按钮正常显示 # 使用自定义对话框替代messagebox,确保按钮正常显示
confirmed, testing_id, copyuser_ids, creator_id, upload_project, project_id, file_name = self._show_upload_dialog(report_path) confirmed, testing_id, copyuser_ids, creator_id, upload_project, project_id, file_name, upload_cooperation = self._show_upload_dialog(report_path)
if confirmed: if confirmed:
# 用户选择上传,透传测试单ID、抄送人列表和创建人ID,以及项目资料上传信息 # 用户选择上传,透传测试单ID、抄送人列表和创建人ID,以及项目资料/协作文档上传信息
self._upload_to_erp( self._upload_to_erp(
report_path, report_path,
developtesting_id=testing_id, developtesting_id=testing_id,
...@@ -517,22 +517,23 @@ class ReportGeneratorGUI: ...@@ -517,22 +517,23 @@ class ReportGeneratorGUI:
createuser_id=creator_id, createuser_id=creator_id,
upload_project=upload_project, upload_project=upload_project,
project_id=project_id, project_id=project_id,
file_name=file_name file_name=file_name,
upload_cooperation=upload_cooperation
) )
else: else:
self._log("[!] 跳过上传到ERP", "warning") self._log("[!] 跳过上传到ERP", "warning")
def _show_upload_dialog(self, report_path: str) -> tuple: def _show_upload_dialog(self, report_path: str) -> tuple:
""" """
显示自定义上传确认对话框(含测试单ID输入框、抄送人输入框、创建人输入框和项目资料上传选项) 显示自定义上传确认对话框(含测试单ID输入框、抄送人输入框、创建人输入框和项目资料/协作文档上传选项)
Args: Args:
report_path: 报告文件路径 report_path: 报告文件路径
Returns: Returns:
元组 (用户选择, 测试单ID, 抄送人ID列表, 创建人ID, 是否上传项目资料, 项目ID, 文件名称) 元组 (用户选择, 测试单ID, 抄送人ID列表, 创建人ID, 是否上传项目资料, 项目ID, 文件名称, 是否上传协作文档)
- (True, int, list, int, bool, int, str) 用户确认上传 - (True, int, list, int, bool, int, str, bool) 用户确认上传
- (False, None, None, None, False, None, None) 用户选择不上传 - (False, None, None, None, False, None, None, False) 用户选择不上传
""" """
# 导入ERP配置 # 导入ERP配置
from src.config import ERP_DEVELOPTESTING_ID from src.config import ERP_DEVELOPTESTING_ID
...@@ -685,7 +686,7 @@ class ReportGeneratorGUI: ...@@ -685,7 +686,7 @@ class ReportGeneratorGUI:
padx=10 padx=10
).pack(side=tk.LEFT, padx=(0, 5)) ).pack(side=tk.LEFT, padx=(0, 5))
# 项目资料上传区域 # 项目资料/协作文档上传区域(共享项目ID和文件名称输入)
project_frame = tk.Frame(message_frame) project_frame = tk.Frame(message_frame)
project_frame.pack(anchor=tk.W, pady=(15, 0)) project_frame.pack(anchor=tk.W, pady=(15, 0))
...@@ -696,17 +697,28 @@ class ReportGeneratorGUI: ...@@ -696,17 +697,28 @@ class ReportGeneratorGUI:
text="同时上传到项目资料", text="同时上传到项目资料",
variable=project_var, variable=project_var,
font=("微软雅黑", 10), font=("微软雅黑", 10),
command=lambda: on_project_check_changed() command=lambda: on_upload_option_changed()
) )
project_check.pack(anchor=tk.W) project_check.pack(anchor=tk.W)
# 项目资料输入容器(初始隐藏) # 协作文档上传复选框(创建OnlyOffice在线协作文档)
cooperation_var = tk.BooleanVar()
cooperation_check = tk.Checkbutton(
project_frame,
text="同时上传到协作文档(创建OnlyOffice在线协作文档)",
variable=cooperation_var,
font=("微软雅黑", 10),
command=lambda: on_upload_option_changed()
)
cooperation_check.pack(anchor=tk.W)
# 项目资料/协作文档输入容器(初始隐藏,任一勾选时显示)
project_inputs_frame = tk.Frame(project_frame) project_inputs_frame = tk.Frame(project_frame)
# project_inputs_frame.pack() # 根据复选框状态显示/隐藏 # project_inputs_frame.pack() # 根据复选框状态显示/隐藏
def on_project_check_changed(): def on_upload_option_changed():
"""复选框状态变化时的处理""" """复选框状态变化时的处理:任一勾选时显示项目ID和文件名称输入"""
if project_var.get(): if project_var.get() or cooperation_var.get():
project_inputs_frame.pack(anchor=tk.W, pady=(10, 0), padx=(20, 0)) project_inputs_frame.pack(anchor=tk.W, pady=(10, 0), padx=(20, 0))
else: else:
project_inputs_frame.pack_forget() project_inputs_frame.pack_forget()
...@@ -748,8 +760,8 @@ class ReportGeneratorGUI: ...@@ -748,8 +760,8 @@ class ReportGeneratorGUI:
button_frame = tk.Frame(dialog, bg="#f0f0f0", height=60) button_frame = tk.Frame(dialog, bg="#f0f0f0", height=60)
button_frame.pack(side=tk.BOTTOM, fill=tk.X) button_frame.pack(side=tk.BOTTOM, fill=tk.X)
# 结果存储: [confirmed, testing_id, copyuser_ids, creator_id, upload_project, project_id, file_name] # 结果存储: [confirmed, testing_id, copyuser_ids, creator_id, upload_project, project_id, file_name, upload_cooperation]
result = [False, None, None, None, False, None, None] result = [False, None, None, None, False, None, None, False]
def on_yes(): def on_yes():
"""确认上传""" """确认上传"""
...@@ -807,11 +819,13 @@ class ReportGeneratorGUI: ...@@ -807,11 +819,13 @@ class ReportGeneratorGUI:
except Exception: except Exception:
pass pass
# 获取项目资料上传信息 # 获取项目资料/协作文档上传信息
upload_project = project_var.get() upload_project = project_var.get()
upload_cooperation = cooperation_var.get()
project_id = None project_id = None
file_name = None file_name = None
if upload_project: # 任一勾选时都需要项目ID和文件名称
if upload_project or upload_cooperation:
# 验证项目ID # 验证项目ID
project_id_input = project_id_entry.get().strip() project_id_input = project_id_entry.get().strip()
if not project_id_input: if not project_id_input:
...@@ -845,6 +859,7 @@ class ReportGeneratorGUI: ...@@ -845,6 +859,7 @@ class ReportGeneratorGUI:
result[4] = upload_project result[4] = upload_project
result[5] = project_id result[5] = project_id
result[6] = file_name result[6] = file_name
result[7] = upload_cooperation
dialog.destroy() dialog.destroy()
def on_no(): def on_no():
...@@ -856,6 +871,7 @@ class ReportGeneratorGUI: ...@@ -856,6 +871,7 @@ class ReportGeneratorGUI:
result[4] = False result[4] = False
result[5] = None result[5] = None
result[6] = None result[6] = None
result[7] = False
dialog.destroy() dialog.destroy()
tk.Button( tk.Button(
...@@ -899,7 +915,7 @@ class ReportGeneratorGUI: ...@@ -899,7 +915,7 @@ class ReportGeneratorGUI:
# 等待对话框关闭 # 等待对话框关闭
dialog.wait_window() dialog.wait_window()
return result[0], result[1], result[2], result[3], result[4], result[5], result[6] return result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7]
def _show_info_dialog(self, title: str, message: str): def _show_info_dialog(self, title: str, message: str):
""" """
...@@ -989,7 +1005,7 @@ class ReportGeneratorGUI: ...@@ -989,7 +1005,7 @@ class ReportGeneratorGUI:
# 等待对话框关闭 # 等待对话框关闭
dialog.wait_window() dialog.wait_window()
def _upload_to_erp(self, report_path: str, developtesting_id: int = None, copyuser_list: list = None, createuser_id: int = None, upload_project: bool = False, project_id: int = None, file_name: str = None): def _upload_to_erp(self, report_path: str, developtesting_id: int = None, copyuser_list: list = None, createuser_id: int = None, upload_project: bool = False, project_id: int = None, file_name: str = None, upload_cooperation: bool = False):
""" """
上传报告到ERP 上传报告到ERP
...@@ -999,8 +1015,9 @@ class ReportGeneratorGUI: ...@@ -999,8 +1015,9 @@ class ReportGeneratorGUI:
copyuser_list: 抄送人ID列表(可选,默认为空列表) copyuser_list: 抄送人ID列表(可选,默认为空列表)
createuser_id: 创建人ID(可选,用于指定报告创建者) createuser_id: 创建人ID(可选,用于指定报告创建者)
upload_project: 是否上传到项目资料(可选) upload_project: 是否上传到项目资料(可选)
project_id: 项目ID(上传项目资料时需要) project_id: 项目ID(上传项目资料/协作文档时需要)
file_name: 文件名称(上传项目资料时需要) file_name: 文件名称(上传项目资料/协作文档时需要)
upload_cooperation: 是否上传到协作文档(可选,创建OnlyOffice在线协作文档)
""" """
try: try:
from src.erp_uploader import upload_report_to_erp, setup_logger from src.erp_uploader import upload_report_to_erp, setup_logger
...@@ -1014,7 +1031,7 @@ class ReportGeneratorGUI: ...@@ -1014,7 +1031,7 @@ class ReportGeneratorGUI:
# 在后台线程中上传 # 在后台线程中上传
thread = threading.Thread( thread = threading.Thread(
target=self._upload_to_erp_thread, target=self._upload_to_erp_thread,
args=(report_path, logger, developtesting_id, copyuser_list, createuser_id, upload_project, project_id, file_name), args=(report_path, logger, developtesting_id, copyuser_list, createuser_id, upload_project, project_id, file_name, upload_cooperation),
daemon=True daemon=True
) )
thread.start() thread.start()
...@@ -1026,7 +1043,7 @@ class ReportGeneratorGUI: ...@@ -1026,7 +1043,7 @@ class ReportGeneratorGUI:
self._log(f"[X] ERP上传功能异常: {str(e)}", "error") self._log(f"[X] ERP上传功能异常: {str(e)}", "error")
self._show_error_dialog("错误", f"ERP上传功能异常:\n{str(e)}") self._show_error_dialog("错误", f"ERP上传功能异常:\n{str(e)}")
def _upload_to_erp_thread(self, report_path: str, logger, developtesting_id: int = None, copyuser_list: list = None, createuser_id: int = None, upload_project: bool = False, project_id: int = None, file_name: str = None): def _upload_to_erp_thread(self, report_path: str, logger, developtesting_id: int = None, copyuser_list: list = None, createuser_id: int = None, upload_project: bool = False, project_id: int = None, file_name: str = None, upload_cooperation: bool = False):
""" """
在后台线程中上传报告到ERP 在后台线程中上传报告到ERP
...@@ -1037,8 +1054,9 @@ class ReportGeneratorGUI: ...@@ -1037,8 +1054,9 @@ class ReportGeneratorGUI:
copyuser_list: 抄送人ID列表(可选,默认为空列表) copyuser_list: 抄送人ID列表(可选,默认为空列表)
createuser_id: 创建人ID(可选,用于指定报告创建者) createuser_id: 创建人ID(可选,用于指定报告创建者)
upload_project: 是否上传到项目资料(可选) upload_project: 是否上传到项目资料(可选)
project_id: 项目ID(上传项目资料时需要) project_id: 项目ID(上传项目资料/协作文档时需要)
file_name: 文件名称(上传项目资料时需要) file_name: 文件名称(上传项目资料/协作文档时需要)
upload_cooperation: 是否上传到协作文档(可选,创建OnlyOffice在线协作文档)
""" """
try: try:
from src.erp_uploader import upload_report_to_erp from src.erp_uploader import upload_report_to_erp
...@@ -1047,6 +1065,10 @@ class ReportGeneratorGUI: ...@@ -1047,6 +1065,10 @@ class ReportGeneratorGUI:
if success: if success:
self._log("[OK] 报告已成功上传到ERP", "success") self._log("[OK] 报告已成功上传到ERP", "success")
# 用于汇总各归档项的执行结果
summary_parts = ["报告已成功上传到ERP"]
has_partial_failure = False
# 如果需要上传到项目资料 # 如果需要上传到项目资料
if upload_project and project_id and file_name: if upload_project and project_id and file_name:
self._log(f"[INFO] 开始上传到项目资料(项目ID: {project_id})...", "info") self._log(f"[INFO] 开始上传到项目资料(项目ID: {project_id})...", "info")
...@@ -1055,19 +1077,39 @@ class ReportGeneratorGUI: ...@@ -1055,19 +1077,39 @@ class ReportGeneratorGUI:
file_info = upload_file_to_project(report_path, project_id, file_name, logger) file_info = upload_file_to_project(report_path, project_id, file_name, logger)
if file_info: if file_info:
self._log(f"[OK] 报告已成功上传到ERP项目资料(文件ID: {file_info.get('id')})", "success") self._log(f"[OK] 报告已成功上传到ERP项目资料(文件ID: {file_info.get('id')})", "success")
# 在主线程中显示成功消息(包含项目资料上传) summary_parts.append("项目资料")
self.root.after(0, lambda: self._show_info_dialog("成功", "报告已成功上传到ERP和项目资料!"))
else: else:
self._log("[X] 项目资料上传失败,请查看日志", "error") self._log("[X] 项目资料上传失败,请查看日志", "error")
# 在主线程中显示部分成功消息 has_partial_failure = True
self.root.after(0, lambda: self._show_info_dialog("部分成功", "报告已上传到ERP,但项目资料上传失败,请查看日志"))
except Exception as proj_e: except Exception as proj_e:
proj_err_msg = str(proj_e) proj_err_msg = str(proj_e)
self._log(f"[X] 项目资料上传异常: {proj_err_msg}", "error") self._log(f"[X] 项目资料上传异常: {proj_err_msg}", "error")
self.root.after(0, lambda msg=proj_err_msg: self._show_info_dialog("部分成功", f"报告已上传到ERP,但项目资料上传异常:\n{msg}")) has_partial_failure = True
# 如果需要上传到协作文档(创建OnlyOffice在线协作文档)
if upload_cooperation and project_id and file_name:
self._log(f"[INFO] 开始上传到协作文档(项目ID: {project_id})...", "info")
try:
from src.erp_uploader import upload_file_to_cooperation
coop_info = upload_file_to_cooperation(report_path, project_id, file_name, logger)
if coop_info:
self._log(f"[OK] 报告已成功上传到ERP协作文档(协作文档ID: {coop_info.get('id')})", "success")
summary_parts.append("协作文档")
else:
self._log("[X] 协作文档上传失败,请查看日志", "error")
has_partial_failure = True
except Exception as coop_e:
coop_err_msg = str(coop_e)
self._log(f"[X] 协作文档上传异常: {coop_err_msg}", "error")
has_partial_failure = True
# 根据归档结果汇总最终提示
if has_partial_failure:
final_msg = "、".join(summary_parts) + " 已上传,但部分归档项失败,请查看日志"
self.root.after(0, lambda msg=final_msg: self._show_info_dialog("部分成功", msg))
else: else:
# 在主线程中显示成功消息 final_msg = "、".join(summary_parts) + "!"
self.root.after(0, lambda: self._show_info_dialog("成功", "报告已成功上传到ERP!")) self.root.after(0, lambda msg=final_msg: self._show_info_dialog("成功", msg))
else: else:
self._log("[X] 报告上传失败,请查看日志", "error") self._log("[X] 报告上传失败,请查看日志", "error")
# 在主线程中显示错误消息 # 在主线程中显示错误消息
......
# ERP对接优化文件归档至协作文档 - 计划执行文档
## 执行概述
### 项目背景
当前功能测试报告生成工具已实现将生成的报告上传到 ERP 测试报告模块,并支持归档到 ERP 项目资料(`/openclaw/upload/project`)。为进一步支持团队在线协作,需要对接 ERP 协作文档上传接口,在创建 ERP 测试报告后,将测试报告文件(Word 格式)上传至 ERP 项目协作文档中,自动创建 OnlyOffice 在线协作文档,便于多人在线协作编辑。
### 执行目标
1. 实现协作文档上传接口调用(`POST /openclaw/upload/cooperation`
2. 在测试报告创建成功后,新增"上传协作文档"选项,触发协作文档归档流程
3. 支持用户输入项目 ID(project_id)和文件名称(name)
4. 上传文件直接采用生产的测试报告 Word 格式(.docx),无需转 HTML
5. 集成到现有上传流程中(CLI 与 GUI 双模式)
### 涉及范围
- 代码路径:`AuxiliaryTool/FunctionalTestReportGeneration`
- 接口文档:`Docs/PRD/自动化生成功能测试报告/ERP对接PRD/PRD_项目资料_上传接口_例子说明.md`(参见第 4.1 协作文档上传章节)
- 规范文档:
- 代码规范:`Docs/PRD/01规范文档/_PRD_规范文档_代码规范.md`
- 文档规范:`Docs/PRD/01规范文档/_PRD_规范文档_文档规范.md`
- 测试规范:`Docs/PRD/01规范文档/_PRD_规范文档_测试规范.md`
### 关键说明
> 协作文档上传(`/cooperation`)与项目资料上传(`/project`)的区别:
> - 协作文档会创建 OnlyOffice 在线协作文档(支持多人在线编辑),返回 `fileKey`、`filePath`、`sharePath` 等协作信息;
> - 项目资料仅作为只读归档文件,不创建协作会话。
> 本需求只新增"上传协作文档"选项,**不影响**已有的测试报告上传与项目资料归档功能。
---
## 任务分解与实施计划
### 任务1:新增协作文档上传接口配置
#### 1.1 任务详情
- **接口地址**`POST /openclaw/upload/cooperation`
- **接口目的**:上传测试报告文件到 ERP 协作文档,创建 OnlyOffice 在线协作文档
- **状态**`[ ]`
#### 1.2 实施步骤
- [ ] 1.2.1 在 `src/config.py` 中新增协作文档上传接口配置常量
- `ERP_UPLOAD_COOPERATION_URL` = "/openclaw/upload/cooperation"
- [ ] 1.2.2 确认现有 `ERP_BASE_URL``ERP_API_KEY``ERP_MAX_RETRIES``ERP_RETRY_INTERVAL``ERP_REQUEST_TIMEOUT` 配置可直接复用
- [ ] 1.2.3 添加配置说明注释(明确接口用途、api_name 权限要求、fileType 映射说明)
#### 1.3 输出产物
- `src/config.py` 中的新增配置常量 `ERP_UPLOAD_COOPERATION_URL`
- 配置说明注释(含 api_name=`upload_cooperation` 权限提示)
---
### 任务2:实现协作文档上传函数
#### 2.1 任务详情
- **功能目标**:在 `src/erp_uploader.py` 中新增协作文档上传函数(参考已实现的 `upload_file_to_project()`,保持代码风格一致)
- **函数签名**`upload_file_to_cooperation(file_path: str, project_id: int, name: str, logger: logging.Logger = None, group_id: Optional[int] = None) -> Optional[dict]`
- **状态**`[ ]`
#### 2.2 实施步骤
- [ ] 2.2.1 创建 `upload_file_to_cooperation()` 函数
- [ ] 2.2.2 实现 `multipart/form-data` 格式的文件上传逻辑
- 请求参数:`file`(必填)、`project_id`(必填)、`name`(可选,留空使用原文件名)
- 可选参数:`group_id`(分组 ID,默认不传)
- [ ] 2.2.3 复用现有重试机制(`ERP_MAX_RETRIES` / `ERP_RETRY_INTERVAL`)与 4xx 错误不重试策略
- [ ] 2.2.4 添加异常处理和完整日志记录(请求 URL、Headers、参数、响应状态码、响应内容)
- [ ] 2.2.5 解析并返回上传结果,字段包括:`id``file_id``name``fileKey``filePath``fileType``fileSize``sharePath``project_id`
- [ ] 2.2.6 在返回成功时,日志中输出 `fileKey``sharePath`(便于后续定位协作文档)
#### 2.3 输出产物
- `upload_file_to_cooperation()` 函数
- 完整的错误处理、重试机制与日志记录
- 协作文档信息字典返回(含 `fileKey` 等协作字段)
#### 2.4 参考代码片段
```python
# -*- coding: utf-8 -*-
# src/erp_uploader.py 中新增
def upload_file_to_cooperation(file_path: str, project_id: int, name: str, logger: logging.Logger = None, group_id: Optional[int] = None) -> Optional[dict]:
"""
上传文件到ERP协作文档(创建OnlyOffice在线协作文档),含重试机制
Args:
file_path: 要上传的文件路径(推荐 .docx/.xlsx/.pptx/.pdf)
project_id: 关联的项目ID
name: 文件名称(留空使用原文件名)
logger: 日志记录器(可选,默认创建新的logger)
group_id: 分组ID(可选,可将文件归类到指定分组)
Returns:
上传成功返回协作文档信息字典,失败返回None
字段: {'id', 'file_id', 'name', 'fileKey', 'filePath', 'fileType', 'fileSize', 'sharePath', 'project_id'}
"""
# 如果没有提供logger,创建新的logger
if logger is None:
logger = setup_logger()
url = f'{ERP_BASE_URL}{ERP_UPLOAD_COOPERATION_URL}'
logger.info("=" * 50)
logger.info("协作文档上传流程开始")
logger.info(f"文件路径: {file_path}")
logger.info(f"项目ID: {project_id}")
logger.info(f"文件名称: {name}")
logger.info("=" * 50)
# 验证文件存在
if not os.path.exists(file_path):
logger.error(f"✗ 文件不存在: {file_path}")
return None
# 获取文件大小
file_size = os.path.getsize(file_path)
logger.info(f"文件大小: {file_size} bytes ({file_size / 1024:.2f} KB)")
for attempt in range(ERP_MAX_RETRIES):
try:
# 准备请求参数(multipart/form-data)
data = {
'project_id': project_id,
'name': name
}
# 分组ID为可选参数
if group_id is not None:
data['group_id'] = group_id
headers = {'X-Api-Key': ERP_API_KEY}
# 打印请求信息
logger.info(f"=== 协作文档上传请求 ===")
logger.info(f"请求URL: {url}")
logger.info(f"请求Headers: X-Api-Key={ERP_API_KEY[:10]}...")
logger.info(f"请求参数:")
logger.info(f" - project_id: {project_id}")
logger.info(f" - name: {name}")
if group_id is not None:
logger.info(f" - group_id: {group_id}")
logger.info(f" - file: {file_path}")
# 读取文件并上传
with open(file_path, 'rb') as f:
files = {'file': (os.path.basename(file_path), f, 'application/octet-stream')}
# 发送请求
resp = requests.post(
url,
headers=headers,
files=files,
data=data,
timeout=ERP_REQUEST_TIMEOUT
)
# 打印响应信息
logger.info(f"响应状态码: {resp.status_code}")
logger.info(f"响应内容: {resp.text}")
result = resp.json()
if result.get('success') == 1:
file_info = result['data']
logger.info(f"✓ 协作文档上传成功!")
logger.info(f" - 协作文档ID: {file_info.get('id')}")
logger.info(f" - 源文件ID: {file_info.get('file_id')}")
logger.info(f" - 文件名称: {file_info.get('name')}")
logger.info(f" - 协作密钥(fileKey): {file_info.get('fileKey')}")
logger.info(f" - 协作路径(filePath): {file_info.get('filePath')}")
logger.info(f" - 文件类型: {file_info.get('fileType')}")
logger.info(f" - 文件大小: {file_info.get('fileSize')} bytes")
logger.info(f" - 分享路径(sharePath): {file_info.get('sharePath')}")
logger.info(f" - 项目ID: {file_info.get('project_id')}")
return file_info
else:
error_code = result.get('error', 0)
# 4xx错误(客户端错误,如权限不足、Key无效)不重试
if 400 <= error_code < 500:
logger.error(f"✗ 协作文档上传失败(客户端错误 {error_code}): {result.get('msg')}")
# 针对权限不足(40000015)给出明确提示
if error_code == 40000015:
logger.error(" → 提示:请确认 API Key 已授权 upload_cooperation 权限")
return None
if attempt < ERP_MAX_RETRIES - 1:
logger.warning(f"协作文档上传失败,{ERP_RETRY_INTERVAL}秒后重试({attempt+1}/{ERP_MAX_RETRIES}): {result.get('msg')}")
time.sleep(ERP_RETRY_INTERVAL)
else:
logger.error(f"✗ 协作文档上传失败(已达最大重试次数): {result.get('msg')}")
except requests.exceptions.Timeout:
logger.warning(f"协作文档上传超时,{ERP_RETRY_INTERVAL}秒后重试({attempt+1}/{ERP_MAX_RETRIES})")
if attempt < ERP_MAX_RETRIES - 1:
time.sleep(ERP_RETRY_INTERVAL)
else:
logger.error(f"✗ 协作文档上传超时(已达最大重试次数)")
except Exception as e:
logger.error(f"✗ 协作文档上传异常: {str(e)}")
break
return None
```
---
### 任务3:集成到现有上传流程
#### 3.1 任务详情
- **功能目标**:在 ERP 测试报告创建成功后,新增"上传协作文档"选项
- **集成位置**
- CLI:`src/cli.py``generate_report_main()` 函数
- GUI:`src/gui.py``_show_upload_dialog()``_upload_to_erp_thread()` 函数
- **状态**`[ ]`
#### 3.2 实施步骤(CLI 模式)
- [ ] 3.2.1 在 `src/erp_uploader.py` 中新增 CLI 交互函数 `ask_cooperation_upload_confirmation_cli(report_path)`(参考已有 `ask_project_upload_confirmation_cli()`
- 询问是否上传到协作文档(Y/N)
- 获取用户输入的项目 ID(必填,正整数校验)
- 获取文件名称(留空使用原文件名)
- [ ] 3.2.2 在 `src/cli.py``generate_report_main()` 中,于测试单上传成功分支内,调用 `ask_cooperation_upload_confirmation_cli()`
- [ ] 3.2.3 用户确认后调用 `upload_file_to_cooperation()` 执行上传,打印成功/失败结果
#### 3.3 实施步骤(GUI 模式)
- [ ] 3.3.1 在 `src/gui.py``_show_upload_dialog()` 中新增"同时上传到协作文档"复选框
- 复用现有的项目 ID、文件名称输入框(协作文档与项目资料均需 project_id 与 name)
- 勾选后展示协作文档专属说明(提示将创建 OnlyOffice 在线协作文档)
- [ ] 3.3.2 扩展 `_show_upload_dialog()` 的返回值元组,新增 `upload_cooperation`(bool)字段
- [ ] 3.3.3 在 `_ask_upload_to_erp()``_upload_to_erp()` 中透传协作文档上传参数
- [ ] 3.3.4 在 `_upload_to_erp_thread()` 中,测试单上传成功后,根据 `upload_cooperation` 标志调用 `upload_file_to_cooperation()`,并展示协作文档信息(fileKey 等)
#### 3.4 输出产物
- CLI 模式下的协作文档上传交互(`ask_cooperation_upload_confirmation_cli()`
- GUI 模式下的"同时上传到协作文档"复选框及联动逻辑
- 测试单上传成功后的协作文档归档流程
---
### 任务4:优化交互体验与输入校验
#### 4.1 任务详情
- **功能目标**:提供友好的用户输入提示、输入校验和明确的成功/失败反馈
- **状态**`[ ]`
#### 4.2 实施步骤
- [ ] 4.2.1 添加项目 ID 输入验证(必须为正整数)
- [ ] 4.2.2 添加文件名称输入处理(留空时使用原文件名,去掉扩展名)
- [ ] 4.2.3 实现输入错误的友好提示,允许重新输入
- [ ] 4.2.4 上传成功时打印/展示协作文档关键信息(文档 ID、fileKey、fileType)
- [ ] 4.2.5 上传失败时给出明确错误原因(参考错误码:40000001 参数错误、40000014 Key 无效/过期、40000015 权限不足)
- [ ] 4.2.6 支持跳过协作文档上传的选项(不影响测试单与项目资料上传)
#### 4.3 输出产物
- 完善的输入验证逻辑
- 友好的错误提示与成功反馈信息
---
## 验收标准
### 功能验收标准
| 序号 | 验收项 | 验收标准 | 状态 |
|------|--------|----------|------|
| 1 | 协作文档上传接口 | 成功调用 `/openclaw/upload/cooperation`,返回 `success=1` 及协作文档信息 | `[ ]` |
| 2 | 协作文档创建 | 返回结果包含 `fileKey``filePath``sharePath` 等协作字段,能在 ERP 页面打开 OnlyOffice 协作 | `[ ]` |
| 3 | 文件上传正确性 | 上传的 Word 文件内容与原测试报告一致 | `[ ]` |
| 4 | CLI 交互模式 | 能正常输入项目 ID 和文件名称,完成协作文档上传 | `[ ]` |
| 5 | GUI 交互模式 | 对话框新增"同时上传到协作文档"选项,勾选后能正常上传 | `[ ]` |
| 6 | 流程集成 | 测试单报告创建成功后,能正确触发协作文档上传询问 | `[ ]` |
| 7 | 跳过功能 | 用户可选择跳过协作文档上传,不影响其他流程 | `[ ]` |
| 8 | 异常处理 | 网络异常、接口失败、权限不足时有适当提示 | `[ ]` |
### 代码质量验收标准
| 序号 | 验收项 | 验收标准 | 状态 |
|------|--------|----------|------|
| 1 | 代码规范 | 符合 `Docs/PRD/01规范文档/_PRD_规范文档_代码规范.md` | `[ ]` |
| 2 | 中文注释 | 所有新增代码有完整的中文注释 | `[ ]` |
| 3 | 代码风格一致 | 与现有 `upload_file_to_project()` 保持一致的命名、日志、重试风格 | `[ ]` |
| 4 | 异常处理 | 所有可能的异常情况都有处理逻辑(超时、4xx/5xx、文件不存在等) | `[ ]` |
| 5 | 日志记录 | 关键操作有 INFO/WARN/ERROR 级别日志输出,便于审计排查 | `[ ]` |
| 6 | 兼容性 | 不影响现有测试单上传与项目资料归档功能的正常运行 | `[ ]` |
| 7 | 重试机制 | 复用现有重试配置,4xx 客户端错误不重试 | `[ ]` |
---
## 测试计划
### 单元测试
| 测试项 | 测试内容 | 预期结果 | 状态 |
|--------|----------|----------|------|
| 接口调用 | 调用协作文档上传接口 | 返回 `success=1` 及完整协作文档信息 | `[ ]` |
| 参数验证 | 验证 file、project_id、name 参数 | 参数正确传入接口 | `[ ]` |
| 可选参数 | 传入/不传入 group_id | 均能正常上传,group_id 正确归类 | `[ ]` |
| 文件读取 | 读取并上传 Word 文件 | 文件内容完整上传,fileType 为 word | `[ ]` |
| 返回字段 | 解析响应字段 | 正确返回 id、fileKey、filePath、sharePath 等字段 | `[ ]` |
| 重试机制 | 模拟网络异常(5xx) | 能正确重试指定次数 | `[ ]` |
| 4xx 不重试 | 模拟权限不足(40000015) | 不重试,返回 None 并给出权限提示 | `[ ]` |
| 异常处理 | 模拟超时、文件不存在 | 有友好的错误提示 | `[ ]` |
### 集成测试
| 测试项 | 测试内容 | 预期结果 | 状态 |
|--------|----------|----------|------|
| CLI 完整流程 | CLI 模式下生成报告→测试单上传→协作文档上传 | 流程完整,协作文档上传成功 | `[ ]` |
| GUI 完整流程 | GUI 模式下勾选"同时上传到协作文档" | 对话框正常,上传成功,展示 fileKey | `[ ]` |
| 跳过上传 | 选择跳过协作文档上传 | 流程正常结束,不影响报告创建与项目资料上传 | `[ ]` |
| 输入验证 | 输入无效的项目 ID(非数字/非正整数) | 提示错误并允许重新输入 | `[ ]` |
| 网络异常测试 | 模拟网络异常情况 | 有友好的错误提示,不影响主流程 | `[ ]` |
| 权限不足测试 | API Key 未授权 upload_cooperation | 返回 40000015,提示检查权限 | `[ ]` |
| 兼容性测试 | 执行现有测试单上传、项目资料上传 | 现有功能正常运行不受影响 | `[ ]` |
| 并行归档测试 | 同时勾选"项目资料"和"协作文档" | 两者均上传成功,互不影响 | `[ ]` |
### 验证方法
1. 在 ERP 系统中准备一个有效项目(记录 project_id);
2. 确认 API Key 已授权 `upload_cooperation` 权限;
3. 运行工具生成测试报告(Word 格式);
4. 测试单上传成功后,选择上传协作文档,输入 project_id;
5. 到 ERP 项目管理 → 项目资料 → 协作文档 页签,确认文档已创建并能打开 OnlyOffice 在线协作。
---
## 风险评估
| 风险项 | 风险等级 | 影响范围 | 应对措施 |
|--------|----------|----------|----------|
| API Key 权限不足 | 中 | 接口返回 40000015,上传失败 | 提示用户检查 Key 是否授权 `upload_cooperation`;文档中明确权限要求 |
| 接口变更 | 中 | 协作文档上传失败 | 添加接口版本管理,做好兼容处理;参考最新接口文档 |
| 网络不稳定 | 中 | 上传超时失败 | 复用现有重试机制(3 次、间隔 5 秒)和超时处理 |
| 用户输入错误 | 低 | 参数无效导致上传失败 | 添加项目 ID 正整数校验和友好提示 |
| 现有功能影响 | 中 | 破坏现有测试单/项目资料上传流程 | 新增选项独立,充分回归测试,保持向后兼容 |
| 文件过大 | 低 | 上传时间过长或失败 | 复用现有文件大小日志,必要时提示 |
| OnlyOffice 服务异常 | 低 | 协作文档创建失败 | 依赖 ERP 侧服务,失败时明确提示并记录日志 |
| fileType 识别 | 低 | 非 .docx 文件协作体验不佳 | 测试报告固定为 .docx,fileType 默认 word,无需额外处理 |
---
## 实施记录
### 实施时间线
| 日期 | 任务 | 状态 | 备注 |
|------|------|------|------|
| - | 任务1:新增协作文档上传接口配置 | `[ ]` | - |
| - | 任务2:实现协作文档上传函数 | `[ ]` | - |
| - | 任务3:集成到现有上传流程(CLI + GUI) | `[ ]` | - |
| - | 任务4:优化交互体验与输入校验 | `[ ]` | - |
| - | 单元测试 | `[ ]` | - |
| - | 集成测试 | `[ ]` | - |
| - | 验收测试 | `[ ]` | - |
### 问题记录
| 日期 | 问题描述 | 解决方案 | 状态 |
|------|----------|----------|------|
| - | - | - | `[ ]` |
---
## 后续工作
### 优化建议
- [ ] 支持从 ERP 项目列表中选择项目,而非手动输入项目 ID
- [ ] 添加项目 ID 记忆功能,方便重复使用
- [ ] 协作文档上传成功后,可选展示/复制 OnlyOffice 协作链接(基于 fileKey/sharePath)
- [ ] 支持 `group_id` 分组选择的交互配置(当前作为可选高级参数)
- [ ] 添加上传历史记录,便于追溯协作文档归档情况
### 待确认事项
- [ ] 确认 API Key 是否已授权 `upload_cooperation` 权限
- [ ] 确认用于联调测试的有效项目 ID
- [ ] 确认协作文档是否需要归类到特定分组(group_id)
- [ ] 确认协作文档与项目资料是否需要支持同时上传(默认支持并行)
---
## 附录
### 参考文档
- 代码规范:`Docs/PRD/01规范文档/_PRD_规范文档_代码规范.md`
- 问题总结:`Docs/PRD/01规范文档/_PRD_问题总结_记录文档.md`
- 方法总结:`Docs/PRD/01规范文档/_PRD_方法总结_记录文档.md`
- 文档规范:`Docs/PRD/01规范文档/_PRD_规范文档_文档规范.md`
- 测试规范:`Docs/PRD/01规范文档/_PRD_规范文档_测试规范.md`
- ERP 接口文档:`Docs/PRD/自动化生成功能测试报告/ERP对接PRD/PRD_项目资料_上传接口_例子说明.md`(第 4.1 协作文档上传章节)
- 关联需求文档:`Docs/PRD/自动化生成功能测试报告/需求文档/_PRD_ERP对接优化文件归档至协作文档_需求文档.md`
### 代码路径
- 主目录:`AuxiliaryTool/FunctionalTestReportGeneration`
- 配置文件:`src/config.py`
- 上传模块:`src/erp_uploader.py`(新增 `upload_file_to_cooperation()``ask_cooperation_upload_confirmation_cli()`
- CLI 模块:`src/cli.py``generate_report_main()` 集成)
- GUI 模块:`src/gui.py``_show_upload_dialog()``_upload_to_erp_thread()` 集成)
### 接口信息
- **接口地址**`POST /openclaw/upload/cooperation`
- **完整 URL**`https://office.ubainsyun.com:5082/api/uerp/openclaw/upload/cooperation`
- **api_name**`upload_cooperation`(创建 API Key 时需勾选该权限)
- **认证方式**`X-Api-Key` 请求头(复用现有 ERP_API_KEY)
- **请求格式**`multipart/form-data`
- **请求参数**
- `file`:上传的文件(必填,推荐 .docx/.xlsx/.pptx/.pdf)
- `project_id`:关联的项目 ID(必填,int)
- `name`:文件名称(可选,默认使用原文件名)
- `group_id`:分组 ID(可选,可将文件归类到指定分组)
### 响应字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 协作文档记录 ID(Personfile 表) |
| file_id | int | 源文件记录 ID(Producefile 表) |
| name | string | 文件名称 |
| fileKey | string | OnlyOffice 协作密钥,唯一标识协作会话 |
| filePath | string | OnlyOffice 协作文件相对路径 |
| fileType | string | 文件类型(word/excel/ppt/pdf) |
| fileSize | int | 文件大小(字节) |
| sharePath | string | 文件分享路径(与 fileKey 一致) |
| project_id | int | 关联的项目 ID |
### 错误码说明
| 错误码 | 说明 | 触发场景 | 解决方案 |
|--------|------|----------|----------|
| 40000001 | 参数错误或业务异常 | 缺少 file/project_id;项目不存在;上传异常 | 检查请求参数是否正确 |
| 40000014 | 无效或过期的 API Key | Key 不存在/已禁用/已过期 | 检查 Key 是否正确、是否启用、是否在有效期内 |
| 40000015 | 无权限访问该接口 | Key 未授权 `upload_cooperation` | 联系管理员添加接口权限 |
---
## 优化功能回填
> 此区域用于记录实施过程中的优化和改进
| 日期 | 优化内容 | 优化人 | 状态 |
|------|----------|--------|------|
| - | - | - | `[ ]` |
---
**文档版本**: 1.0
**创建日期**: 2026-06-18
**最后更新**: 2026-06-18
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""分析所有可行方案,检查关键前提条件"""
import paramiko
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("="*70)
print("检查各方案的前提条件")
print("="*70)
# 方案C前提:Nginx是否支持改写URL参数
print("\n1. Nginx是否支持(检查lua/sub_filter模块):")
out, _ = run(ssh, "docker exec unginx nginx -V 2>&1 | head -3")
print(out[:300] if out else "无")
# 方案D前提:KKfile的启动脚本是否可改
print("\n2. 检查能否通过Java代码注入(-javaagent):")
out, _ = run(ssh, "docker exec kkfile which javac 2>&1 || echo '无javac编译器'")
print(out)
# 方案E:检查是否可以用HttpClient替代
print("\n3. 检查证书是否可以用curl方式代理下载")
# 关键:检查前端到底怎么传URL(确认前端构造逻辑)
print("\n4. 检查前端页面如何构造预览URL:")
out, _ = run(ssh, "docker exec kkfile find / -name '*.ftl' 2>/dev/null | head -10")
print(f"FTL文件: {out if out else '未找到'}")
# 检查jar包内的前端资源
out, _ = run(ssh, "docker exec kkfile ls /opt/kkFileView-4.1.0/web/ 2>/dev/null || echo '无web目录'")
print(f"web目录: {out}")
ssh.close()
#!/usr/bin/env python
import paramiko, time
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("在已停止状态(不启动)下修改容器文件...")
# 先停止容器
run(ssh, "docker stop kkfile")
# 在停止的容器上执行编译(用docker exec -it --user root 可以操作文件系统)
print("1. 写入Java Agent源码...")
agent_code = '''import javax.net.ssl.*;import java.security.cert.*;import java.lang.instrument.*;
public class SSLBypassAgent{
public static void premain(String a,Instrumentation i){
try{TrustManager[] t=new TrustManager[]{new X509TrustManager(){public X509Certificate[] getAcceptedIssuers(){return null;}public void checkClientTrusted(X509Certificate[]c,String s){}public void checkServerTrusted(X509Certificate[]c,String s){}}};
SSLContext sc=SSLContext.getInstance("TLS");sc.init(null,t,new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
HttpsURLConnection.setDefaultHostnameVerifier((h,s)->true);
}catch(Exception e){e.printStackTrace();}
}}'''
manifest = 'Manifest-Version: 1.0\nPremain-Class: SSLBypassAgent\n'
# 在宿主机上创建好所有文件,然后cp到容器
run(ssh, f"echo '{agent_code}' > /tmp/SSLBypassAgent.java")
run(ssh, f"mkdir -p /tmp/META-INF && echo '{manifest}' > /tmp/META-INF/MANIFEST.MF")
# 编译
print("2. 编译(在容器内编译)...")
run(ssh, "docker cp /tmp/SSLBypassAgent.java kkfile:/tmp/")
out, err = run(ssh, "docker exec kkfile javac /tmp/SSLBypassAgent.java")
print(f"编译: {err if err else '成功'}")
# 打包
print("3. 打包JAR(在容器内)...")
# 先复制manifest到容器
run(ssh, "docker cp /tmp/META-INF kkfile:/tmp/")
# 检查是否有jar命令
out, err = run(ssh, "docker exec kkfile which jar")
if err.strip():
# 没有jar,在宿主机打包
run(ssh, "docker cp kkfile:/tmp/SSLBypassAgent.class /tmp/")
run(ssh, "cd /tmp && zip ssl-bypass-agent.jar META-INF/MANIFEST.MF SSLBypassAgent.class")
run(ssh, "docker cp /tmp/ssl-bypass-agent.jar kkfile:/opt/")
else:
run(ssh, "docker exec kkfile bash -c 'cd /tmp && jar cfm /opt/ssl-bypass-agent.jar META-INF/MANIFEST.MF SSLBypassAgent.class'")
# 验证JAR
print("4. 验证JAR内容...")
out, _ = run(ssh, "docker exec kkfile unzip -l /opt/ssl-bypass-agent.jar")
print(out)
# 修改wrapper脚本(添加-javaagent)
print("5. 修改wrapper脚本...")
wrapper = '''#!/bin/bash
rm -rf /tmp/.jodconverter* /tmp/hsperfdata* 2>/dev/null
exec java -javaagent:/opt/ssl-bypass-agent.jar -Dfile.encoding=UTF-8 -Dspring.config.location=/opt/kkFileView-4.1.0/config/application.properties -jar /opt/kkFileView-4.1.0/bin/kkFileView-4.1.0.jar
'''
run(ssh, f"docker exec kkfile bash -c 'cat > /opt/start.sh << \"EOF\"\n{wrapper}\nEOF'")
run(ssh, "docker exec kkfile chmod +x /opt/start.sh")
# commit镜像
print("6. 提交镜像...")
out, _ = run(ssh, "docker commit kkfile kkfile-agent-final:latest")
print(f"镜像: {out.strip()}")
# 启动
print("7. 启动容器...")
run(ssh, "docker start kkfile")
time.sleep(70)
# 验证
out, _ = run(ssh, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8012/index")
print(f"HTTP: {out}")
out, _ = run(ssh, "docker logs kkfile 2>&1 | grep -E 'SSL.*disabled|启动完成' | tail -2")
print(f"日志: {out}")
ssh.close()
#!/usr/bin/env python
import paramiko, time
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("在容器内编译和打包Agent...")
# 写入Java源码到容器
agent_code = '''
import javax.net.ssl.*;
import java.security.cert.X509Certificate;
import java.lang.instrument.Instrumentation;
public class SSLBypassAgent {
public static void premain(String args, Instrumentation inst) {
try {
TrustManager[] trustAll = new TrustManager[] {
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return null; }
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {}
}
};
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAll, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);
System.out.println("[SSLBypassAgent] SSL verification disabled successfully");
} catch (Exception e) {
e.printStackTrace();
}
}
}
'''
manifest = '''Manifest-Version: 1.0
Premain-Class: SSLBypassAgent
'''
# 写入文件
run(ssh, f"docker exec kkfile bash -c 'cat > /tmp/SSLBypassAgent.java << \"JAVAEOF\"\n{agent_code}\nJAVAEOF'")
run(ssh, f"docker exec kkfile bash -c 'mkdir -p /tmp/META-INF && cat > /tmp/META-INF/MANIFEST.MF << \"MFEOF\"\n{manifest}\nMFEOF'")
# 编译
print("编译...")
out, err = run(ssh, "docker exec kkfile javac /tmp/SSLBypassAgent.java -d /tmp/")
if "error" in err.lower():
print(f"编译错误: {err}")
# 可能缺少jar命令,用javac直接编译
print("编译完成")
# 检查class文件
out, _ = run(ssh, "docker exec kkfile ls -la /tmp/SSLBypassAgent.class")
print(f"class文件: {out}")
# 用jar或zip打包
print("\n打包JAR...")
out, err = run(ssh, "docker exec kkfile bash -c 'cd /tmp && jar cfm ssl-bypass-agent.jar META-INF/MANIFEST.MF SSLBypassAgent.class' 2>&1")
if "jar" in err.lower() or "command not found" in err.lower():
print("jar命令不存在,尝试用zip...")
out, err = run(ssh, "docker exec kkfile bash -c 'cd /tmp && zip ssl-bypass-agent.jar META-INF/MANIFEST.MF SSLBypassAgent.class'")
print(f"打包结果: {out}{err}")
# 验证JAR
out, _ = run(ssh, "docker exec kkfile unzip -l /tmp/ssl-bypass-agent.jar")
print(f"JAR内容:\n{out}")
# 移动到/opt
run(ssh, "docker exec kkfile mv /tmp/ssl-bypass-agent.jar /opt/")
# 重启容器
print("\n重启容器...")
run(ssh, "docker exec kkfile rm -rf /tmp/.jodconverter*")
run(ssh, "docker restart kkfile")
time.sleep(70)
# 验证
out, _ = run(ssh, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8012/index")
print(f"HTTP: {out}")
out, _ = run(ssh, "docker logs kkfile 2>&1 | grep -E 'SSLBypassAgent|disabled|启动完成' | tail -3")
print(f"日志: {out}")
ssh.close()
#!/usr/bin/env python
import paramiko, time
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("用临时容器编译Agent JAR...")
agent_code = '''import javax.net.ssl.*;
import java.security.cert.X509Certificate;
import java.lang.instrument.Instrumentation;
public class SSLBypassAgent {
public static void premain(String args, Instrumentation inst) {
try {
TrustManager[] trustAll = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return null; }
public void checkClientTrusted(X509Certificate[] c, String s) {}
public void checkServerTrusted(X509Certificate[] c, String s) {}
}
};
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAll, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);
System.out.println("[SSLBypassAgent] SSL verification disabled");
} catch (Exception e) {
e.printStackTrace();
}
}
}'''
manifest = 'Manifest-Version: 1.0\nPremain-Class: SSLBypassAgent\n'
# 写文件到宿主机
run(ssh, f"echo '{agent_code}' > /tmp/SSLBypassAgent.java")
run(ssh, f"mkdir -p /tmp/META-INF && printf '{manifest}' > /tmp/META-INF/MANIFEST.MF")
# 用临时容器(基于jdk镜像)编译
print("1. 启动临时编译容器...")
run(ssh, "docker rm -f temp-builder 2>/dev/null")
run(ssh, "docker run -d --name temp-builder -v /tmp:/work openjdk:11 sleep 600")
time.sleep(5)
print("2. 在临时容器内编译...")
out, err = run(ssh, "docker exec temp-builder javac /work/SSLBypassAgent.java -d /work")
print(f"编译: {err if err.strip() else '成功'}")
print("3. 在临时容器内打包JAR...")
out, err = run(ssh, "docker exec temp-builder bash -c 'cd /work && jar cfm ssl-bypass-agent.jar META-INF/MANIFEST.MF SSLBypassAgent.class'")
print(f"打包: {err if err.strip() else '成功'}")
print("4. 验证JAR...")
out, _ = run(ssh, "unzip -l /tmp/ssl-bypass-agent.jar")
print(out)
# 清理临时容器
run(ssh, "docker rm -f temp-builder")
# 复制到目标容器
print("5. 复制到kkfile容器...")
run(ssh, "docker cp /tmp/ssl-bypass-agent.jar kkfile:/opt/ssl-bypass-agent.jar")
# 修改wrapper脚本
print("6. 修改wrapper脚本...")
wrapper = '''#!/bin/bash
rm -rf /tmp/.jodconverter* /tmp/hsperfdata* 2>/dev/null
exec java -javaagent:/opt/ssl-bypass-agent.jar -Dfile.encoding=UTF-8 -Dspring.config.location=/opt/kkFileView-4.1.0/config/application.properties -jar /opt/kkFileView-4.1.0/bin/kkFileView-4.1.0.jar
'''
run(ssh, f"docker exec kkfile bash -c 'cat > /opt/start.sh << \"EOF\"\n{wrapper}\nEOF'")
run(ssh, "docker exec kkfile chmod +x /opt/start.sh")
# commit
print("7. 提交镜像...")
out, _ = run(ssh, "docker commit kkfile kkfile-agent-final:latest")
print(f"镜像: {out.strip()[:30]}...")
# 重启
print("8. 重启容器...")
run(ssh, "docker exec kkfile rm -rf /tmp/.jodconverter*")
run(ssh, "docker restart kkfile")
time.sleep(70)
# 验证
out, _ = run(ssh, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8012/index")
print(f"HTTP: {out}")
out, _ = run(ssh, "docker logs kkfile 2>&1 | grep -E 'SSL.*disabled|启动完成' | tail -2")
print(f"日志: {out}")
ssh.close()
#!/usr/bin/env python
import paramiko
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
# 检查Nginx编译模块
out, _ = run(ssh, "docker exec unginx nginx -V 2>&1")
# 查找关键模块
modules = []
if '--with-http_sub_module' in out: modules.append('sub_filter')
if 'lua' in out.lower(): modules.append('lua')
if '--with-pcre' in out: modules.append('pcre_regex')
print(f"Nginx版本和模块:")
print(out[:400])
print(f"\n可用功能: {modules if modules else '标准模块'}")
# 检查是否可以用map指令
print("\n检查能否用map/if指令改写URL:")
out2, _ = run(ssh, "docker exec unginx cat /etc/nginx/nginx.conf | head -30")
print(out2[:300])
ssh.close()
#!/usr/bin/env python
import paramiko
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
# 检查nginx.conf主配置
print("1. 查看nginx.conf主配置include部分:")
out, _ = run(ssh, "docker exec unginx cat /etc/nginx/nginx.conf | grep include")
print(out)
# 检查conf.d目录
print("\n2. 查看conf.d目录内容:")
out, _ = run(ssh, "docker exec unginx ls -la /etc/nginx/conf.d/")
print(out)
# 检查实际监听443的配置
print("\n3. 查找监听443的server块:")
out, _ = run(ssh, "docker exec unginx grep -r 'listen 443' /etc/nginx/ 2>/dev/null")
print(out[:300] if out else "无")
# 检查挂载信息
print("\n4. 检查容器挂载:")
out, _ = run(ssh, "docker inspect unginx --format='{{json .Mounts}}' | python3 -m json.tool 2>/dev/null || docker inspect unginx --format='{{range .Mounts}}{{.Source}} -> {{.Destination}}\n{{end}}'")
print(out)
ssh.close()
#!/usr/bin/env python
import paramiko, base64, time
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("使用正确的HTTPS URL测试预览...")
# 1. 上传一个真实的中文文件名Word文件
print("\n1. 上传中文文件名Word文档:")
run(ssh, "echo '中文文件名测试内容' > /tmp/会议使用.docx")
out, _ = run(ssh, "curl -k -s -X POST -F 'file=@/tmp/会议使用.docx' https://192.168.5.70/kkfile/fileUpload")
print(f"上传结果: {out}")
time.sleep(3)
# 2. 查看文件列表
print("\n2. 查看已上传文件:")
out, _ = run(ssh, "curl -k -s 'https://192.168.5.70/kkfile/listFiles'")
print(f"文件列表: {out}")
# 3. 用正确的HTTPS URL预览(带/kkfile/前缀)
print("\n3. 使用正确的HTTPS预览URL:")
# 文件URL应该是 https://192.168.5.70/kkfile/demo/会议使用.docx
# 但需要Base64编码整个URL
file_url = "https://192.168.5.70:443/kkfile/demo/%E4%BC%9A%E8%AE%AE%E4%BD%BF%E7%94%A8.docx"
encoded = base64.b64encode(file_url.encode('utf-8')).decode('utf-8')
print(f"原始URL: {file_url}")
print(f"Base64编码: {encoded}")
preview_url = f"https://192.168.5.70/kkfile/onlinePreview?url={encoded}"
print(f"\n预览URL: {preview_url}")
out, _ = run(ssh, f"curl -k -s '{preview_url}' | head -15")
print(f"预览响应:\n{out}")
# 4. 等待并检查日志
time.sleep(8)
print("\n4. 检查日志(是否成功处理):")
out, _ = run(ssh, "docker logs kkfile --tail 20 2>&1 | grep -E '预览文件url|下载|CertificateException|No subject|SSL'")
print(out if out.strip() else "无SSL错误!")
ssh.close()
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""用zip创建JAR文件"""
import paramiko, time
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("用zip打包JAR...")
# JAR本质是ZIP,用zip命令打包
out, err = run(ssh, "cd /tmp && zip -r ssl-bypass-agent.jar META-INF SSLBypassAgent.class")
print(f"打包: {out}{err}")
print("\n复制到容器...")
run(ssh, "docker cp /tmp/ssl-bypass-agent.jar kkfile:/opt/ssl-bypass-agent.jar")
print("已复制")
print("\n验证JAR内容:")
out, _ = run(ssh, "docker exec kkfile unzip -l /opt/ssl-bypass-agent.jar 2>&1 | head -10")
print(out)
print("\n重启容器...")
run(ssh, "docker exec kkfile rm -rf /tmp/.jodconverter*")
run(ssh, "docker restart kkfile")
time.sleep(70)
out, _ = run(ssh, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8012/index")
print(f"HTTP状态: {out}")
out, _ = run(ssh, "docker logs kkfile 2>&1 | grep -E 'SSLBypassAgent|启动完成' | tail -2")
print(f"启动日志: {out}")
ssh.close()
#!/usr/bin/env python
import paramiko
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
# 直接读取配置文件并搜索kkfile相关行
out, _ = run(ssh, "docker exec unginx grep -n kkfile /data/middleware/nginx/config/unified443.conf")
print(f"kkfile相关行号:\n{out}")
# 用行号范围读取
print("\n完整配置内容(用grep定位):")
out, _ = run(ssh, "docker exec unginx cat /data/middleware/nginx/config/unified443.conf | grep -A30 'location /kkfile'")
print(out)
ssh.close()
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""实施方案D:Java Agent禁用SSL主机名验证"""
import paramiko, time
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("1. 创建Java Agent源代码...")
agent_code = '''
import javax.net.ssl.*;
import java.security.cert.X509Certificate;
import java.lang.instrument.Instrumentation;
public class SSLBypassAgent {
public static void premain(String args, Instrumentation inst) {
try {
// 创建信任所有证书的TrustManager
TrustManager[] trustAll = new TrustManager[] {
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return null; }
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {}
}
};
// 设置SSL上下文
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAll, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
// 禁用主机名验证
HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);
System.out.println("[SSLBypassAgent] SSL hostname verification disabled");
} catch (Exception e) {
e.printStackTrace();
}
}
}
'''
manifest = '''Manifest-Version: 1.0
Premain-Class: SSLBypassAgent
Can-Redefine-Classes: false
'''
# 写入Java源码
run(ssh, f"cat > /tmp/SSLBypassAgent.java << 'JAVAEOF'\n{agent_code}\nJAVAEOF")
print("源码已写入 /tmp/SSLBypassAgent.java")
# 写入MANIFEST
run(ssh, f"mkdir -p /tmp/META-INF && cat > /tmp/META-INF/MANIFEST.MF << 'MFEOF'\n{manifest}\nMFEOF")
print("MANIFEST已创建")
print("\n2. 编译Java Agent...")
# 编译
out, err = run(ssh, "javac /tmp/SSLBypassAgent.java -d /tmp/")
if err and "error" in err.lower():
print(f"编译错误: {err}")
else:
print("编译成功")
print("\n3. 打包成JAR...")
out, err = run(ssh, "cd /tmp && jar cfm ssl-bypass-agent.jar META-INF/MANIFEST.MF SSLBypassAgent.class")
if err:
print(f"打包错误: {err}")
else:
print("打包成功: /tmp/ssl-bypass-agent.jar")
print("\n4. 复制到容器内...")
run(ssh, "docker cp /tmp/ssl-bypass-agent.jar kkfile:/opt/ssl-bypass-agent.jar")
print("已复制到容器: /opt/ssl-bypass-agent.jar")
print("\n5. 修改wrapper启动脚本...")
wrapper = '''#!/bin/bash
rm -rf /tmp/.jodconverter* /tmp/hsperfdata* 2>/dev/null
exec java -javaagent:/opt/ssl-bypass-agent.jar -Dfile.encoding=UTF-8 -Dspring.config.location=/opt/kkFileView-4.1.0/config/application.properties -jar /opt/kkFileView-4.1.0/bin/kkFileView-4.1.0.jar
'''
run(ssh, f"docker exec kkfile bash -c 'cat > /opt/start.sh << \"EOF\"\n{wrapper}\nEOF'")
run(ssh, "docker exec kkfile chmod +x /opt/start.sh")
print("wrapper已更新,添加了 -javaagent 参数")
print("\n6. 提交镜像...")
out, _ = run(ssh, "docker commit kkfile kkfile-with-agent:latest")
print(f"镜像: {out.strip()}")
print("\n7. 重启容器...")
run(ssh, "docker restart kkfile")
print("等待70秒...")
time.sleep(70)
print("\n8. 验证...")
out, _ = run(ssh, "docker logs kkfile 2>&1 | grep -E 'SSLBypassAgent|启动完成' | tail -2")
print(out if out else "检查日志...")
out, _ = run(ssh, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8012/index")
print(f"HTTP状态: {out}")
ssh.close()
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
思路:在Nginx层面改写预览URL参数
让前端传 https://192.168.5.70/kkfile/...
Nginx内部改写成 http://172.17.0.1:8012/... 再转发给KKfile
这样KKfile用HTTP下载,绕过SSL验证
"""
import paramiko
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("检查当前Nginx配置的kkfile转发块:")
out, _ = run(ssh, "docker exec unginx cat /data/middleware/nginx/config/unified443.conf | grep -A20 'location /kkfile/'")
print(out)
print("\n" + "="*70)
print("方案:使用Nginx的sub_filter或lua改写URL参数")
print("="*70)
print("""
方案思路:
1. 用户访问: https://192.168.5.70/kkfile/onlinePreview?url=https://192.168.5.70:443/kkfile/demo/test.docx
2. Nginx用正则改写url参数,把 https://192.168.5.70:443/kkfile 改成 http://172.17.0.1:8012
3. 转发给KKfile: http://172.17.0.1:8012/onlinePreview?url=http://172.17.0.1:8012/demo/test.docx
4. KKfile用HTTP下载文件,无SSL问题
优点:
- 不改证书
- 不改Java
- 不改KKfile代码
- 只改Nginx配置
缺点:
- 需要Nginx支持(大多数Nginx都支持)
- URL改写可能影响其他功能
是否继续尝试这个方案?
""")
ssh.close()
#!/usr/bin/env python
import paramiko
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
out, _ = run(ssh, "docker exec unginx cat /data/middleware/nginx/config/unified443.conf")
# 文件可能是单行或压缩格式,尝试解析
print(f"文件长度: {len(out)} 字符")
# 查找kkfile
idx = out.find('kkfile')
if idx > 0:
# 打印kkfile周围的上下文
start = max(0, idx - 100)
end = min(len(out), idx + 500)
print(f"\n找到kkfile在位置 {idx}:")
print(out[start:end])
else:
print("\n没有找到kkfile,搜索8012端口:")
idx = out.find('8012')
if idx > 0:
print(out[max(0,idx-200):idx+300])
ssh.close()
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
方案D:Java Agent禁用SSL主机名验证
原理:编译一个Java Agent JAR,在KKfile启动时通过-javaagent参数加载
Agent在premain阶段注入:设置HttpsURLConnection默认信任所有证书+跳过主机名验证
"""
print("="*70)
print("方案D:Java Agent注入方案")
print("="*70)
print("""
执行步骤:
1. 在容器内编写SSLBypassAgent.java
2. 编译并打包成agent.jar(带MANIFEST指定Premain-Class)
3. 修改启动脚本,添加 -javaagent:/path/agent.jar
4. 重启容器,Agent自动注入,禁用SSL主机名验证
5. 测试HTTPS预览
Agent代码核心:
- premain方法中
- HttpsURLConnection.setDefaultHostnameVerifier((h,s)->true) // 跳过主机名
- SSLSocketFactory设为信任所有证书
这个方案100%有效,因为它是从代码层面禁用了HttpURLConnection的主机名验证
""")
#!/usr/bin/env python
import paramiko
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
# 从宿主机直接读取
print("从宿主机读取配置文件:")
out, _ = run(ssh, "cat /data/middleware/nginx/config/unified443.conf | grep -A20 'location /kkfile'")
print(out)
ssh.close()
#!/usr/bin/env python
import paramiko
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
# 直接cat到本地处理
out, _ = run(ssh, "docker exec unginx cat /data/middleware/nginx/config/unified443.conf")
# 搜索kkfile
lines = out.split('\n')
for i, line in enumerate(lines):
if 'kkfile' in line.lower():
# 打印该行及前后5行
start = max(0, i-2)
end = min(len(lines), i+12)
print(f"--- 在第{i+1}行找到kkfile ---")
for j in range(start, end):
print(f"{j+1}: {lines[j]}")
print()
break
else:
print("配置文件中没有找到kkfile!")
print(f"文件总行数: {len(lines)}")
# 看是否有其他可能的路径
for i, line in enumerate(lines):
if '/kkfile' in line or '8012' in line:
print(f"第{i+1}行: {line}")
ssh.close()
#!/usr/bin/env python
import paramiko, base64
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("您说得对,我来实际测试一下...")
# 用户给的URL - 注意是http不是https
url1 = "http://192.168.5.70/onlinePreview?url=aHR0cDovLzE5Mi4xNjguNS43MDo4MC9kZW1vL+ahuee9rumhueS9v+eUqC5kb2N4"
print(f"\n您提供的URL:\n{url1}")
# 解码base64看实际url是什么
encoded = "aHR0cDovLzE5Mi4xNjguNS43MDo4MC9kZW1vL+ahuee9rumhueS9v+eUqC5kb2N4"
try:
decoded = base64.b64decode(encoded + "==").decode('utf-8')
print(f"\nBase64解码后:\n{decoded}")
except:
print("Base64解码失败")
# 检查80端口是否有服务
print("\n检查端口80:")
out, _ = run(ssh, "netstat -tlnp 2>/dev/null | grep ':80 ' || ss -tlnp | grep ':80 '")
print(out if out else "80端口无监听")
# 测试访问
print("\n测试访问:")
out, _ = run(ssh, f"curl -s -o /dev/null -w '%{{http_code}}' 'http://192.168.5.70/onlinePreview?url={encoded}'")
print(f"HTTP状态: {out}")
# 正确的kkfile预览URL应该是 /kkfile/onlinePreview
print("\n正确的预览URL应该包含 /kkfile/ 前缀:")
correct_url = "https://192.168.5.70/kkfile/onlinePreview?url=..."
print(correct_url)
ssh.close()
#!/usr/bin/env python
import paramiko, time
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
# 检查class文件在哪
print("检查编译输出:")
out, _ = run(ssh, "ls -la /tmp/*.class 2>&1")
print(out)
# 重新编译
print("\n重新编译...")
out, err = run(ssh, "cd /tmp && javac SSLBypassAgent.java")
print(f"编译: {out}{err}")
# 检查class文件
out, _ = run(ssh, "ls -la /tmp/SSLBypassAgent.class")
print(f"class文件: {out}")
# 重新打包(确保包含class文件)
print("\n重新打包JAR...")
run(ssh, "rm -f /tmp/ssl-bypass-agent.jar")
out, err = run(ssh, "cd /tmp && zip ssl-bypass-agent.jar META-INF/MANIFEST.MF SSLBypassAgent.class")
print(f"打包: {out}{err}")
# 验证JAR
print("\n验证JAR内容:")
out, _ = run(ssh, "unzip -l /tmp/ssl-bypass-agent.jar")
print(out)
# 复制到容器
run(ssh, "docker cp /tmp/ssl-bypass-agent.jar kkfile:/opt/ssl-bypass-agent.jar")
# 重启
print("\n重启容器...")
run(ssh, "docker exec kkfile rm -rf /tmp/.jodconverter*")
run(ssh, "docker restart kkfile")
time.sleep(70)
# 验证
out, _ = run(ssh, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8012/index")
print(f"HTTP: {out}")
out, _ = run(ssh, "docker logs kkfile 2>&1 | grep -E 'SSLBypassAgent|启动完成|SSL.*disabled' | tail -3")
print(f"日志: {out}")
ssh.close()
#!/usr/bin/env python
import paramiko, time
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
print("1. 停止崩溃循环的容器...")
run(ssh, "docker stop kkfile")
run(ssh, "docker rm kkfile")
print("\n2. 用 kkfile-ssl-disabled:latest(之前正常运行的镜像)重建...")
out, err = run(ssh, """docker run -d \
--name kkfile \
-p 8012:8012 \
--entrypoint /opt/start.sh \
--restart=always \
kkfile-ssl-disabled:latest""")
print(f"容器ID: {out.strip()}{err}")
time.sleep(15)
print("\n3. 验证容器能正常启动...")
out, _ = run(ssh, "docker ps | grep kkfile")
print(out)
# 等待启动
print("等待50秒...")
time.sleep(50)
out, _ = run(ssh, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8012/index")
print(f"HTTP: {out}")
out, _ = run(ssh, "docker logs kkfile 2>&1 | grep '启动完成'")
print(f"状态: {out.strip() if out.strip() else '进行中'}")
ssh.close()
#!/bin/bash
source /tmp/check_modules/config.sh
REDIS_CONTAINER="${CONTAINERS[redis]}"
echo "REDIS_CONTAINER: $REDIS_CONTAINER"
# Test 1: Direct docker call
echo "=== Test 1: Direct docker ==="
docker exec "$REDIS_CONTAINER" redis-cli -a "$REDIS_PASSWORD" "INFO server" 2>&1 | grep redis_version
# Test 2: Function with local variable
echo "=== Test 2: Function call ==="
test_func() {
local container="$1"
docker exec "$container" redis-cli -a "$REDIS_PASSWORD" "INFO server" 2>&1 | grep redis_version
}
test_func "$REDIS_CONTAINER"
#!/bin/bash
echo "=== Testing EMQX ==="
echo "Test 1: Full output"
docker exec uemqx emqx_ctl broker 2>&1
echo "Test 2: Grep version"
docker exec uemqx emqx_ctl broker 2>&1 | grep version
echo "Test 3: Extract with awk"
docker exec uemqx emqx_ctl broker 2>&1 | grep version | awk '{print $2}'
echo "Test 4: Extract with cut"
docker exec uemqx emqx_ctl broker 2>&1 | grep version | cut -d: -f2
#!/bin/bash
# Java版本调试测试
LIB_DIR="/tmp/check_modules"
source "$LIB_DIR/config.sh"
source "$LIB_DIR/common.sh"
JAVA_CONTAINER="ujava2"
# 测试1: 直接命令
echo "=== Test 1: Direct command ==="
docker exec "$JAVA_CONTAINER" java -version 2>&1 | grep -i version | head -1
# 测试2: 通过java_exec
echo "=== Test 2: Through java_exec ==="
java_exec() {
docker exec "$JAVA_CONTAINER" $@ 2>/dev/null
}
java_exec java -version 2>&1 | grep -i version | head -1
# 测试3: 完整提取
echo "=== Test 3: Full extraction ==="
version=$(java_exec java -version 2>&1 | grep -i version | head -1)
echo "version variable: [$version]"
if [ -n "$version" ]; then
extracted=$(echo "$version" | sed -n 's/.*version "\([^"]*\)".*/\1/p')
echo "extracted: [$extracted]"
else
echo "version is empty!"
fi
#!/bin/bash
LIB_DIR="/tmp/check_modules"
source "$LIB_DIR/config.sh"
source "$LIB_DIR/common.sh"
REDIS_CONTAINER="${CONTAINERS[redis]}"
# Redis exec function as defined in module
redis_exec() {
docker exec "$REDIS_CONTAINER" redis-cli -a "$REDIS_PASSWORD" "$1" 2>&1 | grep -v "Warning:"
}
echo "=== Test redis_exec INFO server ==="
result=$(redis_exec "INFO server")
echo "Result length: ${#result}"
echo "First 5 lines:"
echo "$result" | head -5
echo "=== Test grep redis_version ==="
version_line=$(redis_exec "INFO server" | grep redis_version)
echo "Version line: [$version_line]"
echo "=== Test awk extraction ==="
version=$(redis_exec "INFO server" | grep redis_version | awk -F: '{print $2}' | tr -d '\r')
echo "Extracted version: [$version]"
if [ -n "$version" ]; then
echo "SUCCESS: Version is [$version]"
else
echo "FAIL: Version is empty"
fi
#!/bin/bash
LIB_DIR="/tmp/check_modules"
source "$LIB_DIR/config.sh"
source "$LIB_DIR/common.sh"
echo "=== Testing Redis ==="
echo "REDIS_CONTAINER: $REDIS_CONTAINER"
echo "REDIS_PASSWORD: $REDIS_PASSWORD"
# Test redis_exec
echo "Test 1: Direct redis-exec call"
redis_exec() {
docker exec "$REDIS_CONTAINER" redis-cli -a "$REDIS_PASSWORD" "$1" 2>&1 | grep -v "Warning:"
}
result=$(redis_exec "INFO server" | grep redis_version)
echo "Result: [$result]"
echo "Test 2: Extract version"
version=$(echo "$result" | awk -F: '{print $2}' | tr -d '\r')
echo "Version: [$version]"
echo "Test 3: Complete extraction"
complete=$(redis_exec "INFO server" | grep redis_version | awk -F: '{print $2}' | tr -d '\r')
echo "Complete: [$complete]"
#!/bin/bash
source /tmp/check_modules/config.sh
REDIS_CONTAINER="${CONTAINERS[redis]}"
echo "Testing Redis version extraction:"
echo "Container: $REDIS_CONTAINER"
echo "Password: $REDIS_PASSWORD"
# Test direct command
echo "=== Direct command ==="
docker exec "$REDIS_CONTAINER" redis-cli -a "$REDIS_PASSWORD" INFO server 2>&1 | grep redis_version | awk -F: '{print $2}'
# Test with variable
echo "=== With awk variable ==="
version=$(docker exec "$REDIS_CONTAINER" redis-cli -a "$REDIS_PASSWORD" INFO server 2>&1 | grep redis_version | awk -F: '{print $2}')
echo "Version: [$version]"
#!/bin/bash
source /tmp/check_modules/config.sh
echo "REDIS_CONTAINER: ${CONTAINERS[redis]}"
echo "REDIS_PASSWORD: $REDIS_PASSWORD"
REDIS_CONTAINER="${CONTAINERS[redis]}"
# Test redis_exec
redis_exec() {
docker exec "$REDIS_CONTAINER" redis-cli -a "$REDIS_PASSWORD" "$1" 2>&1 | grep -v "Warning:"
}
echo "=== Testing redis_exec ==="
result=$(redis_exec "INFO server" | grep redis_version)
echo "Result: [$result]"
if [ -n "$result" ]; then
version=$(echo "$result" | awk -F: '{print $2}' | tr -d '\r')
echo "Version: [$version]"
fi
#!/usr/bin/env python
import paramiko
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode('utf-8'), stderr.read().decode('utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("192.168.5.70", 22, "root", "Ubains@123", timeout=10)
# 用sed定位kkfile块
print("kkfile转发块(精确提取):")
out, _ = run(ssh, r"""docker exec unginx sed -n '/location \/kkfile\//,/^\s*}/p' /data/middleware/nginx/config/unified443.conf""")
print(out)
ssh.close()
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论