dukuisong 4 روز پیش
کامیت
ca00aa8ee0

+ 0 - 0
core/__init__.py


+ 266 - 0
core/api_app.py

@@ -0,0 +1,266 @@
+import ctypes
+import logging
+import os
+import shutil
+import site
+import sys
+
+import pythoncom
+import pywintypes
+import win32com.client
+from flask import Flask, jsonify, request, current_app
+from flask_cors import CORS
+from win10toast import ToastNotifier
+
+from core.wps_handle import is_file_open_in_wps, bring_wps_window_to_front
+from tools.check import detect_oss_file_changes, detect_local_file_changes
+from tools.config import get_config, file_type_map
+from tools.file_manager import get_file_md5
+from tools.logger_handle import logger
+from tools.oss_client import oss_handle
+from tools.serve_client import ServerClient
+
+toaster = ToastNotifier()
+
+app = Flask(__name__)
+
+
+class InterceptHandler(logging.Handler):
+    def emit(self, record):
+        # 将标准日志记录转为 loguru
+        try:
+            level = logger.level(record.levelname).name
+        except ValueError:
+            level = record.levelno
+        logger.opt(depth=6, exception=record.exc_info).log(level, record.getMessage())
+
+
+logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO)
+for name in ("flask", "werkzeug", "watchdog"):
+    logging.getLogger(name).handlers = [InterceptHandler()]
+    logging.getLogger(name).propagate = False
+CORS(app)
+
+
+@logger.catch()
+def open_file_by_wps(file_path):
+    ext = file_path.split(".")[-1]
+    shutil.rmtree(win32com.client.gencache.GetGeneratePath())
+    if ext not in file_type_map:
+        return jsonify({'code': 3001, 'msg': '不支持该类型的文件'})
+
+    if not os.path.exists(file_path):
+        return jsonify({'code': 3006, 'msg': '文件不存在'})
+
+    pythoncom.CoInitialize()
+    try:
+        if ext in ['csv', 'xlsx', 'xls']:
+            # wps = win32com.client.gencache.EnsureDispatch(file_type_map['xlsx'][0])
+            wps = win32com.client.Dispatch(file_type_map[ext][0])
+
+            wps.Visible = True
+            getattr(wps, file_type_map[ext][1]).Open(os.path.abspath(file_path))
+        else:
+            wps = win32com.client.Dispatch(file_type_map[ext][0])
+            wps.Visible = True
+            getattr(wps, file_type_map[ext][1]).open(os.path.abspath(file_path))
+    except pywintypes.com_error as e:
+        logger.exception(e)
+
+    bring_wps_window_to_front(file_path)
+    pythoncom.CoUninitialize()
+
+    return jsonify({'code': 1000, 'msg': '操作完成'})
+
+
+@logger.catch()
+@app.route('/download_and_open_file', methods=['POST'])
+def download_and_open_with_wps():
+    '''
+    1000: 操作完成
+    2001: 检测到文档正在被编辑,无法下载
+    2002: 检测到远程文件已经更新本地文件也发生改动,终止操作
+    3001: 不支持该类型的文件
+    3002: 下载文件失败
+    :return:
+    '''
+    file_id = request.get_json()['file_id']
+    # 判断是否为exe模式执行
+
+    file_info = app.config['serve_client'].get_file_info(file_id)
+    if not file_info:
+        return jsonify({'code': 3006, 'msg': '文件不存在'})
+
+    local_file = os.path.join(current_app.config['work_path'], file_info['filePath'].replace('/', '_'))
+
+    # 判断本地是否存在已下载的同名文件
+    if not os.path.exists(local_file) or not os.path.exists(local_file + '.metadata.json'):
+        app.config['serve_client'].download_file(file_info,
+                                                 current_app.config['work_path'])
+
+        return open_file_by_wps(local_file)
+
+    # 判断本地文件是否被打开
+    if is_file_open_in_wps(local_file):
+        bring_wps_window_to_front(local_file)
+        return jsonify({'code': 1000, 'msg': '检测到文件已被打开'})
+
+    # 判断本地文件和线上文件是否一致
+    local_file_metadata = oss_handle.load_metadata(local_file)
+    if local_file_metadata['update_time'] == file_info['updateTime']:
+        return open_file_by_wps(local_file)
+
+    if local_file_metadata['md5'] == app.config['serve_client'].get_file_md5(local_file):
+        app.config['serve_client'].download_file(file_info, current_app.config['work_path'])
+
+        return open_file_by_wps(local_file)
+
+    return jsonify({'code': 2002, 'msg': '检测到远程文件已经更新本地文件也发生改动,终止操作'})
+
+
+@logger.catch()
+@app.route('/upload_local_file', methods=['POST'])
+def upload_local_file():
+    file_name = request.get_json()['file_name']
+    if is_file_open_in_wps(os.path.join(current_app.config['work_path'], file_name)):
+        return jsonify({'code': 3004, 'msg': '操作失败,文件已被打开无法操作'})
+    metadata = app.config['serve_client'].load_metadata(os.path.join(current_app.config['work_path'], file_name))
+    code = app.config['serve_client'].upload_file(os.path.join(current_app.config['work_path'], file_name))
+    if not code:
+        return jsonify({'code': 3003, 'msg': '操作失败,上传文件失败'})
+
+    try:
+        os.remove(os.path.join(current_app.config['work_path'], file_name))
+        os.remove(os.path.join(current_app.config['work_path'], file_name + '.metadata'))
+
+        file_info = app.config['serve_client'].get_file_info(metadata['file_id'])
+        if not file_info:
+            return jsonify({'code': 3006, 'msg': '文件不存在'})
+
+        app.config['serve_client'].download_file(file_info, current_app.config['work_path'])
+    except Exception as e:
+        logger.exception(e)
+        return jsonify({'code': 1000, 'msg': '文件上传成功,本地文件清除失败'})
+
+    return jsonify({'code': 1000, 'msg': '操作成功'})
+
+
+@logger.catch()
+@app.route('/update_local_file', methods=['POST'])
+def update_local_file():
+    file_name = request.get_json()['file_name']
+    if is_file_open_in_wps(os.path.join(current_app.config['work_path'], file_name)):
+        return jsonify({'code': 3004, 'msg': '操作失败,文件已被打开无法操作'})
+
+    try:
+        metadata = oss_handle.load_metadata(os.path.join(current_app.config['work_path'], file_name))
+
+        os.remove(os.path.join(current_app.config['work_path'], file_name))
+        os.remove(os.path.join(current_app.config['work_path'], file_name + '.metadata'))
+    except Exception as e:
+        logger.exception(e)
+        return jsonify({'code': 3005, 'msg': '操作失败,无权限操作本地文件'})
+
+    try:
+        file_info = app.config['serve_client'].get_file_info(metadata['file_id'])
+        if not app.config['serve_client'].download_file(file_info, current_app.config['work_path']):
+            return jsonify({'code': 3005, 'msg': '文件下载失败'})
+    except Exception as e:
+        logger.exception(e)
+        return jsonify({'code': 3005, 'msg': '文件下载失败'})
+
+    return jsonify({'code': 1000, 'msg': '操作成功'})
+
+
+@logger.catch()
+@app.route('/file_state_list', methods=['GET'])
+def file_state_list():
+    '''
+    state : 0 正常, 1: 云端有更新 2: 本地有变动且无冲突可以上传 3: 本地文件和云端文件有冲突
+    :return:
+    '''
+    file_info_list = []
+    for file_name in os.listdir(current_app.config['work_path']):
+        suffer = file_name.split('.')[-1]
+        if suffer not in file_type_map:
+            continue
+
+        if not os.path.exists(os.path.join(current_app.config['work_path'], file_name + '.metadata')):
+            continue
+
+        metadata = oss_handle.load_metadata(os.path.join(current_app.config['work_path'], file_name))
+        file_info = {
+            'file_name': file_name,
+            'show_name': metadata['file_name'],
+            'cloud_update_time': metadata['update_time'],
+            'source_md5': metadata['md5'],
+            'state': 0
+        }
+        cloud_file_info = current_app.config['serve_client'].get_file_info(metadata['file_id'])
+        if cloud_file_info == 2:
+            file_info['state'] = 4
+            file_info_list.append(file_info)
+            continue
+
+        if not cloud_file_info:
+            file_info_list.append(file_info)
+            continue
+
+        if cloud_file_info['updateTime'] != metadata['update_time']:
+            file_info['state'] = 1
+
+        try:
+            file_md5 = get_file_md5(os.path.join(current_app.config['work_path'], file_name))
+            if file_md5 != metadata['md5']:
+                if file_info['state'] == 1:
+                    file_info['state'] = 3
+                else:
+                    file_info['state'] = 2
+        except Exception as e:
+            logger.exception(e)
+
+        file_info_list.append(file_info)
+
+    return jsonify({'code': 1000, 'msg': '操作成功', 'data': file_info_list})
+
+
+@logger.catch()
+@app.route('/remove_file', methods=['POST'])
+def remove_file():
+    file_name = request.get_json()['file_name']
+    file_path = os.path.join(current_app.config['work_path'], file_name)
+    if not os.path.exists(file_path):
+        return jsonify({'code': '3006', 'msg': '文件不存在'})
+
+    try:
+        os.remove(file_path)
+        os.remove(f'{file_path}.metadata')
+    except Exception as e:
+        logger.exception(e)
+        return jsonify({'code': 3005, 'msg': '操作失败,无权限操作本地文件'})
+
+    return jsonify({'code': 1000, 'msg': '操作成功'})
+
+
+@logger.catch()
+@app.route('/open_file', methods=['POST'])
+def open_file():
+    file_name = request.get_json()['file_name']
+    file_path = os.path.join(current_app.config['work_path'], file_name)
+
+    if not os.path.exists(file_path):
+        return jsonify({'code': '3006', 'msg': '文件不存在'})
+
+    if is_file_open_in_wps(file_path):
+        bring_wps_window_to_front(file_path)
+        return jsonify({'code': 1000, 'msg': '检测到文件已被打开'})
+
+    return open_file_by_wps(file_path)
+
+
+def start_flask(serve_client, work_path):
+    app.config['serve_client'] = serve_client
+    app.config['work_path'] = work_path
+    # app.logger.handlers.clear()
+    # app.logger.addHandler(InterceptHandler())
+    app.run(host='0.0.0.0', port=5855)

