CCL_QMS检验
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

807 lines
30 KiB

2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
  1. # -*- coding: utf-8 -*-
  2. import json
  3. import os
  4. import queue
  5. import shutil
  6. import socket
  7. import sys
  8. import threading
  9. import time
  10. from datetime import datetime
  11. from pathlib import Path
  12. from tkinter import Tk, Label, Entry, Button, StringVar, END, DISABLED, NORMAL, filedialog, messagebox, simpledialog
  13. from tkinter.scrolledtext import ScrolledText
  14. import requests
  15. from requests.adapters import HTTPAdapter
  16. from urllib3.util.retry import Retry
  17. CONTROL_HOST = "127.0.0.1"
  18. CONTROL_PORT = 52347
  19. SINGLE_INSTANCE_MUTEX_NAME = "Global\\QMSFileCollector_SingleInstance"
  20. ERROR_ALREADY_EXISTS = 183
  21. def acquire_single_instance_lock():
  22. if os.name != "nt":
  23. return None, True
  24. try:
  25. import ctypes
  26. kernel32 = ctypes.windll.kernel32
  27. handle = kernel32.CreateMutexW(None, False, SINGLE_INSTANCE_MUTEX_NAME)
  28. if not handle:
  29. return None, True
  30. if kernel32.GetLastError() == ERROR_ALREADY_EXISTS:
  31. kernel32.CloseHandle(handle)
  32. return None, False
  33. return handle, True
  34. except Exception:
  35. return None, True
  36. def notify_existing_instance_show(retries=8, sleep_seconds=0.25):
  37. for _ in range(max(1, retries)):
  38. try:
  39. with socket.create_connection((CONTROL_HOST, CONTROL_PORT), timeout=1.0) as conn:
  40. conn.sendall(b"SHOW")
  41. return True
  42. except Exception:
  43. time.sleep(sleep_seconds)
  44. return False
  45. def get_runtime_dir():
  46. if getattr(sys, "frozen", False):
  47. return Path(sys.executable).resolve().parent
  48. return Path(__file__).resolve().parent
  49. def _ensure_windows_run_registry(entry_name, exe_path):
  50. import winreg
  51. run_key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
  52. target_value = "\"%s\"" % exe_path
  53. try:
  54. with winreg.OpenKey(
  55. winreg.HKEY_CURRENT_USER,
  56. run_key_path,
  57. 0,
  58. winreg.KEY_READ | winreg.KEY_SET_VALUE
  59. ) as key:
  60. try:
  61. old_value, _ = winreg.QueryValueEx(key, entry_name)
  62. except FileNotFoundError:
  63. old_value = None
  64. if old_value and str(old_value).strip().strip("\"").lower() == exe_path.lower():
  65. return False, "系统自启动项已存在,无需重复写入。"
  66. winreg.SetValueEx(key, entry_name, 0, winreg.REG_SZ, target_value)
  67. if old_value:
  68. return True, "检测到自启动路径变化,已自动更新。"
  69. return True, "首次启动已写入系统自启动项,下次开机会自动运行。"
  70. except Exception as e:
  71. return False, "写入系统自启动项失败: %s" % e
  72. def _remove_windows_run_entry(entry_name):
  73. try:
  74. import winreg
  75. run_key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
  76. with winreg.OpenKey(
  77. winreg.HKEY_CURRENT_USER,
  78. run_key_path,
  79. 0,
  80. winreg.KEY_SET_VALUE
  81. ) as key:
  82. try:
  83. winreg.DeleteValue(key, entry_name)
  84. except FileNotFoundError:
  85. pass
  86. except Exception:
  87. pass
  88. def _cleanup_legacy_startup_items(legacy_entry_name, app_data):
  89. if app_data:
  90. startup_dir = Path(app_data) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
  91. legacy_script = startup_dir / (legacy_entry_name + ".vbs")
  92. try:
  93. if legacy_script.exists():
  94. legacy_script.unlink()
  95. except Exception:
  96. pass
  97. _remove_windows_run_entry(legacy_entry_name)
  98. def ensure_windows_startup():
  99. if os.name != "nt":
  100. return False, "当前系统非Windows,跳过自启动注册。"
  101. if not getattr(sys, "frozen", False):
  102. return False, "当前为源码运行,跳过自启动注册。"
  103. exe_path = str(Path(sys.executable).resolve())
  104. entry_name = "QMSFileCollector"
  105. app_data = os.getenv("APPDATA", "").strip()
  106. _cleanup_legacy_startup_items("CKPClientFileCollector", app_data)
  107. if app_data:
  108. startup_dir = Path(app_data) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
  109. startup_script = startup_dir / (entry_name + ".vbs")
  110. script_content = (
  111. "Set WshShell = CreateObject(\"WScript.Shell\")\n"
  112. "WshShell.Run Chr(34) & \"%s\" & Chr(34), 7\n"
  113. "Set WshShell = Nothing\n"
  114. ) % exe_path.replace("\"", "\"\"")
  115. try:
  116. startup_dir.mkdir(parents=True, exist_ok=True)
  117. old_content = ""
  118. if startup_script.exists():
  119. old_content = startup_script.read_text(encoding="utf-8")
  120. if old_content == script_content:
  121. return False, "启动文件夹自启动项已存在,无需重复写入。"
  122. startup_script.write_text(script_content, encoding="utf-8")
  123. _remove_windows_run_entry(entry_name)
  124. if old_content:
  125. return True, "检测到启动文件夹自启动路径变化,已自动更新。"
  126. return True, "首次启动已写入启动文件夹自启动项,下次开机会自动运行。"
  127. except Exception as startup_error:
  128. try:
  129. if startup_script.exists():
  130. startup_script.unlink()
  131. except Exception:
  132. pass
  133. try:
  134. changed, reg_msg = _ensure_windows_run_registry(entry_name, exe_path)
  135. return changed, "写入启动文件夹失败,已回退注册表方式: %s" % reg_msg
  136. except Exception as reg_error:
  137. return False, "写入启动文件夹和注册表均失败: %s; %s" % (startup_error, reg_error)
  138. try:
  139. return _ensure_windows_run_registry(entry_name, exe_path)
  140. except Exception as e:
  141. return False, "未获取到APPDATA且注册表写入失败: %s" % e
  142. class CollectorApp:
  143. def __init__(self, root):
  144. self.root = root
  145. self.root.title("文件采集客户端")
  146. self.root.geometry("1280x720")
  147. self.root.resizable(True, True)
  148. self.config_path = get_runtime_dir() / "collector_config.json"
  149. self.log_queue = queue.Queue()
  150. self.running = False
  151. self.worker_thread = None
  152. self.stop_event = threading.Event()
  153. self.active_rows = []
  154. self.upload_state = {}
  155. self.notice_ts = {}
  156. self.processing_lock = threading.Lock()
  157. self.processing_files = set()
  158. self.control_stop_event = threading.Event()
  159. self.http_session = self._create_http_session()
  160. self.base_url_var = StringVar()
  161. self.poll_seconds_var = StringVar(value="10")
  162. self.row_vars = []
  163. for _ in range(3):
  164. self.row_vars.append({
  165. "site": StringVar(),
  166. "bu_no": StringVar(),
  167. "equipment_no": StringVar(),
  168. "source_path": StringVar(),
  169. "backup_path": StringVar(),
  170. })
  171. self._build_ui()
  172. self.root.protocol("WM_DELETE_WINDOW", self._on_window_close)
  173. self._start_control_server()
  174. self._init_windows_startup()
  175. self._load_config()
  176. self.root.after(200, self._flush_logs)
  177. self.root.after(3000, self._watch_worker)
  178. def _start_control_server(self):
  179. self.control_thread = threading.Thread(target=self._control_server_loop, daemon=True)
  180. self.control_thread.start()
  181. def _control_server_loop(self):
  182. server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  183. server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  184. try:
  185. server.bind((CONTROL_HOST, CONTROL_PORT))
  186. server.listen(1)
  187. server.settimeout(1.0)
  188. except Exception:
  189. try:
  190. server.close()
  191. except Exception:
  192. pass
  193. return
  194. while not self.control_stop_event.is_set():
  195. try:
  196. conn, _ = server.accept()
  197. except socket.timeout:
  198. continue
  199. except Exception:
  200. break
  201. try:
  202. payload = conn.recv(16)
  203. if b"SHOW" in payload:
  204. self.root.after(0, self._show_main_window)
  205. except Exception:
  206. pass
  207. finally:
  208. try:
  209. conn.close()
  210. except Exception:
  211. pass
  212. try:
  213. server.close()
  214. except Exception:
  215. pass
  216. def _show_main_window(self):
  217. try:
  218. self.root.deiconify()
  219. self.root.state("normal")
  220. self.root.lift()
  221. self.root.attributes("-topmost", True)
  222. self.root.after(300, lambda: self.root.attributes("-topmost", False))
  223. self.root.focus_force()
  224. except Exception:
  225. pass
  226. def _create_http_session(self):
  227. retry = Retry(
  228. total=3,
  229. connect=3,
  230. read=3,
  231. backoff_factor=1.0,
  232. status_forcelist=[500, 502, 503, 504],
  233. allowed_methods=frozenset(["POST"]),
  234. raise_on_status=False,
  235. )
  236. adapter = HTTPAdapter(max_retries=retry, pool_connections=8, pool_maxsize=8)
  237. session = requests.Session()
  238. session.mount("http://", adapter)
  239. session.mount("https://", adapter)
  240. return session
  241. def _init_windows_startup(self):
  242. changed, message = ensure_windows_startup()
  243. if changed:
  244. self.log(message)
  245. return
  246. if getattr(sys, "frozen", False) and ("失败" in message):
  247. self.log(message)
  248. def _build_ui(self):
  249. Label(self.root, text="系统地址").place(x=20, y=20)
  250. Entry(self.root, textvariable=self.base_url_var, width=92).place(x=85, y=20)
  251. Button(self.root, text="弹框设置地址", command=self.popup_set_base_url).place(x=860, y=16)
  252. Button(self.root, text="保存配置", command=self.save_config).place(x=970, y=16)
  253. Label(self.root, text="轮询秒").place(x=20, y=58)
  254. Entry(self.root, textvariable=self.poll_seconds_var, width=10).place(x=85, y=58)
  255. Button(self.root, text="开始同步", command=self.start_collect).place(x=180, y=54)
  256. Button(self.root, text="停止同步", command=self.stop_collect).place(x=265, y=54)
  257. Button(self.root, text="退出程序", command=self.exit_app).place(x=1080, y=54)
  258. Label(self.root, text="配置说明:最多3行,每行需填写 site + buNo + equipmentNo + 本地目录;备份目录可选(为空则自动使用本地目录_bak)").place(x=360, y=58)
  259. Label(self.root, text="").place(x=20, y=100)
  260. Label(self.root, text="Site").place(x=80, y=100)
  261. Label(self.root, text="BuNo").place(x=220, y=100)
  262. Label(self.root, text="EquipmentNo").place(x=360, y=100)
  263. Label(self.root, text="本地目录").place(x=530, y=100)
  264. Label(self.root, text="备份目录").place(x=890, y=100)
  265. for idx, row in enumerate(self.row_vars):
  266. y = 130 + idx * 40
  267. Label(self.root, text=str(idx + 1)).place(x=26, y=y)
  268. Entry(self.root, textvariable=row["site"], width=16).place(x=80, y=y)
  269. Entry(self.root, textvariable=row["bu_no"], width=16).place(x=220, y=y)
  270. Entry(self.root, textvariable=row["equipment_no"], width=18).place(x=360, y=y)
  271. Entry(self.root, textvariable=row["source_path"], width=40).place(x=530, y=y)
  272. Button(self.root, text="选择目录", command=lambda x=idx: self.choose_source_dir(x)).place(x=815, y=y - 4)
  273. Entry(self.root, textvariable=row["backup_path"], width=30).place(x=890, y=y)
  274. Button(self.root, text="选择目录", command=lambda x=idx: self.choose_backup_dir(x)).place(x=1110, y=y - 4)
  275. self.log_text = ScrolledText(self.root, width=165, height=23)
  276. self.log_text.place(x=20, y=280)
  277. self.log_text.configure(state=DISABLED)
  278. def popup_set_base_url(self):
  279. new_url = simpledialog.askstring(
  280. "设置目标地址",
  281. "请输入xujie-sys地址,例如:http://172.26.58.88:8080",
  282. initialvalue=self.base_url_var.get().strip()
  283. )
  284. if new_url is not None:
  285. self.base_url_var.set(new_url.strip())
  286. self.log("已设置目标地址: %s" % new_url.strip())
  287. def choose_source_dir(self, row_index):
  288. path = filedialog.askdirectory(title="选择第%d行本地目录" % (row_index + 1))
  289. if path:
  290. row = self.row_vars[row_index]
  291. row["source_path"].set(path)
  292. if not row["backup_path"].get().strip():
  293. row["backup_path"].set(self._default_backup_path(path))
  294. def choose_backup_dir(self, row_index):
  295. path = filedialog.askdirectory(title="选择第%d行备份目录" % (row_index + 1))
  296. if path:
  297. self.row_vars[row_index]["backup_path"].set(path)
  298. def _default_backup_path(self, source_path):
  299. source_text = str(source_path).strip()
  300. if not source_text:
  301. return ""
  302. source = Path(source_text)
  303. return str(source.parent / (source.name + "_bak"))
  304. def _load_config(self):
  305. if not self.config_path.exists():
  306. self.log("未找到本地配置,请先录入并保存。")
  307. return
  308. try:
  309. config = json.loads(self.config_path.read_text(encoding="utf-8"))
  310. self.base_url_var.set(config.get("base_url", ""))
  311. self.poll_seconds_var.set(str(config.get("poll_seconds", "10")))
  312. rows = config.get("rows")
  313. # 兼容旧版本单行配置
  314. if not isinstance(rows, list):
  315. rows = [{
  316. "site": config.get("site", ""),
  317. "bu_no": config.get("bu_no", ""),
  318. "equipment_no": config.get("equipment_no", ""),
  319. "source_path": config.get("source_path", ""),
  320. "backup_path": config.get("backup_path", ""),
  321. }]
  322. for idx in range(min(3, len(rows))):
  323. item = rows[idx] if isinstance(rows[idx], dict) else {}
  324. source_path = str(item.get("source_path", "")).strip()
  325. backup_path = str(item.get("backup_path", "")).strip()
  326. if source_path and not backup_path:
  327. backup_path = self._default_backup_path(source_path)
  328. self.row_vars[idx]["site"].set(str(item.get("site", "")).strip())
  329. self.row_vars[idx]["bu_no"].set(str(item.get("bu_no", "")).strip())
  330. self.row_vars[idx]["equipment_no"].set(str(item.get("equipment_no", "")).strip())
  331. self.row_vars[idx]["source_path"].set(source_path)
  332. self.row_vars[idx]["backup_path"].set(backup_path)
  333. self.log("配置加载完成。")
  334. if self._can_auto_start():
  335. self.start_collect(auto_mode=True)
  336. except Exception as e:
  337. self.log("读取配置失败: %s" % e)
  338. def save_config(self):
  339. try:
  340. rows = self._get_valid_rows(require_complete=True, raise_on_empty=False)
  341. except ValueError as e:
  342. messagebox.showwarning("提示", str(e))
  343. return
  344. data = {
  345. "base_url": self.base_url_var.get().strip(),
  346. "poll_seconds": self.poll_seconds_var.get().strip(),
  347. "rows": rows,
  348. }
  349. try:
  350. self.config_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
  351. self.log("配置已保存: %s" % self.config_path)
  352. except Exception as e:
  353. messagebox.showerror("错误", "保存配置失败: %s" % e)
  354. return
  355. if self.running:
  356. self.active_rows = rows
  357. self.log("采集配置已更新,下一轮轮询自动生效。")
  358. elif self._can_auto_start():
  359. self.start_collect(auto_mode=True)
  360. def _can_auto_start(self):
  361. if not self._base_url():
  362. return False
  363. if not self._is_poll_seconds_valid():
  364. return False
  365. try:
  366. rows = self._get_valid_rows(require_complete=True, raise_on_empty=True)
  367. return len(rows) > 0
  368. except Exception:
  369. return False
  370. def _is_poll_seconds_valid(self):
  371. try:
  372. return int(self.poll_seconds_var.get().strip()) > 0
  373. except Exception:
  374. return False
  375. def _base_url(self):
  376. return self.base_url_var.get().strip().rstrip("/")
  377. def _get_valid_rows(self, require_complete, raise_on_empty):
  378. rows = []
  379. for idx, row in enumerate(self.row_vars):
  380. site = row["site"].get().strip()
  381. bu_no = row["bu_no"].get().strip()
  382. equipment_no = row["equipment_no"].get().strip()
  383. source_path = row["source_path"].get().strip()
  384. backup_path = row["backup_path"].get().strip()
  385. filled_count = 0
  386. for value in (site, bu_no, equipment_no, source_path, backup_path):
  387. if value:
  388. filled_count += 1
  389. if filled_count == 0:
  390. continue
  391. required_count = 0
  392. for value in (site, bu_no, equipment_no, source_path):
  393. if value:
  394. required_count += 1
  395. if require_complete and required_count < 4:
  396. raise ValueError("%d行配置未填写完整,请补全site、buNo、equipmentNo、本地目录。" % (idx + 1))
  397. if required_count == 4:
  398. if not backup_path:
  399. backup_path = self._default_backup_path(source_path)
  400. if os.path.normcase(os.path.normpath(source_path)) == os.path.normcase(os.path.normpath(backup_path)):
  401. raise ValueError("%d行配置错误:本地目录和备份目录不能相同。" % (idx + 1))
  402. rows.append({
  403. "row_index": idx + 1,
  404. "site": site,
  405. "bu_no": bu_no,
  406. "equipment_no": equipment_no,
  407. "source_path": source_path,
  408. "backup_path": backup_path,
  409. })
  410. if raise_on_empty and len(rows) == 0:
  411. raise ValueError("请至少填写一行完整采集配置。")
  412. return rows
  413. def start_collect(self, auto_mode=False):
  414. if self.running and self.worker_thread is not None and self.worker_thread.is_alive():
  415. if not auto_mode:
  416. self.log("采集任务已在运行中。")
  417. return
  418. if self.running and (self.worker_thread is None or not self.worker_thread.is_alive()):
  419. self.log("检测到采集线程已退出,正在自动重启。")
  420. self.running = False
  421. if not self._base_url():
  422. if auto_mode:
  423. self.log("自动启动失败:系统地址为空。")
  424. else:
  425. messagebox.showwarning("提示", "请先设置系统地址")
  426. return
  427. try:
  428. poll_seconds = int(self.poll_seconds_var.get().strip() or "10")
  429. if poll_seconds <= 0:
  430. raise ValueError("轮询秒必须大于0")
  431. except Exception:
  432. if auto_mode:
  433. self.log("自动启动失败:轮询秒无效。")
  434. else:
  435. messagebox.showwarning("提示", "轮询秒必须是大于0的整数")
  436. return
  437. try:
  438. rows = self._get_valid_rows(require_complete=True, raise_on_empty=True)
  439. except ValueError as e:
  440. if auto_mode:
  441. self.log("自动启动失败:%s" % e)
  442. else:
  443. messagebox.showwarning("提示", str(e))
  444. return
  445. self.active_rows = rows
  446. # 每次手动/自动启动都重置一次状态,避免停启后误判“已同步”。
  447. self.upload_state = {}
  448. self.notice_ts = {}
  449. with self.processing_lock:
  450. self.processing_files.clear()
  451. self.stop_event = threading.Event()
  452. self.running = True
  453. self.worker_thread = threading.Thread(target=self._worker_loop, args=(self.stop_event,), daemon=True)
  454. self.worker_thread.start()
  455. if auto_mode:
  456. self.log("配置有效,已自动启动轮询同步。")
  457. else:
  458. self.log("采集任务已启动。")
  459. def stop_collect(self):
  460. if not self.running:
  461. self.log("采集任务未运行。")
  462. return
  463. self.running = False
  464. self.stop_event.set()
  465. self.log("正在停止采集任务...")
  466. def _on_window_close(self):
  467. self.root.iconify()
  468. self._log_with_interval("window_hidden", "配置窗口已最小化到任务栏,采集仍在后台运行。", 5)
  469. def exit_app(self):
  470. self.running = False
  471. self.stop_event.set()
  472. self.control_stop_event.set()
  473. try:
  474. self.http_session.close()
  475. except Exception:
  476. pass
  477. self.root.destroy()
  478. def _watch_worker(self):
  479. try:
  480. if self.running and (self.worker_thread is None or not self.worker_thread.is_alive()):
  481. self.log("检测到采集线程中断,正在自动恢复...")
  482. self.running = False
  483. self.start_collect(auto_mode=True)
  484. finally:
  485. self.root.after(3000, self._watch_worker)
  486. def _worker_loop(self, stop_event):
  487. while not stop_event.is_set():
  488. try:
  489. rows = list(self.active_rows)
  490. for row in rows:
  491. if stop_event.is_set():
  492. break
  493. try:
  494. self._sync_one_row(row, stop_event)
  495. except Exception as row_error:
  496. self.log("%d行同步异常,已跳过并继续: %s" % (row.get("row_index", 0), row_error))
  497. try:
  498. poll_seconds = int(self.poll_seconds_var.get().strip() or "10")
  499. except Exception:
  500. poll_seconds = 10
  501. for _ in range(max(1, poll_seconds)):
  502. if stop_event.is_set():
  503. break
  504. time.sleep(1)
  505. except Exception as e:
  506. self.log("采集线程异常,将自动继续: %s" % e)
  507. for _ in range(5):
  508. if stop_event.is_set():
  509. break
  510. time.sleep(1)
  511. if self.stop_event is stop_event:
  512. self.running = False
  513. self.worker_thread = None
  514. self.log("采集任务已停止。")
  515. def _sync_one_row(self, row, stop_event):
  516. row_index = row["row_index"]
  517. source_path = row["source_path"]
  518. path_key = "row%d_missing" % row_index
  519. empty_key = "row%d_empty" % row_index
  520. if not os.path.isdir(source_path):
  521. self._log_with_interval(path_key, "%d行目录不存在: %s" % (row_index, source_path), 60)
  522. return
  523. files = []
  524. try:
  525. for name in os.listdir(source_path):
  526. file_path = Path(source_path) / name
  527. if file_path.is_file():
  528. files.append(file_path)
  529. except Exception as e:
  530. self._log_with_interval(path_key, "%d行读取目录失败: %s, 原因: %s" % (row_index, source_path, e), 30)
  531. return
  532. self._cleanup_row_upload_state(row_index, files)
  533. if not files:
  534. self._log_with_interval(empty_key, "%d行目录为空,等待新文件..." % row_index, 60)
  535. return
  536. files.sort(key=self._safe_mtime)
  537. for file_path in files:
  538. if stop_event.is_set():
  539. return
  540. state_key = "%d|%s" % (row_index, str(file_path).lower())
  541. if not self._try_acquire_processing(state_key):
  542. continue
  543. try:
  544. signature = self._file_signature(file_path)
  545. if signature is None:
  546. continue
  547. # 已上传但上次备份失败,则仅重试备份,避免重复上传造成服务器/备份放大。
  548. if self.upload_state.get(state_key) == signature:
  549. if self._backup_and_remove_file(row, file_path):
  550. self.upload_state.pop(state_key, None)
  551. continue
  552. if self._upload_file(row, file_path):
  553. self.upload_state[state_key] = signature
  554. if self._backup_and_remove_file(row, file_path):
  555. # 文件已从源目录移走,立即清理状态,允许后续同名新文件再次同步。
  556. self.upload_state.pop(state_key, None)
  557. finally:
  558. self._release_processing(state_key)
  559. def _cleanup_row_upload_state(self, row_index, files):
  560. prefix = "%d|" % row_index
  561. current_keys = set()
  562. for file_path in files:
  563. current_keys.add("%d|%s" % (row_index, str(file_path).lower()))
  564. stale_keys = []
  565. for key in list(self.upload_state.keys()):
  566. if key.startswith(prefix) and key not in current_keys:
  567. stale_keys.append(key)
  568. for key in stale_keys:
  569. self.upload_state.pop(key, None)
  570. def _try_acquire_processing(self, state_key):
  571. with self.processing_lock:
  572. if state_key in self.processing_files:
  573. return False
  574. self.processing_files.add(state_key)
  575. return True
  576. def _release_processing(self, state_key):
  577. with self.processing_lock:
  578. self.processing_files.discard(state_key)
  579. def _file_signature(self, file_path):
  580. try:
  581. stat = file_path.stat()
  582. return stat.st_mtime_ns, stat.st_size
  583. except Exception as e:
  584. self.log("读取文件状态失败: %s, 原因: %s" % (file_path, e))
  585. return None
  586. def _safe_mtime(self, file_path):
  587. try:
  588. return file_path.stat().st_mtime
  589. except Exception:
  590. return 0
  591. def _get_backup_dir(self, row):
  592. backup_path = str(row.get("backup_path", "")).strip()
  593. if backup_path:
  594. return Path(backup_path)
  595. return Path(self._default_backup_path(row["source_path"]))
  596. def _backup_and_remove_file(self, row, file_path):
  597. source_path = row["source_path"]
  598. row_index = row["row_index"]
  599. backup_dir = self._get_backup_dir(row)
  600. if os.path.normcase(os.path.normpath(str(source_path))) == os.path.normcase(os.path.normpath(str(backup_dir))):
  601. self.log("%d行备份目录不能与本地目录相同: %s" % (row_index, backup_dir))
  602. return False
  603. try:
  604. backup_dir.mkdir(parents=True, exist_ok=True)
  605. except Exception as e:
  606. self.log("%d行创建备份目录失败: %s, 原因: %s" % (row_index, backup_dir, e))
  607. return False
  608. if not file_path.exists():
  609. self.log("%d行源文件已不存在,视为已备份完成: %s" % (row_index, file_path.name))
  610. return True
  611. target = self._build_unique_backup_target(backup_dir, file_path)
  612. try:
  613. shutil.move(str(file_path), str(target))
  614. self.log("%d行源文件已转移至备份: %s -> %s" % (row_index, file_path.name, target))
  615. return True
  616. except FileNotFoundError:
  617. self.log("%d行源文件在备份时已被处理,忽略重复备份: %s" % (row_index, file_path.name))
  618. return True
  619. except Exception as e:
  620. self.log("%d行源文件备份转移失败: %s, 原因: %s" % (row_index, file_path.name, e))
  621. return False
  622. def _build_unique_backup_target(self, backup_dir, file_path):
  623. target = backup_dir / file_path.name
  624. if not target.exists():
  625. return target
  626. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
  627. base_name = file_path.stem
  628. suffix = file_path.suffix
  629. candidate = backup_dir / ("%s_%s%s" % (base_name, timestamp, suffix))
  630. index = 1
  631. while candidate.exists():
  632. candidate = backup_dir / ("%s_%s_%d%s" % (base_name, timestamp, index, suffix))
  633. index += 1
  634. return candidate
  635. def _upload_file(self, row, file_path):
  636. url = self._base_url() + "/collector/client/upload"
  637. data = {
  638. "site": row["site"],
  639. "buNo": row["bu_no"],
  640. "equipmentNo": row["equipment_no"],
  641. }
  642. try:
  643. with open(file_path, "rb") as fp:
  644. files = {"file": (file_path.name, fp, "application/octet-stream")}
  645. resp = self.http_session.post(url, data=data, files=files, timeout=180)
  646. resp.raise_for_status()
  647. result = resp.json()
  648. if result.get("code") == 0:
  649. response_data = result.get("data") or {}
  650. server_path = response_data.get("savedFullPath", "")
  651. self.log("%d行上传成功: %s -> %s" % (row["row_index"], file_path.name, server_path))
  652. return True
  653. self.log("%d行上传失败: %s, 原因: %s" % (
  654. row["row_index"], file_path.name, result.get("msg")))
  655. return False
  656. except Exception as e:
  657. self.log("%d行上传异常: %s, 原因: %s" % (row["row_index"], file_path.name, e))
  658. return False
  659. def _log_with_interval(self, key, message, seconds):
  660. now = time.time()
  661. last = self.notice_ts.get(key, 0)
  662. if now - last >= seconds:
  663. self.notice_ts[key] = now
  664. self.log(message)
  665. def log(self, message):
  666. self.log_queue.put("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), message))
  667. def _flush_logs(self):
  668. while not self.log_queue.empty():
  669. line = self.log_queue.get()
  670. self.log_text.configure(state=NORMAL)
  671. self.log_text.insert(END, line + "\n")
  672. self.log_text.see(END)
  673. self.log_text.configure(state=DISABLED)
  674. self.root.after(200, self._flush_logs)
  675. if __name__ == "__main__":
  676. instance_lock_handle, is_primary = acquire_single_instance_lock()
  677. if not is_primary:
  678. notify_existing_instance_show()
  679. sys.exit(0)
  680. app_root = Tk()
  681. app = CollectorApp(app_root)
  682. app_root.mainloop()
  683. if instance_lock_handle and os.name == "nt":
  684. try:
  685. import ctypes
  686. ctypes.windll.kernel32.CloseHandle(instance_lock_handle)
  687. except Exception:
  688. pass