166 Commits

Author SHA1 Message Date
Gitea Actions Bot
7fb90ace65 [skip ci] Update version to v1.5.71 2025-11-04 17:05:46 +08:00
kris 郝
01aed205fe [patch] 正式环境代码提交 2025-11-04 16:44:56 +08:00
Gitea Actions Bot
14991ae2aa [skip ci] Update version to v1.5.70 2025-11-04 14:35:46 +08:00
kris 郝
1de233b197 [patch] 更新PDD过滤消息基础逻辑 过滤部分系统机器人消息 2025-11-04 14:23:53 +08:00
Gitea Actions Bot
32a17fb255 [skip ci] Update version to v1.5.69 2025-11-03 16:13:35 +08:00
kris 郝
4f17092305 [patch] 修复因自动更新回归代码config.py因覆盖逻辑导致的新增代码被覆盖的逻辑 2025-11-03 15:51:00 +08:00
kris 郝
58868c9c18 [patch] 修复因自动更新回归代码config.py因覆盖逻辑导致的新增代码被覆盖的逻辑 2025-11-03 14:56:01 +08:00
Gitea Actions Bot
7054beebeb [skip ci] Update version to v1.5.67 2025-11-03 11:52:42 +08:00
kris 郝
8a5da5e906 [patch] 新增多实例测试开关控制流 逻辑 2025-11-03 11:31:20 +08:00
Gitea Actions Bot
2083516b8c [skip ci] Update version to v1.5.66 2025-10-31 14:01:18 +08:00
f11e5ec210 [patch] 新增JD图片视频类型数据发送方法编写(强化send_message方法) 新增下载视频临时处理存储逻辑 2025-10-31 13:48:52 +08:00
Gitea Actions Bot
27b15773e0 [skip ci] Update version to v1.5.65 2025-10-30 17:26:16 +08:00
8575de6ea5 [patch] 优化更新提示逻辑 与 样式细节 2025-10-30 16:54:24 +08:00
Gitea Actions Bot
5440aa7d6d [skip ci] Update version to v1.5.64 2025-10-30 15:26:47 +08:00
30ac26160b [patch] 处理DY安装包打包后图片视频暂存路径部分权限问题 处理编码问题 处理js内置环境dll补充 2025-10-30 14:57:58 +08:00
b884cc97a0 [patch] 处理DY安装包打包后图片视频暂存路径部分权限问题 处理编码问题 处理js内置环境dll补充 2025-10-30 14:03:40 +08:00
f1b9e35b09 [patch] 处理DY安装包打包后图片视频暂存路径部分权限问题 处理编码问题 处理js内置环境dll补充 2025-10-30 12:01:58 +08:00
Gitea Actions Bot
5945e8c761 [skip ci] Update version to v1.5.60 2025-10-30 09:41:54 +08:00
cd96935c6b [patch] 处理DY安装包打包后图片视频暂存路径部分权限问题 处理编码问题 2025-10-30 09:25:07 +08:00
Gitea Actions Bot
a807bdb74d [skip ci] Update version to v1.5.59 2025-10-29 17:31:51 +08:00
a95278368d [patch] 处理DY安装包打包后图片视频暂存路径部分权限问题 2025-10-29 17:05:06 +08:00
Gitea Actions Bot
791f98bf61 [skip ci] Update version to v1.5.58 2025-10-29 16:20:10 +08:00
e024150c5a [patch] 新增自动更新功能 模块 优化更新后打开--不被遮挡 2025-10-29 15:56:04 +08:00
89d09777a0 [patch] 新增自动更新功能 模块 优化更新后打开--不被遮挡 2025-10-29 15:55:53 +08:00
Gitea Actions Bot
aa61e8a931 [skip ci] Update version to v1.5.56 2025-10-29 15:21:56 +08:00
8c0580d140 [patch] 新增自动更新功能 模块 优化进度条显示 横幅图片资源上传 2025-10-29 14:58:09 +08:00
Gitea Actions Bot
8f1b51ef4f [skip ci] Update version to v1.5.55 2025-10-28 17:40:46 +08:00
16d5d95c4e [patch] 新增自动更新功能 模块Test-center 2025-10-28 17:15:56 +08:00
Gitea Actions Bot
5945e1b0e7 [skip ci] Update version to v1.5.54 2025-10-28 16:27:01 +08:00
bd363d0e33 [minor] 新增自动更新功能 模块Test 2025-10-28 15:59:18 +08:00
Gitea Actions Bot
3d155fb097 [skip ci] Update version to v1.5.53 2025-10-27 18:02:54 +08:00
1f1deb5f7f [patch] 新增DY 图片 视频上传 发送等方法逻辑集成 优化抖音心跳维护与弹性发送心跳包 DY集成内置js环境 PDD取消过滤系统机器消息 2025-10-27 16:32:07 +08:00
Gitea Actions Bot
787c52a8dc [skip ci] Update version to v1.5.51 2025-10-25 09:35:23 +08:00
7a1ad47439 [patch] 因pdd内部回复消息接口与登录接口不同步导致监听过程中发送消息接口返回会话过期处理优化逻辑 并设计开发提示用户 并提供用户是否重新发送会话过期消息体按钮 2025-10-24 17:47:28 +08:00
Gitea Actions Bot
9effc859ac [skip ci] Update version to v1.5.49 2025-10-23 09:26:08 +08:00
8d31834112 [patch] 重连机制新增is_reconnect参数 2025-10-23 09:14:43 +08:00
9f0c81ea82 [patch] 重连机制新增is_reconnect参数 2025-10-22 17:45:06 +08:00
Gitea Actions Bot
df7f38bbd8 [skip ci] Update version to v1.5.47 2025-10-22 17:39:20 +08:00
Gitea Actions Bot
782c56f9b6 [patch] 修复抖音消息格式 过滤抖音系统消息 失效后发送结构体给后端 2025-10-22 17:14:05 +08:00
Gitea Actions Bot
38267640c3 [skip ci] Update version to v1.5.46 2025-10-22 15:04:00 +08:00
1b08ff46b7 [patch] DY日志优化2 2025-10-22 14:50:36 +08:00
Gitea Actions Bot
099c3e2db2 [skip ci] Update version to v1.5.45 2025-10-22 13:29:08 +08:00
ff6203d387 [patch] DY-token检测逻辑修改 修改本地打包环境路径锁定 2025-10-22 13:16:26 +08:00
286d7916e9 [patch] 优化DY日志结构 降低冗余代码 2025-10-22 11:53:30 +08:00
Gitea Actions Bot
a7864a05ee [skip ci] Update version to v1.5.43 2025-10-21 16:45:57 +08:00
cf72bc6827 [patch] 禁用ks3代理 2025-10-21 16:19:17 +08:00
03457a7f9e [patch] 设计PDD的处理消息架构为msg_type 通用模式 处理PDD支持图片类型和 商品卡片类型的消息的发送回复 修改GUI托盘内部样式 2025-10-21 16:04:29 +08:00
0c92d8e4c2 [patch] 设计PDD的处理消息架构为msg_type 通用模式 处理PDD支持图片类型和 商品卡片类型的消息的发送回复 修改GUI托盘内部样式 2025-10-21 15:38:17 +08:00
2f1f409710 [patch] 设计PDD的处理消息架构为msg_type 通用模式 处理PDD支持图片类型和 商品卡片类型的消息的发送回复 修改GUI托盘内部样式 2025-10-21 15:19:35 +08:00
9b21287bd0 [patch] 设计PDD的处理消息架构为msg_type 通用模式 处理PDD支持图片类型和 商品卡片类型的消息的发送回复 修改GUI托盘内部样式 2025-10-21 14:20:19 +08:00
Gitea Actions Bot
f7606f09cf [skip ci] Update version to v1.5.38 2025-10-18 17:45:47 +08:00
449ebf83cb [patch] 处理PDD登录时get_auth_token方法报出的response返回异常未处理报错问题 2025-10-18 17:15:16 +08:00
fd6dbfc8da [patch] 处理PDD登录时get_auth_token方法报出的response返回异常未处理报错问题 2025-10-18 16:12:57 +08:00
Gitea Actions Bot
ceb5d83d75 [skip ci] Update version to v1.5.36 2025-10-17 18:05:28 +08:00
8eb03ddedc [patch] 新增用户余额不足 交互模式代码 2025-10-17 17:38:02 +08:00
Gitea Actions Bot
a5c2a44512 [skip ci] Update version to v1.5.35 2025-10-17 17:29:41 +08:00
Gitea Actions Bot
3853b8faf8 [patch] 新增拼多多处理聊天页商品卡片 2025-10-17 17:05:02 +08:00
Gitea Actions Bot
67f83e94dc [skip ci] Update version to v1.5.34 2025-10-17 16:14:35 +08:00
fe2b29da9c [patch] config配置修改 ping机制 2025-10-17 15:50:37 +08:00
Gitea Actions Bot
3dd3ceeb3e [skip ci] Update version to v1.5.33 2025-10-17 15:45:17 +08:00
c32d326c69 [patch] 修改适中心跳机制 JD的平台登录抢占 提示弹框优化 并下发连接断开通知 托盘右击提示小标题显示精炼 增加store_name入回调方法做店铺名称定位 2025-10-17 15:21:03 +08:00
Gitea Actions Bot
90bf763bde [skip ci] Update version to v1.5.32 2025-10-17 11:47:44 +08:00
1438e39ffd [patch] 优化心跳检测逻辑 优化与后端交互连接处理逻辑 新增自动登录与手动下发冲突问题解决方法块 2025-10-17 11:29:27 +08:00
Gitea Actions Bot
e2ab13d599 [skip ci] Update version to v1.5.31 2025-10-15 16:52:52 +08:00
737737a6c1 [patch] 优化PDD的js环境补充 优化配置文件心跳环境检测 优化打包环境补充逻辑 2025-10-15 16:26:44 +08:00
Gitea Actions Bot
e15c1db49b [skip ci] Update version to v1.5.30 2025-10-15 10:15:40 +08:00
340670742c [patch] 优化提交检测合并逻辑 2025-10-15 09:55:11 +08:00
10cefc3c11 [patch] PDD售后消息的过滤处理逻辑优化 2025-10-15 09:28:54 +08:00
Gitea Actions Bot
bdd3e125a0 [skip ci] Update version to v1.5.28 2025-10-14 18:38:16 +08:00
9a4e2bba79 [patch] 修改打包逻辑 优化js代码因环境不兼容问题处理方案开发 2025-10-14 18:07:18 +08:00
Gitea Actions Bot
92d69d9166 [skip ci] Update version to v1.5.27 2025-10-14 17:53:39 +08:00
Gitea Actions Bot
7312d3f91a [patch] 修正京东丢失客服列表功能 2025-10-14 17:26:53 +08:00
Gitea Actions Bot
3d20c47eb9 [skip ci] Update version to v1.5.26 2025-10-13 17:32:02 +08:00
177eddc695 [patch] 修改GUI页面布局以自适应横幅位置 与 横幅跳转功能实现 2025-10-13 17:07:22 +08:00
Gitea Actions Bot
5e9e87bbba [skip ci] Update version to v1.5.25 2025-10-13 15:57:13 +08:00
3c57b99064 [patch] 修改打包固定项缓存复用 提升打包效率 2025-10-13 15:31:54 +08:00
9d9677972b [patch] 修改打包固定项缓存复用 提升打包效率 2025-10-13 15:18:39 +08:00
a2fa008cb6 [patch] 修改打包固定项缓存复用 提升打包效率 2025-10-13 15:07:00 +08:00
303c33b44d [patch] 修改打包固定项缓存复用 提升打包效率 2025-10-13 14:58:56 +08:00
Gitea Actions Bot
7d9297616f [skip ci] Update version to v1.5.21 2025-10-13 14:12:30 +08:00
Gitea Actions Bot
f651e0e3f0 [patch] 修正pdd不识别系统消息的问题 2025-10-13 14:04:01 +08:00
Gitea Actions Bot
461d743ea6 [skip ci] Update version to v1.5.20 2025-10-13 13:52:00 +08:00
5896b422f7 [patch] 修改逻辑: 在用户误触或开了多个GUI程序的时候不能同时建立多个连接 确保一个账号只能建立一个与后端的连接 友好提示的集成review 修改powershell语法问题 2025-10-13 13:34:23 +08:00
aabd450ea3 [patch] 修改逻辑: 在用户误触或开了多个GUI程序的时候不能同时建立多个连接 确保一个账号只能建立一个与后端的连接 友好提示的集成review 修改powershell语法问题 2025-10-13 13:02:24 +08:00
0abac8518c [patch] 修改逻辑: 在用户误触或开了多个GUI程序的时候不能同时建立多个连接 确保一个账号只能建立一个与后端的连接 友好提示的集成review 修改powershell语法问题 2025-10-13 12:38:54 +08:00
5247de38df [patch] 修改逻辑: 在用户误触或开了多个GUI程序的时候不能同时建立多个连接 确保一个账号只能建立一个与后端的连接 友好提示的集成review 修改powershell语法问题 2025-10-13 12:06:50 +08:00
40846dd7f4 [patch] 修改逻辑: 在用户误触或开了多个GUI程序的时候不能同时建立多个连接 确保一个账号只能建立一个与后端的连接 友好提示的集成review 2025-10-13 11:39:38 +08:00
29fdf85c07 [patch] 修改逻辑: 在用户误触或开了多个GUI程序的时候不能同时建立多个连接 确保一个账号只能建立一个与后端的连接 友好提示的集成review 2025-10-13 10:53:42 +08:00
f6c55d4185 [patch] 修改逻辑: 在用户误触或开了多个GUI程序的时候不能同时建立多个连接 确保一个账号只能建立一个与后端的连接 友好提示的集成review 2025-10-13 10:27:15 +08:00
Gitea Actions Bot
7ef6b36ff5 [skip ci] Update version to v1.5.13 2025-10-11 17:35:40 +08:00
Gitea Actions Bot
d8154025ea [patch] 修正ks3问题且优化pillow依赖安装时间 2025-10-11 17:09:09 +08:00
Gitea Actions Bot
5c73bbf8bc [patch] 修正ks3问题 2025-10-11 16:54:14 +08:00
Gitea Actions Bot
bcb76c0a99 [patch] 修正ks3包名问题 2025-10-11 16:43:59 +08:00
Gitea Actions Bot
99446eca74 [patch] 修正Pillow依赖冲突问题 2025-10-11 16:34:03 +08:00
Gitea Actions Bot
1472646624 [patch] 修正Pillow安装步骤 2025-10-11 16:30:01 +08:00
Gitea Actions Bot
b685e64e71 [patch] 安装Pillow依赖 2025-10-11 16:27:26 +08:00
Gitea Actions Bot
ee7c76ef68 [patch] 测试是否安装成功 2025-10-11 16:24:30 +08:00
Gitea Actions Bot
afcd360603 [patch] 修改nsis安装流程 2025-10-11 16:19:58 +08:00
Gitea Actions Bot
3352a59ff9 [patch] 修改nsis安装流程 2025-10-11 16:15:22 +08:00
Gitea Actions Bot
468a092fd9 [patch] 修改nsis安装流程 2025-10-11 16:11:26 +08:00
Gitea Actions Bot
7c0667cb39 [patch] 解决乱码bug 2025-10-11 16:08:34 +08:00
Gitea Actions Bot
2d05024e82 [patch] 解决乱码bug 2025-10-11 16:04:35 +08:00
Gitea Actions Bot
2dddcc9804 [patch] 添加develop用于测试 2025-10-11 15:56:12 +08:00
Gitea Actions Bot
d879626b56 [patch] 修复CI/CD中的GBK编码问题 2025-10-11 15:54:06 +08:00
Gitea Actions Bot
8ecec1edbe 实现更新版本管理 2025-10-11 15:46:39 +08:00
Gitea Actions Bot
95534f5304 [skip ci] Update version to v1.4.29 2025-10-11 15:30:39 +08:00
160b3f38ed [patch] 修改逻辑: 在用户误触或开了多个GUI程序的时候不能同时建立多个连接 确保一个账号只能建立一个与后端的连接 2025-10-11 15:30:12 +08:00
Gitea Actions Bot
2dd384e8e4 [skip ci] Update version to v1.4.28 2025-10-11 10:17:21 +08:00
c6ae22c7e1 [patch] PDD登录状态GUI回调优化 与 非原生git指令导致check任务失败 2025-10-11 10:17:01 +08:00
a6a9e5861e [patch] PDD登录状态GUI回调优化 2025-10-11 10:16:13 +08:00
Gitea Actions Bot
a584f2a61a [skip ci] Update version to v1.4.27 2025-10-11 10:15:03 +08:00
b80e205680 [patch] PDD登录状态GUI回调优化 2025-10-11 10:14:45 +08:00
Gitea Actions Bot
9a2ea3f2a3 [skip ci] Update version to v1.4.26 2025-10-11 10:13:08 +08:00
80e9bba722 [patch] PDD登录状态GUI回调优化 2025-10-11 10:12:45 +08:00
66dd6051e5 [patch] PDD登录状态GUI回调优化 2025-10-11 10:09:59 +08:00
3ab1c82a37 [patch] PDD登录状态GUI回调优化 2025-10-11 10:08:10 +08:00
d72fe1a781 [patch] PDD登录状态GUI回调优化 2025-10-11 10:03:58 +08:00
b7d9d73de5 [patch] PDD登录状态GUI回调优化 2025-10-11 09:59:26 +08:00
ca65024cd2 [patch] PDD登录状态GUI回调优化 2025-10-11 09:55:21 +08:00
jjz
2ae051f07d [skip ci] Update version to v1.4.24 2025-10-10 16:17:36 +08:00
31ca5d0819 [patch] 优化打包逻辑 控制变量为日志产出 和 安装包整合路径统一 2025-10-10 16:16:36 +08:00
jjz
9ff009712a [skip ci] Update version to v1.4.23 2025-10-10 15:35:24 +08:00
0d9ab498b1 Merge remote-tracking branch 'origin/develop' into develop 2025-10-10 15:34:36 +08:00
12c1b1dfb8 [patch] 优化版本管理显示 与 更新提示(修改表名信息) 修复因线程安全问题导致的崩溃 2025-10-10 15:34:26 +08:00
jjz
fa2b0486d0 [skip ci] Update version to v1.4.22 2025-10-10 14:42:07 +08:00
Gitea Actions
8c150054a0 强制空提交 2025-10-10 14:41:15 +08:00
jjz
a9cb0c6b4f [skip ci] Update version to v1.4.21 2025-10-10 14:39:19 +08:00
Gitea Actions
95acd6e76a 强制空提交 2025-10-10 14:38:28 +08:00
jjz
aab2a6767e [skip ci] Update version to v1.4.19 2025-10-10 14:32:04 +08:00
Gitea Actions
00391fba67 Merge branch 'develop' of http://120.92.90.209:3000/magua/shuidrop_gui into develop 2025-10-10 14:30:54 +08:00
Gitea Actions
f9cc46f879 [patch] 增强Git提交调试日志 2025-10-10 14:29:04 +08:00
jjz
a0c97dc4c2 [skip ci] Update version to v1.4.18 2025-10-10 14:27:28 +08:00
Gitea Actions
e60c99d81e [patch] 增强版本更新日志输出 2025-10-10 14:24:46 +08:00
jjz
734dd8d1fe [skip ci] Update version to v1.4.17 2025-10-10 14:20:35 +08:00
jjz
1b196ac4ca [skip ci] Update version to v1.4.16 2025-10-10 14:18:27 +08:00
Gitea Actions
0bdac1bff8 强制空提交 2025-10-10 14:17:27 +08:00
Gitea Actions
82e31e0ef0 Merge remote-tracking branch 'origin/develop' into develop 2025-10-10 14:16:52 +08:00
Gitea Actions
707ace7ba1 [patch] 修复CI/CD提交消息乱码问题 2025-10-10 14:15:24 +08:00
jjz
bc419e2d22 [skip ci] 馃 鑷姩鏇存柊鐗堟湰鍒?v1.4.15 2025-10-10 14:07:50 +08:00
Gitea Actions
dddf9a7a74 强制空提交 2025-10-10 13:55:04 +08:00
Gitea Actions
d47b9536ee 强制空提交 2025-10-10 13:53:09 +08:00
Gitea Actions
533b1d2b01 Merge remote-tracking branch 'origin/develop' into develop 2025-10-10 13:51:00 +08:00
Gitea Actions
1868d26472 优化ci/cd 2025-10-10 13:49:49 +08:00
jjz
1dcaf847a6 [skip ci] 馃 鑷姩鏇存柊鐗堟湰鍒?v1.4.14 2025-10-10 13:47:32 +08:00
Gitea Actions
8eb17baf35 强制空提交 2025-10-10 13:46:12 +08:00
jjz
e67392e2c0 [skip ci] 馃 鑷姩鏇存柊鐗堟湰鍒?v1.4.13 2025-10-10 12:05:19 +08:00
6ad58b81b0 Merge remote-tracking branch 'origin/develop' into develop 2025-10-10 12:04:29 +08:00
42633da853 [patch] 优化版本管理显示 与 更新提示(修改表名信息) 修复因线程安全问题导致的崩溃 2025-10-10 12:04:17 +08:00
jjz
b4e7d957e4 [skip ci] 馃 鑷姩鏇存柊鐗堟湰鍒?v1.4.12 2025-10-10 11:54:57 +08:00
c819bdaa1c [patch] 优化版本管理显示 与 更新提示(修改表名信息) 2025-10-10 11:53:56 +08:00
bb5ef9ccbf [patch] 优化版本管理显示 与 更新提示(修改表名信息) 2025-10-10 10:53:16 +08:00
982e896916 [patch] 优化版本管理显示 与 更新提示 2025-10-10 10:41:57 +08:00
a21e2693fa [patch] 优化版本管理显示 2025-10-10 10:10:07 +08:00
Gitea Actions
e7a7bd2644 提交ci/cd文件 2025-10-09 17:49:50 +08:00
Gitea Actions
b64f15a483 提交ci/cd文件 2025-10-09 17:49:19 +08:00
c08b2c1e54 Todo: 补充nsis集成需要的依赖 并提交uninstall logo的图片资源 并集成 2025-09-29 16:08:16 +08:00
4f8415bd6a Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	Utils/Dy/DyUtils.py
#	Utils/Pdd/PddUtils.py
#	WebSocket/BackendClient.py
#	WebSocket/backend_singleton.py
#	config.py
#	main.py
2025-09-29 15:41:28 +08:00
1ac50b99a9 Todo: 补充nsis集成需要的依赖 并提交uninstall logo的图片资源 并集成关于GUI版本控制显示代码 2025-09-29 15:38:34 +08:00
jjz
3a17ed0da1 初步实现提示用户更新 2025-09-29 14:49:32 +08:00
jjz
d5d205e575 实现pdd抖音平台cookie登录 2025-09-29 13:01:34 +08:00
jjz
4f2706d8d9 实现抖音验证码登录 2025-09-29 11:25:43 +08:00
c58cec750f Todo: 修改因exe_token错误导致的不断重连问题
Todo: 修改打包中.bat打包文件与测试打包脚本不一致问题
New: 新增installer安装包环境搭建数据
2025-09-28 17:00:02 +08:00
jjz
7f9894908d 修复回消息 2025-09-26 15:38:23 +08:00
jjz
13b30c6f75 Merge branch 'develop' of http://8.155.9.53:3000/magua/shuidrop_gui into develop 2025-09-26 14:40:16 +08:00
jjz
4dbc1a0ace 添加后端重连后剔除ws功能 2025-09-26 14:35:28 +08:00
jjz
19de5dee88 添加后端重连后剔除ws功能 2025-09-26 14:35:14 +08:00
29 changed files with 11694 additions and 1075 deletions

419
.gitea/README.md Normal file
View File

@@ -0,0 +1,419 @@
# 🚀 GUI客户端自动化版本管理系统
## 📋 概述
**与后端完全一致的版本管理系统** - 直接连接PostgreSQL数据库
-**直接数据库操作** - 与后端使用相同方式PostgreSQL
-**完全一致** - 相同的表、相同的字段、相同的逻辑
-**极简设计** - 无需API配置一次永久生效
-**全自动** - 提交代码即可,自动版本递增
---
## 🎯 核心特性
### 存储方式(与后端完全一致)
```
后端Django GUI客户端
↓ ↓
Django ORM psycopg2
↓ ↓
└──────────┬──────────────────┘
PostgreSQL
web_versionhistory表
```
| 特性 | 后端 | GUI |
|------|------|-----|
| **操作方式** | Django ORM | psycopg2SQL |
| **数据库** | PostgreSQL (8.155.9.53) | **相同** |
| **表名** | web_versionhistory | **相同** |
| **版本类型** | "Web端" | "水滴智能通讯插件" |
---
## 🚀 快速开始2步
### 1⃣ 安装依赖
```bash
pip install psycopg2-binary
```
### 2⃣ 提交代码
```bash
# 新增功能MINOR版本
git commit -m "[minor] 新增拼多多平台支持"
# 修复BugPATCH版本
git commit -m "[patch] 修复京东连接超时"
# 重大变更MAJOR版本
git commit -m "[major] 重构WebSocket架构"
# 推送触发自动版本发布
git push origin main
```
**就这么简单!** 🎉
---
## 📖 版本号规则
采用**语义化版本**Semantic Versioning`MAJOR.MINOR.PATCH`
| 版本类型 | 说明 | 示例 |
|---------|------|------|
| `MAJOR` | 重大架构变更 | 1.0.0 → 2.0.0 |
| `MINOR` | 新增功能 | 1.0.0 → 1.1.0 |
| `PATCH` | Bug修复、优化 | 1.0.0 → 1.0.1 |
---
## 🔍 Commit Message 关键词
### 手动标记(优先级最高)
```bash
git commit -m "[major] 重构WebSocket通信架构"
git commit -m "[minor] 新增拼多多平台支持"
git commit -m "[patch] 修复京东连接超时问题"
```
### 自动识别关键词
**MAJOR**: `重构`, `refactor`, `架构`, `breaking`
**MINOR**: `新增`, `add`, `feature`, `功能`, `实现`
**PATCH**: `修复`, `fix`, `bug`, `优化`, `调整`
---
## 📁 文件结构
```
shuidrop_gui/
├── .gitea/
│ ├── workflows/
│ │ └── gui-version-release.yml # CI/CD工作流
│ ├── scripts/
│ │ ├── gui_version_creator.py # ⭐ 核心:版本创建器(直接数据库)
│ │ ├── view_version_history.py # 版本历史查看
│ │ └── init_version_system.py # 系统初始化
│ └── README.md # 本文档
├── version_history.json # 版本历史本地备份
├── config.py # 包含 APP_VERSION
└── VERSION_MANAGEMENT_GUIDE.md # 快速入门指南
```
---
## 🛠️ 常用命令
### 查看版本历史
```bash
# 查看最近10个版本
python .gitea/scripts/view_version_history.py --list
# 查看最新版本
python .gitea/scripts/view_version_history.py --latest
# 查看特定版本详情
python .gitea/scripts/view_version_history.py --detail 1.2.3
# 导出更新日志
python .gitea/scripts/view_version_history.py --export
```
### 本地测试
```bash
# 设置数据库连接
export DB_HOST=8.155.9.53
export DB_PORT=5400
export DB_NAME=ai_web
export DB_USER=user_emKCAb
export DB_PASSWORD=password_ee2iQ3
# 运行版本创建脚本
python .gitea/scripts/gui_version_creator.py
```
---
## 🔄 版本创建流程
```
提交代码
git push origin main
Gitea Actions触发
gui_version_creator.py
┌────────────────────────────────┐
│ 1. 连接PostgreSQL数据库 │
│ (8.155.9.53:5400/ai_web) │
│ 2. 查询最新版本 │
│ SELECT version FROM ... │
│ 3. 分析commit message │
│ 识别版本类型 │
│ 4. 计算新版本号 │
│ 1.0.5 → 1.1.0 │
│ 5. 插入数据库 │
│ INSERT INTO web_versionhistory...│
│ 6. 保存本地JSON备份 │
│ 7. 更新config.py │
│ APP_VERSION = "1.1.0" │
└────────────────────────────────┘
完成!
```
---
## 💾 存储策略
### 主要存储PostgreSQL数据库
```sql
-- web_versionhistory表与后端共用
INSERT INTO web_versionhistory (
version, -- "1.1.0"
type, -- "水滴智能通讯插件"
content, -- commit message
download_url, -- 下载地址
release_time, -- 发布时间
is_delete -- FALSE
) VALUES (...);
```
### 备份存储本地JSON
```json
// version_history.json快速查询/离线使用)
[
{
"version": "1.1.0",
"update_type": "minor",
"content": "新增拼多多平台支持",
"author": "张三",
"release_time": "2025-10-09 14:30:00",
"stats": {
"files_changed": 5,
"lines_added": 234,
"lines_deleted": 56
}
}
]
```
---
## ⚙️ CI/CD配置
### 触发条件
- **分支**: `main`
- **事件**: `push`
### 环境变量(已配置好)
```yaml
env:
DB_HOST: 8.155.9.53 # 数据库主机
DB_PORT: 5400 # 端口
DB_NAME: ai_web # 数据库名
DB_USER: user_emKCAb # 用户名
DB_PASSWORD: password_ee2iQ3 # 密码
```
**这些配置与后端完全一致!**
### 执行步骤
1. 📦 检出代码
2. 🐍 设置Python环境
3. 📦 安装依赖psycopg2-binary
4. 🏷️ 创建版本记录(直接数据库操作)
5. 📦 自动打包(可选)
---
## 📊 版本历史格式
### PostgreSQL数据库主要
```sql
-- 与后端共享web_versionhistory表
SELECT * FROM web_versionhistory
WHERE type = '水滴智能通讯插件'
ORDER BY release_time DESC;
```
### 本地JSON备份
```json
[
{
"version": "1.1.0",
"update_type": "minor",
"content": "新增拼多多平台支持",
"author": "张三",
"commit_hash": "abc123def456...",
"release_time": "2025-10-09 14:30:00",
"stats": {...}
}
]
```
---
## 🔧 常见问题
### Q: 为什么不用API
**A**: 直接数据库操作的优势:
- ✅ 与后端存储方式完全一致
- ✅ 无需后端开发接口
- ✅ 更简单、更可靠
- ✅ 减少中间层故障点
### Q: 数据库连接失败怎么办?
**A**: 不影响使用!
- ✅ 本地JSON备份仍会保存
- ✅ config.py仍会更新
- ✅ 后续可手动同步数据库
### Q: 如何跳过版本发布?
**A**: 在commit message中添加 `[skip ci]`
```bash
git commit -m "更新文档 [skip ci]"
```
### Q: 如何验证版本记录?
**A**:
```bash
# 方式1: 查看本地备份
python .gitea/scripts/view_version_history.py --latest
# 方式2: 查询数据库
psql -h 8.155.9.53 -p 5400 -U user_emKCAb -d ai_web \
-c "SELECT * FROM web_versionhistory WHERE type='水滴智能通讯插件' LIMIT 5;"
```
---
## 🎓 学习资源
### 1. 查看快速入门指南
```bash
cat VERSION_MANAGEMENT_GUIDE.md
```
### 2. 查看实施说明
```bash
cat IMPLEMENTATION_NOTES.md
```
### 3. 初始化系统
```bash
python .gitea/scripts/init_version_system.py
```
---
## 📞 完整示例
### 场景:新增拼多多平台支持
```bash
# 1. 开发功能
# 修改: Utils/Pdd/PddUtils.py, config.py 等
# 2. 提交代码
git commit -m "[minor] 新增拼多多平台支持,实现消息收发和自动重连"
git push origin main
```
**自动执行**
1. Gitea Actions检测push
2. 连接PostgreSQL数据库
3. 查询最新版本: `1.0.5`
4. 版本递增: `1.0.5``1.1.0`
5. **插入数据库**: `INSERT INTO web_versionhistory ...`
6. 保存本地备份: `version_history.json`
7. 更新配置: `APP_VERSION = "1.1.0"`
**验证结果**
```bash
$ python .gitea/scripts/view_version_history.py --latest
🏷️ 最新版本: v1.1.0
📅 发布时间: 2025-10-09 14:30:00
👤 发布者: 张三
📝 内容: 新增拼多多平台支持,实现消息收发和自动重连
```
---
## 💡 核心优势
### 1. 与后端完全一致
- ✅ 相同的数据库PostgreSQL
- ✅ 相同的表web_versionhistory
- ✅ 相同的字段结构
- ✅ 相同的时间处理逻辑
### 2. 极简设计
- ✅ 只需安装1个依赖psycopg2-binary
- ✅ 无需后端开发API
- ✅ 配置一次,永久生效
### 3. 全自动
- ✅ 提交代码自动触发
- ✅ 自动版本递增
- ✅ 自动更新配置
### 4. 双重保障
- ✅ PostgreSQL数据库主要
- ✅ 本地JSON备份保底
---
## 🚀 下一步行动
```bash
# 1. 安装依赖(必需)
pip install psycopg2-binary
# 2. 初始化系统(推荐)
python .gitea/scripts/init_version_system.py
# 3. 测试提交
git commit -m "[patch] 测试版本管理系统"
git push origin main
# 4. 验证结果
python .gitea/scripts/view_version_history.py --latest
```
---
**恭喜!** 🎉 你现在拥有了一套**与后端完全一致**的自动化版本管理系统!
**版本**: 3.0(最终版)
**最后更新**: 2025-10-09
**核心特点**: 直接PostgreSQL数据库操作与后端完全一致

View File

@@ -0,0 +1,505 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GUI客户端版本创建器
⚠️ 仅用于CI/CD环境Gitea Actions不打包到用户端GUI
安全说明:
- 本脚本包含数据库凭证仅在受控的CI/CD环境运行
- 用户端GUI不包含此脚本避免数据库凭证泄漏
- 用户端GUI只读取本地version_history.json文件
"""
import os
import sys
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
# 添加项目根目录到路径
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s'
)
logger = logging.getLogger(__name__)
# 数据库配置(与后端一致)
# ⚠️ 仅在CI/CD环境使用不会打包到用户端
DB_CONFIG = {
'host': os.getenv('DB_HOST', '8.155.9.53'),
'port': int(os.getenv('DB_PORT', '5400')),
'database': os.getenv('DB_NAME', 'ai_web'),
'user': os.getenv('DB_USER', 'user_emKCAb'),
'password': os.getenv('DB_PASSWORD', 'password_ee2iQ3')
}
class GitUtils:
"""Git操作工具"""
@staticmethod
def run_command(args: list) -> Optional[str]:
"""执行Git命令"""
import subprocess
try:
result = subprocess.run(
['git'] + args,
capture_output=True,
text=True,
encoding='utf-8'
)
return result.stdout.strip() if result.returncode == 0 else None
except Exception as e:
logger.error(f"Git命令执行失败: {e}")
return None
@classmethod
def get_commit_info(cls) -> Dict[str, str]:
"""获取当前提交信息"""
return {
'message': cls.run_command(['log', '-1', '--pretty=format:%B']) or "自动发布",
'hash': cls.run_command(['rev-parse', 'HEAD']) or "",
'short_hash': (cls.run_command(['rev-parse', 'HEAD']) or "")[:8],
'author': cls.run_command(['log', '-1', '--pretty=format:%an']) or "系统",
'commit_date': cls.run_command(['log', '-1', '--pretty=format:%ci']) or "",
'branch': cls.run_command(['rev-parse', '--abbrev-ref', 'HEAD']) or "unknown"
}
@classmethod
def get_commit_stats(cls) -> Dict[str, int]:
"""获取提交统计"""
stats_output = cls.run_command(['diff', '--numstat', 'HEAD~1', 'HEAD'])
if not stats_output:
return {'files_changed': 0, 'lines_added': 0, 'lines_deleted': 0}
files_changed = 0
lines_added = 0
lines_deleted = 0
for line in stats_output.split('\n'):
if line.strip():
parts = line.split('\t')
if len(parts) >= 2:
try:
added = int(parts[0]) if parts[0].isdigit() else 0
deleted = int(parts[1]) if parts[1].isdigit() else 0
files_changed += 1
lines_added += added
lines_deleted += deleted
except (ValueError, IndexError):
continue
return {
'files_changed': files_changed,
'lines_added': lines_added,
'lines_deleted': lines_deleted
}
class DatabaseVersionManager:
"""
数据库版本管理器
⚠️ 安全警告仅在CI/CD环境使用
"""
def __init__(self):
self.project_root = PROJECT_ROOT
self.version_file = self.project_root / 'version_history.json'
self.config_file = self.project_root / 'config.py'
self.git_utils = GitUtils()
self.db_config = DB_CONFIG
self.conn = None
def connect_db(self):
"""连接数据库"""
try:
import psycopg2
self.conn = psycopg2.connect(**self.db_config)
logger.info("✅ 数据库连接成功")
return True
except ImportError:
logger.error("❌ psycopg2未安装请执行: pip install psycopg2-binary")
return False
except Exception as e:
logger.error(f"❌ 数据库连接失败: {e}")
return False
def close_db(self):
"""关闭数据库连接"""
if self.conn:
self.conn.close()
logger.info("数据库连接已关闭")
def analyze_update_type(self, commit_message: str) -> str:
"""分析更新类型"""
message = commit_message.lower()
# 手动标记优先
if '[major]' in message or '【major】' in message:
return 'major'
elif '[minor]' in message or '【minor】' in message:
return 'minor'
elif '[patch]' in message or '【patch】' in message:
return 'patch'
# 关键词识别
major_keywords = ['重构', 'refactor', '架构', 'framework', 'breaking', '大版本', '底层重写']
minor_keywords = ['新增', 'add', 'feature', '功能', 'feat:', 'new', '增加', 'implement', '实现']
patch_keywords = ['修复', 'fix', 'bug', '优化', 'optimize', 'improve', '调整', 'update', '界面', 'ui', '样式']
if any(keyword in message for keyword in major_keywords):
return 'major'
elif any(keyword in message for keyword in minor_keywords):
return 'minor'
elif any(keyword in message for keyword in patch_keywords):
return 'patch'
else:
return 'patch'
def get_latest_version_from_db(self) -> str:
"""从数据库获取最新版本"""
try:
cursor = self.conn.cursor()
cursor.execute("""
SELECT version FROM web_version_history
WHERE type = '水滴智能通讯插件' AND is_delete = FALSE
ORDER BY release_time DESC LIMIT 1
""")
result = cursor.fetchone()
cursor.close()
if result:
logger.info(f"📡 从数据库获取最新版本: v{result[0]}")
return result[0]
return "1.0.0"
except Exception as e:
logger.warning(f"⚠️ 从数据库获取版本失败: {e},使用默认版本")
return "1.0.0"
def calculate_next_version(self, update_type: str) -> str:
"""计算下一个版本号"""
current_version = self.get_latest_version_from_db()
try:
parts = current_version.split('.')
major = int(parts[0]) if len(parts) > 0 else 1
minor = int(parts[1]) if len(parts) > 1 else 0
patch = int(parts[2]) if len(parts) > 2 else 0
except (ValueError, IndexError):
return "1.0.0"
if update_type == 'major':
return f"{major + 1}.0.0"
elif update_type == 'minor':
return f"{major}.{minor + 1}.0"
else: # patch
return f"{major}.{minor}.{patch + 1}"
def check_duplicate_in_db(self, commit_hash: str) -> bool:
"""检查数据库中是否已存在该版本"""
if not commit_hash:
return False
commit_id = commit_hash[:16]
try:
cursor = self.conn.cursor()
cursor.execute("""
SELECT version FROM web_version_history
WHERE content LIKE %s AND type = '水滴智能通讯插件'
ORDER BY release_time DESC LIMIT 1
""", (f'%{commit_id}%',))
result = cursor.fetchone()
cursor.close()
if result:
logger.info(f"📡 数据库检测到重复版本: v{result[0]}")
return True
return False
except Exception as e:
logger.warning(f"⚠️ 检查重复版本失败: {e}")
return False
def save_to_database(self, version_record: dict) -> bool:
"""保存版本记录到数据库(与后端完全一致)"""
try:
# 时间处理:与后端保持一致
from datetime import timezone as dt_timezone
import uuid
beijing_time_naive = datetime.now()
beijing_time_as_utc = beijing_time_naive.replace(tzinfo=dt_timezone.utc)
# 生成UUID
record_id = str(uuid.uuid4())
cursor = self.conn.cursor()
cursor.execute("""
INSERT INTO web_version_history
(id, version, type, content, download_url, release_time, is_delete)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (
record_id,
version_record['version'],
'水滴智能通讯插件',
version_record['content'],
version_record.get('download_url', ''),
beijing_time_as_utc,
False
))
self.conn.commit()
cursor.close()
logger.info(f"✅ 版本记录已保存到数据库 (ID: {record_id})")
logger.info(f" 📦 版本: {version_record['version']}")
logger.info(f" 🔗 下载地址: {version_record.get('download_url', '')}")
logger.info(f" 📝 内容: {version_record['content'][:50]}...")
return True
except Exception as e:
self.conn.rollback()
logger.error(f"❌ 保存到数据库失败: {e}")
import traceback
logger.error(traceback.format_exc())
return False
def save_local_backup(self, version_record: dict):
"""
保存本地JSON备份
⚠️ 此文件会被打包到用户端GUI用于版本历史查看
"""
try:
# 读取现有历史
history = []
if self.version_file.exists():
with open(self.version_file, 'r', encoding='utf-8') as f:
history = json.load(f)
# 添加新记录
history.insert(0, version_record)
# 保留最近50个版本
if len(history) > 50:
history = history[:50]
# 保存
with open(self.version_file, 'w', encoding='utf-8') as f:
json.dump(history, f, ensure_ascii=False, indent=2)
logger.info(f"✅ 本地备份已保存: {self.version_file}")
logger.info(f" 此文件将打包到用户端GUI用于版本历史查看")
except Exception as e:
logger.warning(f"⚠️ 保存本地备份失败: {e}")
def update_config_version(self, new_version: str):
"""更新config.py中的APP_VERSION"""
logger.info(f"正在更新config.py到版本: {new_version}")
logger.info(f"配置文件路径: {self.config_file}")
logger.info(f"配置文件绝对路径: {self.config_file.absolute()}")
logger.info(f"配置文件是否存在: {self.config_file.exists()}")
if not self.config_file.exists():
logger.error(f"❌ 配置文件不存在: {self.config_file}")
return False
try:
# 读取文件
with open(self.config_file, 'r', encoding='utf-8') as f:
content = f.read()
logger.info(f"已读取config.py文件大小: {len(content)} 字节")
# 查找当前版本
import re
pattern = r'APP_VERSION\s*=\s*["\'][\d.]+["\']'
match = re.search(pattern, content)
if match:
old_version_line = match.group(0)
logger.info(f"找到当前版本行: {old_version_line}")
replacement = f'APP_VERSION = "{new_version}"'
new_content = re.sub(pattern, replacement, content)
logger.info(f"准备写入新版本: {replacement}")
# 写入文件
with open(self.config_file, 'w', encoding='utf-8') as f:
f.write(new_content)
# 验证写入
with open(self.config_file, 'r', encoding='utf-8') as f:
verify_content = f.read()
verify_match = re.search(pattern, verify_content)
if verify_match:
logger.info(f"✅ 写入验证成功: {verify_match.group(0)}")
logger.info(f"✅ 已更新 config.py: APP_VERSION = \"{new_version}\"")
return True
else:
logger.error(f"❌ 未找到APP_VERSION配置项pattern: {pattern}")
logger.info(f"文件内容预览前200字符: {content[:200]}")
return False
except Exception as e:
logger.error(f"❌ 更新config.py失败: {e}")
import traceback
logger.error(traceback.format_exc())
return False
def create_version_record(self) -> dict:
"""
创建版本记录(直接数据库操作)
⚠️ 仅在CI/CD环境运行
"""
try:
logger.info("=" * 70)
logger.info("🚀 开始创建GUI版本记录CI/CD环境")
logger.info("=" * 70)
# 1. 连接数据库
if not self.connect_db():
return {'success': False, 'error': '数据库连接失败'}
# 2. 获取Git提交信息
commit_info = self.git_utils.get_commit_info()
logger.info(f"📝 提交消息: {commit_info['message'][:100]}")
logger.info(f"👤 提交作者: {commit_info['author']}")
logger.info(f"🔖 提交哈希: {commit_info['short_hash']}")
# 3. 检查是否重复
if self.check_duplicate_in_db(commit_info['hash']):
logger.info(f"⏭️ 版本记录已存在,跳过创建")
self.close_db()
return {
'success': True,
'duplicate': True,
'message': '版本记录已存在'
}
# 4. 分析版本信息
update_type = self.analyze_update_type(commit_info['message'])
next_version = self.calculate_next_version(update_type)
logger.info(f"📊 更新类型: {update_type.upper()}")
logger.info(f"🔢 新版本号: v{next_version}")
# 5. 获取提交统计
stats = self.git_utils.get_commit_stats()
logger.info(f"📈 代码统计: {stats['files_changed']} 文件, "
f"+{stats['lines_added']}/-{stats['lines_deleted']}")
# 6. 创建版本记录
# 生成下载地址KS3对象存储 - 广州节点,使用三级域名格式)
installer_filename = f"ShuiDi_AI_Assistant_Setup_v{next_version}.exe"
download_url = f"https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/{installer_filename}"
# 完整示例: https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.0.exe
version_record = {
'version': next_version,
'update_type': update_type,
'content': commit_info['message'],
'author': commit_info['author'],
'commit_hash': commit_info['hash'],
'commit_short_hash': commit_info['short_hash'],
'branch': commit_info['branch'],
'release_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'download_url': download_url, # 新增:下载地址
'stats': stats
}
# 7. 保存到数据库主要存储CI/CD专用
logger.info("")
logger.info("💾 步骤1: 保存到PostgreSQL数据库...")
db_success = self.save_to_database(version_record)
# 8. 保存到本地JSON用户端可见
logger.info("💾 步骤2: 保存到本地JSON备份用户端可见...")
self.save_local_backup(version_record)
# 9. 更新config.py
logger.info("💾 步骤3: 更新config.py...")
self.update_config_version(next_version)
# 10. 关闭数据库连接
self.close_db()
# 11. 返回结果
logger.info("")
logger.info("=" * 70)
logger.info("✅ GUI版本记录创建完成")
logger.info("=" * 70)
logger.info(f"📦 版本号: v{next_version}")
logger.info(f"📂 类型: {update_type.upper()}")
logger.info(f"👤 作者: {commit_info['author']}")
logger.info(f"🔗 下载地址: {download_url}")
logger.info(f"📈 变更: {stats['files_changed']} 文件, "
f"+{stats['lines_added']}/-{stats['lines_deleted']}")
logger.info(f"💾 数据库: {'✅ 成功' if db_success else '❌ 失败'}")
logger.info(f"💾 本地备份: ✅ 成功")
logger.info(f"💾 config.py: ✅ 成功")
logger.info("=" * 70)
logger.info("🔒 安全提示本脚本仅在CI/CD环境运行不会打包到用户端")
logger.info("=" * 70)
return {
'success': True,
'duplicate': False,
'version': next_version,
'update_type': update_type,
'author': commit_info['author'],
'content': commit_info['message'],
'stats': stats,
'db_saved': db_success
}
except Exception as e:
logger.error(f"❌ 创建版本记录失败: {e}")
import traceback
logger.error(traceback.format_exc())
if self.conn:
self.close_db()
return {
'success': False,
'error': str(e)
}
def main():
"""主函数"""
try:
logger.info("🔒 安全检查此脚本应仅在CI/CD环境运行")
logger.info(" 用户端GUI不应包含此脚本")
logger.info("")
manager = DatabaseVersionManager()
result = manager.create_version_record()
# 输出JSON结果供CI/CD使用
print(json.dumps(result, ensure_ascii=False, indent=2))
# 设置退出码
if not result['success']:
sys.exit(1)
except Exception as e:
logger.error(f"脚本执行失败: {e}")
print(json.dumps({
'success': False,
'error': str(e)
}, ensure_ascii=False))
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Upload GUI installer to KS3 object storage
- Target directory: installers/
- Set proper HTTP headers for browser download
"""
import os
import sys
import logging
from pathlib import Path
# 🔥 强制禁用代理,避免代理连接问题
# 必须在所有网络操作之前设置
os.environ['NO_PROXY'] = '*'
os.environ['HTTP_PROXY'] = ''
os.environ['HTTPS_PROXY'] = ''
os.environ['http_proxy'] = ''
os.environ['https_proxy'] = ''
os.environ['ALL_PROXY'] = ''
os.environ['all_proxy'] = ''
try:
from ks3.connection import Connection
except ImportError:
print("ERROR: ks3sdk not installed. Please run: pip install ks3sdk")
sys.exit(1)
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s'
)
logger = logging.getLogger(__name__)
# KS3 Configuration (using actual project config)
KS3_ACCESS_KEY = 'AKLT0Ey7Nq7ZSXykki0X0RGG'
KS3_SECRET_KEY = 'OONxkt9wwJa1FIco21vCbirs1HB6AGzDWdRyV0k2'
KS3_ENDPOINT = 'ks3-cn-guangzhou.ksyuncs.com' # Guangzhou region
KS3_BUCKET = 'shuidrop-chat-server'
KS3_IS_SECURE = True # Use HTTPS
KS3_PREFIX = 'installers/'
def get_ks3_connection():
"""Get KS3 connection"""
try:
connection = Connection(
KS3_ACCESS_KEY,
KS3_SECRET_KEY,
host=KS3_ENDPOINT,
is_secure=KS3_IS_SECURE,
timeout=1800, # Increase timeout to 30 minutes for large files
)
logger.info(f"KS3 connection established: {KS3_ENDPOINT}")
return connection
except Exception as e:
logger.error(f"KS3 connection failed: {e}")
return None
def find_latest_installer():
"""Find the latest generated installer"""
project_root = Path(__file__).parent.parent.parent
installer_dir = project_root / 'installer' / 'output'
if not installer_dir.exists():
logger.error(f"Installer directory not found: {installer_dir}")
return None
installers = list(installer_dir.glob('*.exe'))
if not installers:
logger.error(f"No installer files found in: {installer_dir}")
return None
latest_installer = max(installers, key=lambda p: p.stat().st_mtime)
file_size_mb = latest_installer.stat().st_size / 1024 / 1024
logger.info(f"Found installer: {latest_installer.name}")
logger.info(f"File size: {file_size_mb:.2f} MB")
return latest_installer
def progress_callback(uploaded_bytes, total_bytes):
"""Upload progress callback"""
if total_bytes > 0:
percentage = (uploaded_bytes / total_bytes) * 100
uploaded_mb = uploaded_bytes / 1024 / 1024
total_mb = total_bytes / 1024 / 1024
logger.info(f"Upload progress: {percentage:.1f}% ({uploaded_mb:.1f}/{total_mb:.1f} MB)")
def upload_installer(connection, installer_path):
"""Upload installer to KS3 with progress tracking"""
try:
bucket = connection.get_bucket(KS3_BUCKET)
ks3_key = f"{KS3_PREFIX}{installer_path.name}"
file_size = installer_path.stat().st_size
file_size_mb = file_size / 1024 / 1024
logger.info(f"Starting upload to KS3...")
logger.info(f"Target path: {ks3_key}")
logger.info(f"File size: {file_size_mb:.2f} MB")
logger.info(f"Estimated time: ~{int(file_size_mb / 2)} seconds (assuming 2MB/s)")
logger.info("Please wait, this may take several minutes...")
key = bucket.new_key(ks3_key)
# Upload file with public read permission and progress tracking
# Use cb parameter for progress callback (called every 5% or every 10MB)
key.set_contents_from_filename(
str(installer_path),
headers={
'Content-Type': 'application/octet-stream',
'Content-Disposition': f'attachment; filename="{installer_path.name}"',
'Cache-Control': 'public, max-age=3600',
'x-kss-storage-class': 'STANDARD',
'x-kss-acl': 'public-read' # Set public read permission in headers
},
policy='public-read', # Set ACL policy
cb=progress_callback, # Progress callback function
num_cb=10 # Call callback 10 times during upload (every 10%)
)
# Generate download URL (using KS3 third-level domain format)
# Format: https://{bucket}.{endpoint}/{key}
protocol = 'https' if KS3_IS_SECURE else 'http'
download_url = f"{protocol}://{KS3_BUCKET}.{KS3_ENDPOINT}/{ks3_key}"
logger.info("")
logger.info("=" * 70)
logger.info("Upload successful!")
logger.info(f"Download URL: {download_url}")
logger.info("=" * 70)
return download_url
except Exception as e:
logger.error("")
logger.error("=" * 70)
logger.error(f"Upload failed: {e}")
logger.error("=" * 70)
import traceback
logger.error(traceback.format_exc())
return None
def main():
"""Main function"""
logger.info("=" * 70)
logger.info("Starting GUI installer upload to KS3")
logger.info(f"KS3 Endpoint: {KS3_ENDPOINT}")
logger.info(f"Bucket: {KS3_BUCKET}")
logger.info(f"Target directory: {KS3_PREFIX}")
logger.info("Proxy disabled: Direct connection to KS3")
logger.info("=" * 70)
installer_path = find_latest_installer()
if not installer_path:
return 1
connection = get_ks3_connection()
if not connection:
return 1
download_url = upload_installer(connection, installer_path)
if not download_url:
return 1
logger.info("=" * 70)
logger.info("Task completed successfully!")
logger.info(f"Installer: {installer_path.name}")
logger.info(f"Download URL: {download_url}")
logger.info("=" * 70)
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
版本历史查看工具
用于查看和管理GUI客户端的版本历史记录
"""
import json
import argparse
from pathlib import Path
from datetime import datetime
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
def load_version_history():
"""加载版本历史"""
version_file = PROJECT_ROOT / 'version_history.json'
if not version_file.exists():
return []
with open(version_file, 'r', encoding='utf-8') as f:
return json.load(f)
def display_version_list(limit: int = 10):
"""显示版本列表"""
history = load_version_history()
if not history:
print("📭 暂无版本历史记录")
return
print("=" * 80)
print(f"📦 GUI客户端版本历史 (共 {len(history)} 个版本)")
print("=" * 80)
for i, record in enumerate(history[:limit], 1):
version = record.get('version', 'Unknown')
update_type = record.get('update_type', 'unknown').upper()
author = record.get('author', 'Unknown')
release_time = record.get('release_time', 'Unknown')
content = record.get('content', '')[:60]
print(f"\n{i}. v{version} [{update_type}]")
print(f" 👤 作者: {author}")
print(f" 📅 时间: {release_time}")
print(f" 📝 内容: {content}{'...' if len(record.get('content', '')) > 60 else ''}")
stats = record.get('stats', {})
if stats:
print(f" 📊 变更: {stats.get('files_changed', 0)} 文件, "
f"+{stats.get('lines_added', 0)}/-{stats.get('lines_deleted', 0)}")
if len(history) > limit:
print(f"\n... 还有 {len(history) - limit} 个历史版本")
print("\n" + "=" * 80)
def display_version_detail(version: str):
"""显示特定版本的详细信息"""
history = load_version_history()
for record in history:
if record.get('version') == version:
print("=" * 80)
print(f"📦 GUI客户端版本详情: v{version}")
print("=" * 80)
print(f"版本号: v{record.get('version')}")
print(f"更新类型: {record.get('update_type', 'unknown').upper()}")
print(f"作者: {record.get('author')}")
print(f"发布时间: {record.get('release_time')}")
print(f"分支: {record.get('branch')}")
print(f"提交哈希: {record.get('commit_short_hash')}")
print(f"\n更新内容:\n{record.get('content')}")
stats = record.get('stats', {})
if stats:
print(f"\n提交统计:")
print(f" 文件变更: {stats.get('files_changed', 0)}")
print(f" 新增行数: +{stats.get('lines_added', 0)}")
print(f" 删除行数: -{stats.get('lines_deleted', 0)}")
print("=" * 80)
return
print(f"❌ 未找到版本: v{version}")
def display_latest_version():
"""显示最新版本"""
history = load_version_history()
if not history:
print("📭 暂无版本历史记录")
return
latest = history[0]
print(f"🏷️ 最新版本: v{latest.get('version')}")
print(f"📅 发布时间: {latest.get('release_time')}")
print(f"👤 发布者: {latest.get('author')}")
def export_changelog(output_file: str = None):
"""导出更新日志Markdown格式"""
history = load_version_history()
if not history:
print("📭 暂无版本历史记录")
return
output_file = output_file or (PROJECT_ROOT / 'CHANGELOG.md')
with open(output_file, 'w', encoding='utf-8') as f:
f.write("# 水滴GUI客户端更新日志\n\n")
f.write(f"*自动生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n")
for record in history:
version = record.get('version')
update_type = record.get('update_type', 'patch').upper()
release_time = record.get('release_time')
author = record.get('author')
content = record.get('content', '')
f.write(f"## v{version} - {release_time}\n\n")
f.write(f"**类型**: {update_type} | **作者**: {author}\n\n")
f.write(f"{content}\n\n")
stats = record.get('stats', {})
if stats:
f.write(f"*变更统计: {stats.get('files_changed', 0)} 文件, "
f"+{stats.get('lines_added', 0)}/-{stats.get('lines_deleted', 0)} 行*\n\n")
f.write("---\n\n")
print(f"✅ 更新日志已导出到: {output_file}")
def main():
"""主函数"""
parser = argparse.ArgumentParser(description='GUI版本历史查看工具')
parser.add_argument('--list', '-l', action='store_true', help='显示版本列表')
parser.add_argument('--limit', type=int, default=10, help='显示数量限制默认10')
parser.add_argument('--detail', '-d', type=str, help='查看特定版本详情')
parser.add_argument('--latest', action='store_true', help='显示最新版本')
parser.add_argument('--export', '-e', action='store_true', help='导出更新日志')
parser.add_argument('--output', '-o', type=str, help='导出文件路径')
args = parser.parse_args()
if args.latest:
display_latest_version()
elif args.detail:
display_version_detail(args.detail)
elif args.export:
export_changelog(args.output)
elif args.list or not any(vars(args).values()):
display_version_list(args.limit)
else:
parser.print_help()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,683 @@
name: GUI Version Release
on:
push:
branches: [ master, develop ] # Trigger on master and develop branches
jobs:
gui-version-release:
runs-on: windows
defaults:
run:
working-directory: E:\shuidrop_gui
steps:
# Step 1: Pull latest code
- name: Pull latest code
shell: powershell
run: |
Write-Host "==========================================";
Write-Host "Pulling latest code";
Write-Host "==========================================";
Write-Host "Working directory: $(Get-Location)";
Write-Host "Branch: ${{ github.ref_name }}";
Write-Host "Commit: ${{ github.sha }}";
Write-Host "";
git config core.autocrlf false;
git config core.longpaths true;
Write-Host "Fetching from origin...";
git fetch origin;
Write-Host "Checking out branch: ${{ github.ref_name }}";
git checkout ${{ github.ref_name }};
Write-Host "Pulling latest changes...";
git reset --hard origin/${{ github.ref_name }};
Write-Host "";
Write-Host "Current status:";
git log -1 --oneline;
Write-Host "==========================================";
# Step 2: Check Python environment
- name: Check Python environment
shell: powershell
run: |
Write-Host "Python version:"
try {
python --version
} catch {
Write-Host "Python not installed"
}
Write-Host "Pip version:"
try {
pip --version
} catch {
Write-Host "Pip not installed"
}
# Step 3: Install dependencies
- name: Install dependencies
shell: powershell
run: |
Write-Host "Installing psycopg2-binary..."
python -m pip install --upgrade pip
if ($LASTEXITCODE -ne 0) {
Write-Host "Failed to upgrade pip"
exit 1
}
pip install psycopg2-binary
if ($LASTEXITCODE -ne 0) {
Write-Host "Failed to install psycopg2-binary"
exit 1
}
pip install py-mini-racer
if ($LASTEXITCODE -ne 0) {
Write-Host "WARNING: Failed to install py-mini-racer (DY platform may not work)"
} else {
Write-Host "OK: py-mini-racer installed"
}
Write-Host "Dependencies installed successfully"
# Step 4: Create GUI version record
- name: Create version record
id: create_version
shell: powershell
run: |
Write-Host "Starting GUI version release process..."
Write-Host "Commit hash: ${{ github.sha }}"
Write-Host "Commit author: ${{ github.actor }}"
Write-Host "Branch: ${{ github.ref_name }}"
# Retry mechanism: maximum 3 attempts
$SUCCESS = $false
for ($i = 1; $i -le 3; $i++) {
Write-Host "Attempt $i to create version record..."
python .gitea/scripts/gui_version_creator.py
if ($LASTEXITCODE -eq 0) {
Write-Host "Version record created successfully"
$SUCCESS = $true
break
} else {
Write-Host "Attempt $i failed"
if ($i -eq 3) {
Write-Host "All 3 attempts failed"
} else {
Write-Host "Waiting 5 seconds before retry..."
Start-Sleep -Seconds 5
}
}
}
if (-not $SUCCESS) {
Write-Host "Version creation failed"
exit 1
}
# Verify version was updated
if (Test-Path "config.py") {
$configContent = Get-Content "config.py" -Raw
if ($configContent -match 'APP_VERSION\s*=\s*"([\d.]+)"') {
$VERSION = $matches[1]
Write-Host "Version updated successfully: $VERSION"
}
}
env:
DB_HOST: 8.155.9.53
DB_NAME: ai_web
DB_USER: user_emKCAb
DB_PASSWORD: password_ee2iQ3
DB_PORT: 5400
# Step 4.5: Build production executable
- name: Build production executable
if: success()
shell: powershell
env:
PYTHONIOENCODING: utf-8
run: |
Write-Host "==========================================";
Write-Host "Step 4.5: Build production executable";
Write-Host "==========================================";
# Check and install dependencies only if needed
Write-Host "Checking dependencies...";
# Check PyInstaller
$pyinstallerInstalled = python -c "try: import PyInstaller; print('installed')`nexcept: print('not-installed')" 2>$null;
if ($pyinstallerInstalled -ne "installed") {
Write-Host "Installing PyInstaller...";
python -m pip install pyinstaller --quiet;
} else {
Write-Host "PyInstaller: already installed";
}
# Check Pillow
$pillowInstalled = python -c "try: from PIL import Image; print('installed')`nexcept: print('not-installed')" 2>$null;
if ($pillowInstalled -ne "installed") {
Write-Host "Installing Pillow...";
python -m pip install Pillow --quiet;
} else {
Write-Host "Pillow: already installed";
}
Write-Host "";
Write-Host "Environment ready:";
Write-Host " Python:" (python --version);
Write-Host " PyInstaller:" (python -m PyInstaller --version);
Write-Host " Pillow:" (python -c "from PIL import Image; print(Image.__version__)");
Write-Host "";
python build_production.py;
if ($LASTEXITCODE -ne 0) {
Write-Host "Build failed";
exit 1;
}
Write-Host "Production build completed successfully";
Write-Host "";
# Step 4.6: Check or Install NSIS
- name: Check or Install NSIS
if: success()
shell: powershell
run: |
Write-Host "==========================================";
Write-Host "Step 4.6: Check or Install NSIS";
Write-Host "==========================================";
# Step 1: Check if NSIS is already installed
Write-Host "Checking if NSIS is already installed...";
$nsisPaths = @(
"C:\Program Files (x86)\NSIS",
"C:\Program Files\NSIS",
"C:\Tools\NSIS",
"$env:ProgramFiles\NSIS",
"$env:ProgramFiles(x86)\NSIS"
);
$nsisFound = $false;
$nsisPath = "";
foreach ($path in $nsisPaths) {
if (Test-Path $path) {
$makensisPath = Join-Path $path "makensis.exe";
if (Test-Path $makensisPath) {
$nsisPath = $path;
$nsisFound = $true;
Write-Host "Found NSIS at: $nsisPath";
break;
}
}
}
# Also check if makensis is in PATH
if (-not $nsisFound) {
$makensisInPath = Get-Command makensis -ErrorAction SilentlyContinue;
if ($makensisInPath) {
$nsisPath = Split-Path $makensisInPath.Source;
$nsisFound = $true;
Write-Host "Found makensis in PATH: $nsisPath";
}
}
if ($nsisFound) {
$env:Path = "$nsisPath;$env:Path";
Write-Host "Using existing NSIS installation";
& makensis /VERSION;
Write-Host "";
exit 0;
}
Write-Host "NSIS not found, attempting installation...";
Write-Host "";
# Step 2: Try Chocolatey
Write-Host "Method 1: Trying Chocolatey...";
$chocoInstalled = Get-Command choco -ErrorAction SilentlyContinue;
if ($chocoInstalled) {
try {
choco install nsis -y --no-progress --limit-output;
if ($LASTEXITCODE -eq 0) {
Start-Sleep -Seconds 3;
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User");
$nsisPath = "C:\Program Files (x86)\NSIS";
if (-not (Test-Path $nsisPath)) {
$nsisPath = "C:\Program Files\NSIS";
}
if (Test-Path (Join-Path $nsisPath "makensis.exe")) {
$env:Path = "$nsisPath;$env:Path";
& makensis /VERSION;
Write-Host "NSIS installed via Chocolatey";
Write-Host "";
exit 0;
}
}
} catch {
Write-Host "Chocolatey installation failed: $_";
}
} else {
Write-Host "Chocolatey not available";
}
# Step 3: Try winget (Windows Package Manager)
Write-Host "";
Write-Host "Method 2: Trying winget...";
$wingetInstalled = Get-Command winget -ErrorAction SilentlyContinue;
if ($wingetInstalled) {
try {
winget install --id=NSIS.NSIS -e --silent --accept-package-agreements --accept-source-agreements;
if ($LASTEXITCODE -eq 0) {
Start-Sleep -Seconds 3;
$nsisPath = "C:\Program Files (x86)\NSIS";
if (-not (Test-Path $nsisPath)) {
$nsisPath = "C:\Program Files\NSIS";
}
if (Test-Path (Join-Path $nsisPath "makensis.exe")) {
$env:Path = "$nsisPath;$env:Path";
& makensis /VERSION;
Write-Host "NSIS installed via winget";
Write-Host "";
exit 0;
}
}
} catch {
Write-Host "winget installation failed: $_";
}
} else {
Write-Host "winget not available";
}
# Step 4: Manual installation is not possible due to network restrictions
Write-Host "";
Write-Host "========================================";
Write-Host "ERROR: NSIS is not installed and automatic installation failed";
Write-Host "";
Write-Host "Please install NSIS manually on the CI/CD runner:";
Write-Host "1. Download from: https://nsis.sourceforge.io/Download";
Write-Host "2. Install to: C:\Program Files (x86)\NSIS";
Write-Host "3. Or use: choco install nsis";
Write-Host "4. Or use: winget install NSIS.NSIS";
Write-Host "========================================";
exit 1;
# Step 4.7: Build NSIS installer
- name: Build NSIS installer
if: success()
shell: powershell
run: |
Write-Host "==========================================";
Write-Host "Step 4.7: Build NSIS installer";
Write-Host "==========================================";
# Ensure NSIS is in PATH
$nsisPath = "C:\Program Files (x86)\NSIS";
if (-not (Test-Path $nsisPath)) {
$nsisPath = "C:\Program Files\NSIS";
}
$env:Path = "$nsisPath;$env:Path";
Write-Host "Using NSIS from: $nsisPath";
cd installer;
python build_installer.py;
if ($LASTEXITCODE -ne 0) {
Write-Host "Installer build failed";
exit 1;
}
$installers = Get-ChildItem -Path "output" -Filter "*.exe" -ErrorAction SilentlyContinue;
if (-not $installers -or $installers.Count -eq 0) {
Write-Host "No installer file found";
exit 1;
}
$installerName = $installers[0].Name;
$installerSize = [math]::Round($installers[0].Length / 1MB, 2);
Write-Host "Installer built successfully";
Write-Host " Filename: $installerName";
Write-Host " Size: $installerSize MB";
Write-Host "";
# Step 4.8: Upload installer to KS3
- name: Upload installer to KS3
if: success()
shell: powershell
timeout-minutes: 30
run: |
Write-Host "==========================================";
Write-Host "Step 4.8: Upload installer to KS3";
Write-Host "==========================================";
Write-Host "NOTE: Large file upload may take 5-10 minutes";
Write-Host "";
# Install KS3 SDK
Write-Host "Installing KS3 SDK...";
python -m pip install ks3sdk --quiet;
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: Failed to install ks3sdk";
Write-Host "Skipping KS3 upload (version release continues)";
exit 0;
}
Write-Host "Starting upload process...";
Write-Host "This may take several minutes, please be patient...";
Write-Host "";
# Run upload script
python .gitea/scripts/upload_installer_to_ks3.py;
if ($LASTEXITCODE -eq 0) {
Write-Host "";
Write-Host "OK: Installer uploaded to KS3 successfully";
} else {
Write-Host "";
Write-Host "WARNING: KS3 upload failed (exit code: $LASTEXITCODE)";
Write-Host "NOTICE: Version release continues";
Write-Host "NOTICE: You can manually upload the installer later";
Write-Host "";
Write-Host "Manual upload steps:";
Write-Host " 1. Find installer in: installer/output/";
Write-Host " 2. Upload to KS3 bucket: shuidrop-chat-server";
Write-Host " 3. Target path: installers/";
}
Write-Host "";
# Step 5: Commit version changes
- name: Commit version changes
if: success()
shell: powershell
run: |
Write-Host "========================================";
Write-Host "Step 5: Commit version changes";
Write-Host "========================================";
# Read new version number
$VERSION = "";
if (Test-Path "config.py") {
$configContent = Get-Content "config.py" -Raw;
if ($configContent -match 'APP_VERSION\s*=\s*"([\d.]+)"') {
$VERSION = $matches[1];
Write-Host "New version: $VERSION";
}
}
# Configure Git
git config user.name "Gitea Actions Bot";
git config user.email "bot@gitea.local";
# Ensure we are on the correct branch (not detached HEAD)
$BRANCH = "${{ github.ref_name }}";
$currentBranch = git rev-parse --abbrev-ref HEAD;
Write-Host "Current branch: $currentBranch";
Write-Host "Target branch: $BRANCH";
if ($currentBranch -ne $BRANCH) {
Write-Host "WARNING: Not on target branch, checking out...";
git checkout $BRANCH;
Write-Host "OK: Checked out to $BRANCH";
}
# Clean up any existing rebase state before starting
$rebaseExists = (Test-Path ".git/rebase-merge") -or (Test-Path ".git/rebase-apply");
if ($rebaseExists) {
Write-Host "WARNING: Found existing rebase state, cleaning up...";
git rebase --abort 2>$null;
Remove-Item -Path ".git/rebase-merge" -Recurse -Force -ErrorAction SilentlyContinue;
Remove-Item -Path ".git/rebase-apply" -Recurse -Force -ErrorAction SilentlyContinue;
Write-Host "OK: Rebase state cleaned";
}
# Check for changes
git add config.py version_history.json;
$hasChanges = git diff --staged --quiet;
if ($LASTEXITCODE -ne 0) {
Write-Host "Detected changes in version files";
Write-Host "";
# KEY CHANGE: Pull first, then commit
Write-Host "Step 5.1: Pulling latest changes first...";
git fetch origin $BRANCH;
# Check if remote has updates
$LOCAL = git rev-parse HEAD;
$REMOTE = git rev-parse origin/$BRANCH;
if ($LOCAL -ne $REMOTE) {
Write-Host "Remote has new commits, need to merge...";
Write-Host "Local: $LOCAL";
Write-Host "Remote: $REMOTE";
Write-Host "";
# FIX: Stash local changes before pull to avoid conflicts
Write-Host "Stashing local version changes...";
git stash push -m "CI-CD-temp-stash" config.py version_history.json;
Write-Host "Pulling remote changes...";
# Pull remote changes (use merge strategy to avoid rebase conflicts)
git pull origin $BRANCH --no-rebase;
# Pop stashed changes back
Write-Host "Restoring version changes...";
$stashPopResult = git stash pop 2>&1;
$stashPopExitCode = $LASTEXITCODE;
if ($stashPopExitCode -ne 0) {
Write-Host "WARNING: Stash pop encountered conflicts, resolving...";
Write-Host "$stashPopResult";
# Check conflict files
$conflicts = git diff --name-only --diff-filter=U;
Write-Host "Conflict files: $conflicts";
# FIX: Smart conflict resolution to preserve user code
# For config.py: use remote version (user code), then re-apply version update
if ($conflicts -match "config.py") {
Write-Host "Resolving config.py conflict...";
# Use remote version (preserve user code)
git checkout --theirs config.py;
# Re-apply version update only
python .gitea/scripts/gui_version_creator.py;
git add config.py;
Write-Host "OK: Resolved config.py (preserved user code + updated version)";
}
# For version_history.json: use CI/CD version (append record)
if ($conflicts -match "version_history.json") {
Write-Host "Resolving version_history.json conflict...";
git checkout --ours version_history.json;
git add version_history.json;
Write-Host "OK: Resolved version_history.json (using CI/CD version)";
}
# Drop the stash after resolving
git stash drop 2>$null;
} else {
Write-Host "OK: Stash pop successful";
# FIX: After stash pop, files are automatically merged
# config.py now contains:
# 1. User's code from remote (from pull)
# 2. Version update from stash (from gui_version_creator.py)
Write-Host "Checking if version update is preserved...";
# Verify version number is correct
$configContent = Get-Content "config.py" -Raw;
if ($configContent -match 'APP_VERSION\s*=\s*"([\d.]+)"') {
$currentVersion = $matches[1];
Write-Host "Current APP_VERSION in config.py: $currentVersion";
Write-Host "Expected version: $VERSION";
if ($currentVersion -ne $VERSION) {
Write-Host "WARNING: Version mismatch, re-applying version update...";
# Re-execute version update (only modify APP_VERSION line)
python .gitea/scripts/gui_version_creator.py;
} else {
Write-Host "OK: Version is correct";
}
}
# Add current files (includes user code + version update)
git add config.py version_history.json;
Write-Host "Files staged successfully";
}
} else {
Write-Host "No remote changes, proceeding with commit...";
# FIX: Ensure files are staged even if no remote changes
git add config.py version_history.json;
Write-Host "Files staged for commit";
}
Write-Host "";
Write-Host "Step 5.2: Committing version changes...";
# Check if there are changes to commit
$hasUncommitted = git diff --quiet; $diffExitCode1 = $LASTEXITCODE;
$hasStagedChanges = git diff --staged --quiet; $diffExitCode2 = $LASTEXITCODE;
if (($diffExitCode1 -ne 0) -or ($diffExitCode2 -ne 0)) {
Write-Host "Detected uncommitted changes, creating commit...";
git commit -m "[skip ci] Update version to v$VERSION" --no-verify;
if ($LASTEXITCODE -eq 0) {
Write-Host "OK: Commit successful";
} else {
Write-Host "ERROR: Commit failed";
}
} else {
Write-Host "No changes to commit (already committed in merge)";
Write-Host "Skipping commit step";
$LASTEXITCODE = 0;
}
if ($LASTEXITCODE -eq 0) {
Write-Host "";
Write-Host "Step 5.3: Pushing to remote...";
# Push to remote (retry up to 3 times with smart strategy)
$pushSuccess = $false;
for ($i = 1; $i -le 3; $i++) {
Write-Host "Push attempt $i/3...";
# Before each attempt, ensure clean state
if ($i -gt 1) {
Write-Host "Preparing for retry $i...";
# Step 1: Clean up any rebase state
$rebaseCheck = (Test-Path ".git/rebase-merge") -or (Test-Path ".git/rebase-apply");
if ($rebaseCheck) {
Write-Host "Cleaning up rebase state...";
git rebase --abort 2>$null;
Remove-Item -Path ".git/rebase-merge" -Recurse -Force -ErrorAction SilentlyContinue;
Remove-Item -Path ".git/rebase-apply" -Recurse -Force -ErrorAction SilentlyContinue;
}
# Step 2: Ensure on correct branch
$currentBranchCheck = git rev-parse --abbrev-ref HEAD;
if ($currentBranchCheck -ne $BRANCH) {
Write-Host "Checking out to branch $BRANCH...";
git checkout $BRANCH;
}
# Step 3: Fetch latest remote state
Write-Host "Fetching latest remote state...";
git fetch origin $BRANCH;
# Step 4: Record current version number
$currentVersionMatch = (Get-Content "config.py" -Raw) -match 'APP_VERSION\s*=\s*"([\d.]+)"';
$targetVersion = $matches[1];
Write-Host "Target version to apply: $targetVersion";
# Step 5: Reset to remote state
Write-Host "Resetting to remote state...";
git reset --hard origin/$BRANCH;
# Step 6: FIX - Re-apply version update only, do not overwrite entire file
Write-Host "Re-applying version update only...";
python .gitea/scripts/gui_version_creator.py;
# Step 7: Stage and commit again
git add config.py version_history.json;
git commit -m "[skip ci] Update version to v$VERSION" --no-verify;
Write-Host "Retry preparation complete";
Start-Sleep -Seconds 1;
}
# Attempt to push
git push origin $BRANCH;
if ($LASTEXITCODE -eq 0) {
Write-Host "OK: Push successful on attempt $i";
$pushSuccess = $true;
break;
} else {
Write-Host "WARNING: Push attempt $i failed";
}
}
if (-not $pushSuccess) {
Write-Host "ERROR: Push failed after 3 attempts";
Write-Host "NOTICE: This is not critical - version is already in database and KS3";
Write-Host "NOTICE: Git repository sync can be done manually later";
}
} else {
Write-Host "ERROR: Commit failed";
}
} else {
Write-Host "No changes to commit";
}
Write-Host "========================================";
Write-Host "";
# Step 6: Display summary
- name: Display summary
if: always()
shell: powershell
run: |
$VERSION = "Unknown";
if (Test-Path "config.py") {
$configContent = Get-Content "config.py" -Raw;
if ($configContent -match 'APP_VERSION\s*=\s*"([\d.]+)"') {
$VERSION = $matches[1];
}
}
Write-Host "==========================================";
Write-Host "GUI Version Release Summary";
Write-Host "==========================================";
Write-Host "Author: ${{ github.actor }}";
Write-Host "Branch: ${{ github.ref_name }}";
Write-Host "Version: $VERSION";
Write-Host "Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')";
Write-Host "Commit: ${{ github.sha }}";
Write-Host "==========================================";

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ import uuid
import json import json
# 发送消息 # 发送文本消息
def send_message(pigeon_sign: str, token: str, receiver_id: str, shop_id: str, talk_id: int, session_did: str, p_id: int, user_code: str, text: str): def send_message(pigeon_sign: str, token: str, receiver_id: str, shop_id: str, talk_id: int, session_did: str, p_id: int, user_code: str, text: str):
""" """
构造发送消息消息体 构造发送消息消息体
@@ -78,7 +78,7 @@ def send_message(pigeon_sign: str, token: str, receiver_id: str, shop_id: str, t
'15': [ '15': [
{'1': b'pigeon_source', '2': b'web'}, {'1': b'pigeon_source', '2': b'web'},
{'1': b'PIGEON_BIZ_TYPE', '2': b'2'}, {'1': b'PIGEON_BIZ_TYPE', '2': b'2'},
{'1': b'pigeon_sign', '2': b'MIG6BAz2BNUON43WdlOBuGYEgZcsIho9ZjVP4yyExLShzXgAZtsvUMj2e3jZWeMZv+6+TNVQQMq3xSLrqiwcs2cCaOVBDuS6zGsWm5gBlGtlvOOLM5td2/9OS8P37t1sdkjN4BSH2mB7FlGItioZIsTh1sodn6pYCGj+45mtId3Itenufgai3Mnkpt573uoWJmagF8J3jVPHMFtdwd25Qf5vsWC2kB30glpQBBCbk2VO2ubMqctqQSzhI6uD'}, {'1': b'pigeon_sign', '2': pigeon_sign.encode()}, # 🔥 修复:使用动态参数,不是硬编码
{'1': b'session_aid', '2': b'1383'}, {'1': b'session_aid', '2': b'1383'},
{'1': b'session_did', '2': session_did.encode()}, {'1': b'session_did', '2': session_did.encode()},
{'1': b'app_name', '2': b'im'}, {'1': b'app_name', '2': b'im'},
@@ -373,3 +373,229 @@ def heartbeat_message(pigeon_sign: str, token: str, session_did: str):
message_type = {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'int', 'name': ''}, '4': {'type': 'int', 'name': ''}, '5': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '7': {'type': 'message', 'message_typedef': {'14': {'type': 'int', 'name': ''}}, 'name': ''}, '8': {'type': 'message', 'message_typedef': {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'bytes', 'name': ''}, '4': {'type': 'bytes', 'name': ''}, '5': {'type': 'int', 'name': ''}, '6': {'type': 'int', 'name': ''}, '7': {'type': 'bytes', 'name': ''}, '8': {'type': 'message', 'message_typedef': {'200': {'type': 'message', 'message_typedef': {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}}, 'name': ''}}, 'name': ''}, '9': {'type': 'bytes', 'name': ''}, '11': {'type': 'bytes', 'name': ''}, '15': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '18': {'type': 'int', 'name': ''}}, 'name': ''}} message_type = {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'int', 'name': ''}, '4': {'type': 'int', 'name': ''}, '5': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '7': {'type': 'message', 'message_typedef': {'14': {'type': 'int', 'name': ''}}, 'name': ''}, '8': {'type': 'message', 'message_typedef': {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'bytes', 'name': ''}, '4': {'type': 'bytes', 'name': ''}, '5': {'type': 'int', 'name': ''}, '6': {'type': 'int', 'name': ''}, '7': {'type': 'bytes', 'name': ''}, '8': {'type': 'message', 'message_typedef': {'200': {'type': 'message', 'message_typedef': {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}}, 'name': ''}}, 'name': ''}, '9': {'type': 'bytes', 'name': ''}, '11': {'type': 'bytes', 'name': ''}, '15': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '18': {'type': 'int', 'name': ''}}, 'name': ''}}
return value, message_type return value, message_type
# 🔥 新增:发送图片消息
def send_img(pigeon_sign: str, token: str, receiver_id: str, shop_id: str, talk_id: int, session_did: str, p_id: int, user_code: str, img: str, image_width: str = "2000", image_height: str = "1125", image_format: str = "png", image_size: str = "3157512"):
"""
构造发送图片消息体
:param image_width: 图片宽度
:param image_size: 图片大小
:param image_height: 图片高度
:param image_format: 图片格式
:param pigeon_sign: 接口返回
:param token: 接口返回
:param receiver_id: wss消息返回 对方用户id
:param shop_id: cookie自带
:param talk_id: wss消息返回 激活窗口id
:param session_did: cookie自带
:param p_id: wss消息返回
:param user_code: 用户token
:param img: 图片URI或URL
:return: (value, message_type)
"""
value = {
'1': 11778,
'2': int(time.time() * 1000),
'3': 10001,
'4': 1,
'5': [
{'1': b'pigeon_source', '2': b'web'},
{'1': b'PIGEON_BIZ_TYPE', '2': b'2'},
{'1': b'pigeon_sign', '2': pigeon_sign.encode()},
],
'7': {'14': 98},
'8': {
'1': 100,
'2': 11778,
'3': b'1.0.4-beta.2',
'4': token.encode(),
'5': 3,
'6': 3,
'7': b'2d97ea6:feat/add_init_callback',
'8': {
'100': {
'1': f"{receiver_id}:{shop_id}::2:1:pigeon".encode(),
'2': 11,
'3': p_id,
'4': "[图片]".encode(),
'5': [
{'1': b'type', '2': b'file_image'},
{'1': b'shop_id', '2': shop_id.encode()},
{'1': b'sender_role', '2': b'2'},
{'1': b'PIGEON_BIZ_TYPE', '2': b'2'},
{'1': b'src', '2': b'pc'},
{'1': b'srcType', '2': b'1'},
{'1': b'source', '2': b'pc-web'},
{'1': b'receiver_id', '2': str(receiver_id).encode()},
{'1': b'hierarchical_dimension', '2': b'{"dynamic_dimension":"4541_1131_9042_6599_9420_6832_4050_3823_3994_8564_1528_0388_8667_2179_7948_1870_1949_0989_8012_6240_7898_7548_8852_6245_9393_3650_8570_4026_4034_4057_6537_8632_2068_8958_0363_2387_9033_3425_2238_0982_1935_8188_3817_8557_7931_3278_4065_1893_6049_6961_3814_4883_4401_6637_7282_3652_9354_0437_4769_4815_9572_7230_5054_3951_4852_2188_3505_6813_2570_5394_0729","goofy_id":"1.0.1.1508","desk_version":"0.0.0","open_stores":"0","memL":"","cpuL":"","session_throughput":0,"message_throughput_send":0,"message_throughput_revice":0}'},
{'1': b'tag_valid', '2': b'1'},
{'1': b'imageUrl', '2': img.encode()},
{'1': b'imageWidth', '2': image_width.encode()},
{'1': b'imageHeight', '2': image_height.encode()},
{'1': b'imageFormat', '2': image_format.encode()},
{'1': b'imageSize', '2': image_size.encode()},
{'1': b'uuid', '2': str(uuid.uuid4()).encode()},
{'1': b'track_info','2': json.dumps({"send_time": int(time.time() * 1000), "_send_delta": "77","_send_delta_2": "216"}).encode()},
{'1': b'user_agent', '2': b'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'},
{'1': b'sender_id', '2': b''},
{'1': b'biz_ext', '2': b'{}'},
{'1': b'p:from_source', '2': b'web'},
{'1': b's:mentioned_users', '2': b''},
{'1': b's:client_message_id', '2': str(uuid.uuid4()).encode()}
],
'6': 1000,
'7': user_code.encode(),
'8': str(uuid.uuid4()).encode(),
'14': talk_id
}
},
'9': session_did.encode(),
'11': b'web',
'15': [
{'1': b'pigeon_source', '2': b'web'},
{'1': b'PIGEON_BIZ_TYPE', '2': b'2'},
{'1': b'pigeon_sign', '2': b'MIG6BAz2BNUON43WdlOBuGYEgZcsIho9ZjVP4yyExLShzXgAZtsvUMj2e3jZWeMZv+6+TNVQQMq3xSLrqiwcs2cCaOVBDuS6zGsWm5gBlGtlvOOLM5td2/9OS8P37t1sdkjN4BSH2mB7FlGItioZIsTh1sodn6pYCGj+45mtId3Itenufgai3Mnkpt573uoWJmagF8J3jVPHMFtdwd25Qf5vsWC2kB30glpQBBCbk2VO2ubMqctqQSzhI6uD'},
{'1': b'session_aid', '2': b'1383'},
{'1': b'session_did', '2': session_did.encode()},
{'1': b'app_name', '2': b'im'},
{'1': b'priority_region', '2': b'cn'},
{'1': b'user_agent','2': b'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'},
{'1': b'cookie_enabled', '2': b'true'},
{'1': b'browser_language', '2': b'zh-CN'},
{'1': b'browser_platform', '2': b'Win32'},
{'1': b'browser_name', '2': b'Mozilla'},
{'1': b'browser_version', '2': b'5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'},
{'1': b'browser_online', '2': b'true'},
{'1': b'screen_width', '2': b'1707'},
{'1': b'screen_height', '2': b'1067'},
{'1': b'referer', '2': b''},
{'1': b'timezone_name', '2': b'Asia/Shanghai'}
],
'18': 2
}
}
message_type = {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'int', 'name': ''}, '4': {'type': 'int', 'name': ''}, '5': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '7': {'type': 'message', 'message_typedef': {'14': {'type': 'int', 'name': ''}}, 'name': ''}, '8': {'type': 'message', 'message_typedef': {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'bytes', 'name': ''}, '4': {'type': 'bytes', 'name': ''}, '5': {'type': 'int', 'name': ''}, '6': {'type': 'int', 'name': ''}, '7': {'type': 'bytes', 'name': ''}, '8': {'type': 'message', 'message_typedef': {'100': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'int', 'name': ''}, '4': {'type': 'bytes', 'name': ''}, '5': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '6': {'type': 'int', 'name': ''}, '7': {'type': 'bytes', 'name': ''}, '8': {'type': 'bytes', 'name': ''}, '14': {'type': 'int', 'name': ''}}, 'name': ''}}, 'name': ''}, '9': {'type': 'bytes', 'name': ''}, '11': {'type': 'bytes', 'name': ''}, '15': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '18': {'type': 'int', 'name': ''}}, 'name': ''}}
return value, message_type
# 🔥 新增:发送视频消息
def send_video(pigeon_sign: str, token: str, receiver_id: str, shop_id: str, talk_id: int, session_did: str, p_id: int, user_code: str, vid: str, cover_url: str, height: str, width: str, duration: str):
"""
构造发送视频消息体
:param duration: 视频时长
:param width: 视频宽度
:param height: 视频高度
:param cover_url: 封面URL
:param pigeon_sign: 接口返回
:param token: 接口返回
:param receiver_id: wss消息返回 对方用户id
:param shop_id: cookie自带
:param talk_id: wss消息返回 激活窗口id
:param session_did: cookie自带
:param p_id: wss消息返回
:param user_code: 用户token
:param vid: 视频id
:return: (value, message_type)
"""
# 🔥 修复确保数值类型正确height/width为intduration为float
try:
height_int = int(height) if isinstance(height, str) else height
width_int = int(width) if isinstance(width, str) else width
duration_float = float(duration) if isinstance(duration, str) else duration
except (ValueError, TypeError):
# 如果转换失败,使用默认值
height_int = 1080
width_int = 1920
duration_float = 0.0
msg_render_model = json.dumps({
"msg_render_type": "video",
"render_body": {
"vid": vid,
"coverURL": cover_url,
"height": height_int,
"width": width_int,
"duration": duration_float
}
}).encode()
value = {
'1': 10015,
'2': int(time.time() * 1000),
'3': 10001,
'4': 1,
'5': [
{'1': b'pigeon_source', '2': b'web'},
{'1': b'PIGEON_BIZ_TYPE', '2': b'2'},
{'1': b'pigeon_sign', '2': pigeon_sign.encode()},
],
'7': {'14': 98},
'8': {
'1': 100,
'2': 10015,
'3': b'1.0.4-beta.2',
'4': token.encode(),
'5': 3,
'6': 3,
'7': b'2d97ea6:feat/add_init_callback',
'8': {
'100': {
'1': f"{receiver_id}:{shop_id}::2:1:pigeon".encode(),
'2': 11,
'3': p_id,
'4': "[视频]".encode(),
'5': [
{'1': b'type', '2': b'video'},
{'1': b'shop_id', '2': shop_id.encode()},
{'1': b'sender_role', '2': b'2'},
{'1': b'PIGEON_BIZ_TYPE', '2': b'2'},
{'1': b'src', '2': b'pc'},
{'1': b'srcType', '2': b'1'},
{'1': b'source', '2': b'pc-web'},
{'1': b'receiver_id', '2': str(receiver_id).encode()},
{'1': b'hierarchical_dimension', '2': b'{"dynamic_dimension":"4541_1131_9042_6599_9420_6832_4050_3823_3994_8564_1528_0388_8667_2179_7948_1870_1949_0989_8012_6240_7898_7548_8852_6245_9393_3650_8570_4026_4034_4057_6537_8632_2068_8958_0363_2387_9033_3425_2238_0982_1935_8188_3817_8557_7931_3278_4065_1893_6049_6961_3814_4883_4401_6637_7282_3652_9354_0437_4769_4815_9572_7230_5054_3951_4852_2188_3505_6813_2570_5394_0729","goofy_id":"1.0.1.1508","desk_version":"0.0.0","open_stores":"0","memL":"","cpuL":"","session_throughput":0,"message_throughput_send":0,"message_throughput_revice":0}'},
{'1': b'msg_render_model', '2': msg_render_model},
{'1': b'uuid', '2': str(uuid.uuid4()).encode()},
{'1': b'start_scene', '2': b'1'},
{'1': b'track_info','2': json.dumps({"send_time": int(time.time() * 1000), "_send_delta": "77","_send_delta_2": "216"}).encode()},
{'1': b'user_agent', '2': b'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'},
{'1': b'sender_id', '2': b''},
{'1': b'biz_ext', '2': b'{}'},
{'1': b'p:from_source', '2': b'web'},
{'1': b's:mentioned_users', '2': b''},
{'1': b's:client_message_id', '2': str(uuid.uuid4()).encode()}
],
'6': 1000,
'7': user_code.encode(),
'8': str(uuid.uuid4()).encode(),
'14': talk_id
}
},
'9': session_did.encode(),
'11': b'web',
'15': [
{'1': b'pigeon_source', '2': b'web'},
{'1': b'PIGEON_BIZ_TYPE', '2': b'2'},
{'1': b'pigeon_sign', '2': b'MIG6BAz2BNUON43WdlOBuGYEgZcsIho9ZjVP4yyExLShzXgAZtsvUMj2e3jZWeMZv+6+TNVQQMq3xSLrqiwcs2cCaOVBDuS6zGsWm5gBlGtlvOOLM5td2/9OS8P37t1sdkjN4BSH2mB7FlGItioZIsTh1sodn6pYCGj+45mtId3Itenufgai3Mnkpt573uoWJmagF8J3jVPHMFtdwd25Qf5vsWC2kB30glpQBBCbk2VO2ubMqctqQSzhI6uD'},
{'1': b'session_aid', '2': b'1383'},
{'1': b'session_did', '2': session_did.encode()},
{'1': b'app_name', '2': b'im'},
{'1': b'priority_region', '2': b'cn'},
{'1': b'user_agent','2': b'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'},
{'1': b'cookie_enabled', '2': b'true'},
{'1': b'browser_language', '2': b'zh-CN'},
{'1': b'browser_platform', '2': b'Win32'},
{'1': b'browser_name', '2': b'Mozilla'},
{'1': b'browser_version', '2': b'5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'},
{'1': b'browser_online', '2': b'true'},
{'1': b'screen_width', '2': b'1707'},
{'1': b'screen_height', '2': b'1067'},
{'1': b'referer', '2': b''},
{'1': b'timezone_name', '2': b'Asia/Shanghai'}
],
'18': 2
}
}
message_type = {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'int', 'name': ''}, '4': {'type': 'int', 'name': ''}, '5': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '7': {'type': 'message', 'message_typedef': {'14': {'type': 'int', 'name': ''}}, 'name': ''}, '8': {'type': 'message', 'message_typedef': {'1': {'type': 'int', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'bytes', 'name': ''}, '4': {'type': 'bytes', 'name': ''}, '5': {'type': 'int', 'name': ''}, '6': {'type': 'int', 'name': ''}, '7': {'type': 'bytes', 'name': ''}, '8': {'type': 'message', 'message_typedef': {'100': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'int', 'name': ''}, '3': {'type': 'int', 'name': ''}, '4': {'type': 'bytes', 'name': ''}, '5': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '6': {'type': 'int', 'name': ''}, '7': {'type': 'bytes', 'name': ''}, '8': {'type': 'bytes', 'name': ''}, '14': {'type': 'int', 'name': ''}}, 'name': ''}}, 'name': ''}, '9': {'type': 'bytes', 'name': ''}, '11': {'type': 'bytes', 'name': ''}, '15': {'type': 'message', 'message_typedef': {'1': {'type': 'bytes', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}, 'name': ''}, '18': {'type': 'int', 'name': ''}}, 'name': ''}}
return value, message_type

View File

@@ -13,6 +13,8 @@ import requests
import json import json
import time import time
import threading import threading
import uuid
import os
# 定义持久化数据类 # 定义持久化数据类
@@ -155,6 +157,14 @@ class FixJdCookie:
self.max_reconnect_delay = 60.0 # 最大重连延迟 self.max_reconnect_delay = 60.0 # 最大重连延迟
self.reconnect_backoff = 1.5 # 退避系数 self.reconnect_backoff = 1.5 # 退避系数
# 🔥 存储认证信息,用于文件上传
self.cookies_str = None
self.current_aid = None
self.current_pin_zj = None
# 🔥 启动时清理过期临时文件
self._cleanup_old_temp_files(max_age_hours=24)
def _log(self, message, log_type="INFO"): def _log(self, message, log_type="INFO"):
"""内部日志方法""" """内部日志方法"""
if self.log_callback: if self.log_callback:
@@ -177,6 +187,11 @@ class FixJdCookie:
"""初始化 socket""" """初始化 socket"""
await self.send_heartbeat(ws, aid, pin_zj) await self.send_heartbeat(ws, aid, pin_zj)
print("开始监听初始化") print("开始监听初始化")
# 🔧 修复生成唯一设备ID避免多端互踢
import uuid
unique_device_id = f"shuidrop_gui_{uuid.uuid4().hex[:16]}"
auth = { auth = {
"id": hashlib.md5(str(int(time.time() * 1000)).encode()).hexdigest(), "id": hashlib.md5(str(int(time.time() * 1000)).encode()).hexdigest(),
"aid": aid, "aid": aid,
@@ -185,8 +200,9 @@ class FixJdCookie:
"type": "auth", "type": "auth",
"body": {"presence": 1, "clientVersion": "2.6.3"}, "body": {"presence": 1, "clientVersion": "2.6.3"},
"to": {"app": "im.waiter"}, "to": {"app": "im.waiter"},
"from": {"app": "im.waiter", "pin": pin_zj, "clientType": "comet", "dvc": "device1234"} "from": {"app": "im.waiter", "pin": pin_zj, "clientType": "comet", "dvc": unique_device_id}
} }
print(f"[DEBUG] 使用唯一设备ID: {unique_device_id}")
await ws.send(json.dumps(auth)) await ws.send(json.dumps(auth))
@@ -210,6 +226,80 @@ class FixJdCookie:
} }
await ws.send(json.dumps(message)) await ws.send(json.dumps(message))
async def get_all_customer(self, ws, aid, pin_zj):
"""异步获取商家组织架构(客服列表)"""
id_str = hashlib.md5(str(int(time.time() * 1000) + 300).encode()).hexdigest()
message = {
"id": id_str,
"aid": aid,
"lang": "zh_CN",
"timestamp": int(time.time() * 1000),
"from": {
"app": "im.waiter", "pin": pin_zj, "art": "customerGroupMsg", "clientType": "comet"
},
"type": "org_new",
"body": {"param": pin_zj, "paramtype": "ByPin"}
}
await ws.send(json.dumps(message))
while True:
response = await ws.recv()
data = json.loads(response)
if data.get("id") == id_str and data.get("body", {}).get("groupname") == "商家组织架构":
group = data.get("body", {}).get("group")
if group:
try:
# 与旧实现保持一致的数据路径
self.customer_list = group[0].get("group")[0].get("users")
except Exception:
self.customer_list = []
return
async def send_staff_list_to_backend(self, store_id):
"""发送客服列表到后端(通过统一后端连接)"""
try:
if not hasattr(self, 'customer_list') or not self.customer_list:
self._log("⚠️ 客服列表为空", "WARNING")
return False
# 转换客服数据格式
staff_infos = []
for staff in self.customer_list:
staff_info = {
"staff_id": staff.get("pin", ""),
"name": staff.get("nickname", ""),
"status": staff.get("status", 0),
"department": staff.get("department", ""),
"online": staff.get("online", True)
}
staff_infos.append(staff_info)
# 通过后端统一连接发送
try:
from WebSocket.backend_singleton import get_backend_client
backend_client = get_backend_client()
if not backend_client or not getattr(backend_client, 'is_connected', False):
self._log("❌ 统一后端连接不可用", "ERROR")
return False
message = {
"type": "staff_list",
"content": "客服列表更新",
"data": {
"staff_list": staff_infos,
"total_count": len(staff_infos)
},
"store_id": str(store_id)
}
backend_client.send_message(message)
self._log(f"✅ 成功发送客服列表到后端,共 {len(staff_infos)} 个客服", "SUCCESS")
return True
except Exception as e:
self._log(f"❌ 发送客服列表到后端失败: {e}", "ERROR")
return False
except Exception as e:
self._log(f"❌ 发送客服列表到后端异常: {e}", "ERROR")
return False
async def transfer_customer(self, ws, aid, pin, pin_zj, chat_name): async def transfer_customer(self, ws, aid, pin, pin_zj, chat_name):
"""异步客服转接 在发送的消息为客服转接的关键词的时候""" """异步客服转接 在发送的消息为客服转接的关键词的时候"""
message = { message = {
@@ -233,10 +323,27 @@ class FixJdCookie:
traceback.print_exc() traceback.print_exc()
return False return False
async def send_message(self, ws, pin, aid, pin_zj, vender_id, content): async def send_message(self, ws, pin, aid, pin_zj, vender_id, content, msg_type="text"):
"""异步发送单条消息""" """异步发送消息 - 支持文本/图片/视频
Args:
ws: WebSocket连接
pin: 客户pin
aid: 账号aid
pin_zj: 客服pin
vender_id: 商家ID
content: 消息内容文本内容或URL
msg_type: 消息类型 (text/image/video)
"""
try: try:
print('本地发送消息') # 根据消息类型调用不同的发送方法
if msg_type == "image":
return await self.send_image_message(ws, pin, aid, pin_zj, vender_id, content)
elif msg_type == "video":
return await self.send_video_message(ws, pin, aid, pin_zj, vender_id, content)
else:
# 文本消息(默认)
print('本地发送文本消息')
message = { message = {
"ver": "4.3", "ver": "4.3",
"type": "chat_message", "type": "chat_message",
@@ -264,6 +371,528 @@ class FixJdCookie:
logger.error(f"消息发送过程中出现特殊异常异常信息为: {e}") logger.error(f"消息发送过程中出现特殊异常异常信息为: {e}")
raise raise
def _get_temp_directory(self) -> str:
"""智能选择临时文件目录(优先级降级策略)
优先级:
1. 环境变量 SHUIDROP_TEMP_DIR用户自定义
2. 应用程序所在目录/temp如果有写权限且不在Program Files
3. 系统临时目录(兜底方案)
Returns:
str: 临时目录路径
"""
import tempfile
import sys
try:
# 优先级1用户自定义环境变量
custom_temp = os.getenv('SHUIDROP_TEMP_DIR')
if custom_temp:
custom_temp = os.path.join(custom_temp, "shuidrop_jd_temp_uploads")
try:
os.makedirs(custom_temp, exist_ok=True)
# 测试写权限
test_file = os.path.join(custom_temp, ".write_test")
with open(test_file, 'w') as f:
f.write("test")
os.remove(test_file)
self._log(f"✅ [JD路径] 使用自定义临时目录: {custom_temp}", "INFO")
return custom_temp
except Exception as e:
self._log(f"⚠️ [JD路径] 自定义目录不可用: {e}", "WARNING")
# 优先级2应用程序所在目录避免占用C盘
try:
# 获取可执行文件路径
if getattr(sys, 'frozen', False):
# 打包后
app_dir = os.path.dirname(sys.executable)
else:
# 开发环境
app_dir = os.path.dirname(os.path.abspath(__file__))
# 检查是否在 Program Files需要管理员权限
if 'Program Files' not in app_dir and 'Program Files (x86)' not in app_dir:
app_temp = os.path.join(app_dir, "temp_uploads", "jd")
os.makedirs(app_temp, exist_ok=True)
# 测试写权限
test_file = os.path.join(app_temp, ".write_test")
with open(test_file, 'w') as f:
f.write("test")
os.remove(test_file)
self._log(f"✅ [JD路径] 使用应用程序目录: {app_temp}", "INFO")
return app_temp
else:
self._log(f" [JD路径] 应用在Program Files跳过应用目录", "DEBUG")
except Exception as e:
self._log(f"⚠️ [JD路径] 应用目录不可用: {e}", "DEBUG")
# 优先级3系统临时目录兜底方案
system_temp = os.path.join(tempfile.gettempdir(), "shuidrop_jd_temp_uploads")
os.makedirs(system_temp, exist_ok=True)
self._log(f"✅ [JD路径] 使用系统临时目录: {system_temp}", "INFO")
return system_temp
except Exception as e:
# 最终兜底
self._log(f"❌ [JD路径] 所有路径策略失败,使用默认: {e}", "ERROR")
import tempfile
return os.path.join(tempfile.gettempdir(), "shuidrop_jd_temp_uploads")
def _get_file_extension(self, url: str, default_ext: str) -> str:
"""智能提取文件扩展名
Args:
url: 文件URL
default_ext: 默认扩展名jpg/mp4
Returns:
str: 文件扩展名
"""
try:
# 移除查询参数
url_without_params = url.split('?')[0]
# 检查是否有有效的文件扩展名
if '.' in url_without_params:
parts = url_without_params.split('.')
ext = parts[-1].lower()
# 验证扩展名是否合法(只包含字母数字)
if ext and len(ext) <= 5 and ext.isalnum():
# 常见图片/视频扩展名
valid_exts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv']
if ext in valid_exts:
return ext
# 如果无法提取有效扩展名,使用默认值
return default_ext
except Exception:
return default_ext
def _cleanup_old_temp_files(self, max_age_hours=24):
"""清理过期的临时文件24小时以上
Args:
max_age_hours: 文件最大保留时间(小时)
"""
try:
# 使用智能路径选择
temp_dir = self._get_temp_directory()
if not os.path.exists(temp_dir):
return
now = time.time()
max_age_seconds = max_age_hours * 3600
cleaned_count = 0
# 遍历临时目录中的文件
for filename in os.listdir(temp_dir):
file_path = os.path.join(temp_dir, filename)
# 只处理文件,跳过目录
if not os.path.isfile(file_path):
continue
try:
# 检查文件年龄
file_mtime = os.path.getmtime(file_path)
file_age = now - file_mtime
if file_age > max_age_seconds:
file_size = os.path.getsize(file_path)
os.remove(file_path)
cleaned_count += 1
self._log(f"🗑️ [JD清理] 删除过期文件: {filename} ({file_size} bytes, {file_age/3600:.1f}小时前)", "DEBUG")
except Exception as e:
self._log(f"⚠️ [JD清理] 删除文件失败 {filename}: {e}", "DEBUG")
if cleaned_count > 0:
self._log(f"✅ [JD清理] 已清理 {cleaned_count} 个过期临时文件", "INFO")
except Exception as e:
self._log(f"⚠️ [JD清理] 临时文件清理失败: {e}", "DEBUG")
async def download_file(self, url: str, save_dir: str = None, max_retries: int = 3) -> str:
"""下载外部文件到本地(带重试机制 + 智能路径选择)
Args:
url: 文件URL
save_dir: 保存目录None时自动选择最佳路径
max_retries: 最大重试次数
Returns:
str: 本地文件路径
"""
# 使用智能路径选择策略
if save_dir is None:
save_dir = self._get_temp_directory()
# 确保目录存在
os.makedirs(save_dir, exist_ok=True)
# 智能提取文件扩展名
default_ext = 'jpg'
ext = self._get_file_extension(url, default_ext)
# 生成唯一文件名
file_name = f"jd_download_{uuid.uuid4().hex[:12]}.{ext}"
save_path = os.path.join(save_dir, file_name)
# 重试机制
for attempt in range(max_retries):
try:
if attempt > 0:
self._log(f"🔄 [JD下载] 第{attempt + 1}次重试下载...", "INFO")
else:
self._log(f"📥 [JD下载] 开始下载: {url[:100]}...", "INFO")
# 使用线程池下载
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: requests.get(url, timeout=60, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
)
# 检查HTTP状态
if response.status_code != 200:
self._log(f"❌ [JD下载] HTTP状态码: {response.status_code}", "ERROR")
if attempt < max_retries - 1:
await asyncio.sleep(2)
continue
raise Exception(f"下载失败HTTP状态码: {response.status_code}")
# 获取文件数据
file_data = response.content
file_size_kb = len(file_data) // 1024
# 检查文件大小限制50MB
if file_size_kb > 51200:
self._log(f"❌ [JD下载] 文件过大: {file_size_kb}KB超过50MB限制", "ERROR")
raise Exception(f"文件过大: {file_size_kb}KB")
# 检查文件是否为空
if file_size_kb == 0:
self._log(f"❌ [JD下载] 下载的文件为空", "ERROR")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
raise Exception("下载的文件为空")
# 写入临时文件
with open(save_path, 'wb') as f:
f.write(file_data)
self._log(f"✅ [JD下载] 下载成功,大小: {file_size_kb}KB文件: {file_name}", "SUCCESS")
return save_path
except requests.exceptions.RequestException as e:
self._log(f"❌ [JD下载] 网络请求失败: {e}", "ERROR")
if attempt < max_retries - 1:
await asyncio.sleep(2)
continue
raise
except Exception as e:
self._log(f"❌ [JD下载] 下载失败: {e}", "ERROR")
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
raise
# 所有重试都失败
raise Exception(f"下载失败:已重试{max_retries}")
async def upload_file_to_jd(self, file_path: str, file_type: str) -> dict:
"""上传文件到京东服务器
Args:
file_path: 本地文件路径
file_type: 文件类型 (image/video)
Returns:
dict: {path: 京东URL, width: 宽度, height: 高度}
"""
try:
if not self.cookies_str or not self.current_aid or not self.current_pin_zj:
raise Exception("缺少必要的认证信息cookies/aid/pin")
self._log(f"📤 开始上传文件到京东: {file_path}", "INFO")
# 读取文件内容
with open(file_path, 'rb') as f:
file_content = f.read()
# 根据文件类型选择API和MIME类型
if file_type == "image" or any(ext in file_path.lower() for ext in ['.png', '.jpg', '.jpeg', '.gif']):
url = "https://imio.jd.com/uploadfile/file/uploadImg.action"
mime_type = 'image/jpeg'
elif file_type == "video" or '.mp4' in file_path.lower():
url = "https://imio.jd.com/uploadfile/file/uploadFile.action"
mime_type = 'video/mp4'
else:
raise Exception(f"不支持的文件类型: {file_path}")
# 准备请求
headers = {
"authority": "imio.jd.com",
"accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9",
"cache-control": "no-cache",
"origin": "https://dongdong.jd.com",
"pragma": "no-cache",
"referer": "https://dongdong.jd.com/",
"sec-ch-ua": '"Chromium";v="134", "Not:A-Brand";v="24", "Google Chrome";v="134"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"cookie": self.cookies_str
}
files = {
'upload': (os.path.basename(file_path), file_content, mime_type)
}
data = {
'httpsEnable': 'true',
'clientType': 'comet',
'appId': 'im.waiter',
'pin': self.current_pin_zj,
'aid': self.current_aid
}
# 同步请求(在异步上下文中使用 run_in_executor
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: requests.post(url, headers=headers, files=files, data=data, timeout=30)
)
result = response.json()
if result.get('path'):
self._log(f"✅ 文件上传成功: {result.get('path')}", "SUCCESS")
return result
else:
raise Exception(f"上传失败: {result}")
except Exception as e:
self._log(f"❌ 文件上传失败: {e}", "ERROR")
raise
async def get_video_thumbnail(self, video_path: str) -> dict:
"""提取视频封面并上传
Args:
video_path: 视频文件路径
Returns:
dict: {path: 封面URL, width: 宽度, height: 高度}
"""
import cv2
try:
self._log(f"🎬 开始提取视频封面: {video_path}", "INFO")
# 提取视频第一帧
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
frame_position = int(0 * fps) # 第0秒
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_position)
ret, frame = cap.read()
cap.release()
if not ret:
raise Exception("无法读取视频帧")
# 编码为JPG
success, encoded_image = cv2.imencode('.jpg', frame)
if not success:
raise Exception("无法编码图像")
thumbnail_content = encoded_image.tobytes()
# 上传封面(使用图片上传接口)
headers = {
"authority": "imio.jd.com",
"accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9",
"cache-control": "no-cache",
"origin": "https://dongdong.jd.com",
"pragma": "no-cache",
"referer": "https://dongdong.jd.com/",
"sec-ch-ua": '"Chromium";v="134", "Not:A-Brand";v="24", "Google Chrome";v="134"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"cookie": self.cookies_str
}
url = "https://imio.jd.com/uploadfile/file/uploadImg.action"
files = {
'upload': ("thumbnail.jpg", thumbnail_content, 'image/jpeg')
}
data = {
'httpsEnable': 'true',
'clientType': 'comet',
'appId': 'im.waiter',
'pin': self.current_pin_zj,
'aid': self.current_aid
}
# 同步请求
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: requests.post(url, headers=headers, files=files, data=data, timeout=30)
)
result = response.json()
if result.get('path'):
self._log(f"✅ 视频封面上传成功: {result.get('path')}", "SUCCESS")
return result
else:
raise Exception(f"封面上传失败: {result}")
except Exception as e:
self._log(f"❌ 视频封面提取失败: {e}", "ERROR")
raise
async def send_image_message(self, ws, pin: str, aid: str, pin_zj: str, vender_id: str, image_url: str):
"""发送图片消息
Args:
ws: WebSocket连接
pin: 客户pin
aid: 账号aid
pin_zj: 客服pin
vender_id: 商家ID
image_url: 图片URL外部URL需要下载后上传
"""
temp_file = None
try:
self._log(f"📷 开始发送图片消息: {image_url}", "INFO")
# 1. 下载图片
temp_file = await self.download_file(image_url)
# 2. 上传到京东服务器
upload_result = await self.upload_file_to_jd(temp_file, "image")
# 3. 发送图片消息
message = {
"ver": "4.3",
"type": "chat_message",
"from": {"pin": pin_zj, "app": "im.waiter", "clientType": "comet"},
"to": {"app": "im.customer", "pin": pin},
"id": hashlib.md5(str(int(time.time() * 1000)).encode()).hexdigest(),
"lang": "zh_CN",
"aid": aid,
"timestamp": int(time.time() * 1000),
"readFlag": 0,
"body": {
"height": upload_result.get("height", 0),
"width": upload_result.get("width", 0),
"url": upload_result.get("path"),
"translated": "",
"param": {"cusVenderId": vender_id},
"type": "image"
}
}
await ws.send(json.dumps(message))
self._log(f"✅ 图片消息发送成功: {pin}", "SUCCESS")
except Exception as e:
self._log(f"❌ 图片消息发送失败: {e}", "ERROR")
raise
finally:
# 清理临时文件
if temp_file and os.path.exists(temp_file):
try:
os.remove(temp_file)
self._log(f"🗑️ 已清理临时文件: {temp_file}", "DEBUG")
except Exception:
pass
async def send_video_message(self, ws, pin: str, aid: str, pin_zj: str, vender_id: str, video_url: str):
"""发送视频消息
Args:
ws: WebSocket连接
pin: 客户pin
aid: 账号aid
pin_zj: 客服pin
vender_id: 商家ID
video_url: 视频URL外部URL需要下载后上传
"""
temp_video = None
try:
self._log(f"🎥 开始发送视频消息: {video_url}", "INFO")
# 1. 下载视频
temp_video = await self.download_file(video_url)
# 2. 提取并上传封面
thumbnail_result = await self.get_video_thumbnail(temp_video)
# 3. 上传视频
await asyncio.sleep(2) # 等待2秒避免请求过快
video_result = await self.upload_file_to_jd(temp_video, "video")
# 4. 发送视频消息
message = {
"ver": "4.3",
"type": "chat_message",
"from": {"pin": pin_zj, "app": "im.waiter", "clientType": "comet"},
"to": {"app": "im.customer", "pin": pin},
"id": hashlib.md5(str(int(time.time() * 1000)).encode()).hexdigest(),
"lang": "zh_CN",
"aid": aid,
"timestamp": int(time.time() * 1000),
"readFlag": 0,
"body": {
"desc": "",
"duration": 6,
"param": {},
"reupload": "false",
"size": os.path.getsize(temp_video),
"thumbHeight": thumbnail_result.get("height", 0),
"thumbWidth": thumbnail_result.get("width", 0),
"thumbnail": thumbnail_result.get("path"),
"url": video_result.get("path"),
"type": "video"
}
}
await ws.send(json.dumps(message))
self._log(f"✅ 视频消息发送成功: {pin}", "SUCCESS")
except Exception as e:
self._log(f"❌ 视频消息发送失败: {e}", "ERROR")
raise
finally:
# 清理临时文件
if temp_video and os.path.exists(temp_video):
try:
os.remove(temp_video)
self._log(f"🗑️ 已清理临时视频: {temp_video}", "DEBUG")
except Exception:
pass
def get_userinfo(self, response_text): def get_userinfo(self, response_text):
"""获取用户 pin 并存入 pins 列表""" """获取用户 pin 并存入 pins 列表"""
pin = jsonpath.jsonpath(json.loads(response_text), "$..from.pin") pin = jsonpath.jsonpath(json.loads(response_text), "$..from.pin")
@@ -475,6 +1104,11 @@ class FixJdCookie:
print("✅ DEBUG 进入message_monitoring方法") print("✅ DEBUG 进入message_monitoring方法")
print(f"参数验证: cookies={bool(cookies_str)}, aid={aid}, pin_zj={pin_zj}") print(f"参数验证: cookies={bool(cookies_str)}, aid={aid}, pin_zj={pin_zj}")
# 🔥 保存认证信息,用于文件上传
self.cookies_str = cookies_str
self.current_aid = aid
self.current_pin_zj = pin_zj
# 连接后端AI服务 - 使用店铺ID或venderId # 连接后端AI服务 - 使用店铺ID或venderId
store_id = str(store.get('id', '')) or str(vender_id) store_id = str(store.get('id', '')) or str(vender_id)
self._log(f"🔗 尝试连接后端服务店铺ID: {store_id}", "DEBUG") self._log(f"🔗 尝试连接后端服务店铺ID: {store_id}", "DEBUG")
@@ -506,6 +1140,13 @@ class FixJdCookie:
# print(f"✅ 连接状态: open={ws.open}, closed={ws.closed}") # print(f"✅ 连接状态: open={ws.open}, closed={ws.closed}")
print(f"🖥️ 服务端地址: {ws.remote_address}") print(f"🖥️ 服务端地址: {ws.remote_address}")
# 连接成功后,获取并上报一次客服列表(最小改动恢复)
try:
await self.get_all_customer(ws, aid, pin_zj)
await self.send_staff_list_to_backend(store.get('id', ''))
except Exception as e:
self._log(f"❌ 获取或发送客服列表失败: {e}", "ERROR")
# --- 注册连接信息到全局管理 # --- 注册连接信息到全局管理
shop_key = f"京东:{store['id']}" shop_key = f"京东:{store['id']}"
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -516,7 +1157,8 @@ class FixJdCookie:
aid=aid, aid=aid,
pin_zj=pin_zj, pin_zj=pin_zj,
platform="京东", platform="京东",
loop=loop loop=loop,
cookies_str=cookies_str # 🔥 传递cookies用于文件上传
) )
await self.waiter_status_switch(ws=ws, aid=aid, pin_zj=pin_zj) await self.waiter_status_switch(ws=ws, aid=aid, pin_zj=pin_zj)
@@ -530,12 +1172,44 @@ class FixJdCookie:
print(f"等待监听消息-{datetime.now()}") print(f"等待监听消息-{datetime.now()}")
response = await asyncio.wait_for(ws.recv(), timeout=1) response = await asyncio.wait_for(ws.recv(), timeout=1)
print(f"原始消息类型:{type(response)}, 消息体为: {response}") print(f"原始消息类型:{type(response)}, 消息体为: {response}")
await self.process_incoming_message(response, ws, aid, pin_zj, vender_id, store)
# 安全解析消息 # 🔧 修复:检测被踢下线消息
json_resp = json.loads(response) if isinstance(response, (str, bytes)) else response json_resp = json.loads(response) if isinstance(response, (str, bytes)) else response
print(json_resp) # 检查是否为server_msg类型且code=5被踢下线
if json_resp.get("type") == "server_msg":
body = json_resp.get("body", {})
if body.get("code") == 5:
msgtext = body.get("msgtext", "账号在其他设备登录")
self._log(f"⚠️ 收到被踢下线消息: {msgtext}", "WARNING")
self._log("⚠️ 检测到多端登录冲突,请确保:", "WARNING")
self._log(" 1. 关闭网页版京东咚咚", "WARNING")
self._log(" 2. 关闭其他客户端", "WARNING")
self._log(" 3. 确认只有本客户端连接", "WARNING")
# 通知GUI显示弹窗
try:
from WebSocket.backend_singleton import get_websocket_manager
ws_manager = get_websocket_manager()
if ws_manager:
# 从platform_listeners获取店铺名称
store_name = "京东店铺"
for key, info in ws_manager.platform_listeners.items():
if info.get('store_id') == store:
store_name = info.get('store_name', '') or "京东店铺"
break
# 传递 store_id 参数,用于通知后端
ws_manager.notify_platform_kicked("京东", store_name, msgtext, store_id=store)
except Exception as notify_error:
self._log(f"通知GUI失败: {notify_error}", "ERROR")
# 不再自动重连,等待用户处理
stop_event.set()
break
await self.process_incoming_message(response, ws, aid, pin_zj, vender_id, store)
# print(json_resp)
ver = json_resp.get("ver") ver = json_resp.get("ver")
print(f"版本{ver}") print(f"版本{ver}")
@@ -579,9 +1253,8 @@ class FixJdCookie:
if not await self.handle_reconnect(e): if not await self.handle_reconnect(e):
break break
# 关闭后端服务连接 # 关闭后端服务连接JDBackendService没有close方法跳过
if self.backend_connected: if self.backend_connected:
await self.backend_service.close()
self.backend_connected = False self.backend_connected = False
self._log("🛑 消息监听已停止", "INFO") self._log("🛑 消息监听已停止", "INFO")

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
# WebSocket/BackendClient.py # WebSocket/BackendClient.py
# hsc1
import json import json
import threading import threading
import time import time
@@ -29,6 +30,11 @@ class BackendClient:
self.error_callback: Optional[Callable] = None self.error_callback: Optional[Callable] = None
self.login_callback: Optional[Callable] = None # 新增平台登录下发cookies回调 self.login_callback: Optional[Callable] = None # 新增平台登录下发cookies回调
self.success_callback: Optional[Callable] = None # 新增:后端连接成功回调 self.success_callback: Optional[Callable] = None # 新增:后端连接成功回调
self.token_error_callback: Optional[Callable] = None # 新增token错误回调
self.version_callback: Optional[Callable] = None # 新增:版本检查回调
self.disconnect_callback: Optional[Callable] = None # 新增:被踢下线回调
self.balance_insufficient_callback: Optional[Callable] = None # 新增:余额不足回调
self.log_callback: Optional[Callable] = None # 新增:日志回调
self.is_connected = False self.is_connected = False
@@ -44,6 +50,18 @@ class BackendClient:
self.loop = None self.loop = None
self.thread = None self.thread = None
def _log(self, message: str, level: str = "INFO"):
"""Unified logging method that works in both dev and packaged environments"""
# Always print to console (visible in dev mode)
print(f"[{level}] {message}")
# Also call the log callback if available (for file logging)
if self.log_callback:
try:
self.log_callback(message, level)
except Exception:
pass
def connect(self): def connect(self):
"""连接到WebSocket服务器""" """连接到WebSocket服务器"""
if self.is_connected: if self.is_connected:
@@ -87,7 +105,7 @@ class BackendClient:
"""连接并监听消息 - 带重连机制""" """连接并监听消息 - 带重连机制"""
while not self.should_stop: while not self.should_stop:
try: try:
print(f"正在连接后端WebSocket: {self.url}") self._log(f"正在连接后端WebSocket: {self.url}")
# 建立连接可配置的ping设置 # 建立连接可配置的ping设置
from config import WS_PING_INTERVAL, WS_PING_TIMEOUT, WS_ENABLE_PING from config import WS_PING_INTERVAL, WS_PING_TIMEOUT, WS_ENABLE_PING
@@ -99,42 +117,56 @@ class BackendClient:
ping_timeout=WS_PING_TIMEOUT, # 可配置ping超时 ping_timeout=WS_PING_TIMEOUT, # 可配置ping超时
close_timeout=10, # 10秒关闭超时 close_timeout=10, # 10秒关闭超时
# 增加TCP keepalive配置 # 增加TCP keepalive配置
max_size=2**20, # 1MB最大消息大小 max_size=2 ** 20, # 1MB最大消息大小
max_queue=32, # 最大队列大小 max_queue=32, # 最大队列大小
compression=None # 禁用压缩以提高性能 compression=None # 禁用压缩以提高性能
) )
print(f"[连接] 已启用心跳ping_interval={WS_PING_INTERVAL}s, ping_timeout={WS_PING_TIMEOUT}s") self._log(f"已启用心跳ping_interval={WS_PING_INTERVAL}s, ping_timeout={WS_PING_TIMEOUT}s")
else: else:
self.websocket = await websockets.connect( self.websocket = await websockets.connect(
self.url, self.url,
max_size=2**20, max_size=2 ** 20,
max_queue=32, max_queue=32,
compression=None compression=None
) )
print("[连接] 已禁用心跳机制") self._log("已禁用心跳机制", "WARNING")
self.is_connected = True self.is_connected = True
# 🔥 在重置之前记录是否是重连(用于后续上报平台状态)
was_reconnecting = self.reconnect_attempts > 0
self.reconnect_attempts = 0 # 重置重连计数 self.reconnect_attempts = 0 # 重置重连计数
self.is_reconnecting = False self.is_reconnecting = False
print("后端WebSocket连接成功") self._log("后端WebSocket连接成功", "SUCCESS")
# 等待连接稳定后再发送状态通知 # 等待连接稳定后再发送状态通知
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
# 发送连接状态通知给后端 # 发送连接状态通知给后端
self._notify_connection_status(True) self._notify_connection_status(True, is_reconnect=was_reconnecting)
self.on_connected() self.on_connected(was_reconnecting)
# 消息循环 # 消息循环
async for message in self.websocket: async for message in self.websocket:
try: try:
# 打印原始文本帧与长度 # 🔍 添加心跳检测日志
try: try:
raw_len = len(message.encode('utf-8')) if isinstance(message, str) else len(message) raw_len = len(message.encode('utf-8')) if isinstance(message, str) else len(message)
# 解析消息类型
data_preview = json.loads(message)
msg_type = data_preview.get('type', 'unknown')
# 心跳相关消息用DEBUG级别其他消息用INFO级别
if msg_type in ['pong', 'connection_status_ack']:
self._log(f"💓 [心跳] 收到后端响应: {msg_type}", "DEBUG")
else:
self._log(f"📨 [后端] 收到消息: type={msg_type}, 长度={raw_len}字节", "DEBUG")
print(f"后端发送消息体内容:{message}") print(f"后端发送消息体内容:{message}")
except Exception: except Exception:
pass pass
data = json.loads(message) data = json.loads(message)
self.on_message_received(data) self.on_message_received(data)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -146,13 +178,13 @@ class BackendClient:
# 详细分析断开原因 # 详细分析断开原因
if e.code == 1006: if e.code == 1006:
print(f"[重连] WebSocket异常关闭 (1006): 可能是心跳超时或网络问题") self._log("WebSocket异常关闭 (1006): 可能是心跳超时或网络问题", "WARNING")
elif e.code == 1000: elif e.code == 1000:
print(f"[重连] WebSocket正常关闭 (1000): 服务端主动断开") self._log("WebSocket正常关闭 (1000): 服务端主动断开", "INFO")
elif e.code == 1001: elif e.code == 1001:
print(f"[重连] WebSocket关闭 (1001): 端点离开") self._log("WebSocket关闭 (1001): 端点离开", "INFO")
else: else:
print(f"[重连] WebSocket关闭 ({e.code}): {e.reason}") self._log(f"WebSocket关闭 ({e.code}): {e.reason}", "WARNING")
self._handle_connection_closed(e) self._handle_connection_closed(e)
if not await self._should_reconnect(): if not await self._should_reconnect():
@@ -182,14 +214,14 @@ class BackendClient:
def _handle_connection_closed(self, error): def _handle_connection_closed(self, error):
"""处理连接关闭""" """处理连接关闭"""
error_msg = f"WebSocket连接已关闭: {error.code} {error.reason if hasattr(error, 'reason') else ''}" error_msg = f"WebSocket连接已关闭: {error.code} {error.reason if hasattr(error, 'reason') else ''}"
print(f"[重连] {error_msg}") self._log(error_msg, "WARNING")
# 特殊处理ping超时等情况 # 特殊处理ping超时等情况
if hasattr(error, 'code'): if hasattr(error, 'code'):
if error.code == 1011: # Internal error (ping timeout) if error.code == 1011: # Internal error (ping timeout)
print("[重连] 检测到ping超时这是常见的网络问题") self._log("检测到ping超时这是常见的网络问题", "WARNING")
elif error.code == 1006: # Abnormal closure elif error.code == 1006: # Abnormal closure
print("[重连] 检测到异常关闭,可能是网络中断") self._log("检测到异常关闭,可能是网络中断或心跳超时", "WARNING")
if not self.is_reconnecting: if not self.is_reconnecting:
self.on_error(error_msg) self.on_error(error_msg)
@@ -197,25 +229,25 @@ class BackendClient:
def _handle_network_error(self, error): def _handle_network_error(self, error):
"""处理网络错误""" """处理网络错误"""
error_msg = f"网络连接错误: {type(error).__name__} - {str(error)}" error_msg = f"网络连接错误: {type(error).__name__} - {str(error)}"
print(f"[重连] {error_msg}") self._log(error_msg, "ERROR")
if not self.is_reconnecting: if not self.is_reconnecting:
self.on_error(error_msg) self.on_error(error_msg)
def _handle_general_error(self, error): def _handle_general_error(self, error):
"""处理一般错误""" """处理一般错误"""
error_msg = f"WebSocket连接异常: {type(error).__name__} - {str(error)}" error_msg = f"WebSocket连接异常: {type(error).__name__} - {str(error)}"
print(f"[重连] {error_msg}") self._log(error_msg, "ERROR")
if not self.is_reconnecting: if not self.is_reconnecting:
self.on_error(error_msg) self.on_error(error_msg)
async def _should_reconnect(self) -> bool: async def _should_reconnect(self) -> bool:
"""判断是否应该重连""" """判断是否应该重连"""
if self.should_stop: if self.should_stop:
print("[重连] 程序正在关闭,停止重连") self._log("程序正在关闭,停止重连", "INFO")
return False return False
if self.reconnect_attempts >= self.max_reconnect_attempts: if self.reconnect_attempts >= self.max_reconnect_attempts:
print(f"[重连] 已达到最大重连次数({self.max_reconnect_attempts}),停止重连") self._log(f"已达到最大重连次数({self.max_reconnect_attempts}),停止重连", "ERROR")
# 通知上层重连失败 # 通知上层重连失败
if not self.is_reconnecting: if not self.is_reconnecting:
self.on_error(f"重连失败:已达到最大重连次数({self.max_reconnect_attempts})") self.on_error(f"重连失败:已达到最大重连次数({self.max_reconnect_attempts})")
@@ -233,7 +265,7 @@ class BackendClient:
self.reconnect_attempts += 1 self.reconnect_attempts += 1
self.is_reconnecting = True self.is_reconnecting = True
print(f"[重连] {self.reconnect_attempts}次重连尝试,等待{delay:.1f}秒...") self._log(f"{self.reconnect_attempts}次重连尝试,等待{delay:.1f}秒...", "INFO")
# 分割等待时间,支持快速退出 # 分割等待时间,支持快速退出
wait_steps = max(1, int(delay)) wait_steps = max(1, int(delay))
@@ -261,7 +293,12 @@ class BackendClient:
close: Callable = None, close: Callable = None,
error: Callable = None, error: Callable = None,
login: Callable = None, login: Callable = None,
success: Callable = None): success: Callable = None,
token_error: Callable = None,
version: Callable = None,
disconnect: Callable = None,
balance_insufficient: Callable = None,
log: Callable = None):
"""设置各种消息类型的回调函数""" """设置各种消息类型的回调函数"""
if store_list: if store_list:
self.store_list_callback = store_list self.store_list_callback = store_list
@@ -279,56 +316,81 @@ class BackendClient:
self.login_callback = login self.login_callback = login
if success: if success:
self.success_callback = success self.success_callback = success
if token_error:
self.token_error_callback = token_error
if version:
self.version_callback = version
if disconnect:
self.disconnect_callback = disconnect
if balance_insufficient:
self.balance_insufficient_callback = balance_insufficient
if log:
self.log_callback = log
def on_connected(self): def on_connected(self, was_reconnecting: bool = False):
"""连接成功时的处理""" """连接成功时的处理"""
if self.reconnect_attempts > 0: if was_reconnecting:
print(f"[重连] 后端WebSocket重连成功(第{self.reconnect_attempts}次尝试)") self._log("后端WebSocket重连成功", "SUCCESS")
else: # 重连成功后上报平台状态给后端
print("后端WebSocket连接成功")
# 重连成功后可选择上报状态给后端
if self.reconnect_attempts > 0:
self._report_reconnect_status() self._report_reconnect_status()
else:
self._log("后端WebSocket首次连接成功", "SUCCESS")
# 不再主动请求 get_store避免与后端不兼容导致协程未完成 # 不再主动请求 get_store避免与后端不兼容导致协程未完成
def _report_reconnect_status(self): def _report_reconnect_status(self):
"""重连成功后上报当前状态(可选)""" """重连成功后上报当前状态"""
try: try:
# 获取当前已连接的平台列表 # 获取当前已连接的平台列表
from WebSocket.backend_singleton import get_websocket_manager from WebSocket.backend_singleton import get_websocket_manager
manager = get_websocket_manager() manager = get_websocket_manager()
if hasattr(manager, 'platform_listeners') and manager.platform_listeners: if hasattr(manager, 'platform_listeners') and manager.platform_listeners:
platform_count = len(manager.platform_listeners)
self._log(f"🔄 检测到 {platform_count} 个活跃平台连接,准备重新上报状态", "INFO")
# 延迟1秒确保后端完全准备好
import time
time.sleep(1.0)
for platform_key, listener_info in manager.platform_listeners.items(): for platform_key, listener_info in manager.platform_listeners.items():
store_id = listener_info.get('store_id') store_id = listener_info.get('store_id')
platform = listener_info.get('platform') platform = listener_info.get('platform')
store_name = listener_info.get('store_name', '')
if store_id and platform: if store_id and platform:
# 上报平台仍在连接状态 # 上报平台仍在连接状态
try: try:
store_display = store_name or store_id[:8] + "..."
reconnect_message = { reconnect_message = {
"type": "connect_message", "type": "connect_message",
"store_id": store_id, "store_id": store_id,
"status": True, "status": True,
"content": f"GUI重连成功{platform}平台状态正常" "cookies": "" # 重连时无需再次发送cookies
} }
# 异步发送,不阻塞连接过程
asyncio.run_coroutine_threadsafe( # 同步发送,确保发送成功
future = asyncio.run_coroutine_threadsafe(
self._send_to_backend(reconnect_message), self._send_to_backend(reconnect_message),
self.loop self.loop
) )
print(f"[重连] 已上报{platform}平台状态") # 等待发送完成最多2秒
future.result(timeout=2)
self._log(f"✅ 已重新上报 {platform} 平台状态: {store_display}", "SUCCESS")
except Exception as e: except Exception as e:
print(f"[重连] 上报{platform}平台状态失败: {e}") self._log(f" 上报 {platform} 平台状态失败: {e}", "ERROR")
import traceback
self._log(f"详细错误: {traceback.format_exc()}", "DEBUG")
else: else:
print("[重连] 当前无活跃平台连接,跳过状态上报") self._log("当前无活跃平台连接,跳过状态上报", "INFO")
except Exception as e: except Exception as e:
print(f"[重连] 状态上报过程异常: {e}") self._log(f"状态上报过程异常: {e}", "ERROR")
import traceback
self._log(f"详细错误: {traceback.format_exc()}", "DEBUG")
def _notify_connection_status(self, connected: bool): def _notify_connection_status(self, connected: bool, is_reconnect: bool = False):
"""通知后端连接状态变化""" """通知后端连接状态变化"""
try: try:
if not self.loop: if not self.loop:
@@ -337,30 +399,21 @@ class BackendClient:
# 获取当前活跃平台的store_id # 获取当前活跃平台的store_id
active_store_id = None active_store_id = None
try: try:
from Utils.JD.JdUtils import WebsocketManager as JdManager from WebSocket.backend_singleton import get_websocket_manager
from Utils.Dy.DyUtils import WebsocketManager as DyManager manager = get_websocket_manager()
from Utils.Pdd.PddUtils import WebsocketManager as PddManager
# 检查各平台是否有活跃连接 # 从 platform_listeners 获取活跃平台
for mgr_class, platform_name in [(JdManager, "京东"), (DyManager, "抖音"), (PddManager, "拼多多")]: if hasattr(manager, 'platform_listeners') and manager.platform_listeners:
try: # 获取第一个活跃平台的 store_id
mgr = mgr_class() for platform_key, listener_info in manager.platform_listeners.items():
if hasattr(mgr, '_store') and mgr._store: store_id = listener_info.get('store_id')
for shop_key, entry in mgr._store.items(): platform = listener_info.get('platform')
if entry and entry.get('platform'): if store_id and platform:
# 从shop_key中提取store_id格式平台:store_id
if ':' in shop_key:
_, store_id = shop_key.split(':', 1)
active_store_id = store_id active_store_id = store_id
print(f"[状态] 检测到活跃{platform_name}平台: {store_id}") self._log(f"检测到活跃{platform}平台: {store_id}", "DEBUG")
break
if active_store_id:
break break
except Exception as e: except Exception as e:
print(f"[状态] 检查{platform_name}平台失败: {e}") self._log(f"获取活跃平台信息失败: {e}", "DEBUG")
continue
except Exception as e:
print(f"[状态] 获取活跃平台信息失败: {e}")
status_message = { status_message = {
"type": "connection_status", "type": "connection_status",
@@ -369,26 +422,36 @@ class BackendClient:
"client_uuid": self.uuid "client_uuid": self.uuid
} }
# 🔥 新增:如果是重连,添加重连标识
if is_reconnect:
status_message["is_reconnect"] = True
# 如果有活跃平台添加store_id # 如果有活跃平台添加store_id
if active_store_id: if active_store_id:
status_message["store_id"] = active_store_id status_message["store_id"] = active_store_id
print(f"[状态] 添加store_id到状态消息: {active_store_id}") self._log(f"添加store_id到状态消息: {active_store_id}", "DEBUG")
else: else:
print(f"[状态] 未检测到活跃平台不添加store_id") self._log("未检测到活跃平台不添加store_id", "DEBUG")
# 异步发送状态通知 # 异步发送状态通知
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
self._send_to_backend(status_message), self._send_to_backend(status_message),
self.loop self.loop
) )
#
# # 🔥 等待发送完成(可选,避免警告)
# try:
# future.result(timeout=2) # 最多等待2秒
# except Exception as send_error:
# self._log(f"发送状态通知异常: {send_error}", "DEBUG")
status_text = "连接" if connected else "断开" status_text = "连接" if connected else "断开"
print(f"[状态] 已通知后端GUI客户端{status_text}") self._log(f"已通知后端GUI客户端{status_text}", "DEBUG")
except Exception as e: except Exception as e:
print(f"[状态] 发送状态通知失败: {e}") self._log(f"发送状态通知失败: {e}", "ERROR")
import traceback import traceback
print(f"[状态] 详细错误: {traceback.format_exc()}") self._log(f"详细错误: {traceback.format_exc()}", "DEBUG")
def on_message_received(self, message: Dict[str, Any]): def on_message_received(self, message: Dict[str, Any]):
"""处理接收到的消息 - 根据WebSocket文档v2更新""" """处理接收到的消息 - 根据WebSocket文档v2更新"""
@@ -429,8 +492,16 @@ class BackendClient:
self._handle_login(message) self._handle_login(message)
elif msg_type == 'error': elif msg_type == 'error':
self._handle_error_message(message) self._handle_error_message(message)
elif msg_type == 'error_token':
self._handle_token_error(message)
elif msg_type == 'staff_list': elif msg_type == 'staff_list':
self._handle_staff_list(message) self._handle_staff_list(message)
elif msg_type == 'version_response': # 新增:版本检查响应
self._handle_version_response(message)
elif msg_type == 'disconnect': # 新增:被踢下线
self._handle_disconnect(message)
elif msg_type == 'balance_insufficient': # 新增:余额不足
self._handle_balance_insufficient(message)
else: else:
print(f"未知消息类型: {msg_type}") print(f"未知消息类型: {msg_type}")
@@ -646,7 +717,8 @@ class BackendClient:
store_id = message.get('store_id', '') store_id = message.get('store_id', '')
data = message.get('data') data = message.get('data')
content = message.get('content', '') content = message.get('content', '')
print(f"[{store_id}] [{message.get('msg_type', 'unknown')}] : {content}") msg_type = message.get('msg_type', 'text') # 获取消息类型,默认为text
print(f"[{store_id}] [{msg_type}] : {content}")
# 尝试将后端AI/客服回复转发到对应平台 # 尝试将后端AI/客服回复转发到对应平台
try: try:
@@ -657,13 +729,16 @@ class BackendClient:
platform_type = self._get_platform_by_store_id(store_id) platform_type = self._get_platform_by_store_id(store_id)
if platform_type == "京东": if platform_type == "京东":
self._forward_to_jd(store_id, recv_pin, content) # 🔥 传递msg_type参数支持图片/视频等类型
self._forward_to_jd(store_id, recv_pin, content, msg_type)
elif platform_type == "抖音": elif platform_type == "抖音":
self._forward_to_douyin(store_id, recv_pin, content) # 传递msg_type参数支持图片/视频等类型
self._forward_to_douyin(store_id, recv_pin, content, msg_type)
elif platform_type == "千牛": elif platform_type == "千牛":
self._forward_to_qianniu(store_id, recv_pin, content) self._forward_to_qianniu(store_id, recv_pin, content)
elif platform_type == "拼多多": elif platform_type == "拼多多":
self._forward_to_pdd(store_id, recv_pin, content) # 传递msg_type参数支持图片/视频等类型
self._forward_to_pdd(store_id, recv_pin, content, msg_type)
else: else:
print(f"[Forward] 未知平台类型或未找到店铺: {platform_type}, store_id={store_id}") print(f"[Forward] 未知平台类型或未找到店铺: {platform_type}, store_id={store_id}")
except Exception as e: except Exception as e:
@@ -687,8 +762,15 @@ class BackendClient:
print(f"获取平台类型失败: {e}") print(f"获取平台类型失败: {e}")
return "" return ""
def _forward_to_jd(self, store_id: str, recv_pin: str, content: str): def _forward_to_jd(self, store_id: str, recv_pin: str, content: str, msg_type: str = "text"):
"""转发消息到京东平台""" """转发消息到京东平台(支持文本/图片/视频)
Args:
store_id: 店铺ID
recv_pin: 接收者pin
content: 消息内容文本内容或URL
msg_type: 消息类型text/image/video
"""
try: try:
from Utils.JD.JdUtils import WebsocketManager as JDWSManager from Utils.JD.JdUtils import WebsocketManager as JDWSManager
jd_mgr = JDWSManager() jd_mgr = JDWSManager()
@@ -704,52 +786,62 @@ class BackendClient:
pin_zj = platform_info.get('pin_zj') pin_zj = platform_info.get('pin_zj')
vender_id = platform_info.get('vender_id') vender_id = platform_info.get('vender_id')
loop = platform_info.get('loop') loop = platform_info.get('loop')
cookies_str = platform_info.get('cookies_str') # 🔥 获取cookies用于文件上传
print( print(
f"[JD Forward] shop_key={shop_key} has_ws={bool(ws)} aid={aid} pin_zj={pin_zj} vender_id={vender_id} has_loop={bool(loop)} recv_pin={recv_pin}") f"[JD Forward] shop_key={shop_key} has_ws={bool(ws)} aid={aid} pin_zj={pin_zj} vender_id={vender_id} has_loop={bool(loop)} has_cookies={bool(cookies_str)} recv_pin={recv_pin} msg_type={msg_type}")
if ws and aid and pin_zj and vender_id and loop and content: if ws and aid and pin_zj and vender_id and loop and content:
# 🔥 获取 FixJdCookie 实例,使用其 send_message 方法(支持多媒体)
async def _send(): async def _send():
import hashlib as _hashlib from Utils.JD.JdUtils import FixJdCookie
import time as _time # 创建临时实例用于发送
import json as _json jd_instance = FixJdCookie()
msg = { # 🔥 设置认证信息(用于图片/视频上传)
"ver": "4.3", jd_instance.cookies_str = cookies_str
"type": "chat_message", jd_instance.current_aid = aid
"from": {"pin": pin_zj, "app": "im.waiter", "clientType": "comet"}, jd_instance.current_pin_zj = pin_zj
"to": {"app": "im.customer", "pin": recv_pin}, # 调用支持多媒体的 send_message 方法
"id": _hashlib.md5(str(int(_time.time() * 1000)).encode()).hexdigest(), await jd_instance.send_message(
"lang": "zh_CN", ws=ws,
"aid": aid, pin=recv_pin,
"timestamp": int(_time.time() * 1000), aid=aid,
"readFlag": 0, pin_zj=pin_zj,
"body": { vender_id=vender_id,
"content": content, content=content,
"translated": False, msg_type=msg_type
"param": {"cusVenderId": vender_id}, )
"type": "text"
}
}
await ws.send(_json.dumps(msg))
import asyncio as _asyncio import asyncio as _asyncio
_future = _asyncio.run_coroutine_threadsafe(_send(), loop) _future = _asyncio.run_coroutine_threadsafe(_send(), loop)
try: try:
_future.result(timeout=2) _future.result(timeout=60) # 图片/视频需要更长时间
print(f"[JD Forward] 已转发到平台: pin={recv_pin}, content_len={len(content)}") print(f"[JD Forward] 已转发到平台: pin={recv_pin}, type={msg_type}, content_len={len(content)}")
except Exception as fe: except Exception as fe:
print(f"[JD Forward] 转发提交失败: {fe}") print(f"[JD Forward] 转发提交失败: {fe}")
import traceback
traceback.print_exc()
else: else:
print("[JD Forward] 条件不足,未转发:", print("[JD Forward] 条件不足,未转发:",
{ {
'has_ws': bool(ws), 'has_aid': bool(aid), 'has_pin_zj': bool(pin_zj), 'has_ws': bool(ws), 'has_aid': bool(aid), 'has_pin_zj': bool(pin_zj),
'has_vender_id': bool(vender_id), 'has_loop': bool(loop), 'has_content': bool(content) 'has_vender_id': bool(vender_id), 'has_loop': bool(loop), 'has_cookies': bool(cookies_str),
'has_content': bool(content)
}) })
except Exception as e: except Exception as e:
print(f"[JD Forward] 转发失败: {e}") print(f"[JD Forward] 转发失败: {e}")
import traceback
traceback.print_exc()
def _forward_to_douyin(self, store_id: str, recv_pin: str, content: str): def _forward_to_douyin(self, store_id: str, recv_pin: str, content: str, msg_type: str = "text"):
"""转发消息到抖音平台""" """转发消息到抖音平台
Args:
store_id: 店铺ID
recv_pin: 接收者ID
content: 消息内容
msg_type: 消息类型text/image/video
"""
try: try:
from Utils.Dy.DyUtils import DouYinWebsocketManager from Utils.Dy.DyUtils import DouYinWebsocketManager
dy_mgr = DouYinWebsocketManager() dy_mgr = DouYinWebsocketManager()
@@ -765,7 +857,7 @@ class BackendClient:
message_handler = platform_info.get('message_handler') message_handler = platform_info.get('message_handler')
print( print(
f"[DY Forward] shop_key={shop_key} has_bot={bool(douyin_bot)} has_handler={bool(message_handler)} recv_pin={recv_pin}") f"[DY Forward] shop_key={shop_key} has_bot={bool(douyin_bot)} has_handler={bool(message_handler)} recv_pin={recv_pin} msg_type={msg_type}")
if douyin_bot and message_handler and content: if douyin_bot and message_handler and content:
# 在消息处理器的事件循环中发送消息 # 在消息处理器的事件循环中发送消息
@@ -774,16 +866,16 @@ class BackendClient:
# 获取消息处理器的事件循环 # 获取消息处理器的事件循环
loop = message_handler._loop loop = message_handler._loop
if loop and not loop.is_closed(): if loop and not loop.is_closed():
# 在事件循环中执行发送 # 在事件循环中执行发送传递msg_type参数
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
message_handler.send_message_external(recv_pin, content), message_handler.send_message_external(recv_pin, content, msg_type),
loop loop
) )
# 等待结果 # 等待结果
try: try:
result = future.result(timeout=5) result = future.result(timeout=30) # 图片/视频需要更长时间
if result: if result:
print(f"[DY Forward] 已转发到平台: pin={recv_pin}, content_len={len(content)}") print(f"[DY Forward] 已转发到平台: pin={recv_pin}, type={msg_type}, content_len={len(content)}")
else: else:
print(f"[DY Forward] 转发失败: 消息处理器返回False") print(f"[DY Forward] 转发失败: 消息处理器返回False")
except Exception as fe: except Exception as fe:
@@ -817,8 +909,15 @@ class BackendClient:
except Exception as e: except Exception as e:
print(f"[QN Forward] 转发失败: {e}") print(f"[QN Forward] 转发失败: {e}")
def _forward_to_pdd(self, store_id: str, recv_pin: str, content: str): def _forward_to_pdd(self, store_id: str, recv_pin: str, content: str, msg_type: str = "text"):
"""转发消息到拼多多平台""" """转发消息到拼多多平台
Args:
store_id: 店铺ID
recv_pin: 接收者ID
content: 消息内容文本或图片URL
msg_type: 消息类型text/image/video
"""
try: try:
from Utils.Pdd.PddUtils import WebsocketManager as PDDWSManager from Utils.Pdd.PddUtils import WebsocketManager as PDDWSManager
pdd_mgr = PDDWSManager() pdd_mgr = PDDWSManager()
@@ -834,22 +933,22 @@ class BackendClient:
loop = platform_info.get('loop') loop = platform_info.get('loop')
print( print(
f"[PDD Forward] shop_key={shop_key} has_pdd_instance={bool(pdd_instance)} has_loop={bool(loop)} recv_pin={recv_pin}") f"[PDD Forward] shop_key={shop_key} has_pdd_instance={bool(pdd_instance)} has_loop={bool(loop)} recv_pin={recv_pin} msg_type={msg_type}")
if pdd_instance and loop and content: if pdd_instance and loop and content:
# 在拼多多实例的事件循环中发送消息 # 在拼多多实例的事件循环中发送消息
def send_in_loop(): def send_in_loop():
try: try:
# 在事件循环中执行发送 # 在事件循环中执行发送传递msg_type参数
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
pdd_instance.send_message_external(recv_pin, content), pdd_instance.send_message_external(recv_pin, content, msg_type),
loop loop
) )
# 等待结果 # 等待结果
try: try:
result = future.result(timeout=10) # 拼多多可能需要更长时间 result = future.result(timeout=10) # 拼多多可能需要更长时间
if result: if result:
print(f"[PDD Forward] 已转发到平台: uid={recv_pin}, content_len={len(content)}") print(f"[PDD Forward] 已转发到平台: uid={recv_pin}, type={msg_type}, content_len={len(content)}")
else: else:
print(f"[PDD Forward] 转发失败: 拼多多实例返回False") print(f"[PDD Forward] 转发失败: 拼多多实例返回False")
except Exception as fe: except Exception as fe:
@@ -1138,35 +1237,64 @@ class BackendClient:
self.get_store() self.get_store()
def _handle_login(self, message: Dict[str, Any]): def _handle_login(self, message: Dict[str, Any]):
"""处理平台登录消息新版type=login, cookies/login_params, store_id, platform_name""" """处理平台登录消息新版type=login, cookies/login_params, store_id, platform_name, store_name"""
cookies = message.get('cookies', '') cookies = message.get('cookies', '')
store_id = message.get('store_id', '') store_id = message.get('store_id', '')
platform_name = message.get('platform_name', '') platform_name = message.get('platform_name', '')
store_name = message.get('store_name', '') # 新增:获取店铺名称
content = message.get('content', '') content = message.get('content', '')
data = message.get('data', {}) data = message.get('data', {})
# 判断是拼多多登录参数还是普通Cookie # 🔥 判断是登录参数模式还是普通Cookie模式(支持拼多多和抖音)
if platform_name == "拼多多" and ("拼多多登录" in content) and data.get('login_params'): if (platform_name in ["拼多多", "抖音"] and
# 拼多多登录参数模式 - 传递完整的消息JSON给处理器 (("拼多多登录" in content and data.get('login_params')) or
print(f"收到拼多多登录参数: 平台={platform_name}, 店铺={store_id}, 类型={content}") ("抖音登录" in content and data.get('login_flow')))):
# 登录参数模式 - 传递完整的消息JSON给处理器
print(f"收到{platform_name}登录参数: 平台={platform_name}, 店铺={store_name or store_id}, 类型={content}")
if self.login_callback: if self.login_callback:
# 传递完整的JSON消息拼多多处理器来解析login_params # 传递完整的JSON消息让处理器来解析登录参数
import json import json
full_message = json.dumps(message) full_message = json.dumps(message)
self.login_callback(platform_name, store_id, full_message) self.login_callback(platform_name, store_id, full_message, store_name)
else: else:
# 普通Cookie模式 # 普通Cookie模式
print(f"收到登录指令: 平台={platform_name}, 店铺={store_id}, cookies_len={len(cookies) if cookies else 0}") print(
f"收到登录指令: 平台={platform_name}, 店铺={store_name or store_id}, cookies_len={len(cookies) if cookies else 0}")
if self.login_callback: if self.login_callback:
self.login_callback(platform_name, store_id, cookies) self.login_callback(platform_name, store_id, cookies, store_name)
def _handle_error_message(self, message: Dict[str, Any]): def _handle_error_message(self, message: Dict[str, Any]):
"""处理错误消息""" """处理错误消息"""
error_msg = message.get('error', '未知错误') error_msg = message.get('error', '未知错误')
content = message.get('content', '')
# 检查是否为token错误无论type是error还是error_token
if content == "无效的exe_token" or "无效的exe_token" in content:
print(f"[错误] 检测到token错误: {content}")
self._handle_token_error(message)
return
print(f"后端连接错误: {error_msg}") print(f"后端连接错误: {error_msg}")
if self.error_callback: if self.error_callback:
self.error_callback(error_msg, message) self.error_callback(error_msg, message)
def _handle_token_error(self, message: Dict[str, Any]):
"""处理token错误消息 - 无效token时停止重连并显示错误"""
error_content = message.get('content', '无效的exe_token')
print(f"[错误] Token验证失败: {error_content}")
# 停止重连机制
self.should_stop = True
self.is_reconnecting = False
# 触发token错误回调
if self.token_error_callback:
self.token_error_callback(error_content)
# 主动关闭连接
if self.websocket:
asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)
def _handle_staff_list(self, message: Dict[str, Any]): def _handle_staff_list(self, message: Dict[str, Any]):
"""处理客服列表更新消息""" """处理客服列表更新消息"""
staff_list = message.get('data', {}).get('staff_list', []) staff_list = message.get('data', {}).get('staff_list', [])
@@ -1180,6 +1308,39 @@ class BackendClient:
if self.customers_callback: # 假设客服列表更新也触发客服列表回调 if self.customers_callback: # 假设客服列表更新也触发客服列表回调
self.customers_callback(staff_list, store_id) self.customers_callback(staff_list, store_id)
def _handle_version_response(self, message: Dict[str, Any]):
"""处理版本检查响应"""
latest_version = message.get('latest_version')
download_url = message.get('download_url')
print(f"收到版本检查响应: 最新版本={latest_version}, 下载地址={download_url}")
if self.version_callback:
self.version_callback(message)
def _handle_disconnect(self, message: Dict[str, Any]):
"""处理被踢下线消息"""
disconnect_message = message.get('content', '您的账号在其他设备登录,当前连接已断开')
print(f"[断开] 收到后端断开通知: {disconnect_message}")
# 停止重连机制(不再尝试重连)
self.should_stop = True
# 触发断开回调
if self.disconnect_callback:
self.disconnect_callback(disconnect_message)
def _handle_balance_insufficient(self, message: Dict[str, Any]):
"""处理余额不足消息"""
balance_message = message.get('content', '所有平台已断开。请充值条数后,重新连接')
self._log(f"⚠️ 余额不足: {balance_message}", "WARNING")
# 触发余额不足回调(在主线程中处理断开逻辑)
if self.balance_insufficient_callback:
self.balance_insufficient_callback(balance_message)
# ==================== 辅助方法 ==================== # ==================== 辅助方法 ====================
def set_token(self, token: str): def set_token(self, token: str):

View File

@@ -4,6 +4,8 @@ from typing import Optional, Callable
import threading import threading
import asyncio import asyncio
from threading import Thread from threading import Thread
from PyQt5.QtCore import QObject, pyqtSignal
# 创建新的后端客户端 # 创建新的后端客户端
from WebSocket.BackendClient import BackendClient from WebSocket.BackendClient import BackendClient
from Utils.JD.JdUtils import JDListenerForGUI as JDListenerForGUI_WS from Utils.JD.JdUtils import JDListenerForGUI as JDListenerForGUI_WS
@@ -11,6 +13,12 @@ from Utils.Dy.DyUtils import DouYinListenerForGUI as DYListenerForGUI_WS
from Utils.Pdd.PddUtils import PddListenerForGUI as PDDListenerForGUI_WS from Utils.Pdd.PddUtils import PddListenerForGUI as PDDListenerForGUI_WS
from Utils.QianNiu.QianNiuUtils import QianNiuListenerForGUI as QNListenerForGUI_WS from Utils.QianNiu.QianNiuUtils import QianNiuListenerForGUI as QNListenerForGUI_WS
class PlatformConnectionSignals(QObject):
"""平台连接信号(线程安全)"""
platform_connected = pyqtSignal(str, str) # (platform_name, store_id)
_backend_client = None _backend_client = None
@@ -27,18 +35,36 @@ class WebSocketManager:
"""WebSocket连接管理器统一处理后端连接和平台监听""" """WebSocket连接管理器统一处理后端连接和平台监听"""
def __init__(self): def __init__(self):
# 后端客户端
self.backend_client = None self.backend_client = None
self.is_connected = False
# 版本检查器
self.version_checker = None
self.gui_update_callback = None
self.platform_listeners = {} # 存储各平台的监听器 self.platform_listeners = {} # 存储各平台的监听器
self.connected_platforms = [] # 存储已连接的平台列表 # <- 新增 self.connected_platforms = [] # 存储已连接的平台列表 # <- 新增
# 平台连接信号(线程安全)
self.platform_signals = PlatformConnectionSignals()
self.platform_signals.platform_connected.connect(self._on_platform_signal_received)
# 🔥 会话超时信号(用于拼多多会话过期重试)
self.session_timeout_signal = None
self.callbacks = { self.callbacks = {
'log': None, 'log': None,
'success': None, 'success': None,
'error': None, 'error': None,
'platform_connected': None, 'platform_connected': None,
'token_error': None,
'disconnect': None, # 新增:被踢下线回调
} }
def set_callbacks(self, log: Callable = None, success: Callable = None, error: Callable = None, def set_callbacks(self, log: Callable = None, success: Callable = None, error: Callable = None,
platform_connected: Callable = None): # ← 新增参数 platform_connected: Callable = None, platform_disconnected: Callable = None,
token_error: Callable = None, disconnect: Callable = None,
balance_insufficient: Callable = None):
"""设置回调函数""" """设置回调函数"""
if log: if log:
self.callbacks['log'] = log self.callbacks['log'] = log
@@ -48,6 +74,14 @@ class WebSocketManager:
self.callbacks['error'] = error self.callbacks['error'] = error
if platform_connected: # ← 新增 if platform_connected: # ← 新增
self.callbacks['platform_connected'] = platform_connected self.callbacks['platform_connected'] = platform_connected
if platform_disconnected:
self.callbacks['platform_disconnected'] = platform_disconnected
if token_error:
self.callbacks['token_error'] = token_error
if disconnect:
self.callbacks['disconnect'] = disconnect
if balance_insufficient:
self.callbacks['balance_insufficient'] = balance_insufficient
def _log(self, message: str, level: str = "INFO"): def _log(self, message: str, level: str = "INFO"):
"""内部日志方法""" """内部日志方法"""
@@ -56,8 +90,16 @@ class WebSocketManager:
else: else:
print(f"[{level}] {message}") print(f"[{level}] {message}")
def _on_platform_signal_received(self, platform_name: str, store_id: str):
"""接收平台连接信号(在主线程中执行)"""
try:
self._log(f"📡 收到平台连接信号: {platform_name} (店铺:{store_id})", "INFO")
self._notify_platform_connected(platform_name)
except Exception as e:
self._log(f"处理平台连接信号失败: {e}", "ERROR")
def _notify_platform_connected(self, platform_name: str): def _notify_platform_connected(self, platform_name: str):
"""通知GUI平台连接成功""" """通知GUI平台连接成功(仅在主线程中调用)"""
try: try:
if platform_name not in self.connected_platforms: if platform_name not in self.connected_platforms:
self.connected_platforms.append(platform_name) self.connected_platforms.append(platform_name)
@@ -68,20 +110,117 @@ class WebSocketManager:
except Exception as e: except Exception as e:
self._log(f"通知平台连接失败: {e}", "ERROR") self._log(f"通知平台连接失败: {e}", "ERROR")
def notify_platform_kicked(self, platform_name: str, store_name: str, reason: str = "账号在其他设备登录",
store_id: str = None):
"""通知GUI平台被踢下线供平台监听器调用"""
try:
self._log(f"⚠️ 平台被踢下线: {platform_name} - {store_name}, 原因: {reason}", "WARNING")
# 从连接列表中移除
if platform_name in self.connected_platforms:
self.connected_platforms.remove(platform_name)
# 🔥 发送平台断开消息给后端status=false
if store_id and self.backend_client:
try:
disconnect_message = {
"type": "connect_message",
"store_id": store_id,
"status": False,
"cookies": ""
}
self.backend_client.send_message(disconnect_message)
self._log(f"✅ 已通知后端 {platform_name} 平台断开: {store_id}", "INFO")
except Exception as send_error:
self._log(f"❌ 发送平台断开消息失败: {send_error}", "ERROR")
# 通知GUI显示弹窗
if self.callbacks['platform_disconnected']:
self.callbacks['platform_disconnected'](platform_name, store_name, reason)
except Exception as e:
self._log(f"通知平台断开失败: {e}", "ERROR")
def disconnect_all_async(self):
"""异步断开所有连接(余额不足时调用)- 不阻塞当前线程"""
try:
self._log("🔴 收到余额不足通知,开始断开所有连接...", "ERROR")
# 1. 断开所有平台连接
if self.platform_listeners:
platform_count = len(self.platform_listeners)
self._log(f"正在断开 {platform_count} 个平台连接...", "INFO")
# 复制键列表,避免遍历时修改字典
platform_keys = list(self.platform_listeners.keys())
for key in platform_keys:
try:
listener_info = self.platform_listeners.get(key)
if listener_info:
platform_type = listener_info.get('platform', '')
self._log(f"断开 {platform_type} 平台连接: {key}", "INFO")
# 移除连接记录
self.platform_listeners.pop(key, None)
except Exception as e:
self._log(f"断开平台连接失败: {e}", "ERROR")
self._log(f"✅ 已断开所有 {platform_count} 个平台连接", "INFO")
# 清空连接列表
self.connected_platforms.clear()
# 2. 延迟断开后端连接(在新线程中执行,避免阻塞)
import threading
def _delayed_backend_disconnect():
try:
import time
time.sleep(0.5) # 延迟0.5秒,确保上面的操作完成
if self.backend_client:
self.backend_client.should_stop = True
self.backend_client.is_connected = False
self._log("✅ 已标记后端连接为断开状态", "INFO")
except Exception as e:
self._log(f"断开后端连接失败: {e}", "ERROR")
disconnect_thread = threading.Thread(target=_delayed_backend_disconnect, daemon=True)
disconnect_thread.start()
except Exception as e:
self._log(f"断开所有连接失败: {e}", "ERROR")
def connect_backend(self, token: str) -> bool: def connect_backend(self, token: str) -> bool:
"""连接后端WebSocket""" """连接后端WebSocket"""
try: try:
# 1 保存token到配置
# 🔥 根据配置决定是否保存token
# 生产模式保存token方便用户下次自动加载
# 测试模式:不保存,避免多实例冲突
import config as cfg
if not cfg.is_multi_instance_mode():
try: try:
from config import set_saved_token from config import set_saved_token
set_saved_token(token) set_saved_token(token)
except Exception: self._log("生产模式已保存token到配置文件", "INFO")
pass except Exception as e:
self._log(f"保存token失败: {e}", "WARNING")
else:
self._log("测试模式不保存token支持多实例运行", "INFO")
# 2 获取或创建后端客户端 # 2 获取或创建后端客户端
backend = get_backend_client() backend = get_backend_client()
if backend: if backend:
# 检查现有客户端是否因token错误而停止
if backend.should_stop:
self._log("检测到客户端因token错误已停止创建新的客户端", "INFO")
# 断开旧客户端
backend.disconnect()
# 清除旧客户端引用
set_backend_client(None)
backend = None
else:
# 3 如果有客户端更新token并重连 # 3 如果有客户端更新token并重连
backend.set_token(token) backend.set_token(token)
@@ -91,16 +230,41 @@ class WebSocketManager:
self._log("连接服务成功", "SUCCESS") self._log("连接服务成功", "SUCCESS")
if self.callbacks['success']: if self.callbacks['success']:
self.callbacks['success']() self.callbacks['success']()
# 启动版本检查器
self._start_version_checker()
except Exception as e: except Exception as e:
self._log(f"成功回调执行失败: {e}", "ERROR") self._log(f"成功回调执行失败: {e}", "ERROR")
def _on_backend_login(platform_name: str, store_id: str, cookies: str): def _on_backend_login(platform_name: str, store_id: str, cookies: str, store_name: str = ""):
store_display = store_name or store_id
self._log( self._log(
f"收到后端登录指令: 平台={platform_name}, 店铺={store_id}, cookies_len={len(cookies) if cookies else 0}", f"收到后端登录指令: 平台={platform_name}, 店铺={store_display}, cookies_len={len(cookies) if cookies else 0}",
"INFO") "INFO")
self._handle_platform_login(platform_name, store_id, cookies) self._handle_platform_login(platform_name, store_id, cookies, store_name)
backend.set_callbacks(success=_on_backend_success, login=_on_backend_login) def _on_token_error(error_content: str):
self._log(f"Token验证失败: {error_content}", "ERROR")
if self.callbacks['token_error']:
self.callbacks['token_error'](error_content)
def _on_disconnect(disconnect_msg: str):
self._log(f"被踢下线: {disconnect_msg}", "WARNING")
if self.callbacks['disconnect']:
self.callbacks['disconnect'](disconnect_msg)
def _on_balance_insufficient(balance_msg: str):
"""余额不足回调"""
if self.callbacks['balance_insufficient']:
self.callbacks['balance_insufficient'](balance_msg)
def _on_log(message: str, level: str = "INFO"):
"""Backend client log callback"""
self._log(message, level)
backend.set_callbacks(success=_on_backend_success, login=_on_backend_login,
token_error=_on_token_error, disconnect=_on_disconnect,
balance_insufficient=_on_balance_insufficient,
log=_on_log)
if not backend.is_connected: if not backend.is_connected:
backend.connect() backend.connect()
@@ -108,25 +272,52 @@ class WebSocketManager:
self.backend_client = backend self.backend_client = backend
self._log("令牌已提交已连接后端。等待后端下发平台cookies后自动连接平台...", "SUCCESS") self._log("令牌已提交已连接后端。等待后端下发平台cookies后自动连接平台...", "SUCCESS")
return True return True
else:
# 如果没有现有客户端或客户端被重置,创建新的
if not backend:
backend = BackendClient.from_exe_token(token) backend = BackendClient.from_exe_token(token)
def _on_backend_login(platform_name: str, store_id: str, cookies: str): def _on_backend_login(platform_name: str, store_id: str, cookies: str, store_name: str = ""):
store_display = store_name or store_id
self._log( self._log(
f"收到后端登录指令: 平台={platform_name}, 店铺={store_id}, cookies_len={len(cookies) if cookies else 0}", f"收到后端登录指令: 平台={platform_name}, 店铺={store_display}, cookies_len={len(cookies) if cookies else 0}",
"INFO") "INFO")
self._handle_platform_login(platform_name, store_id, cookies) self._handle_platform_login(platform_name, store_id, cookies, store_name)
def _on_backend_success(): def _on_backend_success():
try: try:
self._log("连接服务成功", "SUCCESS") self._log("连接服务成功", "SUCCESS")
if self.callbacks['success']: if self.callbacks['success']:
self.callbacks['success']() self.callbacks['success']()
# 启动版本检查器
self._start_version_checker()
except Exception as e: except Exception as e:
self._log(f"成功回调执行失败: {e}", "ERROR") self._log(f"成功回调执行失败: {e}", "ERROR")
backend.set_callbacks(login=_on_backend_login, success=_on_backend_success) def _on_token_error(error_content: str):
self._log(f"Token验证失败: {error_content}", "ERROR")
if self.callbacks['token_error']:
self.callbacks['token_error'](error_content)
def _on_disconnect(disconnect_msg: str):
self._log(f"被踢下线: {disconnect_msg}", "ERROR")
if self.callbacks['disconnect']:
self.callbacks['disconnect'](disconnect_msg)
def _on_balance_insufficient(balance_msg: str):
"""余额不足回调"""
if self.callbacks['balance_insufficient']:
self.callbacks['balance_insufficient'](balance_msg)
def _on_log(message: str, level: str = "INFO"):
"""Backend client log callback"""
self._log(message, level)
backend.set_callbacks(login=_on_backend_login, success=_on_backend_success,
token_error=_on_token_error, disconnect=_on_disconnect,
balance_insufficient=_on_balance_insufficient,
log=_on_log)
backend.connect() backend.connect()
set_backend_client(backend) set_backend_client(backend)
@@ -140,9 +331,106 @@ class WebSocketManager:
self.callbacks['error'](str(e)) self.callbacks['error'](str(e))
return False return False
def _handle_platform_login(self, platform_name: str, store_id: str, cookies: str): def _handle_platform_login(self, platform_name: str, store_id: str, cookies: str, store_name: str = ""):
"""处理平台登录请求""" """处理平台登录请求"""
try: try:
# 🔥 检查并断开当前店铺的旧连接策略B先断开旧连接再建立新连接
store_key_pattern = f":{store_id}" # 匹配 "平台名:store_id" 格式
keys_to_remove = [key for key in self.platform_listeners.keys() if key.endswith(store_key_pattern)]
if keys_to_remove:
self._log(f"🔄 检测到店铺 {store_id} 重复登录,断开 {len(keys_to_remove)} 个旧连接", "INFO")
for key in keys_to_remove:
listener_info = self.platform_listeners.get(key)
if listener_info:
platform_type = listener_info.get('platform', '')
# 从各平台的 WebsocketManager 中获取连接并关闭WebSocket
try:
if platform_type == "京东":
from Utils.JD.JdUtils import WebsocketManager as JDWSManager
jd_mgr = JDWSManager()
conn_info = jd_mgr.get_connection(key)
if conn_info and conn_info.get('platform'):
ws = conn_info['platform'].get('ws')
if ws and hasattr(ws, 'close'):
try:
import asyncio
loop = conn_info['platform'].get('loop')
if loop and not loop.is_closed():
asyncio.run_coroutine_threadsafe(ws.close(), loop)
self._log(f"✅ 已关闭京东WebSocket连接: {key}", "DEBUG")
except Exception:
pass
jd_mgr.remove_connection(key)
self._log(f"✅ 已从京东管理器移除连接: {key}", "DEBUG")
elif platform_type == "抖音":
from Utils.Dy.DyUtils import DouYinWebsocketManager as DYWSManager
dy_mgr = DYWSManager()
conn_info = dy_mgr.get_connection(key)
if conn_info and conn_info.get('platform'):
ws = conn_info['platform'].get('ws')
if ws and hasattr(ws, 'close'):
try:
import asyncio
loop = conn_info['platform'].get('loop')
if loop and not loop.is_closed():
asyncio.run_coroutine_threadsafe(ws.close(), loop)
self._log(f"✅ 已关闭抖音WebSocket连接: {key}", "DEBUG")
except Exception:
pass
dy_mgr.remove_connection(key)
self._log(f"✅ 已从抖音管理器移除连接: {key}", "DEBUG")
elif platform_type == "千牛":
from Utils.QianNiu.QianNiuUtils import QianNiuWebsocketManager as QNWSManager
qn_mgr = QNWSManager()
qn_mgr.remove_connection(key)
self._log(f"✅ 已从千牛管理器移除连接: {key}", "DEBUG")
elif platform_type == "拼多多":
from Utils.Pdd.PddUtils import WebsocketManager as PDDWSManager
pdd_mgr = PDDWSManager()
conn_info = pdd_mgr.get_connection(key)
# 清理旧listener的定时器避免遗留定时器触发
old_listener = listener_info.get('listener')
if old_listener and hasattr(old_listener, 'pdd_bot') and old_listener.pdd_bot:
try:
old_listener.pdd_bot._cancel_session_timeout_check()
self._log(f"✅ [PDD] 旧监听器的定时器已清理", "DEBUG")
except Exception:
pass
if conn_info and conn_info.get('platform'):
# 关闭WebSocket连接
ws = conn_info['platform'].get('ws')
if ws and hasattr(ws, 'close'):
try:
import asyncio
loop = conn_info['platform'].get('loop')
if loop and not loop.is_closed():
asyncio.run_coroutine_threadsafe(ws.close(), loop)
self._log(f"✅ 已关闭拼多多WebSocket连接: {key}", "DEBUG")
except Exception as ws_e:
self._log(f"⚠️ 关闭WebSocket时出错: {ws_e}", "DEBUG")
pdd_mgr.remove_connection(key)
self._log(f"✅ 已从拼多多管理器移除连接: {key}", "DEBUG")
except Exception as e:
self._log(f"⚠️ 移除{platform_type}连接时出错: {e}", "WARNING")
# 从监听器字典中移除
self.platform_listeners.pop(key, None)
# 给WebSocket一点时间完全关闭
import time
time.sleep(0.5)
self._log(f"✅ 旧连接已全部断开,准备建立新连接", "INFO")
# 平台名称映射 # 平台名称映射
platform_map = { platform_map = {
"淘宝": "千牛", "淘宝": "千牛",
@@ -167,16 +455,16 @@ class WebSocketManager:
if cookies == "login_success": if cookies == "login_success":
self._log("⚠️ 千牛平台收到空cookies但允许启动监听器", "WARNING") self._log("⚠️ 千牛平台收到空cookies但允许启动监听器", "WARNING")
cookies = "" # 清空cookies千牛不需要真实cookies cookies = "" # 清空cookies千牛不需要真实cookies
self._start_qianniu_listener(store_id, cookies) self._start_qianniu_listener(store_id, cookies, store_name)
elif normalized_platform == "京东": elif normalized_platform == "京东":
self._start_jd_listener(store_id, cookies) self._start_jd_listener(store_id, cookies, store_name)
elif normalized_platform == "抖音": elif normalized_platform == "抖音":
self._start_douyin_listener(store_id, cookies) self._start_douyin_listener(store_id, cookies, store_name)
elif normalized_platform == "拼多多": elif normalized_platform == "拼多多":
self._start_pdd_listener(store_id, cookies) self._start_pdd_listener(store_id, cookies, store_name)
else: else:
self._log(f"❌ 不支持的平台: {platform_name}", "ERROR") self._log(f"❌ 不支持的平台: {platform_name}", "ERROR")
@@ -186,7 +474,35 @@ class WebSocketManager:
import traceback import traceback
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG") self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
def _start_jd_listener(self, store_id: str, cookies: str): def _start_version_checker(self):
"""启动版本检查器"""
try:
from version_checker import VersionChecker
self.version_checker = VersionChecker(
backend_client=self.backend_client,
update_callback=self._on_update_available
)
self.backend_client.version_callback = self.version_checker.handle_version_response
self.version_checker.start()
self._log("✅ 版本检查器已启动将每10分钟检查一次更新", "SUCCESS")
except Exception as e:
self._log(f"❌ 启动版本检查器失败: {e}", "ERROR")
def _on_update_available(self, latest_version, download_url):
"""发现新版本时的处理(在子线程中调用)"""
self._log(f"🔔 发现新版本 {latest_version}", "INFO")
# 通知主GUI显示更新提醒通过 Qt 信号机制,线程安全)
if hasattr(self, 'gui_update_callback') and self.gui_update_callback:
try:
# 直接调用回调(回调内部使用信号机制调度到主线程)
self.gui_update_callback(latest_version, download_url)
self._log(f"✅ 已调用更新回调", "DEBUG")
except Exception as e:
self._log(f"❌ 调用更新回调失败: {e}", "ERROR")
import traceback
self._log(f"详细错误: {traceback.format_exc()}", "ERROR")
def _start_jd_listener(self, store_id: str, cookies: str, store_name: str = ""):
"""启动京东平台监听""" """启动京东平台监听"""
try: try:
def _runner(): def _runner():
@@ -204,7 +520,8 @@ class WebSocketManager:
self.platform_listeners[f"京东:{store_id}"] = { self.platform_listeners[f"京东:{store_id}"] = {
'thread': thread, 'thread': thread,
'platform': '京东', 'platform': '京东',
'store_id': store_id 'store_id': store_id,
'store_name': store_name # 保存店铺名称
} }
# 上报连接状态给后端 # 上报连接状态给后端
@@ -235,21 +552,63 @@ class WebSocketManager:
except Exception as send_e: except Exception as send_e:
self._log(f"失败状态下报连接状态也失败: {send_e}", "ERROR") self._log(f"失败状态下报连接状态也失败: {send_e}", "ERROR")
def _start_douyin_listener(self, store_id: str, cookies: str): def _start_douyin_listener(self, store_id: str, cookies: str, store_name: str = ""):
"""启动抖音平台监听""" """启动抖音平台监听"""
try: try:
def _runner(): def _runner():
try: try:
import json import json
self._log("🚀 开始创建抖音监听器实例", "DEBUG")
listener = DYListenerForGUI_WS() listener = DYListenerForGUI_WS()
# 将JSON字符串格式的cookies解析为字典 self._log("✅ 抖音监听器实例创建成功", "DEBUG")
try:
cookie_dict = json.loads(cookies) if isinstance(cookies, str) else cookies
except json.JSONDecodeError as e:
self._log(f"❌ Cookie JSON解析失败: {e}", "ERROR")
return False
# 🔥 检查是否为登录参数模式(与拼多多保持一致)
if cookies and ('"login_flow"' in cookies or '"phone_number"' in cookies):
# 使用登录参数模式
self._log("📋 使用登录参数启动抖音监听器", "INFO")
self._log("🔄 开始执行 start_with_login_params", "DEBUG")
result = asyncio.run(listener.start_with_login_params(store_id=store_id, login_params=cookies))
self._log(f"📊 start_with_login_params 执行结果: {result}", "DEBUG")
# 详细的结果分析仅日志记录GUI 已在主线程中通知)
if result == "need_verification_code":
self._log("✅ [DY] 登录流程正常,已发送验证码需求通知给后端", "SUCCESS")
elif result == "verification_code_error":
self._log("⚠️ [DY] 验证码错误,已发送错误通知给后端", "WARNING")
elif result:
self._log("✅ [DY] 登录成功,平台连接已建立", "SUCCESS")
else:
self._log("❌ [DY] 登录失败", "ERROR")
else:
# 传统cookie模式保留兼容性
self._log("🍪 使用Cookie启动抖音监听器", "INFO")
self._log("🔄 开始执行 start_with_cookies", "DEBUG")
try:
# 🔥 修复尝试JSON解析失败时用ast.literal_eval解析Python字典字符串
if isinstance(cookies, str):
try:
cookie_dict = json.loads(cookies)
except json.JSONDecodeError:
# 后端发送的是Python字典字符串格式使用ast.literal_eval
import ast
cookie_dict = ast.literal_eval(cookies)
self._log("✅ 使用ast.literal_eval成功解析cookies", "DEBUG")
else:
cookie_dict = cookies
except (json.JSONDecodeError, ValueError, SyntaxError) as e:
self._log(f"❌ Cookie解析失败: {e}", "ERROR")
return False
result = asyncio.run(listener.start_with_cookies(store_id=store_id, cookie_dict=cookie_dict)) result = asyncio.run(listener.start_with_cookies(store_id=store_id, cookie_dict=cookie_dict))
self._log(f"📊 start_with_cookies 执行结果: {result}", "DEBUG")
# Cookie启动成功时记录日志GUI 已在主线程中通知)
if result:
self._log("✅ [DY] Cookie启动成功平台连接已建立", "SUCCESS")
# 🔥 移除不再在backend_singleton中发送connect_message
# 抖音的连接状态报告应该在DyUtils中的DyLogin类中发送与拼多多保持一致
# 所有特殊状态通知都已经在DyLogin中发送过了这里不需要重复发送
return result return result
except Exception as e: except Exception as e:
self._log(f"抖音监听器运行异常: {e}", "ERROR") self._log(f"抖音监听器运行异常: {e}", "ERROR")
@@ -264,42 +623,25 @@ class WebSocketManager:
'thread': thread, 'thread': thread,
'platform': '抖音', 'platform': '抖音',
'store_id': store_id, 'store_id': store_id,
'store_name': store_name # 保存店铺名称
} }
# 更新监听器状态 # 更新监听器状态
if f"抖音:{store_id}" in self.platform_listeners: if f"抖音:{store_id}" in self.platform_listeners:
self.platform_listeners[f"抖音:{store_id}"]['status'] = 'success' self.platform_listeners[f"抖音:{store_id}"]['status'] = 'success'
# 上报连接状态给后端 # ✅ 临时方案:启动后立即通知(因为 DY 监听器也会阻塞)
if self.backend_client: # DY 内部会处理验证码流程,失败时会向后端上报相应状态
try:
self.backend_client.send_message({
"type": "connect_message",
"store_id": store_id,
"status": True
})
self._log("已上报抖音平台连接状态: 成功", "INFO")
except Exception as e:
self._log(f"上报抖音平台连接状态失败: {e}", "WARNING")
self._log("已启动抖音平台监听", "SUCCESS") self._log("已启动抖音平台监听", "SUCCESS")
self._notify_platform_connected("抖音") # ← 新增 self._notify_platform_connected("抖音") # ← 立即通知
except Exception as e: except Exception as e:
self._log(f"启动抖音平台监听失败: {e}", "ERROR") self._log(f"启动抖音平台监听失败: {e}", "ERROR")
# 确保失败时也上报状态 # 🔥 移除:确保失败时也不在这里上报状态
if self.backend_client: # 失败状态应该在DyLogin中处理与拼多多保持一致
try:
self.backend_client.send_message({
"type": "connect_message",
"store_id": store_id,
"status": False
})
except Exception as send_e:
self._log(f"失败状态下报抖音平台连接状态也失败: {send_e}", "ERROR")
def _start_qianniu_listener(self, store_id: str, cookies: str): def _start_qianniu_listener(self, store_id: str, cookies: str, store_name: str = ""):
"""启动千牛平台监听(单连接多店铺架构)""" """启动千牛平台监听(单连接多店铺架构)"""
try: try:
# 获取用户token从后端客户端获取 # 获取用户token从后端客户端获取
@@ -328,6 +670,7 @@ class WebSocketManager:
'thread': thread, 'thread': thread,
'platform': '千牛', 'platform': '千牛',
'store_id': store_id, 'store_id': store_id,
'store_name': store_name, # 保存店铺名称
'exe_token': exe_token 'exe_token': exe_token
} }
@@ -359,43 +702,46 @@ class WebSocketManager:
except Exception as send_e: except Exception as send_e:
self._log(f"失败状态下报千牛平台连接状态也失败: {send_e}", "ERROR") self._log(f"失败状态下报千牛平台连接状态也失败: {send_e}", "ERROR")
def _start_pdd_listener(self, store_id: str, data: str): def _start_pdd_listener(self, store_id: str, data: str, store_name: str = ""):
"""启动拼多多平台监听""" """启动拼多多平台监听"""
try: try:
# 在创建新实例前,清理旧实例的定时器(避免遗留定时器触发)
shop_key = f"拼多多:{store_id}"
old_listener_info = self.platform_listeners.get(shop_key)
if old_listener_info:
try:
old_listener = old_listener_info.get('listener')
if old_listener and hasattr(old_listener, 'pdd_bot') and old_listener.pdd_bot:
old_listener.pdd_bot._cancel_session_timeout_check()
self._log(f"✅ 已清理旧监听器定时器", "DEBUG")
except Exception:
pass
def _runner(): def _runner():
try: try:
self._log("🚀 开始创建拼多多监听器实例", "DEBUG") # 传递session_timeout_signal用于会话过期重试
listener = PDDListenerForGUI_WS(log_callback=self._log) listener = PDDListenerForGUI_WS(
self._log("✅ 拼多多监听器实例创建成功", "DEBUG") log_callback=self._log,
session_timeout_signal=self.session_timeout_signal
)
# 立即保存listener引用到platform_listeners用于后续清理定时器
shop_key = f"拼多多:{store_id}"
if shop_key in self.platform_listeners:
self.platform_listeners[shop_key]['listener'] = listener
# 判断是登录参数还是Cookie # 判断是登录参数还是Cookie
if self._is_pdd_login_params(data): if self._is_pdd_login_params(data):
# 使用登录参数启动 # 使用登录参数启动
self._log("📋 使用登录参数启动拼多多监听器", "INFO")
self._log("🔄 开始执行 start_with_login_params", "DEBUG")
result = asyncio.run(listener.start_with_login_params(store_id=store_id, login_params=data)) result = asyncio.run(listener.start_with_login_params(store_id=store_id, login_params=data))
self._log(f"📊 start_with_login_params 执行结果: {result}", "DEBUG")
# 详细的结果分析
if result == "need_verification_code":
self._log("✅ [PDD] 登录流程正常,已发送验证码需求通知给后端", "SUCCESS")
elif result == "verification_code_error":
self._log("⚠️ [PDD] 验证码错误,已发送错误通知给后端", "WARNING")
elif result:
self._log("✅ [PDD] 登录成功,平台连接已建立", "SUCCESS")
self._notify_platform_connected("拼多多")
else:
self._log("❌ [PDD] 登录失败", "ERROR")
else: else:
# 使用Cookie启动兼容旧方式 # 使用Cookie启动兼容旧方式
self._log("🍪 使用Cookie启动拼多多监听器", "INFO")
self._log("🔄 开始执行 start_with_cookies", "DEBUG")
result = asyncio.run(listener.start_with_cookies(store_id=store_id, cookies=data)) result = asyncio.run(listener.start_with_cookies(store_id=store_id, cookies=data))
self._log(f"📊 start_with_cookies 执行结果: {result}", "DEBUG")
# 根据实际登录结果上报状态给后端 # 根据实际登录结果上报状态给后端
if self.backend_client and result not in ["need_verification_code", "verification_code_error", "login_failure"]: if self.backend_client and result not in ["need_verification_code", "verification_code_error",
# 如果是特殊状态说明通知已经在PddLogin中发送了不需要重复发送 "login_failure", "cookie_expired"]:
# 🔥 如果是特殊状态包括cookie_expired说明通知已经发送不需要重复发送
try: try:
message = { message = {
"type": "connect_message", "type": "connect_message",
@@ -404,7 +750,8 @@ class WebSocketManager:
} }
self.backend_client.send_message(message) self.backend_client.send_message(message)
status_text = "成功" if result else "失败" status_text = "成功" if result else "失败"
self._log(f"上报拼多多平台连接状态{status_text}: {message}", "SUCCESS" if result else "ERROR") self._log(f"上报拼多多平台连接状态{status_text}: {message}",
"SUCCESS" if result else "ERROR")
except Exception as send_e: except Exception as send_e:
self._log(f"上报拼多多平台连接状态失败: {send_e}", "ERROR") self._log(f"上报拼多多平台连接状态失败: {send_e}", "ERROR")
elif result == "need_verification_code": elif result == "need_verification_code":
@@ -413,6 +760,8 @@ class WebSocketManager:
self._log("验证码错误错误通知已由PddLogin发送等待后端处理", "INFO") self._log("验证码错误错误通知已由PddLogin发送等待后端处理", "INFO")
elif result == "login_failure": elif result == "login_failure":
self._log("登录失败失败通知已由PddLogin发送等待后端处理", "INFO") self._log("登录失败失败通知已由PddLogin发送等待后端处理", "INFO")
elif result == "cookie_expired":
self._log("🔄 Cookie已过期过期通知已发送等待后端重新下发Cookie", "INFO")
return result return result
@@ -446,9 +795,13 @@ class WebSocketManager:
'thread': thread, 'thread': thread,
'platform': '拼多多', 'platform': '拼多多',
'store_id': store_id, 'store_id': store_id,
'store_name': store_name # 保存店铺名称
} }
self._log("拼多多平台监听线程已启动,等待登录结果...", "INFO") # ✅ 临时方案:启动后立即通知(因为 PDD 监听器会阻塞,无法通过返回值判断)
# PDD 内部会处理验证码流程,失败时会向后端上报 status=False
self._log("已启动拼多多平台监听", "SUCCESS")
self._notify_platform_connected("拼多多") # ← 立即通知
except Exception as e: except Exception as e:
self._log(f"启动拼多多平台监听失败: {e}", "ERROR") self._log(f"启动拼多多平台监听失败: {e}", "ERROR")
@@ -468,29 +821,19 @@ class WebSocketManager:
def _is_pdd_login_params(self, data: str) -> bool: def _is_pdd_login_params(self, data: str) -> bool:
"""判断是否为拼多多登录参数""" """判断是否为拼多多登录参数"""
try: try:
self._log(f"🔍 [DEBUG] 检查是否为登录参数,数据长度: {len(data)}", "DEBUG")
self._log(f"🔍 [DEBUG] 数据前100字符: {data[:100]}", "DEBUG")
import json import json
parsed_data = json.loads(data) parsed_data = json.loads(data)
self._log(f"🔍 [DEBUG] JSON解析成功键: {list(parsed_data.keys())}", "DEBUG")
login_params = parsed_data.get("data", {}).get("login_params", {}) login_params = parsed_data.get("data", {}).get("login_params", {})
self._log(f"🔍 [DEBUG] login_params存在: {bool(login_params)}", "DEBUG")
if not login_params: if not login_params:
self._log("🔍 [DEBUG] login_params为空返回False", "DEBUG")
return False return False
# 检查必需的登录参数字段 # 检查必需的登录参数字段
required_fields = ["username", "password", "anti_content", "risk_sign", "timestamp"] required_fields = ["username", "password", "anti_content", "risk_sign", "timestamp"]
has_all_fields = all(field in login_params for field in required_fields) has_all_fields = all(field in login_params for field in required_fields)
self._log(f"🔍 [DEBUG] 包含所有必需字段: {has_all_fields}", "DEBUG")
self._log(f"🔍 [DEBUG] 现有字段: {list(login_params.keys())}", "DEBUG")
return has_all_fields return has_all_fields
except Exception as e: except Exception:
self._log(f"🔍 [DEBUG] 解析失败: {e}", "DEBUG")
return False return False
def send_message(self, message: dict): def send_message(self, message: dict):
@@ -526,3 +869,4 @@ def get_websocket_manager() -> WebSocketManager:
if _websocket_manager is None: if _websocket_manager is None:
_websocket_manager = WebSocketManager() _websocket_manager = WebSocketManager()
return _websocket_manager return _websocket_manager

489
auto_updater.py Normal file
View File

@@ -0,0 +1,489 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
自动更新模块 - 支持后台下载、静默安装和自动重启
优化版:带重试、超时、自动重启功能
"""
import os
import sys
import subprocess
import urllib.request
import tempfile
import time
import socket
from pathlib import Path
from PyQt5.QtCore import QThread, pyqtSignal
import ctypes
class UpdateDownloader(QThread):
"""
优化版更新下载器线程
特性:
- 自动重试3次
- 30秒网络超时
- 文件大小验证
- 进度实时更新
"""
# 信号定义
progress = pyqtSignal(int, int) # (已下载字节, 总字节)
finished = pyqtSignal(str) # 下载完成,返回文件路径
error = pyqtSignal(str) # 下载失败,返回错误信息
retry_info = pyqtSignal(int, int) # 重试信息 (当前重试次数, 总重试次数)
def __init__(self, download_url, version, max_retries=3):
super().__init__()
self.download_url = download_url
self.version = version
self.max_retries = max_retries # 最大重试次数
self.is_cancelled = False
def run(self):
"""执行下载(带重试机制)"""
for attempt in range(self.max_retries):
try:
if attempt > 0:
# 显示重试信息
self.retry_info.emit(attempt + 1, self.max_retries)
time.sleep(2) # 等待2秒后重试
# 执行实际下载
self._download_file()
return # 下载成功,退出
except Exception as e:
error_msg = str(e)
print(f"[UpdateDownloader] 下载失败 (尝试 {attempt + 1}/{self.max_retries}): {error_msg}")
if self.is_cancelled:
# 用户取消,立即退出
self.error.emit("下载已取消")
return
if attempt < self.max_retries - 1:
# 还有重试机会,继续
continue
else:
# 所有重试都失败了
self.error.emit(f"下载失败(已重试{self.max_retries}次)\n错误: {error_msg}")
def _download_file(self):
"""实际下载逻辑(带超时和验证)"""
# 设置网络超时
socket.setdefaulttimeout(30) # 30秒超时
# 准备临时目录和文件名
temp_dir = tempfile.gettempdir()
filename = f"ShuiDi_AI_Assistant_Setup_v{self.version}.exe"
file_path = os.path.join(temp_dir, filename)
print(f"[UpdateDownloader] 开始下载到: {file_path}")
print(f"[UpdateDownloader] 下载地址: {self.download_url}")
# 删除旧文件(如果存在)
if os.path.exists(file_path):
try:
os.remove(file_path)
print(f"[UpdateDownloader] 已删除旧文件")
except Exception as e:
print(f"[UpdateDownloader] 删除旧文件失败: {e}")
# 下载文件(带进度回调)
def progress_callback(block_num, block_size, total_size):
if self.is_cancelled:
raise Exception("用户取消下载")
downloaded = block_num * block_size
# 避免超过总大小
if total_size > 0 and downloaded > total_size:
downloaded = total_size
self.progress.emit(downloaded, total_size)
# 执行下载
urllib.request.urlretrieve(
self.download_url,
file_path,
reporthook=progress_callback
)
# 验证下载的文件
if not os.path.exists(file_path):
raise Exception("下载的文件不存在")
file_size = os.path.getsize(file_path)
print(f"[UpdateDownloader] 下载完成,文件大小: {file_size / (1024*1024):.2f} MB")
# 验证文件大小至少1MB避免下载了错误页面
min_file_size = 1024 * 1024 # 1MB正式环境
# 🧪 测试小文件时可以临时改为min_file_size = 10 * 1024 # 10KB
if file_size < min_file_size:
raise Exception(f"下载的文件大小异常: {file_size} bytes可能不是有效的安装包")
# 发送完成信号
self.finished.emit(file_path)
def cancel(self):
"""取消下载"""
print("[UpdateDownloader] 用户取消下载")
self.is_cancelled = True
def run_as_admin(script_path):
"""
以管理员身份运行脚本Windows UAC提升
特点:
- 在GUI退出前弹出UAC提示
- 用户点击"允许"后,脚本以管理员身份运行
- 用户点击"取消"返回False
Args:
script_path: 批处理脚本路径
Returns:
bool: True表示成功启动用户点击允许False表示失败或用户取消
"""
try:
print(f"[RunAsAdmin] 请求管理员权限执行: {script_path}")
# 使用 ShellExecute 以管理员身份运行
# "runas" 参数会触发UAC提示
ret = ctypes.windll.shell32.ShellExecuteW(
None, # hwnd: 父窗口句柄None表示无父窗口
"runas", # lpOperation: 以管理员身份运行
"cmd.exe", # lpFile: 要执行的程序
f'/c "{script_path}"', # lpParameters: 命令行参数
None, # lpDirectory: 工作目录None表示当前目录
0 # nShowCmd: 0=隐藏窗口
)
# 返回值说明:
# >32: 成功
# <=32: 失败(具体错误码)
# - 5: 用户拒绝UAC点击"否"
# - 2: 文件未找到
# - 其他: 其他错误
if ret > 32:
print(f"[RunAsAdmin] ✅ 成功:用户已授权,脚本正在以管理员身份运行")
return True
elif ret == 5:
print(f"[RunAsAdmin] ⚠️ 用户取消了UAC授权返回码: {ret}")
return False
else:
print(f"[RunAsAdmin] ❌ 启动失败(返回码: {ret}")
return False
except Exception as e:
print(f"[RunAsAdmin] ❌ 异常: {e}")
import traceback
traceback.print_exc()
return False
def install_update_and_restart(installer_path):
"""
简化版更新安装文件替换方式不使用NSIS安装
工作流程:
1. 将安装包移动到程序目录
2. 创建替换脚本
3. 以管理员身份启动脚本提前弹出UAC
4. 脚本等待程序退出
5. 脚本执行静默安装
6. 重启程序
Args:
installer_path: 安装包路径
Returns:
bool: 是否成功启动安装
"""
try:
# 获取当前程序信息
if getattr(sys, 'frozen', False):
# 打包后的exe
current_exe = sys.executable
current_dir = os.path.dirname(current_exe)
exe_name = os.path.basename(current_exe)
else:
# 开发环境
current_exe = os.path.abspath("main.py")
current_dir = os.path.dirname(os.path.abspath(__file__))
exe_name = "main.exe"
print(f"[Updater] 当前程序: {current_exe}")
print(f"[Updater] 程序目录: {current_dir}")
print(f"[Updater] 主程序名: {exe_name}")
print(f"[Updater] 安装包路径: {installer_path}")
# 🔥 关键改进:将安装包移动到程序目录(避免跨盘符问题)
installer_name = os.path.basename(installer_path)
target_installer_path = os.path.join(current_dir, installer_name)
# 如果安装包不在程序目录,移动过去
if os.path.abspath(installer_path) != os.path.abspath(target_installer_path):
try:
import shutil
shutil.move(installer_path, target_installer_path)
print(f"[Updater] 安装包已移动到程序目录: {target_installer_path}")
installer_path = target_installer_path
except Exception as e:
print(f"[Updater] 移动安装包失败,使用原路径: {e}")
# 创建更新脚本
update_script_path = create_update_installer_script(
installer_path,
current_dir,
exe_name
)
# 🔥 关键修复以管理员身份启动批处理脚本提前触发UAC
print(f"[Updater] 以管理员身份启动更新脚本: {update_script_path}")
success = run_as_admin(update_script_path)
if success:
print(f"[Updater] ✅ 更新脚本已以管理员身份启动")
return True
else:
print(f"[Updater] ❌ 用户取消了UAC授权更新已取消")
return False
except Exception as e:
print(f"[Updater] ❌ 启动更新失败: {e}")
import traceback
traceback.print_exc()
return False
def create_update_installer_script(installer_path, install_dir, exe_name):
"""
创建更新安装脚本(在程序目录下执行)
工作流程:
1. 等待主程序退出
2. 在程序目录下执行NSIS安装
3. 清理安装包
4. 重启程序
Args:
installer_path: 安装包路径(应该已经在程序目录下)
install_dir: 程序安装目录
exe_name: 主程序文件名
Returns:
str: 脚本路径
"""
# 脚本放在程序目录下,避免权限问题
script_path = os.path.join(install_dir, "update_installer.bat")
# 转换为绝对路径(处理引号问题)
installer_path = os.path.abspath(installer_path)
install_dir = os.path.abspath(install_dir)
script_content = f"""@echo off
REM ShuiDi AI Assistant Auto Update Script
REM Encoding: UTF-8 BOM
title ShuiDi AI Assistant - Auto Update
echo ============================================
echo ShuiDi AI Assistant Auto Update
echo ============================================
echo.
REM Change to program directory
cd /d "{install_dir}"
echo INFO: Program directory: {install_dir}
echo.
REM Wait for main program to exit (max 60 seconds)
echo INFO: Waiting for main program to exit...
set count=0
:wait_main_exit
tasklist /FI "IMAGENAME eq {exe_name}" 2>NUL | find /I "{exe_name}" >NUL
if NOT ERRORLEVEL 1 (
if %count% LSS 60 (
timeout /t 1 /nobreak > nul
set /a count+=1
goto wait_main_exit
) else (
echo WARN: Main program did not exit normally, forcing termination
taskkill /F /IM {exe_name} 2>nul
)
)
echo OK: Main program exited
echo.
REM Critical fix: Wait 2 seconds to ensure all file handles are released
echo INFO: Waiting for file handles to release...
timeout /t 2 /nobreak > nul
echo OK: File handles released
echo.
REM Critical fix: Delete old _internal directory to force clean install
echo INFO: Removing old _internal directory...
if exist "{install_dir}\\_internal" (
rd /s /q "{install_dir}\\_internal" 2>nul
if exist "{install_dir}\\_internal" (
echo WARN: Failed to delete _internal directory
) else (
echo OK: Old _internal directory removed successfully
)
) else (
echo INFO: No old _internal directory found
)
echo.
REM Execute NSIS silent installation
echo INFO: Starting installation...
echo INFO: Installer: "{installer_path}"
echo INFO: Target directory: "{install_dir}"
echo.
REM Execute installation (with admin rights from NSIS)
"{installer_path}" /S /D={install_dir}
REM Wait for installation to complete (extended to 35 seconds for full copy)
echo INFO: Waiting for installation to complete...
timeout /t 35 /nobreak > nul
REM Cleanup installer
echo INFO: Cleaning up installer...
del /f /q "{installer_path}" 2>nul
REM Start new version (with --after-update flag to show window on top)
echo INFO: Starting new version...
if exist "{install_dir}\\{exe_name}" (
start "" "{install_dir}\\{exe_name}" --after-update
echo OK: Program started successfully
) else (
echo ERROR: Program file not found: {exe_name}
echo INFO: Please start program manually
pause
)
REM Delete this script after delay
timeout /t 2 /nobreak > nul
del /f /q "%~f0" 2>nul
exit
"""
try:
with open(script_path, 'w', encoding='utf-8-sig') as f:
f.write(script_content)
print(f"[Updater] 更新脚本已创建: {script_path}")
except Exception as e:
print(f"[Updater] 创建更新脚本失败: {e}")
return script_path
def create_restart_launcher(install_dir, exe_name):
"""
创建重启启动器脚本
该脚本会:
1. 等待当前GUI进程退出
2. 等待NSIS安装完成15秒
3. 启动新版本GUI
4. 自我删除
Args:
install_dir: 安装目录
exe_name: 主程序文件名
Returns:
str: 脚本路径
"""
script_path = os.path.join(tempfile.gettempdir(), "shuidrop_restart_launcher.bat")
# 构建批处理脚本
script_content = f"""@echo off
chcp 65001 > nul
title ShuiDi AI Assistant - Update Helper
echo [%time%] Updating ShuiDi AI Assistant...
echo.
REM Wait for main program to exit (max 10 seconds)
echo [%time%] Waiting for main program to exit...
set count=0
:wait_main_exit
tasklist /FI "IMAGENAME eq {exe_name}" 2>NUL | find /I "{exe_name}" >NUL
if NOT ERRORLEVEL 1 (
if %count% LSS 10 (
timeout /t 1 /nobreak > nul
set /a count+=1
goto wait_main_exit
)
)
echo [%time%] Main program exited
REM Wait for installation to complete (15 seconds)
echo [%time%] Waiting for installation to complete...
timeout /t 15 /nobreak > nul
REM Clean up old installer files
echo [%time%] Cleaning up temporary files...
del /f /q "%TEMP%\\ShuiDi_AI_Assistant_Setup_*.exe" 2>nul
REM Start new version
echo [%time%] Starting new version...
cd /d "{install_dir}"
if exist "{exe_name}" (
start "" "{install_dir}\\{exe_name}"
echo [%time%] Program started successfully
) else (
echo [%time%] ERROR: Program file not found: {exe_name}
pause
)
REM Delete this script after delay
timeout /t 2 /nobreak > nul
del /f /q "%~f0" 2>nul
exit
"""
# Write script file with UTF-8 BOM encoding
try:
with open(script_path, 'w', encoding='utf-8-sig') as f:
f.write(script_content)
print(f"[Updater] Restart launcher created: {script_path}")
except Exception as e:
print(f"[Updater] Failed to create restart launcher: {e}")
return script_path
# 兼容性函数(保留旧接口)
def install_update_silently(installer_path):
"""
启动静默安装(不自动重启)
兼容旧接口,建议使用 install_update_and_restart
"""
return install_update_and_restart(installer_path)
if __name__ == "__main__":
# 测试代码
print("自动更新模块测试")
print("=" * 50)
# 测试重启启动器创建
script_path = create_restart_launcher("C:\\Program Files\\ShuiDi AI Assistant", "main.exe")
print(f"测试脚本路径: {script_path}")
if os.path.exists(script_path):
print("✅ 重启启动器创建成功")
with open(script_path, 'r', encoding='utf-8-sig') as f:
print("\n脚本内容预览:")
print("-" * 50)
print(f.read())
else:
print("❌ 重启启动器创建失败")

View File

@@ -1,106 +1,151 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
生产环境打包脚本 - 自动禁用日志功能 Production build script - Automatically disable logging functionality
""" """
import os import os
import shutil import shutil
import subprocess import subprocess
import sys
# Ensure script runs in correct directory
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
os.chdir(SCRIPT_DIR)
print(f"Working directory: {SCRIPT_DIR}")
def disable_logging(): def disable_logging():
"""自动禁用所有日志功能""" """Disable all logging functionality"""
print("🔧 正在禁用日志功能...") print("Disabling logging functionality...")
# 备份原文件 # Backup original files
files_to_backup = ['main.py', 'exe_file_logger.py'] files_to_backup = ['main.py', 'exe_file_logger.py']
for file_name in files_to_backup: for file_name in files_to_backup:
if os.path.exists(file_name): if os.path.exists(file_name):
shutil.copy(file_name, f'{file_name}.backup') shutil.copy(file_name, f'{file_name}.backup')
print(f"✅ 已备份 {file_name}") print(f"Backed up {file_name}")
# 1. 修改 main.py - 注释掉日志初始化 # 1. Modify main.py - comment out logging initialization
if os.path.exists('main.py'): if os.path.exists('main.py'):
with open('main.py', 'r', encoding='utf-8') as f: with open('main.py', 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
# 注释掉日志相关的导入和初始化 # Comment out logging related imports and initialization
content = content.replace( content = content.replace(
'from exe_file_logger import setup_file_logging, log_to_file', 'from exe_file_logger import setup_file_logging, log_to_file',
'# from exe_file_logger import setup_file_logging, log_to_file # 生产环境禁用' '# from exe_file_logger import setup_file_logging, log_to_file # Production disabled'
).replace( ).replace(
'setup_file_logging()', 'setup_file_logging()',
'# setup_file_logging() # 生产环境禁用' '# setup_file_logging() # Production disabled'
).replace( ).replace(
'print("文件日志系统已在main.py中初始化")', 'print("文件日志系统已在main.py中初始化")',
'# print("文件日志系统已在main.py中初始化") # 生产环境禁用' '# print("文件日志系统已在main.py中初始化") # Production disabled'
) )
with open('main.py', 'w', encoding='utf-8') as f: with open('main.py', 'w', encoding='utf-8') as f:
f.write(content) f.write(content)
print("✅ 已禁用 main.py 中的日志初始化") print("Disabled logging in main.py")
# 2. 修改 exe_file_logger.py - 让所有函数变成空操作 # 2. Modify exe_file_logger.py - make all functions no-op
if os.path.exists('exe_file_logger.py'): if os.path.exists('exe_file_logger.py'):
with open('exe_file_logger.py', 'r', encoding='utf-8') as f: with open('exe_file_logger.py', 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
# setup_file_logging 函数替换为空函数 # Replace setup_file_logging function with empty function
content = content.replace( content = content.replace(
'def setup_file_logging():', 'def setup_file_logging():',
'def setup_file_logging():\n """生产环境 - 禁用文件日志"""\n return None\n\ndef setup_file_logging_original():' 'def setup_file_logging():\n """Production - Logging disabled"""\n return None\n\ndef setup_file_logging_original():'
).replace( ).replace(
'def log_to_file(message):', 'def log_to_file(message):',
'def log_to_file(message):\n """生产环境 - 禁用文件日志"""\n pass\n\ndef log_to_file_original(message):' 'def log_to_file(message):\n """Production - Logging disabled"""\n pass\n\ndef log_to_file_original(message):'
) )
with open('exe_file_logger.py', 'w', encoding='utf-8') as f: with open('exe_file_logger.py', 'w', encoding='utf-8') as f:
f.write(content) f.write(content)
print("✅ 已禁用 exe_file_logger.py 中的日志功能") print("Disabled logging in exe_file_logger.py")
print("✅ 所有日志功能已禁用") print("All logging functionality disabled")
def restore_logging(): def restore_logging():
"""恢复日志功能""" """Restore logging functionality"""
print("Restoring development configuration...")
files_to_restore = ['main.py', 'exe_file_logger.py'] files_to_restore = ['main.py', 'exe_file_logger.py']
restored_count = 0
for file_name in files_to_restore: for file_name in files_to_restore:
backup_file = f'{file_name}.backup' backup_file = f'{file_name}.backup'
try:
if os.path.exists(backup_file): if os.path.exists(backup_file):
# Ensure target file is writable
if os.path.exists(file_name):
os.chmod(file_name, 0o666)
shutil.copy(backup_file, file_name) shutil.copy(backup_file, file_name)
os.remove(backup_file) os.remove(backup_file)
print(f"✅ 已恢复 {file_name}") print(f"[OK] Restored {file_name}")
print("✅ 所有文件已恢复到开发环境配置") restored_count += 1
else:
print(f"[WARN] Backup file not found: {backup_file}")
except Exception as e:
print(f"[ERROR] Failed to restore {file_name}: {e}")
if restored_count == len(files_to_restore):
print("[OK] All files restored to development configuration")
else:
print(f"[WARN] Restored {restored_count}/{len(files_to_restore)} files")
def main(): def main():
"""主函数""" """Main function"""
print("🔥 生产环境打包工具(无日志版本)") print("Production Build Tool (No Logging Version)")
print("=" * 60) print("=" * 60)
try: try:
# 1. 禁用日志功能 # 1. Disable logging functionality
disable_logging() disable_logging()
# 2. 执行打包 # 2. Execute build
print("\n🚀 开始打包...") print("\nStarting build...")
result = subprocess.run(['python', 'quick_build.py'], capture_output=False) result = subprocess.run(['python', 'quick_build.py'], capture_output=False)
if result.returncode == 0: if result.returncode == 0:
print("\n🎉 生产环境打包成功!") # Verify output (quick_build.py outputs to MultiPlatformGUI)
print("📁 输出目录: dist/MultiPlatformGUI/") if os.path.exists('dist/MultiPlatformGUI'):
print("🔇 已禁用所有日志功能,客户端不会产生日志文件") print("\nProduction build completed successfully!")
print("Output directory: dist/MultiPlatformGUI/")
print("All logging disabled - client will not generate log files")
# Verify key files
exe_path = 'dist/MultiPlatformGUI/main.exe'
if os.path.exists(exe_path):
size = os.path.getsize(exe_path) / 1024 / 1024 # MB
print(f"Main executable: main.exe ({size:.1f} MB)")
else: else:
print("\n❌ 打包失败") print("WARNING: Main executable not found")
# 🔥 关键修复添加PyMiniRacer DLL修复步骤
print("\nPyMiniRacer DLL fix phase started...")
try:
import fix_pyminiracer_dll
if fix_pyminiracer_dll.auto_fix_after_build():
print("PyMiniRacer DLL fix completed")
else:
print("WARNING: PyMiniRacer DLL fix failed")
except Exception as e:
print(f"WARNING: PyMiniRacer DLL fix error: {e}")
else:
print("\nERROR: Output directory verification failed - dist/MultiPlatformGUI not found")
else:
print("\nERROR: Build failed")
except Exception as e: except Exception as e:
print(f"❌ 打包过程出错: {e}") print(f"ERROR: Build process error: {e}")
finally: finally:
# 3. 恢复日志功能(用于开发) # 3. Restore logging functionality (for development)
print("\n🔄 恢复开发环境配置...") print()
restore_logging() restore_logging()
print("\n" + "=" * 60) print("\n" + "=" * 60)
input("按Enter键退出...")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -9,19 +9,20 @@ import json # 用于将令牌保存为 JSON 格式
# 后端服务器配置 # 后端服务器配置
# BACKEND_HOST = "192.168.5.233" # BACKEND_HOST = "192.168.5.233"
BACKEND_HOST = "192.168.5.12" # BACKEND_HOST = "192.168.5.106"
# BACKEND_HOST = "shuidrop.com" # BACKEND_HOST = "192.168.5.12"
BACKEND_HOST = "shuidrop.com"
# BACKEND_HOST = "test.shuidrop.com" # BACKEND_HOST = "test.shuidrop.com"
BACKEND_PORT = "8000" # BACKEND_PORT = "8000"
# BACKEND_PORT = "" BACKEND_PORT = ""
BACKEND_WS_URL = f"ws://{BACKEND_HOST}:{BACKEND_PORT}" # BACKEND_WS_URL = f"ws://{BACKEND_HOST}:{BACKEND_PORT}"
# BACKEND_WS_URL = f"wss://{BACKEND_HOST}" BACKEND_WS_URL = f"wss://{BACKEND_HOST}"
# WebSocket配置 # WebSocket配置
WS_CONNECT_TIMEOUT = 16.0 WS_CONNECT_TIMEOUT = 16.0
WS_MESSAGE_TIMEOUT = 30.0 WS_MESSAGE_TIMEOUT = 30.0
WS_PING_INTERVAL = 10 # 10秒ping间隔提高检测频率 WS_PING_INTERVAL = 15 # 10秒ping间隔提高检测频率
WS_PING_TIMEOUT = 5 # 5秒ping超时更快检测断线 WS_PING_TIMEOUT = 10 # 5秒ping超时更快检测断线
WS_ENABLE_PING = True # 是否启用WebSocket原生ping心跳 WS_ENABLE_PING = True # 是否启用WebSocket原生ping心跳
WS_ENABLE_APP_PING = False # 禁用应用层ping心跳避免重复 WS_ENABLE_APP_PING = False # 禁用应用层ping心跳避免重复
@@ -37,9 +38,48 @@ FUTURE_TIMEOUT = 300 # 5分钟
# 终端日志配置(简化) # 终端日志配置(简化)
LOG_LEVEL = "INFO" LOG_LEVEL = "INFO"
VERSION = "1.0" VERSION = "1.0"
# GUI配置 # GUI配置
WINDOW_TITLE = "AI回复连接入口-V1.0" WINDOW_TITLE = "AI回复连接入口-V1.0"
# 应用版本号(用于版本检查)
APP_VERSION = "1.5.71"
# 🔥 多实例运行模式开关
# - True: 测试模式多实例不保存token避免冲突
# - False: 生产模式单实例保存token自动加载
#
# 使用方法:
# 1. 修改此值MULTI_INSTANCE_MODE = False # 改为生产模式
# 2. 或设置环境变量SHUIDROP_MULTI_INSTANCE=0 # 临时切换到生产模式
MULTI_INSTANCE_MODE = True # 默认:测试模式
def is_multi_instance_mode() -> bool:
"""
检查是否为多实例模式(支持环境变量覆盖)
优先级:
1. 环境变量 SHUIDROP_MULTI_INSTANCE0=生产1=测试)
2. 配置文件 MULTI_INSTANCE_MODE
Returns:
bool: True=多实例模式False=单实例模式
"""
# 检查环境变量
env_value = os.getenv('SHUIDROP_MULTI_INSTANCE')
if env_value is not None:
# 0, false, False, no, No → 生产模式
if env_value.lower() in ('0', 'false', 'no'):
return False
# 1, true, True, yes, Yes → 测试模式
if env_value.lower() in ('1', 'true', 'yes'):
return True
# 使用配置文件值 (如果不做设置我们可以直接用编码变量进行控制是否可以允许多实例的方式运行)
return MULTI_INSTANCE_MODE
# 平台特定配置 # 平台特定配置
PLATFORMS = { PLATFORMS = {
"JD": { "JD": {

344
custom_update_dialog.py Normal file
View File

@@ -0,0 +1,344 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
自定义版本更新对话框
美观的布局和用户体验
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTextEdit, QFrame)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QCursor
class UpdateNotificationDialog(QDialog):
"""
版本更新提示对话框
布局:
- 标题区
- 更新内容区
- 下载地址区(可复制)
- 按钮区(两行,美观布局)
"""
# 用户选择(返回值)
CHOICE_UPDATE_NOW = 1 # 立即更新
CHOICE_UPDATE_LATER = 2 # 稍后更新
CHOICE_IGNORE = 3 # 忽略此版本
def __init__(self, version, download_url, update_content="", parent=None):
super().__init__(parent)
self.version = version
self.download_url = download_url
self.update_content = update_content
self.user_choice = None
self.init_ui()
def init_ui(self):
"""初始化UI"""
self.setWindowTitle("版本更新")
self.setFixedWidth(550)
self.setMinimumHeight(400)
# 禁用关闭按钮(只能通过按钮关闭)
self.setWindowFlags(Qt.Dialog | Qt.WindowTitleHint | Qt.CustomizeWindowHint)
# 主布局
layout = QVBoxLayout()
layout.setSpacing(15)
layout.setContentsMargins(25, 20, 25, 20)
# ============ 标题区 ============
title_label = QLabel(f"🔔 发现新版本 v{self.version}")
title_label.setFont(QFont('Microsoft YaHei', 14, QFont.Bold))
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("color: #2c3e50; margin-bottom: 5px;")
layout.addWidget(title_label)
# 分隔线
line1 = QFrame()
line1.setFrameShape(QFrame.HLine)
line1.setStyleSheet("background: #ddd; max-height: 1px;")
layout.addWidget(line1)
# ============ 更新内容区(可选)============
# 🔥 只有在有更新内容时才显示
if self.update_content and self.update_content.strip():
content_label = QLabel("📝 更新内容:")
content_label.setFont(QFont('Microsoft YaHei', 10, QFont.Bold))
content_label.setStyleSheet("color: #34495e;")
layout.addWidget(content_label)
# 更新内容文本
content_text = QTextEdit()
content_text.setReadOnly(True)
content_text.setMaximumHeight(100)
content_text.setStyleSheet("""
QTextEdit {
border: 1px solid #ddd;
border-radius: 6px;
padding: 8px;
background: #f8f9fa;
font-size: 11px;
color: #555;
}
""")
content_text.setText(self.update_content)
layout.addWidget(content_text)
# ============ 下载地址区 ============
url_label = QLabel("📥 下载地址:")
url_label.setFont(QFont('Microsoft YaHei', 10, QFont.Bold))
url_label.setStyleSheet("color: #34495e; margin-top: 5px;")
layout.addWidget(url_label)
# 下载地址文本框(可选择复制)
self.url_text = QTextEdit()
self.url_text.setReadOnly(True)
self.url_text.setMaximumHeight(60)
self.url_text.setText(self.download_url)
self.url_text.setStyleSheet("""
QTextEdit {
border: 2px solid #4285f4;
border-radius: 6px;
padding: 8px;
background: #e3f2fd;
font-size: 10px;
color: #1565c0;
font-family: 'Consolas', 'Courier New', monospace;
}
""")
# 全选文本,方便复制
self.url_text.selectAll()
layout.addWidget(self.url_text)
# 复制提示
copy_hint = QLabel("💡 提示:上方地址已自动选中,按 Ctrl+C 即可复制")
copy_hint.setFont(QFont('Microsoft YaHei', 8))
copy_hint.setStyleSheet("color: #7f8c8d;")
layout.addWidget(copy_hint)
# 分隔线
line2 = QFrame()
line2.setFrameShape(QFrame.HLine)
line2.setStyleSheet("background: #ddd; max-height: 1px; margin-top: 10px;")
layout.addWidget(line2)
# ============ 操作说明区 ============
hint_label = QLabel(
"• 在线自动更新:自动下载并安装新版本\n"
"• 稍后更新:保留提示在状态栏,可随时点击更新\n"
"• 忽略此版本:本次运行不再提示此版本"
)
hint_label.setFont(QFont('Microsoft YaHei', 9))
hint_label.setStyleSheet("color: #666; margin: 5px 0;")
layout.addWidget(hint_label)
# ============ 按钮区(两行布局)============
# 第一行:主要操作
button_row1 = QHBoxLayout()
button_row1.setSpacing(10)
self.update_now_btn = QPushButton("🚀 在线自动更新")
self.update_now_btn.setFont(QFont('Microsoft YaHei', 11, QFont.Bold))
self.update_now_btn.setMinimumHeight(42)
self.update_now_btn.setCursor(QCursor(Qt.PointingHandCursor))
self.update_now_btn.clicked.connect(self.on_update_now)
self.update_later_btn = QPushButton("⏰ 稍后更新")
self.update_later_btn.setFont(QFont('Microsoft YaHei', 11))
self.update_later_btn.setMinimumHeight(42)
self.update_later_btn.setCursor(QCursor(Qt.PointingHandCursor))
self.update_later_btn.clicked.connect(self.on_update_later)
button_row1.addWidget(self.update_now_btn)
button_row1.addWidget(self.update_later_btn)
layout.addLayout(button_row1)
# 第二行:次要操作
button_row2 = QHBoxLayout()
button_row2.setSpacing(10)
self.ignore_btn = QPushButton("🚫 忽略此版本")
self.ignore_btn.setFont(QFont('Microsoft YaHei', 10))
self.ignore_btn.setMinimumHeight(38)
self.ignore_btn.setCursor(QCursor(Qt.PointingHandCursor))
self.ignore_btn.clicked.connect(self.on_ignore)
self.copy_btn = QPushButton("📋 复制下载地址")
self.copy_btn.setFont(QFont('Microsoft YaHei', 10))
self.copy_btn.setMinimumHeight(38)
self.copy_btn.setCursor(QCursor(Qt.PointingHandCursor))
self.copy_btn.clicked.connect(self.on_copy_url)
button_row2.addWidget(self.ignore_btn)
button_row2.addWidget(self.copy_btn)
layout.addLayout(button_row2)
self.setLayout(layout)
# 应用样式
self.apply_styles()
def apply_styles(self):
"""应用现代化样式"""
self.setStyleSheet("""
QDialog {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #f8fafb,
stop:1 #e8ecef
);
}
/* 主要操作按钮(立即更新、稍后更新)*/
QPushButton#updateNowBtn {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #4285f4,
stop:1 #1565c0
);
border: none;
color: white;
border-radius: 8px;
font-weight: bold;
}
QPushButton#updateNowBtn:hover {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #5294f5,
stop:1 #1e74c1
);
}
QPushButton#updateLaterBtn {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #ff9800,
stop:1 #f57c00
);
border: none;
color: white;
border-radius: 8px;
}
QPushButton#updateLaterBtn:hover {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #ffa726,
stop:1 #fb8c00
);
}
/* 次要操作按钮(忽略、复制)*/
QPushButton {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #f5f5f5,
stop:1 #e0e0e0
);
border: 2px solid #ccc;
border-radius: 8px;
color: #555;
}
QPushButton:hover {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #e8e8e8,
stop:1 #d0d0d0
);
border: 2px solid #999;
}
""")
# 设置按钮对象名以应用特定样式
self.update_now_btn.setObjectName("updateNowBtn")
self.update_later_btn.setObjectName("updateLaterBtn")
def on_update_now(self):
"""立即更新"""
self.user_choice = self.CHOICE_UPDATE_NOW
self.accept()
def on_update_later(self):
"""稍后更新"""
self.user_choice = self.CHOICE_UPDATE_LATER
self.accept()
def on_ignore(self):
"""忽略此版本"""
self.user_choice = self.CHOICE_IGNORE
self.accept()
def on_copy_url(self):
"""复制下载地址"""
from PyQt5.QtWidgets import QApplication, QMessageBox
clipboard = QApplication.clipboard()
clipboard.setText(self.download_url)
# 显示复制成功提示(不关闭主对话框)
QMessageBox.information(
self,
"复制成功",
f"✅ 下载地址已复制到剪贴板!\n\n"
f"您可以直接粘贴(Ctrl+V)到浏览器下载。",
QMessageBox.Ok
)
def get_user_choice(self):
"""获取用户选择"""
return self.user_choice
if __name__ == "__main__":
# 测试对话框
import sys
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
print("=" * 60)
print("测试更新对话框")
print("=" * 60)
print("\n场景1有更新内容")
dialog = UpdateNotificationDialog(
version="1.5.64",
download_url="https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.64.exe",
update_content="• 修复了DY图片/视频上传问题\n• 优化了自动更新流程\n• 添加了圆形进度条"
)
dialog.exec_()
choice = dialog.get_user_choice()
if choice == UpdateNotificationDialog.CHOICE_UPDATE_NOW:
print("\n用户选择:在线自动更新")
elif choice == UpdateNotificationDialog.CHOICE_UPDATE_LATER:
print("\n用户选择:稍后更新")
elif choice == UpdateNotificationDialog.CHOICE_IGNORE:
print("\n用户选择:忽略此版本")
else:
print("\n用户关闭了对话框")
# 测试场景2无更新内容
print("\n" + "=" * 60)
print("场景2无更新内容应该隐藏更新内容区域")
print("=" * 60)
dialog2 = UpdateNotificationDialog(
version="1.5.65",
download_url="https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.65.exe",
update_content="" # 空内容
)
dialog2.exec_()
print("\n测试完成!")
sys.exit(0)

136
fix_pyminiracer_dll.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PyMiniRacer DLL Auto-Fix Script
Automatically copy PyMiniRacer native DLL files after build
"""
import os
import shutil
import sys
from pathlib import Path
def find_mini_racer_dll():
"""Find PyMiniRacer DLL files"""
print("Searching for PyMiniRacer DLL files...")
try:
import py_mini_racer
pkg_path = Path(py_mini_racer.__file__).parent
# Find DLL files
dll_files = list(pkg_path.glob("*.dll")) + list(pkg_path.glob("*.pyd"))
if dll_files:
print(f"OK: Found PyMiniRacer native library files:")
for dll in dll_files:
print(f" - {dll.name} ({dll.stat().st_size / 1024:.1f} KB)")
return dll_files
else:
print("WARN: No DLL files found")
# Try subdirectories
for subdir in pkg_path.iterdir():
if subdir.is_dir():
dll_files = list(subdir.glob("*.dll")) + list(subdir.glob("*.pyd"))
if dll_files:
print(f"OK: Found in subdirectory {subdir.name}:")
for dll in dll_files:
print(f" - {dll.name}")
return dll_files
return []
except ImportError:
print("ERROR: PyMiniRacer is not installed")
return []
except Exception as e:
print(f"ERROR: Failed to find DLL: {e}")
return []
def copy_dll_to_dist():
"""Copy DLL files to dist directory"""
print("\n" + "=" * 70)
print("PyMiniRacer DLL Auto-Fix Tool")
print("=" * 70)
# Find dist directory
dist_dir = Path("dist/MultiPlatformGUI/_internal")
if not dist_dir.exists():
print(f"ERROR: dist directory not found: {dist_dir}")
print(" Please run: python quick_build.py or python build_production.py")
return False
print(f"OK: Found dist directory: {dist_dir}")
# Find source DLL
dll_files = find_mini_racer_dll()
if not dll_files:
print("\nERROR: Cannot find PyMiniRacer DLL files")
print(" Please ensure it is installed: pip install py-mini-racer")
return False
# Copy DLL files
print(f"\nCopying DLL files to dist directory...")
success_count = 0
for dll_file in dll_files:
try:
# Target path (put in _internal root directory)
target = dist_dir / dll_file.name
# Copy file
shutil.copy2(dll_file, target)
print(f"OK: Copied {dll_file.name} -> {target}")
success_count += 1
# Also copy to py_mini_racer subdirectory (if exists)
py_mini_racer_dir = dist_dir / "py_mini_racer"
if py_mini_racer_dir.exists():
target2 = py_mini_racer_dir / dll_file.name
shutil.copy2(dll_file, target2)
print(f"OK: Copied {dll_file.name} -> {target2}")
except Exception as e:
print(f"ERROR: Failed to copy {dll_file.name}: {e}")
if success_count > 0:
print(f"\nSUCCESS: Copied {success_count} file(s)")
print("\nVerification:")
print(f" Checking if file exists: {dist_dir / 'mini_racer.dll'}")
if (dist_dir / 'mini_racer.dll').exists():
size = (dist_dir / 'mini_racer.dll').stat().st_size / (1024 * 1024)
print(f" OK: mini_racer.dll exists ({size:.1f} MB)")
print("\nYou can now run:")
print(" cd dist\\MultiPlatformGUI")
print(" .\\main.exe")
print("\n PDD platform should work properly now!")
return True
else:
print("\nERROR: No files were copied successfully")
return False
def auto_fix_after_build():
"""Auto-fix after build (called by build script)"""
return copy_dll_to_dist()
def main():
"""Main function"""
success = copy_dll_to_dist()
if not success:
print("\nAlternative solutions:")
print(" 1. Manually install Node.js (to enable execjs)")
print(" 2. Check if PyMiniRacer is installed correctly")
print(" 3. Try a different version of py-mini-racer")
sys.exit(1)
print("\nDLL fix completed successfully!")
if __name__ == "__main__":
main()

106
installer/README.md Normal file
View File

@@ -0,0 +1,106 @@
# GUI 安装包构建工具
这个目录包含了使用 NSIS 构建 Windows 安装包的完整解决方案。
## 目录结构
```
installer/
├── build_installer.py # 主要的构建脚本
├── installer.nsi # 自动生成的 NSIS 脚本
├── assets/ # 安装包资源文件
│ ├── icon.png # 应用程序图标
│ └── license.txt # 软件许可协议
└── output/ # 生成的安装包输出目录
```
## 使用方法
### 方法一:使用批处理脚本(推荐)
```bash
# 在 GUI_main 目录下运行
build_installer.bat
```
### 方法二直接运行Python脚本
```bash
# 在 GUI_main/installer 目录下运行
python build_installer.py
```
## 前提条件
1. **NSIS 安装**
- 下载地址https://nsis.sourceforge.io/Download
- 安装后确保 `makensis` 命令在 PATH 中
2. **应用程序构建**
- 必须先运行 `build_production.py` 构建应用程序
- 确保 `dist/MultiPlatformGUI/` 目录存在
## 构建流程
1. **环境检查**:验证 NSIS 安装和应用程序构建
2. **准备资源**:复制图标文件和创建许可证
3. **生成脚本**:自动生成 NSIS 安装脚本
4. **编译安装包**:使用 makensis 编译最终的安装程序
## 输出结果
安装包将生成在 `installer/output/` 目录下,文件名格式:
```
ShuiDi_AI_Assistant_Setup_1.0.0_YYYYMMDD_HHMMSS.exe
```
## 安装包功能
- ✅ 完整的中文界面
- ✅ 自动创建开始菜单快捷方式
- ✅ 自动创建桌面快捷方式
- ✅ 注册表集成
- ✅ 完整的卸载功能
- ✅ 版本信息显示
## 自定义配置
可以修改 `build_installer.py` 中的以下配置:
```python
# 应用程序信息
self.app_name = "水滴AI客服智能助手"
self.app_name_en = "ShuiDi AI Assistant"
self.app_version = "1.0.0"
self.app_publisher = "水滴科技"
self.app_url = "https://www.shuidi.tech"
```
## 故障排除
### 常见问题
1. **找不到 makensis 命令**
- 确保 NSIS 已正确安装
- 将 NSIS 安装目录添加到 PATH 环境变量
2. **找不到 dist 目录**
- 先运行 `build_production.py` 构建应用程序
3. **图标文件问题**
- 确保 `static/` 目录下有图标文件
- 建议准备 ICO 格式的图标文件
### 日志和调试
构建过程中的详细信息会在控制台显示,包括:
- NSIS 版本信息
- 文件复制状态
- 编译过程输出
- 最终安装包信息
## 进一步优化
可以考虑添加以下功能:
- 数字签名支持
- 多语言支持
- 自定义安装选项
- 更新检查机制

View File

@@ -0,0 +1,374 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NSIS 安装包构建脚本
用于创建 GUI 应用程序的Windows安装包
"""
import os
import sys
import shutil
import subprocess
import datetime
from pathlib import Path
class NSISInstaller:
def __init__(self):
self.script_dir = Path(__file__).parent
self.project_root = self.script_dir.parent
self.dist_dir = self.project_root / "dist" / "MultiPlatformGUI"
self.output_dir = self.script_dir / "output"
self.assets_dir = self.script_dir / "assets"
self.nsis_script = self.script_dir / "installer.nsi"
# 应用程序信息使用英文避免CI/CD环境乱码
self.app_name = "ShuiDi AI Assistant"
self.app_name_en = "ShuiDi AI Assistant"
# 从 config.py 读取版本号(自动同步)
self.app_version = self._get_version_from_config()
self.app_publisher = "Shuidrop Technology"
self.app_url = "https://shuidrop.com/"
self.exe_name = "main.exe"
def _get_version_from_config(self):
"""从 config.py 读取版本号"""
try:
config_file = self.project_root / "config.py"
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
content = f.read()
import re
match = re.search(r'APP_VERSION\s*=\s*["\']([^"\']+)["\']', content)
if match:
version = match.group(1)
print(f"Read version from config.py: v{version}")
return version
except Exception as e:
print(f"WARNING: Failed to read version: {e}, using default version")
return "1.0.0"
def check_prerequisites(self):
"""检查构建前提条件"""
print("Checking build prerequisites...")
# 检查NSIS安装
try:
result = subprocess.run(['makensis', '/VERSION'],
capture_output=True, text=True, check=True)
nsis_version = result.stdout.strip()
print(f"NSIS version: {nsis_version}")
except (subprocess.CalledProcessError, FileNotFoundError):
print("ERROR: NSIS or makensis command not found")
print(" Please download and install NSIS from https://nsis.sourceforge.io/Download")
print(" Make sure to add NSIS to system PATH")
return False
# 检查dist目录
if not self.dist_dir.exists():
print(f"ERROR: Build output directory not found: {self.dist_dir}")
print(" Please run build_production.py first")
return False
# 检查主程序文件
exe_path = self.dist_dir / self.exe_name
if not exe_path.exists():
print(f"ERROR: Main executable not found: {exe_path}")
return False
print(f"Found main executable: {exe_path}")
return True
def prepare_assets(self):
"""准备安装包资源文件"""
print("Preparing asset files...")
# 创建assets目录
self.assets_dir.mkdir(exist_ok=True)
# 准备主程序图标
icon_sources = [
self.project_root / "static" / "ai_assistant_icon_64.png",
self.project_root / "static" / "ai_assistant_icon_32.png",
self.project_root / "static" / "ai_assistant_icon_16.png"
]
# 准备卸载程序专用图标
uninstall_icon_source = self.project_root / "static" / "ai_assistant_icon_uninstall.png"
# 转换主程序图标
icon_found = False
for icon_source in icon_sources:
if icon_source.exists():
try:
from PIL import Image
img = Image.open(icon_source)
ico_path = self.assets_dir / "icon.ico"
img.save(ico_path, format='ICO', sizes=[(16,16), (32,32), (48,48), (64,64)])
print(f"Converted main icon: {icon_source.name} -> icon.ico")
icon_found = True
break
except ImportError:
print("WARNING: PIL library not installed, skipping icon conversion")
break
except Exception as e:
print(f"WARNING: Main icon conversion failed: {e}")
continue
# 转换卸载程序图标
uninstall_icon_found = False
if uninstall_icon_source.exists():
try:
from PIL import Image
img = Image.open(uninstall_icon_source)
uninstall_ico_path = self.assets_dir / "uninstall_icon.ico"
img.save(uninstall_ico_path, format='ICO', sizes=[(16,16), (32,32), (48,48), (64,64)])
print(f"Converted uninstall icon: {uninstall_icon_source.name} -> uninstall_icon.ico")
uninstall_icon_found = True
except ImportError:
print("WARNING: PIL library not installed, skipping uninstall icon conversion")
except Exception as e:
print(f"WARNING: Uninstall icon conversion failed: {e}")
else:
print("WARNING: Uninstall icon not found, will use main icon")
if not icon_found:
print("WARNING: Main icon not found, will use default icon")
return {
'main_icon': icon_found,
'uninstall_icon': uninstall_icon_found
}
def generate_nsis_script(self, icon_info):
"""生成NSIS安装脚本"""
print("Generating NSIS installer script...")
# 标准化安装包命名(不带时间戳和空格,便于固定下载地址)
# 将应用名称中的空格替换为下划线
app_name_safe = self.app_name_en.replace(' ', '_')
installer_name = f"{app_name_safe}_Setup_v{self.app_version}.exe"
# 示例: ShuiDi_AI_Assistant_Setup_v1.4.12.exe
print(f"Installer name: {installer_name}")
nsis_content = f'''# ShuiDi AI Assistant NSIS Installer Script
# 自动生成于 {datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
Unicode True
# 🔥 请求管理员权限(解决静默安装时的"Access denied"问题)
RequestExecutionLevel admin
# 定义应用程序信息
!define APP_NAME "{self.app_name}"
!define APP_NAME_EN "{self.app_name_en}"
!define APP_VERSION "{self.app_version}"
!define APP_PUBLISHER "{self.app_publisher}"
!define APP_URL "{self.app_url}"
!define APP_EXE "{self.exe_name}"
# 安装程序信息
Name "${{APP_NAME}} v${{APP_VERSION}}"
OutFile "output\\{installer_name}"
InstallDir "$PROGRAMFILES\\${{APP_NAME_EN}}"
InstallDirRegKey HKLM "Software\\${{APP_PUBLISHER}}\\${{APP_NAME_EN}}" "InstallPath"
# 界面设置
!include "MUI2.nsh"
!define MUI_ABORTWARNING
{f'!define MUI_ICON "assets\\\\icon.ico"' if icon_info.get('main_icon', False) else ''}
{f'!define MUI_UNICON "assets\\\\uninstall_icon.ico"' if icon_info.get('uninstall_icon', False) else f'!define MUI_UNICON "assets\\\\icon.ico"' if icon_info.get('main_icon', False) else ''}
# 页面配置
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_LICENSE "assets\\license.txt"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_WELCOME
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_UNPAGE_FINISH
# 语言
!insertmacro MUI_LANGUAGE "SimpChinese"
# 版本信息
VIProductVersion "{self.app_version}.0"
VIAddVersionKey /LANG=2052 "ProductName" "${{APP_NAME}}"
VIAddVersionKey /LANG=2052 "Comments" "ShuiDi AI Assistant"
VIAddVersionKey /LANG=2052 "CompanyName" "${{APP_PUBLISHER}}"
VIAddVersionKey /LANG=2052 "FileDescription" "${{APP_NAME}} 安装程序"
VIAddVersionKey /LANG=2052 "FileVersion" "${{APP_VERSION}}"
VIAddVersionKey /LANG=2052 "ProductVersion" "${{APP_VERSION}}"
VIAddVersionKey /LANG=2052 "OriginalFilename" "{installer_name}"
VIAddVersionKey /LANG=2052 "LegalCopyright" "© 2024 ${{APP_PUBLISHER}}"
# 安装段
Section "MainSection" SEC01
SetOutPath "$INSTDIR"
SetOverwrite on
# 复制所有文件
File /r "..\\dist\\MultiPlatformGUI\\*.*"
# 复制图标文件到安装目录
{f'File "assets\\\\icon.ico"' if icon_info.get('main_icon', False) else ''}
{f'File "assets\\\\uninstall_icon.ico"' if icon_info.get('uninstall_icon', False) else ''}
# 创建开始菜单快捷方式
CreateDirectory "$SMPROGRAMS\\${{APP_NAME}}"
{f'CreateShortCut "$SMPROGRAMS\\\\${{APP_NAME}}\\\\${{APP_NAME}}.lnk" "$INSTDIR\\\\${{APP_EXE}}" "" "$INSTDIR\\\\icon.ico" 0' if icon_info.get('main_icon', False) else 'CreateShortCut "$SMPROGRAMS\\\\${{APP_NAME}}\\\\${{APP_NAME}}.lnk" "$INSTDIR\\\\${{APP_EXE}}"'}
{f'CreateShortCut "$SMPROGRAMS\\\\${{APP_NAME}}\\\\卸载${{APP_NAME}}.lnk" "$INSTDIR\\\\uninstall.exe" "" "$INSTDIR\\\\uninstall_icon.ico" 0' if icon_info.get('uninstall_icon', False) else f'CreateShortCut "$SMPROGRAMS\\\\${{APP_NAME}}\\\\卸载${{APP_NAME}}.lnk" "$INSTDIR\\\\uninstall.exe" "" "$INSTDIR\\\\icon.ico" 0' if icon_info.get('main_icon', False) else 'CreateShortCut "$SMPROGRAMS\\\\${{APP_NAME}}\\\\卸载${{APP_NAME}}.lnk" "$INSTDIR\\\\uninstall.exe"'}
# 创建桌面快捷方式
{f'CreateShortCut "$DESKTOP\\\\${{APP_NAME}}.lnk" "$INSTDIR\\\\${{APP_EXE}}" "" "$INSTDIR\\\\icon.ico" 0' if icon_info.get('main_icon', False) else 'CreateShortCut "$DESKTOP\\\\${{APP_NAME}}.lnk" "$INSTDIR\\\\${{APP_EXE}}"'}
# 写入注册表
WriteRegStr HKLM "Software\\${{APP_PUBLISHER}}\\${{APP_NAME_EN}}" "InstallPath" "$INSTDIR"
WriteRegStr HKLM "Software\\${{APP_PUBLISHER}}\\${{APP_NAME_EN}}" "Version" "${{APP_VERSION}}"
# 写入卸载信息
WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME_EN}}" "DisplayName" "${{APP_NAME}}"
WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME_EN}}" "UninstallString" "$INSTDIR\\uninstall.exe"
WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME_EN}}" "DisplayVersion" "${{APP_VERSION}}"
WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME_EN}}" "Publisher" "${{APP_PUBLISHER}}"
WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME_EN}}" "URLInfoAbout" "${{APP_URL}}"
WriteRegDWORD HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME_EN}}" "NoModify" 1
WriteRegDWORD HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME_EN}}" "NoRepair" 1
# 创建卸载程序
WriteUninstaller "$INSTDIR\\uninstall.exe"
SectionEnd
# 卸载段
Section "Uninstall"
# 删除快捷方式
Delete "$SMPROGRAMS\\${{APP_NAME}}\\${{APP_NAME}}.lnk"
Delete "$SMPROGRAMS\\${{APP_NAME}}\\卸载${{APP_NAME}}.lnk"
RMDir "$SMPROGRAMS\\${{APP_NAME}}"
Delete "$DESKTOP\\${{APP_NAME}}.lnk"
# 删除安装目录
RMDir /r "$INSTDIR"
# 删除注册表项
DeleteRegKey HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME_EN}}"
DeleteRegKey HKLM "Software\\${{APP_PUBLISHER}}\\${{APP_NAME_EN}}"
SectionEnd
'''
# 写入NSIS脚本文件
# 使用UTF-8 BOM编码NSIS可以正确识别
with open(self.nsis_script, 'w', encoding='utf-8-sig') as f:
f.write(nsis_content)
print(f"NSIS script generated: {self.nsis_script}")
return installer_name
def create_license_file(self):
"""创建许可证文件"""
license_file = self.assets_dir / "license.txt"
license_content = f"""软件许可协议
{self.app_name} v{self.app_version}
版权所有 © 2025 {self.app_publisher}
使用本软件即表示您同意以下条款:
1. 本软件仅供合法用途使用
2. 不得对软件进行反向工程、反编译或反汇编
3. 软件按"原样"提供,不提供任何明示或暗示的保证
4. 在任何情况下,软件提供者均不对因使用本软件而产生的任何损害承担责任
如有疑问,请联系:{self.app_url}
"""
with open(license_file, 'w', encoding='utf-8-sig') as f:
f.write(license_content)
print(f"License file created: {license_file}")
def build_installer(self):
"""构建安装包"""
print("Building NSIS installer...")
# 创建输出目录
self.output_dir.mkdir(exist_ok=True)
# 执行NSIS编译
try:
cmd = ['makensis', str(self.nsis_script)]
result = subprocess.run(cmd, cwd=str(self.script_dir),
capture_output=True, text=True, check=True)
print("NSIS compilation successful")
if result.stdout:
print("NSIS output:")
print(result.stdout)
return True
except subprocess.CalledProcessError as e:
print("ERROR: NSIS compilation failed")
print(f"Error message: {e.stderr}")
return False
def run(self):
"""执行完整的构建流程"""
print("=" * 60)
print(f"Installer Build Tool for {self.app_name}")
print("=" * 60)
try:
# 1. 检查前提条件
if not self.check_prerequisites():
return False
# 2. 准备资源文件
icon_info = self.prepare_assets()
# 3. 创建许可证文件
self.create_license_file()
# 4. 生成NSIS脚本
installer_name = self.generate_nsis_script(icon_info)
# 5. 构建安装包
if not self.build_installer():
return False
# 6. 显示结果
installer_path = self.output_dir / installer_name
if installer_path.exists():
installer_size = installer_path.stat().st_size / (1024 * 1024)
print("\n" + "=" * 60)
print("Installer build completed successfully!")
print(f"Installer location: {installer_path}")
print(f"File size: {installer_size:.1f} MB")
print("Ready to distribute to users")
print("=" * 60)
return True
else:
print("ERROR: Installer file not found")
return False
except Exception as e:
print(f"ERROR: Build process error: {e}")
return False
def main():
"""主函数"""
installer = NSISInstaller()
success = installer.run()
if not success:
sys.exit(1)
if __name__ == "__main__":
main()

1182
main.py

File diff suppressed because it is too large Load Diff

View File

@@ -7,57 +7,62 @@
import os import os
import subprocess import subprocess
import shutil import shutil
from pathlib import Path
def clean_build(): def clean_build():
"""清理构建目录""" """清理构建目录"""
print("🧹 清理旧的构建文件...") print("Cleaning old build files...")
# 跳过进程结束步骤,避免影响当前脚本运行 # 跳过进程结束步骤,避免影响当前脚本运行
print("🔄 跳过进程结束步骤(避免脚本自终止)...") print("Skipping process termination step...")
# 等待少量时间确保文件句柄释放 # 等待少量时间确保文件句柄释放
import time import time
print("⏳ 等待文件句柄释放...") print("Waiting for file handles to release...")
time.sleep(0.5) time.sleep(0.5)
dirs_to_clean = ['dist', 'build'] dirs_to_clean = ['dist', 'build']
for dir_name in dirs_to_clean: for dir_name in dirs_to_clean:
print(f"🔍 检查目录: {dir_name}") print(f"Checking directory: {dir_name}")
if os.path.exists(dir_name): if os.path.exists(dir_name):
try: try:
print(f"🗑️ 正在删除: {dir_name}") print(f"Deleting: {dir_name}")
shutil.rmtree(dir_name) shutil.rmtree(dir_name)
print(f"✅ 已删除: {dir_name}") print(f"Deleted: {dir_name}")
except PermissionError as e: except PermissionError as e:
print(f"⚠️ {dir_name} 被占用,尝试强制删除... 错误: {e}") print(f"WARNING: {dir_name} is in use, attempting force delete... Error: {e}")
try: try:
subprocess.run(['rd', '/s', '/q', dir_name], subprocess.run(['rd', '/s', '/q', dir_name],
shell=True, check=True) shell=True, check=True)
print(f"✅ 强制删除成功: {dir_name}") print(f"Force delete successful: {dir_name}")
except Exception as e2: except Exception as e2:
print(f"❌ 无法删除 {dir_name}: {e2}") print(f"ERROR: Cannot delete {dir_name}: {e2}")
print("💡 请手动删除或运行 force_clean_dist.bat") print("TIP: Please delete manually or run force_clean_dist.bat")
except Exception as e: except Exception as e:
print(f"❌ 删除 {dir_name} 时出错: {e}") print(f"ERROR: Failed to delete {dir_name}: {e}")
else: else:
print(f" {dir_name} 不存在,跳过") print(f"INFO: {dir_name} does not exist, skipping")
print("✅ 清理阶段完成") print("Cleaning phase completed")
def build_with_command(): def build_with_command():
"""使用命令行参数直接打包""" """使用命令行参数直接打包"""
print("🚀 开始打包...") print("Starting build...")
cmd = [ cmd = [
'pyinstaller', 'pyinstaller',
'--name=main', '--name=MultiPlatformGUI', # 直接使用最终目录名
'--onedir', # 相当于 --exclude-binaries '--onedir', # 相当于 --exclude-binaries
'--windowed', # 相当于 -w '--windowed', # 相当于 -w
'--icon=static/ai_assistant_icon_64.png', # 添加主程序图标
'--add-data=config.py;.', '--add-data=config.py;.',
'--add-data=exe_file_logger.py;.', '--add-data=exe_file_logger.py;.',
'--add-data=windows_taskbar_fix.py;.', '--add-data=windows_taskbar_fix.py;.',
'--add-data=auto_updater.py;.',
'--add-data=update_dialog.py;.',
'--add-data=version_checker.py;.',
'--add-data=Utils/PythonNew32;Utils/PythonNew32', '--add-data=Utils/PythonNew32;Utils/PythonNew32',
'--add-data=Utils/JD;Utils/JD', '--add-data=Utils/JD;Utils/JD',
'--add-data=Utils/Dy;Utils/Dy', '--add-data=Utils/Dy;Utils/Dy',
@@ -85,28 +90,44 @@ def build_with_command():
'--hidden-import=WebSocket.backend_singleton', '--hidden-import=WebSocket.backend_singleton',
'--hidden-import=WebSocket.BackendClient', '--hidden-import=WebSocket.BackendClient',
'--hidden-import=windows_taskbar_fix', '--hidden-import=windows_taskbar_fix',
'--hidden-import=py_mini_racer', # PDD平台内置JavaScript引擎
'--hidden-import=py_mini_racer.py_mini_racer',
'--collect-all=py_mini_racer', # 🔧 收集所有PyMiniRacer文件包括DLL
'main.py' 'main.py'
] ]
try: try:
print(f"执行命令: {' '.join(cmd[:5])}... ({len(cmd)}个参数)") print(f"go to compile: {' '.join(cmd[:5])}... (all{len(cmd)} args)")
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8') result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
if result.returncode == 0: if result.returncode == 0:
print("✅ 打包成功!") print("Build successful!")
print("📁 打包结果: dist/main/") print("Build result: dist/MultiPlatformGUI/")
# 重命名目录 # 验证输出目录(不需要重命名了)
if os.path.exists('dist/main'): if os.path.exists('dist/MultiPlatformGUI/MultiPlatformGUI.exe'):
# PyInstaller 生成的 exe 名称是 MultiPlatformGUI.exe
# 重命名为 main.exe保持兼容性
try: try:
if os.path.exists('dist/MultiPlatformGUI'): old_exe = 'dist/MultiPlatformGUI/MultiPlatformGUI.exe'
shutil.rmtree('dist/MultiPlatformGUI') new_exe = 'dist/MultiPlatformGUI/main.exe'
os.rename('dist/main', 'dist/MultiPlatformGUI')
print("✅ 已重命名为: dist/MultiPlatformGUI/") if os.path.exists(new_exe):
os.remove(new_exe)
os.rename(old_exe, new_exe)
print("Main executable renamed: MultiPlatformGUI.exe -> main.exe")
exe_size = os.path.getsize(new_exe) / 1024 / 1024
print(f"Main executable verified: main.exe ({exe_size:.1f} MB)")
except Exception as e: except Exception as e:
print(f"⚠️ 重命名失败: {e}") print(f"WARNING: Failed to rename main executable: {e}")
print("💡 可以手动重命名: dist/main -> dist/MultiPlatformGUI") elif os.path.exists('dist/MultiPlatformGUI/main.exe'):
print("📁 当前可用路径: dist/main/") # 如果已经是 main.exe直接验证
exe_size = os.path.getsize('dist/MultiPlatformGUI/main.exe') / 1024 / 1024
print(f"Main executable verified: main.exe ({exe_size:.1f} MB)")
else:
print("WARNING: Main executable file not found")
# 创建使用说明 # 创建使用说明
try: try:
@@ -116,14 +137,14 @@ def build_with_command():
return True return True
else: else:
print("❌ 打包失败") print("ERROR: Build failed")
if result.stderr: if result.stderr:
print("错误信息:") print("Error messages:")
print(result.stderr) print(result.stderr)
return False return False
except Exception as e: except Exception as e:
print(f"❌ 打包过程出错: {e}") print(f"ERROR: Build process error: {e}")
return False return False
@@ -182,9 +203,9 @@ MultiPlatformGUI/
try: try:
with open('dist/MultiPlatformGUI/使用说明.txt', 'w', encoding='utf-8') as f: with open('dist/MultiPlatformGUI/使用说明.txt', 'w', encoding='utf-8') as f:
f.write(guide_content) f.write(guide_content)
print("✅ 已生成使用说明文件") print("Usage guide file generated")
except: except:
print("⚠️ 使用说明生成失败") print("WARNING: Usage guide generation failed")
def create_usage_guide_fallback(): def create_usage_guide_fallback():
@@ -212,107 +233,148 @@ cd dist/main
try: try:
with open('dist/main/使用说明.txt', 'w', encoding='utf-8') as f: with open('dist/main/使用说明.txt', 'w', encoding='utf-8') as f:
f.write(guide_content) f.write(guide_content)
print("✅ 已生成使用说明文件 (备用路径)") print("Usage guide file generated (fallback path)")
except: except:
print("⚠️ 使用说明生成失败") print("WARNING: Usage guide generation failed")
def verify_result(): def verify_result():
"""验证打包结果""" """验证打包结果"""
print("\n🔍 验证打包结果...") print("\nVerifying build result...")
# 检查MultiPlatformGUI再检查main目录 # 检查MultiPlatformGUI目录(统一输出目录)
base_dirs = ["dist/MultiPlatformGUI", "dist/main"] target_dir = "dist/MultiPlatformGUI"
for base_dir in base_dirs: if os.path.exists(target_dir):
if os.path.exists(base_dir): return _verify_directory(target_dir)
exe_path = f"{base_dir}/main.exe"
dll_path = f"{base_dir}/_internal/Utils/PythonNew32/SaiNiuApi.dll"
if os.path.exists(exe_path):
size = os.path.getsize(exe_path)
print(f"✅ 主程序: {exe_path} ({size:,} bytes)")
if os.path.exists(dll_path):
dll_size = os.path.getsize(dll_path)
print(f"✅ 千牛DLL: SaiNiuApi.dll ({dll_size:,} bytes)")
print(f"📁 有效路径: {base_dir}/")
print("✅ 验证通过")
return True
else: else:
print("❌ 千牛DLL不存在") print("ERROR: Build output directory not found: dist/MultiPlatformGUI")
else: print("TIP: Please check if PyInstaller executed successfully")
print(f"{exe_path} 不存在")
print("❌ 未找到有效的打包结果")
return False return False
def _verify_directory(base_dir):
"""验证指定目录的打包结果"""
# 检查可能的 exe 名称
exe_path = f"{base_dir}/main.exe"
if not os.path.exists(exe_path):
exe_path = f"{base_dir}/MultiPlatformGUI.exe"
internal_dir = f"{base_dir}/_internal"
dll_path = f"{base_dir}/_internal/Utils/PythonNew32/SaiNiuApi.dll"
static_dir = f"{base_dir}/_internal/static"
print(f"Verifying directory: {base_dir}")
# 检查主程序
if os.path.exists(exe_path):
size = os.path.getsize(exe_path) / 1024 / 1024 # MB
print(f"Main executable: main.exe ({size:.1f} MB)")
else:
print("ERROR: Main executable main.exe does not exist")
return False
# 检查_internal目录
if os.path.exists(internal_dir):
file_count = len([f for f in os.listdir(internal_dir) if os.path.isfile(os.path.join(internal_dir, f))])
dir_count = len([d for d in os.listdir(internal_dir) if os.path.isdir(os.path.join(internal_dir, d))])
print(f"Dependencies directory: _internal/ ({file_count} files, {dir_count} subdirectories)")
else:
print("ERROR: _internal directory does not exist")
return False
# 检查关键DLL可选
if os.path.exists(dll_path):
dll_size = os.path.getsize(dll_path) / 1024 # KB
print(f"QianNiu DLL: SaiNiuApi.dll ({dll_size:.1f} KB)")
else:
print("WARNING: QianNiu DLL does not exist (may be normal)")
# 检查静态资源
if os.path.exists(static_dir):
icon_files = list(Path(static_dir).glob("*.png"))
print(f"Static resources: static/ ({len(icon_files)} icon files)")
else:
print("WARNING: static directory does not exist")
print(f"Directory {base_dir} verification passed")
return True
def main(): def main():
"""主函数""" """主函数"""
print("🔥 多平台客服GUI快速打包工具") print("Multi-Platform Customer Service GUI Quick Build Tool")
print("=" * 60) print("=" * 60)
try: try:
# 检查依赖 # 检查依赖
print("🔍 检查打包依赖...") print("Checking build dependencies...")
try: try:
import subprocess import subprocess
result = subprocess.run(['pyinstaller', '--version'], result = subprocess.run(['pyinstaller', '--version'],
capture_output=True, text=True, check=False) capture_output=True, text=True, check=False)
if result.returncode == 0: if result.returncode == 0:
print(f"PyInstaller 版本: {result.stdout.strip()}") print(f"PyInstaller version: {result.stdout.strip()}")
else: else:
print(" PyInstaller 未安装或不可用") print("ERROR: PyInstaller not installed or unavailable")
print("💡 请运行: pip install pyinstaller") print("TIP: Run: pip install pyinstaller")
return False return False
except Exception as e: except Exception as e:
print(f"❌ 检查PyInstaller时出错: {e}") print(f"ERROR: Failed to check PyInstaller: {e}")
return False return False
# 清理 # 清理
print("\n📍 开始清理阶段...") print("\nCleaning phase started...")
clean_build() clean_build()
print("📍 清理阶段完成") print("Cleaning phase completed")
# 打包 # 打包
print("\n📍 开始打包阶段...") print("\nBuild phase started...")
if not build_with_command(): if not build_with_command():
print("❌ 打包阶段失败") print("ERROR: Build phase failed")
return False return False
print("📍 打包阶段完成") print("Build phase completed")
# 验证 # 验证
print("\n📍 开始验证阶段...") print("\nVerification phase started...")
if not verify_result(): if not verify_result():
print("❌ 验证阶段失败") print("ERROR: Verification phase failed")
return False return False
print("📍 验证阶段完成") print("Verification phase completed")
# 🔧 修复PyMiniRacer DLL
print("\nPyMiniRacer DLL fix phase started...")
try:
import fix_pyminiracer_dll
if fix_pyminiracer_dll.auto_fix_after_build():
print("PyMiniRacer DLL fix completed")
else:
print("WARNING: PyMiniRacer DLL fix failed - PDD platform may not work without Node.js")
except Exception as e:
print(f"WARNING: PyMiniRacer DLL fix error: {e}")
print(" PDD platform may need Node.js environment")
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("🎉 打包完成!") print("Build completed successfully!")
# 智能显示可用路径 # 显示打包结果
if os.path.exists("dist/MultiPlatformGUI"): if os.path.exists("dist/MultiPlatformGUI"):
print("📁 打包结果: dist/MultiPlatformGUI/") print("Build result: dist/MultiPlatformGUI/")
print("🚀 运行方式: cd dist/MultiPlatformGUI && .\\main.exe") print("Run command: cd dist/MultiPlatformGUI && .\\main.exe")
elif os.path.exists("dist/main"): print("Create installer: python installer/build_installer.py")
print("<EFBFBD><EFBFBD> 打包结果: dist/main/")
print("🚀 运行方式: cd dist/main && .\\main.exe")
print("💡 提示: 可手动重命名为 MultiPlatformGUI")
print("=" * 60) print("=" * 60)
return True return True
except Exception as e: except Exception as e:
print(f"❌ 打包失败: {e}") print(f"ERROR: Build failed: {e}")
return False return False
if __name__ == "__main__": if __name__ == "__main__":
success = main() success = main()
if success: if success:
print("\n✅ 可以开始测试了!") print("\nReady for testing!")
else: else:
print("\n❌ 请检查错误信息并重试") print("\nERROR: Please check error messages and retry")

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

BIN
static/hengfu.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

View File

@@ -1,4 +1,4 @@
window = globalThis;; window = globalThis;
var CryptoJS = CryptoJS || (function (Math, undefined) { var CryptoJS = CryptoJS || (function (Math, undefined) {
var C = {}; var C = {};

View File

@@ -1,4 +1,4 @@
window = globalThis;; window = globalThis;
var CryptoJS = CryptoJS || (function (Math, undefined) { var CryptoJS = CryptoJS || (function (Math, undefined) {
var C = {}; var C = {};

474
static/js/sign.js Normal file
View File

@@ -0,0 +1,474 @@
var CryptoJS = CryptoJS || (function (Math, undefined) {
var C = {};
var C_lib = C.lib = {};
var Base = C_lib.Base = (function () {
function F() {};
return {
extend: function (overrides) {
F.prototype = this;
var subtype = new F();
if (overrides) {
subtype.mixIn(overrides);
}
if (!subtype.hasOwnProperty('init') || this.init === subtype.init) {
subtype.init = function () {
subtype.$super.init.apply(this, arguments);
};
}
subtype.init.prototype = subtype;
subtype.$super = this;
return subtype;
}, create: function () {
var instance = this.extend();
instance.init.apply(instance, arguments);
return instance;
}, init: function () {}, mixIn: function (properties) {
for (var propertyName in properties) {
if (properties.hasOwnProperty(propertyName)) {
this[propertyName] = properties[propertyName];
}
}
if (properties.hasOwnProperty('toString')) {
this.toString = properties.toString;
}
}, clone: function () {
return this.init.prototype.extend(this);
}
};
}());
var WordArray = C_lib.WordArray = Base.extend({
init: function (words, sigBytes) {
words = this.words = words || [];
if (sigBytes != undefined) {
this.sigBytes = sigBytes;
} else {
this.sigBytes = words.length * 4;
}
}, toString: function (encoder) {
return (encoder || Hex).stringify(this);
}, concat: function (wordArray) {
var thisWords = this.words;
var thatWords = wordArray.words;
var thisSigBytes = this.sigBytes;
var thatSigBytes = wordArray.sigBytes;
this.clamp();
if (thisSigBytes % 4) {
for (var i = 0; i < thatSigBytes; i++) {
var thatByte = (thatWords[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
thisWords[(thisSigBytes + i) >>> 2] |= thatByte << (24 - ((thisSigBytes + i) % 4) * 8);
}
} else if (thatWords.length > 0xffff) {
for (var i = 0; i < thatSigBytes; i += 4) {
thisWords[(thisSigBytes + i) >>> 2] = thatWords[i >>> 2];
}
} else {
thisWords.push.apply(thisWords, thatWords);
}
this.sigBytes += thatSigBytes;
return this;
}, clamp: function () {
var words = this.words;
var sigBytes = this.sigBytes;
words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8);
words.length = Math.ceil(sigBytes / 4);
}, clone: function () {
var clone = Base.clone.call(this);
clone.words = this.words.slice(0);
return clone;
}, random: function (nBytes) {
var words = [];
var r = (function (m_w) {
var m_w = m_w;
var m_z = 0x3ade68b1;
var mask = 0xffffffff;
return function () {
m_z = (0x9069 * (m_z & 0xFFFF) + (m_z >> 0x10)) & mask;
m_w = (0x4650 * (m_w & 0xFFFF) + (m_w >> 0x10)) & mask;
var result = ((m_z << 0x10) + m_w) & mask;
result /= 0x100000000;
result += 0.5;
return result * (Math.random() > .5 ? 1 : -1);
}
});
for (var i = 0, rcache; i < nBytes; i += 4) {
var _r = r((rcache || Math.random()) * 0x100000000);
rcache = _r() * 0x3ade67b7;
words.push((_r() * 0x100000000) | 0);
}
return new WordArray.init(words, nBytes);
}
});
var C_enc = C.enc = {};
var Hex = C_enc.Hex = {
stringify: function (wordArray) {
var words = wordArray.words;
var sigBytes = wordArray.sigBytes;
var hexChars = [];
for (var i = 0; i < sigBytes; i++) {
var bite = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
hexChars.push((bite >>> 4).toString(16));
hexChars.push((bite & 0x0f).toString(16));
}
return hexChars.join('');
}, parse: function (hexStr) {
var hexStrLength = hexStr.length;
var words = [];
for (var i = 0; i < hexStrLength; i += 2) {
words[i >>> 3] |= parseInt(hexStr.substr(i, 2), 16) << (24 - (i % 8) * 4);
}
return new WordArray.init(words, hexStrLength / 2);
}
};
var Latin1 = C_enc.Latin1 = {
stringify: function (wordArray) {
var words = wordArray.words;
var sigBytes = wordArray.sigBytes;
var latin1Chars = [];
for (var i = 0; i < sigBytes; i++) {
var bite = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
latin1Chars.push(String.fromCharCode(bite));
}
return latin1Chars.join('');
}, parse: function (latin1Str) {
var latin1StrLength = latin1Str.length;
var words = [];
for (var i = 0; i < latin1StrLength; i++) {
words[i >>> 2] |= (latin1Str.charCodeAt(i) & 0xff) << (24 - (i % 4) * 8);
}
return new WordArray.init(words, latin1StrLength);
}
};
var Utf8 = C_enc.Utf8 = {
stringify: function (wordArray) {
try {
return decodeURIComponent(escape(Latin1.stringify(wordArray)));
} catch (e) {
throw new Error('Malformed UTF-8 data');
}
}, parse: function (utf8Str) {
return Latin1.parse(unescape(encodeURIComponent(utf8Str)));
}
};
var BufferedBlockAlgorithm = C_lib.BufferedBlockAlgorithm = Base.extend({
reset: function () {
this._data = new WordArray.init();
this._nDataBytes = 0;
}, _append: function (data) {
if (typeof data == 'string') {
data = Utf8.parse(data);
}
this._data.concat(data);
this._nDataBytes += data.sigBytes;
}, _process: function (doFlush) {
var data = this._data;
var dataWords = data.words;
var dataSigBytes = data.sigBytes;
var blockSize = this.blockSize;
var blockSizeBytes = blockSize * 4;
var nBlocksReady = dataSigBytes / blockSizeBytes;
if (doFlush) {
nBlocksReady = Math.ceil(nBlocksReady);
} else {
nBlocksReady = Math.max((nBlocksReady | 0) - this._minBufferSize, 0);
}
var nWordsReady = nBlocksReady * blockSize;
var nBytesReady = Math.min(nWordsReady * 4, dataSigBytes);
if (nWordsReady) {
for (var offset = 0; offset < nWordsReady; offset += blockSize) {
this._doProcessBlock(dataWords, offset);
}
var processedWords = dataWords.splice(0, nWordsReady);
data.sigBytes -= nBytesReady;
}
return new WordArray.init(processedWords, nBytesReady);
}, clone: function () {
var clone = Base.clone.call(this);
clone._data = this._data.clone();
return clone;
}, _minBufferSize: 0
});
var Hasher = C_lib.Hasher = BufferedBlockAlgorithm.extend({
cfg: Base.extend(),
init: function (cfg) {
this.cfg = this.cfg.extend(cfg);
this.reset();
}, reset: function () {
BufferedBlockAlgorithm.reset.call(this);
this._doReset();
}, update: function (messageUpdate) {
this._append(messageUpdate);
this._process();
return this;
}, finalize: function (messageUpdate) {
if (messageUpdate) {
this._append(messageUpdate);
}
var hash = this._doFinalize();
return hash;
}, blockSize: 512 / 32,
_createHelper: function (hasher) {
return function (message, cfg) {
return new hasher.init(cfg).finalize(message);
};
}, _createHmacHelper: function (hasher) {
return function (message, key) {
return new C_algo.HMAC.init(hasher, key).finalize(message);
};
}
});
var C_algo = C.algo = {};
return C;
}(Math));
(function () {
var C = CryptoJS;
var C_lib = C.lib;
var Base = C_lib.Base;
var C_enc = C.enc;
var Utf8 = C_enc.Utf8;
var C_algo = C.algo;
var HMAC = C_algo.HMAC = Base.extend({
init: function (hasher, key) {
hasher = this._hasher = new hasher.init();
if (typeof key == 'string') {
key = Utf8.parse(key);
}
var hasherBlockSize = hasher.blockSize;
var hasherBlockSizeBytes = hasherBlockSize * 4;
if (key.sigBytes > hasherBlockSizeBytes) {
key = hasher.finalize(key);
}
key.clamp();
var oKey = this._oKey = key.clone();
var iKey = this._iKey = key.clone();
var oKeyWords = oKey.words;
var iKeyWords = iKey.words;
for (var i = 0; i < hasherBlockSize; i++) {
oKeyWords[i] ^= 0x5c5c5c5c;
iKeyWords[i] ^= 0x36363636;
}
oKey.sigBytes = iKey.sigBytes = hasherBlockSizeBytes;
this.reset();
}, reset: function () {
var hasher = this._hasher;
hasher.reset();
hasher.update(this._iKey);
}, update: function (messageUpdate) {
this._hasher.update(messageUpdate);
return this;
}, finalize: function (messageUpdate) {
var hasher = this._hasher;
var innerHash = hasher.finalize(messageUpdate);
hasher.reset();
var hmac = hasher.finalize(this._oKey.clone().concat(innerHash));
return hmac;
}
});
}());
(function (Math) {
var C = CryptoJS;
var C_lib = C.lib;
var WordArray = C_lib.WordArray;
var Hasher = C_lib.Hasher;
var C_algo = C.algo;
var H = [];
var K = [];
(function () {
function isPrime(n) {
var sqrtN = Math.sqrt(n);
for (var factor = 2; factor <= sqrtN; factor++) {
if (!(n % factor)) {
return false;
}
}
return true;
}
function getFractionalBits(n) {
return ((n - (n | 0)) * 0x100000000) | 0;
}
var n = 2;
var nPrime = 0;
while (nPrime < 64) {
if (isPrime(n)) {
if (nPrime < 8) {
H[nPrime] = getFractionalBits(Math.pow(n, 1 / 2));
}
K[nPrime] = getFractionalBits(Math.pow(n, 1 / 3));
nPrime++;
}
n++;
}
}());
var W = [];
var SHA256 = C_algo.SHA256 = Hasher.extend({
_doReset: function () {
this._hash = new WordArray.init(H.slice(0));
}, _doProcessBlock: function (M, offset) {
var H = this._hash.words;
var a = H[0];
var b = H[1];
var c = H[2];
var d = H[3];
var e = H[4];
var f = H[5];
var g = H[6];
var h = H[7];
for (var i = 0; i < 64; i++) {
if (i < 16) {
W[i] = M[offset + i] | 0;
} else {
var gamma0x = W[i - 15];
var gamma0 = ((gamma0x << 25) | (gamma0x >>> 7)) ^ ((gamma0x << 14) | (gamma0x >>> 18)) ^ (gamma0x >>> 3);
var gamma1x = W[i - 2];
var gamma1 = ((gamma1x << 15) | (gamma1x >>> 17)) ^ ((gamma1x << 13) | (gamma1x >>> 19)) ^ (gamma1x >>> 10);
W[i] = gamma0 + W[i - 7] + gamma1 + W[i - 16];
}
var ch = (e & f) ^ (~e & g);
var maj = (a & b) ^ (a & c) ^ (b & c);
var sigma0 = ((a << 30) | (a >>> 2)) ^ ((a << 19) | (a >>> 13)) ^ ((a << 10) | (a >>> 22));
var sigma1 = ((e << 26) | (e >>> 6)) ^ ((e << 21) | (e >>> 11)) ^ ((e << 7) | (e >>> 25));
var t1 = h + sigma1 + ch + K[i] + W[i];
var t2 = sigma0 + maj;
h = g;
g = f;
f = e;
e = (d + t1) | 0;
d = c;
c = b;
b = a;
a = (t1 + t2) | 0;
}
H[0] = (H[0] + a) | 0;
H[1] = (H[1] + b) | 0;
H[2] = (H[2] + c) | 0;
H[3] = (H[3] + d) | 0;
H[4] = (H[4] + e) | 0;
H[5] = (H[5] + f) | 0;
H[6] = (H[6] + g) | 0;
H[7] = (H[7] + h) | 0;
}, _doFinalize: function () {
var data = this._data;
var dataWords = data.words;
var nBitsTotal = this._nDataBytes * 8;
var nBitsLeft = data.sigBytes * 8;
dataWords[nBitsLeft >>> 5] |= 0x80 << (24 - nBitsLeft % 32);
dataWords[(((nBitsLeft + 64) >>> 9) << 4) + 14] = Math.floor(nBitsTotal / 0x100000000);
dataWords[(((nBitsLeft + 64) >>> 9) << 4) + 15] = nBitsTotal;
data.sigBytes = dataWords.length * 4;
this._process();
return this._hash;
}, clone: function () {
var clone = Hasher.clone.call(this);
clone._hash = this._hash.clone();
return clone;
}
});
C.SHA256 = Hasher._createHelper(SHA256);
C.HmacSHA256 = Hasher._createHmacHelper(SHA256);
}(Math));
function u(e) {
try {
return encodeURIComponent(e).replace(/[^A-Za-z0-9_.~\-%]+/g, escape).replace(/[*]/g, (function(e) {
return "%".concat(e.charCodeAt(0).toString(16).toUpperCase())
}
))
} catch (e) {
return ""
}
}
function d(e) {
return Object.keys(e).sort().map((function(t) {
var n = e[t];
if (null != n) {
var r = u(t);
if (r)
return Array.isArray(n) ? "".concat(r, "=").concat(n.map(u).sort().join("&".concat(r, "="))) : "".concat(r, "=").concat(u(n))
}
}
)).filter((function(e) {
return e
}
)).join("&")
};
var glo = ""
function canonicalString(method, params, x_amz_date, x_amz_security_token, data){
var date = "x-amz-date:" + x_amz_date;
var token = "x-amz-security-token:" + x_amz_security_token;
if (data){
var sha512 = CryptoJS.SHA256(JSON.stringify(data).replace(" ", "")).toString()
glo = sha512;
var content = "x-amz-content-sha256:" + sha512;
var sss = content + "\n" + date + "\n" + token
var ttt = JSON.stringify(data).replace(" ", "");
var zzz = "x-amz-content-sha256;x-amz-date;x-amz-security-token"
}else{
var sss = date + "\n" + token
var zzz = "x-amz-date;x-amz-security-token"
var ttt = ""
}
var e = []
, t = "/";
return e.push(method),
e.push(t),
e.push(d(params) || ""),
e.push("".concat((sss), "\n")),
e.push(zzz),
e.push(CryptoJS.SHA256(ttt)),
e.join("\n")
}
function stringToSign(method, type, key, params, x_amz_date, x_amz_security_token, data){
var t = [];
return t.push("AWS4-HMAC-SHA256"),
t.push(x_amz_date),
t.push(key[0] + "/cn-north-1/" + type + "/aws4_request"),
t.push(CryptoJS.SHA256(canonicalString(method, params, x_amz_date, x_amz_security_token, data))),
t.join("\n")
}
function signature(method, type, secret_access_key, x_amz_date, x_amz_security_token, params, data){
var key = x_amz_date.split("T");
var o = CryptoJS.HmacSHA256(key[0], secret_access_key);
var i = CryptoJS.HmacSHA256("cn-north-1", o);
var s = CryptoJS.HmacSHA256(type, i);
var n = CryptoJS.HmacSHA256("aws4_request", s);
return CryptoJS.HmacSHA256(stringToSign(method, type, key, params, x_amz_date, x_amz_security_token, data), n).toString();
}
// console.log(signature("POST", "vod", "AWS4D2OsX7tT/swv6JehKT62QmrUjrnR+KYH63SNma7buX3P46UoZqcHH+JHvykozVlr", "20251024T033313Z", "STS2eyJMVEFjY2Vzc0tleUlEIjoiQUtMVE5tRmhZVGhsWTJJM1lqbGpORGc0WkRsbE1UTXlZelJoWkRkaU1EaGlaV1EiLCJBY2Nlc3NLZXlJRCI6IkFLVFBOVEF6TVRSalpqSTNZV00wTkRjd1lUazBNak14WVRrelpEUXhPRGxsWXpjIiwiU2lnbmVkU2VjcmV0QWNjZXNzS2V5IjoiTWcrU1laeXlCajNyc05UL0dKaFlHYTcxUEloWURvMms1Z1J4Q2QyQk5nUDV6NXdZaWpyeEhwUmYzT1dKNW9PRWNwK3FCTVNGNXhsaG40Y3hhOHhDZmkrQWgvZU0wWS95QVhqbGhMZWJmYU09IiwiRXhwaXJlZFRpbWUiOjE3NjEyODAxNDksIlBvbGljeVN0cmluZyI6IntcIlN0YXRlbWVudFwiOlt7XCJFZmZlY3RcIjpcIkFsbG93XCIsXCJBY3Rpb25cIjpbXCJ2b2Q6QXBwbHlVcGxvYWRcIixcInZvZDpBcHBseVVwbG9hZElubmVyXCIsXCJ2b2Q6Q29tbWl0VXBsb2FkXCIsXCJ2b2Q6Q29tbWl0VXBsb2FkSW5uZXJcIixcInZvZDpHZXRVcGxvYWRDYW5kaWRhdGVzXCIsXCJJbWFnZVg6QXBwbHlJbWFnZVVwbG9hZFwiLFwiSW1hZ2VYOkNvbW1pdEltYWdlVXBsb2FkXCIsXCJJbWFnZVg6QXBwbHlVcGxvYWRJbWFnZUZpbGVcIixcIkltYWdlWDpDb21taXRVcGxvYWRJbWFnZUZpbGVcIl0sXCJSZXNvdXJjZVwiOltcIipcIl0sXCJDb25kaXRpb25cIjpcIntcXFwiUFNNXFxcIjpcXFwiY21wLmVjb20ucGlnZW9uX2FwaVxcXCJ9XCJ9XX0iLCJTaWduYXR1cmUiOiIxOTBmNzkxZTU2NTk0MzgwOGIzNTllYTJhOWI4OGExOTAyZDE5MTEwODBhMmNmYmVhMTQwZmVmYWNhYTViNGU5In0=", {
// "Action": "CommitUploadInner",
// "Version": "2020-11-19",
// "SpaceName": "pigeon-video"
// }, {
// "SessionKey": "eyJhY2NvdW50VHlwZSI6InNwYWNlIiwiZW5jcnlwdEtleSI6IiIsImVuY3J5cHRNb2RlIjoiIiwiZXh0cmEiOiJlZGdlX25vZGU9bGZcdTAwMjZmaWxlX3NpemU9NDY1MDQ5LjAwMDAwMFx1MDAyNmhvc3Q9dG9zLWQtY3QtbGYuc25zc2RrLmNvbVx1MDAyNnByb3ZpbmNlPUd1YW5nZG9uZ1x1MDAyNnJlZ2lvbj1DTlx1MDAyNnN0cmF0ZWd5PWxvbmdfbWVtb3J5X2ZpbHRlcl92Mlx1MDAyNnRlZGR5X2VkZ2VfaG9zdD10b3MtZC1jdC1sZi5zbnNzZGsuY29tXHUwMDI2dXBsb2FkX21vZGU9c2VyaWFsXHUwMDI2dXNlcl9pcD0xNC4yMy45MS4yMzBcdTAwMjZ2aWRjPWxxXHUwMDI2dnRzPTE3NjEyNzY3OTI3Nzk2NDYzMTUiLCJmaWxlVHlwZSI6InZpZGVvIiwibWVkaWFUeXBlIjoiIiwibWV0YUNvbmZpZyI6IntcImFjY3VyYXRlXCI6ZmFsc2UsXCJuZWVkX3Bvc3RlclwiOnRydWUsXCJza2lwX2JsYWNrX2RldGVjdFwiOmZhbHNlLFwid2hpdGVfZGV0ZWN0XCI6ZmFsc2UsXCJzZXRfY29udGVudF90eXBlXCI6ZmFsc2UsXCJmZl9tZXRhZGF0YVwiOmZhbHNlLFwibmVlZF9tZDVcIjpmYWxzZSxcIm5lZWRfc2hhMjU2XCI6ZmFsc2UsXCJuZWVkX3JlZHVuZGFudF9oYXNoXCI6ZmFsc2UsXCJzc19hZnRlcl9pbnB1dFwiOmZhbHNlLFwibmVlZF9leGFjdF9mb3JtYXRcIjpmYWxzZSxcImttc19lbmNyeXB0X2tleVwiOlwiXCIsXCJlbmNyeXB0X3Bvc3Rlcl9rZXlcIjpcIlwifSIsInNjZW5lIjoiIiwidG9rZW4iOiJleUpvYjNOMElqb2lkRzl6TFdRdFkzUXRiR1l1YzI1emMyUnJMbU52YlNJc0ltNXZibU5sSWpvaVlWSnJSM2xRUkVNaUxDSjFjR3h2WVdSZmMybG5iaUk2SWxOd1lXTmxTMlY1TDNCcFoyVnZiaTEyYVdSbGJ5OHdMenAyWlhKemFXOXVPbll5T21WNVNtaGlSMk5wVDJsS1NWVjZTVEZPYVVselNXNVNOV05EU1RaSmEzQllWa05LT1M1bGVVcHNaVWhCYVU5cVJUTk9ha1Y2VG1wTmVFOVVTWE5KYms1d1dqSTFhR1JJVm5sYVZXeDFXbTA0YVU5dWMybFpWMDVxV2xoT2VsTXlWalZKYW05cFdtMUdjbHBXT1doWk1rNXNZek5PWm1FeVZqVkphWGRwV1c1V2FtRXlWakJKYW05cFpFYzVla3hYVG5WTVdGbDBXWHBOZUUxVVJURkphWGRwV2xob2QyRllTbXhKYW05NFRucFplRTE2V1hwTlZHdDVURU5LYldGWGVHeFRWelZ0WWpOTmFVOXNkRGRKYlRsd1drVjBiR1ZUU1RaSmJUbENZbTFhVDFKWFZrWlJibXg0WWxab1EwOUZXbTVoVmtGNlVrZHNTVk51UW5CbFdGcElZakJHVlZaVVVYZGhSVTB5U1dsM2FWcHRiSE5hVmxJMVkwZFZhVTlwU1hkSmJqRmtURU5LYkdWSVVubFpVMGsyWlhsS2FGa3lUblprVnpVd1dETkNlV0l5VWpGWk0xRnBUMmxLTW1JeVVXbE1RMHBwWWtjNWFtRXhPWFJpTWxKc1NXcHZhVWxwZDJsWk1qbDFaRWRXZFdSR09UQmxXRUpzV0RKS2MySXlUbkpKYW05cFpURjNhV0pYYkhSYVZqbDNXVE5TWTBscWIzZE1SbmRwWWxjNWExcFdkMmxQYWtGeldFTktkR0ZYTVd4WU1uaHdZek5TWTBscWNIVmtWM2h6VEVaM2FWa3lPWFZhYlhod1dUTlNabGx0ZUhaWk1uUmpTV3B3YlZsWGVIcGFXREJwVEVOS2JHSnRUbmxsV0VJd1dESkdjMW95T0dsUGFVbHBURU5LYkdKdFRubGxXRUl3V0RKMGJHVlRTVFpKYVVselNXNU9kMWxYVG14SmFtOXBZMGRzYmxwWE9YVk1XRnB3V2tkV2RrbHVNVGxtVVM0eVh6UlNVakpXYkROeWN6SmFUV3B5ZGt4eFEyTmxkVE54WTI1RWFGa3RPWGhmU1RkTGFtRXdUVE5KSW4wPTo0ZTU0MTNhZTY2OTFjM2U2ZTU2ODc0NGEyYmQzNzZiMDFiODY0YjI4OGRhODg0MzY4ZDA1YzJiMTE4ZWE1MGM4IiwidG9wRG9tYWluIjoib3Blbi5ieXRlZGFuY2VhcGkuY29tIiwidXJpIjoidG9zLWNuLXYtYzMxMTE1L29BbmZORWVFQnlxbVhCOEZnaVAzRGlISnBpeXZHb0FUVTQwaEM2IiwidXNlSXNwU2NoZWR1bGluZyI6ImZhbHNlIiwidmlkIjoidjBkZDM4ZzEwMDAwZDN0ZjZ1N29nNjVvcGJ1YWk1cTAifQ==",
// "Functions": [
// {
// "name": "GetMeta"
// },
// {
// "name": "Snapshot",
// "input": {
// "SnapshotTime": 0
// }
// }
// ]
// }))
//console.log(d({"SessionKey":"eyJhY2NvdW50VHlwZSI6InNwYWNlIiwiZW5jcnlwdEtleSI6IiIsImVuY3J5cHRNb2RlIjoiIiwiZXh0cmEiOiJlZGdlX25vZGU9bGZcdTAwMjZmaWxlX3NpemU9NDY1MDQ5LjAwMDAwMFx1MDAyNmhvc3Q9dG9zLWQtY3QtbGYuc25zc2RrLmNvbVx1MDAyNnByb3ZpbmNlPUd1YW5nZG9uZ1x1MDAyNnJlZ2lvbj1DTlx1MDAyNnN0cmF0ZWd5PWxvbmdfbWVtb3J5X2ZpbHRlcl92Mlx1MDAyNnRlZGR5X2VkZ2VfaG9zdD10b3MtZC1jdC1sZi5zbnNzZGsuY29tXHUwMDI2dXBsb2FkX21vZGU9c2VyaWFsXHUwMDI2dXNlcl9pcD0xNC4yMy45MS4yMzBcdTAwMjZ2aWRjPWxxXHUwMDI2dnRzPTE3NjEyMTU2Nzc4NDc0NTE1ODMiLCJmaWxlVHlwZSI6InZpZGVvIiwibWVkaWFUeXBlIjoiIiwibWV0YUNvbmZpZyI6IntcImFjY3VyYXRlXCI6ZmFsc2UsXCJuZWVkX3Bvc3RlclwiOnRydWUsXCJza2lwX2JsYWNrX2RldGVjdFwiOmZhbHNlLFwid2hpdGVfZGV0ZWN0XCI6ZmFsc2UsXCJzZXRfY29udGVudF90eXBlXCI6ZmFsc2UsXCJmZl9tZXRhZGF0YVwiOmZhbHNlLFwibmVlZF9tZDVcIjpmYWxzZSxcIm5lZWRfc2hhMjU2XCI6ZmFsc2UsXCJuZWVkX3JlZHVuZGFudF9oYXNoXCI6ZmFsc2UsXCJzc19hZnRlcl9pbnB1dFwiOmZhbHNlLFwibmVlZF9leGFjdF9mb3JtYXRcIjpmYWxzZSxcImttc19lbmNyeXB0X2tleVwiOlwiXCIsXCJlbmNyeXB0X3Bvc3Rlcl9rZXlcIjpcIlwifSIsInNjZW5lIjoiIiwidG9rZW4iOiJleUpvYjNOMElqb2lkRzl6TFdRdFkzUXRiR1l1YzI1emMyUnJMbU52YlNJc0ltNXZibU5sSWpvaVlVRlhkVmx1VDNBaUxDSjFjR3h2WVdSZmMybG5iaUk2SWxOd1lXTmxTMlY1TDNCcFoyVnZiaTEyYVdSbGJ5OHdMenAyWlhKemFXOXVPbll5T21WNVNtaGlSMk5wVDJsS1NWVjZTVEZPYVVselNXNVNOV05EU1RaSmEzQllWa05LT1M1bGVVcHNaVWhCYVU5cVJUTk9ha1Y2VFVSSmQwNTZZM05KYms1d1dqSTFhR1JJVm5sYVZXeDFXbTA0YVU5dWMybFpWMDVxV2xoT2VsTXlWalZKYW05cFdtMUdjbHBXT1doWk1rNXNZek5PWm1FeVZqVkphWGRwV1c1V2FtRXlWakJKYW05cFpFYzVla3hYVG5WTVdGbDBXWHBOZUUxVVJURkphWGRwV2xob2QyRllTbXhKYW05NFRucFplRTE2UVhsTlJHTXpURU5LYldGWGVHeFRWelZ0WWpOTmFVOXNkRGRKYlRsd1drVjBiR1ZUU1RaSmJUa3pVMVZTV1U5V1JtMVNTR2hFV214V2RsbFlXblZYYlhjelVsZGFRbG93TURSU01GWlpVV3BTZGxFd1RrNVZhMFp1U1dsM2FWcHRiSE5hVmxJMVkwZFZhVTlwU1hkSmJqRmtURU5LYkdWSVVubFpVMGsyWlhsS2FGa3lUblprVnpVd1dETkNlV0l5VWpGWk0xRnBUMmxLTW1JeVVXbE1RMHBwWWtjNWFtRXhPWFJpTWxKc1NXcHZhVWxwZDJsWk1qbDFaRWRXZFdSR09UQmxXRUpzV0RKS2MySXlUbkpKYW05cFpURjNhV0pYYkhSYVZqbDNXVE5TWTBscWIzZE1SbmRwWWxjNWExcFdkMmxQYWtGeldFTktkR0ZYTVd4WU1uaHdZek5TWTBscWNIVmtWM2h6VEVaM2FWa3lPWFZhYlhod1dUTlNabGx0ZUhaWk1uUmpTV3B3YlZsWGVIcGFXREJwVEVOS2JHSnRUbmxsV0VJd1dESkdjMW95T0dsUGFVbHBURU5LYkdKdFRubGxXRUl3V0RKMGJHVlRTVFpKYVVselNXNU9kMWxYVG14SmFtOXBZMGRzYmxwWE9YVk1XRnB3V2tkV2RrbHVNVGxtVVM1SVJEWktTRGxYUzJoVFh6Uk9TRWhyY0VGSVQzZHpPV3hrTmxjNGNXeGFSVGcwTkVKTVFtOVZXa3BySW4wPTpjM2I3YjRmY2U1ZjFlMDQ4NWNhYTFhMjNmNTE1ZTM0NjUxN2QxNjI3YzQ3YzAyNDdlMjlmNTE4ODcxYjlhYzNhIiwidG9wRG9tYWluIjoib3Blbi5ieXRlZGFuY2VhcGkuY29tIiwidXJpIjoidG9zLWNuLXYtYzMxMTE1L293SURYOVFmRHhDZlVvYXZuWmw3RWZBZ004R0VYQjRvQ0NNUkFnIiwidXNlSXNwU2NoZWR1bGluZyI6ImZhbHNlIiwidmlkIjoidjBkZDM4ZzEwMDAwZDN0MDlmZm9nNjVxczRwMWdsMGcifQ==","Functions":[{"name":"GetMeta"},{"name":"Snapshot","input":{"SnapshotTime":0}}]}))
// console.log(CryptoJS.SHA256(JSON.stringify({"SessionKey":"eyJhY2NvdW50VHlwZSI6InNwYWNlIiwiZW5jcnlwdEtleSI6IiIsImVuY3J5cHRNb2RlIjoiIiwiZXh0cmEiOiJlZGdlX25vZGU9bGZcdTAwMjZmaWxlX3NpemU9NDY1MDQ5LjAwMDAwMFx1MDAyNmhvc3Q9dG9zLWQtY3QtbGYuc25zc2RrLmNvbVx1MDAyNnByb3ZpbmNlPUd1YW5nZG9uZ1x1MDAyNnJlZ2lvbj1DTlx1MDAyNnN0cmF0ZWd5PWxvbmdfbWVtb3J5X2ZpbHRlcl92Mlx1MDAyNnRlZGR5X2VkZ2VfaG9zdD10b3MtZC1jdC1sZi5zbnNzZGsuY29tXHUwMDI2dXBsb2FkX21vZGU9c2VyaWFsXHUwMDI2dXNlcl9pcD0xNC4yMy45MS4yMzBcdTAwMjZ2aWRjPWxxXHUwMDI2dnRzPTE3NjEyMTU2Nzc4NDc0NTE1ODMiLCJmaWxlVHlwZSI6InZpZGVvIiwibWVkaWFUeXBlIjoiIiwibWV0YUNvbmZpZyI6IntcImFjY3VyYXRlXCI6ZmFsc2UsXCJuZWVkX3Bvc3RlclwiOnRydWUsXCJza2lwX2JsYWNrX2RldGVjdFwiOmZhbHNlLFwid2hpdGVfZGV0ZWN0XCI6ZmFsc2UsXCJzZXRfY29udGVudF90eXBlXCI6ZmFsc2UsXCJmZl9tZXRhZGF0YVwiOmZhbHNlLFwibmVlZF9tZDVcIjpmYWxzZSxcIm5lZWRfc2hhMjU2XCI6ZmFsc2UsXCJuZWVkX3JlZHVuZGFudF9oYXNoXCI6ZmFsc2UsXCJzc19hZnRlcl9pbnB1dFwiOmZhbHNlLFwibmVlZF9leGFjdF9mb3JtYXRcIjpmYWxzZSxcImttc19lbmNyeXB0X2tleVwiOlwiXCIsXCJlbmNyeXB0X3Bvc3Rlcl9rZXlcIjpcIlwifSIsInNjZW5lIjoiIiwidG9rZW4iOiJleUpvYjNOMElqb2lkRzl6TFdRdFkzUXRiR1l1YzI1emMyUnJMbU52YlNJc0ltNXZibU5sSWpvaVlVRlhkVmx1VDNBaUxDSjFjR3h2WVdSZmMybG5iaUk2SWxOd1lXTmxTMlY1TDNCcFoyVnZiaTEyYVdSbGJ5OHdMenAyWlhKemFXOXVPbll5T21WNVNtaGlSMk5wVDJsS1NWVjZTVEZPYVVselNXNVNOV05EU1RaSmEzQllWa05LT1M1bGVVcHNaVWhCYVU5cVJUTk9ha1Y2VFVSSmQwNTZZM05KYms1d1dqSTFhR1JJVm5sYVZXeDFXbTA0YVU5dWMybFpWMDVxV2xoT2VsTXlWalZKYW05cFdtMUdjbHBXT1doWk1rNXNZek5PWm1FeVZqVkphWGRwV1c1V2FtRXlWakJKYW05cFpFYzVla3hYVG5WTVdGbDBXWHBOZUUxVVJURkphWGRwV2xob2QyRllTbXhKYW05NFRucFplRTE2UVhsTlJHTXpURU5LYldGWGVHeFRWelZ0WWpOTmFVOXNkRGRKYlRsd1drVjBiR1ZUU1RaSmJUa3pVMVZTV1U5V1JtMVNTR2hFV214V2RsbFlXblZYYlhjelVsZGFRbG93TURSU01GWlpVV3BTZGxFd1RrNVZhMFp1U1dsM2FWcHRiSE5hVmxJMVkwZFZhVTlwU1hkSmJqRmtURU5LYkdWSVVubFpVMGsyWlhsS2FGa3lUblprVnpVd1dETkNlV0l5VWpGWk0xRnBUMmxLTW1JeVVXbE1RMHBwWWtjNWFtRXhPWFJpTWxKc1NXcHZhVWxwZDJsWk1qbDFaRWRXZFdSR09UQmxXRUpzV0RKS2MySXlUbkpKYW05cFpURjNhV0pYYkhSYVZqbDNXVE5TWTBscWIzZE1SbmRwWWxjNWExcFdkMmxQYWtGeldFTktkR0ZYTVd4WU1uaHdZek5TWTBscWNIVmtWM2h6VEVaM2FWa3lPWFZhYlhod1dUTlNabGx0ZUhaWk1uUmpTV3B3YlZsWGVIcGFXREJwVEVOS2JHSnRUbmxsV0VJd1dESkdjMW95T0dsUGFVbHBURU5LYkdKdFRubGxXRUl3V0RKMGJHVlRTVFpKYVVselNXNU9kMWxYVG14SmFtOXBZMGRzYmxwWE9YVk1XRnB3V2tkV2RrbHVNVGxtVVM1SVJEWktTRGxYUzJoVFh6Uk9TRWhyY0VGSVQzZHpPV3hrTmxjNGNXeGFSVGcwTkVKTVFtOVZXa3BySW4wPTpjM2I3YjRmY2U1ZjFlMDQ4NWNhYTFhMjNmNTE1ZTM0NjUxN2QxNjI3YzQ3YzAyNDdlMjlmNTE4ODcxYjlhYzNhIiwidG9wRG9tYWluIjoib3Blbi5ieXRlZGFuY2VhcGkuY29tIiwidXJpIjoidG9zLWNuLXYtYzMxMTE1L293SURYOVFmRHhDZlVvYXZuWmw3RWZBZ004R0VYQjRvQ0NNUkFnIiwidXNlSXNwU2NoZWR1bGluZyI6ImZhbHNlIiwidmlkIjoidjBkZDM4ZzEwMDAwZDN0MDlmZm9nNjVxczRwMWdsMGcifQ==","Functions":[{"name":"GetMeta"},{"name":"Snapshot","input":{"SnapshotTime":0}}]}).replace(" ")).toString())

518
update_dialog.py Normal file
View File

@@ -0,0 +1,518 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
现代化圆形进度条更新对话框
展示完整更新流程:下载 → UAC授权 → 安装 → 重启
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QLabel,
QPushButton, QHBoxLayout, QWidget)
from PyQt5.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve, pyqtProperty
from PyQt5.QtGui import QFont, QPainter, QColor, QPen, QLinearGradient, QConicalGradient
from PyQt5.QtCore import QRectF, QPointF
import math
class CircularProgressBar(QWidget):
"""
自定义圆形进度条组件
特性:
- 顺时针旋转动画
- 渐变色圆环
- 中心显示百分比
- 支持平滑过渡
"""
def __init__(self, parent=None):
super().__init__(parent)
self._value = 0 # 当前进度值0-100
self._max_value = 100
self._text = "0%"
self.setMinimumSize(200, 200)
# 动画效果
self.animation = QPropertyAnimation(self, b"value")
self.animation.setDuration(500) # 500ms过渡动画
self.animation.setEasingCurve(QEasingCurve.OutCubic)
@pyqtProperty(int)
def value(self):
return self._value
@value.setter
def value(self, val):
self._value = val
self.update() # 触发重绘
def setValue(self, val):
"""设置进度值(带动画)"""
if val != self._value:
self.animation.stop()
self.animation.setStartValue(self._value)
self.animation.setEndValue(val)
self.animation.start()
def setText(self, text):
"""设置中心文本"""
self._text = text
self.update()
def paintEvent(self, event):
"""绘制圆形进度条"""
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing) # 抗锯齿
# 计算中心和半径
width = self.width()
height = self.height()
size = min(width, height)
center = QPointF(width / 2, height / 2)
radius = size / 2 - 15 # 留出边距
# 绘制背景圆环(灰色)
pen = QPen()
pen.setWidth(12)
pen.setColor(QColor(230, 230, 230))
pen.setCapStyle(Qt.RoundCap)
painter.setPen(pen)
rect = QRectF(center.x() - radius, center.y() - radius,
radius * 2, radius * 2)
painter.drawArc(rect, 90 * 16, -360 * 16) # 绘制完整圆环
# 绘制进度圆环(渐变色)
if self._value > 0:
# 创建圆锥渐变(顺时针旋转效果)
gradient = QConicalGradient(center, 90) # 从顶部开始
gradient.setColorAt(0.0, QColor(66, 133, 244)) # 蓝色
gradient.setColorAt(0.5, QColor(25, 118, 210)) # 深蓝
gradient.setColorAt(1.0, QColor(21, 101, 192)) # 更深蓝
pen.setBrush(gradient)
pen.setColor(QColor(66, 133, 244))
painter.setPen(pen)
# 计算进度角度(顺时针,从顶部开始)
span_angle = int(-(self._value / self._max_value) * 360 * 16)
painter.drawArc(rect, 90 * 16, span_angle)
# 绘制中心文本(百分比)
painter.setPen(QColor(44, 62, 80))
font = QFont('Microsoft YaHei', 28, QFont.Bold)
painter.setFont(font)
painter.drawText(rect, Qt.AlignCenter, self._text)
class UpdateProgressDialog(QDialog):
"""
现代化更新进度对话框
更新流程阶段:
1. 下载安装包 (0-50%)
2. 等待UAC授权 (50-60%)
3. 执行安装 (60-90%)
4. 等待程序重启 (90-100%)
"""
# 流程阶段定义
STAGE_DOWNLOAD = 0 # 下载阶段 (0-50%)
STAGE_UAC = 1 # UAC授权阶段 (50-60%)
STAGE_INSTALL = 2 # 安装阶段 (60-90%)
STAGE_RESTART = 3 # 重启阶段 (90-100%)
def __init__(self, version, parent=None):
super().__init__(parent)
self.version = version
self.downloader = None
self.current_stage = self.STAGE_DOWNLOAD
self.download_progress = 0 # 下载进度0-100
self.restart_progress = 0 # 重启阶段进度0-100用于外部检查
self.init_ui()
def init_ui(self):
"""初始化UI"""
self.setWindowTitle("正在更新")
self.setFixedSize(500, 600)
# 禁止关闭按钮(只能通过取消按钮关闭)
self.setWindowFlags(Qt.Dialog | Qt.WindowTitleHint | Qt.CustomizeWindowHint)
# 主布局
layout = QVBoxLayout()
layout.setSpacing(20)
layout.setContentsMargins(30, 30, 30, 30)
# ============ 标题区域 ============
self.title_label = QLabel(f"正在更新到 v{self.version}")
self.title_label.setFont(QFont('Microsoft YaHei', 16, QFont.Bold))
self.title_label.setAlignment(Qt.AlignCenter)
self.title_label.setStyleSheet("color: #2c3e50; margin-bottom: 10px;")
layout.addWidget(self.title_label)
# ============ 圆形进度条 ============
self.circular_progress = CircularProgressBar()
layout.addWidget(self.circular_progress, alignment=Qt.AlignCenter)
# ============ 阶段提示 ============
self.stage_label = QLabel("📥 正在下载安装包...")
self.stage_label.setFont(QFont('Microsoft YaHei', 12))
self.stage_label.setAlignment(Qt.AlignCenter)
self.stage_label.setStyleSheet("color: #34495e; margin-top: 10px;")
layout.addWidget(self.stage_label)
# ============ 详细状态 ============
self.status_label = QLabel("准备开始下载...")
self.status_label.setAlignment(Qt.AlignCenter)
self.status_label.setStyleSheet("color: #7f8c8d; font-size: 11px;")
self.status_label.setMinimumHeight(25)
layout.addWidget(self.status_label)
# ============ 重试信息(默认隐藏)============
self.retry_label = QLabel("")
self.retry_label.setAlignment(Qt.AlignCenter)
self.retry_label.setStyleSheet("color: #ff9800; font-size: 11px; font-weight: bold;")
self.retry_label.setMinimumHeight(25)
self.retry_label.hide()
layout.addWidget(self.retry_label)
# ============ 按钮区域 ============
button_layout = QHBoxLayout()
button_layout.addStretch()
self.cancel_button = QPushButton("取消更新")
self.cancel_button.setFixedWidth(120)
self.cancel_button.setFixedHeight(36)
self.cancel_button.clicked.connect(self.cancel_download)
button_layout.addWidget(self.cancel_button)
button_layout.addStretch()
layout.addLayout(button_layout)
self.setLayout(layout)
# 应用样式
self.apply_styles()
def apply_styles(self):
"""应用现代化样式"""
self.setStyleSheet("""
QDialog {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #f8fafb,
stop:0.5 #f1f4f6,
stop:1 #e8ecef
);
}
QLabel {
background: transparent;
}
QPushButton {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #f5f5f5,
stop:1 #e0e0e0
);
border: 2px solid #ccc;
border-radius: 8px;
padding: 8px 20px;
font-size: 12px;
font-weight: bold;
font-family: 'Microsoft YaHei';
color: #555;
}
QPushButton:hover {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #e8e8e8,
stop:1 #d0d0d0
);
border: 2px solid #999;
}
QPushButton:pressed {
background: #d0d0d0;
}
QPushButton:disabled {
background: #f0f0f0;
color: #aaa;
border: 2px solid #ddd;
}
""")
def update_progress(self, downloaded, total):
"""
更新下载进度阶段10-50%
Args:
downloaded: 已下载字节数
total: 总字节数
"""
if self.current_stage != self.STAGE_DOWNLOAD:
return # 不在下载阶段,忽略
if total > 0:
# 计算下载百分比0-100
download_percent = int((downloaded / total) * 100)
self.download_progress = download_percent
# 映射到总进度的0-50%
overall_progress = int(download_percent * 0.5)
# 更新圆形进度条
self.circular_progress.setValue(overall_progress)
self.circular_progress.setText(f"{overall_progress}%")
# 转换为MB显示
downloaded_mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
# 更新状态文本
self.status_label.setText(f"已下载 {downloaded_mb:.1f} MB / {total_mb:.1f} MB")
else:
# 无法获取总大小时显示不确定状态
self.status_label.setText("正在下载,请稍候...")
def show_retry_info(self, current_retry, max_retries):
"""
显示重试信息
Args:
current_retry: 当前重试次数
max_retries: 最大重试次数
"""
self.retry_label.setText(f"⚠️ 下载失败,正在重试... ({current_retry}/{max_retries})")
self.retry_label.show()
# 重置下载进度
self.download_progress = 0
self.circular_progress.setValue(0)
self.circular_progress.setText("0%")
self.status_label.setText("正在重试连接...")
# 更新标题
self.title_label.setText(f"正在重试下载 v{self.version}")
def download_finished(self, file_path):
"""
下载完成进入UAC授权阶段阶段250-60%
Args:
file_path: 下载的文件路径
"""
self.current_stage = self.STAGE_UAC
# 更新进度到50%
self.circular_progress.setValue(50)
self.circular_progress.setText("50%")
# 更新阶段提示
self.stage_label.setText("🔐 等待管理员授权...")
self.title_label.setText(f"✅ 下载完成")
self.status_label.setText("请在UAC窗口点击「允许」以继续...")
self.status_label.setStyleSheet("color: #28a745; font-size: 11px; font-weight: bold;")
# 禁用取消按钮
self.cancel_button.setEnabled(False)
self.retry_label.hide()
# 启动UAC等待动画50% → 60%
self.start_uac_waiting_animation()
def start_uac_waiting_animation(self):
"""启动UAC等待动画进度条在50-60%之间缓慢移动)"""
self.uac_timer = QTimer(self)
self.uac_progress = 50
def update_uac_progress():
self.uac_progress += 0.5 # 每次增加0.5%
if self.uac_progress <= 60:
self.circular_progress.setValue(int(self.uac_progress))
self.circular_progress.setText(f"{int(self.uac_progress)}%")
else:
# 超过60%停止(等待用户授权)
self.uac_timer.stop()
self.uac_timer.timeout.connect(update_uac_progress)
self.uac_timer.start(500) # 每500ms更新一次
def uac_authorized(self):
"""
用户已授权UAC进入安装阶段阶段360-90%
"""
# 停止UAC等待动画
if hasattr(self, 'uac_timer'):
self.uac_timer.stop()
self.current_stage = self.STAGE_INSTALL
# 更新进度到60%
self.circular_progress.setValue(60)
self.circular_progress.setText("60%")
# 更新阶段提示
self.stage_label.setText("⚙️ 正在安装新版本...")
self.title_label.setText("正在安装")
self.status_label.setText("正在替换程序文件,请稍候...")
self.status_label.setStyleSheet("color: #007bff; font-size: 11px; font-weight: bold;")
# 启动安装进度动画60% → 90%
self.start_install_animation()
def start_install_animation(self):
"""启动安装进度动画60% → 90%"""
self.install_timer = QTimer(self)
self.install_progress = 60
def update_install_progress():
self.install_progress += 1 # 每次增加1%
if self.install_progress <= 90:
self.circular_progress.setValue(self.install_progress)
self.circular_progress.setText(f"{self.install_progress}%")
else:
# 安装完成,进入重启阶段
self.install_timer.stop()
self.prepare_restart()
self.install_timer.timeout.connect(update_install_progress)
self.install_timer.start(800) # 每800ms增加1%共24秒
def prepare_restart(self):
"""
准备重启阶段490-100%
"""
self.current_stage = self.STAGE_RESTART
# 更新进度到90%
self.circular_progress.setValue(90)
self.circular_progress.setText("90%")
# 更新阶段提示
self.stage_label.setText("🚀 准备启动新版本...")
self.title_label.setText("即将完成")
self.status_label.setText("程序即将重启,请稍候...")
self.status_label.setStyleSheet("color: #28a745; font-size: 11px; font-weight: bold;")
# 启动重启倒计时动画90% → 100%
self.start_restart_animation()
def start_restart_animation(self):
"""启动重启倒计时动画90% → 100%"""
self.restart_timer = QTimer(self)
self.restart_progress = 90
def update_restart_progress():
self.restart_progress += 2 # 每次增加2%
if self.restart_progress <= 100:
self.circular_progress.setValue(self.restart_progress)
self.circular_progress.setText(f"{self.restart_progress}%")
if self.restart_progress == 100:
# 🔥 到达100%,更新提示但不自动关闭
self.status_label.setText("✅ 安装完成!等待启动确认...")
self.stage_label.setText("🎉 更新成功!")
self.restart_timer.stop() # 停止定时器
else:
self.restart_timer.stop()
self.restart_timer.timeout.connect(update_restart_progress)
self.restart_timer.start(500) # 每500ms增加2%共2.5秒)
def download_error(self, error_msg):
"""
下载失败
Args:
error_msg: 错误信息
"""
self.title_label.setText(f"❌ 下载失败")
# 错误信息截断(避免太长)
if len(error_msg) > 80:
display_msg = error_msg[:80] + "..."
else:
display_msg = error_msg
self.stage_label.setText("⚠️ 更新失败")
self.status_label.setText(display_msg)
self.status_label.setStyleSheet("color: #dc3545; font-size: 11px; font-weight: bold;")
self.cancel_button.setText("关闭")
self.cancel_button.setEnabled(True)
self.retry_label.hide()
def cancel_download(self):
"""取消下载"""
if self.current_stage == self.STAGE_DOWNLOAD and self.downloader and self.downloader.isRunning():
# 下载阶段可以取消
from PyQt5.QtWidgets import QMessageBox
reply = QMessageBox.question(
self,
"确认取消",
"确定要取消更新吗?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.downloader.cancel()
self.downloader.wait()
self.status_label.setText("更新已取消")
self.reject() # 会自动清理定时器
else:
# 其他阶段或下载已完成,直接关闭
self.reject() # 会自动清理定时器
def cleanup_timers(self):
"""清理所有定时器"""
if hasattr(self, 'uac_timer'):
self.uac_timer.stop()
if hasattr(self, 'install_timer'):
self.install_timer.stop()
if hasattr(self, 'restart_timer'):
self.restart_timer.stop()
def accept(self):
"""对话框接受时清理资源"""
self.cleanup_timers()
super().accept()
def reject(self):
"""对话框拒绝时清理资源"""
self.cleanup_timers()
super().reject()
if __name__ == "__main__":
# 测试代码
import sys
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
# 创建测试对话框
dialog = UpdateProgressDialog("1.5.55")
dialog.show()
# 模拟完整更新流程
def simulate_update():
# 阶段1模拟下载 (0-50%)
def simulate_download():
total_size = 100 * 1024 * 1024 # 100MB
for i in range(0, 101, 5):
downloaded = int(i * total_size / 100)
dialog.update_progress(downloaded, total_size)
QApplication.processEvents()
QTimer.singleShot(100, lambda: None) # 延迟
# 下载完成
QTimer.singleShot(2000, lambda: dialog.download_finished("/tmp/test.exe"))
# 阶段2模拟UAC授权 (50-60%)
QTimer.singleShot(5000, lambda: dialog.uac_authorized())
simulate_download()
QTimer.singleShot(500, simulate_update)
sys.exit(app.exec_())

216
version_checker.py Normal file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
版本检查器
定期检查后端是否有新版本并通知GUI更新
"""
import threading
import time
import logging
from typing import Callable, Optional
logger = logging.getLogger(__name__)
class VersionChecker:
"""
版本检查器
定期向后端发送版本检查请求,如果有新版本则触发更新回调
"""
def __init__(self, backend_client, update_callback: Optional[Callable] = None):
"""
初始化版本检查器
Args:
backend_client: 后端WebSocket客户端实例
update_callback: 发现新版本时的回调函数 callback(latest_version, download_url)
"""
self.backend_client = backend_client
self.update_callback = update_callback
self.is_running = False
self.check_interval = 600 # 10分钟检查一次
self.thread = None
# 从config获取当前版本
try:
from config import APP_VERSION
self.current_version = APP_VERSION
logger.info(f"✅ 版本检查器初始化成功,当前版本: v{self.current_version}")
except ImportError:
self.current_version = "1.0.0"
logger.warning("⚠️ 无法从config读取APP_VERSION使用默认版本: 1.0.0")
def start(self):
"""启动版本检查线程"""
if self.is_running:
logger.warning("版本检查器已在运行中")
return
self.is_running = True
self.thread = threading.Thread(target=self._check_loop, daemon=True)
self.thread.start()
logger.info(f"✅ 版本检查器已启动,每{self.check_interval}秒检查一次更新")
def stop(self):
"""停止版本检查"""
self.is_running = False
if self.thread:
self.thread.join(timeout=2)
logger.info("版本检查器已停止")
def _check_loop(self):
"""版本检查循环"""
# 等待5秒后首次检查避免启动时立即检查
time.sleep(5)
while self.is_running:
try:
self._perform_version_check()
except Exception as e:
logger.error(f"版本检查失败: {e}")
# 等待下次检查
time.sleep(self.check_interval)
def _perform_version_check(self):
"""执行版本检查"""
try:
if not self.backend_client or not self.backend_client.is_connected:
logger.debug("后端未连接,跳过版本检查")
return
# 向后端发送版本检查请求
message = {
'type': 'version_check',
'current_version': self.current_version,
'client_type': 'gui'
}
logger.debug(f"发送版本检查请求: v{self.current_version}")
self.backend_client.send_message(message)
except Exception as e:
logger.error(f"发送版本检查请求失败: {e}")
def handle_version_response(self, response: dict):
"""
处理后端返回的版本检查响应
Args:
response: 后端响应消息
{
"type": "version_response",
"has_update": true,
"latest_version": "1.5.0",
"current_version": "1.4.7",
"download_url": "https://example.com/download/v1.5.0",
"update_content": "修复了若干Bug"
}
"""
try:
has_update = response.get('has_update', None) # 改为 None便于后续判断
latest_version = response.get('latest_version', '')
current_version = response.get('current_version', self.current_version)
download_url = response.get('download_url', '')
update_content = response.get('update_content', '')
logger.info(f"📡 收到版本检查响应: 当前v{current_version}, 最新v{latest_version}")
# 如果后端没有返回 has_update 字段,自动通过版本比较判断
if has_update is None and latest_version:
# 自动比较版本号
compare_result = self.compare_versions(latest_version, current_version)
has_update = compare_result > 0 # latest > current 表示有更新
logger.info(f"🔍 后端未返回has_update字段通过版本比较判断: {has_update}")
if has_update and latest_version:
logger.info(f"🔔 发现新版本: v{latest_version}")
if update_content:
logger.info(f"📝 更新内容: {update_content[:100]}")
# 触发更新回调
if self.update_callback:
try:
self.update_callback(latest_version, download_url)
except Exception as e:
logger.error(f"执行更新回调失败: {e}")
else:
logger.debug(f"✅ 当前已是最新版本: v{current_version}")
except Exception as e:
logger.error(f"处理版本响应失败: {e}")
def compare_versions(self, version1: str, version2: str) -> int:
"""
比较两个版本号
Args:
version1: 版本1 (如 "1.4.7")
version2: 版本2 (如 "1.5.0")
Returns:
1: version1 > version2
0: version1 == version2
-1: version1 < version2
"""
try:
v1_parts = [int(x) for x in version1.split('.')]
v2_parts = [int(x) for x in version2.split('.')]
# 补齐长度
max_len = max(len(v1_parts), len(v2_parts))
v1_parts.extend([0] * (max_len - len(v1_parts)))
v2_parts.extend([0] * (max_len - len(v2_parts)))
# 逐位比较
for v1, v2 in zip(v1_parts, v2_parts):
if v1 > v2:
return 1
elif v1 < v2:
return -1
return 0
except Exception as e:
logger.error(f"版本比较失败: {e}")
return 0
# 使用示例
if __name__ == '__main__':
# 配置日志
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(message)s'
)
# 模拟使用
class MockBackendClient:
is_connected = True
def send_message(self, msg):
print(f"发送消息: {msg}")
def on_update_available(version, url):
print(f"🔔 发现新版本: {version}, 下载地址: {url}")
client = MockBackendClient()
checker = VersionChecker(client, on_update_available)
checker.start()
# 模拟接收后端响应
time.sleep(2)
response = {
"type": "version_response",
"has_update": True,
"latest_version": "1.5.0",
"current_version": "1.4.7",
"download_url": "https://example.com/download",
"update_content": "修复了若干Bug"
}
checker.handle_version_response(response)
time.sleep(2)
checker.stop()

802
version_history.json Normal file
View File

@@ -0,0 +1,802 @@
[
{
"version": "1.5.71",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.70",
"author": "Gitea Actions Bot",
"commit_hash": "14991ae2aa2c36ee51e4b8ef080d333712e4faac",
"commit_short_hash": "14991ae2",
"branch": "develop",
"release_time": "2025-11-04 16:45:16",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.71.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 17
}
},
{
"version": "1.5.70",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.69",
"author": "Gitea Actions Bot",
"commit_hash": "32a17fb255f3ace34262c8b34679b2129147f01c",
"commit_short_hash": "32a17fb2",
"branch": "jiang",
"release_time": "2025-11-04 14:24:14",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.70.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 17
}
},
{
"version": "1.5.69",
"update_type": "patch",
"content": "[patch] 修复因自动更新回归代码config.py因覆盖逻辑导致的新增代码被覆盖的逻辑",
"author": "kris 郝",
"commit_hash": "4f17092305e1f73e7c19b28d751d5bbaf6895022",
"commit_short_hash": "4f170923",
"branch": "develop",
"release_time": "2025-11-03 15:51:19",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.69.exe",
"stats": {
"files_changed": 1,
"lines_added": 25,
"lines_deleted": 15
}
},
{
"version": "1.5.67",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.66",
"author": "Gitea Actions Bot",
"commit_hash": "2083516b8c66377b5f13df0989fce0eeb82c105e",
"commit_short_hash": "2083516b",
"branch": "develop",
"release_time": "2025-11-03 11:31:45",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.67.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 17
}
},
{
"version": "1.5.66",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.65",
"author": "Gitea Actions Bot",
"commit_hash": "27b15773e0fffa15a65e4a69f3b9cd2097d8c24b",
"commit_short_hash": "27b15773",
"branch": "develop",
"release_time": "2025-10-31 13:49:16",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.66.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 17
}
},
{
"version": "1.5.65",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.64",
"author": "Gitea Actions Bot",
"commit_hash": "5440aa7d6dd075237772087f3c2ded8ebeb0d3b9",
"commit_short_hash": "5440aa7d",
"branch": "develop",
"release_time": "2025-10-30 16:54:42",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.65.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 17
}
},
{
"version": "1.5.64",
"update_type": "patch",
"content": "[patch] 处理DY安装包打包后图片视频暂存路径部分权限问题 处理编码问题 处理js内置环境dll补充",
"author": "haosicheng",
"commit_hash": "30ac26160ba7dbf90b1f17c9b062f3a965987649",
"commit_short_hash": "30ac2616",
"branch": "develop",
"release_time": "2025-10-30 14:58:17",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.64.exe",
"stats": {
"files_changed": 1,
"lines_added": 2,
"lines_deleted": 1
}
},
{
"version": "1.5.60",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.59",
"author": "Gitea Actions Bot",
"commit_hash": "a807bdb74de2cb1934dac1d7b83109407877acd6",
"commit_short_hash": "a807bdb7",
"branch": "develop",
"release_time": "2025-10-30 09:25:26",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.60.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 17
}
},
{
"version": "1.5.59",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.58",
"author": "Gitea Actions Bot",
"commit_hash": "791f98bf61f88e3d9b05e4941f8145a0d9b29f8a",
"commit_short_hash": "791f98bf",
"branch": "develop",
"release_time": "2025-10-29 17:05:23",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.59.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 17
}
},
{
"version": "1.5.58",
"update_type": "patch",
"content": "[patch] 新增自动更新功能 模块 优化更新后打开--不被遮挡",
"author": "haosicheng",
"commit_hash": "e024150c5aaa2d376f7ca371af7f819d6a458ced",
"commit_short_hash": "e024150c",
"branch": "develop",
"release_time": "2025-10-29 15:56:21",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.58.exe",
"stats": {
"files_changed": 1,
"lines_added": 2,
"lines_deleted": 2
}
},
{
"version": "1.5.56",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.55",
"author": "Gitea Actions Bot",
"commit_hash": "8f1b51ef4f7b7a832c06e008afea81a000d144cc",
"commit_short_hash": "8f1b51ef",
"branch": "develop",
"release_time": "2025-10-29 14:58:30",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.56.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 17
}
},
{
"version": "1.5.55",
"update_type": "patch",
"content": "[patch] 新增自动更新功能 模块Test-center",
"author": "haosicheng",
"commit_hash": "16d5d95c4ef1dd29537a718de5c87fb4f2aa4f48",
"commit_short_hash": "16d5d95c",
"branch": "develop",
"release_time": "2025-10-28 17:16:13",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.55.exe",
"stats": {
"files_changed": 2,
"lines_added": 133,
"lines_deleted": 32
}
},
{
"version": "1.5.54",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.53",
"author": "Gitea Actions Bot",
"commit_hash": "3d155fb0976f7efb2233d4e1444ded916b9d962f",
"commit_short_hash": "3d155fb0",
"branch": "develop",
"release_time": "2025-10-28 16:01:33",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.54.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 17
}
},
{
"version": "1.5.53",
"update_type": "patch",
"content": "[patch] 新增DY 图片 视频上传 发送等方法逻辑集成 优化抖音心跳维护与弹性发送心跳包 DY集成内置js环境 PDD取消过滤系统机器消息",
"author": "haosicheng",
"commit_hash": "1f1deb5f7ff9886951f2d996cc39716a1f9d10ac",
"commit_short_hash": "1f1deb5f",
"branch": "develop",
"release_time": "2025-10-27 17:34:09",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.53.exe",
"stats": {
"files_changed": 5,
"lines_added": 1556,
"lines_deleted": 64
}
},
{
"version": "1.5.51",
"update_type": "patch",
"content": "[patch] 因pdd内部回复消息接口与登录接口不同步导致监听过程中发送消息接口返回会话过期处理优化逻辑 并设计开发提示用户 并提供用户是否重新发送会话过期消息体按钮",
"author": "haosicheng",
"commit_hash": "7a1ad474396b151b921f5a55925e63b87f039564",
"commit_short_hash": "7a1ad474",
"branch": "develop",
"release_time": "2025-10-25 09:09:31",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.51.exe",
"stats": {
"files_changed": 3,
"lines_added": 393,
"lines_deleted": 50
}
},
{
"version": "1.5.49",
"update_type": "patch",
"content": "[patch] 重连机制新增is_reconnect参数",
"author": "haosicheng",
"commit_hash": "9f0c81ea82af30d82b9db7ec9b9668f444ba62b5",
"commit_short_hash": "9f0c81ea",
"branch": "develop",
"release_time": "2025-10-23 09:14:58",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.49.exe",
"stats": {
"files_changed": 2,
"lines_added": 8,
"lines_deleted": 4
}
},
{
"version": "1.5.47",
"update_type": "patch",
"content": "[patch] 修复抖音消息格式 过滤抖音系统消息 失效后发送结构体给后端",
"author": "Gitea Actions Bot",
"commit_hash": "782c56f9b634d05dfd7e913017cf57fdf197ff10",
"commit_short_hash": "782c56f9",
"branch": "develop",
"release_time": "2025-10-22 17:14:33",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.47.exe",
"stats": {
"files_changed": 1,
"lines_added": 92,
"lines_deleted": 7
}
},
{
"version": "1.5.46",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.45",
"author": "Gitea Actions Bot",
"commit_hash": "099c3e2db2b367b575477578eb46b018972925ac",
"commit_short_hash": "099c3e2d",
"branch": "develop",
"release_time": "2025-10-22 14:50:54",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.46.exe",
"stats": {
"files_changed": 2,
"lines_added": 18,
"lines_deleted": 2
}
},
{
"version": "1.5.45",
"update_type": "patch",
"content": "[patch] DY-token检测逻辑修改 修改本地打包环境路径锁定",
"author": "haosicheng",
"commit_hash": "ff6203d387aedb0bbe7c7728efaa2a4fdba7d882",
"commit_short_hash": "ff6203d3",
"branch": "develop",
"release_time": "2025-10-22 13:16:48",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.45.exe",
"stats": {
"files_changed": 2,
"lines_added": 141,
"lines_deleted": 59
}
},
{
"version": "1.5.43",
"update_type": "patch",
"content": "[patch] 禁用ks3代理",
"author": "haosicheng",
"commit_hash": "cf72bc68271a7952940bc07b24b5b9f3b73250ae",
"commit_short_hash": "cf72bc68",
"branch": "develop",
"release_time": "2025-10-21 16:19:34",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.43.exe",
"stats": {
"files_changed": 1,
"lines_added": 1,
"lines_deleted": 5
}
},
{
"version": "1.5.38",
"update_type": "patch",
"content": "[patch] 处理PDD登录时get_auth_token方法报出的response返回异常未处理报错问题",
"author": "haosicheng",
"commit_hash": "449ebf83cbf88852ca6918420f8a4e491dcdeaa4",
"commit_short_hash": "449ebf83",
"branch": "develop",
"release_time": "2025-10-18 17:15:36",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.38.exe",
"stats": {
"files_changed": 1,
"lines_added": 2,
"lines_deleted": 0
}
},
{
"version": "1.5.36",
"update_type": "patch",
"content": "[patch] 新增用户余额不足 交互模式代码",
"author": "haosicheng",
"commit_hash": "8eb03ddedcad2d276ad1f2d4aa68252f963633c2",
"commit_short_hash": "8eb03dde",
"branch": "develop",
"release_time": "2025-10-17 17:38:19",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.36.exe",
"stats": {
"files_changed": 4,
"lines_added": 225,
"lines_deleted": 77
}
},
{
"version": "1.5.35",
"update_type": "patch",
"content": "[patch] 新增拼多多处理聊天页商品卡片",
"author": "Gitea Actions Bot",
"commit_hash": "3853b8faf8a55095c133730ce9b080ca3a68b3dd",
"commit_short_hash": "3853b8fa",
"branch": "develop",
"release_time": "2025-10-17 17:05:28",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.35.exe",
"stats": {
"files_changed": 1,
"lines_added": 15,
"lines_deleted": 1
}
},
{
"version": "1.5.34",
"update_type": "patch",
"content": "[patch] config配置修改 ping机制",
"author": "haosicheng",
"commit_hash": "fe2b29da9c483ae82d6e7f6a95c66f561683731f",
"commit_short_hash": "fe2b29da",
"branch": "develop",
"release_time": "2025-10-17 15:50:54",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.34.exe",
"stats": {
"files_changed": 1,
"lines_added": 4,
"lines_deleted": 3
}
},
{
"version": "1.5.33",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.32",
"author": "Gitea Actions Bot",
"commit_hash": "90bf763bded8c6cb163ded90bf014cbdab61c312",
"commit_short_hash": "90bf763b",
"branch": "develop",
"release_time": "2025-10-17 15:21:22",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.33.exe",
"stats": {
"files_changed": 2,
"lines_added": 20,
"lines_deleted": 5
}
},
{
"version": "1.5.32",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.31",
"author": "Gitea Actions Bot",
"commit_hash": "e2ab13d599a6d5484f097826358764c5e3ad1436",
"commit_short_hash": "e2ab13d5",
"branch": "develop",
"release_time": "2025-10-17 11:29:44",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.32.exe",
"stats": {
"files_changed": 2,
"lines_added": 150,
"lines_deleted": 134
}
},
{
"version": "1.5.31",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.30",
"author": "Gitea Actions Bot",
"commit_hash": "e15c1db49b2feb7aee650de5d23681bd9842ede8",
"commit_short_hash": "e15c1db4",
"branch": "develop",
"release_time": "2025-10-15 16:27:08",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.31.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 1
}
},
{
"version": "1.5.30",
"update_type": "patch",
"content": "[patch] 优化提交检测合并逻辑",
"author": "haosicheng",
"commit_hash": "340670742c41a6de65a76fb24b25959c82618793",
"commit_short_hash": "34067074",
"branch": "develop",
"release_time": "2025-10-15 09:55:32",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.30.exe",
"stats": {
"files_changed": 1,
"lines_added": 19,
"lines_deleted": 2
}
},
{
"version": "1.5.28",
"update_type": "patch",
"content": "[patch] 修改打包逻辑 优化js代码因环境不兼容问题处理方案开发",
"author": "haosicheng",
"commit_hash": "9a4e2bba7982129b4c0f305482dac0d0e97dfb24",
"commit_short_hash": "9a4e2bba",
"branch": "develop",
"release_time": "2025-10-14 18:07:38",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.28.exe",
"stats": {
"files_changed": 3,
"lines_added": 5,
"lines_deleted": 5
}
},
{
"version": "1.5.27",
"update_type": "patch",
"content": "[patch] 修正京东丢失客服列表功能",
"author": "Gitea Actions Bot",
"commit_hash": "7312d3f91a5810eae8c6d41688bdc735def78459",
"commit_short_hash": "7312d3f9",
"branch": "develop",
"release_time": "2025-10-14 17:27:26",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.27.exe",
"stats": {
"files_changed": 2,
"lines_added": 82,
"lines_deleted": 1
}
},
{
"version": "1.5.26",
"update_type": "patch",
"content": "[skip ci] Update version to v1.5.25",
"author": "Gitea Actions Bot",
"commit_hash": "5e9e87bbba56a98f27e1d115018c856e1e266f3f",
"commit_short_hash": "5e9e87bb",
"branch": "develop",
"release_time": "2025-10-13 17:07:40",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.26.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 1
}
},
{
"version": "1.5.25",
"update_type": "patch",
"content": "[patch] 修改打包固定项缓存复用 提升打包效率",
"author": "haosicheng",
"commit_hash": "3c57b990649e9061e09f6665f92aa7a09bdcf79f",
"commit_short_hash": "3c57b990",
"branch": "develop",
"release_time": "2025-10-13 15:32:15",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.25.exe",
"stats": {
"files_changed": 2,
"lines_added": 1,
"lines_deleted": 44
}
},
{
"version": "1.5.21",
"update_type": "patch",
"content": "[patch] 修正pdd不识别系统消息的问题",
"author": "Gitea Actions Bot",
"commit_hash": "f651e0e3f02a42f14c86a1d54f366e06cd9b5c49",
"commit_short_hash": "f651e0e3",
"branch": "develop",
"release_time": "2025-10-13 14:04:33",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.21.exe",
"stats": {
"files_changed": 1,
"lines_added": 3259,
"lines_deleted": 3254
}
},
{
"version": "1.5.20",
"update_type": "patch",
"content": "[patch] 修改逻辑: 在用户误触或开了多个GUI程序的时候不能同时建立多个连接 确保一个账号只能建立一个与后端的连接 友好提示的集成review 修改powershell语法问题",
"author": "haosicheng",
"commit_hash": "5896b422f73104cb7b9dba00d19936736f584a4b",
"commit_short_hash": "5896b422",
"branch": "develop",
"release_time": "2025-10-13 13:40:48",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.20.exe",
"stats": {
"files_changed": 1,
"lines_added": 171,
"lines_deleted": 171
}
},
{
"version": "1.5.13",
"update_type": "patch",
"content": "[patch] 修正ks3问题且优化pillow依赖安装时间",
"author": "Gitea Actions Bot",
"commit_hash": "d8154025ea712deafa30b7c0a8434d1ba4c2cab7",
"commit_short_hash": "d8154025",
"branch": "develop",
"release_time": "2025-10-11 17:11:50",
"download_url": "https://shuidrop-chat-server.ks3-cn-guangzhou.ksyuncs.com/installers/ShuiDi_AI_Assistant_Setup_v1.5.13.exe",
"stats": {
"files_changed": 6,
"lines_added": 53,
"lines_deleted": 21
}
},
{
"version": "1.5.12",
"update_type": "patch",
"content": "[patch] 修正ks3问题",
"author": "Gitea Actions Bot",
"commit_hash": "5c73bbf8bce718195e1d4b4f727ede53a3921c1a",
"commit_short_hash": "5c73bbf8",
"branch": "develop",
"release_time": "2025-10-11 16:54:44",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.12.exe",
"stats": {
"files_changed": 3,
"lines_added": 29,
"lines_deleted": 13
}
},
{
"version": "1.5.11",
"update_type": "patch",
"content": "[patch] 修正ks3包名问题",
"author": "Gitea Actions Bot",
"commit_hash": "bcb76c0a99d8ec689610a12d2942bd486e381358",
"commit_short_hash": "bcb76c0a",
"branch": "develop",
"release_time": "2025-10-11 16:44:27",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.11.exe",
"stats": {
"files_changed": 5,
"lines_added": 29,
"lines_deleted": 6
}
},
{
"version": "1.5.10",
"update_type": "patch",
"content": "[patch] 修正Pillow依赖冲突问题",
"author": "Gitea Actions Bot",
"commit_hash": "99446eca748e93c289012a8465a94e4ae7ca8961",
"commit_short_hash": "99446eca",
"branch": "develop",
"release_time": "2025-10-11 16:34:25",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.10.exe",
"stats": {
"files_changed": 3,
"lines_added": 30,
"lines_deleted": 14
}
},
{
"version": "1.5.9",
"update_type": "patch",
"content": "[patch] 修正Pillow安装步骤",
"author": "Gitea Actions Bot",
"commit_hash": "14726466248048dc40e1e4d84119a0bc5da41432",
"commit_short_hash": "14726466",
"branch": "develop",
"release_time": "2025-10-11 16:30:23",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.9.exe",
"stats": {
"files_changed": 3,
"lines_added": 29,
"lines_deleted": 2
}
},
{
"version": "1.5.8",
"update_type": "patch",
"content": "[patch] 安装Pillow依赖",
"author": "Gitea Actions Bot",
"commit_hash": "b685e64e7119e680cb55f323c882e036b08c0784",
"commit_short_hash": "b685e64e",
"branch": "develop",
"release_time": "2025-10-11 16:27:55",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.8.exe",
"stats": {
"files_changed": 3,
"lines_added": 21,
"lines_deleted": 1
}
},
{
"version": "1.5.7",
"update_type": "patch",
"content": "[patch] 测试是否安装成功",
"author": "Gitea Actions Bot",
"commit_hash": "ee7c76ef68dd8e2672ec1a6b984ee65edab583bf",
"commit_short_hash": "ee7c76ef",
"branch": "develop",
"release_time": "2025-10-11 16:24:59",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.7.exe",
"stats": {
"files_changed": 3,
"lines_added": 22,
"lines_deleted": 6
}
},
{
"version": "1.5.6",
"update_type": "patch",
"content": "[patch] 修改nsis安装流程",
"author": "Gitea Actions Bot",
"commit_hash": "afcd360603aa89b41f32ac59c4527103b1aa33f9",
"commit_short_hash": "afcd3606",
"branch": "develop",
"release_time": "2025-10-11 16:20:24",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.6.exe",
"stats": {
"files_changed": 3,
"lines_added": 121,
"lines_deleted": 96
}
},
{
"version": "1.5.5",
"update_type": "patch",
"content": "[patch] 修改nsis安装流程",
"author": "Gitea Actions Bot",
"commit_hash": "3352a59ff9c90d09b080f82e04e06c881becea80",
"commit_short_hash": "3352a59f",
"branch": "develop",
"release_time": "2025-10-11 16:16:08",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.5.exe",
"stats": {
"files_changed": 3,
"lines_added": 96,
"lines_deleted": 54
}
},
{
"version": "1.5.4",
"update_type": "patch",
"content": "[patch] 修改nsis安装流程",
"author": "Gitea Actions Bot",
"commit_hash": "468a092fd93c81e08481b212fd730f11964ff928",
"commit_short_hash": "468a092f",
"branch": "develop",
"release_time": "2025-10-11 16:11:56",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.4.exe",
"stats": {
"files_changed": 3,
"lines_added": 97,
"lines_deleted": 12
}
},
{
"version": "1.5.3",
"update_type": "patch",
"content": "[patch] 解决乱码bug",
"author": "Gitea Actions Bot",
"commit_hash": "7c0667cb39c2795ce9901e141e1a9ff698a4955b",
"commit_short_hash": "7c0667cb",
"branch": "develop",
"release_time": "2025-10-11 16:09:00",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.3.exe",
"stats": {
"files_changed": 3,
"lines_added": 60,
"lines_deleted": 5
}
},
{
"version": "1.5.2",
"update_type": "patch",
"content": "[patch] 解决乱码bug",
"author": "Gitea Actions Bot",
"commit_hash": "2d05024e82ac0c16eff30625195486f7c6862ea8",
"commit_short_hash": "2d05024e",
"branch": "develop",
"release_time": "2025-10-11 16:05:03",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.2.exe",
"stats": {
"files_changed": 5,
"lines_added": 207,
"lines_deleted": 192
}
},
{
"version": "1.5.1",
"update_type": "patch",
"content": "[patch] 添加develop用于测试",
"author": "Gitea Actions Bot",
"commit_hash": "2dddcc9804ad4a6966439f2139110ab639ca1452",
"commit_short_hash": "2dddcc98",
"branch": "develop",
"release_time": "2025-10-11 15:56:40",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.1.exe",
"stats": {
"files_changed": 1,
"lines_added": 1,
"lines_deleted": 1
}
},
{
"version": "1.5.0",
"update_type": "minor",
"content": "实现更新版本管理",
"author": "Gitea Actions Bot",
"commit_hash": "8ecec1edbed34ef40869cf7a0c07fc33e81d9c37",
"commit_short_hash": "8ecec1ed",
"branch": "develop",
"release_time": "2025-10-11 15:47:09",
"download_url": "https://ks3-cn-guangzhou.ksyuncs.com/shuidrop-chat-server/installers/ShuiDi_AI_Assistant_Setup_v1.5.0.exe",
"stats": {
"files_changed": 6,
"lines_added": 265,
"lines_deleted": 168
}
},
{
"version": "1.4.29",
"update_type": "patch",
"content": "[skip ci] Update version to v1.4.28",
"author": "Gitea Actions Bot",
"commit_hash": "2dd384e8e48ed7f142bae43821b08db862c2bcca",
"commit_short_hash": "2dd384e8",
"branch": "develop",
"release_time": "2025-10-11 15:30:32",
"download_url": "https://shuidrop.com/download/gui/ShuiDi_AI_Assistant_Setup_v1.4.29.exe",
"stats": {
"files_changed": 2,
"lines_added": 17,
"lines_deleted": 1
}
},
{
"version": "1.4.28",
"update_type": "patch",
"content": "[patch] PDD登录状态GUI回调优化 与 非原生git指令导致check任务失败",
"author": "haosicheng",
"commit_hash": "c6ae22c7e10ff560a117f7dec0929f43c224d720",
"commit_short_hash": "c6ae22c7",
"branch": "develop",
"release_time": "2025-10-11 10:17:19",
"download_url": "https://shuidrop.com/download/gui/ShuiDi_AI_Assistant_Setup_v1.4.28.exe",
"stats": {
"files_changed": 1,
"lines_added": 0,
"lines_deleted": 1
}
}
]