+ 61 - 0
core/browser_option.py

@@ -0,0 +1,61 @@
+import os
+from selenium import webdriver
+from selenium.common import WebDriverException
+from selenium.webdriver.chrome.service import Service
+
+from tools.check import extract_domain_or_ip
+
+
+class ChromeHandle:
+    def __init__(self):
+        self.driver = None
+
+    def init_browser(self):
+        driver_path = '../chrome-win/chromedriver.exe'
+        service = Service(executable_path=driver_path)
+        options = webdriver.ChromeOptions()
+
+        options.add_experimental_option("excludeSwitches", ["enable-automation"])
+        options.add_experimental_option("useAutomationExtension", False)
+        self.driver = webdriver.Chrome(service=service, options=options)
+
+    def login(self, url):
+        if not self.check_driver_alive():
+            self.init_browser()
+
+        self.driver.execute_script(f"window.open({url}, '_blank');")
+
+    def u121_40_47_16_8088(self, params):
+        self.login('http://121.40.47.16:8088/union/')
+
+        handles = self.driver.window_handles
+
+        self.driver.switch_to.window(handles[-1])
+
+        self.driver.find_element()
+
+    def run(self, params):
+        func_name = extract_domain_or_ip(params['url'])
+        func = getattr(self, func_name)
+        if not func:
+            TypeError('未实现对应方法')
+
+        func(params)
+
+    def check_driver_alive(self) -> bool:
+        try:
+            if not self.driver:
+                return False
+            _ = self.driver.window_handles
+            return True
+        except WebDriverException:
+            return False
+
+
+browser_handle = ChromeHandle()
+
+if __name__ == '__main__':
+    obj = ChromeHandle()
+    # obj.init_browser()
+    obj.u121_40_47_16_8088({'user_name':'杜奎松', 'password':'123456'})
+    print(1)

+ 45 - 0
core/monitor_file.py

@@ -0,0 +1,45 @@
+import os
+import time
+
+from watchdog.observers import Observer
+from watchdog.events import FileSystemEventHandler
+from tools.logger_handle import logger
+from tools.oss_client import MinioClient, oss_handle
+
+
+class FileSaveHandler(FileSystemEventHandler):
+    def __init__(self, serve_client):
+        self.serve_client = serve_client
+        super().__init__()
+
+    def on_modified(self, event):
+        ext = os.path.splitext(event.src_path)[-1]
+        if '~$' in event.src_path:
+            return
+
+        if ext in ['.docx', '.doc', '.ppt', '.pptx', '.xls', '.xlsx']:
+            logger.info(f"[文件修改] 文件已保存: {event.src_path}, 执行上传操作。")
+            res = self.serve_client.upload_file(event.src_path)
+            if res:
+                logger.info(f'文件{event.src_path}上传成功')
+            else:
+                logger.info(f'文件{event.src_path}上传失败')
+
+
+def start_watchdog(serve_client, work_path):
+    event_handler = FileSaveHandler(serve_client)
+    observer = Observer()
+    observer.schedule(event_handler, work_path, recursive=True)
+    observer.start()
+    logger.info('watch dog start')
+    try:
+        while True:
+            time.sleep(1)
+    except KeyboardInterrupt:
+        observer.stop()
+    observer.join()
+
+
+if __name__ == '__main__':
+    start_watchdog()
+    # print(os.listdir('../test/'))

+ 78 - 0
core/wps_handle.py

@@ -0,0 +1,78 @@
+import os
+import time
+
+import pythoncom
+import win32com
+import win32com.client
+import win32con
+import win32gui
+from win10toast import ToastNotifier
+
+from tools.check import is_file_open_in_wps
+from tools.config import file_type_map
+
+toaster = ToastNotifier()
+
+
+
+def bring_wps_window_to_front(file_path: str) -> bool:
+    '''
+    找到文件被打开的窗口置顶,目前实现方式会被windows安全程序拦截只会在控制栏闪烁窗口图标
+    :param file_path:
+    :return:
+    '''
+    # abs_path = os.path.abspath(file_path).lower()
+    # suffix = file_path.split('.')[-1]
+    #
+    # try:
+    #     pythoncom.CoInitialize()
+    #     app = win32com.client.GetActiveObject(file_type_map[suffix][0])
+    #     for wb in getattr(app, file_type_map[suffix][1]):
+    #         if wb.FullName.lower() == abs_path:
+    #             hwnd = app.Hwnd  # WPS 主窗口句柄
+    #             if hwnd:
+    #                 win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)  # 恢复最小化窗口
+    #                 win32gui.SetForegroundWindow(hwnd)  # 置顶窗口
+    #                 return True
+    # except Exception as e:
+    #     print("置顶失败:", e)
+    #     return False
+    # finally:
+    #     pythoncom.CoUninitialize()
+    # return False
+    return True
+
+
+def get_active_wps_text():
+    pythoncom.CoInitialize()  # 初始化 COM
+    try:
+        wps = win32com.client.Dispatch("Kwps.Application")  # WPS Word
+        doc = wps.ActiveDocument
+        content = doc.Content.Text
+        print("[WPS] 当前文档内容:", content[:100])  # 打印前100个字符
+        return content
+    except Exception as e:
+        print("[!] 无法访问 WPS 文档:", e)
+        return None
+
+
+def check_for_changes():
+    global current_text
+    while True:
+        time.sleep(2)  # 每2秒检查一次文档内容
+        new_text = get_active_wps_text()
+        if new_text and new_text != current_text:
+            current_text = new_text
+            update_recommendation(new_text)
+            toaster.show_toast("新推荐内容", recommendation, duration=10)
+
+
+def update_recommendation(content):
+    global recommendation
+    recommendation = f"基于你输入的内容,推荐:{content[:50]}..."
+
+
+if __name__ == '__main__':
+    if is_file_open_in_wps('storage/接口文档.txt'):
+        bring_wps_window_to_front('storage/接口文档.txt')
+    bring_wps_window_to_front('storage/接口文档.txt')

BIN
dist/office_helper_main.exe


+ 3 - 0
dist/test.py

@@ -0,0 +1,3 @@
+data = [[
+    {'type': 'text'}
+],[]]

+ 188 - 0
doc/桌面插件接口文档.md

@@ -0,0 +1,188 @@
+### 下载并打开线上文件
+
+**url:**127.0.0.1:5855/download_and_open_file
+
+**method:** post
+
+**请求:**
+参数说明
+
+| 字段名称 | 类型   | 解释   | 示例 |
+| -------- | ------ | ------ | ---- |
+| file_id  | string | 文件id |      |
+
+示例:
+
+```json
+{"file_id":"sgjufgsajlgdfusyag", 
+}
+```
+
+返回:
+
+参数说明:
+
+| 字段名称 | 类型   | 解释     | 示例                                                         |
+| -------- | ------ | -------- | ------------------------------------------------------------ |
+| code     | int    | 业务代码 | 1000: 操作完成,<br />2001: 检测到文档正在被编辑,无法下载 <br />2002: 检测到远程文件已经更新本地文件也发生改动,终止操作 <br />3001: 不支持该类型的文件 <br />3002: 下载文件失败 |
+| msg      | string | 返回消息 |                                                              |
+
+示例:
+
+```json
+{
+    "code": 1000,
+    "msg": "操作完成"
+}
+```
+
+
+
+### 获取本地文件状态
+
+**url:**127.0.0.1:5855/file_state_list
+
+**method:** get
+
+请求参数:无
+
+返回:
+
+
+| 字段名称                 | 类型        | 解释                  | 示例                                                         |
+| ------------------------ | ----------- | --------------------- | ------------------------------------------------------------ |
+| code                     | int         | 业务代码              | 1000 :操作成功<br />3000:操作失败                          |
+| msg                      | string      |                       |                                                              |
+| data                     | list[dict,] |                       |                                                              |
+| &emsp; file_name         | string      | 文件名                |                                                              |
+| &emsp; cloud_update_time | string      | 云端文件更新时间      |                                                              |
+| &emsp; source_md5        | string      | 下载文件时文件的md5值 |                                                              |
+| &emsp; show_name         | string      | 展示的文件名          |                                                              |
+| &emsp;state              | int         | 文件当前状态          | 0 正常, <br />1: 云端有更新 <br />2: 本地有变动且无冲突可以上传 <br />3: 本地文件和云端文件有冲突<br />4:云端文件已被删除<br /> |
+
+示例:
+
+```json
+{
+    "code": 1000,
+    "msg":"操作完成",
+    "data":[
+        {
+            "file_name":"xxx.csv",
+            "cloud_update_time":"2025-06-05 12:00:00",
+            "source_md5": "***********************",
+            "show_name":"xxx.csv"
+            "state":0
+        }
+    ]
+}
+```
+
+### 上传本地文件
+
+**url:**127.0.0.1:5855/upload_local_file
+
+**method:** post
+
+**注:**上传文件后会尝试删除本地文件,此接口会覆盖线上文件
+
+**请求:**
+参数说明
+
+| 字段名称  | 类型   | 解释   | 示例 |
+| --------- | ------ | ------ | ---- |
+| file_name | string | 文件名 |      |
+
+示例:
+
+```json
+{"file_name":"aaa.csv"}
+```
+
+返回示例:
+
+```json
+{
+    "code":1000,
+    "msg": "操作成功"
+}
+```
+
+| 字段名称 | 类型   | 解释     | 示例                                                         |
+| -------- | ------ | -------- | ------------------------------------------------------------ |
+| code     | int    | 业务代码 | 1000:操作成功<br />3003:操作失败,上传文件失败<br />3004:操作失败,文件已被打开无法操作 |
+| msg      | string | 返回消息 |                                                              |
+
+### 更新本地文件
+
+**url:**127.0.0.1:5855/update_local_file
+
+**method:** post
+
+**注:**此接口会强制覆盖本地文件
+
+**请求:**
+参数说明
+
+| 字段名称  | 类型   | 解释   | 示例 |
+| --------- | ------ | ------ | ---- |
+| file_name | string | 文件名 |      |
+
+```json
+{
+    "file_name": "aaa.csv"
+}
+```
+
+响应:
+
+| 字段名称 | 类型   | 解释     | 示例                                                         |
+| -------- | ------ | -------- | ------------------------------------------------------------ |
+| code     | int    | 业务代码 | 1000:操作成功<br />3004:操作失败,文件已被打开无法操作<br />3005:操作失败,无权限操作本地文件 |
+| msg      | string |          |                                                              |
+
+### 删除本地文件
+
+**url:**127.0.0.1:5855/remove_file
+
+**method:** post
+
+| 字段名称  | 类型   | 解释   | 示例 |
+| --------- | ------ | ------ | ---- |
+| file_name | string | 文件名 |      |
+
+```json
+{
+    "file_name": "aaa.csv"
+}
+```
+
+响应
+
+| 字段名称 | 类型   | 解释     | 示例                                                         |
+| -------- | ------ | -------- | ------------------------------------------------------------ |
+| code     | int    | 业务代码 | 1000:操作成功<br />3006:操作失败,文件不存在<br />3005:操作失败,无权限操作本地文件 |
+| msg      | string |          |                                                              |
+
+### 打开本地文件
+
+**url:**127.0.0.1:5855/open_file
+
+**method:** post
+
+| 字段名称  | 类型   | 解释   | 示例 |
+| --------- | ------ | ------ | ---- |
+| file_name | string | 文件名 |      |
+
+```json
+{
+    "file_name": "aaa.csv"
+}
+```
+
+响应
+
+| 字段名称 | 类型   | 解释     | 示例                                                         |
+| -------- | ------ | -------- | ------------------------------------------------------------ |
+| code     | int    | 业务代码 | 1000:操作成功<br />3006:操作失败,文件不存在<br />3001:操作失败,不支持该类型的文件 |
+| msg      | string |          |                                                              |

+ 50 - 0
office_helper_main.py

@@ -0,0 +1,50 @@
+import argparse
+import os.path
+
+
+import threading
+
+from core.api_app import app, start_flask
+from core.monitor_file import start_watchdog
+from tools.serve_client import ServerClient
+from tools.logger_handle import logger
+
+# 初始化 Flask 应用
+
+
+# 初始化桌面通知工具
+
+
+# 存储文档内容和推荐内容
+current_text = ""
+recommendation = "这是默认推荐内容"
+
+
+# 启动所有后台服务
+def start_all_services(serve_client, work_path):
+    # 启动 Flask 服务
+    threading.Thread(target=start_flask, args=[serve_client, work_path], daemon=True).start()
+    # 启动文件监控
+    # threading.Thread(target=start_watchdog, args=[serve_client, work_path], daemon=True).start()
+    # 启动文档内容检查
+    # threading.Thread(target=check_for_changes, daemon=True).start()
+    logger.info('server running')
+    while True:
+        a = 0
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description='办公助手')
+
+    parser.add_argument('--worker_path', type=str, required=True, help='文件下载目录。')
+    parser.add_argument('--username', type=str, required=True, help='用户名')
+    parser.add_argument('--password', type=str, required=True, help='密码')
+    parser.add_argument('--server', type=str, required=True, help='密码')
+    args = parser.parse_args()
+
+    client = ServerClient(args.server, args.username, args.password)
+    if not os.path.exists(os.path.join(args.worker_path, args.username)):
+        os.makedirs(os.path.join(args.worker_path, args.username))
+
+    start_all_services(client, os.path.join(args.worker_path, args.username))
+

+ 32 - 0
readme

@@ -0,0 +1,32 @@
+download_and_open_file_use_wps
+下载并使用wps打开云文件逻辑:
+用户点击文件使用wps打开时首先校验本地有没有同名文件,
+    如果没有则下载文件并生成metadata 并打开文件
+    如果文件已存在则判断文件md5值和线上文件md5值是否一致
+        如果一致则判断文件是否已被打开
+            如果已经被打开则弹出窗口提示 文档正在被编辑
+            如果未被打开则直接打开该文档
+        如果不一致则
+            判断metadata中md5值和本地文件的md5值是否一致
+                如果一致则判断文件是否已经被打开
+                    如果已经被打开则弹出提示 远程文件已经更新,请关闭当前文档后重新打开远程文档
+                    如果未被打开则下载最新oss文件覆盖本地并打开文件
+                如果不一致则弹出提示框 当前编辑的文件***对应的云文件已经更新,请另存为当前编辑的文档后重复此操作
+
+监控文件夹只会监控文件夹内的ppt/pptx/doc/docx/xls/xlsx/csv 七种格式的文件。
+
+watchdog serve
+在监控到文件被保存时如果同时存在~$开头的同名文件不会上传该文件。
+监控到文件被保存并符合上传条件后则会校验本地文件metadata信息中md5值和oss端的md5值
+    若一致则直接上传
+    若不一致则弹出窗口询问检测到远程文件发生变化 是否覆盖云端文件
+
+check_file_change_bu_oss
+检测云端文件和本地文件不一致的文件
+
+upload_file
+上传本地文件覆盖oss端文件\
+
+
+
+pyinstaller --onefile --noconsole --windowed main.py

+ 73 - 0
tools/check.py

@@ -0,0 +1,73 @@
+import json
+import os
+import re
+import ipaddress
+from urllib.parse import urlparse
+
+import pythoncom
+import win32com
+
+from tools.config import base_path, file_type_map
+from tools.oss_client import MinioClient
+
+REPLY = {'html': None, 'cookies': None, 'token': None}
+
+
+def extract_domain_or_ip(url):
+    """提取网页中的域名或 IP+端口"""
+    try:
+        parsed_url = urlparse(url)
+        host = parsed_url.hostname  # 提取主机部分(域名或 IP)
+        port = parsed_url.port  # 提取端口
+
+        result = host
+        # 如果是 IP 地址,则保留端口
+        if is_ip_address(host):
+            result = f"u{host}:{port}" if port else 'u' + host
+
+        return result.replace('.', '_').replace(':', '_')  # 仅返回域名
+    except Exception as e:
+        print(f"解析错误: {e}")
+        return None
+
+
+def is_ip_address(host):
+    """判断是否是 IP 地址"""
+    try:
+        ipaddress.ip_address(host)
+        return True
+    except ValueError:
+        return False
+
+
+def detect_oss_file_changes(oss_client, file_info):
+    cloud_file_md5 = oss_client.get_cloud_file_md5(file_info['bucket_name'], file_info['file_path'])
+    with open(os.path.join(base_path, file_info['file_name'] + '.metadata'), 'r', encoding='utf-8') as f:
+        metadata = json.load(f)
+
+    return cloud_file_md5 == metadata['md5']
+
+
+def detect_local_file_changes(file_path):
+    with open(os.path.join(base_path, file_path + '.metadata'), 'r', encoding='utf-8') as f:
+        metadata = json.load(f)
+
+    current_file_md5 = MinioClient.get_file_md5(file_path)
+    return metadata['md5'] == current_file_md5
+
+
+def is_file_open_in_wps(file_path: str) -> bool:
+    abs_path = os.path.abspath(file_path).lower()
+    suffix = file_path.split('.')[-1]
+    try:
+        pythoncom.CoInitialize()
+        app = win32com.client.GetActiveObject(file_type_map[suffix][0])
+
+        for wb in getattr(app, file_type_map[suffix][1]):
+            if wb.FullName.lower() == abs_path:
+                return True
+    except Exception:
+        return False
+    finally:
+        pythoncom.CoUninitialize()
+    return False

+ 6 - 0
tools/cloud_file.py

@@ -0,0 +1,6 @@
+import requests
+
+
+def get_file_info_by_id(file_id):
+    return file_id
+

+ 31 - 0
tools/config.py

@@ -0,0 +1,31 @@
+import os
+import sys
+
+CONFIG = {'oss': {
+    'endpoint': '172.10.3.36:9000',
+    'access_key': 'miniominio',
+    'secret_key': 'miniominio'
+}}
+# SERVER_POINT = 'http://120.195.49.22:7215'
+SERVER_POINT = 'http://172.10.3.71:10001'
+
+
+def get_config(config_name):
+    return CONFIG.get(config_name)
+
+
+def is_frozen():
+    return getattr(sys, 'frozen', False)
+
+
+file_type_map = {
+    'docx': ['kwps.Application', 'Documents'],
+    'doc': ['kwps.Application', 'Documents'],
+    'txt': ['kwps.Application', 'Documents'],
+    'ppt': ['kwpp.Application', 'Presentations'],
+    'pptx': ['kwpp.Application', 'Presentations'],
+    'csv': ['ket.Application', 'Workbooks'],
+    'xlsx': ['ket.Application', 'Workbooks'],
+    'xls': ['ket.Application', 'Workbooks'],
+}
+base_path = os.path.dirname(sys.executable if getattr(sys, 'frozen', False) else __file__)

+ 26 - 0
tools/file_manager.py

@@ -0,0 +1,26 @@
+import hashlib
+
+import requests
+
+
+def download_file(file_url, local_file_path):
+    reply = requests.get(file_url)
+    with open(local_file_path, 'wb') as f:
+        f.write(reply.content)
+
+
+def upload_file(file_name):
+    data = {'file_path': file_name}
+
+    reply = requests.post('https://127.0.0.1:5555/upload_file', data=data, files=data)
+
+    return reply.json()
+
+
+def get_file_md5(file_path):
+    md5 = hashlib.md5()
+    with open(file_path, 'rb') as f:
+        # 分块读取以支持大文件
+        while chunk := f.read(8192):
+            md5.update(chunk)
+    return md5.hexdigest()

+ 15 - 0
tools/logger_handle.py

@@ -0,0 +1,15 @@
+import io
+
+import sys
+
+from loguru import logger
+
+from datetime import datetime
+
+log_month = datetime.now().strftime("%Y-%m")
+logger.remove()
+# sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
+#
+logger.add(sys.stdout, level="INFO", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}")
+logger.add(f"serve_log/log_{datetime.now().strftime('%Y-%m-%d')}.log", format="{time} {level} {message}",
+           rotation="100 MB", retention="1 week", compression=None)

+ 110 - 0
tools/oss_client.py

@@ -0,0 +1,110 @@
+import datetime
+import hashlib
+import json
+import os
+
+from minio import Minio, S3Error
+
+from tools.config import get_config
+from tools.logger_handle import logger
+
+
+class MinioClient:
+    client = None
+
+    def __new__(cls, *args, **kwargs):
+        if not cls.client:
+            cls.client = object.__new__(cls)
+        return cls.client
+
+    def __init__(self, service, access_key, secret_key, secure=False):
+        self.service = service
+        self.access_key = access_key
+        self.secret_key = secret_key
+        self.client = Minio(service, access_key=access_key, secret_key=secret_key, secure=secure)
+
+    @staticmethod
+    def load_metadata(local_path):
+        with open(f'{local_path}.metadata', 'r', encoding='utf-8') as f:
+            file_metadata = json.loads(f.read())
+            return file_metadata
+
+    def get_bucket_list(self):
+        buckets = self.client.list_buckets()
+        bucket_list = []
+        for bucket in buckets:
+            bucket_list.append(
+                {"bucket_name": bucket.name, "create_time": bucket.creation_date}
+            )
+        return bucket_list
+
+    def upload_file(self, file_path):
+        """
+        上传文件 + 写入
+        :param file_path: 本地文件路径
+        :return:
+        """
+        try:
+            with open(f'{file_path}.metadata', 'r', encoding='utf-8') as f:
+                file_metadata = json.loads(f.read())
+            oss_path = file_metadata['oss_path']
+
+            self.client.fput_object(file_metadata['bucket_name'], oss_path, file_path)
+            return True
+        except S3Error as e:
+            logger.exception(e)
+            return False
+
+    def fget_file(self, bucket_name, oss_path, file_path):
+        """
+        下载保存文件保存本地
+        :param bucket_name:
+        :param oss_path:服务端文件名
+        :param file_path:要写入的本地路径
+        :return:
+        """
+        self.client.fget_object(bucket_name, oss_path, file_path)
+        with open(f'{file_path}.metadata', 'w', encoding='utf-8') as f:
+            f.write(json.dumps({
+                'serve': self.service,
+                'access_key': self.access_key,
+                'secret_key': self.secret_key,
+                'bucket_name': bucket_name,
+                'md5': self.get_cloud_file_md5(bucket_name, oss_path),
+                'oss_path': oss_path,
+                'last_modified': self.get_cloud_last_modified(bucket_name, oss_path)
+            }))
+
+    def get_cloud_file_md5(self, bucket_name, file):
+        return self.client.stat_object(bucket_name, file).etag
+
+    def get_cloud_last_modified(self, bucket_name, file):
+        return self.client.stat_object(bucket_name, file).last_modified.strftime("%Y-%m-%d %H:%M:%S")
+
+    @staticmethod
+    def get_file_md5(file_path):
+        md5 = hashlib.md5()
+        with open(file_path, 'rb') as f:
+            # 分块读取以支持大文件
+            while chunk := f.read(8192):
+                md5.update(chunk)
+        return md5.hexdigest()
+
+
+oss_config = get_config('oss')
+oss_handle = MinioClient(oss_config['endpoint'],
+                         access_key=oss_config['access_key'],
+                         secret_key=oss_config['secret_key'])
+
+if __name__ == '__main__':
+    client = MinioClient('172.10.3.36:9000', access_key='miniominio', secret_key='miniominio')
+    # print(client.get_cloud_file_md5('document-mgr', '接口文档.txt'))
+    # print(client.get_file_md5('接口文档.txt'))
+    print(client.get_cloud_file_md5('document-mgr', 'test.docx'))
+
+    print(client.upload_file(r'E:\PycharmProjects\office_plugin\tools\storage\admin\document-mgr_test.docx'))
+    oss_path = 'test.docx'
+    print(client.get_cloud_file_md5('document-mgr', 'test.docx'))
+    # client.fget_file('document-mgr',
+    #                  'document-mgr/ten_1/2_2/document-mgr/document-mgr/2025/05/27/2025-05-271112065750681423872-新建文件.docx',
+    #                  '接口文档.docx')

+ 267 - 0
tools/serve_client.py

@@ -0,0 +1,267 @@
+import base64
+import hashlib
+import json
+import os.path
+import re
+
+import requests
+
+from Crypto.Cipher import AES
+
+from tools.config import SERVER_POINT
+from tools.oss_client import oss_handle
+from tools.logger_handle import logger
+
+
+class ServerClient:
+    def __init__(self, server_host, user_name, password):
+        self.user_name = user_name
+        self.password = password
+        self.serve_host = server_host
+        self.access_token = None
+        self.headers = {
+            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
+        }
+        self.login()
+
+    def login(self):
+        url = self.serve_host + '/auth/oauth2/token'
+        reply = requests.post(url, params={
+            'username': self.user_name,
+            'password': self.encryption(self.password),
+            'grant_type': 'password',
+            'scope': 'server',
+            'client_id': 'knowledge',
+            'client_secret': 'knowledge'
+        }, headers={'Content-Type': 'multipart/form-data',
+                    'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'})
+
+        self.access_token = reply.json()['access_token']
+        self.headers['Authorization'] = f'Bearer {self.access_token}'
+
+    def decryption(self, hex_str, secret='anZz000000000000'):
+        key_bytes = secret.encode('utf-8')
+        iv = key_bytes
+        base64_str = base64.b64encode(bytes.fromhex(hex_str)).decode('utf-8')
+        cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
+        decrypted = cipher.decrypt(base64.b64decode(base64_str))
+        try:
+            decoded = decrypted.decode('utf-8')
+        except UnicodeDecodeError:
+            decoded = decrypted.decode('utf-8', errors='ignore')
+        # 清除控制字符(类似 JS 中的 [\u0000-\u001F\u007F-\u009F])
+        cleaned = re.sub(r'[\x00-\x1F\x7F-\x9F]', ' ', decoded)
+        try:
+            return json.loads(cleaned)
+        except json.JSONDecodeError:
+            return cleaned.strip()
+
+    def zero_pad(self, data, block_size=16):
+        pad_len = block_size - (len(data) % block_size)
+        if pad_len == 0:
+            return data
+        return data + b'\x00' * pad_len
+
+    def encryption(self, payload, secret='a25vd2xlZGdl0000'.encode('utf-8')):
+        data_bytes = payload.encode('utf-8')
+        padded_data = self.zero_pad(data_bytes)
+        cipher = AES.new(secret, AES.MODE_CBC, secret)
+        encrypted = cipher.encrypt(padded_data)
+        return encrypted.hex()
+
+    def check_resp(self, resp):
+        if resp.json()['code'] == -2:
+            self.login()
+            return True
+        return False
+
+    def download_file(self, file_info, storage_path):
+
+        try:
+            resp = requests.get(f'{self.serve_host}/mgr/document/dcLibrary/file/get/file/{file_info["id"]}',
+                                headers=self.headers)
+            local_file_name = file_info['filePath'].replace('/', '_')
+            with open(os.path.join(storage_path, local_file_name), 'wb') as f:
+                f.write(resp.content)
+
+            with open(os.path.join(storage_path, local_file_name + '.metadata'), 'w', encoding='utf-8') as f:
+                f.write(json.dumps({
+                    'serve': oss_handle.service,
+                    'access_key': oss_handle.access_key,
+                    'secret_key': oss_handle.secret_key,
+                    'file_id': file_info["id"],
+                    'bucket_name': file_info['bucketName'],
+                    'md5': self.get_file_md5(os.path.join(storage_path, local_file_name)),
+                    'file_name': file_info['name'],
+                    'oss_path': file_info['filePath'],
+                    'update_time': file_info['updateTime']
+                }))
+        except Exception as e:
+            logger.exception(e)
+            return False
+        return True
+
+    def upload_file(self, file_path):
+        try:
+            with open(f'{file_path}.metadata', 'r', encoding='utf-8') as f:
+                file_metadata = json.loads(f.read())
+            file_id = file_metadata['file_id']
+
+            files = [('file', (file_metadata['file_name'], open(file_path, 'rb'),
+                               'application/vnd.openxmlformats-officedocument.wordprocessingml.document'))]
+            resp = requests.post(
+                f'{self.serve_host}/mgr/document/dcLibrary/saveByPython/{file_id}',
+                headers=self.headers,
+                files=files,
+                data={}
+            )
+            if self.check_resp(resp):
+                resp = requests.post(
+                    f'{self.serve_host}/mgr/document/dcLibrary/saveByPython/{file_id}',
+                    headers=self.headers,
+                    files=files,
+                    data={})
+            if resp.json()['code'] == 0:
+                return True
+            else:
+                logger.error(resp.json())
+                return False
+        except Exception as e:
+            logger.exception(e)
+            return False
+
+    @staticmethod
+    def get_file_md5(file_path):
+        md5 = hashlib.md5()
+        with open(file_path, 'rb') as f:
+            # 分块读取以支持大文件
+            while chunk := f.read(8192):
+                md5.update(chunk)
+        return md5.hexdigest()
+
+    @staticmethod
+    def load_metadata(local_path):
+        with open(f'{local_path}.metadata', 'r', encoding='utf-8') as f:
+            file_metadata = json.loads(f.read())
+            return file_metadata
+
+    def get_file_info(self, file_id):
+        '''
+        {'createTime': '2025-06-03 15:57:37',
+        'updateTime': '2025-06-16 11:17:52',
+        'createById': '1',
+        'createBy': '超级管理员',
+        'updateBy': '超级管理员',
+        'id': 'c5a8f3d5811e572265501eeaf6d8b1cf',
+        'filePath': 'ten_1/lujie/document-mgr/SYG测试文库/test2.docx/2025/06/16/2025-06-161119224319835017216-c9b17214ea4144cf922f2fbfc0cdf70d.office_doc.docx',
+        'bucketName': 'document-mgr',
+        'name': 'test2.docx',
+         'shareRole': 'user',
+         'size': 26424,
+         'parentId': '6b79a0c8359b89653b95a14ad08b4954',
+          'type': 'office_doc',
+          'orderId': 12, 'delFlag': False,
+          'pathId': ['6b79a0c8359b89653b95a14ad08b4954'],
+          'knowledgeId': '6b79a0c8359b89653b95a14ad08b4954',
+          'tenantId': '1', 'commentable': True, '
+          readNotify': True, 'nameSuffix': 'docx',
+          'fileStatus': 'NORMAL'}
+
+        :param file_id:
+        :return:
+        '''
+        try:
+            resp = requests.get(f'{self.serve_host}/mgr/document/no/auth/dcLibrary/info/{file_id}',
+                                headers=self.headers)
+            if not self.check_resp(resp):
+                resp = requests.get(f'{self.serve_host}/mgr/document/no/auth/dcLibrary/info/{file_id}',
+                                    headers=self.headers)
+            if not resp.json()['data']:
+                return 2
+            return self.decryption(resp.json()['data'])
+        except Exception as e:
+            logger.exception(e)
+            return False
+
+
+if __name__ == '__main__':
+    client = ServerClient('http://172.10.3.71:10001','admin', '123456')
+    # client = ServerClient('admin', 'jxkj123456')
+    # client.download_file(
+    #     {'file_name': 'test.docx', 'file_id': 'c5a8f3d5811e572265501eeaf6d8b1cf', 'bucket_name': 'aaa', }, '')
+    res = client.get_file_info(r'585ec718900fa82ce3d35d515c9d8a4b')
+    res = client.decryption(
+        "")
+
+    for row in res['data']:
+        print(row)
+    a = {'createTime': '2025-06-13 11:48:35', 'updateTime': '2025-06-13 14:01:48', 'createById': '1',
+         'createBy': '超级管理员', 'updateBy': '超级管理员', 'id': 'c04b39ebfa8141746c20b88c7417f972',
+         'filePath': 'ten_1/2_2/document-mgr/法律法规/新建文件.docx/2025/06/13/2025-06-131118178409944354816-e251bced912f4d68bc50d80a33cbbd5d.office_doc.docx',
+         'bucketName': 'document-mgr', 'name': '新建文件.docx', 'shareRole': 'user', 'size': 29527,
+         'parentId': 'a3d958a6bba28357757ca20c87af9954', 'type': 'office_doc', 'orderId': 24, 'delFlag': False,
+         'pathId': ['a3d958a6bba28357757ca20c87af9954'], 'knowledgeId': 'a3d958a6bba28357757ca20c87af9954',
+         'tenantId': '1', 'commentable': True, 'readNotify': True, 'nameSuffix': 'docx', 'fileStatus': 'NORMAL',
+         'dcIdentifying': [
+             {'id': '1', 'identifyingName': '文档分享', 'name': '文档分享', 'identifyingKey': 'document_share',
+              'identifyingType': 'document', 'isSelect': False, 'possessorIs': False},
+             {'id': '11', 'identifyingName': '文库删除', 'name': '文库删除', 'identifyingKey': 'library_delete',
+              'identifyingType': 'library', 'isSelect': False, 'possessorIs': False},
+             {'id': '12', 'identifyingName': '文库编辑', 'name': '文库编辑', 'identifyingKey': 'library_update',
+              'identifyingType': 'library', 'isSelect': False, 'possessorIs': False},
+             {'id': '13', 'identifyingName': '文库下载', 'name': '文库下载', 'identifyingKey': 'library_down',
+              'identifyingType': 'library', 'isSelect': False, 'possessorIs': False},
+             {'id': '5', 'identifyingName': '文档删除', 'name': '文档删除', 'identifyingKey': 'document_delete',
+              'identifyingType': 'document', 'isSelect': False, 'possessorIs': False},
+             {'id': '6', 'identifyingName': '新建文档', 'name': '新建文档', 'identifyingKey': 'document_add',
+              'identifyingType': 'document', 'isSelect': False, 'possessorIs': False},
+             {'id': '7', 'identifyingName': '文档下载', 'name': '文档下载', 'identifyingKey': 'document_down',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False},
+             {'id': '8', 'identifyingName': '文档编辑', 'name': '文档编辑', 'identifyingKey': 'document_update',
+              'identifyingType': 'document', 'isSelect': False, 'possessorIs': False},
+             {'id': '9', 'identifyingName': '文档移动', 'name': '文档移动', 'identifyingKey': 'document_move',
+              'identifyingType': 'document', 'isSelect': False, 'possessorIs': False},
+             {'id': '14', 'identifyingName': '文档消息设置', 'name': '文档消息设置',
+              'identifyingKey': 'document_remind_settings', 'identifyingType': 'document', 'isSelect': False,
+              'possessorIs': False}, {'id': '15', 'identifyingName': '文库权限设置', 'name': '文库权限设置',
+                                      'identifyingKey': 'library_auth_settings', 'identifyingType': 'document',
+                                      'isSelect': False, 'possessorIs': False},
+             {'id': '3', 'identifyingName': '文库消息设置', 'name': '文库消息设置',
+              'identifyingKey': 'library_remind_settings', 'identifyingType': 'library', 'isSelect': False,
+              'possessorIs': False}, {'id': '4', 'identifyingName': '文档权限设置', 'name': '文档权限设置',
+                                      'identifyingKey': 'document_auth_settings', 'identifyingType': 'document',
+                                      'isSelect': False, 'possessorIs': False},
+             {'id': '1', 'identifyingName': '文档分享', 'name': '', 'identifyingKey': 'document_share',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '10', 'identifyingName': '查看', 'name': '', 'identifyingKey': 'view',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '11', 'identifyingName': '文库删除', 'name': '', 'identifyingKey': 'library_delete',
+              'identifyingType': 'library', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '12', 'identifyingName': '文库编辑', 'name': '', 'identifyingKey': 'library_update',
+              'identifyingType': 'library', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '13', 'identifyingName': '文库下载', 'name': '', 'identifyingKey': 'library_down',
+              'identifyingType': 'library', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '14', 'identifyingName': '文档消息设置', 'name': '', 'identifyingKey': 'document_remind_settings',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '15', 'identifyingName': '文库权限设置', 'name': '', 'identifyingKey': 'library_auth_settings',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '2', 'identifyingName': '文库转移项目', 'name': '', 'identifyingKey': 'library_transfer',
+              'identifyingType': 'library', 'isSelect': True, 'possessorIs': True, 'select': True},
+             {'id': '3', 'identifyingName': '文库消息设置', 'name': '', 'identifyingKey': 'library_remind_settings',
+              'identifyingType': 'library', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '4', 'identifyingName': '文档权限设置', 'name': '', 'identifyingKey': 'document_auth_settings',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '5', 'identifyingName': '文档删除', 'name': '', 'identifyingKey': 'document_delete',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '6', 'identifyingName': '新建文档', 'name': '', 'identifyingKey': 'document_add',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '7', 'identifyingName': '文档下载', 'name': '', 'identifyingKey': 'document_down',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '8', 'identifyingName': '文档编辑', 'name': '', 'identifyingKey': 'document_update',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True},
+             {'id': '9', 'identifyingName': '文档移动', 'name': '', 'identifyingKey': 'document_move',
+              'identifyingType': 'document', 'isSelect': True, 'possessorIs': False, 'select': True}], 'share': False}
+
+    # access_token = client.login()
+    # print(requests.post('http://120.195.49.22:7215/mgr/document/dcLibrary/knowledges?size=1000&current=1', headers={
+    #     'authorization': f'Bearer {access_token}'}).json())

+ 0 - 0
tools/test.docx


+ 1 - 0
tools/test.docx.metadata

@@ -0,0 +1 @@
+{"serve": "172.10.3.36:9000", "access_key": "miniominio", "secret_key": "miniominio", "bucket_name": "aaa", "file_name": "test.docx", "file_id": "c5a8f3d5811e572265501eeaf6d8b1cf"}