Compare commits

..

595 Commits

Author SHA1 Message Date
kuaifan
d9132a722f build 2025-08-11 07:03:29 +08:00
kuaifan
ea7a4e46e0 no message 2025-08-11 06:59:38 +08:00
kuaifan
07b91058af perf: 优化粘贴提及消息 2025-08-11 06:55:55 +08:00
kuaifan
c27ace6a6a perf: 优化消息类型的判断 2025-08-11 05:52:09 +08:00
kuaifan
1c0a5b17ca no message 2025-08-11 05:15:49 +08:00
kuaifan
9b12a829d2 perf: 签到记录窗口添加打开签到机器人 2025-08-11 05:15:35 +08:00
kuaifan
0f41172468 perf: 文件名长度限制最长为100字 2025-08-10 21:54:31 +08:00
kuaifan
8597705a77 fix: 无法打包文件加载的情况 2025-08-10 21:00:35 +08:00
kuaifan
3f733ce857 perf: 允许打包下载一个文件夹 2025-08-10 20:14:53 +08:00
kuaifan
40f8ec77b8 no message 2025-08-10 19:48:03 +08:00
kuaifan
0af967d6c9 perf: 优化桌面端出现打开久之后访问错误的情况 2025-08-09 23:38:36 +08:00
kuaifan
f6d43c9f39 perf: 优化桌面端出现打开久之后访问错误的情况 2025-08-09 13:49:53 +08:00
kuaifan
70b0538dd5 no message 2025-08-09 10:03:45 +08:00
kuaifan
439262b930 no message 2025-08-09 09:11:16 +08:00
kuaifan
968b2587ae feat: 添加 setCapsuleConfig 方法以更新胶囊配置 2025-08-09 00:18:57 +08:00
kuaifan
15f471a032 no message 2025-08-08 22:00:54 +08:00
kuaifan
5175157ba6 no message 2025-08-08 18:23:30 +08:00
kuaifan
e51e8f7196 perf: 优化抽屉样式 2025-08-08 17:20:34 +08:00
kuaifan
00b34fda42 no message 2025-08-08 12:45:07 +08:00
kuaifan
b34fabab54 no message 2025-08-08 12:21:09 +08:00
kuaifan
487c7e2824 perf: 更新应用胶囊配置和优化微应用加载 2025-08-08 11:48:22 +08:00
kuaifan
46c79a8772 perf: 更新应用胶囊配置和优化微应用加载 2025-08-08 11:48:17 +08:00
kuaifan
bfb4144e57 perf: 优化 css 语法 2025-08-08 07:58:49 +08:00
kuaifan
dc1bb72070 perf: 优化抽屉窗口 2025-08-07 14:53:09 +08:00
kuaifan
8e084d2362 perf: 优化抽屉窗口 2025-08-07 14:27:00 +08:00
kuaifan
d5a75f887d perf: 优化抽屉窗口 2025-08-07 14:22:00 +08:00
kuaifan
710609e98b perf: 优化微应用 2025-08-06 22:27:32 +08:00
kuaifan
b73ab76bfb perf: 优化微应用 2025-08-06 16:51:21 +08:00
kuaifan
27b64df870 no message 2025-08-06 11:04:22 +08:00
kuaifan
eabb897f96 no message 2025-08-05 19:12:28 +08:00
kuaifan
68c5e47bad feat: 添加应用移动端胶囊布局 2025-08-05 18:38:54 +08:00
kuaifan
2ae5af7019 no message 2025-08-05 11:12:56 +08:00
kuaifan
860d1ca9b3 perf: 优化微应用关闭窗口逻辑 2025-08-05 10:13:00 +08:00
kuaifan
66a9d1f25e no message 2025-08-05 07:57:38 +08:00
kuaifan
bbfeedcdb3 perf: 优化消息重复 2025-08-05 07:09:13 +08:00
kuaifan
079e273edb fix: 修复@弹窗无法滚动 2025-08-05 06:48:20 +08:00
kuaifan
393aab4c4b no message 2025-08-04 22:00:55 +08:00
kuaifan
4f2bf7549c no message 2025-08-04 06:02:22 +08:00
kuaifan
acdf23571c no message 2025-08-01 13:03:03 +08:00
kuaifan
62ec634db3 build 2025-08-01 12:51:36 +08:00
kuaifan
c53e978106 no message 2025-08-01 12:47:49 +08:00
kuaifan
a7fa757d0d fix: 表格消息文字颜色冲突 2025-08-01 12:46:10 +08:00
kuaifan
5fb1bd4175 feat: 添加待办完成状态的支持 2025-08-01 12:33:00 +08:00
kuaifan
e792ab7b4d feat: 工作流支持自定义颜色 2025-08-01 11:27:00 +08:00
kuaifan
02544d29fd no message 2025-08-01 08:23:35 +08:00
kuaifan
20acbd0331 no message 2025-07-31 16:15:18 +08:00
kuaifan
115b4aacb8 fix: 修复无法导出的问题 2025-07-31 15:27:17 +08:00
kuaifan
8746caab06 feat: 重构基础模块 2025-07-31 14:26:06 +08:00
kuaifan
625648c908 feat: 更新请求上下文处理 2025-07-31 11:06:23 +08:00
kuaifan
734b5f9534 build 2025-07-31 07:35:12 +08:00
kuaifan
a0579318bd no message 2025-07-30 21:55:49 +08:00
kuaifan
a437e3cbd3 no message 2025-07-30 21:25:04 +08:00
kuaifan
1b242dc04e perf: 优化错误提示 2025-07-30 20:33:27 +08:00
kuaifan
a1a51914a2 feat: 优化请求上下文处理 2025-07-30 18:57:35 +08:00
kuaifan
f6cab9b5a9 no message 2025-07-30 18:57:35 +08:00
kuaifan
a3649c04e2 perf: 优化应用菜单 2025-07-30 18:57:35 +08:00
kuaifan
a562bfdb08 no message 2025-07-29 21:57:24 +08:00
kuaifan
8ffe64ad8e no message 2025-07-29 17:23:08 +08:00
kuaifan
a116d06d61 no message 2025-07-29 17:15:36 +08:00
kuaifan
c26f73a5a8 no message 2025-07-29 16:58:33 +08:00
kuaifan
f5847a57c1 fix: 修复无法删除webhook的问题 2025-07-29 16:30:30 +08:00
kuaifan
fe9d23a0ff no message 2025-07-29 16:22:37 +08:00
kuaifan
cdc27004bf perf: 优化机器人消息接收处理任务 2025-07-29 15:13:24 +08:00
kuaifan
b914164a77 no message 2025-07-28 10:26:00 +08:00
kuaifan
35e58f90bc perf: 签到新增高德和腾讯地图 2025-07-28 08:46:54 +08:00
kuaifan
16d360c582 perf: 签到新增高德和腾讯地图 2025-07-28 06:22:28 +08:00
kuaifan
4c075b4d11 perf: 签到新增高德和腾讯地图 2025-07-28 06:22:20 +08:00
kuaifan
8c9c1c5afa no message 2025-07-28 05:39:50 +08:00
kuaifan
d093163cd4 perf: 优化国际化 2025-07-26 15:18:00 +08:00
kuaifan
9bd6fcefd3 perf: 优化 AI 设置 2025-07-26 15:14:15 +08:00
kuaifan
5139947643 perf: 优化 AI 设置 2025-07-26 14:24:58 +08:00
kuaifan
01ff10385a perf: 优化 AI 设置 2025-07-26 12:01:37 +08:00
kuaifan
9969c3a7ac perf: 优化应用弹窗
- 优化应用弹窗工具栏
- 优化应用弹窗全屏
2025-07-26 10:45:31 +08:00
kuaifan
f7ed2ec3e3 no message 2025-07-25 23:33:48 +08:00
kuaifan
fedeeb3076 perf: 优化会员选择器 2025-07-25 19:31:24 +08:00
kuaifan
8157c27529 perf: 优化会员搜索接口 2025-07-25 16:11:10 +08:00
kuaifan
0eba0c6a4b perf: 优化提及窗口 2025-07-25 15:52:04 +08:00
kuaifan
13fb9db52b perf: 优化国际化 2025-07-25 14:20:35 +08:00
kuaifan
f6818ba880 perf: 优化机器人消息 2025-07-25 14:06:07 +08:00
kuaifan
dbf3b3cc79 no message 2025-07-25 14:01:41 +08:00
kuaifan
24534069da perf: 优化机器人消息 2025-07-25 14:01:34 +08:00
kuaifan
4cec0a7350 perf: 机器人支持新会话 2025-07-25 11:38:51 +08:00
kuaifan
0b86fa7bee perf: 机器人支持新会话 2025-07-25 11:25:02 +08:00
kuaifan
b406e22695 no message 2025-07-23 19:11:58 +08:00
kuaifan
3fca783dd8 perf: 优化应用方法 2025-07-23 12:04:17 +08:00
kuaifan
6de4865052 fix: 用户头像加载失败的情况 2025-07-22 19:59:05 +08:00
kuaifan
facc2fab24 no message 2025-07-20 20:18:56 +08:00
kuaifan
ddc0931e90 perf: 机器人 webhook 添加用户信息 2025-07-20 20:18:51 +08:00
kuaifan
d5d32038f5 perf: 优化应用 2025-07-19 10:38:59 +08:00
kuaifan
a20edd9bec no message 2025-07-18 16:41:22 +08:00
kuaifan
3da90337ef build 2025-07-18 13:59:48 +08:00
kuaifan
9633f7644e fix: 修复客户度右键复制图片失败的情况 2025-07-18 13:20:08 +08:00
kuaifan
a19cf0e1c3 fix: 修复部分emoji表情无法提交的情况 2025-07-18 13:06:01 +08:00
kuaifan
1a841c4b5d perf: 优化预览消息 2025-07-18 13:05:27 +08:00
kuaifan
937e7ba154 perf: 优化应用参数 2025-07-18 11:13:17 +08:00
kuaifan
4cc0c85a6c perf: 优化应用参数 2025-07-18 09:43:48 +08:00
kuaifan
943941e0f6 perf: 优化应用参数 2025-07-18 08:25:47 +08:00
kuaifan
b160021e67 build 2025-07-17 16:07:08 +08:00
kuaifan
1bcc035979 no message 2025-07-17 16:03:18 +08:00
kuaifan
ef67dc144f fix: 修复机器人发送消息接口 2025-07-16 23:14:45 +08:00
kuaifan
cc96fcd6a0 fix: 修复应用无法在窗口独立显示 2025-07-16 22:38:02 +08:00
kuaifan
d1f00b2d48 fix: 修复机器人发送消息接口 2025-07-16 22:36:49 +08:00
kuaifan
2fe28d2335 build 2025-07-16 13:42:37 +08:00
kuaifan
f9276f4d83 perf: 优化应用 2025-07-16 08:08:22 +08:00
kuaifan
099004a080 no message 2025-07-16 07:18:50 +08:00
kuaifan
cc1df8d7d0 perf: 优化应用 2025-07-15 19:58:08 +08:00
kuaifan
686a2e4fff perf: 优化创建新会话数据 2025-07-15 19:10:00 +08:00
kuaifan
e98fe3eec5 fix: 转发消息同时留言时ai会回复两条的情况 2025-07-15 19:03:46 +08:00
kuaifan
fc34ff38d3 perf: 优化应用 2025-07-15 17:52:06 +08:00
kuaifan
b6b44b3782 no message 2025-07-15 17:33:56 +08:00
kuaifan
c906636776 perf: 优化应用 2025-07-15 17:29:09 +08:00
kuaifan
db282d1a04 perf: 新增使用系统机器人发送消息 2025-07-15 17:26:26 +08:00
kuaifan
898656963d perf: 优化应用中心 2025-07-15 14:26:42 +08:00
kuaifan
6426e0238a fix: 修复应用 {system_theme} 参数无效的问题 2025-07-14 22:57:23 +08:00
kuaifan
08e8faf3ff fix: 修复应用 selectUsers 方法的问题 2025-07-14 22:57:22 +08:00
kuaifan
d21adf6004 perf: 获取我的部门列表接口 2025-07-14 22:29:50 +08:00
kuaifan
c3ac7dd1ab fix: 修复应用地址转换不正确的问题 2025-07-10 20:21:52 +08:00
kuaifan
f1cfba3ad8 build 2025-07-09 22:45:46 +08:00
kuaifan
1ceed3461c perf: 优化应用商城 2025-07-09 22:43:50 +08:00
kuaifan
d25c26156d no message 2025-07-08 20:16:35 +08:00
kuaifan
d75c22114c perf: 优化一些样式 2025-07-08 18:46:54 +08:00
kuaifan
05a754f446 perf: 优化一些样式 2025-07-08 17:18:45 +08:00
kuaifan
b5e6eff65d perf: 优化桌面端服务 2025-07-08 16:34:40 +08:00
kuaifan
eaa8ae66db no message 2025-07-08 11:08:13 +08:00
kuaifan
5e4a08538b perf: 优化标签选择 2025-07-08 10:57:09 +08:00
kuaifan
b01a54437a perf: 优化标签操作日志 2025-07-08 10:41:41 +08:00
kuaifan
5f0fc78f30 perf: 优化标签操作日志 2025-07-08 07:56:15 +08:00
kuaifan
325dc5e2fe fix: 修复修改删除标签未同步任务标签的问题 2025-07-08 07:50:15 +08:00
kuaifan
a15b29122e perf: 支持管理自己创建的标签 2025-07-08 06:50:43 +08:00
kuaifan
074ccc8aab perf: 调整项目最多支持添加50个模板、100个标签 2025-07-08 06:04:56 +08:00
kuaifan
3809046fbc fix: 修复部分屏幕无法完全显示项目管理员菜单 2025-07-08 06:04:10 +08:00
kuaifan
83ceb3264f perf: 优化翻译 2025-07-07 21:42:17 +08:00
kuaifan
9055858d55 perf: 优化邀请加入项目 2025-07-07 21:33:10 +08:00
kuaifan
2a465b5f1d perf: 优化项目邀请链接 2025-07-07 20:46:42 +08:00
kuaifan
b4101f856a fix: 修复项目成员无法认领任务的情况 2025-07-07 20:34:24 +08:00
kuaifan
44baa743c0 perf: 优化聊天发送会员、任务、文件支持搜索ID 2025-07-07 18:10:37 +08:00
kuaifan
46dd449b2f perf: 优化发送消息结果 2025-07-07 17:42:44 +08:00
kuaifan
f21d45e697 perf: 优化通知内容 2025-07-07 14:52:24 +08:00
kuaifan
1e0a19ea7a no message 2025-07-06 11:34:22 +08:00
kuaifan
dcfa47291e perf: 优化群消息推送内容 2025-07-06 11:34:16 +08:00
kuaifan
bd32c9555e build 2025-07-05 13:15:19 +08:00
kuaifan
f8f612544e no message 2025-07-05 10:13:36 +08:00
kuaifan
1c0271f55e no message 2025-07-04 23:17:59 +08:00
kuaifan
a10bc74de1 perf: 优化应用商城
- 支持 background 参数
- iframe 模式添加安全距离
- iframe 支持 dootask/tools
2025-07-04 19:21:31 +08:00
kuaifan
958ef80602 build 2025-06-29 22:12:03 +08:00
kuaifan
124b63f325 perf: 优化客户端缓存 2025-06-29 21:58:43 +08:00
kuaifan
40f5ba5004 fix: 修复客户端无法打开部分应用的问题 2025-06-29 21:57:46 +08:00
kuaifan
32f30826b9 perf: 优化已知问题 2025-06-18 20:25:56 +08:00
kuaifan
b4aa8b37ea perf: 优化iPadOS兼容性 2025-06-18 15:35:37 +08:00
kuaifan
8368bbec47 perf: 优化iPadOS兼容性 2025-06-18 15:35:37 +08:00
kuaifan
618e482507 perf: 优化iPadOS兼容性 2025-06-18 15:35:30 +08:00
kuaifan
43711a1a59 perf: 优化iPadOS兼容性 2025-06-18 15:35:23 +08:00
kuaifan
bbe071545d perf: 优化设备登录 2025-06-17 00:17:04 +08:00
kuaifan
4710479b46 add tmp log 2025-06-16 21:08:42 +08:00
kuaifan
c28a375b5d build 2025-06-05 07:28:49 +08:00
kuaifan
750d3429e0 feat: 微应用支持iframe模式 2025-06-05 07:15:34 +08:00
kuaifan
4c34fe9b85 fix: 修复应用商店参数失效问题 2025-06-04 16:27:40 +08:00
kuaifan
34c56980d4 no message 2025-06-04 15:29:04 +08:00
kuaifan
a5b8609df1 perf: 优化导出签到功能 2025-06-04 15:14:31 +08:00
kuaifan
1fd7f0314a perf: 优化导出审批功能 2025-06-04 15:08:31 +08:00
kuaifan
25e82d690e perf: 优化导出任务功能 2025-06-04 14:58:54 +08:00
kuaifan
31879cb60b build 2025-06-04 07:22:43 +08:00
kuaifan
489e5b551c perf: 优化本地资源加载方式 2025-06-04 07:02:19 +08:00
kuaifan
e29bd01f68 no message 2025-06-03 21:38:53 +08:00
kuaifan
e5d9140aa0 perf: 优化微应用参数变量的支持 2025-06-03 21:38:22 +08:00
kuaifan
09f5cca948 fix: 修复已经在消息中打开项目对话时无法在其他地方打开项目沟通 2025-06-03 20:10:22 +08:00
kuaifan
405e09fdf4 fix: 修复搜索标签后搜索框消失的情况 2025-06-03 14:48:04 +08:00
kuaifan
43624e9b7b fix: 修复部分标签背景色不显示的情况 2025-06-03 14:47:42 +08:00
kuaifan
29ed0ad05a build 2025-06-02 19:34:59 +08:00
kuaifan
0aafe79c65 no message 2025-06-02 18:36:33 +08:00
kuaifan
802afd592c fix: 修复客户端无法打开工作报告 2025-06-02 18:25:19 +08:00
kuaifan
ac1644e32d fix: 修复部分机子无法打开OKR的情况 2025-06-02 18:14:12 +08:00
kuaifan
5a1f130bec fix: 修复客户端无法打开工作报告 2025-06-02 18:13:51 +08:00
kuaifan
678868153a fix: 修复客户端无法打开工作报告 2025-06-02 10:06:22 +08:00
kuaifan
991f050dbb build 2025-05-31 22:53:46 +08:00
kuaifan
be04355685 no message 2025-05-31 10:30:47 +08:00
kuaifan
762b6b3f3b no message 2025-05-30 19:11:19 +08:00
kuaifan
71d9cbdce6 no message 2025-05-30 15:10:13 +08:00
kuaifan
d995ef19b5 no message 2025-05-30 09:25:28 +08:00
Pang
bf80e4b02b feat: 桌面端使用web服务启动 2025-05-30 07:33:14 +08:00
Pang
64a047cd7c feat: 桌面端使用web服务启动 2025-05-30 07:13:19 +08:00
kuaifan
566421003c build 2025-05-29 08:52:24 +08:00
kuaifan
198da7608d no message 2025-05-29 08:49:37 +08:00
kuaifan
0a7edb219e no message 2025-05-29 02:02:21 +08:00
kuaifan
65c20f2211 fix: 修复移动端审批列表无法滚动到底部的情况 2025-05-29 01:51:24 +08:00
kuaifan
144767152a no message 2025-05-29 01:44:06 +08:00
kuaifan
82be52be52 no message 2025-05-29 01:35:28 +08:00
kuaifan
46c8caa627 no message 2025-05-29 01:35:28 +08:00
kuaifan
0027e838a0 no message 2025-05-29 01:35:28 +08:00
kuaifan
7e846e2a58 no message 2025-05-29 01:35:28 +08:00
kuaifan
de7f55bb97 no message 2025-05-29 01:35:28 +08:00
kuaifan
2ac9a59469 no message 2025-05-29 01:35:28 +08:00
kuaifan
bdb4014b94 no message 2025-05-29 01:35:28 +08:00
weifashi
9509bd1510 fix: 修复重复周期 子任务没有复制过去 2025-05-28 22:11:56 +08:00
kuaifan
3f11770baa no message 2025-05-28 15:23:59 +08:00
kuaifan
259a040b7e no message 2025-05-28 11:42:42 +08:00
kuaifan
ccb14630f7 no message 2025-05-28 10:13:05 +08:00
kuaifan
4aa865a60f no message 2025-05-28 07:23:20 +08:00
kuaifan
c7ea7b057c build 2025-05-28 07:05:27 +08:00
kuaifan
91dbbd46c0 no message 2025-05-27 16:34:02 +08:00
kuaifan
e5146077eb no message 2025-05-27 09:18:34 +08:00
kuaifan
76918bf973 no message 2025-05-27 07:32:48 +08:00
kuaifan
b7d3e69f87 no message 2025-05-26 23:44:16 +08:00
kuaifan
d7bccfd267 no message 2025-05-26 23:15:26 +08:00
kuaifan
2a25917e41 no message 2025-05-26 23:04:00 +08:00
kuaifan
d73239b274 build 2025-05-26 22:45:59 +08:00
kuaifan
9f6fffbe6b feat: 新增应用商店 2025-05-26 21:19:51 +08:00
kuaifan
72f7ff3df5 no message 2025-05-26 21:02:06 +08:00
kuaifan
2af1dba8dc no message 2025-05-26 20:54:06 +08:00
kuaifan
ca353d747b no message 2025-05-26 20:26:40 +08:00
kuaifan
1589d4df1c no message 2025-05-26 20:12:51 +08:00
kuaifan
b3abe8af9c no message 2025-05-26 17:45:16 +08:00
kuaifan
c178e36f9b no message 2025-05-26 15:55:42 +08:00
kuaifan
d93092de99 no message 2025-05-26 15:45:37 +08:00
kuaifan
790b05880a no message 2025-05-25 18:26:32 +08:00
kuaifan
f34766ade0 no message 2025-05-25 08:46:25 +08:00
kuaifan
0e1d5e802c no message 2025-05-25 08:09:57 +08:00
kuaifan
db526dfcc8 no message 2025-05-25 00:09:14 +08:00
kuaifan
c18db60e80 no message 2025-05-24 23:48:21 +08:00
kuaifan
b579a6ade2 no message 2025-05-24 21:04:13 +08:00
kuaifan
9d1d642734 no message 2025-05-24 20:28:36 +08:00
kuaifan
261c051052 no message 2025-05-24 19:40:36 +08:00
kuaifan
e499e2d0dc no message 2025-05-24 19:21:26 +08:00
kuaifan
b860b6f389 no message 2025-05-24 19:09:40 +08:00
kuaifan
05d5d5a967 no message 2025-05-24 19:06:14 +08:00
kuaifan
74ba1cc723 no message 2025-05-24 18:26:48 +08:00
kuaifan
f2042efdc2 no message 2025-05-24 18:10:34 +08:00
kuaifan
6b7e7fa1e4 no message 2025-05-24 17:32:40 +08:00
kuaifan
6677e6e74f no message 2025-05-24 16:53:48 +08:00
kuaifan
c3994ddbea no message 2025-05-24 16:05:25 +08:00
kuaifan
a981ff2f6c no message 2025-05-24 15:57:31 +08:00
kuaifan
3e0ba398d4 no message 2025-05-24 15:42:37 +08:00
kuaifan
aa4f7c8536 no message 2025-05-24 14:39:54 +08:00
kuaifan
959f9454d8 no message 2025-05-24 13:30:01 +08:00
kuaifan
6b72a309f5 no message 2025-05-24 07:49:15 +08:00
kuaifan
c388fe373d no message 2025-05-24 07:37:08 +08:00
kuaifan
270ddc6487 feat: 检查应用是否已安装 2025-05-23 13:39:57 +08:00
kuaifan
5ccaa8f106 perf: 更新AI默认模型列表 2025-05-23 13:03:52 +08:00
王昱
1d92c2668d feat: 检查应用是否已安装 2025-05-23 12:40:33 +08:00
kuaifan
6e03a05e6d no message 2025-05-23 11:30:31 +08:00
kuaifan
2905059947 no message 2025-05-23 11:17:05 +08:00
kuaifan
1df927f771 no message 2025-05-22 23:01:07 +08:00
kuaifan
bd8b6d0319 no message 2025-05-21 16:23:11 +08:00
kuaifan
fef39b2720 no message 2025-05-20 20:07:16 +08:00
kuaifan
7de433c5fc no message 2025-05-20 17:53:49 +08:00
kuaifan
0a711f2656 no message 2025-05-18 17:06:11 +08:00
kuaifan
04b6b8aa8a no message 2025-05-18 13:25:13 +08:00
kuaifan
58f286efe4 no message 2025-05-18 11:30:42 +08:00
kuaifan
a01b292eb3 no message 2025-05-18 11:00:06 +08:00
kuaifan
18c608ad7e no message 2025-05-18 00:20:23 +08:00
kuaifan
8d144f4e12 no message 2025-05-17 23:54:24 +08:00
kuaifan
7f895bfbec no message 2025-05-17 23:25:09 +08:00
kuaifan
b0c356fa9b no message 2025-05-17 23:12:47 +08:00
kuaifan
79defdc3f3 no message 2025-05-17 22:46:10 +08:00
kuaifan
b2d9568deb no message 2025-05-17 20:56:54 +08:00
kuaifan
a130c049bf no message 2025-05-17 13:53:07 +08:00
kuaifan
8904039515 no message 2025-05-17 09:52:52 +08:00
kuaifan
7d28181b16 no message 2025-05-17 09:09:59 +08:00
kuaifan
98e4c81b9b no message 2025-05-17 07:09:47 +08:00
kuaifan
10f5af5f09 no message 2025-05-17 02:03:49 +08:00
kuaifan
18ffad5de5 no message 2025-05-16 20:24:43 +08:00
kuaifan
428db42140 no message 2025-05-16 08:23:58 +08:00
kuaifan
6e5426764e no message 2025-05-15 23:16:10 +08:00
kuaifan
f3cfcc650c no message 2025-05-15 23:04:09 +08:00
kuaifan
fc88573c9d no message 2025-05-15 22:53:48 +08:00
kuaifan
5cf1c3d14f no message 2025-05-15 22:44:41 +08:00
kuaifan
79f15cc34d no message 2025-05-15 21:37:18 +08:00
kuaifan
775cab1080 no message 2025-05-15 21:26:14 +08:00
kuaifan
3e20e7d0ce no message 2025-05-15 21:02:42 +08:00
kuaifan
54407e0a60 no message 2025-05-15 20:00:17 +08:00
kuaifan
ef696391d8 no message 2025-05-15 19:42:16 +08:00
kuaifan
0c34df290e no message 2025-05-15 17:43:04 +08:00
kuaifan
04d31bd814 no message 2025-05-15 16:56:08 +08:00
kuaifan
9888d9f59e no message 2025-05-15 16:44:58 +08:00
kuaifan
3bb1bf0967 no message 2025-05-15 16:01:45 +08:00
kuaifan
dfbcb1f45c no message 2025-05-15 15:54:58 +08:00
kuaifan
12ecf4de40 no message 2025-05-15 15:39:03 +08:00
kuaifan
7be1171004 no message 2025-05-15 15:32:52 +08:00
kuaifan
2bb646d150 no message 2025-05-15 15:03:49 +08:00
kuaifan
e7749b2dff no message 2025-05-15 09:22:18 +08:00
kuaifan
434d8eabc8 no message 2025-05-15 08:09:56 +08:00
kuaifan
0a14219112 no message 2025-05-15 00:39:25 +08:00
kuaifan
5b811df8ee no message 2025-05-15 00:20:41 +08:00
kuaifan
bc264109f3 no message 2025-05-14 23:55:00 +08:00
kuaifan
9c29c1ca9b no message 2025-05-13 12:55:08 +08:00
kuaifan
fe4f62ff8d no message 2025-05-13 09:51:21 +08:00
kuaifan
35dfb9d1ff no message 2025-05-13 01:25:24 +08:00
kuaifan
3809aca09d no message 2025-05-12 14:15:42 +08:00
kuaifan
bcd5bb5009 no message 2025-05-12 14:08:53 +08:00
kuaifan
0d7cc6a386 no message 2025-05-12 13:47:05 +08:00
kuaifan
12265699b3 no message 2025-05-12 13:32:20 +08:00
kuaifan
783c0356c7 no message 2025-05-12 13:13:23 +08:00
kuaifan
7ef31bc0b5 no message 2025-05-12 12:14:30 +08:00
kuaifan
467f2368dd no message 2025-05-12 09:49:31 +08:00
kuaifan
2cfcb081a2 no message 2025-05-12 08:33:51 +08:00
kuaifan
1829ac851d no message 2025-05-11 10:07:38 +08:00
kuaifan
2e715004ae no message 2025-05-11 00:39:15 +08:00
kuaifan
45ee092593 no message 2025-05-10 21:31:48 +08:00
kuaifan
20e543f721 no message 2025-05-10 12:32:07 +08:00
kuaifan
b2148eb656 no message 2025-05-09 18:13:45 +08:00
kuaifan
efab4fb41b no message 2025-05-09 10:43:36 +08:00
kuaifan
9ac3fd3615 no message 2025-05-09 10:16:59 +08:00
kuaifan
14bc7a0f76 no message 2025-05-09 09:53:46 +08:00
kuaifan
26727fea17 no message 2025-05-09 07:33:24 +08:00
kuaifan
34f8d4c2a6 no message 2025-05-09 00:54:10 +08:00
kuaifan
02708807bd no message 2025-05-09 00:39:21 +08:00
kuaifan
176e5de531 no message 2025-05-08 23:38:02 +08:00
kuaifan
b9180a4426 no message 2025-05-08 23:02:31 +08:00
kuaifan
959b30c788 no message 2025-05-08 20:36:26 +08:00
kuaifan
df79ef59ea no message 2025-05-08 20:05:46 +08:00
kuaifan
4dc1f01cf0 no message 2025-05-08 20:01:16 +08:00
kuaifan
cb17110562 no message 2025-05-08 19:46:10 +08:00
kuaifan
4424e4f9be no message 2025-05-08 16:46:39 +08:00
kuaifan
fb641ac960 no message 2025-05-08 15:53:30 +08:00
kuaifan
a5faa378b0 no message 2025-05-08 13:07:59 +08:00
kuaifan
66ea277a59 no message 2025-05-08 10:34:47 +08:00
kuaifan
006bc6ceda no message 2025-05-08 07:11:58 +08:00
kuaifan
aef3e869dc no message 2025-05-07 20:28:52 +08:00
kuaifan
9c46d28871 no message 2025-05-07 17:53:21 +08:00
kuaifan
1fc141050f no message 2025-05-07 13:53:12 +08:00
kuaifan
1e45d199e2 no message 2025-05-07 13:32:50 +08:00
kuaifan
3018f3653c no message 2025-05-07 12:21:09 +08:00
kuaifan
1c5b856800 no message 2025-05-07 09:41:25 +08:00
kuaifan
f53a5ea6c1 no message 2025-05-07 08:27:22 +08:00
kuaifan
a608734be9 no message 2025-05-06 23:33:00 +08:00
kuaifan
e52523f903 no message 2025-05-06 22:41:43 +08:00
kuaifan
82778014b8 no message 2025-05-06 22:12:38 +08:00
kuaifan
cb4b9a361f no message 2025-05-06 16:13:37 +08:00
kuaifan
3a337940d1 no message 2025-05-06 16:13:09 +08:00
kuaifan
9abdafb905 no message 2025-05-06 09:55:42 +08:00
kuaifan
cd494b52a4 no message 2025-05-06 09:19:21 +08:00
kuaifan
5581d1431b no message 2025-05-06 04:36:11 +08:00
kuaifan
6539b14ecf no message 2025-05-06 03:37:28 +08:00
kuaifan
45b30e4a33 no message 2025-05-05 12:08:20 +08:00
kuaifan
ff48d543e7 no message 2025-05-05 11:19:47 +08:00
kuaifan
6562b74130 no message 2025-05-05 09:38:23 +08:00
kuaifan
23a68370b4 no message 2025-05-05 08:25:21 +08:00
kuaifan
cc9f346d49 no message 2025-05-05 08:04:09 +08:00
kuaifan
8c18865138 no message 2025-05-05 06:31:01 +08:00
kuaifan
db3a17a2c8 no message 2025-05-05 00:17:45 +08:00
kuaifan
5a0d1ac0c0 no message 2025-05-04 22:45:07 +08:00
kuaifan
5414accc6c no message 2025-05-04 12:57:04 +08:00
kuaifan
c415ace453 no message 2025-05-01 12:30:20 +08:00
kuaifan
bf34beec20 no message 2025-05-01 11:55:15 +08:00
kuaifan
f4d459af7f fix: 修复录音文件转文字后无法切换翻译的问题 2025-05-01 11:49:22 +08:00
kuaifan
508cc2bd91 no message 2025-04-24 22:40:33 +08:00
kuaifan
35b7e3a289 no message 2025-04-24 21:22:04 +08:00
kuaifan
fc907d23a7 no message 2025-04-24 20:40:52 +08:00
kuaifan
45e663fcf8 no message 2025-04-24 20:15:10 +08:00
kuaifan
b00c6a9268 no message 2025-04-24 09:16:18 +08:00
kuaifan
ad7b0cd834 no message 2025-04-24 09:11:41 +08:00
kuaifan
eccb3e2825 no message 2025-04-24 08:57:43 +08:00
kuaifan
f5a343f358 no message 2025-04-24 07:00:09 +08:00
kuaifan
f8b65a5546 no message 2025-04-23 22:46:52 +08:00
kuaifan
0f0b9c5551 no message 2025-04-23 14:50:28 +08:00
kuaifan
41ab11e7b4 build 2025-04-22 23:02:38 +08:00
kuaifan
9949e7c8d4 no message 2025-04-22 22:11:36 +08:00
kuaifan
9e36d84f19 no message 2025-04-22 22:07:39 +08:00
kuaifan
ada526fa63 no message 2025-04-22 21:37:13 +08:00
kuaifan
ca65eb907d no message 2025-04-22 21:04:17 +08:00
kuaifan
7f2a0dd3e8 no message 2025-04-22 20:39:38 +08:00
kuaifan
6e34409225 no message 2025-04-22 18:12:04 +08:00
kuaifan
4af354e918 no message 2025-04-22 16:32:06 +08:00
kuaifan
207d0caf2a no message 2025-04-22 15:56:35 +08:00
kuaifan
f7487d22d5 no message 2025-04-22 15:52:47 +08:00
kuaifan
3c10976aff no message 2025-04-22 15:00:29 +08:00
kuaifan
ec8e144655 no message 2025-04-22 12:35:41 +08:00
kuaifan
ae5ccfd775 no message 2025-04-22 11:48:54 +08:00
kuaifan
9d9e22451d no message 2025-04-22 09:37:48 +08:00
kuaifan
e68daf870f no message 2025-04-22 09:06:29 +08:00
kuaifan
534e95f86c no message 2025-04-22 00:19:36 +08:00
kuaifan
077713003f feat: 添加删除附件日志记录 2025-04-21 22:56:51 +08:00
kuaifan
35fd8e62ac no message 2025-04-21 22:48:26 +08:00
kuaifan
b7c2ddd59d perf: 优化从任务页面发送消息 2025-04-21 21:29:15 +08:00
kuaifan
f931567f56 perf: 优化已归档/已删除任务列表支持按状态检索 2025-04-21 20:14:31 +08:00
kuaifan
8545e0692c fix: 修复任务详情查看历史空白的情况 2025-04-21 19:53:17 +08:00
kuaifan
19eb05269b fix: 修复我的机器人不回复的情况 2025-04-21 18:28:56 +08:00
kuaifan
92d757662a perf: 优化长按消息菜单位置 2025-04-21 14:50:18 +08:00
kuaifan
1b1406a4d9 perf: 优化登录设备名称 2025-04-21 14:49:36 +08:00
kuaifan
03fc19f070 perf: 优化登录设备名称 2025-04-21 14:26:31 +08:00
kuaifan
ffa09f1b29 fix: 修复设待办后数据不立即显示的问题 2025-04-21 13:46:42 +08:00
kuaifan
ff9a1523fd no message 2025-04-21 12:40:49 +08:00
kuaifan
8a7e5c0830 no message 2025-04-21 12:19:20 +08:00
kuaifan
8e90ad69b1 no message 2025-04-21 12:06:47 +08:00
kuaifan
52c389edd8 no message 2025-04-21 12:06:47 +08:00
kuaifan
f7206c1603 no message 2025-04-21 12:06:35 +08:00
kuaifan
86d9baa503 no message 2025-04-21 08:36:49 +08:00
kuaifan
92c4565590 no message 2025-04-21 01:54:50 +08:00
kuaifan
c51870ff79 build 2025-04-21 00:56:08 +08:00
kuaifan
182f061354 no message 2025-04-20 23:10:31 +08:00
kuaifan
80507cab27 no message 2025-04-20 19:37:12 +08:00
kuaifan
f801ae9b63 no message 2025-04-20 17:53:32 +08:00
kuaifan
977173d987 no message 2025-04-20 09:40:46 +08:00
kuaifan
cd0fcb903f no message 2025-04-20 09:19:46 +08:00
kuaifan
7bae1d9537 feat: 新增系统分享搜索功能 2025-04-20 00:24:33 +08:00
kuaifan
b43cbb7afe no message 2025-04-19 22:06:07 +08:00
kuaifan
72982387cc no message 2025-04-19 21:39:02 +08:00
kuaifan
ff0245840a no message 2025-04-19 21:33:10 +08:00
kuaifan
c55f64e209 no message 2025-04-19 21:21:30 +08:00
kuaifan
a4cb5d1b14 no message 2025-04-19 19:48:24 +08:00
kuaifan
13e1415355 no message 2025-04-19 19:11:05 +08:00
kuaifan
7b49d66a8e no message 2025-04-19 19:06:34 +08:00
kuaifan
63c6e12aca no message 2025-04-19 16:57:56 +08:00
kuaifan
b64d4fd96f no message 2025-04-19 07:57:43 +08:00
kuaifan
dda603c7d8 perf: 优化通用菜单 2025-04-19 01:09:29 +08:00
kuaifan
e22de5cba1 no message 2025-04-18 22:46:19 +08:00
kuaifan
bdabfdcb3d perf: 优化视频压缩 2025-04-18 22:28:24 +08:00
kuaifan
00a8514245 no message 2025-04-18 21:44:33 +08:00
kuaifan
94fd3197b3 no message 2025-04-18 20:26:35 +08:00
kuaifan
7957353c3f no message 2025-04-18 19:25:13 +08:00
kuaifan
b3b7589db3 no message 2025-04-18 14:49:05 +08:00
kuaifan
5aed9ce29e perf: 优化全文搜索 2025-04-18 13:56:11 +08:00
kuaifan
924f0a9f7c perf: 优化全文搜索 2025-04-18 12:40:32 +08:00
kuaifan
7a7cd72db9 perf: 优化全文搜索 2025-04-18 11:59:28 +08:00
kuaifan
e9e9bab479 perf: 优化全文搜索 2025-04-18 01:45:03 +08:00
kuaifan
f258dcfca2 perf: 优化全文搜索 2025-04-18 00:46:59 +08:00
kuaifan
fe84f812e7 perf: 优化全文搜索 2025-04-17 22:14:38 +08:00
kuaifan
9eba376976 perf: 优化全文搜索 2025-04-17 21:55:14 +08:00
kuaifan
462705c4ed perf: 优化全文搜索 2025-04-17 16:45:13 +08:00
kuaifan
a2533ce7f9 perf: 优化全文搜索 2025-04-17 13:04:45 +08:00
kuaifan
dbf42c51a4 perf: 优化全文搜索 2025-04-17 12:45:47 +08:00
kuaifan
f61e7caf2b perf: 优化全文搜索 2025-04-17 12:27:21 +08:00
kuaifan
679c2070c1 perf: 优化全文搜索 2025-04-17 11:14:11 +08:00
kuaifan
92d46e1da3 no message 2025-04-17 10:34:32 +08:00
kuaifan
7ab94205e4 no message 2025-04-17 10:09:28 +08:00
kuaifan
ab616c5d32 perf: 优化长按菜单 2025-04-17 09:46:57 +08:00
kuaifan
8f2f68dffc no message 2025-04-17 09:45:48 +08:00
kuaifan
18b7e17e95 no message 2025-04-16 21:34:33 +08:00
kuaifan
cca2298d3a no message 2025-04-16 19:47:39 +08:00
kuaifan
f3683bcc84 no message 2025-04-16 13:00:36 +08:00
kuaifan
fa2959515e no message 2025-04-16 08:49:33 +08:00
kuaifan
7ab5ddc408 no message 2025-04-15 00:24:54 +08:00
kuaifan
f273858248 build 2025-04-15 00:06:25 +08:00
kuaifan
ca8f7374da no message 2025-04-14 23:59:23 +08:00
kuaifan
ff1dce833a no message 2025-04-14 22:08:59 +08:00
kuaifan
d3d5a7bade no message 2025-04-14 19:48:21 +08:00
kuaifan
f5d6702472 no message 2025-04-14 18:23:04 +08:00
kuaifan
3db687ad40 no message 2025-04-14 17:30:59 +08:00
kuaifan
a5cb958398 perf: 优化移动任务 2025-04-14 15:50:20 +08:00
kuaifan
9e522091c6 no message 2025-04-14 15:29:53 +08:00
kuaifan
79f256976e no message 2025-04-14 14:24:00 +08:00
kuaifan
b560c0bafd feat: 新增任务发送功能 2025-04-14 13:40:46 +08:00
kuaifan
bd157d305e fix: 修复调整任务排序后出现空白的情况 2025-04-14 13:22:11 +08:00
kuaifan
923016197a perf: 优化自己的对话不限修改撤回时间 2025-04-14 13:18:38 +08:00
kuaifan
dcf96e2bf5 perf: 优化访问链接 2025-04-14 13:02:04 +08:00
kuaifan
d4697cb203 perf: 优化访问链接 2025-04-14 12:00:54 +08:00
kuaifan
6e6a50b46e no message 2025-04-14 09:39:47 +08:00
kuaifan
b9830bc64a no message 2025-04-14 08:00:02 +08:00
kuaifan
7c501cec45 no message 2025-04-13 14:59:37 +08:00
kuaifan
add23934ca no message 2025-04-13 13:04:03 +08:00
kuaifan
a8b798b00c no message 2025-04-13 11:33:43 +08:00
kuaifan
b522b1de05 no message 2025-04-13 11:19:37 +08:00
kuaifan
3660cbd450 no message 2025-04-13 10:50:47 +08:00
kuaifan
50f8bb8721 feat: 新增会员详情窗口 2025-04-13 10:50:40 +08:00
kuaifan
e1a2d90382 no message 2025-04-13 09:16:19 +08:00
kuaifan
d8872f215b no message 2025-04-13 00:18:39 +08:00
kuaifan
484bc6ea39 no message 2025-04-12 19:45:14 +08:00
kuaifan
7d1979f067 perf: 优化日历 2025-04-12 19:45:06 +08:00
kuaifan
6927c0b30b no message 2025-04-12 18:54:39 +08:00
kuaifan
aa74c5ccaf perf: 优化长按事件 2025-04-12 18:42:27 +08:00
kuaifan
e3d0f571d2 no message 2025-04-12 17:47:43 +08:00
kuaifan
d03dabdfdf perf: 优化日历 2025-04-12 17:40:05 +08:00
kuaifan
fc339ae55f no message 2025-04-12 17:39:23 +08:00
kuaifan
a0aa04fd8c no message 2025-04-12 15:08:33 +08:00
kuaifan
6dc5ae1ae4 perf: 优化移动端任务窗口布局 2025-04-12 13:20:17 +08:00
kuaifan
df02a6b50f no message 2025-04-12 11:55:43 +08:00
kuaifan
9e4f733c28 no message 2025-04-12 09:11:58 +08:00
kuaifan
1175b330f5 no message 2025-04-11 19:17:26 +08:00
kuaifan
3cb9fff07f perf: 优化长按操作 2025-04-11 13:47:53 +08:00
kuaifan
bfdb72dd0a no message 2025-04-11 09:55:18 +08:00
kuaifan
5489462f90 no message 2025-04-11 09:02:16 +08:00
kuaifan
94ac3c3922 no message 2025-04-10 17:24:51 +08:00
kuaifan
bf75946e14 no message 2025-04-10 17:06:12 +08:00
kuaifan
b2a70e0cce perf: 优化转发确认选项保持上一次选择 2025-04-10 17:05:42 +08:00
kuaifan
83780f9bcd feat: 添加从团队管理打开会话窗口 2025-04-10 17:05:03 +08:00
kuaifan
bfb9795913 no message 2025-04-10 16:50:11 +08:00
kuaifan
208598a6df no message 2025-04-10 16:02:01 +08:00
kuaifan
6c79753051 no message 2025-04-10 11:57:56 +08:00
kuaifan
095a238fff perf: 优化移动端布局 2025-04-10 11:29:31 +08:00
kuaifan
ebbde8afd3 perf: 优化移动端布局 2025-04-10 11:13:19 +08:00
kuaifan
bba5bb7411 fix: 修复移动任务时负责人和协助人可以同时选择的情况 2025-04-10 11:06:29 +08:00
kuaifan
9c155c6cf5 perf: 优化禁止选择会员效果 2025-04-10 11:05:50 +08:00
kuaifan
19da7a74df perf: 优化长按菜单位置 2025-04-10 10:47:12 +08:00
kuaifan
f5d76fd5ff perf: 优化移动端打开会话等待效果 2025-04-10 10:40:40 +08:00
kuaifan
77940c9430 perf: 优化长按菜单位置 2025-04-10 07:46:32 +08:00
kuaifan
54a42a14b6 perf: 优化会议弹窗 2025-04-10 07:45:51 +08:00
kuaifan
52faf7884b perf: 任务详情点任务聊天时不要发送消息 2025-04-10 07:27:10 +08:00
kuaifan
841ed4e682 perf: 优化移动端布局 2025-04-09 23:25:48 +08:00
kuaifan
bc417b9eea perf: 优化移动端布局 2025-04-09 19:12:12 +08:00
kuaifan
da7dc477c8 no message 2025-04-09 13:47:26 +08:00
kuaifan
6c519ebd61 no message 2025-04-08 21:43:41 +08:00
kuaifan
88e859817b no message 2025-04-08 15:34:05 +08:00
kuaifan
f5dd36260f perf: 优化国际化 2025-04-08 15:33:08 +08:00
kuaifan
a5325b84ae no message 2025-04-08 15:04:00 +08:00
kuaifan
7095c9e71e no message 2025-04-08 14:43:56 +08:00
kuaifan
fb4373c83a fix: 修复无法从任务页面打开聊天的情况 2025-04-08 14:31:01 +08:00
kuaifan
dd59a1aebb no message 2025-04-08 12:38:30 +08:00
kuaifan
6f7edd0b40 fix: 修复移动端焦点抖动的问题 2025-04-08 08:49:30 +08:00
kuaifan
397421010e build 2025-04-07 23:11:32 +08:00
kuaifan
8872b0519d no message 2025-04-07 23:09:24 +08:00
kuaifan
83d3b3ffbf fix: 修复部分页面出现空白的情况 2025-04-07 20:09:45 +08:00
kuaifan
d1702bd62c no message 2025-04-07 18:26:02 +08:00
kuaifan
928235eac8 fix: 修复输入框无法点击添加链接的情况 2025-04-07 16:22:36 +08:00
kuaifan
3bdfaab158 perf: 优化数据结构 2025-04-07 14:32:24 +08:00
kuaifan
d7902b4d08 perf: 优化数据结构 2025-04-07 14:06:27 +08:00
kuaifan
3334abfb8f no message 2025-04-07 12:16:33 +08:00
kuaifan
3e44e584c0 perf: 优化数据结构 2025-04-07 11:41:01 +08:00
kuaifan
195a305fc3 perf: 优化数据结构 2025-04-07 11:35:19 +08:00
kuaifan
cedffd17b3 perf: 优化图片存储名 2025-04-07 11:10:17 +08:00
kuaifan
59b29014d9 fix: 修复AI机器人不存在的情况 2025-04-07 09:05:17 +08:00
kuaifan
06db036e4a build 2025-04-07 08:49:39 +08:00
kuaifan
617e0837c9 no message 2025-04-07 08:46:09 +08:00
kuaifan
7a275bd802 perf: 优化数据结构 2025-04-07 08:30:06 +08:00
kuaifan
83f58eae68 perf: 优化数据结构 2025-04-07 06:55:35 +08:00
kuaifan
19815fe27d no message 2025-04-07 06:47:59 +08:00
kuaifan
0f75556bed perf: 优化数据结构 2025-04-07 06:15:17 +08:00
kuaifan
dc0f925d24 no message 2025-04-07 01:21:50 +08:00
kuaifan
c5948c4171 feat: 新增转发至AI开启新会话 2025-04-07 01:20:58 +08:00
kuaifan
d144b06c1f perf: 优化数据结构 2025-04-06 23:43:18 +08:00
kuaifan
92dfea677b perf: 优化数据结构 2025-04-06 23:14:20 +08:00
kuaifan
7b5867e2c0 perf: 优化数据结构 2025-04-04 09:18:51 +08:00
kuaifan
b643fe56d5 perf: 优化数据结构 2025-04-04 08:22:18 +08:00
kuaifan
38fa72e9da perf: 优化数据结构 2025-04-03 22:35:46 +08:00
kuaifan
82fddefc94 perf: 优化消息窗口显示 2025-04-03 22:12:29 +08:00
kuaifan
0c9c9cb90a perf: 优化消息窗口显示 2025-04-03 10:01:02 +08:00
kuaifan
38b50a8a84 perf: 优化消息窗口显示 2025-04-02 20:35:03 +08:00
kuaifan
0f250dbafd perf: 优化目录结构 2025-04-02 19:17:03 +08:00
kuaifan
168650649f perf: 优化日历 2025-04-02 18:48:25 +08:00
kuaifan
52babf82ae perf: 优化任务时间范围选择 2025-03-31 23:49:11 +08:00
kuaifan
0c8517667f build 2025-03-30 11:13:39 +08:00
kuaifan
77d105cb9f no message 2025-03-30 10:45:24 +08:00
kuaifan
8af33ea66a perf: 优化消息窗口 2025-03-30 10:13:43 +08:00
kuaifan
a57740e14e perf: 优化消息窗口 2025-03-30 08:56:22 +08:00
kuaifan
82230d70a5 perf: 优化消息窗口 2025-03-30 08:50:50 +08:00
kuaifan
15f3f9c0e5 perf: 优化消息窗口 2025-03-30 07:51:13 +08:00
kuaifan
7fc328492b no message 2025-03-29 22:09:07 +08:00
kuaifan
81cedca590 no message 2025-03-29 14:11:54 +08:00
kuaifan
df4e00e23f perf: 优化消息长按菜单 2025-03-29 14:11:47 +08:00
kuaifan
ad70f23a05 perf: 优化内置浏览器 2025-03-29 13:55:16 +08:00
kuaifan
c93beb27fd no message 2025-03-29 12:44:20 +08:00
kuaifan
41da2231ed no message 2025-03-29 12:33:12 +08:00
kuaifan
9d9213fbdb feat: 添加移动端提示可能要发送的图片 2025-03-29 00:23:49 +08:00
kuaifan
50106d19e8 fix: 修复未读数错误暴增的情况 2025-03-28 19:44:43 +08:00
kuaifan
62d1e676bd perf: 优化App隐私政策提示 2025-03-28 19:35:12 +08:00
kuaifan
0b7d49785c no message 2025-03-28 18:45:09 +08:00
kuaifan
40736c4a05 no message 2025-03-28 18:30:53 +08:00
kuaifan
1f0ab02702 feat: 添加移动端提示可能要发送的图片 2025-03-28 17:01:07 +08:00
kuaifan
21aa4f7b2b fix: 修复地址可能存在localhost的情况 2025-03-28 15:45:13 +08:00
kuaifan
43d0a85061 feat: 添加移动端提示可能要发送的图片 2025-03-28 14:07:33 +08:00
kuaifan
8bdd31ae67 no message 2025-03-28 14:07:26 +08:00
kuaifan
b78f93979d no message 2025-03-27 20:46:12 +08:00
kuaifan
8d6b4a1d2e feat: 添加移动端提示可能要发送的图片 2025-03-27 20:46:03 +08:00
kuaifan
7630c83ae0 perf: 优化对话独立窗口显示 2025-03-27 20:25:51 +08:00
kuaifan
c7c47aff5a fix: 修复消息编辑和发布时序号对不上 2025-03-27 18:09:07 +08:00
kuaifan
72e475cb84 fix: 修复草稿出现上一次内容的情况 2025-03-27 17:03:26 +08:00
kuaifan
f750a6aec2 no message 2025-03-26 23:50:41 +08:00
kuaifan
0dde37e1f1 no message 2025-03-26 18:57:19 +08:00
kuaifan
a2066fc137 no message 2025-03-26 15:26:07 +08:00
kuaifan
f652f35c3a perf: 优化移动端选择交互 2025-03-26 14:17:15 +08:00
kuaifan
562697da27 fix: 修复本地群消息通知没有会员昵称的问题 2025-03-25 18:31:44 +08:00
kuaifan
d23d77ff90 perf: 优化移动端选中消息文本 2025-03-25 18:14:40 +08:00
kuaifan
119443cc88 no message 2025-03-25 17:36:21 +08:00
kuaifan
27ae831799 perf: 优化移动端选中消息文本 2025-03-25 17:35:47 +08:00
kuaifan
b482947207 perf: 优化撤回消息逻辑 2025-03-25 16:39:49 +08:00
kuaifan
6ebc89695a perf: 优化移动端选中消息文本 2025-03-25 16:38:19 +08:00
kuaifan
a65dfec7a8 perf: 优化提及搜索 2025-03-24 21:53:04 +08:00
kuaifan
a0cd79e587 fix: 修复了拉人进群无法踢出去的问题 2025-03-24 21:33:04 +08:00
kuaifan
8fe1e2fee4 no message 2025-03-24 21:13:17 +08:00
kuaifan
3cc9f7bc40 fix: 提及出现白色字的情况 2025-03-24 21:05:25 +08:00
kuaifan
8d3d5025ed no message 2025-03-24 20:39:53 +08:00
kuaifan
a49c0aea47 perf: 优化机器人Webhook消息 2025-03-24 20:34:28 +08:00
kuaifan
d366cf9885 build 2025-03-23 23:24:48 +08:00
kuaifan
be53afe6b4 no message 2025-03-22 19:24:28 +08:00
kuaifan
cdd980112d no message 2025-03-22 19:02:00 +08:00
kuaifan
bca284969d no message 2025-03-22 18:44:09 +08:00
kuaifan
dd899a3e13 feat: 添加我的机器人管理 2025-03-22 18:19:39 +08:00
kuaifan
d6ca66aa2f no message 2025-03-21 22:16:47 +08:00
kuaifan
20ba671cd3 no message 2025-03-21 13:37:27 +08:00
kuaifan
672795ac49 perf: 优化初始化逻辑 2025-03-21 13:37:21 +08:00
kuaifan
9716d7fe43 perf: 优化docker配置 2025-03-21 11:34:09 +08:00
kuaifan
193ad8d902 build 2025-03-21 09:06:41 +08:00
kuaifan
a87f903c50 no message 2025-03-21 09:04:58 +08:00
kuaifan
82f154a229 no message 2025-03-21 00:18:09 +08:00
kuaifan
bee36801ab feat: 新增独立窗口打开会话 2025-03-21 00:09:16 +08:00
kuaifan
37f379c890 no message 2025-03-20 23:18:04 +08:00
kuaifan
88b995ca9c perf: 优化AI支持文件类型 2025-03-20 15:34:31 +08:00
kuaifan
919289c5ca fix: 修复搜索结果显示即将到期 2025-03-20 14:51:13 +08:00
kuaifan
0535b56766 build 2025-03-19 23:37:39 +08:00
kuaifan
6afd413b87 no message 2025-03-19 23:35:02 +08:00
kuaifan
4818409329 perf: 优化AI解析文件 2025-03-19 23:33:17 +08:00
kuaifan
919b652a06 perf: 优化AI解析文件 2025-03-19 22:49:03 +08:00
kuaifan
15d3ec9d81 perf: 优化 WebSocket 消息 2025-03-19 22:01:07 +08:00
kuaifan
e0be6e429e no message 2025-03-19 21:04:05 +08:00
kuaifan
8d24be914d perf: 优化数据 2025-03-19 15:14:23 +08:00
kuaifan
8bbe9c97e9 perf: 优化数据 2025-03-19 12:53:44 +08:00
991 changed files with 26819 additions and 61986 deletions

View File

@@ -17,7 +17,7 @@ LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST="${APP_IPPR}.5"
DB_HOST=mariadb
DB_PORT=3306
DB_DATABASE=dootask
DB_USERNAME=dootask
@@ -34,7 +34,7 @@ SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST="${APP_IPPR}.4"
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ vars.yaml
laravels.conf
laravels.pid
README_LOCAL.md
dootask.lock

View File

@@ -2,6 +2,367 @@
All notable changes to this project will be documented in this file.
## [1.2.5]
### Bug Fixes
- 无法打包文件加载的情况
- 修复@弹窗无法滚动
### Features
- 添加 setCapsuleConfig 方法以更新胶囊配置
- 添加应用移动端胶囊布局
### Performance
- 优化粘贴提及消息
- 优化消息类型的判断
- 签到记录窗口添加打开签到机器人
- 文件名长度限制最长为100字
- 允许打包下载一个文件夹
- 优化桌面端出现打开久之后访问错误的情况
- 优化抽屉样式
- 更新应用胶囊配置和优化微应用加载
- 优化 css 语法
- 优化抽屉窗口
- 优化微应用
- 优化微应用关闭窗口逻辑
- 优化消息重复
## [1.1.66]
### Bug Fixes
- 表格消息文字颜色冲突
- 修复无法导出的问题
### Features
- 添加待办完成状态的支持
- 工作流支持自定义颜色
- 重构基础模块
- 更新请求上下文处理
## [1.1.56]
### Bug Fixes
- 修复无法删除webhook的问题
- 用户头像加载失败的情况
### Features
- 优化请求上下文处理
### Performance
- 优化错误提示
- 优化应用菜单
- 优化机器人消息接收处理任务
- 签到新增高德和腾讯地图
- 优化国际化
- 优化 AI 设置
- 优化应用弹窗
- 优化会员选择器
- 优化会员搜索接口
- 优化提及窗口
- 优化机器人消息
- 机器人支持新会话
- 优化应用方法
- 机器人 webhook 添加用户信息
- 优化应用
## [1.1.15]
### Bug Fixes
- 修复客户度右键复制图片失败的情况
- 修复部分emoji表情无法提交的情况
### Performance
- 优化预览消息
- 优化应用参数
## [1.1.8]
### Bug Fixes
- 修复机器人发送消息接口
- 修复应用无法在窗口独立显示
## [1.1.3]
### Bug Fixes
- 转发消息同时留言时ai会回复两条的情况
- 修复应用 {system_theme} 参数无效的问题
- 修复应用 selectUsers 方法的问题
- 修复应用地址转换不正确的问题
### Performance
- 优化应用
- 优化创建新会话数据
- 新增使用系统机器人发送消息
- 优化应用中心
- 获取我的部门列表接口
## [1.0.88]
### Bug Fixes
- 修复修改删除标签未同步任务标签的问题
- 修复部分屏幕无法完全显示项目管理员菜单
- 修复项目成员无法认领任务的情况
### Performance
- 优化应用商城
- 优化一些样式
- 优化桌面端服务
- 优化标签选择
- 优化标签操作日志
- 支持管理自己创建的标签
- 调整项目最多支持添加50个模板、100个标签
- 优化翻译
- 优化邀请加入项目
- 优化项目邀请链接
- 优化聊天发送会员、任务、文件支持搜索ID
- 优化发送消息结果
- 优化通知内容
- 优化群消息推送内容
## [1.0.65]
### Performance
- 优化应用商城
## [1.0.61]
### Bug Fixes
- 修复客户端无法打开部分应用的问题
### Performance
- 优化客户端缓存
- 优化已知问题
- 优化iPadOS兼容性
- 优化设备登录
## [1.0.51]
### Bug Fixes
- 修复应用商店参数失效问题
### Features
- 微应用支持iframe模式
### Performance
- 优化导出签到功能
- 优化导出审批功能
- 优化导出任务功能
## [1.0.45]
### Bug Fixes
- 修复已经在消息中打开项目对话时无法在其他地方打开项目沟通
- 修复搜索标签后搜索框消失的情况
- 修复部分标签背景色不显示的情况
### Performance
- 优化本地资源加载方式
- 优化微应用参数变量的支持
## [1.0.37]
### Bug Fixes
- 修复客户端无法打开工作报告
- 修复部分机子无法打开OKR的情况
## [1.0.31]
### Bug Fixes
- 修复移动端审批列表无法滚动到底部的情况
- 修复重复周期 子任务没有复制过去
### Features
- 桌面端使用web服务启动
## [1.0.0]
### Bug Fixes
- 修复录音文件转文字后无法切换翻译的问题
### Features
- 新增应用商店
- 检查应用是否已安装
### Performance
- 更新AI默认模型列表
## [0.47.7]
### Bug Fixes
- 修复任务详情查看历史空白的情况
- 修复我的机器人不回复的情况
- 修复设待办后数据不立即显示的问题
### Features
- 添加删除附件日志记录
### Performance
- 优化从任务页面发送消息
- 优化已归档/已删除任务列表支持按状态检索
- 优化长按消息菜单位置
- 优化登录设备名称
## [0.46.74]
### Features
- 新增系统分享搜索功能
### Performance
- 优化通用菜单
- 优化视频压缩
- 优化全文搜索
- 优化长按菜单
## [0.46.16]
### Bug Fixes
- 修复调整任务排序后出现空白的情况
- 修复移动任务时负责人和协助人可以同时选择的情况
- 修复无法从任务页面打开聊天的情况
- 修复移动端焦点抖动的问题
### Features
- 新增任务发送功能
- 新增会员详情窗口
- 添加从团队管理打开会话窗口
### Performance
- 优化移动任务
- 优化自己的对话不限修改撤回时间
- 优化访问链接
- 优化日历
- 优化长按事件
- 优化移动端任务窗口布局
- 优化长按操作
- 优化转发确认选项保持上一次选择
- 优化移动端布局
- 优化禁止选择会员效果
- 优化长按菜单位置
- 优化移动端打开会话等待效果
- 优化会议弹窗
- 任务详情点任务聊天时不要发送消息
- 优化国际化
## [0.45.64]
### Bug Fixes
- 修复部分页面出现空白的情况
- 修复输入框无法点击添加链接的情况
- 修复AI机器人不存在的情况
### Features
- 新增转发至AI开启新会话
### Performance
- 优化数据结构
- 优化图片存储名
- 优化消息窗口显示
- 优化目录结构
- 优化日历
- 优化任务时间范围选择
## [0.45.33]
### Bug Fixes
- 修复未读数错误暴增的情况
- 修复地址可能存在localhost的情况
- 修复消息编辑和发布时序号对不上
- 修复草稿出现上一次内容的情况
- 修复本地群消息通知没有会员昵称的问题
- 修复了拉人进群无法踢出去的问题
- 提及出现白色字的情况
### Features
- 添加移动端提示可能要发送的图片
### Performance
- 优化消息窗口
- 优化消息长按菜单
- 优化内置浏览器
- 优化App隐私政策提示
- 优化对话独立窗口显示
- 优化移动端选择交互
- 优化移动端选中消息文本
- 优化撤回消息逻辑
- 优化提及搜索
- 优化机器人Webhook消息
## [0.44.91]
### Features
- 添加我的机器人管理
### Performance
- 优化初始化逻辑
- 优化docker配置
## [0.44.82]
### Bug Fixes
- 修复搜索结果显示即将到期
### Features
- 新增独立窗口打开会话
### Performance
- 优化AI支持文件类型
## [0.44.74]
### Performance
- 优化AI解析文件
- 优化 WebSocket 消息
- 优化数据
## [0.44.67]
### Bug Fixes

139
README.md
View File

@@ -1,148 +1,147 @@
# Install (Docker)
# DooTask - Open Source Task Management System
English | **[中文文档](./README_CN.md)**
- [Screenshot preview](./README_PREVIEW.md)
- [Demo site](http://www.dootask.com/)
- [Screenshot Preview](./README_PREVIEW.md)
- [Demo Site](http://www.dootask.com/)
**QQ Group**
Group No.: `546574618`
- Group Number: `546574618`
## Setup
## 📍 Migration from 0.x to 1.x
- `Docker v20.10+` & `Docker Compose v2.0+` must be installed
- System: `Centos/Debian/Ubuntu/macOS/Windows`
- Hardware suggestion: 2 cores and above 4G memory
- Special note: Windows users please use `git bash` or `cmder` to run the command
- Please ensure to back up your data before upgrading!
- If the upgrade fails, try running `./cmd update` multiple times.
- If you encounter "Container xxx not found" during upgrade, run `./cmd reup` and then execute `./cmd update`.
- If you see a 502 error after upgrading, run `./cmd reup` to restart the services.
- If you encounter "Application 'xxx' not installed" after upgrading, log in with the admin account and install the relevant applications from the App Store.
### Deployment (Pro Edition)
## Installation Requirements
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems
- Hardware Recommendation: 2+ cores, 4GB+ memory
- Special Note: Windows users can install Linux environment using WSL2 before installing DooTask.
### Deploy Project
```bash
# 1、Clone the repository
# 1、Clone the project to your local machine or server
# Clone projects on github
git clone -b pro --depth=1 https://github.com/kuaifan/dootask.git
# Or you can use gitee
git clone -b pro --depth=1 https://gitee.com/aipaw/dootask.git
# Clone project from GitHub
git clone --depth=1 https://github.com/kuaifan/dootask.git
# Or you can use Gitee
git clone --depth=1 https://gitee.com/aipaw/dootask.git
# 2、Enter directory
cd dootask
# 3、InstallationCustom port installation, as: ./cmd install --port 80
# 3、One-click installation (Custom port installation: ./cmd install --port 80)
./cmd install
```
### Reset password
### Reset Password
```bash
# Reset default account password
# Reset default administrator password
./cmd repassword
```
### Change port
### Change Port
```bash
# This method only replaces the HTTP port. To replace the HTTPS port, please read the SSL configuration below
# This method only changes HTTP port. For HTTPS port, please read SSL configuration below
./cmd port 80
```
### Stop server
### Stop Service
```bash
./cmd stop
# P.S: Once application is set up, whenever you want to start the server (if it is stopped) run below command
./cmd start
./cmd down
```
### Development compilation
- `NodeJs 20+` must be installed
### Start Service
```bash
# Development
./cmd up
```
### Development & Build
Please ensure you have installed `NodeJs 20+`
```bash
# Development mode
./cmd dev
# Production (This is web client. For App/PC/Mac clients, Please read README-CLIENT.md)
# Build project (This is for web client. For desktop apps, refer to ".github/workflows/publish.yml")
./cmd prod
```
### Shortcuts for running command
### SSL Configuration
```bash
# You can do this using the following command
./cmd artisan "your command" # To run a artisan command
./cmd php "your command" # To run a php command
./cmd nginx "your command" # To run a nginx command
./cmd redis "your command" # To run a redis command
./cmd composer "your command" # To run a composer command
./cmd supervisorctl "your command" # To run a supervisorctl command
./cmd mysql "your command" # To run a mysql command (backup: Backup database, recovery: Restore database, open: Open database external port access, close: Close database external port access)
```
### SSL configuration
#### Method 1: Automatic configuration
#### Method 1: Automatic Configuration
```bash
# Running commands in a project
# Run command and follow the prompts
./cmd https
```
#### Or Method 2: Nginx Agent Configuration
#### Method 2: Nginx Proxy Configuration
```bash
# 1、Nginx config add
# 1、Add Nginx proxy configuration
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 2、Running commands in a project (If you unconfigure the NGINX agent, run: ./cmd https close)
# 2、Run command (To cancel Nginx proxy configuration: ./cmd https close)
./cmd https agent
```
## Upgrade
## Upgrade & Update
**Note: Please back up your data before upgrading!**
**Note: Please backup your data before upgrading!**
```bash
# Method 1: Running commands in a project
./cmd update
# Or method 2: use this method if method 1 fails
git pull
./cmd mysql backup
./cmd uninstall
./cmd install
./cmd mysql recovery
```
* Please try again if the upgrade fails across a large version.
* If 502 after the upgrade please run `./cmd restart` restart the service.
* Please retry if upgrade fails across major versions.
* If you encounter 502 errors after upgrade, run `./cmd reup` to restart services.
## Transfer
## Project Migration
Follow these steps to complete the project migration after the new project is installed:
After installing the new project, follow these steps to complete migration:
1. Backup original database
1Backup original database
```bash
# Run command under old project
# Run command in the old project
./cmd mysql backup
```
2. Copy `database backup file` and `public/uploads` directory to the new project.
2Copy the following files and directories from old project to the same paths in new project
3. Restore database to new project
- `Database backup file`
- `docker/appstore`
- `public/uploads`
3、Restore database to new project
```bash
# Run command under new project
# Run command in the new project
./cmd mysql recovery
```
## Uninstall
## Uninstall Project
```bash
# Running commands in a project
./cmd uninstall
```
### More Commands
```bash
./cmd help
```

View File

@@ -1,4 +1,4 @@
# Install (Docker)
# DooTask - 开源任务管理系统
**[English](./README.md)** | 中文文档
@@ -9,22 +9,30 @@
- QQ群号: `546574618`
## 📍 0.x 迁移到 1.x
- 升级时请务必备份好数据!
- 如果升级失败请尝试执行 `./cmd update` 重试几次。
- 如果升级中出现 `没有找到 xxx 容器` 的提示,请运行 `./cmd reup` 后再执行 `./cmd update`
- 如果升级后出现502错误请运行 `./cmd reup` 重启服务即可。
- 如果升级后出现 `应用「xxx」未安装` 的提示,请使用管理员账号进入应用商店安装相关应用。
## 安装程序
- 必须安装:`Docker v20.10+``Docker Compose v2.0+`
- 支持环境:`Centos/Debian/Ubuntu/macOS/Windows`
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统
- 硬件建议2核4G以上
- 特别说明Windows 用户请使用 `git bash` 或者 `cmder` 运行命令
- 特别说明Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
### 部署项目Pro版
### 部署项目
```bash
# 1、克隆项目到您的本地或服务器
# 通过github克隆项目
git clone -b pro --depth=1 https://github.com/kuaifan/dootask.git
git clone --depth=1 https://github.com/kuaifan/dootask.git
# 或者你也可以使用gitee
git clone -b pro --depth=1 https://gitee.com/aipaw/dootask.git
git clone --depth=1 https://gitee.com/aipaw/dootask.git
# 2、进入目录
cd dootask
@@ -50,48 +58,37 @@ cd dootask
### 停止服务
```bash
./cmd stop
./cmd down
```
# 一旦应用程序被设置,无论何时你想要启动服务器(如果它被停止)运行以下命令
./cmd start
### 启动服务
```bash
./cmd up
```
### 开发编译
- 请确保你已经安装了 `NodeJs 20+`
请确保你已经安装了 `NodeJs 20+`
```bash
# 开发模式
./cmd dev
# 编译项目(这是网页端的,App/Pc/Mac客户端请查看 README_CLIENT.md
# 编译项目(这是网页端的,客户端请参考“.github/workflows/publish.yml”文件
./cmd prod
```
### 运行命令的快捷方式
```bash
# 你可以使用以下命令来执行
./cmd artisan "your command" # 运行 artisan 命令
./cmd php "your command" # 运行 php 命令
./cmd nginx "your command" # 运行 nginx 命令
./cmd redis "your command" # 运行 redis 命令
./cmd composer "your command" # 运行 composer 命令
./cmd supervisorctl "your command" # 运行 supervisorctl 命令
./cmd mysql "your command" # 运行 mysql 命令 (backup: 备份数据库recovery: 还原数据库open: 开启数据库外部端口访问close: 关闭数据库外部端口访问)
```
### SSL 配置
#### 方法1自动配置
```bash
# 在项目下运行命令,根据提示执行即可
# 执行指令,根据提示执行即可
./cmd https
```
#### (或者)方法2Nginx 代理配置
#### 方法2Nginx 代理配置
```bash
# 1、Nginx 代理配置添加
@@ -99,7 +96,7 @@ proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 2、在项目下运行命令(如果取消 Nginx 代理配置请运行:./cmd https close
# 2、执行指令(如果取消 Nginx 代理配置请运行:./cmd https close
./cmd https agent
```
@@ -108,19 +105,11 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
**注意:在升级之前请备份好你的数据!**
```bash
# 方法1在项目下运行命令
./cmd update
# 或者方法2如果方法1失败请使用此方法
git pull
./cmd mysql backup
./cmd uninstall
./cmd install
./cmd mysql recovery
```
* 跨越大版本升级失败时请重试执行一次。
* 如果升级后出现502请运行 `./cmd restart` 重启服务即可。
* 如果升级后出现502请运行 `./cmd reup` 重启服务即可。
## 迁移项目
@@ -129,21 +118,30 @@ git pull
1、备份原数据库
```bash
# 在旧的项目下运行命
# 在旧的项目下执行指
./cmd mysql backup
```
2、将`数据库备份文件`及`public/uploads`目录拷贝至新项目
2、将旧项目以下文件和目录拷贝至新项目同路径位置
- `数据库备份文件`
- `docker/appstore`
- `public/uploads`
3、还原数据库至新项目
```bash
# 在新的项目下运行命
# 在新的项目下执行指
./cmd mysql recovery
```
## 卸载项目
```bash
# 在项目下运行命令
./cmd uninstall
```
### 更多指令
```bash
./cmd help
```

View File

@@ -1,254 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Module\ElasticSearch\ElasticSearchKeyValue;
use App\Module\ElasticSearch\ElasticSearchUserMsg;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SyncDialogUserMsgToElasticsearch extends Command
{
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新从上次更新的最后一个ID接上
*
* 清理数据
* --c: 清除索引
*/
protected $signature = 'elasticsearch:sync-dialog-user-msg {--f} {--i} {--c} {--batch=500}';
protected $description = '同步聊天会话用户和消息到Elasticsearch';
protected $es;
/**
* SyncDialogUserMsgToElasticsearch constructor.
*/
public function __construct()
{
parent::__construct();
try {
$this->es = new ElasticSearchUserMsg();
} catch (\Exception $e) {
$this->error('Elasticsearch连接失败: ' . $e->getMessage());
exit(1);
}
}
/**
* @return int
* @throws \Exception
*/
public function handle()
{
$this->info('开始同步聊天数据...');
// 清除索引
if ($this->option('c')) {
$this->info('清除索引...');
if (!$this->es->indexExists()) {
$this->saveLastId(true);
$this->info('索引不存在');
return 0;
}
$result = $this->es->deleteIndex();
if (isset($result['error'])) {
$this->error('删除索引失败: ' . $result['error']);
return 1;
}
$this->saveLastId(true);
$this->info('索引删除成功');
return 0;
}
// 判断创建索引
if (!$this->es->indexExists()) {
$this->info('创建索引...');
$result = ElasticSearchUserMsg::generateIndex();
if (isset($result['error'])) {
$this->error('创建索引失败: ' . $result['error']);
return 1;
}
$this->saveLastId(true);
$this->info('索引创建成功');
}
// 同步用户-会话数据
$this->syncDialogUsers($this->option('batch'));
// 同步消息数据
$this->syncDialogMsgs($this->option('batch'));
// 完成
$this->info("\n同步完成");
return 0;
}
/**
* 保存最后一个ID
* @param string|true $type
* @param integer $lastId
*/
private function saveLastId($type, $lastId = 0)
{
if ($type === true) {
$setting = [];
} else {
$setting = ElasticSearchKeyValue::getArray('elasticSearch:sync');
$setting[$type] = $lastId;
}
ElasticSearchKeyValue::save('elasticSearch:sync', $setting);
}
/**
* 获取最后一个ID
* @param $type
* @return int
*/
private function getLastId($type)
{
if ($this->option('i')) {
$setting = ElasticSearchKeyValue::getArray('elasticSearch:sync');
return intval($setting[$type] ?? 0);
}
return 0;
}
/**
* 同步用户-会话数据(父文档)
* @param $batchSize
* @return void
*/
private function syncDialogUsers($batchSize)
{
$this->info("\n同步用户数据...");
$lastId = $this->getLastId('dialog_user');
$num = 0;
$count = WebSocketDialogUser::where('id', '>', $lastId)->count();
do {
// 获取一批用户-会话关系
$dialogUsers = WebSocketDialogUser::where('id', '>', $lastId)
->orderBy('id')
->limit($batchSize)
->get();
if ($dialogUsers->isEmpty()) {
break;
}
$num += count($dialogUsers);
$progress = round($num / $count * 100, 2);
$this->info("{$num}/{$count} ({$progress}%) 正在同步用户ID {$lastId} ~ {$dialogUsers->last()->id}");
// 批量索引数据
$params = ['body' => []];
foreach ($dialogUsers as $dialogUser) {
$params['body'][] = [
'index' => [
'_index' => ElasticSearchUserMsg::indexName(),
'_id' => ElasticSearchUserMsg::generateUserDicId($dialogUser),
]
];
$params['body'][] = ElasticSearchUserMsg::generateUserFormat($dialogUser);
}
if ($params['body']) {
$result = $this->es->bulk($params);
if (isset($result['errors']) && $result['errors']) {
$this->error('批量索引用户数据部分失败');
Log::error('Elasticsearch批量索引失败: ' . json_encode($result['items']));
}
}
$lastId = $dialogUsers->last()->id;
$this->saveLastId('dialog_user', $lastId);
} while (count($dialogUsers) == $batchSize);
$this->info("同步用户数据结束 - 最后ID {$lastId}");
}
/**
* 同步消息数据(子文档)
*/
private function syncDialogMsgs($batchSize)
{
$this->info("\n同步消息数据...");
$lastId = $this->getLastId('dialog_msg');
$num = 0;
$count = WebSocketDialogMsg::where('id', '>', $lastId)->count();
do {
// 获取一批消息
$dialogMsgs = WebSocketDialogMsg::where('id', '>', $lastId)
->orderBy('id')
->limit($batchSize)
->get();
if ($dialogMsgs->isEmpty()) {
break;
}
$num += count($dialogMsgs);
$progress = round($num / $count * 100, 2);
$this->info("{$num}/{$count} ({$progress}%) 正在同步消息ID {$lastId} ~ {$dialogMsgs->last()->id}");
// 获取这些消息所属的会话对应的所有用户
$dialogIds = $dialogMsgs->pluck('dialog_id')->unique()->toArray();
$userDialogMap = [];
if (!empty($dialogIds)) {
$dialogUsers = WebSocketDialogUser::whereIn('dialog_id', $dialogIds)->get();
foreach ($dialogUsers as $dialogUser) {
$userDialogMap[$dialogUser->dialog_id][] = $dialogUser->userid;
}
}
// 批量索引消息数据
$params = ['body' => []];
foreach ($dialogMsgs as $dialogMsg) {
// 如果该会话没有用户,跳过
if (empty($userDialogMap[$dialogMsg->dialog_id])) {
continue;
}
// 为每个用户-会话关系创建子文档
foreach ($userDialogMap[$dialogMsg->dialog_id] as $userid) {
$params['body'][] = [
'index' => [
'_index' => ElasticSearchUserMsg::indexName(),
'_id' => ElasticSearchUserMsg::generateMsgDicId($dialogMsg, $userid),
'routing' => ElasticSearchUserMsg::generateMsgParentId($dialogMsg, $userid) // 路由到父文档
]
];
$params['body'][] = ElasticSearchUserMsg::generateMsgFormat($dialogMsg, $userid);
}
}
if (!empty($params['body'])) {
// 分批处理
$chunks = array_chunk($params['body'], 1000);
foreach ($chunks as $chunk) {
$chunkParams = ['body' => $chunk];
$result = $this->es->bulk($chunkParams);
if (isset($result['errors']) && $result['errors']) {
$this->error('批量索引消息数据部分失败');
Log::error('Elasticsearch批量索引失败: ' . json_encode($result['items']));
}
}
}
$lastId = $dialogMsgs->last()->id;
$this->saveLastId('dialog_msg', $lastId);
} while (count($dialogMsgs) == $batchSize);
$this->info("同步消息结束 - 最后ID {$lastId}");
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace App\Console\Commands;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\ZincSearch\ZincSearchKeyValue;
use App\Module\ZincSearch\ZincSearchDialogMsg;
use Cache;
use Illuminate\Console\Command;
class SyncUserMsgToZincSearch extends Command
{
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新从上次更新的最后一个ID接上
*
* 清理数据
* --c: 清除索引
*/
protected $signature = 'zinc:sync-user-msg {--f} {--i} {--c} {--batch=1000}';
protected $description = '同步聊天会话用户和消息到 ZincSearch';
/**
* @return int
*/
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「ZincSearch」未安装");
return 1;
}
// 注册信号处理器仅在支持pcntl扩展的环境下
if (extension_loaded('pcntl')) {
pcntl_async_signals(true); // 启用异步信号处理
pcntl_signal(SIGINT, [$this, 'handleSignal']); // Ctrl+C
pcntl_signal(SIGTERM, [$this, 'handleSignal']); // kill
}
// 检查锁,如果已被占用则退出
$lockInfo = $this->getLock();
if ($lockInfo) {
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
return 1;
}
// 设置锁
$this->setLock();
// 清除索引
if ($this->option('c')) {
$this->info('清除索引...');
ZincSearchKeyValue::clear();
ZincSearchDialogMsg::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步聊天数据...');
// 同步消息数据
$this->syncDialogMsgs();
// 完成
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
/**
* 获取锁信息
*
* @return array|null 如果锁存在返回锁信息否则返回null
*/
private function getLock(): ?array
{
$lockKey = md5($this->signature);
return Cache::has($lockKey) ? Cache::get($lockKey) : null;
}
/**
* 设置锁
*/
private function setLock(): void
{
$lockKey = md5($this->signature);
$lockInfo = [
'started_at' => date('Y-m-d H:i:s')
];
Cache::put($lockKey, $lockInfo, 300); // 5分钟
}
/**
* 释放锁
*/
private function releaseLock(): void
{
$lockKey = md5($this->signature);
Cache::forget($lockKey);
}
/**
* 处理终端信号
*
* @param int $signal
* @return void
*/
public function handleSignal(int $signal): void
{
// 释放锁
$this->releaseLock();
exit(0);
}
/**
* 同步消息数据
*
* @return void
*/
private function syncDialogMsgs(): void
{
// 获取上次同步的最后ID
$lastKey = "sync:dialogUserMsgLastId";
$lastId = $this->option('i') ? intval(ZincSearchKeyValue::get($lastKey, 0)) : 0;
if ($lastId > 0) {
$this->info("\n同步消息数据({$lastId}...");
} else {
$this->info("\n同步消息数据...");
}
$num = 0;
$count = WebSocketDialogMsg::where('id', '>', $lastId)->count();
$batchSize = $this->option('batch');
$total = 0;
$lastNum = 0;
do {
// 获取一批
$dialogMsgs = WebSocketDialogMsg::where('id', '>', $lastId)
->orderBy('id')
->limit($batchSize)
->get();
if ($dialogMsgs->isEmpty()) {
break;
}
$num += count($dialogMsgs);
$progress = round($num / $count * 100, 2);
if ($progress < 100) {
$progress = number_format($progress, 2);
}
$this->info("{$num}/{$count} ({$progress}%) 正在同步消息ID {$dialogMsgs->first()->id} ~ {$dialogMsgs->last()->id} ({$total}|{$lastNum})");
// 刷新锁
$this->setLock();
// 同步数据
$lastNum = ZincSearchDialogMsg::batchSync($dialogMsgs);
$total += $lastNum;
// 更新最后ID
$lastId = $dialogMsgs->last()->id;
ZincSearchKeyValue::set($lastKey, $lastId);
} while (count($dialogMsgs) == $batchSize);
$this->info("同步消息结束 - 最后ID {$lastId}");
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Events;
use App\Models\WebSocket;
use App\Services\RequestContext;
use Hhxsv5\LaravelS\Swoole\Events\WorkerStartInterface;
use Swoole\Http\Server;
@@ -25,5 +26,6 @@ class WorkerStartEvent implements WorkerStartInterface
private function handleFirstWorkerTasks()
{
WebSocket::query()->delete();
RequestContext::clearBaseUrlCache();
}
}

View File

@@ -10,13 +10,18 @@ class ApiException extends RuntimeException
*/
protected $data;
/**
* @var bool
*/
protected $writeLog = true;
/**
* ApiException constructor.
* @param string $msg
* @param string|array $msg
* @param array $data
* @param int $code
*/
public function __construct($msg = '', $data = [], $code = 0)
public function __construct($msg = '', $data = [], $code = 0, $writeLog = true)
{
if (is_array($msg) && isset($msg['code'])) {
$code = $msg['code'];
@@ -24,6 +29,7 @@ class ApiException extends RuntimeException
$msg = $msg['msg'];
}
$this->data = $data;
$this->writeLog = $writeLog && $code !== -1;
parent::__construct($msg, $code);
}
@@ -34,4 +40,12 @@ class ApiException extends RuntimeException
{
return $this->data;
}
/**
* @return bool
*/
public function isWriteLog(): bool
{
return $this->writeLog;
}
}

View File

@@ -74,7 +74,7 @@ class Handler extends ExceptionHandler
public function report(Throwable $e)
{
if ($e instanceof ApiException) {
if ($e->getCode() !== -1) {
if ($e->isWriteLog()) {
Log::error($e->getMessage(), [
'code' => $e->getCode(),
'data' => $e->getData(),

View File

@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Api;
use Request;
use Session;
use Response;
use Madzipper;
use Carbon\Carbon;
use App\Module\Down;
use App\Models\User;
use App\Module\Base;
use App\Module\Doo;
@@ -20,8 +20,10 @@ use App\Models\ApproveProcInstHistory;
use App\Exceptions\ApiException;
use App\Models\UserDepartment;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\BillMultipleExport;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Swoole\Coroutine;
/**
* @apiDefine approve
@@ -34,6 +36,7 @@ class ApproveController extends AbstractController
public function __construct()
{
Apps::isInstalledThrow('approve');
$this->flow_url = env('FLOW_URL') ?: 'http://approve';
}
@@ -766,131 +769,192 @@ class ApproveController extends AbstractController
if (Carbon::parse($date[1])->timestamp - Carbon::parse($date[0])->timestamp > 35 * 86400) {
return Base::retError('日期范围限制最大35天');
}
//
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/findAllProcIns', json_encode($data));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) {
return Base::retError($process['message'] ?? '查询失败');
$botUser = User::botGetOrCreate('system-msg');
if (empty($botUser)) {
return Base::retError('系统机器人不存在');
}
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
//
$res = Base::arrayKeyToUnderline($process['data']);
//
$headings = [];
$headings[] = Doo::translate('申请编号');
$headings[] = Doo::translate('标题');
$headings[] = Doo::translate('申请状态');
$headings[] = Doo::translate('发起时间');
$headings[] = Doo::translate('完成时间');
$headings[] = Doo::translate('发起人工号');
$headings[] = Doo::translate('发起人User ID');
$headings[] = Doo::translate('发起人姓名');
$headings[] = Doo::translate('发起人部门');
$headings[] = Doo::translate('发起人部门ID');
$headings[] = Doo::translate('部门负责人');
$headings[] = Doo::translate('历史审批人');
$headings[] = Doo::translate('历史办理人');
$headings[] = Doo::translate('审批记录');
$headings[] = Doo::translate('当前处理人');
$headings[] = Doo::translate('审批节点');
$headings[] = Doo::translate('审批人数');
$headings[] = Doo::translate('审批耗时');
$headings[] = Doo::translate('假期类型');
$headings[] = Doo::translate('开始时间');
$headings[] = Doo::translate('结束时间');
$headings[] = Doo::translate('时长');
$headings[] = Doo::translate('请假事由');
$headings[] = Doo::translate('请假单位');
//
$datas = [];
foreach ($res as $val) {
$doo = Doo::load();
go(function () use ($doo, $data, $user, $botUser, $dialog) {
Coroutine::sleep(1);
//
$nickname = Base::filterEmoji($val['start_user_name']);
$participant = $this->getUserProcessParticipantById($val['id']); // 获取参与人
$participant = $this->handleParticipant($val, $participant['data']); // 处理参与人返回数据
//
$job_number = ''; // 发起人工号
$department_leader = User::userid2nickname(UserDepartment::find(1, ['owner_userid'])['owner_userid']); // 部门负责人
$historical_approver = $participant['historical_approver'] ?? ''; // 历史审批人
$historical_agent = ''; // 历史办理人
$approval_record = $participant['approval_record'] ?? ''; // 审批记录
$current_handler = !$val['is_finished'] ? implode(',', User::whereIn('userid', explode(';', $val['candidate']))->pluck('nickname')->toArray()) : ''; // 当前处理人
$approved_node = $participant['approved_node'] ?? 0; // 审批节点
$approved_num = $participant['approved_num'] ?? 0; // 审批人数
// 计算审批耗时
$startTime = Carbon::parse($val['start_time'])->timestamp;
$endTime = $val['end_time'] ? Carbon::parse($val['end_time'])->timestamp : time();
$approval_time = Doo::translate(Timer::timeDiff($startTime, $endTime)); // 审批耗时
// 计算时长
$varStartTime = Carbon::parse($val['var']['start_time']);
$varEndTime = Carbon::parse($val['var']['end_time']);
$duration = $varEndTime->floatDiffInHours($varStartTime);
$duration_unit = Doo::translate('小时'); // 时长单位
$datas[] = [
$val['id'], // 申请编号
$val['proc_def_name'], // 标题
$this->getStateDescription($val['state']), // 申请状态
$val['start_time'], // 发起时间
$val['end_time'], // 完成时间
$job_number, // 发起人工号
$val['start_user_id'], // 发起人User ID
$nickname, // 发起人姓名
$val['department'], // 发起人部门
$val['department_id'], // 发起人部门ID
$department_leader, // 部门负责人
$historical_approver, // 历史审批人
$historical_agent, // 历史办理人
$approval_record, // 审批记录
$current_handler, // 当前处理人
$approved_node, // 审批节点
$approved_num, // 审批人数
$approval_time, // 审批耗时
$val['var']['type'], // 假期类型
$val['var']['start_time'], // 开始时间
$val['var']['end_time'], // 结束时间
$duration, // 时长
$val['var']['description'], // 请假事由
$duration_unit, // 请假单位
$content = [];
$content[] = [
'content' => '导出审批数据已完成',
'style' => 'font-weight: bold;padding-bottom: 4px;',
];
}
if (empty($datas)) {
return Base::retError('没有任何数据');
}
//
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/findAllProcIns', json_encode($data));
$process = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
if (!$process || $process['status'] != 200) {
$content[] = [
'content' => $process['message'] ?? '查询失败',
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
//
$res = Base::arrayKeyToUnderline($process['data']);
//
$headings = [];
$headings[] = $doo->translate('申请编号');
$headings[] = $doo->translate('标题');
$headings[] = $doo->translate('申请状态');
$headings[] = $doo->translate('发起时间');
$headings[] = $doo->translate('完成时间');
$headings[] = $doo->translate('发起人工号');
$headings[] = $doo->translate('发起人User ID');
$headings[] = $doo->translate('发起人姓名');
$headings[] = $doo->translate('发起人部门');
$headings[] = $doo->translate('发起人部门ID');
$headings[] = $doo->translate('部门负责人');
$headings[] = $doo->translate('历史审批人');
$headings[] = $doo->translate('历史办理人');
$headings[] = $doo->translate('审批记录');
$headings[] = $doo->translate('当前处理人');
$headings[] = $doo->translate('审批节点');
$headings[] = $doo->translate('审批人数');
$headings[] = $doo->translate('审批耗时');
$headings[] = $doo->translate('假期类型');
$headings[] = $doo->translate('开始时间');
$headings[] = $doo->translate('结束时间');
$headings[] = $doo->translate('时长');
$headings[] = $doo->translate('请假事由');
$headings[] = $doo->translate('请假单位');
//
$datas = [];
foreach ($res as $val) {
//
$nickname = Base::filterEmoji($val['start_user_name']);
$participant = $this->getUserProcessParticipantById($val['id']); // 获取参与人
$participant = $this->handleParticipant($val, $participant['data']); // 处理参与人返回数据
//
$job_number = ''; // 发起人工号
$department_leader = User::userid2nickname(UserDepartment::find(1, ['owner_userid'])['owner_userid']); // 部门负责人
$historical_approver = $participant['historical_approver'] ?? ''; // 历史审批人
$historical_agent = ''; // 历史办理人
$approval_record = $participant['approval_record'] ?? ''; // 审批记录
$current_handler = !$val['is_finished'] ? implode(',', User::whereIn('userid', explode(';', $val['candidate']))->pluck('nickname')->toArray()) : ''; // 当前处理人
$approved_node = $participant['approved_node'] ?? 0; // 审批节点
$approved_num = $participant['approved_num'] ?? 0; // 审批人数
// 计算审批耗时
$startTime = Carbon::parse($val['start_time'])->timestamp;
$endTime = $val['end_time'] ? Carbon::parse($val['end_time'])->timestamp : time();
$approval_time = $doo->translate(Timer::timeDiff($startTime, $endTime)); // 审批耗时
// 计算时长
$varStartTime = Carbon::parse($val['var']['start_time']);
$varEndTime = Carbon::parse($val['var']['end_time']);
$duration = $varEndTime->floatDiffInHours($varStartTime);
$duration_unit = $doo->translate('小时'); // 时长单位
$datas[] = [
$val['id'], // 申请编号
$val['proc_def_name'], // 标题
$this->getStateDescription($val['state']), // 申请状态
$val['start_time'], // 发起时间
$val['end_time'], // 完成时间
$job_number, // 发起人工号
$val['start_user_id'], // 发起人User ID
$nickname, // 发起人姓名
$val['department'], // 发起人部门
$val['department_id'], // 发起人部门ID
$department_leader, // 部门负责人
$historical_approver, // 历史审批人
$historical_agent, // 历史办理人
$approval_record, // 审批记录
$current_handler, // 当前处理人
$approved_node, // 审批节点
$approved_num, // 审批人数
$approval_time, // 审批耗时
$val['var']['type'], // 假期类型
$val['var']['start_time'], // 开始时间
$val['var']['end_time'], // 结束时间
$duration, // 时长
$val['var']['description'], // 请假事由
$duration_unit, // 请假单位
];
}
if (empty($datas)) {
$content[] = [
'content' => '没有任何数据',
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
//
$title = $doo->translate("审批记录");
$sheets = [
BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles(["A1:Y1" => ["font" => ["bold" => true]]])
];
//
$fileName = $title . '_' . Timer::time() . '.xlsx';
$filePath = "temp/approve/export/" . date("Ym", Timer::time());
$export = new BillMultipleExport($sheets);
$res = $export->store($filePath . "/" . $fileName);
if ($res != 1) {
$content[] = [
'content' => "导出失败,{$fileName}",
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xlsx') . ".zip";
$zipPath = storage_path($zipFile);
if (file_exists($zipPath)) {
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
} catch (\Throwable) {
}
//
if (file_exists($zipPath)) {
$key = Down::cache_encode([
'file' => $zipFile,
]);
$fileUrl = Base::fillUrl('api/approve/down?key=' . $key);
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'file_download',
'title' => '导出审批数据已完成',
'name' => $fileName,
'size' => filesize($zipPath),
'url' => $fileUrl,
], $botUser->userid, true, false, true);
} else {
$content[] = [
'content' => "打包失败,请稍后再试...",
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
}
});
//
$title = Doo::translate("审批记录");
$sheets = [
BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles(["A1:Y1" => ["font" => ["bold" => true]]])
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'content' => '正在导出审批数据,请稍等...',
], $botUser->userid, true, false, true);
//
$fileName = $title . '_' . Timer::time() . '.xlsx';
$filePath = "temp/approve/export/" . date("Ym", Timer::time());
$export = new BillMultipleExport($sheets);
$res = $export->store($filePath . "/" . $fileName);
if ($res != 1) {
return Base::retError('导出失败,' . $fileName . '');
}
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xlsx') . ".zip";
$zipPath = storage_path($zipFile);
if (file_exists($zipPath)) {
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
} catch (\Throwable) {
}
//
if (file_exists($zipPath)) {
$base64 = base64_encode(Base::array2string([
'file' => $zipFile,
]));
Session::put('approve::export:userid', $user->userid);
return Base::retSuccess('success', [
'size' => Base::twoFloat(filesize($zipPath) / 1024, true),
'url' => Base::fillUrl('api/approve/down?key=' . urlencode($base64)),
]);
} else {
return Base::retError('打包失败,请稍后再试...');
}
return Base::retSuccess('success');
}
function getStateDescription($state)
@@ -918,15 +982,10 @@ class ApproveController extends AbstractController
*/
public function down()
{
$userid = Session::get('approve::export:userid');
if (empty($userid)) {
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
}
//
$array = Base::string2array(base64_decode(urldecode(Request::input('key'))));
$array = Down::cache_decode();
$file = $array['file'];
if (empty($file) || !file_exists(storage_path($file))) {
return Base::ajaxError("文件不存在!", [], 0, 502);
return Base::ajaxError("文件不存在!", [], 0, 403);
}
return Response::download(storage_path($file));
}

View File

@@ -7,6 +7,7 @@ use Request;
use Redirect;
use Carbon\Carbon;
use App\Tasks\PushTask;
use App\Module\AI;
use App\Module\Doo;
use App\Models\File;
use App\Models\User;
@@ -14,11 +15,10 @@ use App\Module\Base;
use App\Module\Timer;
use App\Models\Setting;
use App\Module\Extranet;
use App\Module\ElasticSearch\ElasticSearchUserMsg;
use App\Module\TimeRange;
use App\Module\MsgTool;
use App\Module\Table\OnlineData;
use App\Models\FileContent;
use App\Models\ProjectTask;
use App\Models\AbstractModel;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
@@ -28,6 +28,9 @@ use App\Models\WebSocketDialogMsgRead;
use App\Models\WebSocketDialogMsgTodo;
use App\Models\WebSocketDialogMsgTranslate;
use App\Models\WebSocketDialogSession;
use App\Module\Table\OnlineData;
use App\Module\ZincSearch\ZincSearchDialogMsg;
use App\Tasks\BotReceiveMsgTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
@@ -119,34 +122,11 @@ class DialogController extends AbstractController
return Base::retError('请输入搜索关键词');
}
// 搜索会话
$list = DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->where('u.userid', $user->userid)
->where('d.name', 'LIKE', "%{$key}%")
->whereNull('d.deleted_at')
->orderByDesc('u.top_at')
->orderByDesc('u.last_at')
->take(20)
->get()
->map(function($item) use ($user) {
return WebSocketDialog::synthesizeData($item, $user->userid);
})
->all();
$take = 20;
$list = WebSocketDialog::searchDialog($user->userid, $key, $take);
// 搜索联系人
if (count($list) < 20 && Base::judgeClientVersion("0.21.60")) {
$users = User::select(User::$basicField)
->where(function ($query) use ($key) {
if (str_contains($key, "@")) {
$query->where("email", "like", "%{$key}%");
} else {
$query->where("nickname", "like", "%{$key}%")
->orWhere("pinyin", "like", "%{$key}%")
->orWhere("profession", "like", "%{$key}%");
}
})->orderBy('userid')
->take(20 - count($list))
->get();
if (count($list) < $take && Base::judgeClientVersion("0.21.60")) {
$users = User::searchUser($key, $take - count($list));
$users->transform(function (User $item) use ($user) {
$id = 'u:' . $item->userid;
$lastAt = null;
@@ -172,8 +152,8 @@ class DialogController extends AbstractController
$list = array_merge($list, $users->toArray());
}
// 搜索消息会话
if (count($list) < 20) {
$searchResults = ElasticSearchUserMsg::searchByKeyword($user->userid, $key, 20 - count($list));
if (count($list) < $take) {
$searchResults = ZincSearchDialogMsg::search($user->userid, $key, 0, $take - count($list));
if ($searchResults) {
foreach ($searchResults as $item) {
if ($dialog = WebSocketDialog::find($item['id'])) {
@@ -241,17 +221,10 @@ class DialogController extends AbstractController
//
$dialog_id = intval(Request::input('dialog_id'));
//
$item = DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->where('u.userid', $user->userid)
->where('d.id', $dialog_id)
->whereNull('d.deleted_at')
->first();
if (empty($item)) {
return Base::retError('不在成员列表内');
}
return Base::retSuccess('success', WebSocketDialog::synthesizeData($item, $user->userid));
$dialog = WebSocketDialog::checkDialog($dialog_id);
$data = WebSocketDialog::synthesizeData($dialog, $user->userid);
//
return Base::retSuccess('success', $data);
}
/**
@@ -358,8 +331,6 @@ class DialogController extends AbstractController
]);
}
/**
* @api {get} api/dialog/hide 09. 会话隐藏
*
@@ -574,6 +545,7 @@ class DialogController extends AbstractController
//
if ($list->isNotEmpty()) {
$list->transform(function (WebSocketDialogMsg $item) {
$item->todo_done = $item->isTodoDone();
$item->next_id = 0;
$item->prev_id = 0;
return $item;
@@ -678,15 +650,18 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/search 14. 搜索消息位置
* @api {get} api/dialog/msg/search 14. 搜索消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__search
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {String} key 搜索关键词
* @apiParam {Number} [dialog_id] 对话ID存在则搜索消息在对话的位置
* @apiParam {Number} [take] 搜索数量
* - dialog_id > 0, 默认:200最大:200
* - dialog_id <= 0, 默认:20最大:50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -694,63 +669,42 @@ class DialogController extends AbstractController
*/
public function msg__search()
{
User::auth();
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
$key = trim(Request::input('key'));
$dialogId = intval(Request::input('dialog_id'));
//
if (empty($key)) {
return Base::retError('关键词不能为空');
}
//
WebSocketDialog::checkDialog($dialog_id);
//
$data = WebSocketDialogMsg::whereDialogId($dialog_id)
->where('key', 'LIKE', "%{$key}%")
->take(200)
->pluck('id');
return Base::retSuccess('success', compact('data'));
}
/**
* @api {get} api/dialog/msg/esearch 15. 搜索消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__esearch
*
* @apiParam {String} key 搜索关键词
* @apiParam {Number} [pagesize] 每页显示数量,默认:20最大:50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__esearch()
{
$user = User::auth();
//
$key = trim(Request::input('key'));
$list = [];
//
$searchResults = ElasticSearchUserMsg::searchByKeyword($user->userid, $key, Base::getPaginate(50, 20));
if ($searchResults) {
foreach ($searchResults as $item) {
if ($dialog = WebSocketDialog::find($item['id'])) {
$dialog = array_merge($dialog->toArray(), $item);
$list[] = WebSocketDialog::synthesizeData($dialog, $user->userid);
if ($dialogId > 0) {
// 搜索位置
WebSocketDialog::checkDialog($dialogId);
//
$data = WebSocketDialogMsg::whereDialogId($dialogId)
->where('key', 'LIKE', "%{$key}%")
->take(Base::getPaginate(200, 200, 'take'))
->pluck('id');
return Base::retSuccess('success', compact('data'));
} else {
// 搜索消息
$list = [];
$searchResults = ZincSearchDialogMsg::search($user->userid, $key, 0, Base::getPaginate(50, 20, 'take'));
if ($searchResults) {
foreach ($searchResults as $item) {
if ($dialog = WebSocketDialog::find($item['id'])) {
$dialog = array_merge($dialog->toArray(), $item);
$list[] = WebSocketDialog::synthesizeData($dialog, $user->userid);
}
}
}
return Base::retSuccess('success', ['data' => $list]);
}
//
return Base::retSuccess('success', [
'data' => $list,
]);
}
/**
* @api {get} api/dialog/msg/one 16. 获取单条消息
* @api {get} api/dialog/msg/one 15. 获取单条消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -779,7 +733,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/dot 17. 聊天消息去除点
* @api {get} api/dialog/msg/dot 16. 聊天消息去除点
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -812,7 +766,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/read 18. 已读聊天消息
* @api {get} api/dialog/msg/read 17. 已读聊天消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -883,7 +837,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/unread 19. 获取未读消息数据
* @api {get} api/dialog/msg/unread 18. 获取未读消息数据
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -926,7 +880,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/checked 20. 设置消息checked
* @api {get} api/dialog/msg/checked 19. 设置消息checked
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -992,7 +946,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/stream 21. 通知成员监听消息
* @api {post} api/dialog/msg/stream 20. 通知成员监听消息
*
* @apiDescription 通知指定会员EventSource监听流动消息
* @apiVersion 1.0.0
@@ -1036,7 +990,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendtext 22. 发送消息
* @api {post} api/dialog/msg/sendtext 21. 发送消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1089,11 +1043,11 @@ class DialogController extends AbstractController
$dialogIds = $dialog_ids ? explode(',', $dialog_ids) : [$dialog_id ?: 0];
foreach ($dialogIds as $dialog_id) {
//
WebSocketDialog::checkDialog($dialog_id);
$dialog = WebSocketDialog::checkDialog($dialog_id);
//
if ($update_id > 0) {
$action = $update_mark ? "update-$update_id" : "change-$update_id";
if (!($user->bot || $user->isAdmin())) {
if (!$user->bot && !$dialog->isSelfDialog()) {
Setting::validateMsgLimit('edit', $update_id);
}
} elseif ($reply_id > 0) {
@@ -1181,7 +1135,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendnotice 23. 发送通知
* @api {post} api/dialog/msg/sendnotice 22. 发送通知
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1234,7 +1188,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendtemplate 24. 发送模板消息
* @api {post} api/dialog/msg/sendtemplate 23. 发送模板消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1303,7 +1257,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendrecord 25. 发送语音
* @api {post} api/dialog/msg/sendrecord 24. 发送语音
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1351,7 +1305,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/convertrecord 26. 录音转文字
* @api {post} api/dialog/msg/convertrecord 25. 录音转文字
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1404,26 +1358,27 @@ class DialogController extends AbstractController
'prompt' => "将此语音识别为“" . Doo::getLanguages($language) . "”。",
];
}
$result = Extranet::openAItranscriptions($recordData['file'], $extParams);
$result = AI::transcriptions($recordData['file'], $extParams);
if (Base::isError($result)) {
return $result;
}
if (strlen($result['data']) < 1) {
if (strlen($result['data']['text']) < 1) {
return Base::retError('转文字失败');
}
// 翻译
if ($translate) {
$result = Extranet::openAItranslations($result['data'], Doo::getLanguages($translate));
if (Base::isError($result)) {
return $result;
}
// 翻译
if (!$translate) {
return Base::retSuccess('success', $result['data']['text']);
}
// 返回
return $result;
// 需要翻译
$result = AI::translations($result['data']['text'], Doo::getLanguages($translate));
if (Base::isError($result)) {
return $result;
}
return Base::retSuccess('success', $result['data']['translated_text']);
}
/**
* @api {post} api/dialog/msg/sendfile 27. 文件上传
* @api {post} api/dialog/msg/sendfile 26. 文件上传
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1455,7 +1410,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendfiles 28. 群发文件上传
* @api {post} api/dialog/msg/sendfiles 27. 群发文件上传
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1511,7 +1466,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/sendfileid 29. 通过文件ID发送文件
* @api {get} api/dialog/msg/sendfileid 28. 通过文件ID发送文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1550,6 +1505,45 @@ class DialogController extends AbstractController
return WebSocketDialogMsg::sendMsgBatch($user, $userids, $dialogids, $fileMsg);
}
/**
* @api {get} api/dialog/msg/sendtaskid 29. 通过任务ID发送任务
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__sendtaskid
*
* @apiParam {Number} task_id 消息ID
* @apiParam {Array} dialogids 转发给的对话ID
* @apiParam {Array} userids 转发给的成员ID
* @apiParam {String} leave_message 转发留言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__sendtaskid()
{
$user = User::auth();
//
$task_id = intval(Request::input("task_id"));
$dialogids = Request::input('dialogids');
$userids = Request::input('userids');
$leave_message = Request::input('leave_message');
//
if (empty($dialogids) && empty($userids)) {
return Base::retError("请选择对话或成员");
}
//
$task = ProjectTask::userTask($task_id, null);
$taskMsg = "<p><span class=\"mention task\" data-id=\"{$task_id}\">#{$task->name}</span></p>";
if ($leave_message) {
$taskMsg .= "<p>{$leave_message}</p>";
}
//
return WebSocketDialogMsg::sendMsgBatch($user, $userids, $dialogids, $taskMsg);
}
/**
* @api {post} api/dialog/msg/sendanon 30. 发送匿名消息
*
@@ -1607,7 +1601,89 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendlocation 31. 发送位置消息
* @api {post} api/dialog/msg/sendbot 31. 发送机器人消息
*
* @apiDescription 需要token身份通过机器人发送消息给指定用户
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__sendbot
*
* @apiParam {Number} userid 对方会员ID
* @apiParam {String} text 消息内容markdown格式
* @apiParam {String} [bot_type] 机器人类型
* - system-msg: 系统消息(默认)
* - task-alert: 任务提醒
* - check-in: 签到打卡
* - approval-alert: 审批
* - meeting-alert: 会议通知
* - xxxxxx: 其他机器人xxxxxx 是任意6-20个字符串如果不存在则自动创建
* @apiParam {String} [bot_name] 机器人名称bot_type 为 xxxxxx 时有效)
* @apiParam {Boolean} [silence] 静默发送
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__sendbot()
{
$user = User::auth();
//
$userid = intval(Request::input('userid'));
$text = trim(Request::input('text'));
$botType = trim(Request::input('bot_type', 'system-msg'));
$botName = trim(Request::input('bot_name'));
$silence = Request::input('silence', false);
//
$toUser = User::whereUserid($userid)->first();
if (empty($toUser) || $toUser->bot) {
return Base::retError("机器人消息仅允许发送给个人");
}
if ($toUser->isDisable()) {
return Base::retError("对方已离职");
}
$strlen = mb_strlen($text);
if ($strlen < 1) {
return Base::retError('消息内容不能为空');
}
if ($strlen > 2000) {
return Base::retError('消息内容最大不能超过2000字');
}
//
$botUpdate = [];
if (!in_array($botType, [
'system-msg',
'task-alert',
'check-in',
'approval-alert',
'meeting-alert',
'bot-manager',
])) {
if (strlen($botType) < 6 || strlen($botType) > 20) {
return Base::retError("机器人类型由6-20个字符组成。");
}
if ($botName && (strlen($botName) < 2 || strlen($botName) > 20)) {
return Base::retError("机器人名称由2-20个字符组成。");
}
$botType = 'user-auto-' . $botType;
$botUpdate['nickname'] = $botName;
}
$botUser = User::botGetOrCreate($botType, $botUpdate, $user->userid);
if (empty($botUser)) {
return Base::retError('机器人不存在');
}
$dialog = WebSocketDialog::checkUserDialog($botUser, $toUser->userid);
if (empty($dialog)) {
return Base::retError('机器人会话不存在');
}
$msgData = [
'type' => 'md',
'text' => $text,
];
return WebSocketDialogMsg::sendMsg(null, $dialog->id, 'text', $msgData, $botUser->userid, false, false, $silence);
}
/**
* @api {post} api/dialog/msg/sendlocation 32. 发送位置消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1616,7 +1692,9 @@ class DialogController extends AbstractController
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {String} type 位置类型
* - bd: 百度地图
* - baidu: 百度地图
* - amap: 高德地图
* - tencent: 腾讯地图
* @apiParam {Number} lng 经度
* @apiParam {Number} lat 纬度
* @apiParam {String} title 位置名称
@@ -1651,7 +1729,7 @@ class DialogController extends AbstractController
//
WebSocketDialog::checkDialog($dialog_id);
//
if ($type == 'bd') {
if (in_array($type, ['baidu', 'amap', 'tencent'])) {
$msgData = [
'type' => $type,
'lng' => $lng,
@@ -1667,7 +1745,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/readlist 32. 获取消息阅读情况
* @api {get} api/dialog/msg/readlist 33. 获取消息阅读情况
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1696,7 +1774,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/detail 33. 消息详情
* @api {get} api/dialog/msg/detail 34. 消息详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1756,7 +1834,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/download 34. 文件下载
* @api {get} api/dialog/msg/download 35. 文件下载
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1780,12 +1858,8 @@ class DialogController extends AbstractController
$down = Request::input('down', 'yes');
//
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
if (empty($msg)) {
abort(403, "This file not exist.");
}
if ($msg->type != 'file') {
abort(403, "This file not support download.");
}
abort_if(empty($msg), 403, "This file not exist.");
abort_if($msg->type != 'file', 403, "This file not support download.");
$array = Base::json2array($msg->getRawOriginal('msg'));
//
if ($down === 'preview') {
@@ -1797,7 +1871,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/withdraw 35. 聊天消息撤回
* @api {get} api/dialog/msg/withdraw 36. 聊天消息撤回
*
* @apiDescription 消息撤回限制24小时内需要token身份
* @apiVersion 1.0.0
@@ -1818,7 +1892,9 @@ class DialogController extends AbstractController
if (empty($msg)) {
return Base::retError("消息不存在或已被删除");
}
if (!($user->bot || $user->isAdmin())) {
$dialog = WebSocketDialog::checkDialog($msg->dialog_id);
//
if (!$user->bot && !$dialog->isSelfDialog()) {
Setting::validateMsgLimit('rev', $msg);
}
$msg->withdrawMsg();
@@ -1826,7 +1902,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/voice2text 36. 语音消息转文字
* @api {get} api/dialog/msg/voice2text 37. 语音消息转文字
*
* @apiDescription 将语音消息转文字需要token身份
* @apiVersion 1.0.0
@@ -1865,20 +1941,23 @@ class DialogController extends AbstractController
}
WebSocketDialog::checkDialog($msg->dialog_id);
//
$res = Extranet::openAItranscriptions(public_path($msgData['path']));
if (Base::isError($res)) {
return $res;
$result = AI::transcriptions(public_path($msgData['path']));
if (Base::isError($result)) {
return $result;
}
//
$msg->updateInstance([
'msg' => array_merge($msgData, ['text' => $res['data'], 'text_userid' => [$user->userid]]),
'msg' => array_merge($msgData, [
'text' => $result['data']['text'],
'text_userid' => [$user->userid]
]),
]);
$msg->save();
return Base::retSuccess("success", $msg);
}
/**
* @api {get} api/dialog/msg/translation 37. 翻译消息
* @api {get} api/dialog/msg/translation 38. 翻译消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1931,15 +2010,15 @@ class DialogController extends AbstractController
if ($msg->type === 'text' && $msgData['type'] === 'md') {
$msgData['text'] = preg_replace('/:::\s*reasoning.*?:::/s', '', $msgData['text']);
}
$res = Extranet::openAItranslations($msgData['text'], $targetLanguage);
if (Base::isError($res)) {
return $res;
$result = AI::translations($msgData['text'], $targetLanguage, $force);
if (Base::isError($result)) {
return $result;
}
$row = WebSocketDialogMsgTranslate::createInstance([
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg_id,
'language' => $language,
'content' => $res['data'],
'content' => $result['data']['translated_text'],
]);
$row->save();
//
@@ -1947,7 +2026,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/mark 38. 消息标记操作
* @api {get} api/dialog/msg/mark 39. 消息标记操作
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2011,7 +2090,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/silence 39. 消息免打扰
* @api {get} api/dialog/msg/silence 40. 消息免打扰
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2074,7 +2153,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/forward 40. 转发消息给
* @api {get} api/dialog/msg/forward 41. 转发消息给
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2115,7 +2194,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/emoji 41. emoji回复
* @api {get} api/dialog/msg/emoji 42. emoji回复
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2136,7 +2215,22 @@ class DialogController extends AbstractController
$msg_id = intval(Request::input("msg_id"));
$symbol = Request::input("symbol");
//
if (!preg_match('/[\x{1F300}-\x{1F9FF}]|[\x{1F000}-\x{1F02F}]|[\x{1F0A0}-\x{1F0FF}]|[\x{1F100}-\x{1F64F}]|[\x{1F680}-\x{1F6FF}]|[\x{2600}-\x{26FF}]|[\x{2700}-\x{27BF}]/u', $symbol)) {
$emojiPattern = '/(?:' .
'[\x{1F600}-\x{1F64F}]|' . // 表情符号
'[\x{1F300}-\x{1F5FF}]|' . // 符号和象形文字
'[\x{1F680}-\x{1F6FF}]|' . // 交通和地图符号
'[\x{1F1E0}-\x{1F1FF}]|' . // 区域指示符号(国旗)
'[\x{2600}-\x{26FF}]|' . // 杂项符号
'[\x{2700}-\x{27BF}]|' . // 装饰符号
'[\x{1F900}-\x{1F9FF}]|' . // 补充符号和象形文字
'[\x{1F000}-\x{1F02F}]|' . // 麻将牌
'[\x{1F0A0}-\x{1F0FF}]|' . // 扑克牌
'[\x{1F100}-\x{1F64F}]|' . // 封闭字母数字补充
'[\x{FE0F}]|' . // 变体选择器-16
'[\x{20E3}]|' . // 组合封闭键帽
'[\x{30}-\x{39}\x{23}\x{2A}][\x{FE0F}]?[\x{20E3}]' . // 键帽序列 (0-9, #, *)
')/u';
if (!preg_match($emojiPattern, $symbol)) {
return Base::retError("参数错误");
}
//
@@ -2150,7 +2244,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/tag 42. 标注/取消标注
* @api {get} api/dialog/msg/tag 43. 标注/取消标注
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2179,7 +2273,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/todo 43. 设待办/取消待办
* @api {get} api/dialog/msg/todo 44. 设待办/取消待办
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2222,7 +2316,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/todolist 44. 获取消息待办情况
* @api {get} api/dialog/msg/todolist 45. 获取消息待办情况
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2252,7 +2346,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/done 45. 完成待办
* @api {get} api/dialog/msg/done 46. 完成待办
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2294,6 +2388,7 @@ class DialogController extends AbstractController
$msg->webSocketDialog?->pushMsg('update', [
'id' => $msg->id,
'todo' => $msg->todo,
'todo_done' => $msg->isTodoDone(true),
'dialog_id' => $msg->dialog_id,
]);
}
@@ -2305,7 +2400,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/color 46. 设置颜色
* @api {get} api/dialog/msg/color 47. 设置颜色
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2346,7 +2441,34 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/add 47. 新增群组
* @api {post} api/dialog/msg/webhookmsg2ai 48. 转换为AI对话
*
* @apiDescription 需要token身份将webhook消息转换为适合AI对话的格式消息用于AI对话
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__webhookmsg2ai
*
* @apiParam {String} msg 消息内容
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__webhookmsg2ai()
{
User::auth();
//
$msg = Request::input('msg');
try {
$res = BotReceiveMsgTask::convertMentionForAI($msg);
return Base::retSuccess("success", ['msg' => $res]);
} catch (\Exception $e) {
return Base::retError($e->getMessage());
}
}
/**
* @api {get} api/dialog/group/add 49. 新增群组
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2408,7 +2530,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/edit 48. 修改群组
* @api {get} api/dialog/group/edit 50. 修改群组
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2470,7 +2592,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/adduser 49. 添加群成员
* @api {get} api/dialog/group/adduser 51. 添加群成员
*
* @apiDescription 需要token身份
* - 有群主时:只有群主可以邀请
@@ -2506,7 +2628,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/deluser 50. 移出(退出)群成员
* @api {get} api/dialog/group/deluser 52. 移出(退出)群成员
*
* @apiDescription 需要token身份
* - 只有群主、邀请人可以踢人
@@ -2550,7 +2672,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/transfer 51. 转让群组
* @api {get} api/dialog/group/transfer 53. 转让群组
*
* @apiDescription 需要token身份
* - 只有群主且是个人类型群可以解散
@@ -2599,7 +2721,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/disband 52. 解散群组
* @api {get} api/dialog/group/disband 54. 解散群组
*
* @apiDescription 需要token身份
* - 只有群主且是个人类型群可以解散
@@ -2627,7 +2749,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/searchuser 53. 搜索个人群(仅限管理员)
* @api {get} api/dialog/group/searchuser 55. 搜索个人群(仅限管理员)
*
* @apiDescription 需要token身份用于创建部门搜索个人群组
* @apiVersion 1.0.0
@@ -2656,7 +2778,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/okr/add 54. 创建OKR评论会话
* @api {post} api/dialog/okr/add 56. 创建OKR评论会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2695,7 +2817,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/okr/push 55. 推送OKR相关信息
* @api {post} api/dialog/okr/push 57. 推送OKR相关信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2731,7 +2853,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/wordchain 56. 发送接龙消息
* @api {post} api/dialog/msg/wordchain 58. 发送接龙消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2817,7 +2939,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/vote 57. 发起投票
* @api {post} api/dialog/msg/vote 59. 发起投票
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2933,7 +3055,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/top 58. 置顶/取消置顶
* @api {get} api/dialog/msg/top 60. 置顶/取消置顶
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2993,7 +3115,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/topinfo 59. 获取置顶消息
* @api {get} api/dialog/msg/topinfo 61. 获取置顶消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3020,7 +3142,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/applied 60. 标记消息已应用
* @api {get} api/dialog/msg/applied 62. 标记消息已应用
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3069,7 +3191,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/sticker/search 61. 搜索在线表情
* @api {get} api/dialog/sticker/search 63. 搜索在线表情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3093,7 +3215,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/config 62. 获取会话配置
* @api {get} api/dialog/config 64. 获取会话配置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3129,7 +3251,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/config/save 63. 保存会话配置
* @api {post} api/dialog/config/save 65. 保存会话配置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3175,7 +3297,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/session/create 64. AI-开启新会话
* @api {get} api/dialog/session/create 66. AI-开启新会话
*
* @apiDescription 需要token身份仅限与AI用户会话
* @apiVersion 1.0.0
@@ -3183,6 +3305,7 @@ class DialogController extends AbstractController
* @apiName session_create
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {Number} [userid] 用户ID与 dialog_id 二选一userid 优先)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -3190,27 +3313,22 @@ class DialogController extends AbstractController
*/
public function session__create()
{
User::auth();
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
$userid = intval(Request::input('userid'));
//
$dialog = WebSocketDialog::checkDialog($dialog_id);
if ($userid) {
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
} else {
$dialog = WebSocketDialog::checkDialog($dialog_id);
}
//
if ($dialog->type != 'user') {
if (!$dialog->isSessionDialog()) {
return Base::retError('当前对话不支持');
}
//
$hasAiUser = WebSocketDialogUser::join('users as u', 'web_socket_dialog_users.userid', '=', 'u.userid')
->where('dialog_id', $dialog->id)
->where('u.email', 'like', 'ai-%@bot.system')
->exists();
if (!$hasAiUser) {
return Base::retError('当前对话不支持');
}
//
$session = WebSocketDialogSession::whereDialogId($dialog->id)
->whereTitle('')
->first();
$session = WebSocketDialogSession::whereDialogId($dialog->id)->whereTitle('')->first();
if ($session) {
$dialog->session_id = $session->id;
$dialog->save();
@@ -3219,8 +3337,6 @@ class DialogController extends AbstractController
//
$session = WebSocketDialogSession::create([
'dialog_id' => $dialog->id,
'status' => 1,
'title' => '',
]);
$session->save();
$dialog->session_id = $session->id;
@@ -3230,7 +3346,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/session/list 65. AI-获取会话列表
* @api {get} api/dialog/session/list 67. AI-获取会话列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3270,7 +3386,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/session/open 66. AI-打开会话
* @api {get} api/dialog/session/open 68. AI-打开会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0

View File

@@ -12,10 +12,10 @@ use App\Models\FileLink;
use App\Models\FileUser;
use App\Models\User;
use App\Module\Base;
use App\Module\Down;
use App\Module\Timer;
use App\Module\Ihttp;
use Response;
use Session;
use Swoole\Coroutine;
use Carbon\Carbon;
use Redirect;
@@ -134,7 +134,11 @@ class FileController extends AbstractController
$builder->where("id", $id);
}
if ($key) {
$builder->where("name", "like", "%{$key}%");
if (!$id && Base::isNumber($key)) {
$builder->where("id", $key);
} else {
$builder->where("name", "like", "%{$key}%");
}
}
$array = $builder->take($take)->get()->toArray();
// 搜索共享的
@@ -197,8 +201,8 @@ class FileController extends AbstractController
$pid = intval(Request::input('pid'));
if (mb_strlen($name) < 2) {
return Base::retError('文件名称不可以少于2个字');
} elseif (mb_strlen($name) > 32) {
return Base::retError('文件名称最多只能设置32个字');
} elseif (mb_strlen($name) > 100) {
return Base::retError('文件名称最多只能设置100个字');
}
$tmpName = preg_replace("/[\\\\\/:*?\"<>|]/", '', $name);
if ($tmpName != $name) {
@@ -503,6 +507,10 @@ class FileController extends AbstractController
return Base::retError('参数错误');
}
//
if ($down == 'no') {
File::isNeedInstallApp($file->type);
}
//
if ($only_update_at == 'yes') {
return Base::retSuccess('success', [
'id' => $file->id,
@@ -577,10 +585,12 @@ class FileController extends AbstractController
$contentArray = Base::json2array($content);
$contentString = $contentArray['xml'];
$file->ext = 'drawio';
File::isNeedInstallApp($file->type);
break;
case 'mind':
$contentString = $content;
$file->ext = 'mind';
File::isNeedInstallApp($file->type);
break;
case 'txt':
case 'code':
@@ -632,6 +642,8 @@ class FileController extends AbstractController
{
User::auth();
//
File::isNeedInstallApp('office');
//
$config = Request::input('config');
$token = \Firebase\JWT\JWT::encode($config, env('APP_KEY') ,'HS256');
return Base::retSuccess('成功', [
@@ -657,6 +669,8 @@ class FileController extends AbstractController
{
$user = User::auth();
//
File::isNeedInstallApp('office');
//
$id = intval(Request::input('id'));
$status = intval(Request::input('status'));
$key = Request::input('key');
@@ -666,7 +680,7 @@ class FileController extends AbstractController
//
if ($status === 2) {
$parse = parse_url($url);
$from = 'http://' . env('APP_IPPR') . '.3' . $parse['path'] . '?' . $parse['query'];
$from = 'http://nginx' . $parse['path'] . '?' . $parse['query'];
$path = 'uploads/file/' . $file->type . '/' . date("Ym") . '/' . $file->id . '/' . $key;
$save = public_path($path);
Base::makeDir(dirname($save));
@@ -777,6 +791,8 @@ class FileController extends AbstractController
//
$file = File::permissionFind($id, $user);
//
File::isNeedInstallApp($file->type);
//
$history = FileContent::whereFid($file->id)->whereId($history_id)->first();
if (empty($history)) {
return Base::retError('历史数据不存在或已被删除');
@@ -1000,17 +1016,11 @@ class FileController extends AbstractController
*/
public function download__pack()
{
$key = Request::input('key');
if ($key) {
$userid = Session::get('file::pack:userid');
if (empty($userid)) {
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
}
//
$array = Base::string2array(base64_decode(urldecode($key)));
if (Request::has('key')) {
$array = Down::cache_decode();
$file = $array['file'];
if (empty($file) || !file_exists(storage_path($file))) {
return Base::ajaxError("文件不存在!", [], 0, 502);
return Base::ajaxError("文件不存在!", [], 0, 403);
}
return Response::download(storage_path($file));
}
@@ -1075,11 +1085,10 @@ class FileController extends AbstractController
return Base::retError('文件总大小已超过1GB请分批下载');
}
$base64 = base64_encode(Base::array2string([
$key = Down::cache_encode([
'file' => $zipFile,
]));
$fileUrl = Base::fillUrl('api/file/download/pack?key=' . urlencode($base64));
Session::put('file::pack:userid', $user->userid);
]);
$fileUrl = Base::fillUrl('api/file/download/pack?key=' . $key);
$zip = new \ZipArchive();
Base::makeDir(dirname($zipPath));
@@ -1088,17 +1097,18 @@ class FileController extends AbstractController
return Base::retError('创建压缩文件失败');
}
go(function () use ($zipPath, $fileUrl, $zip, $files, $fileName, $botUser, $dialog) {
$userid = $user->userid;
go(function () use ($userid, $zipPath, $fileUrl, $zip, $files, $fileName, $botUser, $dialog) {
Coroutine::sleep(0.1);
// 压缩进度
$progress = 0;
$zip->registerProgressCallback(0.05, function ($ratio) use ($fileUrl, $fileName, &$progress) {
$zip->registerProgressCallback(0.05, function ($ratio) use ($userid, $fileUrl, $fileName, &$progress) {
$progress = round($ratio * 100);
File::filePushMsg('compress', [
File::pushMsgSimple('compress', [
'name' => $fileName,
'url' => $fileUrl,
'progress' => $progress
]);
], $userid);
});
//
foreach ($files as $file) {
@@ -1107,11 +1117,11 @@ class FileController extends AbstractController
$zip->close();
//
if ($progress < 100) {
File::filePushMsg('compress', [
File::pushMsgSimple('compress', [
'name' => $fileName,
'url' => $fileUrl,
'progress' => 100
]);
], $userid);
}
//
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [

View File

@@ -3,11 +3,11 @@
namespace App\Http\Controllers\Api;
use Request;
use Session;
use Redirect;
use Response;
use Madzipper;
use Carbon\Carbon;
use App\Module\Down;
use App\Module\Doo;
use App\Models\File;
use App\Models\User;
@@ -29,6 +29,7 @@ use App\Models\ProjectColumn;
use App\Models\ProjectInvite;
use App\Models\ProjectFlowItem;
use App\Models\ProjectTaskFile;
use App\Models\ProjectTaskTag;
use App\Models\ProjectTaskUser;
use App\Models\WebSocketDialog;
use App\Exceptions\ApiException;
@@ -424,7 +425,7 @@ class ProjectController extends AbstractController
*/
public function invite()
{
User::auth();
$user = User::auth();
//
$project_id = intval(Request::input('project_id'));
$refresh = Request::input('refresh', 'no');
@@ -440,17 +441,17 @@ class ProjectController extends AbstractController
if (empty($projectInvite)) {
$projectInvite = ProjectInvite::createInstance([
'project_id' => $project->id,
'code' => Base::generatePassword(64),
'code' => base64_encode("{$project->id},{$user->userid}," . Base::generatePassword()),
]);
$projectInvite->save();
} else {
if ($refresh == 'yes') {
$projectInvite->code = Base::generatePassword(64);
$projectInvite->code = base64_encode("{$project->id},{$user->userid}," . Base::generatePassword());
$projectInvite->save();
}
}
return Base::retSuccess('success', [
'url' => Base::fillUrl('manage/project/invite?code=' . $projectInvite->code),
'url' => Base::fillUrl('manage/project/invite/' . $projectInvite->code),
'num' => $projectInvite->num
]);
}
@@ -1257,26 +1258,27 @@ class ProjectController extends AbstractController
}
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
//
go(function () use ($user, $userid, $time, $type, $botUser, $dialog) {
Coroutine::sleep(0.1);
$doo = Doo::load();
go(function () use ($doo, $user, $userid, $time, $type, $botUser, $dialog) {
Coroutine::sleep(1);
$headings = [];
$headings[] = Doo::translate('任务ID');
$headings[] = Doo::translate('父级任务ID');
$headings[] = Doo::translate('所属项目');
$headings[] = Doo::translate('任务标题');
$headings[] = Doo::translate('任务标签');
$headings[] = Doo::translate('任务开始时间');
$headings[] = Doo::translate('任务结束时间');
$headings[] = Doo::translate('完成时间');
$headings[] = Doo::translate('归档时间');
$headings[] = Doo::translate('任务计划用时');
$headings[] = Doo::translate('实际完成用时');
$headings[] = Doo::translate('超时时间');
$headings[] = Doo::translate('开发用时');
$headings[] = Doo::translate('验收/测试用时');
$headings[] = Doo::translate('负责人');
$headings[] = Doo::translate('创建人');
$headings[] = Doo::translate('状态');
$headings[] = $doo->translate('任务ID');
$headings[] = $doo->translate('父级任务ID');
$headings[] = $doo->translate('所属项目');
$headings[] = $doo->translate('任务标题');
$headings[] = $doo->translate('任务标签');
$headings[] = $doo->translate('任务开始时间');
$headings[] = $doo->translate('任务结束时间');
$headings[] = $doo->translate('完成时间');
$headings[] = $doo->translate('归档时间');
$headings[] = $doo->translate('任务计划用时');
$headings[] = $doo->translate('实际完成用时');
$headings[] = $doo->translate('超时时间');
$headings[] = $doo->translate('开发用时');
$headings[] = $doo->translate('验收/测试用时');
$headings[] = $doo->translate('负责人');
$headings[] = $doo->translate('创建人');
$headings[] = $doo->translate('状态');
$datas = [];
//
$content = [];
@@ -1290,7 +1292,7 @@ class ProjectController extends AbstractController
->where('project_task_users.owner', 1)
->whereIn('project_task_users.userid', $userid)
->betweenTime(Carbon::parse($time[0])->startOfDay(), Carbon::parse($time[1])->endOfDay(), $type);
$builder->orderByDesc('project_tasks.id')->chunk(100, function ($tasks) use (&$datas) {
$builder->orderByDesc('project_tasks.id')->chunk(100, function ($tasks) use ($doo, &$datas) {
/** @var ProjectTask $task */
foreach ($tasks as $task) {
$flowChanges = ProjectTaskFlowChange::whereTaskId($task->id)->get();
@@ -1329,9 +1331,9 @@ class ProjectController extends AbstractController
$planTotalTime = $endTime - $startTime;
$residueTime = $planTotalTime - $totalTime;
if ($residueTime < 0) {
$overTime = Doo::translate(Timer::timeFormat(abs($residueTime)));
$overTime = $doo->translate(Timer::timeFormat(abs($residueTime)));
}
$planTime = Doo::translate(Timer::timeDiff($startTime, $endTime));
$planTime = $doo->translate(Timer::timeDiff($startTime, $endTime));
}
$actualTime = $task->complete_at ? $totalTime : 0; // 实际完成用时
$statusText = '未完成';
@@ -1375,13 +1377,13 @@ class ProjectController extends AbstractController
$task->complete_at ?: '-',
$task->archived_at ?: '-',
$planTime,
$actualTime ? Doo::translate(Timer::timeFormat($actualTime)) : '-',
$actualTime ? $doo->translate(Timer::timeFormat($actualTime)) : '-',
$overTime,
$developTime > 0 ? Doo::translate(Timer::timeFormat($developTime)) : '-',
$testTime > 0 ? Doo::translate(Timer::timeFormat($testTime)) : '-',
$developTime > 0 ? $doo->translate(Timer::timeFormat($developTime)) : '-',
$testTime > 0 ? $doo->translate(Timer::timeFormat($testTime)) : '-',
Base::filterEmoji(User::userid2nickname($task->ownerid)) . " (ID: {$task->ownerid})",
Base::filterEmoji(User::userid2nickname($task->userid)) . " (ID: {$task->userid})",
Doo::translate($statusText),
$doo->translate($statusText),
];
}
});
@@ -1394,7 +1396,7 @@ class ProjectController extends AbstractController
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, false, false, true);
], $botUser->userid, true, false, true);
return;
}
//
@@ -1415,7 +1417,7 @@ class ProjectController extends AbstractController
} else {
$fileName .= '的任务统计';
}
$fileName = Doo::translate($fileName) . '_' . Timer::time() . '.xls';
$fileName = $doo->translate($fileName) . '_' . Timer::time() . '.xls';
$filePath = "temp/task/export/" . date("Ym", Timer::time());
$export = new BillMultipleExport($sheets);
$res = $export->store($filePath . "/" . $fileName);
@@ -1428,7 +1430,7 @@ class ProjectController extends AbstractController
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, false, false, true);
], $botUser->userid, true, false, true);
return;
}
//
@@ -1444,18 +1446,17 @@ class ProjectController extends AbstractController
}
//
if (file_exists($zipPath)) {
$base64 = base64_encode(Base::array2string([
$key = Down::cache_encode([
'file' => $zipFile,
]));
$fileUrl = Base::fillUrl('api/project/task/down?key=' . urlencode($base64));
Session::put('task::export:userid', $user->userid);
]);
$fileUrl = Base::fillUrl('api/project/task/down?key=' . $key);
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'file_download',
'title' => '导出任务统计已完成',
'name' => $fileName,
'size' => filesize($zipPath),
'url' => $fileUrl,
], $botUser->userid, false, false, true);
], $botUser->userid, true, false, true);
} else {
$content[] = [
'content' => "打包失败,请稍后再试...",
@@ -1465,9 +1466,15 @@ class ProjectController extends AbstractController
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, false, false, true);
], $botUser->userid, true, false, true);
}
});
//
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'content' => '正在导出任务统计,请稍等...',
], $botUser->userid, true, false, true);
//
return Base::retSuccess('success');
}
@@ -1487,103 +1494,156 @@ class ProjectController extends AbstractController
{
$user = User::auth('admin');
//
$headings = [];
$headings[] = Doo::translate('任务ID');
$headings[] = Doo::translate('父级任务ID');
$headings[] = Doo::translate('所属项目');
$headings[] = Doo::translate('任务标题');
$headings[] = Doo::translate('任务标签');
$headings[] = Doo::translate('任务开始时间');
$headings[] = Doo::translate('任务结束时间');
$headings[] = Doo::translate('任务计划用时');
$headings[] = Doo::translate('超时时间');
$headings[] = Doo::translate('负责人');
$headings[] = Doo::translate('创建人');
$data = [];
$botUser = User::botGetOrCreate('system-msg');
if (empty($botUser)) {
return Base::retError('系统机器人不存在');
}
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
//
ProjectTask::with(['taskTag'])
->whereNull('complete_at')
->whereNotNull('end_at')
->where('end_at', '<=', Carbon::now())
->orderBy('end_at')
->chunk(100, function ($tasks) use (&$data) {
/** @var ProjectTask $task */
foreach ($tasks as $task) {
$taskStartTime = Carbon::parse($task->start_at ?: $task->created_at)->timestamp;
$totalTime = time() - $taskStartTime; //开发测试总用时
$planTime = '-';//任务计划用时
$overTime = '-';//超时时间
if ($task->end_at) {
$startTime = Carbon::parse($task->start_at)->timestamp;
$endTime = Carbon::parse($task->end_at)->timestamp;
$planTotalTime = $endTime - $startTime;
$residueTime = $planTotalTime - $totalTime;
if ($residueTime < 0) {
$overTime = Doo::translate(Timer::timeFormat(abs($residueTime)));
$doo = Doo::load();
go(function () use ($doo, $botUser, $dialog, $user) {
Coroutine::sleep(1);
//
$headings = [];
$headings[] = $doo->translate('任务ID');
$headings[] = $doo->translate('父级任务ID');
$headings[] = $doo->translate('所属项目');
$headings[] = $doo->translate('任务标题');
$headings[] = $doo->translate('任务标签');
$headings[] = $doo->translate('任务开始时间');
$headings[] = $doo->translate('任务结束时间');
$headings[] = $doo->translate('任务计划用时');
$headings[] = $doo->translate('超时时间');
$headings[] = $doo->translate('负责人');
$headings[] = $doo->translate('创建人');
$data = [];
//
$content = [];
$content[] = [
'content' => '导出超期任务已完成',
'style' => 'font-weight: bold;padding-bottom: 4px;',
];
//
ProjectTask::with(['taskTag'])
->whereNull('complete_at')
->whereNotNull('end_at')
->where('end_at', '<=', Carbon::now())
->orderBy('end_at')
->chunk(100, function ($tasks) use ($doo, &$data) {
/** @var ProjectTask $task */
foreach ($tasks as $task) {
$taskStartTime = Carbon::parse($task->start_at ?: $task->created_at)->timestamp;
$totalTime = time() - $taskStartTime; //开发测试总用时
$planTime = '-';//任务计划用时
$overTime = '-';//超时时间
if ($task->end_at) {
$startTime = Carbon::parse($task->start_at)->timestamp;
$endTime = Carbon::parse($task->end_at)->timestamp;
$planTotalTime = $endTime - $startTime;
$residueTime = $planTotalTime - $totalTime;
if ($residueTime < 0) {
$overTime = $doo->translate(Timer::timeFormat(abs($residueTime)));
}
$planTime = $doo->translate(Timer::timeDiff($startTime, $endTime));
}
$planTime = Doo::translate(Timer::timeDiff($startTime, $endTime));
$ownerIds = $task->taskUser->where('owner', 1)->pluck('userid')->toArray();
$ownerNames = [];
foreach ($ownerIds as $ownerId) {
$ownerNames[] = Base::filterEmoji(User::userid2nickname($ownerId)) . " (ID: {$ownerId})";
}
$data[] = [
$task->id,
$task->parent_id ?: '-',
Base::filterEmoji($task->project?->name) ?: '-',
Base::filterEmoji($task->name),
$task->taskTag->map(function ($tag) {
return Base::filterEmoji($tag->name);
})->join(', ') ?: '-',
$task->start_at ?: '-',
$task->end_at ?: '-',
$planTime,
$overTime,
implode(', ', $ownerNames),
Base::filterEmoji(User::userid2nickname($task->userid)) . " (ID: {$task->userid})",
];
}
$ownerIds = $task->taskUser->where('owner', 1)->pluck('userid')->toArray();
$ownerNames = [];
foreach ($ownerIds as $ownerId) {
$ownerNames[] = Base::filterEmoji(User::userid2nickname($ownerId)) . " (ID: {$ownerId})";
}
$data[] = [
$task->id,
$task->parent_id ?: '-',
Base::filterEmoji($task->project?->name) ?: '-',
Base::filterEmoji($task->name),
$task->taskTag->map(function ($tag) {
return Base::filterEmoji($tag->name);
})->join(', ') ?: '-',
$task->start_at ?: '-',
$task->end_at ?: '-',
$planTime,
$overTime,
implode(', ', $ownerNames),
Base::filterEmoji(User::userid2nickname($task->userid)) . " (ID: {$task->userid})",
];
}
});
if (empty($data)) {
return Base::retError('没有任何数据');
}
});
if (empty($data)) {
$content[] = [
'content' => '没有任何数据',
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
//
$title = $doo->translate('超期任务');
$sheets = [
BillExport::create()->setTitle($title)->setHeadings($headings)->setData($data)->setStyles(["A1:J1" => ["font" => ["bold" => true]]])
];
//
$fileName = $title . '_' . Timer::time() . '.xls';
$filePath = "temp/task/export/" . date("Ym", Timer::time());
$export = new BillMultipleExport($sheets);
$res = $export->store($filePath . "/" . $fileName);
if ($res != 1) {
$content[] = [
'content' => "导出失败,{$fileName}",
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xls') . ".zip";
$zipPath = storage_path($zipFile);
if (file_exists($zipPath)) {
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
} catch (\Throwable) {
}
//
if (file_exists($zipPath)) {
$key = Down::cache_encode([
'file' => $zipFile,
]);
$fileUrl = Base::fillUrl('api/project/task/down?key=' . $key);
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'file_download',
'title' => '导出超期任务已完成',
'name' => $fileName,
'size' => filesize($zipPath),
'url' => $fileUrl,
], $botUser->userid, true, false, true);
} else {
$content[] = [
'content' => "打包失败,请稍后再试...",
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
}
});
//
$title = Doo::translate('超期任务');
$sheets = [
BillExport::create()->setTitle($title)->setHeadings($headings)->setData($data)->setStyles(["A1:J1" => ["font" => ["bold" => true]]])
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'content' => '正在导出超期任务,请稍等...',
], $botUser->userid, true, false, true);
//
$fileName = $title . '_' . Timer::time() . '.xls';
$filePath = "temp/task/export/" . date("Ym", Timer::time());
$export = new BillMultipleExport($sheets);
$res = $export->store($filePath . "/" . $fileName);
if ($res != 1) {
return Base::retError('导出失败,' . $fileName . '');
}
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xls') . ".zip";
$zipPath = storage_path($zipFile);
if (file_exists($zipPath)) {
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
} catch (\Throwable) {
}
//
if (file_exists($zipPath)) {
$base64 = base64_encode(Base::array2string([
'file' => $zipFile,
]));
Session::put('task::export:userid', $user->userid);
return Base::retSuccess('success', [
'size' => Base::twoFloat(filesize($zipPath) / 1024, true),
'url' => Base::fillUrl('api/project/task/down?key=' . urlencode($base64)),
]);
} else {
return Base::retError('打包失败,请稍后再试...');
}
return Base::retSuccess('success');
}
/**
@@ -1599,15 +1659,10 @@ class ProjectController extends AbstractController
*/
public function task__down()
{
$userid = Session::get('task::export:userid');
if (empty($userid)) {
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
}
//
$array = Base::string2array(base64_decode(urldecode(Request::input('key'))));
$array = Down::cache_decode();
$file = $array['file'];
if (empty($file) || !file_exists(storage_path($file))) {
return Base::ajaxError("文件不存在!", [], 0, 502);
return Base::ajaxError("文件不存在!", [], 0, 403);
}
return Response::download(storage_path($file));
}
@@ -1783,6 +1838,13 @@ class ProjectController extends AbstractController
//
ProjectPermission::userTaskPermission(Project::userProject($task->project_id), ProjectPermission::TASK_REMOVE, $task);
//
$task->addLog('删除附件:' . $file->name, [
'file_id' => $file->id,
'name' => $file->name,
'size' => $file->size,
'path' => $file->getRawOriginal('path'),
'thumb' => $file->getRawOriginal('thumb'),
]);
$task->pushMsg('filedelete', $file);
$file->delete();
//
@@ -1824,6 +1886,7 @@ class ProjectController extends AbstractController
'update_at' => Carbon::parse($file->updated_at)->toDateTimeString()
]);
}
File::isNeedInstallApp($file->ext);
//
$data = $file->toArray();
$data['path'] = $file->getRawOriginal('path');
@@ -1858,9 +1921,7 @@ class ProjectController extends AbstractController
$down = Request::input('down', 'yes');
//
$file = ProjectTaskFile::find($file_id);
if (empty($file)) {
abort(403, "This file not exist.");
}
abort_if(empty($file), 403, "This file not exist.");
//
try {
ProjectTask::userTask($file->task_id, null);
@@ -2053,14 +2114,17 @@ class ProjectController extends AbstractController
//
$task = ProjectTask::userTask($task_id);
//
$project = Project::userProject($task->project_id);
$permissionKey = ProjectPermission::TASK_UPDATE;
if (Arr::exists($param, 'times')) {
$permissionKey = ProjectPermission::TASK_TIME;
} else if (Arr::exists($param, 'flow_item_id')) {
$permissionKey = ProjectPermission::TASK_STATUS;
if ($task->hasOwner()) {
// 已经存在负责人,则需要检查权限(即:没有任务负责人时,不检查权限)
$project = Project::userProject($task->project_id);
$permissionKey = ProjectPermission::TASK_UPDATE;
if (Arr::exists($param, 'times')) {
$permissionKey = ProjectPermission::TASK_TIME;
} else if (Arr::exists($param, 'flow_item_id')) {
$permissionKey = ProjectPermission::TASK_STATUS;
}
ProjectPermission::userTaskPermission($project, $permissionKey, $task);
}
ProjectPermission::userTaskPermission($project, $permissionKey, $task);
//
$taskUser = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($task_id)->get();
$owners = $taskUser->where('owner', 1)->pluck('userid')->toArray();
@@ -2303,7 +2367,7 @@ class ProjectController extends AbstractController
$task->updateTask($data, $updateMarking);
//
$data = ProjectTask::oneTask($task->id)->toArray();
$data["flow_item_name"] = $newFlowItem->status . "|" . $newFlowItem->name;
$data["flow_item_name"] = $newFlowItem->status . "|" . $newFlowItem->name . "|" . $newFlowItem->color;
$data['update_marking'] = $updateMarking ?: json_decode('{}');
$task->pushMsg('update', $data);
//
@@ -2360,7 +2424,7 @@ class ProjectController extends AbstractController
]);
}
//
$turns = ProjectFlowItem::select(['id', 'name', 'status', 'turns'])->whereFlowId($projectFlow->id)->orderBy('sort')->get();
$turns = ProjectFlowItem::select(['id', 'name', 'status', 'turns', 'color'])->whereFlowId($projectFlow->id)->orderBy('sort')->get();
if (empty($projectFlowItem)) {
$data = [
'task_id' => $projectTask->id,
@@ -2417,6 +2481,11 @@ class ProjectController extends AbstractController
* @apiParam {Number} flow_item_id 工作流id
* @apiParam {Array} owner 负责人
* @apiParam {Array} assist 协助人
* @apiParam {String} [completed] 是否已完成
* - 没有 工作流id 时此参数才生效
* - 有值表示已完成
* - 空值表示未完成
* - 不存在不改变状态
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -2433,7 +2502,7 @@ class ProjectController extends AbstractController
$flow_item_id = intval(Request::input('flow_item_id'));
$owner = Request::input('owner', []);
$assist = Request::input('assist', []);
$completeAt = trim(Request::input('complete_at', ''));
$completed = Request::exists('completed') ? (bool)Request::input('completed') : null;
//
$task = ProjectTask::userTask($task_id);
//
@@ -2454,13 +2523,13 @@ class ProjectController extends AbstractController
if (empty($flowItem)) {
return Base::retError('任务状态不存在');
}
} else if (!$flow_item_id && !$completeAt) {
} else {
if (projectFlowItem::whereProjectId($project->id)->count() > 0) {
return Base::retError('请选择移动后状态', [], 102);
}
}
//
$task->moveTask($project_id, $column_id, $flow_item_id, $owner, $assist, $completeAt);
$task->moveTask($project_id, $column_id, $flow_item_id, $owner, $assist, $completed);
//
$data = [];
$mainTask = ProjectTask::userTask($task_id)?->toArray();
@@ -2832,8 +2901,8 @@ class ProjectController extends AbstractController
$template->update($data);
} else {
$templateCount = ProjectTaskTemplate::where('project_id', $projectId)->count();
if ($templateCount >= 20) {
return Base::retError('每个项目最多添加20个模板');
if ($templateCount >= 50) {
return Base::retError('每个项目最多添加50个模板');
}
$template = ProjectTaskTemplate::create($data);
}
@@ -2916,7 +2985,7 @@ class ProjectController extends AbstractController
/**
* @api {post} api/project/tag/save 51. 保存标签
*
* @apiDescription 需要token身份修改项目负责人添加项目所有成员
* @apiDescription 需要token身份修改项目负责人、标签创建者;添加:项目所有成员)
* @apiVersion 1.0.0
* @apiGroup project
* @apiName tag__save
@@ -2957,22 +3026,69 @@ class ProjectController extends AbstractController
'color' => $color,
'userid' => $user->userid
];
$project = Project::userProject($projectId, true, $id > 0 ? true : null);
$project = Project::userProject($projectId);
if ($id > 0) {
$tag = ProjectTag::where('id', $id)
->where('project_id', $projectId)
->first();
if (!$project->owner && $tag->userid != $user->userid) {
return Base::retError('没有权限修改标签');
}
if (!$tag) {
return Base::retError('标签不存在或已被删除');
}
$tag->update($data);
AbstractModel::transaction(function () use ($data, $tag, $project) {
$tagWhere = [
'project_id' => $tag->project_id,
'name' => $tag->name,
];
// 获取使用该标签的任务ID
$taskIds = ProjectTaskTag::where($tagWhere)->pluck('task_id')->toArray();
// 更新任务
if (!empty($taskIds)) {
ProjectTask::whereIn('id', $taskIds)->update(['updated_at' => Carbon::now()]);
}
// 更新任务标签
ProjectTaskTag::where($tagWhere)->update([
'color' => $data['color'],
'name' => $data['name'],
]);
// 更新标签
$project->addLog("修改标签", [
'change' => [
[
'type' => 'tag',
'name' => $tag->name,
'color' => $tag->color
],
[
'type' => 'tag',
'name' => $data['name'],
'color' => $data['color']
]
],
]);
$tag->update($data);
});
} else {
$tagCount = ProjectTag::where('project_id', $projectId)->count();
if ($tagCount >= 20) {
return Base::retError('每个项目最多添加20个标签');
if ($tagCount >= 100) {
return Base::retError('每个项目最多添加100个标签');
}
if (ProjectTag::where([
'project_id' => $projectId,
'name' => $name,
])->exists()) {
return Base::retError('标签已存在');
}
$project->addLog("添加标签", [
'change' => [
'type' => 'tag',
'name' => $name,
'color' => $color
]
]);
$tag = ProjectTag::create($data);
$project->addLog("添加标签【" . $tag->name . "");
}
return Base::retSuccess('保存成功', $tag);
}
@@ -2980,7 +3096,7 @@ class ProjectController extends AbstractController
/**
* @api {get} api/project/tag/delete 52. 删除标签
*
* @apiDescription 需要token身份项目负责人
* @apiDescription 需要token身份项目负责人、标签创建者
* @apiVersion 1.0.0
* @apiGroup project
* @apiName tag__delete
@@ -2993,7 +3109,7 @@ class ProjectController extends AbstractController
*/
public function tag__delete()
{
User::auth();
$user = User::auth();
//
$id = intval(Request::input('id'));
if (!$id) {
@@ -3003,9 +3119,35 @@ class ProjectController extends AbstractController
if (!$tag) {
return Base::retError('标签不存在或已被删除');
}
Project::userProject($tag->project_id, true, true);
$tag->delete();
return Base::retSuccess('删除成功');
$project = Project::userProject($tag->project_id);
if (!$project->owner && $tag->userid != $user->userid) {
return Base::retError('没有权限删除标签');
}
//
return AbstractModel::transaction(function () use ($tag, $project) {
$tagWhere = [
'project_id' => $tag->project_id,
'name' => $tag->name,
];
// 获取使用该标签的任务ID
$taskIds = ProjectTaskTag::where($tagWhere)->pluck('task_id')->toArray();
// 更新任务
if (!empty($taskIds)) {
ProjectTask::whereIn('id', $taskIds)->update(['updated_at' => Carbon::now()]);
}
// 删除任务标签
ProjectTaskTag::where($tagWhere)->delete();
// 删除标签
$project->addLog("删除标签", [
'change' => [
'type' => 'tag',
'name' => $tag->name,
'color' => $tag->color
],
]);
$tag->delete();
return Base::retSuccess('删除成功');
});
}
/**

View File

@@ -221,7 +221,6 @@ class ReportController extends AbstractController
$report->updateInstance([
"title" => $input["title"],
"type" => $input["type"],
"content" => htmlspecialchars($input["content"]),
]);
} else {
// 生成唯一标识
@@ -235,11 +234,25 @@ class ReportController extends AbstractController
"title" => $input["title"],
"type" => $input["type"],
"userid" => $user->userid,
"content" => htmlspecialchars($input["content"]),
]);
}
$report->save();
// 保存内容
$content = $input["content"];
preg_match_all("/<img\s+src=\"data:image\/(png|jpg|jpeg|webp);base64,(.*?)\"/s", $content, $matchs);
foreach ($matchs[2] as $key => $text) {
$tmpPath = "uploads/report/" . Carbon::parse($report->created_at)->format("Ym") . "/" . $report->id . "/attached/";
Base::makeDir(public_path($tmpPath));
$tmpPath .= md5($text) . "." . $matchs[1][$key];
if (Base::saveContentImage(public_path($tmpPath), base64_decode($text))) {
$paramet = getimagesize(public_path($tmpPath));
$content = str_replace($matchs[0][$key], '<img src="' . Base::fillUrl($tmpPath) . '" original-width="' . $paramet[0] . '" original-height="' . $paramet[1] . '"', $content);
}
}
$report->content = htmlspecialchars($content);
$report->save();
// 删除关联
$report->Receives()->delete();
if ($input["receive_content"]) {
@@ -580,8 +593,9 @@ class ReportController extends AbstractController
return Base::retError("报告不存在或已被删除");
}
$reportTag = count($reportMsgs) > 1 ? 'li' : 'p';
$reportMsgs = array_map(function ($item) use ($reportTag) {
return "<{$reportTag}>{$item}</{$reportTag}>";
$reportAttr = $reportTag === 'li' ? ' data-list="ordered"' : '';
$reportMsgs = array_map(function ($item) use ($reportAttr, $reportTag) {
return "<{$reportTag}{$reportAttr}>{$item}</{$reportTag}>";
}, $reportMsgs);
if ($reportTag === 'li') {
array_unshift($reportMsgs, "<ol>");

View File

@@ -2,10 +2,12 @@
namespace App\Http\Controllers\Api;
use App\Models\UserDevice;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Module\AI;
use App\Module\Down;
use Request;
use Session;
use Response;
use Madzipper;
use Carbon\Carbon;
@@ -14,14 +16,15 @@ use App\Models\User;
use App\Module\Base;
use App\Module\Timer;
use App\Models\Setting;
use App\Module\Extranet;
use LdapRecord\Container;
use App\Module\BillExport;
use Guanguans\Notify\Factory;
use App\Models\UserCheckinRecord;
use App\Module\Apps;
use App\Module\BillMultipleExport;
use LdapRecord\LdapRecordException;
use Guanguans\Notify\Messages\EmailMessage;
use Swoole\Coroutine;
/**
* @apiDefine system
@@ -41,7 +44,7 @@ class SystemController extends AbstractController
* @apiParam {String} type
* - get: 获取(默认)
* - all: 获取所有(需要管理员权限)
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'voice2text', 'translation', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'system_alias', 'system_welcome', 'image_compress', 'image_quality', 'image_save_local', 'start_home']
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'voice2text', 'translation', 'convert_video', 'compress_video', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'system_alias', 'system_welcome', 'image_compress', 'image_quality', 'image_save_local']
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -70,6 +73,8 @@ class SystemController extends AbstractController
'anon_message',
'voice2text',
'translation',
'convert_video',
'compress_video',
'e2e_message',
'msg_rev_limit',
'msg_edit_limit',
@@ -86,7 +91,6 @@ class SystemController extends AbstractController
'image_compress',
'image_quality',
'image_save_local',
'start_home',
'file_upload_limit',
'unclaimed_task_reminder',
'unclaimed_task_reminder_time',
@@ -103,10 +107,10 @@ class SystemController extends AbstractController
}
}
if ($all['voice2text'] == 'open' && !Setting::AIOpen()) {
return Base::retError('开启语音转文字功能需要在应用中开启 ChatGPT AI 机器人。');
return Base::retError('开启语音转文字功能需要先设置 AI 助理。');
}
if ($all['translation'] == 'open' && !Setting::AIOpen()) {
return Base::retError('开启翻译功能需要在应用中开启 ChatGPT AI 机器人。');
return Base::retError('开启翻译功能需要先设置 AI 助理。');
}
if ($all['system_alias'] == env('APP_NAME')) {
$all['system_alias'] = '';
@@ -136,6 +140,8 @@ class SystemController extends AbstractController
$setting['anon_message'] = $setting['anon_message'] ?: 'open';
$setting['voice2text'] = $setting['voice2text'] ?: 'close';
$setting['translation'] = $setting['translation'] ?: 'close';
$setting['convert_video'] = $setting['convert_video'] ?: 'close';
$setting['compress_video'] = $setting['compress_video'] ?: 'close';
$setting['e2e_message'] = $setting['e2e_message'] ?: 'close';
$setting['msg_rev_limit'] = $setting['msg_rev_limit'] ?: '';
$setting['msg_edit_limit'] = $setting['msg_edit_limit'] ?: '';
@@ -146,11 +152,9 @@ class SystemController extends AbstractController
$setting['all_group_autoin'] = $setting['all_group_autoin'] ?: 'yes';
$setting['user_private_chat_mute'] = $setting['user_private_chat_mute'] ?: 'open';
$setting['user_group_chat_mute'] = $setting['user_group_chat_mute'] ?: 'open';
$setting['start_home'] = $setting['start_home'] ?: 'close';
$setting['file_upload_limit'] = $setting['file_upload_limit'] ?: '';
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
$setting['server_closeai'] = env("SERVER_CLOSEAI") ?: 'open';
$setting['server_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion();
//
@@ -282,7 +286,49 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/aibot 04. 获取会议设置、保存AI机器人设置(限管理员)
* @api {get} api/system/setting/ai 04. AI助手设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
* @apiName setting__ai
*
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存设置(参数:['ai_provider', 'ai_api_key', 'ai_api_url', 'ai_proxy']
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function setting__ai()
{
User::auth('admin');
//
$type = trim(Request::input('type'));
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
return Base::retError('当前环境禁止修改');
}
$all = Base::newTrim(Request::input());
foreach ($all as $key => $value) {
if (!in_array($key, [
'ai_provider',
'ai_api_key',
'ai_api_url',
'ai_proxy',
])) {
unset($all[$key]);
}
}
$setting = Base::setting('aiSetting', Base::newTrim($all));
} else {
$setting = Base::setting('aiSetting');
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/aibot 05. 获取会议设置、保存AI机器人设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -301,6 +347,8 @@ class SystemController extends AbstractController
{
User::auth('admin');
//
Apps::isInstalledThrow('ai');
//
$type = trim(Request::input('type'));
$filter = trim(Request::input('filter'));
$setting = Base::setting('aibotSetting');
@@ -338,7 +386,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/aibot_models 05. 获取AI模型
* @api {get} api/system/setting/aibot_models 06. 获取AI模型
*
* @apiDescription 获取所有AI机器人模型设置
* @apiVersion 1.0.0
@@ -359,7 +407,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/aibot_defmodels 06. 获取AI默认模型
* @api {get} api/system/setting/aibot_defmodels 07. 获取AI默认模型
*
* @apiDescription 获取AI机器人默认模型
* @apiVersion 1.0.0
@@ -385,9 +433,9 @@ class SystemController extends AbstractController
if (empty($baseUrl)) {
return Base::retError('请先填写 Base URL');
}
return Extranet::ollamaModels($baseUrl, $key, $agency);
return AI::ollamaModels($baseUrl, $key, $agency);
}
$models = Setting::AIDefaultModels($type);
$models = Setting::AIBotDefaultModels($type);
if (empty($models)) {
return Base::retError('未找到默认模型');
}
@@ -397,7 +445,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/checkin 07. 获取签到设置、保存签到设置(限管理员)
* @api {get} api/system/setting/checkin 08. 获取签到设置、保存签到设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -433,8 +481,13 @@ class SystemController extends AbstractController
'face_remark',
'face_retip',
'locat_remark',
'locat_map_type',
'locat_bd_lbs_key',
'locat_bd_lbs_point', // 格式:{"lng":116.404, "lat":39.915, "radius":500}
'locat_amap_key',
'locat_amap_point', // 格式:{"lng":116.404, "lat":39.915, "radius":500}
'locat_tencent_key',
'locat_tencent_point', // 格式:{"lng":116.404, "lat":39.915, "radius":500}
'manual_remark',
'modes',
'key',
@@ -450,16 +503,33 @@ class SystemController extends AbstractController
if (!$botUser) {
return Base::retError('创建签到机器人失败');
}
if (in_array('locat', $all['modes'])) {
if (empty($all['locat_bd_lbs_key'])) {
return Base::retError('请填写百度地图AK');
if (is_array($all['modes'])) {
if (in_array('locat', $all['modes'])) {
$mapTypes = [
'baidu' => ['key' => 'locat_bd_lbs_key', 'point' => 'locat_bd_lbs_point', 'msg' => '请填写百度地图AK'],
'amap' => ['key' => 'locat_amap_key', 'point' => 'locat_amap_point', 'msg' => '请填写高德地图Key'],
'tencent' => ['key' => 'locat_tencent_key', 'point' => 'locat_tencent_point', 'msg' => '请填写腾讯地图Key'],
];
$type = $all['locat_map_type'];
if (!isset($mapTypes[$type])) {
return Base::retError('请选择地图类型');
}
$conf = $mapTypes[$type];
if (empty($all[$conf['key']])) {
return Base::retError($conf['msg']);
}
if (!is_array($all[$conf['point']])) {
return Base::retError('请选择允许签到位置');
}
$all[$conf['point']]['radius'] = intval($all[$conf['point']]['radius']);
$point = $all[$conf['point']];
if (empty($point['lng']) || empty($point['lat']) || empty($point['radius'])) {
return Base::retError('请选择有效的签到位置');
}
}
if (!is_array($all['locat_bd_lbs_point'])) {
return Base::retError('请选择允许签到位置');
}
$all['locat_bd_lbs_point']['radius'] = intval($all['locat_bd_lbs_point']['radius']);
if (empty($all['locat_bd_lbs_point']['lng']) || empty($all['locat_bd_lbs_point']['lat']) || empty($all['locat_bd_lbs_point']['radius'])) {
return Base::retError('请选择有效的签到位置');
// 人脸识别
if (in_array('face', $all['modes'])) {
Apps::isInstalledThrow('face');
}
}
}
@@ -485,7 +555,10 @@ class SystemController extends AbstractController
$setting['face_remark'] = $setting['face_remark'] ?: Doo::translate('考勤机');
$setting['face_retip'] = $setting['face_retip'] ?: 'open';
$setting['locat_remark'] = $setting['locat_remark'] ?: Doo::translate('定位签到');
$setting['locat_map_type'] = $setting['locat_map_type'] ?: 'baidu';
$setting['locat_bd_lbs_point'] = is_array($setting['locat_bd_lbs_point']) ? $setting['locat_bd_lbs_point'] : ['radius' => 500];
$setting['locat_amap_point'] = is_array($setting['locat_amap_point']) ? $setting['locat_amap_point'] : ['radius' => 500];
$setting['locat_tencent_point'] = is_array($setting['locat_tencent_point']) ? $setting['locat_tencent_point'] : ['radius' => 500];
$setting['manual_remark'] = $setting['manual_remark'] ?: Doo::translate('手动签到');
$setting['time'] = $setting['time'] ? Base::json2array($setting['time']) : ['09:00', '18:00'];
$setting['advance'] = intval($setting['advance']) ?: 120;
@@ -503,7 +576,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/apppush 08. 获取APP推送设置、保存APP推送设置限管理员
* @api {get} api/system/setting/apppush 09. 获取APP推送设置、保存APP推送设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -548,7 +621,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/thirdaccess 09. 第三方帐号(限管理员)
* @api {get} api/system/setting/thirdaccess 10. 第三方帐号(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -618,7 +691,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/file 10. 文件设置(限管理员)
* @api {get} api/system/setting/file 11. 文件设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -658,7 +731,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/demo 11. 获取演示帐号
* @api {get} api/system/demo 12. 获取演示帐号
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -682,7 +755,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/priority 12. 任务优先级
* @api {post} api/system/priority 13. 任务优先级
*
* @apiDescription 获取任务优先级、保存任务优先级
* @apiVersion 1.0.0
@@ -731,7 +804,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/column/template 13. 创建项目模板
* @api {post} api/system/column/template 14. 创建项目模板
*
* @apiDescription 获取创建项目模板、保存创建项目模板
* @apiVersion 1.0.0
@@ -778,7 +851,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/license 14. License
* @api {post} api/system/license 15. License
*
* @apiDescription 获取License信息、保存License限管理员
* @apiVersion 1.0.0
@@ -809,6 +882,7 @@ class SystemController extends AbstractController
'info' => Doo::license(),
'macs' => Doo::macs(),
'doo_sn' => Doo::dooSN(),
'doo_version' => Doo::dooVersion(),
'user_count' => User::whereBot(0)->whereNull('disable_at')->count(),
'error' => []
];
@@ -817,7 +891,7 @@ class SystemController extends AbstractController
if ($data['info']['sn'] != $data['doo_sn']) {
$data['error'][] = '终端SN与License不匹配';
}
if ($data['info']['mac']) {
if ($data['info']['mac'] && $data['macs']) {
$approved = false;
foreach ($data['info']['mac'] as $mac) {
if (in_array($mac, $data['macs'])) {
@@ -847,7 +921,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/info 15. 获取终端详细信息
* @api {get} api/system/get/info 16. 获取终端详细信息
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -866,8 +940,6 @@ class SystemController extends AbstractController
}
return Base::retSuccess('success', [
'ip' => Base::getIp(),
'ip-info' => Extranet::getIpInfo(Base::getIp()),
'ip-gcj02' => Extranet::getIpGcj02(Base::getIp()),
'ip-iscn' => Base::isCnIp(Base::getIp()),
'header' => Request::header(),
'token' => Doo::userToken(),
@@ -876,7 +948,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/ip 16. 获取IP地址
* @api {get} api/system/get/ip 17. 获取IP地址
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -891,7 +963,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/cnip 17. 是否中国IP地址
* @api {get} api/system/get/cnip 18. 是否中国IP地址
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -908,41 +980,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/ipgcj02 18. 获取IP地址经纬度
*
* @apiVersion 1.0.0
* @apiGroup system
* @apiName get__ipgcj02
*
* @apiParam {String} ip IP值
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function get__ipgcj02() {
return Extranet::getIpGcj02(Request::input("ip"));
}
/**
* @api {get} api/system/get/ipinfo 19. 获取IP地址详细信息
*
* @apiVersion 1.0.0
* @apiGroup system
* @apiName get__ipinfo
*
* @apiParam {String} ip IP值
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function get__ipinfo() {
return Extranet::getIpInfo(Request::input("ip"));
}
/**
* @api {post} api/system/imgupload 20. 上传图片
* @api {post} api/system/imgupload 19. 上传图片
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -951,7 +989,7 @@ class SystemController extends AbstractController
*
* @apiParam {File} image post-图片对象
* @apiParam {String} [image64] post-图片base64与'image'二选一)
* @apiParam {String} filename post-文件名
* @apiParam {String} [filename] post-文件名
* @apiParam {Number} [width] 压缩图片宽默认0
* @apiParam {Number} [height] 压缩图片高默认0
* @apiParam {String} [whcut] 压缩方式(等比缩放)
@@ -1008,7 +1046,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/imgview 21. 浏览图片空间
* @api {get} api/system/get/imgview 20. 浏览图片空间
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1105,16 +1143,16 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/fileupload 22. 上传文件
* @api {post} api/system/fileupload 21. 上传文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup system
* @apiName fileupload
*
* @apiParam {String} [image64] 图片base64
* @apiParam {String} filename 文件名
* @apiParam {String} [files] 文件名
* @apiParam {File} files 文件名
* @apiParam {String} [image64] 图片base64与'files'二选一)
* @apiParam {String} [filename] 文件名
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -1149,7 +1187,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/updatelog 23. 获取更新日志
* @api {get} api/system/get/updatelog 22. 获取更新日志
*
* @apiDescription 获取更新日志
* @apiVersion 1.0.0
@@ -1192,7 +1230,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/email/check 24. 邮件发送测试(限管理员)
* @api {get} api/system/email/check 23. 邮件发送测试(限管理员)
*
* @apiDescription 测试配置邮箱是否能发送邮件
* @apiVersion 1.0.0
@@ -1238,7 +1276,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/checkin/export 25. 导出签到数据(限管理员)
* @api {get} api/system/checkin/export 24. 导出签到数据(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1254,7 +1292,7 @@ class SystemController extends AbstractController
*/
public function checkin__export()
{
User::auth('admin');
$user = User::auth('admin');
//
$setting = Base::setting('checkinSetting');
if ($setting['open'] !== 'open') {
@@ -1284,130 +1322,183 @@ class SystemController extends AbstractController
$secondStart = strtotime("2000-01-01 {$time[0]}") - strtotime("2000-01-01 00:00:00");
$secondEnd = strtotime("2000-01-01 {$time[1]}") - strtotime("2000-01-01 00:00:00");
//
$headings = [];
$headings[] = Doo::translate('签到人');
$headings[] = Doo::translate('签到日期');
$headings[] = Doo::translate('班次时间');
$headings[] = Doo::translate('首次签到时间');
$headings[] = Doo::translate('首次签到结果');
$headings[] = Doo::translate('最后签到时间');
$headings[] = Doo::translate('最后签到结果');
$headings[] = Doo::translate('参数数据');
$botUser = User::botGetOrCreate('system-msg');
if (empty($botUser)) {
return Base::retError('系统机器人不存在');
}
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
//
$sheets = [];
$startD = Carbon::parse($date[0])->startOfDay();
$endD = Carbon::parse($date[1])->endOfDay();
$users = User::whereIn('userid', $userid)->take(100)->get();
/** @var User $user */
foreach ($users as $user) {
$recordTimes = UserCheckinRecord::getTimes($user->userid, [$startD, $endD]);
$doo = Doo::load();
go(function () use ($doo, $secondStart, $secondEnd, $time, $userid, $date, $user, $botUser, $dialog) {
Coroutine::sleep(1);
//
$nickname = Base::filterEmoji($user->nickname);
$styles = ["A1:H1" => ["font" => ["bold" => true]]];
$datas = [];
$startT = $startD->timestamp;
$endT = $endD->timestamp;
$index = 1;
while ($startT < $endT) {
$index++;
$sameDate = date("Y-m-d", $startT);
$sameTimes = $recordTimes[$sameDate] ?? [];
$sameCollect = UserCheckinRecord::atCollect($sameDate, $sameTimes);
$firstBetween = [Carbon::createFromTimestamp($startT), Carbon::createFromTimestamp($startT + $secondEnd - 1)];
$lastBetween = [Carbon::createFromTimestamp($startT + $secondStart + 1), Carbon::createFromTimestamp($startT + 86400)];
$firstRecord = $sameCollect?->whereBetween("datetime", $firstBetween)->first();
$lastRecord = $sameCollect?->whereBetween("datetime", $lastBetween)->last();
$firstTimestamp = $firstRecord['timestamp'] ?: 0;
$lastTimestamp = $lastRecord['timestamp'] ?: 0;
if (Timer::time() < $startT + $secondStart) {
$firstResult = "-";
} else {
$firstResult = Doo::translate("正常");
if (empty($firstTimestamp)) {
$firstResult = Doo::translate("缺卡");
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
} elseif ($firstTimestamp > $startT + $secondStart) {
$firstResult = Doo::translate("迟到");
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
$headings = [];
$headings[] = $doo->translate('签到人');
$headings[] = $doo->translate('签到日期');
$headings[] = $doo->translate('班次时间');
$headings[] = $doo->translate('首次签到时间');
$headings[] = $doo->translate('首次签到结果');
$headings[] = $doo->translate('最后签到时间');
$headings[] = $doo->translate('最后签到结果');
$headings[] = $doo->translate('参数数据');
//
$content = [];
$content[] = [
'content' => '导出签到数据已完成',
'style' => 'font-weight: bold;padding-bottom: 4px;',
];
//
$sheets = [];
$startD = Carbon::parse($date[0])->startOfDay();
$endD = Carbon::parse($date[1])->endOfDay();
$users = User::whereIn('userid', $userid)->take(100)->get();
/** @var User $user */
foreach ($users as $user) {
$recordTimes = UserCheckinRecord::getTimes($user->userid, [$startD, $endD]);
//
$nickname = Base::filterEmoji($user->nickname);
$styles = ["A1:H1" => ["font" => ["bold" => true]]];
$datas = [];
$startT = $startD->timestamp;
$endT = $endD->timestamp;
$index = 1;
while ($startT < $endT) {
$index++;
$sameDate = date("Y-m-d", $startT);
$sameTimes = $recordTimes[$sameDate] ?? [];
$sameCollect = UserCheckinRecord::atCollect($sameDate, $sameTimes);
$firstBetween = [Carbon::createFromTimestamp($startT), Carbon::createFromTimestamp($startT + $secondEnd - 1)];
$lastBetween = [Carbon::createFromTimestamp($startT + $secondStart + 1), Carbon::createFromTimestamp($startT + 86400)];
$firstRecord = $sameCollect?->whereBetween("datetime", $firstBetween)->first();
$lastRecord = $sameCollect?->whereBetween("datetime", $lastBetween)->last();
$firstTimestamp = $firstRecord['timestamp'] ?: 0;
$lastTimestamp = $lastRecord['timestamp'] ?: 0;
if (Timer::time() < $startT + $secondStart) {
$firstResult = "-";
} else {
$firstResult = $doo->translate("正常");
if (empty($firstTimestamp)) {
$firstResult = $doo->translate("缺卡");
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
} elseif ($firstTimestamp > $startT + $secondStart) {
$firstResult = $doo->translate("迟到");
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
}
}
}
if (Timer::time() < $startT + $secondEnd) {
$lastResult = "-";
$lastTimestamp = 0;
} else {
$lastResult = Doo::translate("正常");
if (empty($lastTimestamp) || $lastTimestamp === $firstTimestamp) {
$lastResult = Doo::translate("缺卡");
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
} elseif ($lastTimestamp < $startT + $secondEnd) {
$lastResult = Doo::translate("早退");
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
if (Timer::time() < $startT + $secondEnd) {
$lastResult = "-";
$lastTimestamp = 0;
} else {
$lastResult = $doo->translate("正常");
if (empty($lastTimestamp) || $lastTimestamp === $firstTimestamp) {
$lastResult = $doo->translate("缺卡");
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
} elseif ($lastTimestamp < $startT + $secondEnd) {
$lastResult = $doo->translate("早退");
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
}
}
$firstTimestamp = $firstTimestamp ? date("H:i", $firstTimestamp) : "-";
$lastTimestamp = $lastTimestamp ? date("H:i", $lastTimestamp) : "-";
$section = array_map(function($item) {
return $item[0] . "-" . ($item[1] ?: "None");
}, UserCheckinRecord::atSection($sameTimes));
$datas[] = [
"{$nickname} (ID: {$user->userid})",
$sameDate,
implode("-", $time),
$firstTimestamp,
$firstResult,
$lastTimestamp,
$lastResult,
implode(", ", $section),
];
$startT += 86400;
}
$firstTimestamp = $firstTimestamp ? date("H:i", $firstTimestamp) : "-";
$lastTimestamp = $lastTimestamp ? date("H:i", $lastTimestamp) : "-";
$section = array_map(function($item) {
return $item[0] . "-" . ($item[1] ?: "None");
}, UserCheckinRecord::atSection($sameTimes));
$datas[] = [
"{$nickname} (ID: {$user->userid})",
$sameDate,
implode("-", $time),
$firstTimestamp,
$firstResult,
$lastTimestamp,
$lastResult,
implode(", ", $section),
];
$startT += 86400;
$title = (count($sheets) + 1) . "." . ($nickname ?: $user->userid);
$sheets[] = BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles($styles);
}
$title = (count($sheets) + 1) . "." . ($nickname ?: $user->userid);
$sheets[] = BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles($styles);
}
if (empty($sheets)) {
return Base::retError('没有任何数据');
}
if (empty($sheets)) {
$content[] = [
'content' => '没有任何数据',
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
//
$fileName = $users[0]->nickname;
if (count($users) > 1) {
$fileName .= "" . count($userid) . "位成员的签到记录";
} else {
$fileName .= '的签到记录';
}
$fileName = $doo->translate($fileName) . '_' . Timer::time() . '.xlsx';
$filePath = "temp/checkin/export/" . date("Ym", Timer::time());
$export = new BillMultipleExport($sheets);
$res = $export->store($filePath . "/" . $fileName);
if ($res != 1) {
$content[] = [
'content' => "导出失败,{$fileName}",
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
return;
}
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xlsx') . ".zip";
$zipPath = storage_path($zipFile);
if (file_exists($zipPath)) {
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
} catch (\Throwable) {
}
//
if (file_exists($zipPath)) {
$key = Down::cache_encode([
'file' => $zipFile,
]);
$fileUrl = Base::fillUrl('api/system/checkin/down?key=' . $key);
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'file_download',
'title' => '导出签到数据已完成',
'name' => $fileName,
'size' => filesize($zipPath),
'url' => $fileUrl,
], $botUser->userid, true, false, true);
} else {
$content[] = [
'content' => "打包失败,请稍后再试...",
'style' => 'color: #ff0000;',
];
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $content[0]['content'],
'content' => $content,
], $botUser->userid, true, false, true);
}
});
//
$fileName = $users[0]->nickname;
if (count($users) > 1) {
$fileName .= "" . count($userid) . "位成员的签到记录";
} else {
$fileName .= '的签到记录';
}
$fileName = Doo::translate($fileName) . '_' . Timer::time() . '.xlsx';
$filePath = "temp/checkin/export/" . date("Ym", Timer::time());
$export = new BillMultipleExport($sheets);
$res = $export->store($filePath . "/" . $fileName);
if ($res != 1) {
return Base::retError('导出失败,' . $fileName . '');
}
$xlsPath = storage_path("app/" . $filePath . "/" . $fileName);
$zipFile = "app/" . $filePath . "/" . Base::rightDelete($fileName, '.xlsx') . ".zip";
$zipPath = storage_path($zipFile);
if (file_exists($zipPath)) {
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
} catch (\Throwable) {
}
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'content' => '正在导出签到数据,请稍等...',
], $botUser->userid, true, false, true);
//
if (file_exists($zipPath)) {
$base64 = base64_encode(Base::array2string([
'file' => $zipFile,
]));
Session::put('checkin::export:userid', $user->userid);
return Base::retSuccess('success', [
'size' => Base::twoFloat(filesize($zipPath) / 1024, true),
'url' => Base::fillUrl('api/system/checkin/down?key=' . urlencode($base64)),
]);
} else {
return Base::retError('打包失败,请稍后再试...');
}
return Base::retSuccess('success');
}
/**
* @api {get} api/system/checkin/down 26. 下载导出的签到数据
* @api {get} api/system/checkin/down 25. 下载导出的签到数据
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1419,21 +1510,16 @@ class SystemController extends AbstractController
*/
public function checkin__down()
{
$userid = Session::get('checkin::export:userid');
if (empty($userid)) {
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
}
//
$array = Base::string2array(base64_decode(urldecode(Request::input('key'))));
$array = Down::cache_decode();
$file = $array['file'];
if (empty($file) || !file_exists(storage_path($file))) {
return Base::ajaxError("文件不存在!", [], 0, 502);
return Base::ajaxError("文件不存在!", [], 0, 403);
}
return Response::download(storage_path($file));
}
/**
* @api {get} api/system/version 27. 获取版本号
* @api {get} api/system/version 26. 获取版本号
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1441,23 +1527,29 @@ class SystemController extends AbstractController
*
* @apiSuccessExample {json} Success-Response:
{
"version": "0.0.1",
"device_count": 3, // 设备数量
"version": "0.0.1", // 服务端版本号
"publish": {
"provider": "generic",
"url": ""
}
}
// 如果header请求中存在version字段则返回数据包裹在 {ret:1,data:{},msg:"success"} 中
*/
public function version()
{
$url = url('');
$package = Base::getPackage();
$array = [
'device_count' => 0,
'version' => Base::getVersion(),
'publish' => [],
];
if (Doo::userId()) {
$array['device_count'] = UserDevice::whereUserid(Doo::userId())->count();
}
if (is_array($package['app'])) {
$i = 0;
$url = url('');
foreach ($package['app'] as $item) {
$urls = $item['urls'] && is_array($item['urls']) ? $item['urls'] : $item['url'];
if (is_array($item['publish']) && ($i === 0 || Base::hostContrast($url, $urls))) {
@@ -1466,11 +1558,14 @@ class SystemController extends AbstractController
$i++;
}
}
if (Request::hasHeader('version')) {
return Base::retSuccess('success', $array);
}
return $array;
}
/**
* @api {get} api/system/prefetch 28. 预加载的资源
* @api {get} api/system/prefetch 27. 预加载的资源
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1510,11 +1605,13 @@ class SystemController extends AbstractController
}
// 添加office资源
$officePath = '';
$officeApi = 'http://' . env('APP_IPPR') . '.6/web-apps/apps/api/documents/api.js';
$content = @file_get_contents($officeApi);
if ($content) {
if (preg_match("/const\s+ver\s*=\s*'\/*([^']+)'/", $content, $matches)) {
$officePath = $matches[1];
if (Apps::isInstalled('office')) {
$officeApi = 'http://office/web-apps/apps/api/documents/api.js';
$content = @file_get_contents($officeApi);
if ($content) {
if (preg_match("/const\s+ver\s*=\s*'\/*([^']+)'/", $content, $matches)) {
$officePath = $matches[1];
}
}
}
if ($officePath) {
@@ -1529,6 +1626,18 @@ class SystemController extends AbstractController
return !str_starts_with($item, 'office/{path}/');
});
}
// 添加OKR资源
if (Apps::isInstalled('okr')) {
$okrContent = @file_get_contents("http://nginx/apps/okr/");
preg_match_all('/<script[^>]*src=["\']([^"\']+)["\'][^>]*>/i', $okrContent, $scriptMatches);
foreach ($scriptMatches[1] as $src) {
$array[] = $src;
}
preg_match_all('/<link[^>]*rel=["\']stylesheet["\'][^>]*href=["\']([^"\']+)["\'][^>]*>/i', $okrContent, $linkMatches);
foreach ($linkMatches[1] as $href) {
$array[] = $href;
}
}
}
return array_map(function($item) use ($version) {

View File

@@ -19,6 +19,7 @@ use App\Models\UserBot;
use App\Models\WebSocket;
use App\Models\UmengAlias;
use App\Models\UserDelete;
use App\Models\UserDevice;
use App\Models\UserTransfer;
use App\Models\AbstractModel;
use App\Models\UserCheckinFace;
@@ -154,7 +155,7 @@ class UsersController extends AbstractController
//
if (!Project::withTrashed()->whereUserid($user->userid)->wherePersonal(1)->exists()) {
Project::createProject([
'name' => Doo::translate('个人项目'),
'name' => "📝 " . Doo::translate('个人项目'),
'desc' => Doo::translate('注册时系统自动创建项目,你可以自由删除。'),
'personal' => 1,
], $user->userid);
@@ -267,7 +268,23 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/reg/needinvite 06. 是否需要邀请码
* @api {get} api/users/logout 06. 退出登录
*
* @apiVersion 1.0.0
* @apiGroup users
* @apiName logout
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
*/
public function logout()
{
UserDevice::forget();
return Base::retSuccess('退出成功');
}
/**
* @api {get} api/users/reg/needinvite 07. 是否需要邀请码
*
* @apiDescription 用于判断注册是否需要邀请码
* @apiVersion 1.0.0
@@ -286,7 +303,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/info 07. 获取我的信息
* @api {get} api/users/info 08. 获取我的信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -336,7 +353,52 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/editdata 08. 修改自己的资料
* @api {get} api/users/info/departments 09. 获取我的部门列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName info__departments
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccessExample {json} data:
[
{
"id": 1,
"name": "部门1",
"parent_id": 0,
"owner_userid": 1
},
]
*/
public function info__departments()
{
$user = User::auth();
// 获取部门列表
$list = UserDepartment::select(['id', 'owner_userid', 'parent_id', 'name'])
->whereIn('id', $user->department)
->take(10)
->get()
->toArray();
// 将 owner_userid 等于当前用户的部门排在前面
usort($list, function($a, $b) use ($user) {
if ($a['owner_userid'] == $user->userid && $b['owner_userid'] != $user->userid) {
return -1;
} elseif ($a['owner_userid'] != $user->userid && $b['owner_userid'] == $user->userid) {
return 1;
}
return 0;
});
return Base::retSuccess('success', $list);
}
/**
* @api {get} api/users/editdata 10. 修改自己的资料
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -428,7 +490,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/editpass 09. 修改自己的密码
* @api {get} api/users/editpass 11. 修改自己的密码
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -469,7 +531,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/search 10. 搜索会员列表
* @api {get} api/users/search 12. 搜索会员列表
*
* @apiDescription 搜索会员列表
* @apiVersion 1.0.0
@@ -477,18 +539,22 @@ class UsersController extends AbstractController
* @apiName searchinfo
*
* @apiParam {Object} keys 搜索条件
* - keys.key 昵称、拼音、邮箱关键字
* - keys.key 会员ID、昵称、拼音、邮箱关键字
* - keys.disable 0-排除离职默认1-仅离职2-含离职
* - keys.bot 0-排除机器人默认1-仅机器人2-含机器人
* - keys.project_id 在指定项目ID
* - keys.no_project_id 不在指定项目ID
* - keys.dialog_id 在指定对话ID
* - keys.departments 部门ID多个用逗号分隔
* @apiParam {Object} sorts 排序方式
* - sorts.az 按字母asc|desc
* @apiParam {Number} updated_time 在这个时间戳之后更新的
* @apiParam {Number} state 获取在线状态
* - 0: 不获取(默认)
* - 1: 获取会员在线状态返回数据多一个online值
* @apiParam {Number} [with_department] 是否返回部门信息
* - 0: 不返回部门信息(默认)
* - 1: 返回部门信息返回数据多一个department_info字段department_info={id, name, parent_id, owner_userid}
*
* @apiParam {Number} [take] 获取数量10-100
* @apiParam {Number} [page] 当前页,默认:1赋值分页模式take参数无效
@@ -511,12 +577,15 @@ class UsersController extends AbstractController
$sorts = Request::input('sorts');
$updatedTime = intval(Request::input('updated_time'));
$state = intval(Request::input('state', 0));
$withDepartment = intval(Request::input('with_department', 0));
$keys = is_array($keys) ? $keys : [];
$sorts = is_array($sorts) ? $sorts : [];
//
if ($keys['key']) {
if (str_contains($keys['key'], "@")) {
$builder->where("email", "like", "%{$keys['key']}%");
} elseif (Base::isNumber($keys['key'])) {
$builder->where("userid", intval($keys['key']));
} else {
$builder->where(function($query) use ($keys) {
$query->where("nickname", "like", "%{$keys['key']}%")
@@ -580,7 +649,7 @@ class UsersController extends AbstractController
$list = $builder->orderBy('userid')->take(Base::getPaginate(100, 10, 'take'))->get();
}
//
$list->transform(function (User $userInfo) use ($user, $state) {
$list->transform(function (User $userInfo) use ($user, $state, $withDepartment) {
$tags = [];
$dep = $userInfo->getDepartmentName();
$dep = array_values(array_filter(explode(",", $dep), function($item) {
@@ -605,13 +674,16 @@ class UsersController extends AbstractController
if ($state === 1) {
$userInfo->online = $userInfo->getOnlineStatus();
}
if ($withDepartment) {
$userInfo->department_info = UserDepartment::getDepartmentsByIds($userInfo->department);
}
return $userInfo;
});
return Base::retSuccess('success', $list);
}
/**
* @api {get} api/users/search/ai 11. 获取AI机器人
* @api {get} api/users/search/ai 13. 获取AI机器人
*
* @apiDescription 搜索会员列表
* @apiVersion 1.0.0
@@ -630,7 +702,7 @@ class UsersController extends AbstractController
//
$type = trim(Request::input('type'));
$botName = "ai-{$type}";
if (!UserBot::isAiBot("{$botName}@bot.system")) {
if (!UserBot::systemBotName($botName)) {
return Base::retError('AI机器人不存在');
}
//
@@ -642,7 +714,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/basic 12. 获取指定会员基础信息
* @api {get} api/users/basic 14. 获取指定会员基础信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -685,7 +757,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/lists 13. 会员列表(限管理员)
* @api {get} api/users/lists 15. 会员列表(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -834,7 +906,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/operation 14. 操作会员(限管理员)
* @api {get} api/users/operation 16. 操作会员(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -925,8 +997,7 @@ class UsersController extends AbstractController
return UserCheckinMac::saveMac($userInfo->userid, $array);
case 'checkin_face':
$faceimg = $data['checkin_face'] ? $data['checkin_face'] : '';
$faceimg = $data['checkin_face'] ?: '';
return UserCheckinFace::saveFace($userInfo->userid, $userInfo->nickname, $faceimg, "管理员上传");
case 'department':
@@ -1092,7 +1163,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/email/verification 15. 邮箱验证
* @api {get} api/users/email/verification 17. 邮箱验证
*
* @apiDescription 不需要token身份
* @apiVersion 1.0.0
@@ -1140,7 +1211,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/umeng/alias 16. 设置友盟别名
* @api {get} api/users/umeng/alias 18. 设置友盟别名
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1154,6 +1225,7 @@ class UsersController extends AbstractController
* @apiParam {String} [userAgent] 浏览器信息
* @apiParam {String} [deviceModel] 设备型号
* @apiParam {String} [isNotified] 是否有通知权限0不通知、1通知
* @apiParam {Number} [isDebug] 是否调试0不调试、1调试
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -1162,6 +1234,10 @@ class UsersController extends AbstractController
public function umeng__alias()
{
$data = Request::input();
// 判断是否调试
if (intval($data['isDebug'])) {
return Base::retError('调试模式下不允许使用');
}
// 表单验证
Base::validator($data, [
'alias.required' => '别名不能为空',
@@ -1192,6 +1268,7 @@ class UsersController extends AbstractController
$row->update([
'ua' => $data['userAgent'],
'device' => $data['deviceModel'],
'device_hash' => UserDevice::check(),
'version' => $version,
'is_notified' => $isNotified,
'updated_at' => Carbon::now()
@@ -1201,6 +1278,7 @@ class UsersController extends AbstractController
$row = UmengAlias::createInstance(array_merge($inArray, [
'ua' => $data['userAgent'],
'device' => $data['deviceModel'],
'device_hash' => UserDevice::check(),
'version' => $version,
'is_notified' => $isNotified,
]));
@@ -1212,7 +1290,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/meeting/open 17. 【会议】创建会议、加入会议
* @api {get} api/users/meeting/open 19. 【会议】创建会议、加入会议
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1330,7 +1408,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/meeting/link 18. 【会议】获取分享链接
* @api {get} api/users/meeting/link 20. 【会议】获取分享链接
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1359,7 +1437,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/meeting/tourist 19. 【会议】游客信息
* @api {get} api/users/meeting/tourist 21. 【会议】游客信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1382,7 +1460,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/meeting/invitation 20. 【会议】发送邀请
* @api {get} api/users/meeting/invitation 22. 【会议】发送邀请
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1429,7 +1507,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/email/send 21. 发送邮箱验证码
* @api {get} api/users/email/send 23. 发送邮箱验证码
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1469,7 +1547,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/email/edit 22. 修改邮箱
* @api {get} api/users/email/edit 24. 修改邮箱
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1514,7 +1592,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/delete/account 23. 删除帐号
* @api {get} api/users/delete/account 25. 删除帐号
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1576,7 +1654,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/department/list 24. 部门列表(限管理员)
* @api {get} api/users/department/list 26. 部门列表(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1595,7 +1673,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/department/add 25. 新建、修改部门(限管理员)
* @api {get} api/users/department/add 27. 新建、修改部门(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1681,7 +1759,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/department/del 26. 删除部门(限管理员)
* @api {get} api/users/department/del 28. 删除部门(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1714,7 +1792,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/checkin/get 27. 获取签到设置
* @api {get} api/users/checkin/get 29. 获取签到设置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1741,7 +1819,7 @@ class UsersController extends AbstractController
}
/**
* @api {post} api/users/checkin/save 28. 保存签到设置
* @api {post} api/users/checkin/save 30. 保存签到设置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1816,7 +1894,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/checkin/list 29. 获取签到数据
* @api {get} api/users/checkin/list 31. 获取签到数据
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1863,7 +1941,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/socket/status 30. 获取socket状态
* @api {get} api/users/socket/status 32. 获取socket状态
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1886,7 +1964,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/key/client 31. 客户端KEY
* @api {get} api/users/key/client 33. 客户端KEY
*
* @apiDescription 获取客户端KEY用于加密数据发送给服务端
* @apiVersion 1.0.0
@@ -1928,7 +2006,51 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/bot/info 32. 机器人信息
* @api {get} api/users/bot/list 34. 机器人列表
*
* @apiDescription 需要token身份获取我的机器人列表
* @apiVersion 1.0.0
* @apiGroup users
* @apiName bot__list
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function bot__list()
{
// 获取当前认证用户
$user = User::auth();
// 使用连表查询一次性获取所有机器人数据
$bots = User::join('user_bots', 'user_bots.bot_id', '=', 'users.userid')
->where('user_bots.userid', $user->userid)
->select([
'users.userid',
'users.nickname',
'users.userimg',
'user_bots.clear_day',
'user_bots.webhook_url'
])
->orderByDesc('id')
->get()
->toArray();
foreach ($bots as &$bot) {
$bot['id'] = $bot['userid'];
$bot['name'] = $bot['nickname'];
$bot['avatar'] = $bot['userimg'];
$bot['system_name'] = UserBot::systemBotName($bot['name']);
unset($bot['userid'], $bot['nickname'], $bot['userimg']);
}
// 返回成功响应将机器人列表包装在list字段中
return Base::retSuccess('success', [
'list' => $bots
]);
}
/**
* @api {get} api/users/bot/info 35. 机器人信息
*
* @apiDescription 需要token身份获取我的机器人信息
* @apiVersion 1.0.0
@@ -1979,16 +2101,21 @@ class UsersController extends AbstractController
}
/**
* @api {post} api/users/bot/edit 33. 编辑机器人
* @api {post} api/users/bot/edit 36. 添加、编辑机器人
*
* @apiDescription 需要token身份编辑 我的机器人 或 管理员修改系统机器人 信息
* @apiVersion 1.0.0
* @apiGroup users
* @apiName bot__edit
*
* @apiParam {Number} id 机器人ID
* @apiParam {Number} [id] 机器人ID(编辑时必填,留空为添加)
* @apiParam {String} [name] 机器人名称
* @apiParam {String} [avatar] 机器人头像
* @apiParam {Number} [session] 开启新会话功能(仅 我的机器人)
* - 1开启、0关闭, 默认0
* - 此参数仅在添加机器人时有效
* - 开启后,机器人对话窗口会出现新会话菜单和历史会话菜单
* - 开启后webhook_url 消息会多一个 session_id 字段
* @apiParam {Number} [clear_day] 清理天数(仅 我的机器人)
* @apiParam {String} [webhook_url] Webhook地址仅 我的机器人)
*
@@ -2001,10 +2128,20 @@ class UsersController extends AbstractController
$user = User::auth();
//
$botId = intval(Request::input('id'));
$botUser = User::whereUserid($botId)->whereBot(1)->first();
if (empty($botUser)) {
return Base::retError('机器人不存在');
$session = intval(Request::input('session'));
if (empty($botId)) {
$res = UserBot::newBot($user->userid, trim(Request::input('name')), (bool)$session);
if (Base::isError($res)) {
return $res;
}
$botUser = $res['data'];
} else {
$botUser = User::whereUserid($botId)->whereBot(1)->first();
if (empty($botUser)) {
return Base::retError('机器人不存在');
}
}
//
$userBot = UserBot::whereBotId($botUser->userid)->whereUserid($user->userid)->first();
if (empty($userBot)) {
if (UserBot::systemBotName($botUser->email)) {
@@ -2022,10 +2159,10 @@ class UsersController extends AbstractController
$upUser = [];
$upBot = [];
//
if (isset($data['name'])) {
if (Arr::exists($data, 'name')) {
$upUser['nickname'] = trim($data['name']);
}
if (isset($data['avatar'])) {
if (Arr::exists($data, 'avatar')) {
$avatar = $data['avatar'];
$avatar = $avatar ? Base::unFillUrl(is_array($avatar) ? $avatar[0]['path'] : $avatar) : '';
if (str_contains($avatar, 'avatar/')) {
@@ -2033,10 +2170,10 @@ class UsersController extends AbstractController
}
$upUser['userimg'] = $avatar;
}
if (isset($data['clear_day'])) {
if (Arr::exists($data, 'clear_day')) {
$upBot['clear_day'] = min(max(intval($data['clear_day']), 1), 999);
}
if (isset($data['webhook_url'])) {
if (Arr::exists($data, 'webhook_url')) {
$upBot['webhook_url'] = trim($data['webhook_url']);
}
//
@@ -2061,17 +2198,68 @@ class UsersController extends AbstractController
$data['clear_day'] = $userBot->clear_day;
$data['webhook_url'] = $userBot->webhook_url;
}
return Base::retSuccess('修改成功', $data);
return Base::retSuccess($botId ? '修改成功' : '添加成功', $data);
}
/**
* @api {get} api/users/share/list 34. 获取分享列表
* @api {get} api/users/bot/delete 37. 删除机器人
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName bot__delete
*
* @apiParam {Number} id 机器人ID
* @apiParam {String} remark 删除备注
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function bot__delete()
{
$user = User::auth();
//
$botId = intval(Request::input('id'));
$remark = trim(Request::input('remark'));
//
if (empty($remark)) {
return Base::retError('请输入删除备注');
}
if (mb_strlen($remark) > 255) {
return Base::retError('删除备注长度限制255个字');
}
//
$botUser = User::whereUserid($botId)->whereBot(1)->first();
if (empty($botUser)) {
return Base::retError('机器人不存在');
}
$userBot = UserBot::whereBotId($botUser->userid)->whereUserid($user->userid)->first();
if (empty($userBot)) {
if (UserBot::systemBotName($botUser->email)) {
// 系统机器人(仅限管理员)
return Base::retError('系统机器人不能删除');
} else {
// 其他用户的机器人(仅限主人)
return Base::retError('不是你的机器人');
}
}
//
if (!$botUser->deleteUser($remark)) {
return Base::retError('删除失败');
}
return Base::retSuccess('删除成功');
}
/**
* @api {get} api/users/share/list 38. 获取分享列表
*
* @apiVersion 1.0.0
* @apiGroup users
* @apiName share__list
*
* @apiParam {String} [type] 分享类型file-文件text-列表 默认file
* @apiParam {String} [key] 搜索关键词(用于搜索会话)
* @apiParam {Number} [pid] 父级文件id用于获取子目录和上传到指定目录的id
* @apiParam {Number} [upload_file_id] 上传文件id
*
@@ -2083,6 +2271,7 @@ class UsersController extends AbstractController
{
$user = User::auth();
$type = Request::input('type', 'file');
$key = Request::input('key');
$pid = intval(Request::input('pid', -1));
$uploadFileId = intval(Request::input('upload_file_id', -1));
// 上传文件
@@ -2115,10 +2304,14 @@ class UsersController extends AbstractController
'icon' => url("images/file/light/folder.png"),
'extend' => ['upload_file_id' => 0],
'name' => Doo::translate('文件'),
'sort' => Carbon::parse("9999")->timestamp,
];
}
$dialogList = WebSocketDialog::getDialogList($user->userid);
foreach ($dialogList['data'] as $dialog) {
$dialogTake = 50;
$dialogList = WebSocketDialog::searchDialog($user->userid, $key, $dialogTake);
$dialogIds = [];
$itemUrl = $type == "file" ? Base::fillUrl("api/dialog/msg/sendfiles") : Base::fillUrl("api/dialog/msg/sendtext");
foreach ($dialogList as $dialog) {
if ($dialog['avatar']) {
$avatar = url($dialog['avatar']);
} else if ($dialog['type'] == 'user') {
@@ -2135,7 +2328,8 @@ class UsersController extends AbstractController
'type' => 'item',
'name' => $dialog['name'],
'icon' => $avatar,
'url' => $type == "file" ? Base::fillUrl("api/dialog/msg/sendfiles") : Base::fillUrl("api/dialog/msg/sendtext"),
'url' => $itemUrl,
'sort' => Carbon::parse($dialog['last_at'])->timestamp,
'extend' => [
'dialog_ids' => $dialog['id'],
'text_type' => 'text',
@@ -2143,6 +2337,33 @@ class UsersController extends AbstractController
'silence' => 'no'
]
];
$dialogIds[] = $dialog['id'];
}
if ($key && count($dialogList) < $dialogTake) {
$dialogUsers = User::searchUser($key, $dialogTake - count($dialogList));
foreach ($dialogUsers as $item) {
$dialog = WebSocketDialog::getUserDialog($user->userid, $item->userid, now()->addDay());
if ($dialog && !in_array($dialog->id, $dialogIds)) {
$lists[] = [
'type' => 'item',
'name' => $item->nickname,
'icon' => $item->userimg,
'url' => $itemUrl,
'sort' => Carbon::parse($item->line_at)->timestamp,
'extend' => [
'dialog_ids' => $dialog->id,
'text_type' => 'text',
'reply_id' => 0,
'silence' => 'no'
]
];
$dialogIds[] = $dialog->id;
}
}
// 根据 $lists sort 从大到小排序
usort($lists, function ($a, $b) {
return $b['sort'] <=> $a['sort'];
});
}
}
// 返回
@@ -2150,7 +2371,7 @@ class UsersController extends AbstractController
}
/**
* @api {get} api/users/annual/report 35. 年度报告
* @api {get} api/users/annual/report 39. 年度报告
*
* @apiVersion 1.0.0
* @apiGroup users
@@ -2317,4 +2538,97 @@ class UsersController extends AbstractController
//
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/users/device/list 40. 获取设备列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName device__list
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function device__list()
{
$user = User::auth();
//
$list = UserDevice::whereUserid($user->userid)->orderByDesc('id')->take(UserDevice::$deviceLimit)->get();
//
return Base::retSuccess('success', [
'list' => $list
]);
}
/**
* @api {get} api/users/device/logout 41. 登出设备(删除设备)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName device__logout
*
* @apiParam {Number} id 设备id
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function device__logout()
{
$user = User::auth();
//
$id = intval(Request::input('id'));
if (empty($id)) {
return Base::retError('参数错误');
}
$userDevice = UserDevice::whereUserid($user->userid)->whereId($id)->first();
if (empty($userDevice)) {
return Base::retError('设备不存在或已被删除');
}
UserDevice::forget($userDevice->id);
//
return Base::retSuccess('操作成功');
}
/**
* @api {get} api/users/device/edit 42. 编辑设备
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName device__edit
*
* @apiParam {Object} detail 设备信息
* @apiParam {String} detail.device_name 设备名称
* @apiParam {String} detail.app_brand 设备品牌
* @apiParam {String} detail.app_model 设备型号
* @apiParam {String} detail.app_os 设备操作系统
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function device__edit()
{
User::auth();
//
$detail = Request::input();
$detail = array_intersect_key($detail, array_flip([ 'device_name', 'app_brand', 'app_model','app_os']));
if (empty($detail)) {
return Base::retError('参数错误');
}
//
$row = UserDevice::record();
if (empty($row)) {
return Base::retError('设备不存在或已被删除');
}
$deviceInfo = array_merge(Base::json2array($row->detail), $detail);
$row->detail = Base::array2json($deviceInfo);
$row->save();
//
return Base::retSuccess('保存成功');
}
}

View File

@@ -8,8 +8,6 @@ use Request;
use Redirect;
use Response;
use App\Models\File;
use App\Models\User;
use App\Models\UserTransfer;
use App\Module\Doo;
use App\Module\Base;
use App\Module\Extranet;
@@ -23,11 +21,10 @@ use App\Tasks\AutoArchivedTask;
use App\Tasks\DeleteBotMsgTask;
use App\Tasks\CheckinRemindTask;
use App\Tasks\CloseMeetingRoomTask;
use App\Tasks\ElasticSearchSyncTask;
use App\Tasks\ZincSearchSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar;
use Swoole\Coroutine;
/**
@@ -86,6 +83,15 @@ class IndexController extends InvokeController
return Redirect::to(Base::fillUrl('api/system/version'), 301);
}
/**
* 健康检查
* @return string
*/
public function health()
{
return "ok";
}
/**
* 头像
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response|\Symfony\Component\HttpFoundation\BinaryFileResponse
@@ -242,11 +248,12 @@ class IndexController extends InvokeController
// App推送
Task::deliver(new AppPushTask());
// 删除过期的临时表数据
Task::deliver(new DeleteTmpTask('wg_tmp_msgs', 1));
Task::deliver(new DeleteTmpTask('task_worker', 12));
Task::deliver(new DeleteTmpTask('tmp_msgs', 1));
Task::deliver(new DeleteTmpTask('tmp'));
Task::deliver(new DeleteTmpTask('task_worker', 12));
Task::deliver(new DeleteTmpTask('file'));
Task::deliver(new DeleteTmpTask('tmp_file', 24));
Task::deliver(new DeleteTmpTask('user_device', 24));
// 删除机器人消息
Task::deliver(new DeleteBotMsgTask());
// 周期任务
@@ -259,8 +266,8 @@ class IndexController extends InvokeController
Task::deliver(new UnclaimedTaskRemindTask());
// 关闭会议室
Task::deliver(new CloseMeetingRoomTask());
// ElasticSearch 同步
Task::deliver(new ElasticSearchSyncTask());
// ZincSearch 同步
Task::deliver(new ZincSearchSyncTask());
return "success";
}
@@ -324,7 +331,7 @@ class IndexController extends InvokeController
"file" => Request::file('file'),
"type" => 'publish',
"path" => $draftPath,
"fileName" => true,
"saveName" => true,
]);
}
@@ -345,9 +352,7 @@ class IndexController extends InvokeController
break;
}
}
if (empty($avaiPath)) {
abort(404);
}
abort_if(empty($avaiPath), 404);
$lists = Base::recursiveFiles($dirPath, false);
$files = [];
foreach ($lists as $file) {
@@ -425,13 +430,9 @@ class IndexController extends InvokeController
$path = Arr::get($data, 'path');
$file = public_path($path);
// 防止 ../ 穿越获取到系统文件
if (!str_starts_with(realpath($file), public_path())) {
abort(404);
}
//
if (!file_exists($file)) {
abort(404);
}
abort_if(!str_starts_with(realpath($file), public_path()), 404);
// 如果文件不存在,直接返回 404
abort_if(!file_exists($file), 404);
//
parse_str($data['query'], $query);
$name = Arr::get($query, 'name');
@@ -471,7 +472,7 @@ class IndexController extends InvokeController
action: "eeuiAppSendMessage",
data: [
{
action: 'setPageData',
action: 'setPageData', // 设置页面数据
data: {
showProgress: true,
titleFixed: true,
@@ -479,7 +480,7 @@ class IndexController extends InvokeController
}
},
{
action: 'createTarget',
action: 'createTarget', // 创建目标(访问新地址)
url: "{$redirectUrl}",
}
]
@@ -493,7 +494,7 @@ class IndexController extends InvokeController
if (in_array($ext, File::localExt)) {
$url = Base::fillUrl($path);
} else {
$url = 'http://' . env('APP_IPPR') . '.3/' . $path;
$url = 'http://nginx/' . $path;
}
$url = Base::urlAddparameter($url, [
'fullfilename' => Base::rightDelete($name, '.' . $ext) . '_' . filemtime($file) . '.' . $ext
@@ -501,43 +502,4 @@ class IndexController extends InvokeController
$redirectUrl = Base::fillUrl("fileview/onlinePreview?url=" . urlencode(base64_encode($url)));
return Redirect::to($redirectUrl, 301);
}
/**
* 修复操作离职后续操作(todo 临时,后期删除)
* @return array
*/
public function migration__userdialog()
{
if (Request::header('app-key') !== env('APP_KEY')) {
return Base::retError("key error");
}
go(function() {
Coroutine::sleep(3);
$handled = [];
UserTransfer::orderBy('id')->chunkById(10, function ($transfers) use ($handled) {
/** @var UserTransfer $transfer */
foreach ($transfers as $transfer) {
if (in_array($transfer->original_userid, $handled)) {
continue;
}
$handled[] = $transfer->original_userid;
//
$user = User::find($transfer->original_userid);
if ($user?->isDisable()) {
$transfer->exitDialog();
}
}
});
});
return Base::retSuccess('success');
}
/**
* 保存配置 (todo 已废弃)
* @return string
*/
public function storage__synch()
{
return '<!-- Deprecated -->';
}
}

View File

@@ -19,11 +19,13 @@ class WebApi
*/
public function handle($request, Closure $next)
{
// 为每个请求生成唯一ID
$request->requestId = RequestContext::generateRequestId();
// 记录请求信息
RequestContext::set('start_time', microtime(true));
RequestContext::set('header_language', $request->header('language'));
// 更新请求的基本URL
RequestContext::updateBaseUrl($request);
// 加载Doo类
Doo::load();
@@ -73,6 +75,6 @@ class WebApi
public function terminate()
{
// 请求结束后清理上下文
RequestContext::clear();
RequestContext::clean();
}
}

View File

@@ -151,6 +151,25 @@ class AbstractModel extends Model
return $date->format($this->dateFormat ?: 'Y-m-d H:i:s');
}
/**
* 通过模型创建实例
* @param array $param
* @param bool $force
* @return static
*/
public static function fillInstance(array $param = [], bool $force = true)
{
$instance = new static;
if ($param) {
if ($force) {
$instance->forceFill($param);
} else {
$instance->fill($param);
}
}
return $instance;
}
/**
* 创建/更新数据
* @param array $param
@@ -209,24 +228,35 @@ class AbstractModel extends Model
/**
* 数据库更新或插入
* @param $where
* @param array|\Closure $update 存在时更新的内容
* @param array|\Closure $insert 不存在时插入的内容,如果没有则插入更新内容
* @param bool $isInsert 是否是插入数据
* @param array $where 查询条件
* @param array|\Closure $update 存在时更新的内容
* @param array|\Closure $insert 不存在时插入的内容,如果没有则插入更新内容
* @param bool $isInsert 是否是插入数据
* @param bool|null $lockForUpdate 是否加锁true:加锁false:不加锁null:在事务中会自动加锁)
* @return AbstractModel|\Illuminate\Database\Eloquent\Builder|Model|object|static|null
*/
public static function updateInsert($where, $update = [], $insert = [], &$isInsert = true)
public static function updateInsert($where, $update = [], $insert = [], &$isInsert = true, $lockForUpdate = null)
{
$row = static::where($where)->first();
$query = static::where($where);
if ($lockForUpdate === null) {
$lockForUpdate = \DB::transactionLevel() > 0;
}
if ($lockForUpdate) {
$query->lockForUpdate();
}
$row = $query->first();
if (empty($row)) {
$row = new static;
if ($update instanceof \Closure) {
$update = $update();
}
if ($insert instanceof \Closure) {
$insert = $insert();
}
$array = array_merge($where, $insert ?: $update);
if (empty($insert)) {
if ($update instanceof \Closure) {
$update = $update();
}
$insert = $update;
}
$array = array_merge($where, $insert);
if (isset($array[$row->primaryKey])) {
unset($array[$row->primaryKey]);
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use Request;
use App\Module\Apps;
use App\Module\Base;
use App\Tasks\PushTask;
use App\Exceptions\ApiException;
@@ -117,7 +118,7 @@ class File extends AbstractModel
'jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'ico', 'raw',
'tif', 'tiff',
'mp3', 'wav', 'mp4', 'flv',
'avi', 'mov', 'wmv', 'mkv', '3gp', 'rm',
// 'avi', 'mov', 'wmv', 'mkv', '3gp', 'rm', // 这一排是要转换的,无法使用本地播放
];
/**
@@ -641,6 +642,29 @@ class File extends AbstractModel
Task::deliver($task);
}
/**
* 文件推送消息
* @param $action
* @param array|null $data 发送内容
* @param int $userid 会员ID
*/
public static function pushMsgSimple($action, $data, $userid)
{
if (empty($data) || empty($userid)) {
return;
}
$msg = [
'type' => 'file',
'action' => $action,
'data' => $data,
];
$params = [
'userid' => $userid,
'msg' => $msg
];
Task::deliver(new PushTask($params));
}
/**
* 获取推送会员
* @param $action
@@ -956,26 +980,39 @@ class File extends AbstractModel
}
/**
* 文件推送消息
* @param $action
* @param array|null $data 发送内容
* @param array $userid 会员ID
* 根据文件类型判断是否需要安装应用
* @param $type
* @return void
*/
public static function filePushMsg($action, $data = null, $userid = null)
public static function isNeedInstallApp($type): void
{
$userid = User::userid();
if (empty($userid)) {
return;
// 文件类型与应用的映射配置
$fileTypeAppMapping = [
// Office 应用映射
[
'types' => ['word', 'excel', 'ppt', 'docx', 'xlsx', 'pptx'],
'app_id' => 'office',
'app_name' => 'OnlyOffice'
],
// Drawio 应用映射
[
'types' => ['drawio'],
'app_id' => 'drawio',
'app_name' => 'Drawio'
],
// Minder 应用映射
[
'types' => ['mind'],
'app_id' => 'minder',
'app_name' => 'Minder'
]
];
// 遍历配置检查是否需要安装应用
foreach ($fileTypeAppMapping as $config) {
if (in_array($type, $config['types'])) {
Apps::isInstalledThrow($config['app_id']);
}
}
$msg = [
'type' => 'file',
'action' => $action,
'data' => $data,
];
$params = [
'userid' => $userid,
'msg' => $msg
];
Task::deliver(new PushTask($params));
}
}

View File

@@ -129,9 +129,7 @@ class FileContent extends AbstractModel
],
default => json_decode('{}'),
};
if ($download) {
abort(403, "This file is empty.");
}
abort_if($download, 403, "This file is empty.");
} else {
$path = $content['url'];
if ($file->ext) {
@@ -147,11 +145,8 @@ class FileContent extends AbstractModel
}
if ($download) {
$filePath = public_path($path);
if (isset($filePath)) {
return Base::DownloadFileResponse($filePath, $name);
} else {
abort(403, "This file not support download.");
}
abort_if(!isset($filePath),403, "This file not support download.");
return Base::DownloadFileResponse($filePath, $name);
}
}
return Base::retSuccess('success', [ 'content' => $content ]);

View File

@@ -221,6 +221,7 @@ class Project extends AbstractModel
'important' => 1
], function () use ($userid) {
return [
'important' => 1,
'bot' => User::isBot($userid) ? 1 : 0,
];
});
@@ -422,24 +423,25 @@ class Project extends AbstractModel
$projectUserids = $this->relationUserids();
foreach ($flows as $item) {
$id = intval($item['id']);
$name = trim(str_replace('|', '·', $item['name']));
$turns = Base::arrayRetainInt($item['turns'] ?: [], true);
$userids = Base::arrayRetainInt($item['userids'] ?: [], true);
$usertype = trim($item['usertype']);
$userlimit = intval($item['userlimit']);
$columnid = intval($item['columnid']);
if ($usertype == 'replace' && empty($userids)) {
throw new ApiException("状态[{$item['name']}]设置错误,设置流转模式时必须填写状态负责人");
throw new ApiException("状态[{$name}]设置错误,设置流转模式时必须填写状态负责人");
}
if ($usertype == 'merge' && empty($userids)) {
throw new ApiException("状态[{$item['name']}]设置错误,设置剔除模式时必须填写状态负责人");
throw new ApiException("状态[{$name}]设置错误,设置剔除模式时必须填写状态负责人");
}
if ($userlimit && empty($userids)) {
throw new ApiException("状态[{$item['name']}]设置错误,设置限制负责人时必须填写状态负责人");
throw new ApiException("状态[{$name}]设置错误,设置限制负责人时必须填写状态负责人");
}
foreach ($userids as $userid) {
if (!in_array($userid, $projectUserids)) {
$nickname = User::userid2nickname($userid);
throw new ApiException("状态[{$item['name']}]设置错误,状态负责人[{$nickname}]不在项目成员内");
throw new ApiException("状态[{$name}]设置错误,状态负责人[{$nickname}]不在项目成员内");
}
}
$flow = ProjectFlowItem::updateInsert([
@@ -447,8 +449,9 @@ class Project extends AbstractModel
'project_id' => $this->id,
'flow_id' => $projectFlow->id,
], [
'name' => trim($item['name']),
'name' => $name,
'status' => trim($item['status']),
'color' => trim($item['color']),
'sort' => intval($item['sort']),
'turns' => $turns,
'userids' => $userids,
@@ -468,7 +471,7 @@ class Project extends AbstractModel
$hasEnd = true;
}
if (!$isInsert) {
$upTaskList[$flow->id] = $flow->status . "|" . $flow->name;
$upTaskList[$flow->id] = $flow->status . "|" . $flow->name . "|" . $flow->color;
}
}
}
@@ -589,7 +592,7 @@ class Project extends AbstractModel
$project->save();
//
if ($flow == 'open') {
$project->addFlow(Base::json2array('[{"id":-10,"name":"待处理","status":"start","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-11,"name":"进行中","status":"progress","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-12,"name":"待测试","status":"test","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-13,"name":"已完成","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-14,"name":"已取消","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0}]'));
$project->addFlow(Base::json2array('[{"id":-10,"name":"待处理","status":"start","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-11,"name":"进行中","status":"progress","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-12,"name":"待测试","status":"test","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-13,"name":"已完成","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-14,"name":"已取消","status":"end","color":"#999999","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0}]'));
}
});
//

View File

@@ -12,6 +12,7 @@ use App\Module\Base;
* @property int|null $flow_id 流程ID
* @property string|null $name 名称
* @property string|null $status 状态
* @property string|null $color 自定义颜色
* @property array $turns 可流转
* @property array $userids 状态负责人ID
* @property string|null $usertype 流转模式
@@ -38,6 +39,7 @@ use App\Module\Base;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereTurns($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereUserids($value)

View File

@@ -128,8 +128,8 @@ class ProjectPermission extends AbstractModel
/**
* 更新项目权限
*
* @param int $projectId
* @param array $permissions
* @param int $projectId
* @param $newPermissions
* @return ProjectPermission
*/
public static function updatePermissions($projectId, $newPermissions)
@@ -146,9 +146,9 @@ class ProjectPermission extends AbstractModel
/**
* 检查用户是否有执行特定动作的权限
* @param string $action 动作名称
* @param Project $project 项目实例
* @param ProjectTask $task 任务实例
* @param string $action 动作名称
* @param ProjectTask|null $task 任务实例
* @return bool
*/
public static function userTaskPermission(Project $project, $action, ProjectTask $task = null)

View File

@@ -36,7 +36,6 @@ namespace App\Models;
class ProjectTag extends AbstractModel
{
protected $hidden = [
'created_at',
'updated_at',
];

View File

@@ -12,7 +12,6 @@ use App\Tasks\PushTask;
use App\Exceptions\ApiException;
use App\Observers\ProjectTaskObserver;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use League\HTMLToMarkdown\HtmlConverter;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
@@ -486,7 +485,7 @@ class ProjectTask extends AbstractModel
foreach ($projectFlowItem as $item) {
if ($item->status == 'start') {
$task->flow_item_id = $item->id;
$task->flow_item_name = $item->status . "|" . $item->name;
$task->flow_item_name = $item->status . "|" . $item->name . "|" . $item->color;
$owner = array_merge($owner, $item->userids);
break;
}
@@ -650,7 +649,7 @@ class ProjectTask extends AbstractModel
$data['column_id'] = $newFlowItem->columnid;
}
$this->flow_item_id = $newFlowItem->id;
$this->flow_item_name = $newFlowItem->status . "|" . $newFlowItem->name;
$this->flow_item_name = $newFlowItem->status . "|" . $newFlowItem->name . "|" . $newFlowItem->color;
$this->addLog("修改{任务}状态", [
'flow' => $flowData,
'change' => [$currentFlowItem?->name, $newFlowItem->name]
@@ -735,7 +734,9 @@ class ProjectTask extends AbstractModel
if (count($older) == 0 && count($array) == 1 && $array[0] == User::userid()) {
$this->addLog("认领{任务}");
} else {
$this->addLog("修改{任务}负责人", ['userid' => $array]);
if (array_merge(array_diff($array, $older), array_diff($older, $array))) {
$this->addLog("修改{任务}负责人", ['userid' => $array]);
}
}
$this->taskPush(array_values(array_diff($array, $older)), 0);
}
@@ -845,11 +846,12 @@ class ProjectTask extends AbstractModel
$existAt = $this->start_at && !$clearSubTaskTime;
$newStringAt = $existAt ? ($this->start_at->toDateTimeString() . '~' . $this->end_at->toDateTimeString()) : '';
if ($isOverdue) {
$effectiveEndTime = $existAt ? Carbon::parse($this->end_at)->min(Carbon::now()) : Carbon::now();
$this->addLog("{任务}超期未完成", [
'cache' => [
'task_at' => $oldStringAt,
'change_at' => $newStringAt,
'over_sec' => ($existAt ? $this->end_at : Carbon::now())->diffInSeconds($oldAt[1]),
'over_sec' => $effectiveEndTime->diffInSeconds($oldAt[1]),
'owners' => $this->taskUser->where('owner', 1)->pluck('userid')->toArray(),
'assists' => $this->taskUser->where('owner', 0)->pluck('userid')->toArray(),
]
@@ -889,6 +891,7 @@ class ProjectTask extends AbstractModel
}
// 协助人员
if (Arr::exists($data, 'assist')) {
$older = $this->taskUser->where('owner', 0)->pluck('userid')->toArray();
$array = [];
$assist = is_array($data['assist']) ? $data['assist'] : [$data['assist']];
if (count($assist) > 10) {
@@ -909,7 +912,9 @@ class ProjectTask extends AbstractModel
$array[] = $uid;
}
if ($array) {
$this->addLog("修改{任务}协助人员", ['userid' => $array]);
if (array_merge(array_diff($array, $older), array_diff($older, $array))) {
$this->addLog("修改{任务}协助人员", ['userid' => $array]);
}
}
$rows = ProjectTaskUser::whereTaskId($this->id)->whereOwner(0)->whereNotIn('userid', $array)->get();
if ($rows->isNotEmpty()) {
@@ -1138,9 +1143,6 @@ class ProjectTask extends AbstractModel
*/
public function copyTask()
{
if ($this->parent_id > 0) {
throw new ApiException('子任务禁止复制');
}
return AbstractModel::transaction(function() {
// 复制任务
$task = $this->replicate();
@@ -1201,6 +1203,7 @@ class ProjectTask extends AbstractModel
'important' => 1
], function () use ($userid) {
return [
'important' => 1,
'bot' => User::isBot($userid) ? 1 : 0,
];
});
@@ -1347,6 +1350,9 @@ class ProjectTask extends AbstractModel
$addMsg = $this->parent_id == 0 && $this->dialog_id > 0;
if ($complete_at === null) {
// 标记未完成
if (!$this->complete_at) {
return; // 本来就未完成
}
$this->complete_at = null;
$this->addLog("标记{任务}未完成");
if ($addMsg) {
@@ -1356,6 +1362,9 @@ class ProjectTask extends AbstractModel
}
} else {
// 标记已完成
if ($this->complete_at) {
return; // 本来就已完成
}
if ($this->parent_id == 0) {
if (self::whereParentId($this->id)->whereCompleteAt(null)->exists()) {
throw new ApiException('子任务未完成', [
@@ -1422,11 +1431,12 @@ class ProjectTask extends AbstractModel
$this->archived_at = null;
$this->archived_userid = User::userid();
$this->archived_follow = 0;
$this->addLog("任务取消归档");
$logText = "任务取消归档";
$userid = 0;
} else {
// 归档任务
if ($isAuto === true) {
$logText = "自动任务归档";
$logText = "任务自动归档";
$userid = 0;
} else {
$logText = "任务归档";
@@ -1435,13 +1445,20 @@ class ProjectTask extends AbstractModel
$this->archived_at = $archived_at;
$this->archived_userid = $userid;
$this->archived_follow = 0;
$this->addLog($logText, [], $userid);
}
// 添加日志
$this->addLog($logText, [], $userid);
// 推送状态
$this->pushMsg($archived_at === null ? 'recovery' : 'archived', [
'id' => $this->id,
'archived_at' => $this->archived_at,
'archived_userid' => $this->archived_userid,
]);
// 更新对话时间
if ($this->dialog_id > 0) {
WebSocketDialogUser::whereDialogId($this->dialog_id)->update(['updated_at' => Carbon::now()]); // 因为是若提醒,可以直接使用 update 更新
}
// 更新保存
self::whereParentId($this->id)->change([
'archived_at' => $this->archived_at,
'archived_userid' => $this->archived_userid,
@@ -1830,16 +1847,30 @@ class ProjectTask extends AbstractModel
* @param int $flowItemId
* @param array $owner
* @param array $assist
* @param string $completeAt
* @param string|null $completed
* @return bool
*/
public function moveTask(int $projectId, int $columnId,int $flowItemId = 0,array $owner = [], array $assist = [], string $completeAt='')
public function moveTask(int $projectId, int $columnId, int $flowItemId = 0, array $owner = [], array $assist = [], ?string $completed = null)
{
AbstractModel::transaction(function () use ($projectId, $columnId, $flowItemId, $owner, $assist, $completeAt) {
AbstractModel::transaction(function () use ($projectId, $columnId, $flowItemId, $owner, $assist, $completed) {
$newTaskUser = array_merge($owner, $assist);
//
$oldProject = Project::find($this->project_id);
$newProject = $this->project_id != $projectId ? Project::find($projectId) : $oldProject;
if (!$oldProject || !$newProject) {
throw new ApiException('项目不存在');
}
//
$this->project_id = $projectId;
$this->column_id = $columnId;
// 日志
$log = $this->addLog("移动{任务}", [
'change' => [$oldProject->name, $newProject->name]
]);
if ($this->dialog_id) {
$notice = $oldProject->id != $newProject->id ? "{$oldProject->name}」移动至「{$newProject->name}" : $log->detail;
WebSocketDialogMsg::sendMsg(null, $this->dialog_id, 'notice', ['notice' => $notice], User::userid(), true, true);
}
// 任务内容
if ($this->content) {
$this->content->project_id = $projectId;
@@ -1873,26 +1904,25 @@ class ProjectTask extends AbstractModel
]);
//
if ($flowItemId) {
// 更新任务流程
$flowItem = projectFlowItem::whereProjectId($projectId)->whereId($flowItemId)->first();
$this->flow_item_id = $flowItemId;
$this->flow_item_name = $flowItem->status . "|" . $flowItem->name;
$this->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
if ($flowItem->status == 'end') {
$this->completeTask(Carbon::now(), $flowItem->name);
} else {
$this->completeTask(null);
}
} else {
// 没有流程只更新状态
$this->flow_item_id = 0;
$this->flow_item_name = '';
}
//
if ($completeAt) {
$this->complete_at = $completeAt;
if ($completed !== null) {
$this->completeTask($completed ? Carbon::now(): null);
}
}
//
$this->save();
//
$this->addLog("移动{任务}");
});
$this->pushMsg('update');
return true;
@@ -1927,8 +1957,7 @@ class ProjectTask extends AbstractModel
if ($this->content) {
$taskDesc = $this->content?->getContentInfo();
if ($taskDesc) {
$converter = new HtmlConverter(['strip_tags' => true]);
$descContent = Base::cutStr($converter->convert($taskDesc['content']), 2000);
$descContent = Base::cutStr(Base::html2markdown($taskDesc['content'], ['strip_tags' => true]), 2000);
$contexts[] = <<<EOF
任务描述:
```md

View File

@@ -6,8 +6,10 @@ namespace App\Models;
* App\Models\ProjectTaskTag
*
* @property int $id
* @property int|null $tag_id
* @property int|null $project_id 项目ID
* @property int|null $task_id 任务ID
* @property int|null $userid 用户ID创建者
* @property string|null $name 标题
* @property string|null $color 颜色
* @property \Illuminate\Support\Carbon|null $created_at
@@ -26,8 +28,10 @@ namespace App\Models;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereTagId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTag whereUserid($value)
* @mixin \Eloquent
*/
class ProjectTaskTag extends AbstractModel

View File

@@ -68,7 +68,18 @@ class ProjectTaskUser extends AbstractModel
$item->save();
}
if ($item->projectTask) {
$item->projectTask->addLog("移交{任务}身份", ['userid' => [$originalUserid, ' => ', $newUserid]], 0, 1);
$item->projectTask->addLog("移交{任务}身份", [
'change' => [
[
'type' => 'user',
'data' => $originalUserid,
],
[
'type' => 'user',
'data' => $newUserid,
]
],
], 0, 1);
if (!in_array($item->task_pid, $tastIds)) {
$tastIds[] = $item->task_pid;
$item->projectTask->syncDialogUser();

View File

@@ -74,7 +74,18 @@ class ProjectUser extends AbstractModel
$item->project->name = "{$name}{$item->project->name}";
$item->project->save();
}
$item->project->addLog("移交项目身份", ['userid' => [$originalUserid, ' => ', $newUserid]]);
$item->project->addLog("移交项目身份", [
'change' => [
[
'type' => 'user',
'data' => $originalUserid
],
[
'type' => 'user',
'data' => $newUserid
],
],
]);
$item->project->syncDialogUser();
$projectIds[] = $item->project_id;
}

View File

@@ -48,6 +48,7 @@ class Setting extends AbstractModel
}
$value = Base::json2array($value);
switch ($this->name) {
// 系统设置
case 'system':
$value['system_alias'] = $value['system_alias'] ?: env('APP_NAME');
$value['image_compress'] = $value['image_compress'] ?: 'open';
@@ -58,11 +59,21 @@ class Setting extends AbstractModel
}
break;
// 文件设置
case 'fileSetting':
$value['permission_pack_type'] = $value['permission_pack_type'] ?: 'all';
$value['permission_pack_userids'] = is_array($value['permission_pack_userids']) ? $value['permission_pack_userids'] : [];
break;
// AI 助手设置
case 'aiSetting':
$value['ai_provider'] = $value['ai_provider'] ?: 'openai';
$value['ai_api_key'] = $value['ai_api_key'] ?: '';
$value['ai_api_url'] = $value['ai_api_url'] ?: '';
$value['ai_proxy'] = $value['ai_proxy'] ?: '';
break;
// AI 机器人设置
case 'aibotSetting':
if ($value['claude_token'] && empty($value['claude_key'])) {
$value['claude_key'] = $value['claude_token'];
@@ -81,12 +92,12 @@ class Setting extends AbstractModel
$content = array_filter($content);
}
if (empty($content)) {
$content = self::AIDefaultModels($aiName);
$content = self::AIBotDefaultModels($aiName);
}
$content = implode("\n", $content);
break;
case 'model':
$models = Setting::AIModels2Array($array[$key . 's'], true);
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
$content = in_array($content, $models) ? $content : ($models[0] ?? '');
break;
case 'temperature':
@@ -105,69 +116,66 @@ class Setting extends AbstractModel
}
/**
* 是否开启AI
* @param $ai
* 是否开启 AI 助理
* @return bool
*/
public static function AIOpen($ai = 'openai')
public static function AIOpen()
{
$array = Base::setting('aibotSetting');
return !!$array[$ai . '_key'];
return !!Base::settingFind('aiSetting', 'ai_api_key');
}
/**
* AI默认模型
* AI 机器人默认模型
* @param string $ai
* @return array
*/
public static function AIDefaultModels($ai = 'openai')
public static function AIBotDefaultModels($ai = 'openai')
{
return match ($ai) {
'openai' => [
'gpt-4 | GPT-4',
'gpt-4-turbo | GPT-4 Turbo',
'gpt-4.1 | GPT-4.1',
'gpt-4o | GPT-4o',
'gpt-4 | GPT-4',
'gpt-4o-mini | GPT-4o Mini',
'gpt-4-turbo | GPT-4 Turbo',
'o3 (thinking) | GPT-o3',
'o1 | GPT-o1',
'o1-mini | GPT-o1 Mini',
'o4-mini | GPT-o4 Mini',
'o3-mini | GPT-o3 Mini',
'o1-mini | GPT-o1 Mini',
'gpt-3.5-turbo | GPT-3.5 Turbo',
'gpt-3.5-turbo-16k | GPT-3.5 Turbo 16K',
'gpt-3.5-turbo-0125 | GPT-3.5 Turbo 0125',
'gpt-3.5-turbo-1106 | GPT-3.5 Turbo 1106'
],
'claude' => [
'claude-3-5-sonnet-latest | Claude 3.5 Sonnet',
'claude-3-5-sonnet-20241022 | Claude 3.5 Sonnet 20241022',
'claude-3-5-haiku-latest | Claude 3.5 Haiku',
'claude-3-5-haiku-20241022 | Claude 3.5 Haiku 20241022',
'claude-3-opus-latest | Claude 3 Opus',
'claude-3-opus-20240229 | Claude 3 Opus 20240229',
'claude-3-haiku-20240307 | Claude 3 Haiku 20240307',
'claude-2.1 | Claude 2.1',
'claude-2.0 | Claude 2.0'
'claude-opus-4-0 (thinking) | Claude Opus 4',
'claude-sonnet-4-0 (thinking) | Claude Sonnet 4',
'claude-3-7-sonnet-latest (thinking) | Claude Sonnet 3.7',
'claude-3-5-sonnet-latest | Claude Sonnet 3.5',
'claude-3-5-haiku-latest | Claude Haiku 3.5',
'claude-3-opus-latest | Claude Opus 3'
],
'deepseek' => [
'deepseek-chat | DeepSeek V3',
'deepseek-reasoner | DeepSeek R1'
],
'gemini' => [
'gemini-2.5-pro-preview-05-06 (thinking) | Gemini 2.5 Pro Preview',
'gemini-2.0-flash | Gemini 2.0 Flash',
'gemini-2.0-flash-lite-preview-02-05 | Gemini 2.0 Flash-Lite Preview',
'gemini-2.0-flash-lite | Gemini 2.0 Flash-Lite',
'gemini-1.5-flash | Gemini 1.5 Flash',
'gemini-1.5-flash-8b | Gemini 1.5 Flash 8B',
'gemini-1.5-pro | Gemini 1.5 Pro',
'gemini-1.0-pro | Gemini 1.0 Pro'
],
'grok' => [
'grok-2-vision-1212 | Grok 2 Vision 1212',
'grok-2-vision | Grok 2 Vision',
'grok-2-vision-latest | Grok 2 Vision Latest',
'grok-2-1212 | Grok 2 1212',
'grok-2 | Grok 2',
'grok-2-latest | Grok 2 Latest',
'grok-vision-beta | Grok Vision Beta',
'grok-beta | Grok Beta',
'grok-3-latest | Grok 3',
'grok-3-fast-latest | Grok 3 Fast',
'grok-3-mini-latest | Grok 3 Mini',
'grok-3-mini-fast-latest | Grok 3 Mini Fast',
'grok-2-vision-latest | Grok 2 Vision',
'grok-2-latest | Grok 2',
],
'zhipu' => [
'glm-4 | GLM-4',
@@ -206,12 +214,12 @@ class Setting extends AbstractModel
}
/**
* AI模型转数组
* AI 机器人模型转数组
* @param $models
* @param bool $retValue
* @return array
*/
public static function AIModels2Array($models, $retValue = false)
public static function AIBotModels2Array($models, $retValue = false)
{
$list = is_array($models) ? $models : explode("\n", $models);
$array = [];

View File

@@ -15,6 +15,7 @@ use Hedeqiang\UMeng\IOS;
* @property string|null $alias 别名
* @property string|null $platform 平台类型
* @property string|null $device 设备类型
* @property string|null $device_hash 设备哈希值用于关联UserDevice表
* @property string|null $version 应用版本号
* @property string|null $ua userAgent
* @property int|null $is_notified 通知权限
@@ -32,6 +33,7 @@ use Hedeqiang\UMeng\IOS;
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereAlias($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereDevice($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereDeviceHash($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias whereIsNotified($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengAlias wherePlatform($value)
@@ -45,6 +47,54 @@ class UmengAlias extends AbstractModel
{
protected $table = 'umeng_alias';
private static $waitSend = [];
/**
* 推送消息
* @param $push
* @return void
*/
private static function sendTask($push = null)
{
if ($push) {
self::$waitSend[] = $push;
}
if (!self::$waitSend) {
return;
}
$first = array_shift(self::$waitSend);
if (empty($first)) {
return;
}
try {
switch ($first['platform']) {
case 'ios':
$instance = new IOS($first['config']);
break;
case 'android':
$instance = new Android($first['config']);
break;
default:
return;
}
$instance->send($first['data']);
} catch (\Exception $e) {
$first['retry'] = intval($first['retry'] ?? 0) + 1;
if ($first['retry'] > 3) {
info("[PushMsg] fail: " . $e->getMessage());
} else {
info("[PushMsg] retry ({$first['retry']}): " . $e->getMessage());
self::$waitSend[] = $first;
}
} finally {
self::sendTask();
}
}
/**
* 推送内容处理
* @param $string
@@ -88,13 +138,13 @@ class UmengAlias extends AbstractModel
* @param string $alias
* @param string $platform
* @param array $array [title, subtitle, body, description, extra, seconds, badge]
* @return array|false
* @return void
*/
public static function pushMsgToAlias($alias, $platform, $array)
private static function pushMsgToAlias($alias, $platform, $array)
{
$config = self::getPushConfig();
if ($config === false) {
return false;
return;
}
//
$title = self::specialCharacters($array['title'] ?: ''); // 标题
@@ -108,65 +158,71 @@ class UmengAlias extends AbstractModel
switch ($platform) {
case 'ios':
if (!isset($config['iOS'])) {
return false;
return;
}
$ios = new IOS($config);
return $ios->send([
'description' => $description,
'payload' => array_merge([
'aps' => [
'alert' => [
'title' => $title,
'subtitle' => $subtitle,
'body' => $body,
self::sendTask([
'platform' => $platform,
'config' => $config,
'data' => [
'description' => $description,
'payload' => array_merge([
'aps' => [
'alert' => [
'title' => $title,
'subtitle' => $subtitle,
'body' => $body,
],
'sound' => 'default',
'badge' => $badge,
],
'sound' => 'default',
'badge' => $badge,
], $extra),
'type' => 'customizedcast',
'alias_type' => 'userid',
'alias' => $alias,
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
], $extra),
'type' => 'customizedcast',
'alias_type' => 'userid',
'alias' => $alias,
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
]
]);
break;
case 'android':
if (!isset($config['Android'])) {
return false;
return;
}
$android = new Android($config);
return $android->send([
'description' => $description,
'payload' => array_merge([
'display_type' => 'notification',
'body' => [
'ticker' => $title,
'text' => $body,
'title' => $title,
'after_open' => 'go_app',
'play_sound' => true,
self::sendTask([
'platform' => $platform,
'config' => $config,
'data' => [
'description' => $description,
'payload' => array_merge([
'display_type' => 'notification',
'body' => [
'ticker' => $title,
'text' => $body,
'title' => $title,
'after_open' => 'go_app',
'play_sound' => true,
],
], $extra),
'type' => 'customizedcast',
'alias_type' => 'userid',
'alias' => $alias,
'mipush' => true,
'mi_activity' => 'app.eeui.umeng.activity.MfrMessageActivity',
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
], $extra),
'type' => 'customizedcast',
'alias_type' => 'userid',
'alias' => $alias,
'mipush' => true,
'mi_activity' => 'app.eeui.umeng.activity.MfrMessageActivity',
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
'channel_properties' => [
'vivo_category' => 'IM',
'huawei_channel_importance' => 'NORMAL',
'huawei_channel_category' => 'IM',
'channel_fcm' => 0,
],
'channel_properties' => [
'oppo_channel_id' => 'dootask',
'vivo_category' => 'IM',
'huawei_channel_importance' => 'NORMAL',
'huawei_channel_category' => 'IM',
'channel_fcm' => 0,
],
]
]);
default:
return false;
break;
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use App\Module\Doo;
@@ -173,10 +172,9 @@ class User extends AbstractModel
return UserDepartment::where('owner_userid', $this->userid)->exists();
}
/**
* 获取机器人所有者
* @return int|mixed
* @return int
*/
public function getBotOwner()
{
@@ -184,9 +182,9 @@ class User extends AbstractModel
return 0;
}
$key = "userBotOwner::" . $this->userid;
return Cache::remember($key, now()->addMonth(), function() {
return intval(Cache::remember($key, now()->addMonth(), function() {
return intval(UserBot::whereBotId($this->userid)->value('userid')) ?: $this->userid;
});
}));
}
/**
@@ -255,6 +253,18 @@ class User extends AbstractModel
return false;
}
/**
* 返回是否用户机器人
* @return bool
*/
public function isUserBot()
{
if (preg_match('/^user-(.*?)@bot\.system$/', $this->email)) {
return true;
}
return false;
}
/**
* 判断是否管理员
*/
@@ -432,7 +442,9 @@ class User extends AbstractModel
{
$user = self::authInfo();
if (!$user) {
if (Base::token()) {
$token = Base::token();
if ($token) {
UserDevice::forget($token);
throw new ApiException('身份已失效,请重新登录', [], -1);
} else {
throw new ApiException('请登录后继续...', [], -1);
@@ -454,31 +466,46 @@ class User extends AbstractModel
private static function authInfo()
{
if (RequestContext::has('auth')) {
// 缓存
return RequestContext::get('auth');
}
if (Doo::userId() > 0
&& !Doo::userExpired()
&& $user = self::whereUserid(Doo::userId())->whereEmail(Doo::userEmail())->whereEncrypt(Doo::userEncrypt())->first()) {
$upArray = [];
if (Base::getIp() && $user->line_ip != Base::getIp()) {
$upArray['line_ip'] = Base::getIp();
}
if (Carbon::parse($user->line_at)->addSeconds(30)->lt(Carbon::now())) {
$upArray['line_at'] = Carbon::now();
}
$headerLanguage = RequestContext::get('header_language');
if (empty($user->lang) || $headerLanguage) {
if (Doo::checkLanguage($headerLanguage) && $user->lang != $headerLanguage) {
$upArray['lang'] = $headerLanguage;
}
}
if ($upArray) {
$user->updateInstance($upArray);
$user->save();
}
return RequestContext::save('auth', $user);
if (Doo::userId() <= 0) {
// 没有登录
return RequestContext::save('auth', false);
}
return RequestContext::save('auth', false);
if (Doo::userExpired()) {
// 登录过期
return RequestContext::save('auth', false);
}
if (!UserDevice::check()) {
// token 不存在
return RequestContext::save('auth', false);
}
$user = self::whereUserid(Doo::userId())->whereEmail(Doo::userEmail())->whereEncrypt(Doo::userEncrypt())->first();
if (!$user) {
// 登录信息不匹配
return RequestContext::save('auth', false);
}
// 更新登录信息
$upArray = [];
if (Base::getIp() && $user->line_ip != Base::getIp()) {
$upArray['line_ip'] = Base::getIp();
}
if (Carbon::parse($user->line_at)->addSeconds(30)->lt(Carbon::now())) {
$upArray['line_at'] = Carbon::now();
}
$headerLanguage = RequestContext::get('header_language');
if (empty($user->lang) || $headerLanguage) {
if (Doo::checkLanguage($headerLanguage) && $user->lang != $headerLanguage) {
$upArray['lang'] = $headerLanguage;
}
}
if ($upArray) {
$user->updateInstance($upArray);
$user->save();
}
return RequestContext::save('auth', $user);
}
/**
@@ -502,11 +529,28 @@ class User extends AbstractModel
} else {
$token = Doo::userToken();
}
UserDevice::record($token);
unset($userinfo->encrypt);
unset($userinfo->password);
return $userinfo->token = $token;
}
/**
* 生成无设备的 token主要用于接口调用此 token 不检查设备是否存在)
* @param self $userinfo
* @param $ttl
* @return mixed
*/
public static function generateTokenNoDevice($userinfo, $ttl)
{
$key = 'user_token_no_device_' . $userinfo->userid;
return Cache::remember($key, $ttl, function () use ($userinfo, $ttl) {
$token = Doo::tokenEncode($userinfo->userid, $userinfo->email, $userinfo->encrypt);
Cache::put(UserDevice::ck(md5($token)), $userinfo->userid, $ttl);
return $token;
});
}
/**
* userid 获取 基础信息
* @param int $userid 会员ID
@@ -693,11 +737,11 @@ class User extends AbstractModel
}
}
if ($update) {
$botUser->updateInstance($update);
if (isset($update['nickname'])) {
if (isset($update['nickname']) && $botUser->nickname != $update['nickname']) {
$botUser->az = Base::getFirstCharter($botUser->nickname);
$botUser->pinyin = Base::cn2pinyin($botUser->nickname);
}
$botUser->updateInstance($update);
$botUser->save();
}
return $botUser;
@@ -706,17 +750,38 @@ class User extends AbstractModel
/**
* 是否机器人
* @param $userid
* @return bool|mixed
* @return bool
*/
public static function isBot($userid)
{
if (empty($userid)) {
return false;
}
$userid = intval($userid);
if (RequestContext::has("isBot_" . $userid)) {
return RequestContext::get("isBot_" . $userid);
}
return (bool)User::find($userid)?->bot;
// 这个不会有变化,所以可以使用永久缓存
return (bool)Cache::rememberForever('is-bot-user-' . $userid, function () use ($userid) {
return (bool)User::find($userid)?->bot;
});
}
/**
* 搜索用户
* @param $key
* @param $take
* @return User[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection
*/
public static function searchUser($key, $take = 20)
{
return User::select(User::$basicField)
->where(function ($query) use ($key) {
if (str_contains($key, "@")) {
$query->where("email", "like", "%{$key}%");
} else {
$query->where("nickname", "like", "%{$key}%")
->orWhere("pinyin", "like", "%{$key}%")
->orWhere("profession", "like", "%{$key}%");
}
})->orderBy('userid')
->take($take)
->get();
}
}

View File

@@ -16,6 +16,7 @@ use Carbon\Carbon;
* @property int $id
* @property int|null $userid 所属人ID
* @property int|null $bot_id 机器人ID
* @property int|null $session 开启新会话功能
* @property int|null $clear_day 消息自动清理天数
* @property \Illuminate\Support\Carbon|null $clear_at 下一次清理时间
* @property string|null $webhook_url 消息webhook地址
@@ -36,6 +37,7 @@ use Carbon\Carbon;
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereClearDay($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereSession($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookNum($value)
@@ -55,16 +57,6 @@ class UserBot extends AbstractModel
return str_ends_with($email, '@bot.system') && self::systemBotName($email);
}
/**
* 判断是否系统AI机器人
* @param $email
* @return bool
*/
public static function isAiBot($email)
{
return str_starts_with($email, 'ai-') && self::isSystemBot($email);
}
/**
* 系统机器人名称
* @param $name string 邮箱 或 邮箱前缀
@@ -107,39 +99,33 @@ class UserBot extends AbstractModel
{
switch ($email) {
case 'check-in@bot.system':
$menu = [
/*[
'key' => 'it',
'label' => Doo::translate('IT资讯')
], [
'key' => '36ke',
'label' => Doo::translate('36氪')
], [
'key' => '60s',
'label' => Doo::translate('60s读世界')
], [
'key' => 'joke',
'label' => Doo::translate('开心笑话')
], [
'key' => 'soup',
'label' => Doo::translate('心灵鸡汤')
]*/
];
$menu = [];
$setting = Base::setting('checkinSetting');
if ($setting['open'] !== 'open') {
return $menu;
}
if (in_array('locat', $setting['modes']) && Base::isEEUIApp()) {
$menu[] = [
'key' => 'locat-checkin',
'label' => Doo::translate('定位签到'),
'config' => [
'key' => $setting['locat_bd_lbs_key'],
'lng' => $setting['locat_bd_lbs_point']['lng'],
'lat' => $setting['locat_bd_lbs_point']['lat'],
'radius' => $setting['locat_bd_lbs_point']['radius'],
]
$mapTypes = [
'baidu' => ['key' => 'locat_bd_lbs_key', 'point' => 'locat_bd_lbs_point', 'msg' => '请填写百度地图AK'],
'amap' => ['key' => 'locat_amap_key', 'point' => 'locat_amap_point', 'msg' => '请填写高德地图Key'],
'tencent' => ['key' => 'locat_tencent_key', 'point' => 'locat_tencent_point', 'msg' => '请填写腾讯地图Key'],
];
$type = $setting['locat_map_type'];
if (isset($mapTypes[$type])) {
$conf = $mapTypes[$type];
$point = $setting[$conf['point']];
$menu[] = [
'key' => 'locat-checkin',
'label' => Doo::translate('定位签到'),
'config' => [
'type' => $type,
'key' => $setting[$conf['key']],
'lng' => $point['lng'],
'lat' => $point['lat'],
'radius' => intval($point['radius']),
]
];
}
}
if (in_array('manual', $setting['modes'])) {
$menu[] = [
@@ -190,28 +176,8 @@ class UserBot extends AbstractModel
];
default:
if (preg_match('/^ai-(.*?)@bot\.system$/', $email, $match)) {
if (!Base::judgeClientVersion('0.42.62')) {
return [
'key' => '%3A.clear',
'label' => Doo::translate('清空上下文')
];
}
$aibotSetting = Base::setting('aibotSetting');
$aibotModel = $aibotSetting[$match[1] . '_model'];
$aibotModels = Setting::AIModels2Array($aibotSetting[$match[1] . '_models']);
if (empty($aibotModels)) {
return [];
}
return [
[
'key' => '~ai-model-select',
'label' => Doo::translate('选择模型'),
'config' => [
'model' => $aibotModel,
'models' => $aibotModels
]
],
if (preg_match('/^(ai-|user-session-)(.*?)@bot\.system$/', $email, $match)) {
$menus = [
[
'key' => '~ai-session-create',
'label' => Doo::translate('开启新会话'),
@@ -221,6 +187,27 @@ class UserBot extends AbstractModel
'label' => Doo::translate('历史会话'),
]
];
if ($match[1] === "ai-") {
$aibotSetting = Base::setting('aibotSetting');
$aibotModel = $aibotSetting[$match[2] . '_model'];
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models']);
if ($aibotModels) {
$menus = array_merge(
[
[
'key' => '~ai-model-select',
'label' => Doo::translate('选择模型'),
'config' => [
'model' => $aibotModel,
'models' => $aibotModels
]
]
],
$menus
);
}
}
return $menus;
}
return [];
}
@@ -249,7 +236,6 @@ class UserBot extends AbstractModel
return '暂未开放手动签到。';
}
UserBot::checkinBotCheckin('manual-' . $userid, Timer::time(), true);
return null;
} elseif ($command === 'locat-checkin') {
$setting = Base::setting('checkinSetting');
if ($setting['open'] !== 'open') {
@@ -261,16 +247,14 @@ class UserBot extends AbstractModel
if (empty($extra)) {
return '当前客户端版本低所需版本≥v0.39.75)。';
}
if ($extra['type'] === 'bd') {
if (in_array($extra['type'], ['baidu', 'amap', 'tencent'])) {
// todo 判断距离
} else {
return '错误的定位签到。';
}
UserBot::checkinBotCheckin('locat-' . $userid, Timer::time(), true);
return null;
} else {
return Extranet::checkinBotQuickMsg($command);
}
return null;
}
/**
@@ -452,4 +436,49 @@ class UserBot extends AbstractModel
default => [],
};
}
/**
* 创建我的机器人
* @param int $userid 创建人userid
* @param string $botName 机器人名称
* @param bool $sessionSupported 是否支持会话
* @return array
*/
public static function newBot($userid, $botName, $sessionSupported = false)
{
if (User::select(['users.*'])
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
->where('users.bot', 1)
->where('user_bots.userid', $userid)
->count() >= 50) {
return Base::retError("超过最大创建数量。");
}
if (strlen($botName) < 2 || strlen($botName) > 20) {
return Base::retError("机器人名称由2-20个字符组成。");
}
$botType = ($sessionSupported ? "user-session-" : "user-normal-") . Base::generatePassword();
$data = User::botGetOrCreate($botType, [
'nickname' => $botName
], $userid);
if (empty($data)) {
return Base::retError("创建失败。");
}
$dialog = WebSocketDialog::checkUserDialog($data, $userid);
if ($dialog) {
if ($sessionSupported) {
$dialogSession = WebSocketDialogSession::create([
'dialog_id' => $dialog->id,
]);
$dialogSession->save();
$dialog->session_id = $dialogSession->id;
$dialog->save();
}
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => '/hello',
'title' => '创建成功。',
'data' => $data,
], $data->userid);
}
return Base::retSuccess("创建成功。", $data);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Apps;
use App\Module\Base;
use App\Module\Ihttp;
@@ -37,8 +38,9 @@ use App\Module\Ihttp;
class UserCheckinFace extends AbstractModel
{
public static function saveFace($userid, $nickname, $faceimg, $remark='')
public static function saveFace($userid, $nickname, $faceimg, $remark = '')
{
Apps::isInstalledThrow('face');
// 取上传图片的URL
$faceimg = Base::unFillUrl($faceimg);
$record = "";
@@ -47,7 +49,7 @@ class UserCheckinFace extends AbstractModel
$record = base64_encode(file_get_contents($faceFile));
}
$url = 'http://' . env('APP_IPPR') . '.14' . ":7788/user";
$url = "http://face:7788/user";
$data = [
'name' => $nickname,
'enrollid' => $userid,
@@ -59,14 +61,14 @@ class UserCheckinFace extends AbstractModel
}
$res = Ihttp::ihttp_post($url, json_encode($data), 15);
if($res['data'] && $data = json_decode($res['data'])){
if($data->ret != 1 && $data->msg){
if ($res['data'] && $data = json_decode($res['data'])) {
if ($data->ret != 1 && $data->msg) {
throw new ApiException($data->msg);
}
}
return AbstractModel::transaction(function() use ($userid, $faceimg, $remark) {
return AbstractModel::transaction(function () use ($userid, $faceimg, $remark) {
$checkinFace = self::query()->whereUserid($userid)->first();
if ($checkinFace) {
self::updateData(['id' => $checkinFace->id], [
@@ -82,27 +84,24 @@ class UserCheckinFace extends AbstractModel
$checkinFace->save();
}
if ($faceimg == '') {
$res = UserCheckinFace::deleteDeviceUser($userid);
if ($res) {
return $res;
}
UserCheckinFace::deleteDeviceUser($userid);
}
return Base::retSuccess('设置成功');
});
}
public static function deleteDeviceUser($userid) {
$url = 'http://' . env('APP_IPPR') . '.14' . ":7788/user/delete";
private static function deleteDeviceUser($userid)
{
$url = "http://face:7788/user/delete";
$data = [
'enrollid' => $userid,
'backupnum' => 50, // 13 删除整个用户 50 删除图片
];
$res = Ihttp::ihttp_post($url, json_encode($data));
if($res['data'] && $data = json_decode($res['data'])){
if($data->ret != 1 && $data->msg){
if ($res['data'] && $data = json_decode($res['data'])) {
if ($data->ret != 1 && $data->msg) {
throw new ApiException($data->msg);
// return Base::retError($data->msg);
}
}
}

View File

@@ -13,7 +13,7 @@ use App\Module\Base;
* @property int|null $userid 用户id
* @property string|null $email 邮箱帐号
* @property string|null $reason 注销原因
* @property string $cache 会员资料缓存
* @property string|null $cache 会员资料缓存
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
@@ -37,11 +37,6 @@ use App\Module\Base;
*/
class UserDelete extends AbstractModel
{
/**
* 昵称
* @param $value
* @return string
*/
public function getCacheAttribute($value)
{
if (!is_array($value)) {
@@ -65,13 +60,25 @@ class UserDelete extends AbstractModel
*/
public static function userid2basic($userid)
{
$row = self::whereUserid($userid)->first();
if (empty($row) || empty($row->cache)) {
return null;
}
$cache = $row->cache;
$cache = array_intersect_key($cache, array_flip(array_merge(User::$basicField, ['department_name'])));
$cache['delete_at'] = $row->created_at->toDateTimeString();
return $cache;
return \Cache::remember("UserDelete:{$userid}", now()->addDays(3), function () use ($userid) {
$row = self::whereUserid($userid)->first();
if (empty($row) || empty($row->cache)) {
return null;
}
$cache = $row->cache;
$cache = array_intersect_key($cache, array_flip(array_merge(User::$basicField, ['department_name'])));
$cache['delete_at'] = $row->created_at->toDateTimeString();
return $cache;
});
}
/**
* userid 获取 昵称
* @param $userid
* @return string
*/
public static function userid2nickname($userid)
{
return self::userid2basic($userid)['nickname'] ?? '';
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Exceptions\ApiException;
use Cache;
/**
* App\Models\UserDepartment
@@ -168,4 +169,47 @@ class UserDepartment extends AbstractModel
}
});
}
/**
* 获取部门基本信息缓存时间1小时
* @param int|array $ids
* @return \Illuminate\Support\Collection|static|null
*/
public static function getDepartmentsByIds($ids)
{
$ids = is_array($ids) ? $ids : [$ids];
$departments = collect();
$uncachedIds = [];
foreach ($ids as $id) {
$cacheKey = "department_info_{$id}";
$department = Cache::get($cacheKey);
if ($department) {
$departments->push($department);
} else {
$uncachedIds[] = $id;
}
}
if (!empty($uncachedIds)) {
$dbDepartments = self::select(['id', 'name', 'parent_id', 'owner_userid'])->whereIn('id', $uncachedIds)->get();
foreach ($dbDepartments as $department) {
$cacheKey = "department_info_{$department->id}";
Cache::put($cacheKey, $department, 60 * 60); // 1小时
$departments->push($department);
}
}
// 保持返回顺序与传入ids一致
$departments = $departments->keyBy('id');
$result = collect();
foreach ($ids as $id) {
if ($departments->has($id)) {
$result->push($departments->get($id));
}
}
return is_array($ids) ? $result : $result->first();
}
}

312
app/Models/UserDevice.php Normal file
View File

@@ -0,0 +1,312 @@
<?php
namespace App\Models;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Lock;
use Cache;
use Carbon\Carbon;
use DeviceDetector\DeviceDetector;
use Illuminate\Database\Eloquent\SoftDeletes;
use Request;
/**
* App\Models\UserDevice
*
* @property int $id
* @property int|null $userid 会员ID
* @property string|null $hash TOKEN MD5
* @property string|null $detail 详细信息
* @property string|null $expired_at 过期时间
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property-read int $is_current
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereDetail($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereExpiredAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereHash($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice withoutTrashed()
* @mixin \Eloquent
*/
class UserDevice extends AbstractModel
{
use SoftDeletes;
protected $table = 'user_devices';
public static int $deviceLimit = 200; // 每个用户设备限制数量
protected $appends = [
'is_current',
];
public function getDetailAttribute($value)
{
if (is_array($value)) {
return $value;
}
return Base::json2array($value);
}
public function getIsCurrentAttribute(): int
{
return $this->hash === md5(Doo::userToken()) ? 1 : 0;
}
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */
/**
* 缓存key
* @param string $hash
* @return string
*/
public static function ck(string $hash): string
{
return "user_devices:{$hash}";
}
/**
* 解析 UA 获取设备信息
* @param string $ua
* @return array
*/
private static function getDeviceInfo(string $ua): array
{
$result = [
'ip' => Base::getIp(),
'type' => '电脑',
'os' => 'Unknown',
'browser' => 'Unknown',
'version' => '',
'app_name' => '', // 客户端名称
'app_type' => '', // 客户端类型
'app_version' => '', // 客户端版本
];
if (empty($ua)) {
return $result;
}
// 使用 Device-Detector 解析 UA
$dd = new DeviceDetector($ua);
// 解析 UA 字符串
$dd->parse();
// 获取客户端信息(浏览器)
$clientInfo = $dd->getClient();
if (!empty($clientInfo)) {
$result['browser'] = $clientInfo['name'] ?? 'Unknown';
$result['version'] = $clientInfo['version'] ?? '';
}
// 获取操作系统信息
$osInfo = $dd->getOs();
if (!empty($osInfo)) {
$result['os'] = trim(($osInfo['name'] ?? '') . ' ' . ($osInfo['version'] ?? ''));
if (empty($result['os'])) {
$result['os'] = 'Unknown';
}
}
if (preg_match("/android_kuaifan_eeui/i", $ua)) {
// Android 客户端
$result['app_type'] = 'Android';
if ($dd->getBrandName() && $dd->getModel()) {
// 厂商+型号
$result['app_name'] = $dd->getBrandName() . ' ' . $dd->getModel();
} elseif ($dd->getBrandName()) {
// 仅厂商
$result['app_name'] = $dd->getBrandName();
} elseif ($dd->isTablet()) {
// 平板
$result['app_name'] = 'Tablet';
} elseif ($dd->isPhablet()) {
// 平板
$result['app_name'] = 'Phablet';
}
$result['app_version'] = self::getAfterVersion($ua, 'kuaifan_eeui/');
} elseif (preg_match("/ios_kuaifan_eeui/i", $ua)) {
// iOS 客户端
$result['app_type'] = 'iOS';
if (preg_match("/(macintosh|ipad)/i", $ua)) {
// iPad
$result['app_name'] = 'iPad';
} elseif (preg_match("/iphone/i", $ua)) {
// iPhone
$result['app_name'] = 'iPhone';
}
$result['app_version'] = self::getAfterVersion($ua, 'kuaifan_eeui/');
} elseif (preg_match("/dootask/i", $ua)) {
// DooTask 客户端
$result['app_type'] = $osInfo['name'];
$result['app_version'] = self::getAfterVersion($ua, 'dootask/');
} else {
// 其他客户端
$result['app_type'] = 'Web';
$result['app_version'] = Base::getClientVersion();
}
return $result;
}
/**
* 从 ua 的 find 之后的内容获取版本号
* @param string $ua
* @param string $find
* @return string
*/
private static function getAfterVersion(string $ua, string $find): string
{
$findPattern = preg_quote($find, '/');
if (preg_match("/{$findPattern}(.*?)(?:\s|$)/i", $ua, $matches)) {
$appInfo = $matches[1];
// 从内容中提取版本号寻找符合x.x.x格式的部分
if (preg_match("/(\d+\.\d+(?:\.\d+)*)/", $appInfo, $versionMatches)) {
return $versionMatches[1];
}
}
return '';
}
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */
/**
* 检查用户是否存在
* @return string|null
*/
public static function check(): ?string
{
$token = Doo::userToken();
$userid = Doo::userId();
$hash = md5($token);
if (Cache::has(self::ck($hash))) {
return $hash;
}
$row = self::whereHash($hash)->first();
if ($row) {
// 判断是否过期
if ($row->expired_at && Carbon::parse($row->expired_at)->isPast()) {
self::forget($row);
return null;
}
// 更新缓存
self::record();
return $hash;
}
// 没有记录
return null;
}
/**
* 记录设备(添加、更新)
* @param string|null $token
* @return self|null
*/
public static function record(string $token = null): ?self
{
if (empty($token)) {
$token = Doo::userToken();
$userid = Doo::userId();
$expiredAt = Doo::userExpiredAt();
} else {
$info = Doo::tokenDecode($token);
$userid = $info['userid'] ?? 0;
$expiredAt = $info['expired_at'];
}
$hash = md5($token);
//
return Lock::withLock("userDeviceRecord:{$hash}", function () use ($expiredAt, $userid, $hash, $token) {
return AbstractModel::transaction(function () use ($expiredAt, $userid, $hash, $token) {
$row = self::whereHash($hash)->first();
if (empty($row)) {
// 生成一个新的设备记录
$row = self::createInstance([
'userid' => $userid,
'hash' => $hash,
]);
if (!$row->save()) {
return null;
}
// 删除多余的设备记录
$currentDeviceCount = self::whereUserid($userid)->count();
if ($currentDeviceCount > self::$deviceLimit) {
$rows = self::whereUserid($userid)->orderBy('id')->take($currentDeviceCount - self::$deviceLimit)->get();
foreach ($rows as $row) {
UserDevice::forget($row);
}
}
}
$row->expired_at = $expiredAt;
if (Request::hasHeader('version')) {
$deviceInfo = array_merge(Base::json2array($row->detail), self::getDeviceInfo($_SERVER['HTTP_USER_AGENT'] ?? ''));
$row->detail = Base::array2json($deviceInfo);
}
$row->save();
Cache::put(self::ck($hash), $row->userid, now()->addHour());
return $row;
});
});
}
/**
* 忘记设备(删除)
* @param UserDevice|string|int|null $token
* - UserDevice 表示指定的设备对象
* - string 表示指定的 token
* - int 表示指定的数据ID
* - null 表示当前登录的设备
* @return void
*/
public static function forget(UserDevice|string|int $token = null): void
{
if ($token instanceof UserDevice) {
$hash = $token->hash;
$token->delete();
} elseif (Base::isNumber($token)) {
$row = self::find(intval($token));
if ($row) {
$hash = $row->hash;
$row->delete();
}
} else {
if ($token === null) {
$token = Doo::userToken();
}
if ($token) {
$hash = md5($token);
self::whereHash($hash)->delete();
}
}
if (isset($hash)) {
Cache::forget(self::ck($hash));
UmengAlias::whereDeviceHash($hash)->delete();
}
}
}

View File

@@ -97,6 +97,32 @@ class WebSocketDialog extends AbstractModel
->whereNull('users.disable_at');
}
/**
* 搜索对话
* @param $userid
* @param $key
* @param $take
* @return array
*/
public static function searchDialog($userid, $key, $take = 20)
{
return DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->where('u.userid', $userid)
->where(function ($query) use ($key) {
$query->where('d.name', 'like', '%' . $key . '%');
})
->whereNull('d.deleted_at')
->orderByDesc('u.top_at')
->orderByDesc('u.last_at')
->take($take)
->get()
->map(function($item) use ($userid) {
return WebSocketDialog::synthesizeData($item, $userid);
})
->all();
}
/**
* 获取对话列表
@@ -298,7 +324,8 @@ class WebSocketDialog extends AbstractModel
$data['is_disable'] = $basic->isDisable(true);
$data['quick_msgs'] = UserBot::quickMsgs($basic->email);
} else {
$data['name'] = 'non-existent';
$data['name'] = UserDelete::userid2nickname($dialog_user->userid) ?: '[Delete]';
$data['is_disable'] = 1;
$data['dialog_delete'] = 1;
}
$data['dialog_user'] = $dialog_user;
@@ -447,10 +474,10 @@ class WebSocketDialog extends AbstractModel
WebSocketDialogUser::updateInsert([
'dialog_id' => $this->id,
'userid' => $value,
], $updateData, function() use ($value) {
return [
'bot' => User::isBot($value) ? 1 : 0,
];
], $updateData, function() use ($value, $updateData) {
return array_merge($updateData, [
'bot' => User::isBot($value) ? 1 : 0
]);
}, $isInsert);
if ($isInsert) {
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
@@ -666,6 +693,60 @@ class WebSocketDialog extends AbstractModel
Task::deliver($task);
}
/**
* 检查是否是单人对话
* @return bool
*/
public function isSelfDialog()
{
if ($this->type !== 'user') {
return false;
}
return WebSocketDialogUser::whereDialogId($this->id)->where('userid', '>', 0)->count() === 1;
}
/**
* 检查是否支持创建会话
* @return bool
*/
public function isSessionDialog()
{
// 这个不会有变化,所以可以使用永久缓存
return Cache::rememberForever('is-session-dialog-' . $this->id, function () {
if ($this->type !== 'user') {
return false;
}
$data = $this->dialogUserBuilder()->get();
foreach ($data as $item) {
if (preg_match('/^(ai-|user-session-)(.*?)@bot\.system$/', $item->email)) {
return true;
}
}
return false;
});
}
/**
* 检查是否是AI对话
* @return bool
*/
public function isAiDialog()
{
// 这个不会有变化,所以可以使用永久缓存
return Cache::rememberForever('is-ai-dialog-' . $this->id, function () {
if ($this->type !== 'user') {
return false;
}
$data = $this->dialogUserBuilder()->get();
foreach ($data as $item) {
if (preg_match('/^ai-(.*?)@bot\.system$/', $item->email)) {
return true;
}
}
return false;
});
}
/**
* 获取对话(同时检验对话身份)
* @param $dialog_id
@@ -674,6 +755,9 @@ class WebSocketDialog extends AbstractModel
*/
public static function checkDialog($dialog_id, $checkOwner = false)
{
if ($dialog_id <= 0) {
throw new ApiException('参数错误');
}
$dialog = WebSocketDialog::find($dialog_id);
if (empty($dialog)) {
throw new ApiException('对话不存在或已被删除', ['dialog_id' => $dialog_id], -4003);
@@ -687,18 +771,25 @@ class WebSocketDialog extends AbstractModel
throw new ApiException('仅限群主操作');
}
//
if ($dialog->group_type === 'task') {
// 任务群对话校验是否在项目内
$project_id = intval(ProjectTask::whereDialogId($dialog->id)->value('project_id'));
if ($project_id > 0) {
if (ProjectUser::whereProjectId($project_id)->whereUserid($userid)->exists()) {
switch ($dialog->group_type) {
case 'project':
case 'task':
// 项目群、任务群对话校验是否在项目内
if ($dialog->group_type === 'project') {
$projectId = intval(Project::whereDialogId($dialog->id)->value('id'));
} else {
$projectId = intval(ProjectTask::whereDialogId($dialog->id)->value('project_id'));
}
if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) {
return $dialog;
}
}
}
if ($dialog->group_type == 'okr') {
return $dialog;
break;
case 'okr':
// OKR群对话不用校验
return $dialog;
}
//
if (!WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($userid)->exists()) {
WebSocketDialogMsgRead::forceRead($dialog_id, $userid);
throw new ApiException('不在成员列表内', ['dialog_id' => $dialog_id], -4003);
@@ -729,6 +820,7 @@ class WebSocketDialog extends AbstractModel
WebSocketDialogUser::createInstance([
'dialog_id' => $dialog->id,
'userid' => $value,
'bot' => User::isBot($value) ? 1 : 0,
'important' => !in_array($group_type, ['user', 'all']),
'last_at' => in_array($group_type, ['user', 'department', 'all']) ? Carbon::now() : null,
])->save();
@@ -765,17 +857,17 @@ class WebSocketDialog extends AbstractModel
WebSocketDialogUser::createInstance([
'dialog_id' => $dialog->id,
'userid' => $user->userid,
'bot' => User::isBot($user->userid) ? 1 : 0,
])->save();
WebSocketDialogUser::createInstance([
'dialog_id' => $dialog->id,
'userid' => $receiver,
'bot' => User::isBot($receiver) ? 1 : 0,
])->save();
//
if ($user->isAiBot() || User::find($receiver)?->isAiBot()) {
$session = WebSocketDialogSession::create([
'dialog_id' => $dialog->id,
'status' => 1,
'title' => '',
]);
$session->save();
$dialog->session_id = $session->id;
@@ -842,7 +934,7 @@ class WebSocketDialog extends AbstractModel
$data = [];
foreach ($dialogIds as $dialog_id) {
$dialog = WebSocketDialog::checkDialog($dialog_id);
//
$action = $replyId > 0 ? "reply-$replyId" : "";
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
if ($image64) {
@@ -856,49 +948,52 @@ class WebSocketDialog extends AbstractModel
Base::makeDir(public_path($path));
copy($filePath, public_path($path) . basename($filePath));
} else {
$setting = Base::setting("system");
$data = Base::upload([
"file" => $files,
"type" => 'more',
"path" => $path,
"fileName" => $fileName,
"quality" => true,
"convertVideo" => true
"convertVideo" => $setting['convert_video'] === 'open',
"compressVideo" => $setting['compress_video'] === 'open',
]);
}
//
if (Base::isError($data)) {
throw new ApiException($data['msg']);
} else {
$fileData = $data['data'];
$filePath = $fileData['file'];
$fileName = $fileData['name'];
$fileData['thumb'] = Base::unFillUrl($fileData['thumb']);
$fileData['size'] *= 1024;
//
if ($dialog->type === 'group' && $dialog->group_type === 'task') { // 任务群组保存文件
if ($imageAttachment || !in_array($fileData['ext'], File::imageExt)) { // 如果是图片不保存
$task = ProjectTask::whereDialogId($dialog->id)->first();
if ($task) {
$file = ProjectTaskFile::createInstance([
'project_id' => $task->project_id,
'task_id' => $task->id,
'name' => $fileData['name'],
'size' => $fileData['size'],
'ext' => $fileData['ext'],
'path' => $fileData['path'],
'thumb' => $fileData['thumb'],
'userid' => $user->userid,
]);
$file->save();
}
}
$fileData = $data['data'];
$filePath = $fileData['file'];
$fileName = $fileData['name'];
$fileData['thumb'] = Base::unFillUrl($fileData['thumb']);
$fileData['size'] *= 1024;
// 任务群组保存文件
if ($dialog->group_type === 'task') {
// 如果是图片不保存
if ($imageAttachment || !in_array($fileData['ext'], File::imageExt)) {
$task = ProjectTask::whereDialogId($dialog->id)->first();
if ($task) {
$file = ProjectTaskFile::createInstance([
'project_id' => $task->project_id,
'task_id' => $task->id,
'name' => $fileData['name'],
'size' => $fileData['size'],
'ext' => $fileData['ext'],
'path' => $fileData['path'],
'thumb' => $fileData['thumb'],
'userid' => $user->userid,
]);
$file->save();
}
}
//
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'file', $fileData, $user->userid);
if (Base::isSuccess($result)) {
if (isset($task)) {
$result['data']['task_id'] = $task->id;
}
}
// 发送消息
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'file', $fileData, $user->userid);
if (Base::isSuccess($result)) {
if (isset($task)) {
$result['data']['task_id'] = $task->id;
}
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use Cache;
use Carbon\Carbon;
use App\Module\Base;
use App\Module\Doo;
@@ -315,6 +316,24 @@ class WebSocketDialogMsg extends AbstractModel
return Base::retSuccess('success', $resData);
}
/**
* 是否完成所有待办
* @param bool $noCache 是否禁止缓存
* @return int 1=已完成 0=未完成
*/
public function isTodoDone(?bool $noCache = false): int
{
if ($noCache) {
Cache::forget('todo_done_' . $this->id);
}
if ($this->todo <= 0) {
return 1;
}
return (int) Cache::remember('todo_done_' . $this->id, Carbon::now()->addDays(), function () {
return WebSocketDialogMsgTodo::whereMsgId($this->id)->whereDoneAt(null)->exists() ? 0 : 1;
});
}
/**
* 标注、取消标注
* @param int $sender 标注的会员ID
@@ -367,24 +386,15 @@ class WebSocketDialogMsg extends AbstractModel
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
return Base::retError('此消息不支持设待办');
}
$dialog = WebSocketDialog::find($this->dialog_id);
$current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray();
$cancel = array_diff($current, $userids);
$setup = array_diff($userids, $current);
//
$this->todo = $setup || count($current) > count($cancel) ? $sender : 0;
$this->save();
$upData = [
'id' => $this->id,
'todo' => $this->todo,
'dialog_id' => $this->dialog_id,
];
$dialog = WebSocketDialog::find($this->dialog_id);
$dialog->pushMsg('update', $upData);
//
$retData = [
'add' => [],
'update' => $upData
];
$addData = [];
if ($cancel) {
$res = self::sendMsg(null, $this->dialog_id, 'todo', [
'action' => 'remove',
@@ -396,7 +406,7 @@ class WebSocketDialogMsg extends AbstractModel
]
], $sender);
if (Base::isSuccess($res)) {
$retData['add'][] = $res['data'];
$addData[] = $res['data'];
WebSocketDialogMsgTodo::whereMsgId($this->id)->whereIn('userid', $cancel)->delete();
}
}
@@ -411,7 +421,7 @@ class WebSocketDialogMsg extends AbstractModel
]
], $sender);
if (Base::isSuccess($res)) {
$retData['add'][] = $res['data'];
$addData[] = $res['data'];
$useridList = $dialog->dialogUser->pluck('userid')->toArray();
foreach ($setup as $userid) {
if (!in_array($userid, $useridList)) {
@@ -426,7 +436,18 @@ class WebSocketDialogMsg extends AbstractModel
}
}
//
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', $retData);
$upData = [
'id' => $this->id,
'todo' => $this->todo,
'todo_done' => $this->isTodoDone(true),
'dialog_id' => $this->dialog_id,
];
$dialog->pushMsg('update', $upData);
//
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
'add' => $addData,
'update' => $upData,
]);
}
/**
@@ -456,27 +477,10 @@ class WebSocketDialogMsg extends AbstractModel
'parent_id' => $this->id, // 转发的消息ID
'parent_userid' => $this->userid, // 转发的消息会员ID
'show' => $showSource, // 是否显示原发送者信息
'leave' => $leaveMessage ? 1 : 0, // 是否留言用于判断是否发给AI
];
$msgs = [];
$already = [];
if ($dialogids) {
if (!is_array($dialogids)) {
$dialogids = [$dialogids];
}
foreach ($dialogids as $dialogid) {
$res = self::sendMsg('forward-' . $forwardId, $dialogid, $this->type, $msgData, $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
$already[] = $dialogid;
}
if ($leaveMessage) {
$res = self::sendMsg(null, $dialogid, 'text', ['text' => $leaveMessage], $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
}
}
}
}
$dialogs = [];
if ($userids) {
if (!is_array($userids)) {
$userids = [$userids];
@@ -486,17 +490,35 @@ class WebSocketDialogMsg extends AbstractModel
continue;
}
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
if ($dialog && !in_array($dialog->id, $already)) {
$res = self::sendMsg('forward-' . $forwardId, $dialog->id, $this->type, $msgData, $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
}
if ($leaveMessage) {
$res = self::sendMsg(null, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
}
}
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
if ($dialogids) {
if (!is_array($dialogids)) {
$dialogids = [$dialogids];
}
foreach ($dialogids as $dialogid) {
if (isset($dialogs[$dialogid])) {
continue;
}
$dialog = WebSocketDialog::find($dialogid);
if ($dialog) {
$dialogs[$dialog->id] = $dialog;
}
}
}
foreach ($dialogs as $dialog) {
$res = self::sendMsg('forward-' . $forwardId, $dialog->id, $this->type, $msgData, $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
}
if ($leaveMessage) {
$action = $dialog->isAiDialog() ? "reply-{$res['data']['id']}" : null;
$res = self::sendMsg($action, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid);
if (Base::isSuccess($res)) {
$msgs[] = $res['data'];
}
}
}
@@ -655,7 +677,7 @@ class WebSocketDialogMsg extends AbstractModel
* @param bool $preserveHtml 保留html格式
* @return string|string[]|null
*/
private static function previewTextMsg($msgData, $preserveHtml = false)
public static function previewTextMsg($msgData, $preserveHtml = false)
{
$text = $msgData['text'] ?? '';
if (!$text) return '';
@@ -664,8 +686,16 @@ class WebSocketDialogMsg extends AbstractModel
if (preg_match('/:::\s*reasoning\s+/', $text)) {
return Doo::translate('思考中...');
}
$text = Base::markdown2html($text);
$text = self::previewConvertTaskList($text);
$title = '';
if (preg_match('/^#{1,2}\s+(.+)/m', $text, $matches)) {
$title = trim($matches[1]);
}
if ($title) {
$text = $title;
} else {
$text = Base::markdown2html($text);
$text = self::previewConvertTaskList($text);
}
}
$text = preg_replace("/<img\s+class=\"emoticon\"[^>]*?alt=\"(\S+)\"[^>]*?>/", "[$1]", $text);
$text = preg_replace("/<img\s+class=\"emoticon\"[^>]*?>/", "[" . Doo::translate('动画表情') . "]", $text);
@@ -1328,7 +1358,6 @@ class WebSocketDialogMsg extends AbstractModel
});
}
/**
* 将被@的人加入群
* @param WebSocketDialog $dialog 对话

View File

@@ -3,8 +3,8 @@
namespace App\Models;
use App\Module\Base;
use App\Module\Extranet;
use Swoole\Coroutine;
use App\Tasks\UpdateSessionTitleViaAiTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Cache;
/**
@@ -66,15 +66,14 @@ class WebSocketDialogSession extends AbstractModel
if ($dialogMsg->type != 'text') {
return;
}
if ($dialogMsg->msg['text'] === '...') {
return;
}
$cacheKey = 'dialog_session_title_' . $sessionId;
if (Cache::has($cacheKey)) {
return;
}
$originalTitle = $dialogMsg->key ?: $dialogMsg->msg['text'] ?: 'Untitled';
$title = Base::cutStr($originalTitle, 100);
if ($title == '...') {
return;
}
$title = $dialogMsg->key ?: WebSocketDialogMsg::previewTextMsg($dialogMsg->msg) ?: 'Untitled';
$session = self::whereId($sessionId)->first();
if (!$session) {
return;
@@ -82,18 +81,6 @@ class WebSocketDialogSession extends AbstractModel
$session->title = $title;
$session->save();
Cache::forever($cacheKey, true);
// 通过AI接口更新对话标题
go(function () use ($session, $title, $originalTitle) {
Coroutine::sleep(0.1);
$res = Extranet::openAIGenerateTitle($originalTitle);
if (Base::isError($res)) {
return;
}
$newTitle = $res['data'];
if ($newTitle && $newTitle != $title) {
$session->title = Base::cutStr($newTitle, 100);
$session->save();
}
});
Task::deliver(new UpdateSessionTitleViaAiTask($session->id, $dialogMsg->msg['text']));
}
}

461
app/Module/AI.php Normal file
View File

@@ -0,0 +1,461 @@
<?php
namespace App\Module;
use App\Models\Setting;
use Cache;
use Carbon\Carbon;
/**
* AI 助手模块
*/
class AI
{
protected $post = [];
protected $headers = [];
protected $urlPath = '';
protected $timeout = 30;
/**
* 构造函数
* @param array $post
* @param array $headers
*/
public function __construct($post = [], $headers = [])
{
$this->post = $post ?? [];
$this->headers = $headers ?? [];
}
/**
* 设置请求参数
* @param array $post
*/
public function setPost($post)
{
$this->post = array_merge($this->post, $post);
}
/**
* 设置请求头
* @param array $headers
*/
public function setHeaders($headers)
{
$this->headers = array_merge($this->headers, $headers);
}
/**
* 设置请求路径
* @param string $urlPath
*/
public function setUrlPath($urlPath)
{
$this->urlPath = $urlPath;
}
/**
* 设置请求超时时间
* @param int $timeout
*/
public function setTimeout($timeout)
{
$this->timeout = $timeout;
}
/**
* 请求 AI 接口
* @param bool $resRaw 是否返回原始数据
* @return array
*/
public function request($resRaw = false)
{
$aiSetting = Base::setting('aiSetting');
if (!Setting::AIOpen()) {
return Base::retError("AI 助手未开启");
}
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $aiSetting['ai_api_key'],
];
if ($aiSetting['ai_proxy']) {
$headers['CURLOPT_PROXY'] = $aiSetting['ai_proxy'];
$headers['CURLOPT_PROXYTYPE'] = str_contains($aiSetting['ai_proxy'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$headers = array_merge($headers, $this->headers);
$url = $aiSetting['ai_api_url'] ?: 'https://api.openai.com/v1';
$url = $url . ($this->urlPath ?: '/chat/completions');
$result = Ihttp::ihttp_request($url, $this->post, $headers, $this->timeout);
if (Base::isError($result)) {
return Base::retError("AI 接口请求失败", $result);
}
$result = $result['data'];
if (!$resRaw) {
$resData = Base::json2array($result);
if (empty($resData['choices'])) {
return Base::retError("AI 接口返回数据格式错误", $resData);
}
$result = $resData['choices'][0]['message']['content'];
$result = trim($result);
if (empty($result)) {
return Base::retError("AI 接口返回数据为空");
}
}
return Base::retSuccess("success", $result);
}
/** ******************************************************************************************** */
/** ******************************************************************************************** */
/** ******************************************************************************************** */
/**
* 通过 openAI 语音转文字
* @param string $filePath 语音文件路径
* @param array $extParams 扩展参数
* @param bool $noCache 是否禁用缓存
* @return array
*/
public static function transcriptions($filePath, $extParams = [], $noCache = false)
{
if (!file_exists($filePath)) {
return Base::retError("语音文件不存在");
}
$systemSetting = Base::setting('system');
if ($systemSetting['voice2text'] !== 'open') {
return Base::retError("语音转文字功能未开启");
}
$cacheKey = "openAItranscriptions::" . md5($filePath . '_' . Base::array2json($extParams));
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function () use ($extParams, $filePath) {
$post = array_merge($extParams, [
'file' => new \CURLFile($filePath),
'model' => 'whisper-1',
]);
$header = [
'Content-Type' => 'multipart/form-data',
];
$ai = new self($post, $header);
$ai->setUrlPath('/audio/transcriptions');
$ai->setTimeout(15);
$res = $ai->request(true);
if (Base::isError($res)) {
return Base::retError("语音转文字失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['text'])) {
return Base::retError("语音转文字失败", $resData);
}
return Base::retSuccess("success", [
'file' => $filePath,
'text' => $resData['text'],
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 通过 openAI 翻译
* @param string $text 需要翻译的文本内容
* @param string $targetLanguage 目标语言English, 简体中文, 日本語等)
* @param bool $noCache 是否禁用缓存
* @return array
*/
public static function translations($text, $targetLanguage, $noCache = false)
{
$systemSetting = Base::setting('system');
if ($systemSetting['translation'] !== 'open') {
return Base::retError("翻译功能未开启");
}
$cacheKey = "openAItranslations::" . md5($text . '_' . $targetLanguage);
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $targetLanguage) {
$post = json_encode([
"model" => "gpt-4.1-nano",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名资深的专业翻译专家,专门从事项目任务管理系统的多语言本地化工作。
翻译任务:将提供的文本内容翻译为 {$targetLanguage}
专业要求:
1. 术语一致性:确保项目管理、任务管理、团队协作等专业术语的准确翻译
2. 上下文理解:根据项目管理场景选择最合适的表达方式
3. 格式保持:严格保持原文的格式、结构、标点符号和排版
4. 语言规范:使用目标语言的标准表达,符合该语言的语法和习惯
5. 专业性:体现项目管理领域的专业水准和准确性
6. 简洁性:避免冗余表达,保持语言简洁明了
注意事项:
- 保留所有HTML标签、特殊符号、数字、日期格式
- 对于专有名词(如软件名称、品牌名)保持原文
- 确保翻译后的文本自然流畅,符合目标语言的表达习惯
- 如遇到歧义表达,优先选择项目管理场景下的含义
请直接返回翻译结果,不要包含任何解释或标记。
EOF
],
[
"role" => "user",
"content" => "请将以下内容翻译为 {$targetLanguage}\n\n{$text}"
]
],
"temperature" => 0.2,
"max_tokens" => max(1000, intval(mb_strlen($text) * 1.5))
]);
$ai = new self($post);
$ai->setTimeout(60);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("翻译请求失败", $res);
}
$result = $res['data'];
if (empty($result)) {
return Base::retError("翻译结果为空");
}
return Base::retSuccess("success", [
'translated_text' => $result,
'target_language' => $targetLanguage,
'translated_at' => date('Y-m-d H:i:s')
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 通过 openAI 生成标题
* @param string $text 需要生成标题的文本内容
* @param bool $noCache 是否禁用缓存
* @return array
*/
public static function generateTitle($text, $noCache = false)
{
$cacheKey = "openAIGenerateTitle::" . md5($text);
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(24), function () use ($text) {
$post = json_encode([
"model" => "gpt-4.1-nano",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一个专业的标题生成器,专门为项目任务管理系统的对话内容生成精准、简洁的标题。
要求:
1. 标题要准确概括文本的核心内容和主要意图
2. 标题长度控制在5-20个字符之间
3. 语言简洁明了,避免冗余词汇
4. 适合在项目管理场景中使用
5. 不要包含引号或特殊符号
6. 如果是技术讨论,突出技术要点
7. 如果是项目管理内容,突出关键动作或目标
8. 如果是需求讨论,突出需求的核心点
请直接返回标题,不要包含任何解释或其他内容。
EOF
],
[
"role" => "user",
"content" => "请为以下内容生成一个合适的标题:\n\n" . $text
]
],
"temperature" => 0.3,
"max_tokens" => 100
]);
$ai = new self($post);
$ai->setTimeout(10);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("标题生成失败", $res);
}
$result = $res['data'];
if (empty($result)) {
return Base::retError("生成的标题为空");
}
return Base::retSuccess("success", [
'title' => $result,
'length' => mb_strlen($result),
'generated_at' => date('Y-m-d H:i:s')
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 通过 openAI 生成职场笑话、心灵鸡汤
* @param bool $noCache 是否禁用缓存
* @return array 返回20个笑话和20个心灵鸡汤
*/
public static function generateJokeAndSoup($noCache = false)
{
$cacheKey = "openAIJokeAndSoup::" . md5(date('Y-m-d'));
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(6), function () {
$post = json_encode([
"model" => "gpt-4.1-nano",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一个专业的内容生成器。
要求:
1. 笑话要幽默风趣,适合职场环境,内容积极正面
2. 心灵鸡汤要励志温暖,适合职场人士阅读
3. 每个笑话和鸡汤都要简洁明了尽量不超过100字
4. 必须严格按照以下JSON格式返回不要markdown格式不要包含任何其他内容
{
"jokes": [
"笑话内容1",
"笑话内容2",
...
],
"soups": [
"心灵鸡汤内容1",
"心灵鸡汤内容2",
...
]
}
EOF
],
[
"role" => "user",
"content" => "请生成20个职场笑话和20个心灵鸡汤"
]
],
"temperature" => 0.8
]);
$ai = new self($post);
$ai->setTimeout(120);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("生成失败", $res);
}
// 清理可能的markdown代码块标记
$content = $res['data'];
$content = preg_replace('/^\s*```json\s*/', '', $content);
$content = preg_replace('/\s*```\s*$/', '', $content);
if (empty($content)) {
return Base::retError("翻译结果为空");
}
// 解析JSON
$parsedData = Base::json2array($content);
if (!$parsedData || !isset($parsedData['jokes']) || !isset($parsedData['soups'])) {
return Base::retError("生成内容格式错误", $content);
}
// 验证数据完整性
if (!is_array($parsedData['jokes']) || !is_array($parsedData['soups'])) {
return Base::retError("生成内容格式错误", $parsedData);
}
// 过滤空内容并确保有内容
$jokes = array_filter(array_map('trim', $parsedData['jokes']));
$soups = array_filter(array_map('trim', $parsedData['soups']));
if (empty($jokes) || empty($soups)) {
return Base::retError("生成内容为空", $parsedData);
}
return Base::retSuccess("success", [
'jokes' => array_values($jokes),
'soups' => array_values($soups),
'total_jokes' => count($jokes),
'total_soups' => count($soups),
'generated_at' => date('Y-m-d H:i:s')
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 获取 ollama 模型
* @param $baseUrl
* @param $key
* @param $agency
* @return array
*/
public static function ollamaModels($baseUrl, $key = null, $agency = null)
{
$extra = [
'Content-Type' => 'application/json',
];
if ($key) {
$extra['Authorization'] = 'Bearer ' . $key;
}
if ($agency) {
$extra['CURLOPT_PROXY'] = $agency;
$extra['CURLOPT_PROXYTYPE'] = str_contains($agency, 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$res = Ihttp::ihttp_request(rtrim($baseUrl, '/') . '/api/tags', [], $extra, 15);
if (Base::isError($res)) {
return Base::retError("获取失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['models'])) {
return Base::retError("获取失败", $resData);
}
$models = [];
foreach ($resData['models'] as $model) {
if ($model['name'] !== $model['model']) {
$models[] = "{$model['model']} | {$model['name']}";
} else {
$models[] = $model['model'];
}
}
return Base::retSuccess("success", [
'models' => $models,
'original' => $resData['models']
]);
}
}

60
app/Module/Apps.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use App\Services\RequestContext;
use Symfony\Component\Yaml\Yaml;
class Apps
{
/**
* 判断应用是否已安装
*
* @param string $appId 应用ID名称
* @return bool 如果应用已安装返回 true否则返回 false
*/
public static function isInstalled(string $appId): bool
{
if ($appId === 'appstore') {
return true;
}
$key = 'app_installed_' . $appId;
if (RequestContext::has($key)) {
return RequestContext::get($key);
}
$configFile = base_path('docker/appstore/config/' . $appId . '/config.yml');
$installed = false;
if (file_exists($configFile)) {
$configData = Yaml::parseFile($configFile);
$installed = $configData['status'] === 'installed';
}
return RequestContext::save($key, $installed);
}
/**
* 判断应用是否已安装,如果未安装则抛出异常
* @param string $appId
* @return void
*/
public static function isInstalledThrow(string $appId): void
{
if (!self::isInstalled($appId)) {
$name = match ($appId) {
'ai' => 'AI Robot',
'face' => 'Face check-in',
'appstore' => 'AppStore',
'approve' => 'Approval',
'office' => 'OnlyOffice',
'drawio' => 'Drawio',
'minder' => 'Minder',
'search' => 'ZincSearch',
default => $appId,
};
throw new ApiException("应用「{$name}」未安装", [], 0, false);
}
}
}

View File

@@ -9,7 +9,7 @@ use App\Services\RequestContext;
use Cache;
use Carbon\Carbon;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Exception\CommonMarkException;
use League\HTMLToMarkdown\HtmlConverter;
use Overtrue\Pinyin\Pinyin;
use Redirect;
use Request;
@@ -351,7 +351,7 @@ class Base
/**
* 删除文件夹及文件夹下所有的文件
* @param $dirName
* @param bool $undeleteDir 不删除文件夹(只删除文件)
* @param bool $undeleteDir 不删除文件夹本身(只删除文件夹里面的内容
*/
public static function deleteDirAndFile($dirName, $undeleteDir = false)
{
@@ -802,16 +802,16 @@ class Base
str_starts_with(str_replace(' ', '', $str), "data:image/")
) {
return $str;
} else {
if (RequestContext::has('fill_url_remote_url')) {
return "{{RemoteURL}}" . $str;
}
try {
return url($str);
} catch (\Throwable) {
return self::getSchemeAndHost() . "/" . $str;
}
}
if (RequestContext::has('fill_url_remote_url')) {
return "{{RemoteURL}}" . $str;
}
try {
$fillUrl = url($str);
} catch (\Throwable) {
$fillUrl = self::getSchemeAndHost() . "/" . $str;
}
return RequestContext::replaceBaseUrl($fillUrl);
}
/**
@@ -827,12 +827,19 @@ class Base
}
return $str;
}
try {
$find = url('');
} catch (\Throwable) {
$find = self::getSchemeAndHost();
if (empty($str)) {
return $str;
}
return Base::leftDelete($str, $find . '/');
$parsedUrl = parse_url($str);
if (isset($parsedUrl['scheme']) && isset($parsedUrl['host'])) {
$relativePath = $parsedUrl['path'] ?? '';
$relativePath = ltrim($relativePath, '/');
$absolutePath = public_path($relativePath);
if (file_exists($absolutePath) || file_exists(Base::thumbRestore($absolutePath))) {
return $relativePath;
}
}
return $str;
}
/**
@@ -1397,11 +1404,8 @@ class Base
*/
public static function ajaxError($msg, $data = [], $ret = 0, $abortCode = 404)
{
if (Request::header('Content-Type') === 'application/json') {
return Base::retError($msg, $data, $ret);
} else {
abort($abortCode, $msg);
}
abort_if(Request::header('Content-Type') !== 'application/json', $abortCode, Doo::translate($msg));
return Base::retError($msg, $data, $ret);
}
/**
@@ -1851,12 +1855,22 @@ class Base
* 获取每页数量
* @param $max
* @param $default
* @param string $inputName
* @param string|array $inputName
* @return mixed
*/
public static function getPaginate($max, $default, $inputName = 'pagesize')
public static function getPaginate($max, $default, $inputName = ['pagesize', 'take'])
{
return Min(Max(Base::nullShow(Request::input($inputName), $default), 1), $max);
$value = null;
if (!is_array($inputName)) {
$inputName = [$inputName];
}
foreach ($inputName as $name) {
if (Request::exists($name)) {
$value = Request::input($name);
break;
}
}
return Min(Max(Base::nullShow($value, $default), 1), $max);
}
/**
@@ -1872,18 +1886,18 @@ class Base
if (!in_array($extension, ['mp3', 'wav'])) {
return Base::retError('语音格式错误');
}
$fileName = 'record_' . md5($base64) . '.' . $extension;
$saveName = 'record_' . md5($base64) . '.' . $extension;
$fileDir = $param['path'];
$filePath = public_path($fileDir);
Base::makeDir($filePath);
if (file_put_contents($filePath . $fileName, base64_decode(str_replace($res[1], '', $base64)))) {
$fileSize = filesize($filePath . $fileName);
if (file_put_contents($filePath . $saveName, base64_decode(str_replace($res[1], '', $base64)))) {
$fileSize = filesize($filePath . $saveName);
$array = [
"name" => $fileName, //原文件名
"name" => $saveName, //原文件名
"size" => Base::twoFloat($fileSize / 1024, true), //大小KB
"file" => $filePath . $fileName, //文件的完整路径 "D:\www....KzZ.jpg"
"path" => $fileDir . $fileName, //相对路径 "uploads/pic....KzZ.jpg"
"url" => Base::fillUrl($fileDir . $fileName), //完整的URL "https://.....hhsKzZ.jpg"
"file" => $filePath . $saveName, //文件的完整路径 "D:\www....KzZ.jpg"
"path" => $fileDir . $saveName, //相对路径 "uploads/pic....KzZ.jpg"
"url" => Base::fillUrl($fileDir . $saveName), //完整的URL "https://.....hhsKzZ.jpg"
"ext" => $extension, //文件后缀名
];
return Base::retSuccess('success', $array);
@@ -1894,8 +1908,23 @@ class Base
/**
* image64图片保存
* @param array $param [ image64=带前缀的base64, path=>文件路径, fileName=>文件名称, scale=>[压缩原图宽,高, 压缩方式], autoThumb=>false不要自动生成缩略图, 'quality'=>压缩图片质量(默认0不压缩) ]
* @return array [name=>文件名, size=>文件大小(单位KB),file=>绝对地址, path=>相对地址, url=>全路径地址, ext=>文件后缀名]
* @param array $param [
image64=带前缀的base64,
path=>文件路径,
fileName=>文件名称,
saveName=>保存文件名称,
scale=>[压缩原图宽,高, 压缩方式],
autoThumb=>false不要自动生成缩略图,
quality=>压缩图片质量(默认0不压缩)
]
* @return array [
name=>文件名,
size=>文件大小(单位KB),
file=>绝对地址,
path=>相对地址,
url=>全路径地址,
ext=>文件后缀名
]
*/
public static function image64save($param)
{
@@ -1906,8 +1935,8 @@ class Base
return Base::retError('图片格式错误');
}
$scaleName = "";
if ($param['fileName']) {
$fileName = basename($param['fileName']);
if ($param['saveName']) {
$saveName = basename($param['saveName']);
} else {
if ($param['scale'] && is_array($param['scale'])) {
list($width, $height) = $param['scale'];
@@ -1918,21 +1947,21 @@ class Base
}
}
}
$fileName = 'paste_' . md5($imgBase64) . '.' . $extension;
$saveName = 'paste_' . md5($imgBase64) . '.' . $extension;
$scaleName = md5_file($imgBase64) . $scaleName . '.' . $extension;
}
$fileDir = $param['path'];
$filePath = public_path($fileDir);
$fileFullPath = $filePath . $fileName;
$fileFullPath = $filePath . $saveName;
Base::makeDir($filePath);
if (file_put_contents($fileFullPath, base64_decode(str_replace($res[1], '', $imgBase64)))) {
$fileSize = filesize($fileFullPath);
$array = [
"name" => $fileName, //原文件名
"name" => $param['fileName'] ?: $saveName, //原文件名
"size" => Base::twoFloat($fileSize / 1024, true), //大小KB
"file" => $fileFullPath, //文件的完整路径 "D:\www....KzZ.jpg"
"path" => $fileDir . $fileName, //相对路径 "uploads/pic....KzZ.jpg"
"url" => Base::fillUrl($fileDir . $fileName), //完整的URL "https://.....hhsKzZ.jpg"
"path" => $fileDir . $saveName, //相对路径 "uploads/pic....KzZ.jpg"
"url" => Base::fillUrl($fileDir . $saveName), //完整的URL "https://.....hhsKzZ.jpg"
"thumb" => '', //缩略图(预览图) "https://.....hhsKzZ.jpg_thumb.jpg"
"width" => -1, //图片宽度
"height" => -1, //图片高度
@@ -1963,10 +1992,10 @@ class Base
// 重命名
if ($scaleName) {
$scaleName = str_replace(['{WIDTH}', '{HEIGHT}'], [$array['width'], $array['height']], $scaleName);
if (rename($array['file'], Base::rightDelete($array['file'], $fileName) . $scaleName)) {
$array['file'] = Base::rightDelete($array['file'], $fileName) . $scaleName;
$array['path'] = Base::rightDelete($array['path'], $fileName) . $scaleName;
$array['url'] = Base::rightDelete($array['url'], $fileName) . $scaleName;
if (rename($array['file'], Base::rightDelete($array['file'], $saveName) . $scaleName)) {
$array['file'] = Base::rightDelete($array['file'], $saveName) . $scaleName;
$array['path'] = Base::rightDelete($array['path'], $saveName) . $scaleName;
$array['url'] = Base::rightDelete($array['url'], $saveName) . $scaleName;
}
}
}
@@ -2000,12 +2029,14 @@ class Base
file=>Request::file,
path=>文件路径,
fileName=>文件名称,
saveName=>保存文件名称,
scale=>[压缩原图宽,高, 压缩方式],
size=>限制大小KB,
autoThumb=>false不要自动生成缩略图,
chmod=>权限(默认0644),
quality=>压缩图片质量(默认0不压缩),
convertVideo=>转换视频格式(默认false) ,
compressVideo=>压缩视频(默认false如果转换就不压缩) ,
]
* @return array [
name=>原文件名,
@@ -2091,10 +2122,10 @@ class Base
}
}
$scaleName = "";
if ($param['fileName'] === true) {
$fileName = $file->getClientOriginalName();
} elseif ($param['fileName']) {
$fileName = basename($param['fileName']);
if ($param['saveName'] === true) {
$saveName = $file->getClientOriginalName();
} elseif ($param['saveName']) {
$saveName = basename($param['saveName']);
} else {
if ($param['scale'] && is_array($param['scale'])) {
list($width, $height) = $param['scale'];
@@ -2105,19 +2136,19 @@ class Base
}
}
}
$fileName = md5_file($file);
$saveName = md5_file($file);
$scaleName = md5_file($file) . $scaleName;
if ($extension) {
$fileName = $fileName . '.' . $extension;
$saveName = $saveName . '.' . $extension;
$scaleName = $scaleName . '.' . $extension;
}
}
//
$file->move(public_path($param['path']), $fileName);
$file->move(public_path($param['path']), $saveName);
//
$path = $param['path'] . $fileName;
$path = $param['path'] . $saveName;
$array = [
"name" => $file->getClientOriginalName(), //原文件名
"name" => $param['fileName'] ?: $file->getClientOriginalName(), //原文件名
"size" => Base::twoFloat($fileSize / 1024, true), //大小KB
"file" => public_path($path), //文件的完整路径 "D:\www....KzZ.jpg"
"path" => $path, //相对路径 "uploads/pic....KzZ.jpg"
@@ -2163,6 +2194,7 @@ class Base
}
@shell_exec($command);
if (file_exists($output) && filesize($output) > 0) {
// 压缩后的文件正常
@unlink($array['file']);
$array = array_merge($array, [
"name" => Base::rightReplace($array['name'], ".{$array['ext']}", '.mp4'),
@@ -2173,6 +2205,27 @@ class Base
"ext" => 'mp4',
]);
}
$param['compressVideo'] = false; // 如果转换就不压缩
}
if ($param['compressVideo'] && $array['ext'] == 'mp4') {
// 压缩视频
$output = $array['file'] . '_compress';
$command = sprintf("ffmpeg -y -i %s -c:v libx264 -crf 28 -preset medium -c:a aac -b:a 96k %s 2>&1", escapeshellarg($array['file']), escapeshellarg($output));
@shell_exec($command);
if (file_exists($output) && filesize($output) > 0) {
// 压缩后的文件正常
if (filesize($output) < filesize($array['file'])) {
// 小于原文件
@unlink($array['file']);
$array = array_merge($array, [
"size" => Base::twoFloat(filesize($output) / 1024, true),
"file" => $output,
]);
} else {
// 大于原文件
@unlink($output);
}
}
}
if (in_array($array['ext'], ['mov', 'webm', 'mp4'])) {
// 视频尺寸
@@ -2207,10 +2260,10 @@ class Base
// 重命名
if ($scaleName) {
$scaleName = str_replace(['{WIDTH}', '{HEIGHT}'], [$array['width'], $array['height']], $scaleName);
if (rename($array['file'], Base::rightDelete($array['file'], $fileName) . $scaleName)) {
$array['file'] = Base::rightDelete($array['file'], $fileName) . $scaleName;
$array['path'] = Base::rightDelete($array['path'], $fileName) . $scaleName;
$array['url'] = Base::rightDelete($array['url'], $fileName) . $scaleName;
if (rename($array['file'], Base::rightDelete($array['file'], $saveName) . $scaleName)) {
$array['file'] = Base::rightDelete($array['file'], $saveName) . $scaleName;
$array['path'] = Base::rightDelete($array['path'], $saveName) . $scaleName;
$array['url'] = Base::rightDelete($array['url'], $saveName) . $scaleName;
}
}
}
@@ -2535,22 +2588,37 @@ class Base
/**
* 中文转拼音
* @param $str
* @param $delim
* @return string
*/
public static function cn2pinyin($str)
public static function cn2pinyin($str, $delim = '')
{
if (empty($str)) {
return '';
}
if (!preg_match("/^[a-zA-Z0-9_.]+$/", $str)) {
$str = Cache::rememberForever("cn2pinyin:" . md5($str), function() use ($str) {
$str = Cache::rememberForever("cn2pinyin:" . md5($str . '_' . $delim), function () use ($delim, $str) {
$pinyin = new Pinyin();
return $pinyin->permalink($str, '');
return $pinyin->permalink($str, $delim);
});
}
return $str;
}
/**
* 驼峰转下划线
* @param $str
* @return string
*/
public static function camel2snake($str)
{
if (empty($str)) {
return '';
}
$str = preg_replace('/([a-z])([A-Z])/', '$1_$2', $str);
return strtolower($str);
}
/**
* 缓存数据
* @param $name
@@ -2977,11 +3045,27 @@ class Base
*/
public static function markdown2html($markdown)
{
$converter = new CommonMarkConverter();
try {
$converter = new CommonMarkConverter();
return $converter->convert($markdown);
} catch (CommonMarkException $e) {
} catch (\League\CommonMark\Exception\CommonMarkException $e) {
return $markdown;
}
}
/**
* html 转 MD(markdown)
* @param $html
* @param array $options
* @return mixed|string
*/
public static function html2markdown($html, $options = [])
{
try {
$converter = new HtmlConverter($options);
return $converter->convert($html);
} catch (\Exception) {
return $html;
}
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Module;
/**
* 客户端上下文
*/
class ClientContext
{
public array $context = [];
public float $createdAt = 0;
public float $updatedAt = 0;
public function __construct()
{
$this->createdAt = microtime(true);
$this->updatedAt = microtime(true);
}
/**
* 设置上下文
* @param string $key
* @param mixed $value
* @return void
*/
public function set(string $key, mixed $value): void
{
$this->context[$key] = $value;
$this->updatedAt = microtime(true);
}
/**
* 批量设置上下文
* @param array $data
* @return void
*/
public function setMultiple(array $data): void
{
foreach ($data as $key => $value) {
$this->context[$key] = $value;
}
$this->updatedAt = microtime(true);
}
/**
* 获取上下文
* @param string $key
* @param mixed $default
* @return mixed
*/
public function get(string $key, mixed $default = null): mixed
{
return $this->context[$key] ?? $default;
}
/**
* 判断上下文是否存在
* @param string $key
* @return bool
*/
public function has(string $key): bool
{
return isset($this->context[$key]);
}
/**
* 更新上下文
* @return void
*/
public function update(): void
{
$this->updatedAt = microtime(true);
}
/**
* 清除上下文
* @return void
*/
public function clear(): void
{
$this->context = [];
}
}

View File

@@ -2,72 +2,40 @@
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\User;
use Cache;
use Carbon\Carbon;
use FFI;
use App\Module\Interface\DooSo;
use App\Services\RequestContext;
class Doo
{
private static $doo;
private static $userLanguage = "";
private const DOO_INSTANCE = 'doo_instance';
private const DOO_LANGUAGE = 'doo_language';
/**
* char转为字符串
* @param $text
* @return string
*/
private static function string($text): string
{
return FFI::string($text);
}
/**
* 装载
* 加载Doo实例
* - 如果已经存在,则直接返回
* - 否则创建一个新的FFI实例并初始化
* @param $token
* @param $language
* @return DooSo
*/
public static function load($token = null, $language = null)
public static function load($token = null, $language = null): DooSo
{
self::$doo = FFI::cdef(<<<EOF
void initialize(char* work, char* token, char* lang);
char* license();
char* licenseDecode(char* license);
char* licenseSave(char* license);
int userId();
char* userExpiredAt();
char* userEmail();
char* userEncrypt();
char* userToken();
char* userCreate(char* email, char* password);
char* tokenEncode(int userid, char* email, char* encrypt, int days);
char* tokenDecode(char* val);
char* translate(char* val, char* val);
char* md5s(char* text, char* password);
char* macs();
char* dooSN();
char* pgpGenerateKeyPair(char* name, char* email, char* passphrase);
char* pgpEncrypt(char* plainText, char* publicKey);
char* pgpDecrypt(char* cipherText, char* privateKey, char* passphrase);
EOF, "/usr/lib/doo/doo.so");
$token = $token ?: Base::token();
$language = $language ?: Base::headerOrInput('language');
self::$doo->initialize("/var/www", $token, $language);
}
/**
* 获取实例
* @param $token
* @param $language
* @return mixed
*/
public static function doo($token = null, $language = null)
{
if (self::$doo == null) {
self::load($token, $language);
if (RequestContext::has(self::DOO_INSTANCE)) {
return RequestContext::get(self::DOO_INSTANCE);
}
return self::$doo;
$request = request();
if ($request && method_exists($request, 'header')) {
$token = $token ?: Base::token();
$language = $language ?: Base::headerOrInput('language');
}
$instance = new DooSo($token, $language);
RequestContext::set(self::DOO_INSTANCE, $instance);
RequestContext::set(self::DOO_LANGUAGE, $language);
return $instance;
}
/**
@@ -76,41 +44,7 @@ class Doo
*/
public static function license(): array
{
$array = Base::json2array(self::string(self::doo()->license()));
$ips = explode(",", $array['ip']);
$array['ip'] = [];
foreach ($ips as $ip) {
if (Base::is_ipv4($ip)) {
$array['ip'][] = $ip;
}
}
$domains = explode(",", $array['domain']);
$array['domain'] = [];
foreach ($domains as $domain) {
if (Base::is_domain($domain)) {
$array['domain'][] = $domain;
}
}
$macs = explode(",", $array['mac']);
$array['mac'] = [];
foreach ($macs as $mac) {
if (Base::isMac($mac)) {
$array['mac'][] = $mac;
}
}
$emails = explode(",", $array['email']);
$array['email'] = [];
foreach ($emails as $email) {
if (Base::isEmail($email)) {
$array['email'][] = $email;
}
}
return $array;
return self::load()->license();
}
/**
@@ -138,26 +72,13 @@ class Doo
return $content;
}
/**
* 解析License
* @param $license
* @return array
*/
public static function licenseDecode($license): array
{
return Base::json2array(self::string(self::doo()->licenseDecode($license)));
}
/**
* 保存License
* @param $license
*/
public static function licenseSave($license): void
{
$res = self::string(self::doo()->licenseSave($license));
if ($res != 'success') {
throw new ApiException($res ?: 'LICENSE 保存失败');
}
self::load()->licenseSave($license);
}
/**
@@ -166,7 +87,7 @@ class Doo
*/
public static function userId(): int
{
return intval(self::doo()->userId());
return self::load()->userId();
}
/**
@@ -175,18 +96,16 @@ class Doo
*/
public static function userExpired(): bool
{
$expiredAt = self::userExpiredAt();
return $expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now());
return self::load()->userExpired();
}
/**
* token过期时间来自请求的token
* @return string
* @return string|null
*/
public static function userExpiredAt(): string
public static function userExpiredAt(): ?string
{
$expiredAt = self::string(self::doo()->userExpiredAt());
return $expiredAt === 'forever' ? '' : $expiredAt;
return self::load()->userExpiredAt();
}
/**
@@ -195,7 +114,7 @@ class Doo
*/
public static function userEmail(): string
{
return self::string(self::doo()->userEmail());
return self::load()->userEmail();
}
/**
@@ -204,7 +123,7 @@ class Doo
*/
public static function userEncrypt(): string
{
return self::string(self::doo()->userEncrypt());
return self::load()->userEncrypt();
}
/**
@@ -213,7 +132,7 @@ class Doo
*/
public static function userToken(): string
{
return self::string(self::doo()->userToken());
return self::load()->userToken();
}
/**
@@ -224,23 +143,7 @@ class Doo
*/
public static function userCreate($email, $password): User|null
{
$data = Base::json2array(self::string(self::doo()->userCreate($email, $password)));
if (Base::isError($data)) {
throw new ApiException($data['msg'] ?: '注册失败');
}
if (\DB::transactionLevel() > 0) {
try {
\DB::commit();
\DB::beginTransaction();
} catch (\Throwable) {
// do nothing
}
}
$user = User::whereEmail($email)->first();
if (empty($user)) {
throw new ApiException('注册失败');
}
return $user;
return self::load()->userCreate($email, $password);
}
/**
@@ -253,7 +156,7 @@ class Doo
*/
public static function tokenEncode($userid, $email, $encrypt, int $days = 15): string
{
return self::string(self::doo()->tokenEncode($userid, $email, $encrypt, $days));
return self::load()->tokenEncode($userid, $email, $encrypt, $days);
}
/**
@@ -263,38 +166,42 @@ class Doo
*/
public static function tokenDecode($token): array
{
return Base::json2array(self::string(self::doo()->tokenDecode($token)));
return self::load()->tokenDecode($token);
}
/**
* 翻译
* @param $text
* @param string $lang
* @param ?string $lang
* @return string
*/
public static function translate($text, string $lang = ""): string
public static function translate($text, ?string $lang = ""): string
{
return self::string(self::doo()->translate($text, $lang ?: self::$userLanguage));
if (empty($lang)) {
$lang = RequestContext::get(self::DOO_LANGUAGE);
}
return self::load()->translate($text, $lang);
}
/**
* 设置语言
* @param string|integer $lang 语言 或 会员ID
* @param int|string $lang 语言 或 会员ID
* @return void
*/
public static function setLanguage($lang) {
public static function setLanguage(int|string $lang): void
{
if (Base::isNumber($lang)) {
$lang = User::find(intval($lang))?->lang ?: "";
}
self::$userLanguage = $lang;
RequestContext::set(self::DOO_LANGUAGE, $lang);
}
/**
* 获取语言列表 或 语言名称
* @param string|false $lang
* @param bool|string $lang
* @return string|string[]
*/
public static function getLanguages($lang = false)
public static function getLanguages(bool|string $lang = false): array|string
{
$array = [
"zh" => "简体中文",
@@ -331,7 +238,7 @@ class Doo
*/
public static function md5s($text, string $password = ""): string
{
return self::string(self::doo()->md5s($text, $password));
return self::load()->md5s($text, $password);
}
/**
@@ -340,14 +247,7 @@ class Doo
*/
public static function macs(): array
{
$macs = explode(",", self::string(self::doo()->macs()));
$array = [];
foreach ($macs as $mac) {
if (Base::isMac($mac)) {
$array[] = $mac;
}
}
return $array;
return self::load()->macs();
}
/**
@@ -356,7 +256,16 @@ class Doo
*/
public static function dooSN(): string
{
return self::string(self::doo()->dooSN());
return self::load()->dooSN();
}
/**
* 获取当前版本
* @return string
*/
public static function dooVersion(): string
{
return self::load()->dooVersion();
}
/**
@@ -368,7 +277,7 @@ class Doo
*/
public static function pgpGenerateKeyPair($name, $email, string $passphrase = ""): array
{
return Base::json2array(self::string(self::doo()->pgpGenerateKeyPair($name, $email, $passphrase)));
return self::load()->pgpGenerateKeyPair($name, $email, $passphrase);
}
/**
@@ -379,11 +288,7 @@ class Doo
*/
public static function pgpEncrypt($plaintext, $publicKey): string
{
if (strlen($publicKey) < 50) {
$keyCache = Base::json2array(Cache::get("KeyPair::" . $publicKey));
$publicKey = $keyCache['public_key'];
}
return self::string(self::doo()->pgpEncrypt($plaintext, $publicKey));
return self::load()->pgpEncrypt($plaintext, $publicKey);
}
/**
@@ -395,12 +300,7 @@ class Doo
*/
public static function pgpDecrypt($encryptedText, $privateKey, $passphrase = null): string
{
if (strlen($privateKey) < 50) {
$keyCache = Base::json2array(Cache::get("KeyPair::" . $privateKey));
$privateKey = $keyCache['private_key'];
$passphrase = $keyCache['passphrase'];
}
return self::string(self::doo()->pgpDecrypt($encryptedText, $privateKey, $passphrase));
return self::load()->pgpDecrypt($encryptedText, $privateKey, $passphrase);
}
/**
@@ -411,9 +311,7 @@ class Doo
*/
public static function pgpEncryptApi($plaintext, $publicKey): string
{
$content = Base::array2json($plaintext);
$content = self::pgpEncrypt($content, $publicKey);
return preg_replace("/\s*-----(BEGIN|END) PGP MESSAGE-----\s*/i", "", $content);
return self::load()->pgpEncryptApi($plaintext, $publicKey);
}
/**
@@ -425,9 +323,7 @@ class Doo
*/
public static function pgpDecryptApi($encryptedText, $privateKey, $passphrase = null): array
{
$content = "-----BEGIN PGP MESSAGE-----\n\n" . $encryptedText . "\n-----END PGP MESSAGE-----";
$content = self::pgpDecrypt($content, $privateKey, $passphrase);
return Base::json2array($content);
return self::load()->pgpDecryptApi($encryptedText, $privateKey, $passphrase);
}
/**
@@ -437,24 +333,7 @@ class Doo
*/
public static function pgpParseStr($string): array
{
$array = [
'encrypt_type' => '',
'encrypt_id' => '',
'client_type' => '',
'client_key' => '',
];
$string = str_replace(";", "&", $string);
parse_str($string, $params);
foreach ($params as $key => $value) {
$key = strtolower(trim($key));
if ($key) {
$array[$key] = trim($value);
}
}
if ($array['client_type'] === 'pgp' && $array['client_key']) {
$array['client_key'] = self::pgpPublicFormat($array['client_key']);
}
return $array;
return self::load()->pgpParseStr($string);
}
/**
@@ -464,10 +343,6 @@ class Doo
*/
public static function pgpPublicFormat($key): string
{
$key = str_replace(["-", "_", "$"], ["+", "/", "\n"], $key);
if (!str_contains($key, '-----BEGIN PGP PUBLIC KEY BLOCK-----')) {
$key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n" . $key . "\n-----END PGP PUBLIC KEY BLOCK-----";
}
return $key;
return self::load()->pgpPublicFormat($key);
}
}

37
app/Module/Down.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace App\Module;
use Request;
use Cache;
class Down
{
/**
* @param $data
* @param null $ttl
* @return string
*/
public static function cache_encode($data, $ttl = null): string
{
$base64 = base64_encode(Base::array2string($data));
$key = md5($base64);
Cache::put("down::{$key}", $base64, $ttl ?: now()->addHour());
return $key;
}
/**
* @param ?string $inputName
* @return array
*/
public static function cache_decode(?string $inputName = 'key'): array
{
$key = Request::input($inputName);
$base64 = Cache::get("down::{$key}");
if (empty($base64)) {
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 403);
}
//
return Base::string2array(base64_decode($base64));
}
}

View File

@@ -1,308 +0,0 @@
<?php
namespace App\Module\ElasticSearch;
use Elastic\Elasticsearch\ClientBuilder;
use Elastic\Elasticsearch\Exception\MissingParameterException;
use Illuminate\Support\Facades\Log;
/**
* Elasticsearch基础类
*
* Class ElasticSearchBase
* @package App\Module\ElasticSearch
*/
class ElasticSearchBase
{
/**
* Elasticsearch客户端实例
*
* @var \Elastic\Elasticsearch\Client
*/
protected $client;
/**
* 当前操作的索引名称
*
* @var string
*/
protected $index;
/**
* 构造函数
*
* @param null $index 默认索引名称
* @throws \Elastic\Elasticsearch\Exception\ConfigException
*/
public function __construct($index = null)
{
$host = env('ELASTICSEARCH_HOST', env('APP_IPPR') . '.15');
$port = env('ELASTICSEARCH_PORT', '9200');
$scheme = env('ELASTICSEARCH_SCHEME', 'http');
$user = env('ELASTICSEARCH_USER', '');
$pass = env('ELASTICSEARCH_PASS', '');
$verifi = env('ELASTICSEARCH_VERIFI', false);
$ca = env('ELASTICSEARCH_CA', '');
$key = env('ELASTICSEARCH_KEY', '');
$cert = env('ELASTICSEARCH_CERT', '');
// 为8.x版本客户端配置连接
$config = [
'hosts' => ["{$scheme}://{$host}:{$port}"]
];
// 如果设置了用户名和密码
if (!empty($user)) {
$config['basicAuthentication'] = [$user, $pass];
}
$config['SSLVerification'] = $verifi;
if ($verifi) {
$config['SSLCert'] = $cert;
$config['CABundle'] = $ca;
$config['SSLKey'] = $key;
}
// 8.x版本使用ClientBuilder::fromConfig创建客户端
$this->client = ClientBuilder::fromConfig($config);
if ($index) {
$this->index = $index;
}
}
/**
* 设置索引名称
*
* @param string $index
* @return $this
*/
public function setIndex($index)
{
$this->index = $index;
return $this;
}
/**
* 检查索引是否存在
*
* @return bool
* @throws \Exception
*/
public function indexExists()
{
$params = ['index' => $this->index];
return $this->client->indices()->exists($params)->asBool();
}
/**
* 创建索引
*
* @param array $settings 索引设置
* @param array $mappings 字段映射
* @return array
*/
public function createIndex($settings = [], $mappings = [])
{
$params = [
'index' => $this->index
];
$body = [];
if (!empty($settings)) {
$body['settings'] = $settings;
}
if (!empty($mappings)) {
$body['mappings'] = $mappings;
}
if (!empty($body)) {
$params['body'] = $body;
}
try {
// 在8.x中索引操作位于indices()命名空间
return $this->client->indices()->create($params)->asArray();
} catch (\Exception $e) {
Log::error('创建Elasticsearch索引失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* 删除索引
* @return array
*/
public function deleteIndex()
{
try {
$params = ['index' => $this->index];
return $this->client->indices()->delete($params)->asArray();
} catch (\Exception $e) {
Log::error('删除Elasticsearch索引失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* 批量操作(批量添加/更新/删除文档)
*
* @param array $operations 批量操作的数据
* @return array
*/
public function bulk($operations)
{
try {
// 在8.x中批量操作API签名相同但内部实现有所变化
return $this->client->bulk($operations)->asArray();
} catch (\Exception $e) {
Log::error('批量操作失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* 索引单个文档
*
* @param array $document 文档数据
* @param string $id 文档ID
* @param string|null $routing 路由值,用于父子文档
* @return array
*/
public function indexDocument($document, $id, $routing = null)
{
$params = [
'index' => $this->index,
'id' => $id,
'body' => $document
];
if ($routing) {
$params['routing'] = $routing;
}
try {
return $this->client->index($params)->asArray();
} catch (\Exception $e) {
Log::error('索引文档失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* 删除文档
*
* @param string $id 文档ID
* @param string|null $routing 路由值,用于父子文档
* @return array
*/
public function deleteDocument($id, $routing = null)
{
$params = [
'index' => $this->index,
'id' => $id
];
if ($routing) {
$params['routing'] = $routing;
}
try {
return $this->client->delete($params)->asArray();
} catch (MissingParameterException $e) {
// 文档不存在时返回成功
return ['result' => 'not_found', 'error' => $e->getMessage()];
} catch (\Exception $e) {
Log::error('删除文档失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* 刷新索引
* @return array
*/
public function refreshIndex()
{
$params = [
'index' => $this->index
];
try {
return $this->client->indices()->refresh($params)->asArray();
} catch (\Exception $e) {
Log::error('刷新索引失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* 检查索引映射
* @return array
*/
public function checkIndexMapping()
{
try {
return $this->client->indices()->getMapping(['index' => $this->index])->asArray();
} catch (\Exception $e) {
return ['error' => $e->getMessage()];
}
}
/**
* 通用搜索方法
*
* @param array $query 搜索查询
* @param int $from 起始位置
* @param int $size 返回结果数量
* @param array $sort 排序规则
* @return array
*/
public function search($query, $from = 0, $size = 10, $sort = [])
{
$params = [
'index' => $this->index,
'body' => [
'query' => $query,
'from' => $from,
'size' => $size
]
];
if (!empty($sort)) {
$params['body']['sort'] = $sort;
}
try {
return $this->client->search($params)->asArray();
} catch (\Exception $e) {
Log::error('搜索失败: ' . $e->getMessage());
return ['error' => $e->getMessage(), 'hits' => ['total' => ['value' => 0], 'hits' => []]];
}
}
/**
* 索引名称
*/
const indexName = 'default';
/**
* 获取索引名称
* @param string $index 索引名称
* @param string|null $prefix 索引前缀
* @param string|null $subfix 索引后缀
* @return string
*/
public static function indexName($index = '', $prefix = '', $subfix = '')
{
$index = $index ?: static::indexName;
$prefix = $prefix ?: env('ES_INDEX_PREFIX', '');
$subfix = $subfix ?: env('ES_INDEX_SUFFIX', '');
if ($prefix) {
$index = rtrim($prefix, '_') . '_' . $index;
}
if ($subfix) {
$index = $index . '_' . ltrim($subfix, '_');
}
return $index;
}
}

View File

@@ -1,204 +0,0 @@
<?php
namespace App\Module\ElasticSearch;
use App\Module\Base;
use Illuminate\Support\Facades\Log;
/**
* Elasticsearch键值存储
*
* Class ElasticSearchKeyValue
* @package App\Module\ElasticSearch
*/
class ElasticSearchKeyValue extends ElasticSearchBase
{
const indexName = 'key_value_store';
/**
* 构造函数
* @return ElasticSearchBase
* @throws \Elastic\Elasticsearch\Exception\ConfigException
*/
public function __construct()
{
return parent::__construct(self::indexName());
}
/** ******************************************************************************************************** */
/** *********************************** 键值存储方法 ******************************************************** */
/** ******************************************************************************************************** */
/**
* 创建键值存储索引
* @return array
*/
public static function generateIndex()
{
try {
$es = new self();
// 如果索引已存在,则直接返回
if ($es->indexExists()) {
return ['acknowledged' => true, 'message' => '索引已存在'];
}
// 定义映射
$mappings = [
'properties' => [
'key' => ['type' => 'keyword'],
'value' => ['type' => 'text', 'fields' => ['keyword' => ['type' => 'keyword']]],
'created_at' => ['type' => 'integer'],
'updated_at' => ['type' => 'integer']
]
];
// 索引设置
$settings = [
'number_of_shards' => 1,
'number_of_replicas' => 1,
'refresh_interval' => '1s'
];
return $es->createIndex($settings, $mappings);
} catch (\Exception $e) {
Log::error('创建键值存储索引失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* 保存键值对
* @param string $key 键名
* @param mixed $value 键值
* @param string $namespace 命名空间,用于区分不同的键值存储场景
* @return array
*/
public static function save($key, $value, $namespace = 'default')
{
try {
// 确保索引存在
self::generateIndex();
$es = new self();
// 生成文档ID
$docId = "{$namespace}:{$key}";
// 准备文档数据
$document = [
'key' => $key,
'value' => is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : $value,
'namespace' => $namespace,
'created_at' => time(),
'updated_at' => time()
];
// 索引文档
$result = $es->indexDocument($document, $docId);
// 刷新索引以确保立即可见
$es->refreshIndex();
return $result;
} catch (\Exception $e) {
Log::error('保存键值对失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* 获取键值
* @param string $key 键名
* @param mixed $default 默认值,当键不存在时返回
* @param string $namespace 命名空间,用于区分不同的键值存储场景
* @return mixed
*/
public static function get($key, $default = null, $namespace = 'default')
{
try {
$es = new self();
// 如果索引不存在,直接返回默认值
if (!$es->indexExists()) {
return $default;
}
// 生成文档ID
$docId = "{$namespace}:{$key}";
// 查询参数
$params = [
'index' => self::indexName(),
'id' => $docId
];
try {
// 获取文档
$response = $es->client->get($params)->asArray();
// 获取值
$value = $response['_source']['value'] ?? $default;
// 如果值是JSON字符串尝试解码
if (is_string($value) && $decoded = json_decode($value, true)) {
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
}
return $value;
} catch (\Exception $e) {
// 文档不存在或其他错误,返回默认值
return $default;
}
} catch (\Exception $e) {
Log::error('获取键值对失败: ' . $e->getMessage());
return $default;
}
}
/**
* 获取键值,返回数组
* @param string $key 键名
* @param array $default 默认值,当键不存在时返回
* @param string $namespace 命名空间,用于区分不同的键值存储场景
* @return array
*/
public static function getArray($key, $default = [], $namespace = 'default')
{
return Base::string2array(self::get($key, $default, $namespace));
}
/**
* 删除键值对
* @param string $key 键名
* @param string $namespace 命名空间
* @return array
*/
public static function delete($key, $namespace = 'default')
{
try {
$es = new self();
// 如果索引不存在,直接返回成功
if (!$es->indexExists()) {
return ['result' => 'not_found'];
}
// 生成文档ID
$docId = "{$namespace}:{$key}";
// 删除文档
$result = $es->deleteDocument($docId);
// 刷新索引以确保立即生效
$es->refreshIndex();
return $result;
} catch (\Exception $e) {
Log::error('删除键值对失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
}

View File

@@ -1,375 +0,0 @@
<?php
namespace App\Module\ElasticSearch;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use Illuminate\Support\Facades\Log;
/**
* 对话系统消息索引
*
* Class ElasticSearchUserMsg
* @package App\Module\ElasticSearch
*/
class ElasticSearchUserMsg extends ElasticSearchBase
{
const indexName = 'dialog_user_msg';
/**
* 构造函数
* @return ElasticSearchBase
* @throws \Elastic\Elasticsearch\Exception\ConfigException
*/
public function __construct()
{
return parent::__construct(self::indexName());
}
/** ******************************************************************************************************** */
/** *********************************************** 基础 ************************************************** */
/** ******************************************************************************************************** */
/**
* 创建聊天系统索引 - 使用父子关系
* @return array
*/
public static function generateIndex()
{
// 定义映射
$mappings = [
'properties' => [
// 共用字段
'dialog_id' => ['type' => 'keyword'],
'created_at' => ['type' => 'date'],
'updated_at' => ['type' => 'date'],
// dialog_users 字段
'userid' => ['type' => 'keyword'],
'top_at' => ['type' => 'date'],
'last_at' => ['type' => 'date'],
'mark_unread' => ['type' => 'integer'],
'silence' => ['type' => 'integer'],
'hide' => ['type' => 'integer'],
'color' => ['type' => 'keyword'],
// dialog_msgs 字段
'msg_id' => ['type' => 'keyword'],
'sender_userid' => ['type' => 'keyword'],
'msg_type' => ['type' => 'keyword'],
'key' => ['type' => 'text'],
'bot' => ['type' => 'integer'],
// Join字段定义父子关系
'relationship' => [
'type' => 'join',
'relations' => [
'dialog_user' => 'dialog_msg' // dialog_user是父文档dialog_msg是子文档
]
],
]
];
// 索引设置
$settings = [
'number_of_shards' => 5,
'number_of_replicas' => 1,
'refresh_interval' => '5s'
];
try {
$es = new self();
return $es->createIndex($settings, $mappings);
} catch (\Exception $e) {
Log::error('创建聊天系统索引失败: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* 构建对话系统特定的搜索 - 根据用户ID和消息关键词搜索会话
* @param string $userid 用户ID
* @param string $keyword 消息关键词
* @param int $size 返回结果数量
* @return array
*/
public static function searchByKeyword($userid, $keyword, $size = 20)
{
// 注意这里的类型名称要与创建索引时的一致
$query = [
'bool' => [
'must' => [
[
'term' => [
'userid' => $userid
]
],
[
'has_child' => [
'type' => 'dialog_msg',
'query' => [
'bool' => [
'must' => [
[
'match_phrase' => [
'key' => $keyword
]
],
[
'term' => [
'bot' => 0
]
]
]
]
],
'inner_hits' => [
'size' => 1,
'sort' => [
'msg_id' => 'desc'
]
]
]
]
]
]
];
// 结果集合
$searchMap = [];
try {
// 开始搜索
$es = new self();
$results = $es->search($query, 0, $size, ['last_at' => 'desc']);
// 处理搜索结果
$hits = $results['hits']['hits'] ?? [];
foreach ($hits as $hit) {
if (isset($hit['inner_hits']['dialog_msg']['hits']['hits'][0])) {
$msgHit = $hit['inner_hits']['dialog_msg']['hits']['hits'][0];
$source = $hit['_source'];
$msgSource = $msgHit['_source'];
$searchMap[] = [
'id' => $source['dialog_id'],
'top_at' => $source['top_at'],
'last_at' => $source['last_at'],
'mark_unread' => $source['mark_unread'],
'silence' => $source['silence'],
'hide' => $source['hide'],
'color' => $source['color'],
'user_at' => $source['updated_at'],
'search_msg_id' => $msgSource['msg_id'],
];
}
}
} catch (\Exception $e) {
Log::error('searchByKeyword: ' . $e->getMessage());
}
// 返回搜索结果
return $searchMap;
}
/** ******************************************************************************************************** */
/** *********************************************** 用户 ************************************************** */
/** ******************************************************************************************************** */
/**
* 会话用户 - 生成文档ID
* @param WebSocketDialogUser $dialogUser
* @return string
*/
public static function generateUserDicId(WebSocketDialogUser $dialogUser)
{
return "user_{$dialogUser->userid}_dialog_{$dialogUser->dialog_id}";
}
/**
* 会话用户 - 生成文档格式
* @param WebSocketDialogUser $dialogUser
* @return array
*/
public static function generateUserFormat(WebSocketDialogUser $dialogUser)
{
return [
'dialog_id' => $dialogUser->dialog_id,
'created_at' => $dialogUser->created_at,
'updated_at' => $dialogUser->updated_at,
'userid' => $dialogUser->userid,
'top_at' => $dialogUser->top_at,
'last_at' => $dialogUser->last_at,
'mark_unread' => $dialogUser->mark_unread ? 1 : 0,
'silence' => $dialogUser->silence ? 1 : 0,
'hide' => $dialogUser->hide ? 1 : 0,
'color' => $dialogUser->color,
'relationship' => [
'name' => 'dialog_user'
]
];
}
/**
* 会话用户 - 同步到Elasticsearch
* @param WebSocketDialogUser $dialogUser
* @return void
*/
public static function syncUser(WebSocketDialogUser $dialogUser)
{
try {
$es = new self();
$es->indexDocument(self::generateUserFormat($dialogUser), self::generateUserDicId($dialogUser));
} catch (\Exception $e) {
Log::error('syncUser: ' . $e->getMessage());
}
}
/**
* 会话用户 - 从Elasticsearch删除
*/
public static function deleteUser(WebSocketDialogUser $dialogUser)
{
try {
$es = new self();
$docId = "user_{$dialogUser->userid}_dialog_{$dialogUser->dialog_id}";
// 删除用户-会话文档
$es->deleteDocument($docId);
// 注意:这里可能还需要删除所有关联的消息文档
// 但由于父子关系,可以通过查询找到所有子文档并删除
// 这里为简化可以选择在后台任务中处理或者直接依赖ES的级联删除功能
} catch (\Exception $e) {
Log::error('deleteUser: ' . $e->getMessage());
}
}
/** ******************************************************************************************************** */
/** *********************************************** 消息 ************************************************** */
/** ******************************************************************************************************** */
/**
* 会话消息 - 生成父文档ID
* @param WebSocketDialogMsg $dialogMsg
* @param $userid
* @return string
*/
public static function generateMsgParentId(WebSocketDialogMsg $dialogMsg, $userid)
{
return "user_{$userid}_dialog_{$dialogMsg->dialog_id}";
}
/**
* 会话消息 - 生成文档ID
* @param WebSocketDialogMsg $dialogMsg
* @param $userid
* @return string
*/
public static function generateMsgDicId(WebSocketDialogMsg $dialogMsg, $userid)
{
return "msg_{$dialogMsg->id}_user_{$userid}";
}
/**
* 会话消息 - 生成文档格式
* @param WebSocketDialogMsg $dialogMsg
* @param $userid
* @return array
*/
public static function generateMsgFormat(WebSocketDialogMsg $dialogMsg, $userid)
{
return [
'dialog_id' => $dialogMsg->dialog_id,
'created_at' => $dialogMsg->created_at,
'updated_at' => $dialogMsg->updated_at,
'msg_id' => $dialogMsg->id,
'sender_userid' => $dialogMsg->userid,
'msg_type' => $dialogMsg->type,
'key' => $dialogMsg->key,
'bot' => $dialogMsg->bot ? 1 : 0,
'relationship' => [
'name' => 'dialog_msg',
'parent' => self::generateMsgParentId($dialogMsg, $userid)
]
];
}
/**
* 会话消息 - 同步到Elasticsearch
*/
public static function syncMsg(WebSocketDialogMsg $dialogMsg)
{
try {
$es = new self();
// 获取此会话的所有用户
$dialogUsers = WebSocketDialogUser::whereDialogId($dialogMsg->dialog_id)->get();
if ($dialogUsers->isEmpty()) {
return;
}
$params = ['body' => []];
foreach ($dialogUsers as $dialogUser) {
$params['body'][] = [
'index' => [
'_index' => self::indexName(),
'_id' => self::generateMsgDicId($dialogMsg, $dialogUser->userid),
'routing' => self::generateMsgParentId($dialogMsg, $dialogUser->userid)
]
];
$params['body'][] = self::generateMsgFormat($dialogMsg, $dialogUser->userid);
}
if (!empty($params['body'])) {
$es->bulk($params);
}
} catch (\Exception $e) {
Log::error('syncMsg: ' . $e->getMessage());
}
}
/**
* 会话消息 - 从Elasticsearch删除
*/
public static function deleteMsg(WebSocketDialogMsg $dialogMsg)
{
try {
$es = new self();
// 获取此会话的所有用户
$dialogUsers = WebSocketDialogUser::whereDialogId($dialogMsg->dialog_id)->get();
if ($dialogUsers->isEmpty()) {
return;
}
$params = ['body' => []];
foreach ($dialogUsers as $dialogUser) {
$params['body'][] = [
'delete' => [
'_index' => self::indexName(),
'_id' => self::generateMsgDicId($dialogMsg, $dialogUser->userid),
'routing' => self::generateMsgParentId($dialogMsg, $dialogUser->userid)
]
];
}
if (!empty($params['body'])) {
$es->bulk($params);
}
} catch (\Exception $e) {
Log::error('deleteMsg: ' . $e->getMessage());
}
}
}

View File

@@ -4,319 +4,12 @@ namespace App\Module;
use Cache;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
/**
* 外网资源请求
*/
class Extranet
{
/**
* 通过 openAI 语音转文字
* @param string $filePath
* @param array $extParams
* @return array
*/
public static function openAItranscriptions($filePath, $extParams = [])
{
if (!file_exists($filePath)) {
return Base::retError("语音文件不存在");
}
$systemSetting = Base::setting('system');
$aibotSetting = Base::setting('aibotSetting');
if ($systemSetting['voice2text'] !== 'open' || empty($aibotSetting['openai_key'])) {
return Base::retError("语音转文字功能未开启");
}
$extra = [
'Content-Type' => 'multipart/form-data',
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
];
if ($aibotSetting['openai_agency']) {
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$post = array_merge($extParams, [
'file' => new \CURLFile($filePath),
'model' => 'whisper-1',
]);
$cacheKey = "openAItranscriptions::" . md5($filePath . '_' . Base::array2json($extra) . '_' . Base::array2json($extParams));
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function() use ($extra, $post) {
$res = Ihttp::ihttp_request('https://api.openai.com/v1/audio/transcriptions', $post, $extra, 15);
if (Base::isError($res)) {
return Base::retError("语音转文字失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['text'])) {
return Base::retError("语音转文字失败", $resData);
}
return Base::retSuccess("success", $resData['text']);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 通过 openAI 翻译
* @param $text
* @param $targetLanguage
* @return array
*/
public static function openAItranslations($text, $targetLanguage)
{
$systemSetting = Base::setting('system');
$aibotSetting = Base::setting('aibotSetting');
if ($systemSetting['translation'] !== 'open' || empty($aibotSetting['openai_key'])) {
return Base::retError("翻译功能未开启");
}
$extra = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
];
if ($aibotSetting['openai_agency']) {
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$post = json_encode([
"model" => "gpt-4o-mini",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名专业翻译人员,请将 <translation_original_text> 标签内的内容翻译为{$targetLanguage}。
翻译要求:
- 翻译结果需符合“项目任务管理系统”的专业术语和使用场景。
- 保持原文格式、结构和排版不变。
- 语言表达准确、简洁,符合项目管理领域的行业规范。
- 注意专业术语的一致性和连贯性。
EOF
],
[
"role" => "user",
"content" => "<translation_original_text>{$text}</translation_original_text>"
]
]
]);
$cacheKey = "openAItranslations::" . md5(Base::array2json($extra) . '_' . Base::array2json($post));
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function() use ($extra, $post) {
$res = Ihttp::ihttp_request('https://api.openai.com/v1/chat/completions', $post, $extra, 15);
if (Base::isError($res)) {
return Base::retError("翻译失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['choices'])) {
return Base::retError("翻译失败", $resData);
}
$result = $resData['choices'][0]['message']['content'];
$result = preg_replace('/^\"|\"$/', '', trim($result));
$result = preg_replace('/<\/*translation_original_text>/', '', trim($result));
if (empty($result)) {
return Base::retError("翻译失败", $result);
}
return Base::retSuccess("success", $result);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/**
* 通过 openAI 生成标题
* @param $text
* @return array
*/
public static function openAIGenerateTitle($text)
{
$aibotSetting = Base::setting('aibotSetting');
if (empty($aibotSetting['openai_key'])) {
return Base::retError("AI接口未配置");
}
$extra = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
];
if ($aibotSetting['openai_agency']) {
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$res = Ihttp::ihttp_request('https://api.openai.com/v1/chat/completions', json_encode([
"model" => "gpt-4o-mini",
"messages" => [
[
"role" => "system",
"content" => "你是一个专业的标题生成器,擅长为对话生成简洁的标题,请将提供的文本生成一个标题。"
],
[
"role" => "user",
"content" => $text
]
]
]), $extra, 15);
if (Base::isError($res)) {
return Base::retError("生成失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['choices'])) {
return Base::retError("生成失败", $resData);
}
$result = $resData['choices'][0]['message']['content'];
$result = preg_replace('/^\"|\"$/', '', $result);
if (empty($result)) {
return Base::retError("生成失败", $result);
}
return Base::retSuccess("success", $result);
}
/**
* 获取 ollama 模型
* @param $baseUrl
* @param $key
* @param $agency
* @return array
*/
public static function ollamaModels($baseUrl, $key = null, $agency = null)
{
$extra = [
'Content-Type' => 'application/json',
];
if ($key) {
$extra['Authorization'] = 'Bearer ' . $key;
}
if ($agency) {
$extra['CURLOPT_PROXY'] = $agency;
$extra['CURLOPT_PROXYTYPE'] = str_contains($agency, 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
}
$res = Ihttp::ihttp_request(rtrim($baseUrl, '/') . '/api/tags', [], $extra, 15);
if (Base::isError($res)) {
return Base::retError("获取失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['models'])) {
return Base::retError("获取失败", $resData);
}
$models = [];
foreach ($resData['models'] as $model) {
if ($model['name'] !== $model['model']) {
$models[] = "{$model['model']} | {$model['name']}";
} else {
$models[] = $model['model'];
}
}
return Base::retSuccess("success", [
'models' => $models,
'original' => $resData['models']
]);
}
/**
* 获取IP地址经纬度
* @param string $ip
* @return array
*/
public static function getIpGcj02(string $ip = ''): array
{
if (empty($ip)) {
$ip = Base::getIp();
}
$cacheKey = "getIpPoint::" . md5($ip);
$result = Cache::rememberForever($cacheKey, function () use ($ip) {
return Ihttp::ihttp_request("https://www.ifreesite.com/ipaddress/address.php?q=" . $ip, [], [], 12);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
return $result;
}
$data = $result['data'];
$lastPos = strrpos($data, ',');
$long = floatval(Base::getMiddle(substr($data, $lastPos + 1), null, ')'));
$lat = floatval(Base::getMiddle(substr($data, strrpos(substr($data, 0, $lastPos), ',') + 1), null, ','));
return Base::retSuccess("success", [
'long' => $long,
'lat' => $lat,
]);
}
/**
* 百度接口根据ip获取经纬度
* @param string $ip
* @return array
*/
public static function getIpGcj02ByBaidu(string $ip = ''): array
{
if (empty($ip)) {
$ip = Base::getIp();
}
$cacheKey = "getIpPoint::" . md5($ip);
$result = Cache::rememberForever($cacheKey, function () use ($ip) {
$ak = Config::get('app.baidu_app_key');
$url = 'http://api.map.baidu.com/location/ip?ak=' . $ak . '&ip=' . $ip . '&coor=bd09ll';
return Ihttp::ihttp_request($url, [], [], 12);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
return $result;
}
$data = json_decode($result['data'], true);
// x坐标纬度, y坐标经度
$long = Arr::get($data, 'content.point.x');
$lat = Arr::get($data, 'content.point.y');
return Base::retSuccess("success", [
'long' => $long,
'lat' => $lat,
]);
}
/**
* 获取IP地址详情
* @param string $ip
* @return array
*/
public static function getIpInfo(string $ip = ''): array
{
if (empty($ip)) {
$ip = Base::getIp();
}
$cacheKey = "getIpInfo::" . md5($ip);
$result = Cache::rememberForever($cacheKey, function () use ($ip) {
return Ihttp::ihttp_request("http://ip.taobao.com/service/getIpInfo.php?accessKey=alibaba-inc&ip=" . $ip, [], [], 12);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
return $result;
}
$data = json_decode($result['data'], true);
if (!is_array($data) || intval($data['code']) != 0) {
Cache::forget($cacheKey);
return Base::retError("error ip: -1");
}
$data = $data['data'];
if (!is_array($data) || !isset($data['country'])) {
return Base::retError("error ip: -2");
}
$data['text'] = $data['country'];
$data['textSmall'] = $data['country'];
if ($data['region'] && $data['region'] != $data['country'] && $data['region'] != "XX") {
$data['text'] .= " " . $data['region'];
$data['textSmall'] = $data['region'];
}
if ($data['city'] && $data['city'] != $data['region'] && $data['city'] != "XX") {
$data['text'] .= " " . $data['city'];
$data['textSmall'] .= " " . $data['city'];
}
if ($data['county'] && $data['county'] != $data['city'] && $data['county'] != "XX") {
$data['text'] .= " " . $data['county'];
$data['textSmall'] .= " " . $data['county'];
}
return Base::retSuccess("success", $data);
}
/**
* 判断是否工作日
* @param string $Ymd 年月日20220102
@@ -372,125 +65,6 @@ class Extranet
];
}
/**
* 随机笑话接口
* @return string
*/
public static function randJoke(): string
{
$data = self::curl("https://hmajax.itheima.net/api/randjoke");
$data = Base::json2array($data);
if ($data['message'] === '获取成功' && $text = trim($data['data'])) {
return $text;
}
return "";
}
/**
* 心灵鸡汤
* @return string
*/
public static function soups(): string
{
$data = self::curl("https://hmajax.itheima.net/api/ambition");
$data = Base::json2array($data);
if ($data['message'] === '获取成功' && $text = trim($data['data'])) {
return $text;
}
return "";
}
/**
* 签到机器人网络内容
* @param $type
* @return string
*/
public static function checkinBotQuickMsg($type): string
{
$text = "维护中...";
switch ($type) {
case "it":
$data = self::curl('http://vvhan.api.hitosea.com/api/hotlist?type=itNews', 3600);
if ($data = Base::json2array($data)) {
$i = 1;
$array = array_map(function ($item) use (&$i) {
if ($item['title'] && $item['desc']) {
return "<p>" . ($i++) . ". <strong><a href='{$item['mobilUrl']}' target='_blank'>{$item['title']}</a></strong></p><p>{$item['desc']}</p>";
} else {
return null;
}
}, $data['data']);
$array = array_values(array_filter($array));
if ($array) {
array_unshift($array, "<p><strong>{$data['title']}</strong>{$data['update_time']}</p>");
$text = implode("<p>&nbsp;</p>", $array);
}
}
break;
case "36ke":
$data = self::curl('http://vvhan.api.hitosea.com/api/hotlist?type=36Ke', 3600);
if ($data = Base::json2array($data)) {
$i = 1;
$array = array_map(function ($item) use (&$i) {
if ($item['title'] && $item['desc']) {
return "<p>" . ($i++) . ". <strong><a href='{$item['mobilUrl']}' target='_blank'>{$item['title']}</a></strong></p><p>{$item['desc']}</p>";
} else {
return null;
}
}, $data['data']);
$array = array_values(array_filter($array));
if ($array) {
array_unshift($array, "<p><strong>{$data['title']}</strong>{$data['update_time']}</p>");
$text = implode("<p>&nbsp;</p>", $array);
}
}
break;
case "60s":
$data = self::curl('http://vvhan.api.hitosea.com/api/60s?type=json', 3600);
if ($data = Base::json2array($data)) {
$i = 1;
$array = array_map(function ($item) use (&$i) {
if ($item) {
return "<p>" . ($i++) . ". {$item}</p>";
} else {
return null;
}
}, $data['data']);
$array = array_values(array_filter($array));
if ($array) {
array_unshift($array, "<p><strong>{$data['name']}</strong>{$data['time'][0]}</p>");
$text = implode("<p>&nbsp;</p>", $array);
}
}
break;
case "joke":
$text = "笑话被掏空";
$data = self::curl('http://vvhan.api.hitosea.com/api/joke?type=json', 5);
if ($data = Base::json2array($data)) {
if ($data = trim($data['joke'])) {
$text = "开心笑话:{$data}";
}
}
break;
case "soup":
$text = "鸡汤分完了";
$data = self::curl('https://api.ayfre.com/jt/?type=bot', 5);
if ($data) {
$text = "心灵鸡汤:{$data}";
}
break;
default:
$text = "";
break;
}
return $text;
}
/**
* 获取搜狗表情包
* @param $keyword

View File

@@ -0,0 +1,412 @@
<?php
namespace App\Module\Interface;
use App\Exceptions\ApiException;
use App\Module\Base;
use App\Models\User;
use Cache;
use Carbon\Carbon;
use DB;
use FFI;
use FFI\CData;
use FFI\Exception;
use Throwable;
class DooSo
{
private mixed $so;
public function __construct($token = null, $language = null)
{
$this->so = FFI::cdef(<<<EOF
void initialize(char* work, char* token, char* lang);
char* license();
char* licenseDecode(char* license);
char* licenseSave(char* license);
int userId();
char* userExpiredAt();
char* userEmail();
char* userEncrypt();
char* userToken();
char* userCreate(char* email, char* password);
char* tokenEncode(int userid, char* email, char* encrypt, int days);
char* tokenDecode(char* val);
char* translate(char* val, char* val);
char* md5s(char* text, char* password);
char* macs();
char* dooSN();
char* version();
char* pgpGenerateKeyPair(char* name, char* email, char* passphrase);
char* pgpEncrypt(char* plainText, char* publicKey);
char* pgpDecrypt(char* cipherText, char* privateKey, char* passphrase);
EOF, "/usr/lib/doo/doo.so");
$this->so->initialize("/var/www", $token, $language);
return $this->so;
}
/**
* char转为字符串
* @param $text
* @return string
*/
private static function string($text): string
{
if (!($text instanceof CData)) {
return "";
}
try {
return FFI::string($text);
} catch (Exception) {
return "";
}
}
/**
* License
* @return array
*/
public function license(): array
{
$array = Base::json2array(self::string($this->so->license()));
$ips = explode(",", $array['ip']);
$array['ip'] = [];
foreach ($ips as $ip) {
if (Base::is_ipv4($ip)) {
$array['ip'][] = $ip;
}
}
$domains = explode(",", $array['domain']);
$array['domain'] = [];
foreach ($domains as $domain) {
if (Base::is_domain($domain)) {
$array['domain'][] = $domain;
}
}
$macs = explode(",", $array['mac']);
$array['mac'] = [];
foreach ($macs as $mac) {
if (Base::isMac($mac)) {
$array['mac'][] = $mac;
}
}
$emails = explode(",", $array['email']);
$array['email'] = [];
foreach ($emails as $email) {
if (Base::isEmail($email)) {
$array['email'][] = $email;
}
}
return $array;
}
/**
* 解析License
* @param $license
* @return array
*/
public function licenseDecode($license): array
{
return Base::json2array(self::string($this->so->licenseDecode($license)));
}
/**
* 保存License
* @param $license
*/
public function licenseSave($license): void
{
$res = self::string($this->so->licenseSave($license));
if ($res != 'success') {
throw new ApiException($res ?: 'LICENSE 保存失败');
}
}
/**
* 当前会员ID来自请求的token
* @return int
*/
public function userId(): int
{
return intval($this->so->userId());
}
/**
* token是否过期来自请求的token
* @return bool
*/
public function userExpired(): bool
{
$expiredAt = $this->userExpiredAt();
return $expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now());
}
/**
* token过期时间来自请求的token
* @return string|null
*/
public function userExpiredAt(): ?string
{
$expiredAt = self::string($this->so->userExpiredAt());
return $expiredAt === 'forever' ? null : $expiredAt;
}
/**
* 当前会员邮箱地址来自请求的token
* @return string
*/
public function userEmail(): string
{
return self::string($this->so->userEmail());
}
/**
* 当前会员Encrypt来自请求的token
* @return string
*/
public function userEncrypt(): string
{
return self::string($this->so->userEncrypt());
}
/**
* 当前会员token来自请求的token
* @return string
*/
public function userToken(): string
{
return self::string($this->so->userToken());
}
/**
* 创建帐号
* @param $email
* @param $password
* @return User|null
*/
public function userCreate($email, $password): User|null
{
$data = Base::json2array(self::string($this->so->userCreate($email, $password)));
if (Base::isError($data)) {
throw new ApiException($data['msg'] ?: '注册失败');
}
if (DB::transactionLevel() > 0) {
try {
DB::commit();
DB::beginTransaction();
} catch (Throwable) {
// do nothing
}
}
$user = User::whereEmail($email)->first();
if (empty($user)) {
throw new ApiException('注册失败');
}
return $user;
}
/**
* 生成token编码token
* @param $userid
* @param $email
* @param $encrypt
* @param int $days 有效时间(天)
* @return string
*/
public function tokenEncode($userid, $email, $encrypt, int $days = 15): string
{
return self::string($this->so->tokenEncode($userid, $email, $encrypt, $days));
}
/**
* 解码token
* @param $token
* @return array
*/
public function tokenDecode($token): array
{
$array = Base::json2array(self::string($this->so->tokenDecode($token)));
$array['expired_at'] = $array['expired_at'] === 'forever' ? null : $array['expired_at'];
return $array;
}
/**
* 翻译
* @param $text
* @param ?string $lang
* @return string
*/
public function translate($text, ?string $lang = ""): string
{
if (empty($text)) {
return "";
}
if (empty($lang)) {
$lang = "";
}
return self::string($this->so->translate($text, $lang));
}
/**
* md5防破解
* @param $text
* @param string $password
* @return string
*/
public function md5s($text, string $password = ""): string
{
return self::string($this->so->md5s($text, $password));
}
/**
* 获取php容器mac地址组
* @return array
*/
public function macs(): array
{
$macs = explode(",", self::string($this->so->macs()));
$array = [];
foreach ($macs as $mac) {
if (Base::isMac($mac)) {
$array[] = $mac;
}
}
return $array;
}
/**
* 获取当前SN
* @return string
*/
public function dooSN(): string
{
return self::string($this->so->dooSN());
}
/**
* 获取当前版本
* @return string
*/
public function dooVersion(): string
{
return self::string($this->so->version());
}
/**
* 生成PGP密钥对
* @param $name
* @param $email
* @param string $passphrase
* @return array
*/
public function pgpGenerateKeyPair($name, $email, string $passphrase = ""): array
{
return Base::json2array(self::string($this->so->pgpGenerateKeyPair($name, $email, $passphrase)));
}
/**
* PGP加密
* @param $plaintext
* @param $publicKey
* @return string
*/
public function pgpEncrypt($plaintext, $publicKey): string
{
if (strlen($publicKey) < 50) {
$keyCache = Base::json2array(Cache::get("KeyPair::" . $publicKey));
$publicKey = $keyCache['public_key'];
}
return self::string($this->so->pgpEncrypt($plaintext, $publicKey));
}
/**
* PGP解密
* @param $encryptedText
* @param $privateKey
* @param null $passphrase
* @return string
*/
public function pgpDecrypt($encryptedText, $privateKey, $passphrase = null): string
{
if (strlen($privateKey) < 50) {
$keyCache = Base::json2array(Cache::get("KeyPair::" . $privateKey));
$privateKey = $keyCache['private_key'];
$passphrase = $keyCache['passphrase'];
}
return self::string($this->so->pgpDecrypt($encryptedText, $privateKey, $passphrase));
}
/**
* PGP加密API
* @param $plaintext
* @param $publicKey
* @return string
*/
public function pgpEncryptApi($plaintext, $publicKey): string
{
$content = Base::array2json($plaintext);
$content = $this->pgpEncrypt($content, $publicKey);
return preg_replace("/\s*-----(BEGIN|END) PGP MESSAGE-----\s*/i", "", $content);
}
/**
* PGP解密API
* @param $encryptedText
* @param null $privateKey
* @param null $passphrase
* @return array
*/
public function pgpDecryptApi($encryptedText, $privateKey, $passphrase = null): array
{
$content = "-----BEGIN PGP MESSAGE-----\n\n" . $encryptedText . "\n-----END PGP MESSAGE-----";
$content = $this->pgpDecrypt($content, $privateKey, $passphrase);
return Base::json2array($content);
}
/**
* 解析PGP参数
* @param $string
* @return string[]
*/
public function pgpParseStr($string): array
{
$array = [
'encrypt_type' => '',
'encrypt_id' => '',
'client_type' => '',
'client_key' => '',
];
$string = str_replace(";", "&", $string);
parse_str($string, $params);
foreach ($params as $key => $value) {
$key = strtolower(trim($key));
if ($key) {
$array[$key] = trim($value);
}
}
if ($array['client_type'] === 'pgp' && $array['client_key']) {
$array['client_key'] = $this->pgpPublicFormat($array['client_key']);
}
return $array;
}
/**
* 还原公钥格式
* @param $key
* @return string
*/
public function pgpPublicFormat($key): string
{
$key = str_replace(["-", "_", "$"], ["+", "/", "\n"], $key);
if (!str_contains($key, '-----BEGIN PGP PUBLIC KEY BLOCK-----')) {
$key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n" . $key . "\n-----END PGP PUBLIC KEY BLOCK-----";
}
return $key;
}
}

52
app/Module/Lock.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace App\Module;
use Closure;
use Exception;
use Illuminate\Support\Facades\Redis;
class Lock
{
/**
* 使用Redis分布式锁执行闭包
* @param string $key 锁的key
* @param Closure $closure 要执行的闭包函数
* @param int $ttl 锁的过期时间毫秒默认1000010秒
* @param int $waitTimeout 等待锁的超时时间毫秒0表示不等待默认1000010秒
* @return mixed 闭包函数的返回值
* @throws Exception 如果获取锁失败或闭包执行异常
*/
public static function withLock(string $key, Closure $closure, int $ttl = 10000, int $waitTimeout = 10000)
{
$lockKey = "lock:{$key}";
$lockValue = uniqid('', true); // 生成唯一值,用于安全释放锁
// 尝试获取锁如果waitTimeout为0则直接返回false否则等待指定时间
$acquired = false;
if ($waitTimeout > 0) {
$end = microtime(true) + ($waitTimeout / 1000);
while (microtime(true) < $end) {
if (Redis::set($lockKey, $lockValue, 'PX', $ttl, 'NX')) {
$acquired = true;
break;
}
usleep(100000); // 休眠100ms后重试
}
} else {
$acquired = Redis::set($lockKey, $lockValue, 'PX', $ttl, 'NX');
}
if (!$acquired) {
throw new Exception("Failed to acquire lock for key: {$key}");
}
try {
// 执行闭包
return $closure();
} finally {
// 安全释放锁(仅当锁值未变时删除)
Redis::eval("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", 1, $lockKey, $lockValue);
}
}
}

View File

@@ -3,82 +3,99 @@
namespace App\Module;
use Exception;
use Illuminate\Support\Facades\Log;
use PhpOffice\PhpWord\IOFactory;
use Smalot\PdfParser\Parser;
use PhpOffice\PhpWord\IOFactory as WordIOFactory;
use PhpOffice\PhpSpreadsheet\IOFactory as SpreadsheetIOFactory;
use PhpOffice\PhpPresentation\IOFactory as PresentationIOFactory;
use Illuminate\Support\Facades\File as FileFacade;
class TextExtractor
{
private string $filePath;
private string $fileMimeType;
private string $fileExtension;
/**
* 从文件中提取文本
*
* @param string $filePath 文件路径
* @return string
* @param string $filePath
* @throws Exception
*/
public function extractText(string $filePath): string
public function __construct(string $filePath)
{
if (!file_exists($filePath)) {
throw new Exception("文件不存在: {$filePath}");
}
$fileExtension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
try {
return match($fileExtension) {
'pdf' => $this->extractFromPDF($filePath),
'docx' => $this->extractFromDOCX($filePath),
'ipynb' => $this->extractFromIPYNB($filePath),
default => $this->extractFromOtherFile($filePath),
};
} catch (Exception $e) {
Log::error('文本提取失败', [
'file' => $filePath,
'error' => $e->getMessage()
]);
throw $e;
throw new Exception("File does not exist: {$filePath}");
}
$this->filePath = $filePath;
$this->fileMimeType = FileFacade::mimeType($filePath);
$this->fileExtension = $this->detectFileType();
}
/**
* 从PDF文件中提取文本
*
* @param string $filePath
* 从文件中提取文本
* @return string
* @throws Exception
*/
protected function extractFromPDF(string $filePath): string
public function extractContent(): string
{
try {
$parser = new Parser();
$pdf = $parser->parseFile($filePath);
return match ($this->fileExtension) {
// Word文档
'docx' => $this->parseWordDocument(),
return $pdf->getText();
} catch (Exception $e) {
Log::error('PDF解析失败', [
'file' => $filePath,
'error' => $e->getMessage()
]);
throw new Exception("PDF文本提取失败: " . $e->getMessage());
}
// Excel文档
'xlsx', 'xls', 'csv' => $this->parseSpreadsheet(),
// PowerPoint文档
'ppt', 'pptx' => $this->parsePresentation(),
// PDF文档
'pdf' => $this->parsePdf(),
// RTF文档
'rtf' => $this->parseRtf(),
// 其他文本文件
default => $this->parseOther(),
};
}
/**
* 从DOCX文件中提取文本
*
* @param string $filePath
* 获取文件类型
* @return string
* @throws Exception
*/
protected function extractFromDOCX(string $filePath): string
private function detectFileType(): string
{
$phpWord = IOFactory::load($filePath);
return match ($this->fileMimeType) {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-excel' => 'xls',
'text/csv', 'application/csv' => 'csv',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'application/pdf' => 'pdf',
'application/rtf', 'text/rtf' => 'rtf',
default => strtolower(pathinfo($this->filePath, PATHINFO_EXTENSION)),
};
}
/**
* Parse Word documents (.doc, .docx)
* @return string
*/
private function parseWordDocument(): string
{
$phpWord = WordIOFactory::load($this->filePath);
$text = '';
// Extract text from each section
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (method_exists($element, 'getText')) {
$text .= $element->getText() . "\n";
} elseif (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $childElement) {
if (method_exists($childElement, 'getText')) {
$text .= $childElement->getText() . "\n";
}
}
}
}
}
@@ -87,67 +104,126 @@ class TextExtractor
}
/**
* 从Jupyter Notebook文件中提取文本
*
* @param string $filePath
* Parse spreadsheet files (.xlsx, .xls, .csv)
* @return string
* @throws Exception
*/
protected function extractFromIPYNB(string $filePath): string
private function parseSpreadsheet(): string
{
$content = file_get_contents($filePath);
$notebook = json_decode($content, true);
$spreadsheet = SpreadsheetIOFactory::load($this->filePath);
$text = '';
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception("IPYNB文件解析失败: " . json_last_error_msg());
}
// Extract text from all worksheets
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
$text .= 'Worksheet: ' . $worksheet->getTitle() . "\n";
$extractedText = '';
foreach ($worksheet->getRowIterator() as $row) {
$cellIterator = $row->getCellIterator();
$cellIterator->setIterateOnlyExistingCells(false);
$rowText = '';
foreach ($notebook['cells'] ?? [] as $cell) {
if (in_array($cell['cell_type'] ?? '', ['markdown', 'code']) && isset($cell['source'])) {
$source = $cell['source'];
$extractedText .= is_array($source)
? implode("\n", $source)
: $source;
$extractedText .= "\n";
foreach ($cellIterator as $cell) {
$value = $cell->getValue();
if (!empty($value)) {
$rowText .= $value . "\t";
}
}
if (!empty(trim($rowText))) {
$text .= trim($rowText) . "\n";
}
}
$text .= "\n";
}
return $extractedText;
return $text;
}
/**
* 从其他类型文件中提取文本
*
* @param string $filePath
* Parse presentation files (.ppt, .pptx)
* @return string
* @throws Exception
*/
protected function extractFromOtherFile(string $filePath): string
private function parsePresentation(): string
{
if ($this->isBinaryFile($filePath)) {
throw new Exception("无法读取该类型文件的文本内容");
$presentation = PresentationIOFactory::load($this->filePath);
$text = '';
// Extract text from all slides
foreach ($presentation->getAllSlides() as $slide) {
foreach ($slide->getShapeCollection() as $shape) {
if ($shape instanceof \PhpOffice\PhpPresentation\Shape\RichText) {
foreach ($shape->getParagraphs() as $paragraph) {
foreach ($paragraph->getRichTextElements() as $element) {
$text .= $element->getText();
}
$text .= "\n";
}
}
}
$text .= "\n";
}
return file_get_contents($filePath);
return $text;
}
/**
* 检查文件是否为二进制文件
*
* @param string $filePath
* @return bool
* Parse PDF files (requires additional library like Smalot\PdfParser)
* @return string
* @throws Exception
*/
protected function isBinaryFile(string $filePath): bool
private function parsePdf(): string
{
$finfo = finfo_open(FILEINFO_MIME);
$mimeType = finfo_file($finfo, $filePath);
finfo_close($finfo);
// You'll need to install the Smalot PDF Parser: composer require smalot/pdfparser
if (!class_exists('\Smalot\PdfParser\Parser')) {
throw new \Exception("PDF Parser not available. Install with: composer require smalot/pdfparser");
}
return !str_contains($mimeType, 'text/')
&& !str_contains($mimeType, 'application/json')
&& !str_contains($mimeType, 'application/xml');
$parser = new \Smalot\PdfParser\Parser();
$pdf = $parser->parseFile($this->filePath);
return $pdf->getText();
}
/**
* Parse RTF files
* @return string
*/
private function parseRtf(): string
{
// Simple RTF to text conversion
$content = file_get_contents($this->filePath);
// Remove RTF control words and groups
$content = preg_replace('/\\\\([a-z]{1,32})(-?[0-9]{1,10})?[ ]?/i', '', $content);
$content = preg_replace('/\\\\([^a-z]|[a-z]{33,})/i', '', $content);
$content = preg_replace('/\{\*?\\\\[^{}]*\}/', '', $content);
$content = preg_replace('/\{[\r\n]*\}/', '', $content);
// Convert special characters
$content = preg_replace('/\\\\\'([0-9a-f]{2})/i', '', $content);
// Remove remaining curly braces
$content = str_replace(['{', '}'], '', $content);
return $content ?: '';
}
/**
* Parse Other(text) files
* @return string
* @throws Exception
*/
private function parseOther(): string
{
$isBinary = !str_contains($this->fileMimeType, 'text/')
&& !str_contains($this->fileMimeType, 'application/json')
&& !str_contains($this->fileMimeType, 'application/xml');
if ($isBinary) {
throw new Exception("Unable to read the text content of this type of file");
}
return file_get_contents($this->filePath);
}
/** ********************************************************************* */
@@ -157,26 +233,27 @@ class TextExtractor
/**
* 获取文件内容
* @param $filePath
* @param float|int $maxSize 最大文件大小,单位字节,默认300KB
* @return string
* @param int $fileMaxSize 最大文件大小,单位字节,默认1024KB
* @param int $contentMaxSize 最大内容大小单位字节默认300KB
* @return array
*/
public static function getFileContent($filePath, float|int $maxSize = 300 * 1024)
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300): array
{
if (!file_exists($filePath) || !is_file($filePath)) {
return "(Failed to read contents of {$filePath})";
return Base::retError("Failed to read contents of {$filePath}");
}
if (filesize($filePath) > $maxSize) {
return "(File size exceeds " . Base::readableBytes($maxSize) . ", unable to display content)";
if (filesize($filePath) > $fileMaxSize * 1024) {
return Base::retError("File size exceeds " . Base::readableBytes($fileMaxSize * 1024) . ", unable to display content");
}
$te = new self();
try {
$isBinary = $te->isBinaryFile($filePath);
if ($isBinary) {
return "(Binary file, unable to display content)";
$extractor = new self($filePath);
$content = $extractor->extractContent();
if (strlen($content) > $contentMaxSize * 1024) {
return Base::retError("Content size exceeds " . Base::readableBytes($contentMaxSize * 1024) . ", unable to display content");
}
return $te->extractText($filePath);
return Base::retSuccess("success", $content);
} catch (Exception $e) {
return "(Failed to read contents of {$filePath}: {$e->getMessage()})";
return Base::retError($e->getMessage());
}
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace App\Module\ZincSearch;
use App\Module\Apps;
use App\Module\Doo;
/**
* ZincSearch 公共类
*/
class ZincSearchBase
{
private mixed $host;
private mixed $port;
private mixed $user;
private mixed $pass;
/**
* 构造函数
*/
public function __construct()
{
$this->host = env('ZINCSEARCH_HOST', 'search');
$this->port = env('ZINCSEARCH_PORT', '4080');
$this->user = env('DB_USERNAME', '');
$this->pass = env('DB_PASSWORD', '');
}
/**
* 通用请求方法
*/
private function request($path, $body = null, $method = 'POST')
{
if (!Apps::isInstalled("search")) {
return [
'success' => false,
'error' => Doo::translate("应用「ZincSearch」未安装")
];
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "http://{$this->host}:{$this->port}{$path}");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_USERPWD, $this->user . ':' . $this->pass);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
$headers = ['Content-Type: application/json'];
if ($method === 'BULK') {
$headers = ['Content-Type: text/plain'];
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$result = curl_exec($ch);
$error = curl_error($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($error) {
return ['success' => false, 'error' => $error];
}
$data = json_decode($result, true);
return [
'success' => $status >= 200 && $status < 300,
'status' => $status,
'data' => $data
];
}
// ==============================
// 索引管理相关方法
// ==============================
/**
* 创建索引
*/
public static function createIndex($index, $mappings = []): array
{
$body = json_encode([
'name' => $index,
'mappings' => $mappings
]);
return (new self())->request("/api/index", $body);
}
/**
* 获取索引信息
*/
public static function getIndex($index): array
{
return (new self())->request("/api/index/{$index}", null, 'GET');
}
/**
* 判断索引是否存在
*/
public static function indexExists($index): bool
{
$result = self::getIndex($index);
return $result['success'] && isset($result['data']['name']);
}
/**
* 获取所有索引
*/
public static function listIndices(): array
{
return (new self())->request("/api/index", null, 'GET');
}
/**
* 删除索引
*/
public static function deleteIndex($index): array
{
return (new self())->request("/api/index/{$index}", null, 'DELETE');
}
/**
* 删除所有索引
*/
public static function deleteAllIndices(): array
{
$instance = new self();
$result = $instance->request("/api/index", null, 'GET');
if (!$result['success']) {
return $result;
}
$indices = $result['data'] ?? [];
$deleteResults = [];
$success = true;
foreach ($indices as $index) {
$indexName = $index['name'] ?? '';
if (!empty($indexName)) {
$deleteResult = $instance->request("/api/index/{$indexName}", null, 'DELETE');
$deleteResults[$indexName] = $deleteResult;
if (!$deleteResult['success']) {
$success = false;
}
}
}
return [
'success' => $success,
'message' => $success ? '所有索引删除成功' : '部分索引删除失败',
'details' => $deleteResults
];
}
/**
* 分析文本
*/
public static function analyze($analyzer, $text): array
{
$body = json_encode([
'analyzer' => $analyzer,
'text' => $text
]);
return (new self())->request("/api/_analyze", $body);
}
// ==============================
// 文档管理相关方法
// ==============================
/**
* 写入单条文档
*/
public static function addDoc($index, $doc): array
{
$body = json_encode($doc);
return (new self())->request("/api/{$index}/_doc", $body);
}
/**
* 更新文档
*/
public static function updateDoc($index, $id, $doc): array
{
$body = json_encode($doc);
return (new self())->request("/api/{$index}/_update/{$id}", $body);
}
/**
* 删除文档
*/
public static function deleteDoc($index, $id): array
{
return (new self())->request("/api/{$index}/_doc/{$id}", null, 'DELETE');
}
/**
* 批量写入文档
*/
public static function addDocs($index, $docs): array
{
$body = json_encode([
'index' => $index,
'records' => $docs
]);
return (new self())->request("/api/_bulkv2", $body);
}
/**
* 使用原始BULK API批量写入文档
* 请求格式为Elasticsearch兼容格式
*/
public static function bulkDocs($data): array
{
return (new self())->request("/api/_bulk", $data, 'BULK');
}
// ==============================
// 搜索相关方法
// ==============================
/**
* 查询文档
*/
public static function search($index, $query, $from = 0, $size = 10): array
{
$searchParams = [
'search_type' => 'match',
'query' => [
'term' => $query
],
'from' => $from,
'max_results' => $size
];
$body = json_encode($searchParams);
return (new self())->request("/api/{$index}/_search", $body);
}
/**
* 高级查询文档
*/
public static function advancedSearch($index, $searchParams): array
{
$body = json_encode($searchParams);
return (new self())->request("/api/{$index}/_search", $body);
}
/**
* 兼容ES查询文档
*/
public static function elasticSearch($index, $searchParams): array
{
$body = json_encode($searchParams);
return (new self())->request("/es/{$index}/_search", $body);
}
/**
* 多索引查询
*/
public static function multiSearch($queries): array
{
$body = json_encode($queries);
return (new self())->request("/api/_msearch", $body);
}
}

View File

@@ -0,0 +1,612 @@
<?php
namespace App\Module\ZincSearch;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Module\Apps;
use Carbon\Carbon;
use DB;
use Illuminate\Support\Facades\Log;
/**
* ZincSearch 会话消息类
*
* 使用方法:
*
* 1. 基础方法
* - 清空所有数据: clear();
*
* 2. 搜索方法
* - 关键词搜索: search('用户ID', '关键词');
*
* 3. 基本方法
* - 单个同步: sync(WebSocketDialogMsg $dialogMsg);
* - 批量同步: batchSync(WebSocketDialogMsg[] $dialogMsgs);
* - 用户同步: userSync(WebSocketDialogUser $dialogUser);
* - 删除消息: delete(WebSocketDialogMsg|WebSocketDialogUser|int $data);
*/
class ZincSearchDialogMsg
{
/**
* 索引名称
*/
protected static string $indexNameMsg = 'dialogMsg';
protected static string $indexNameUser = 'dialogUser';
// ==============================
// 基础方法
// ==============================
/**
* 确保索引存在
*/
private static function ensureIndex(): bool
{
if (!ZincSearchBase::indexExists(self::$indexNameMsg)) {
$mappings = [
'properties' => [
// 拓展数据
'dialog_userid' => ['type' => 'keyword', 'index' => true], // 对话ID+用户ID
'to_userid' => ['type' => 'numeric', 'index' => true], // 此消息发给的用户ID
// 消息数据
'id' => ['type' => 'numeric', 'index' => true],
'dialog_id' => ['type' => 'numeric', 'index' => true],
'dialog_type' => ['type' => 'keyword', 'index' => true],
'session_id' => ['type' => 'numeric', 'index' => true],
'userid' => ['type' => 'numeric', 'index' => true],
'type' => ['type' => 'keyword', 'index' => true],
'key' => ['type' => 'text', 'index' => true],
'created_at' => ['type' => 'date', 'index' => true],
'updated_at' => ['type' => 'date', 'index' => true],
]
];
$result = ZincSearchBase::createIndex(self::$indexNameMsg, $mappings);
return $result['success'] ?? false;
}
if (!ZincSearchBase::indexExists(self::$indexNameUser)) {
$mappings = [
'properties' => [
// 拓展数据
'dialog_userid' => ['type' => 'keyword', 'index' => true], // 对话ID+用户ID
// 用户数据
'id' => ['type' => 'numeric', 'index' => true],
'dialog_id' => ['type' => 'numeric', 'index' => true],
'userid' => ['type' => 'numeric', 'index' => true],
'top_at' => ['type' => 'date', 'index' => true],
'last_at' => ['type' => 'date', 'index' => true],
'mark_unread' => ['type' => 'numeric', 'index' => true],
'silence' => ['type' => 'numeric', 'index' => true],
'hide' => ['type' => 'numeric', 'index' => true],
'color' => ['type' => 'keyword', 'index' => true],
'created_at' => ['type' => 'date', 'index' => true],
'updated_at' => ['type' => 'date', 'index' => true],
]
];
$result = ZincSearchBase::createIndex(self::$indexNameUser, $mappings);
return $result['success'] ?? false;
}
return true;
}
/**
* 清空所有键值
*
* @return bool 是否成功
*/
public static function clear(): bool
{
// 检查索引是否存在然后删除
if (ZincSearchBase::indexExists(self::$indexNameMsg)) {
$deleteResult = ZincSearchBase::deleteIndex(self::$indexNameMsg);
if (!($deleteResult['success'] ?? false)) {
return false;
}
}
if (ZincSearchBase::indexExists(self::$indexNameUser)) {
$deleteResult = ZincSearchBase::deleteIndex(self::$indexNameUser);
if (!($deleteResult['success'] ?? false)) {
return false;
}
}
return self::ensureIndex();
}
// ==============================
// 搜索方法
// ==============================
/**
* 根据用户ID和消息关键词搜索会话
*
* @param string $userid 用户ID
* @param string $keyword 消息关键词
* @param int $from 起始位置
* @param int $size 返回结果数量
* @return array
*/
public static function search(string $userid, string $keyword, int $from = 0, int $size = 20): array
{
if (!Apps::isInstalled("search")) {
// 如果搜索功能未安装,使用数据库查询
return self::searchByMysql($userid, $keyword, $from, $size);
}
$searchParams = [
'query' => [
'bool' => [
'must' => [
['term' => ['to_userid' => $userid]],
['match_phrase' => ['key' => $keyword]]
]
]
],
'from' => $from,
'size' => $size,
'sort' => [
['updated_at' => 'desc']
]
];
try {
$result = ZincSearchBase::elasticSearch(self::$indexNameMsg, $searchParams);
$hits = $result['data']['hits']['hits'] ?? [];
// 收集所有的用户信息
$dialogUserids = [];
foreach ($hits as $hit) {
$source = $hit['_source'];
$dialogUserids[] = $source['dialog_userid'];
}
$userInfos = self::searchUser(array_unique($dialogUserids));
// 组合返回结果,将用户信息合并到消息中
$msgs = [];
foreach ($hits as $hit) {
$msgInfo = $hit['_source'];
$userInfo = $userInfos[$msgInfo['dialog_userid']] ?? [];
if ($userInfo) {
$msgs[] = [
'id' => $msgInfo['dialog_id'],
'search_msg_id' => $msgInfo['id'],
'user_at' => Carbon::parse($msgInfo['updated_at'])->format('Y-m-d H:i:s'),
'mark_unread' => $userInfo['mark_unread'],
'silence' => $userInfo['silence'],
'hide' => $userInfo['hide'],
'color' => $userInfo['color'],
'top_at' => Carbon::parse($userInfo['top_at'])->format('Y-m-d H:i:s'),
'last_at' => Carbon::parse($userInfo['last_at'])->format('Y-m-d H:i:s'),
];
}
}
return $msgs;
} catch (\Exception $e) {
Log::error('search: ' . $e->getMessage());
return [];
}
}
/**
* 根据用户ID和消息关键词搜索会话MySQL 版本主要用于未安装ZincSearch的情况
*
* @param string $userid 用户ID
* @param string $keyword 消息关键词
* @param int $from 起始位置
* @param int $size 返回结果数量
* @return array
*/
private static function searchByMysql(string $userid, string $keyword, int $from = 0, int $size = 20): array
{
$items = DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at', 'm.id as search_msg_id'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->join('web_socket_dialog_msgs as m', 'm.dialog_id', '=', 'd.id')
->where('u.userid', $userid)
->where('m.bot', 0)
->whereNull('d.deleted_at')
->where('m.key', 'like', "%{$keyword}%")
->orderByDesc('m.id')
->offset($from)
->limit($size)
->get()
->all();
$msgs = [];
foreach ($items as $item) {
$msgs[] = [
'id' => $item->id,
'search_msg_id' => $item->search_msg_id,
'user_at' => Carbon::parse($item->user_at)->format('Y-m-d H:i:s'),
'mark_unread' => $item->mark_unread,
'silence' => $item->silence,
'hide' => $item->hide,
'color' => $item->color,
'top_at' => Carbon::parse($item->top_at)->format('Y-m-d H:i:s'),
'last_at' => Carbon::parse($item->last_at)->format('Y-m-d H:i:s'),
];
}
return $msgs;
}
/**
* 根据对话用户ID搜索用户信息
* @param array $dialogUserids
* @return array
*/
private static function searchUser(array $dialogUserids): array
{
if (empty($dialogUserids)) {
return [];
}
$userInfos = [];
// 构建用户查询条件
$userSearchParams = [
'query' => [
'bool' => [
'should' => []
]
],
'size' => count($dialogUserids) // 确保取到所有符合条件的记录
];
// 添加所有 dialog_userid 到查询条件
foreach ($dialogUserids as $dialogUserid) {
$userSearchParams['query']['bool']['should'][] = [
'term' => ['dialog_userid' => $dialogUserid]
];
}
// 查询用户信息
$userResult = ZincSearchBase::elasticSearch(self::$indexNameUser, $userSearchParams);
$userHits = $userResult['data']['hits']['hits'] ?? [];
// 以 dialog_userid 为键保存用户信息
foreach ($userHits as $userHit) {
$userSource = $userHit['_source'];
$userInfos[$userSource['dialog_userid']] = $userSource;
}
return $userInfos;
}
// ==============================
// 生成内容
// ==============================
/**
* 生成 dialog_userid
*
* @param WebSocketDialogUser $dialogUser
* @return string
*/
private static function generateDialogUserid(WebSocketDialogUser $dialogUser): string
{
return "{$dialogUser->dialog_id}_{$dialogUser->userid}";
}
/**
* 生成文档内容
*
* @param WebSocketDialogMsg $dialogMsg
* @param WebSocketDialogUser $dialogUser
* @return array
*/
private static function generateMsgData(WebSocketDialogMsg $dialogMsg, WebSocketDialogUser $dialogUser): array
{
return [
'_id' => self::$indexNameMsg . "_" . $dialogMsg->id . "_" . $dialogUser->userid,
'dialog_userid' => self::generateDialogUserid($dialogUser),
'to_userid' => $dialogUser->userid,
'id' => $dialogMsg->id,
'dialog_id' => $dialogMsg->dialog_id,
'dialog_type' => $dialogMsg->dialog_type,
'session_id' => $dialogMsg->session_id,
'userid' => $dialogMsg->userid,
'type' => $dialogMsg->type,
'key' => $dialogMsg->key,
'created_at' => $dialogMsg->created_at,
'updated_at' => $dialogMsg->updated_at,
];
}
private static function generateUserData(WebSocketDialogUser $dialogUser): array
{
return [
'_id' => self::$indexNameUser . "_" . $dialogUser->id,
'dialog_userid' => self::generateDialogUserid($dialogUser),
'id' => $dialogUser->id,
'dialog_id' => $dialogUser->dialog_id,
'userid' => $dialogUser->userid,
'top_at' => $dialogUser->top_at,
'last_at' => $dialogUser->last_at,
'mark_unread' => $dialogUser->mark_unread,
'silence' => $dialogUser->silence,
'hide' => $dialogUser->hide,
'color' => $dialogUser->color,
'created_at' => $dialogUser->created_at,
'updated_at' => $dialogUser->updated_at,
];
}
// ==============================
// 基本方法
// ==============================
/**
* 同步消息(建议在异步进程中使用)
*
* @param WebSocketDialogMsg $dialogMsg
* @return bool
*/
public static function sync(WebSocketDialogMsg $dialogMsg): bool
{
if (!self::ensureIndex()) {
return false;
}
if ($dialogMsg->bot) {
// 如果是机器人消息,跳过
return true;
}
try {
// 获取此会话的所有用户
$dialogUsers = WebSocketDialogUser::whereDialogId($dialogMsg->dialog_id)->get();
if ($dialogUsers->isEmpty()) {
return true;
}
$msgs = [];
$users = [];
foreach ($dialogUsers as $dialogUser) {
if (empty($dialogMsg->key)) {
// 如果消息没有关键词,跳过
continue;
}
if ($dialogUser->userid == 0) {
// 跳过系统用户
continue;
}
$msgs[] = self::generateMsgData($dialogMsg, $dialogUser);
$users[$dialogUser->id] = self::generateUserData($dialogUser);
}
if ($msgs) {
// 批量写入消息
ZincSearchBase::addDocs(self::$indexNameMsg, $msgs);
}
if ($users) {
// 批量写入用户
ZincSearchBase::addDocs(self::$indexNameUser, array_values($users));
}
return true;
} catch (\Exception $e) {
Log::error('sync: ' . $e->getMessage());
return false;
}
}
/**
* 批量同步消息(建议在异步进程中使用)
*
* @param WebSocketDialogMsg[] $dialogMsgs
* @return int 成功同步的消息数
*/
public static function batchSync($dialogMsgs): int
{
if (!self::ensureIndex()) {
return 0;
}
$count = 0;
try {
$msgs = [];
$users = [];
$userDialogs = [];
// 预处理收集所有涉及的对话ID
$dialogIds = [];
foreach ($dialogMsgs as $dialogMsg) {
$dialogIds[] = $dialogMsg->dialog_id;
}
$dialogIds = array_unique($dialogIds);
// 获取所有相关的用户-对话关系
if (!empty($dialogIds)) {
$dialogUsers = WebSocketDialogUser::whereIn('dialog_id', $dialogIds)->get();
// 按对话ID组织用户
foreach ($dialogUsers as $dialogUser) {
$userDialogs[$dialogUser->dialog_id][] = $dialogUser;
}
}
// 为每条消息准备所有相关用户的文档
foreach ($dialogMsgs as $dialogMsg) {
if (!isset($userDialogs[$dialogMsg->dialog_id])) {
// 如果该会话没有用户,跳过
continue;
}
if ($dialogMsg->bot) {
// 如果是机器人消息,跳过
continue;
}
/** @var WebSocketDialogUser $dialogUser */
foreach ($userDialogs[$dialogMsg->dialog_id] as $dialogUser) {
if (empty($dialogMsg->key)) {
// 如果消息没有关键词,跳过
continue;
}
if ($dialogUser->userid == 0) {
// 跳过系统用户
continue;
}
$msgs[] = self::generateMsgData($dialogMsg, $dialogUser);
$users[$dialogUser->id] = self::generateUserData($dialogUser);
$count++;
}
}
if ($msgs) {
// 批量写入消息
ZincSearchBase::addDocs(self::$indexNameMsg, $msgs);
}
if ($users) {
// 批量写入用户
ZincSearchBase::addDocs(self::$indexNameUser, array_values($users));
}
} catch (\Exception $e) {
Log::error('batchSync: ' . $e->getMessage());
}
return $count;
}
/**
* 同步用户(建议在异步进程中使用)
* @param WebSocketDialogUser $dialogUser
* @return bool
*/
public static function userSync(WebSocketDialogUser $dialogUser): bool
{
if (!self::ensureIndex()) {
return false;
}
$data = self::generateUserData($dialogUser);
// 生成查询用户条件
$searchParams = [
'query' => [
'bool' => [
'must' => [
['term' => ['dialog_userid' => $data['dialog_userid']]]
]
]
],
'size' => 1
];
try {
// 查询用户是否存在
$result = ZincSearchBase::elasticSearch(self::$indexNameUser, $searchParams);
$hits = $result['data']['hits']['hits'] ?? [];
// 同步用户(存在更新、不存在添加)
$result = ZincSearchBase::addDoc(self::$indexNameUser, $data);
if (!isset($result['success'])) {
return false;
}
// 用户不存在,同步消息
if (empty($hits)) {
$lastId = 0; // 上次同步的最后ID
$batchSize = 500; // 每批处理的消息数量
// 分批同步消息
do {
// 获取一批
$dialogMsgs = WebSocketDialogMsg::whereDialogId($dialogUser->dialog_id)
->where('id', '>', $lastId)
->orderBy('id')
->limit($batchSize)
->get();
if ($dialogMsgs->isEmpty()) {
break;
}
// 同步数据
ZincSearchDialogMsg::batchSync($dialogMsgs);
// 更新最后ID
$lastId = $dialogMsgs->last()->id;
} while (count($dialogMsgs) == $batchSize);
}
return true;
} catch (\Exception $e) {
Log::error('userSync: ' . $e->getMessage());
return false;
}
}
/**
* 删除(建议在异步进程中使用)
*
* @param WebSocketDialogMsg|WebSocketDialogUser|int $data
* @return int
*/
public static function delete(mixed $data): int
{
$batchSize = 500; // 每批处理的文档数量
$totalDeleted = 0; // 总共删除的文档数量
$from = 0;
// 根据数据类型生成查询条件
if ($data instanceof WebSocketDialogMsg) {
$query = [
'field' => 'id',
'term' => (string) $data->id
];
} elseif ($data instanceof WebSocketDialogUser) {
$query = [
'field' => 'dialog_userid',
'term' => self::generateDialogUserid($data),
];
} else {
$query = [
'field' => 'id',
'term' => (string) $data
];
}
try {
while (true) {
// 根据消息ID查找相关文档
$result = ZincSearchBase::advancedSearch(self::$indexNameMsg, [
'search_type' => 'term',
'query' => $query,
'from' => $from,
'max_results' => $batchSize
]);
$hits = $result['data']['hits']['hits'] ?? [];
// 如果没有更多文档,退出循环
if (empty($hits)) {
break;
}
// 删除本批次找到的所有文档
foreach ($hits as $hit) {
if (isset($hit['_id'])) {
ZincSearchBase::deleteDoc(self::$indexNameMsg, $hit['_id']);
$totalDeleted++;
}
}
// 如果返回的文档数少于批次大小,说明已经没有更多文档了
if (count($hits) < $batchSize) {
break;
}
// 移动到下一批
$from += $batchSize;
}
} catch (\Exception $e) {
Log::error('delete: ' . $e->getMessage());
}
return $totalDeleted;
}
}

View File

@@ -0,0 +1,276 @@
<?php
namespace App\Module\ZincSearch;
/**
* ZincSearch 键值存储类
*
* 使用方法:
*
* 1. 基础方法
* - 确保索引存在: ensureIndex();
* - 清空所有数据: clear();
*
* 2. 基本操作
* - 设置键值: set('site_name', '我的网站');
* - 设置复杂数据: set('site_config', ['logo' => 'logo.png', 'theme' => 'dark']);
* - 合并现有数据: set('site_config', ['footer' => '版权所有'], true);
* - 获取键值: $siteName = get('site_name');
* - 获取键值带默认值: $theme = get('theme', 'light');
* - 删除键值: delete('temporary_data');
*
* 3. 批量操作
* - 批量设置: batchSet(['user_count' => 100, 'active_users' => 50]);
* - 批量获取: $stats = batchGet(['user_count', 'active_users']);
*/
class ZincSearchKeyValue
{
/**
* 索引名称
*/
protected static string $indexName = 'keyValue';
// ==============================
// 基础方法
// ==============================
/**
* 确保索引存在
*/
public static function ensureIndex(): bool
{
if (!ZincSearchBase::indexExists(self::$indexName)) {
$mappings = [
'properties' => [
'key' => ['type' => 'keyword', 'index' => true],
'value' => ['type' => 'text', 'index' => true],
'created_at' => ['type' => 'date', 'index' => true],
'updated_at' => ['type' => 'date', 'index' => true]
]
];
$result = ZincSearchBase::createIndex(self::$indexName, $mappings);
return $result['success'] ?? false;
}
return true;
}
/**
* 清空所有键值
*
* @return bool 是否成功
*/
public static function clear(): bool
{
// 检查索引是否存在
if (!ZincSearchBase::indexExists(self::$indexName)) {
return true;
}
// 删除再重建索引
$deleteResult = ZincSearchBase::deleteIndex(self::$indexName);
if (!($deleteResult['success'] ?? false)) {
return false;
}
return self::ensureIndex();
}
// ==============================
// 基本操作
// ==============================
/**
* 设置键值
*
* @param string $key 键名
* @param mixed $value 值
* @param bool $merge 是否合并现有数据(如果值是数组)
* @return bool 是否成功
*/
public static function set(string $key, mixed $value, bool $merge = false): bool
{
if (!self::ensureIndex()) {
return false;
}
// 检查键是否已存在
if ($merge && is_array($value)) {
$existingData = self::get($key);
if (is_array($existingData)) {
$value = array_merge($existingData, $value);
}
}
// 检查是否存在相同键的文档 - 使用精确查询而不是普通搜索
$searchParams = [
'search_type' => 'term',
'query' => [
'field' => 'key',
'term' => $key
],
'from' => 0,
'max_results' => 1
];
$result = ZincSearchBase::advancedSearch(self::$indexName, $searchParams);
$docs = $result['data']['hits']['hits'] ?? [];
$now = date('c');
if (!empty($docs)) {
$docId = $docs[0]['_id'] ?? null;
if ($docId) {
// 更新现有文档
$docData = [
'key' => $key,
'value' => $value,
'updated_at' => $now
];
$updateResult = ZincSearchBase::updateDoc(self::$indexName, $docId, $docData);
return $updateResult['success'] ?? false;
}
}
// 创建新文档
$docData = [
'key' => $key,
'value' => $value,
'created_at' => $now,
'updated_at' => $now
];
$addResult = ZincSearchBase::addDoc(self::$indexName, $docData);
return $addResult['success'] ?? false;
}
/**
* 获取键值
*
* @param string $key 键名
* @param mixed $default 默认值
* @return mixed 值或默认值
*/
public static function get(string $key, mixed $default = null): mixed
{
if (!self::ensureIndex() || empty($key)) {
return $default;
}
// 精确匹配键名
$searchParams = [
'search_type' => 'term',
'query' => [
'field' => 'key',
'term' => $key
],
'from' => 0,
'max_results' => 1
];
$result = ZincSearchBase::advancedSearch(self::$indexName, $searchParams);
if (!($result['success'] ?? false)) {
return $default;
}
$hits = $result['data']['hits']['hits'] ?? [];
if (empty($hits)) {
return $default;
}
return $hits[0]['_source']['value'] ?? $default;
}
/**
* 删除键值
*
* @param string $key 键名
* @return bool 是否成功
*/
public static function delete(string $key): bool
{
if (!self::ensureIndex() || empty($key)) {
return false;
}
// 查找文档ID
$searchParams = [
'search_type' => 'term',
'query' => [
'field' => 'key',
'term' => $key
],
'from' => 0,
'max_results' => 1
];
$result = ZincSearchBase::advancedSearch(self::$indexName, $searchParams);
if (!($result['success'] ?? false)) {
return false;
}
$hits = $result['data']['hits']['hits'] ?? [];
if (empty($hits)) {
return true; // 不存在视为删除成功
}
$docId = $hits[0]['_id'] ?? null;
if (empty($docId)) {
return false;
}
$deleteResult = ZincSearchBase::deleteDoc(self::$indexName, $docId);
return $deleteResult['success'] ?? false;
}
// ==============================
// 批量操作
// ==============================
/**
* 批量设置键值对
*
* @param array $keyValues 键值对数组
* @return bool 是否全部成功
*/
public static function batchSet(array $keyValues): bool
{
if (!self::ensureIndex() || empty($keyValues)) {
return false;
}
$docs = [];
$now = date('c');
foreach ($keyValues as $key => $value) {
$docs[] = [
'key' => $key,
'value' => $value,
'created_at' => $now,
'updated_at' => $now
];
}
$result = ZincSearchBase::addDocs(self::$indexName, $docs);
return $result['success'] ?? false;
}
/**
* 批量获取键值
*
* @param array $keys 键名数组
* @return array 键值对数组
*/
public static function batchGet(array $keys): array
{
if (!self::ensureIndex() || empty($keys)) {
return [];
}
$results = [];
// 遍历查询每个键
foreach ($keys as $key) {
$results[$key] = self::get($key);
}
return $results;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Observers;
use Hhxsv5\LaravelS\Swoole\Task\Task;
class AbstractObserver
{
/**
* @param $task
* @return void
*/
public static function taskDeliver($task)
{
if (app()->bound('swoole')) {
Task::deliver($task);
}
}
}

View File

@@ -3,9 +3,9 @@
namespace App\Observers;
use App\Models\WebSocketDialogMsg;
use App\Module\ElasticSearch\ElasticSearchUserMsg;
use App\Tasks\ZincSearchSyncTask;
class WebSocketDialogMsgObserver
class WebSocketDialogMsgObserver extends AbstractObserver
{
/**
* Handle the WebSocketDialogMsg "created" event.
@@ -15,7 +15,7 @@ class WebSocketDialogMsgObserver
*/
public function created(WebSocketDialogMsg $webSocketDialogMsg)
{
ElasticSearchUserMsg::syncMsg($webSocketDialogMsg);
self::taskDeliver(new ZincSearchSyncTask('sync', $webSocketDialogMsg->toArray()));
}
/**
@@ -26,7 +26,7 @@ class WebSocketDialogMsgObserver
*/
public function updated(WebSocketDialogMsg $webSocketDialogMsg)
{
ElasticSearchUserMsg::syncMsg($webSocketDialogMsg);
self::taskDeliver(new ZincSearchSyncTask('sync', $webSocketDialogMsg->toArray()));
}
/**
@@ -37,7 +37,7 @@ class WebSocketDialogMsgObserver
*/
public function deleted(WebSocketDialogMsg $webSocketDialogMsg)
{
ElasticSearchUserMsg::deleteMsg($webSocketDialogMsg);
self::taskDeliver(new ZincSearchSyncTask('delete', $webSocketDialogMsg->toArray()));
}
/**

View File

@@ -4,10 +4,10 @@ namespace App\Observers;
use App\Models\Deleted;
use App\Models\WebSocketDialogUser;
use App\Module\ElasticSearch\ElasticSearchUserMsg;
use App\Tasks\ZincSearchSyncTask;
use Carbon\Carbon;
class WebSocketDialogUserObserver
class WebSocketDialogUserObserver extends AbstractObserver
{
/**
* Handle the WebSocketDialogUser "created" event.
@@ -30,7 +30,7 @@ class WebSocketDialogUserObserver
}
}
Deleted::forget('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
ElasticSearchUserMsg::syncUser($webSocketDialogUser);
self::taskDeliver(new ZincSearchSyncTask('userSync', $webSocketDialogUser->toArray()));
}
/**
@@ -41,7 +41,7 @@ class WebSocketDialogUserObserver
*/
public function updated(WebSocketDialogUser $webSocketDialogUser)
{
ElasticSearchUserMsg::syncUser($webSocketDialogUser);
self::taskDeliver(new ZincSearchSyncTask('userSync', $webSocketDialogUser->toArray()));
}
/**
@@ -53,7 +53,7 @@ class WebSocketDialogUserObserver
public function deleted(WebSocketDialogUser $webSocketDialogUser)
{
Deleted::record('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
ElasticSearchUserMsg::deleteUser($webSocketDialogUser);
self::taskDeliver(new ZincSearchSyncTask('deleteUser', $webSocketDialogUser->toArray()));
}
/**

View File

@@ -2,33 +2,101 @@
namespace App\Services;
use App\Module\ClientContext;
use Illuminate\Http\Request;
use Swoole\Coroutine;
/**
* 请求上下文
*/
class RequestContext
{
/** @var array<string, array<string, mixed>> */
private static array $context = [];
/** @var string 请求ID的上下文键 */
private const CONTEXT_KEY = 'request_id';
private const REQUEST_ID_PREFIX = 'req_';
/** @var string 请求ID前缀 */
private const REQUEST_ID_PREFIX = 'req';
/** @var int 上下文的TTL生存时间 */
private const TTL_SECONDS = 3600; // 上下文 TTL 为 1 小时
/** @var array<string, ClientContext> 存储每个请求的上下文数据 */
private static array $context = [];
/**
* 生成请求唯一ID
*/
public static function generateRequestId(): string
{
return self::REQUEST_ID_PREFIX . uniqid() . mt_rand(10000, 99999);
$pid = getmypid();
$cid = Coroutine::getCid() ?? 0;
$microtime = str_replace('.', '', microtime(true));
return self::REQUEST_ID_PREFIX . '_' . $pid . '_' . $cid . '_' . $microtime . '_' . mt_rand(1000, 9999);
}
/**
* 获取当前请求ID
*/
private static function getCurrentRequestId(): ?string
public static function getCurrentRequestId($requestId = null): ?string
{
/** @var Request $request */
// 如果提供了有效的请求ID直接返回
if ($requestId && str_starts_with($requestId, self::REQUEST_ID_PREFIX)) {
return $requestId;
}
// 尝试从当前请求获取
$request = request();
return $request?->requestId;
if ($request && method_exists($request, 'attributes') && $request->attributes) {
if (!$request->attributes->has(static::CONTEXT_KEY)) {
$request->attributes->set(static::CONTEXT_KEY, self::generateRequestId());
}
return $request->attributes->get(static::CONTEXT_KEY);
}
// 如果没有请求上下文生成一个新的请求ID
return self::generateRequestId();
}
/**
* 获取当前请求的上下文示例
*/
public static function getCurrentRequestContext($requestId = null): ?ClientContext
{
$requestId = self::getCurrentRequestId($requestId);
if ($requestId === null) {
return null;
}
if (!isset(self::$context[$requestId])) {
// 如果上下文不存在,则创建一个新的上下文
self::$context[$requestId] = new ClientContext();
} else {
// 如果上下文已存在,更新访问时间
self::$context[$requestId]->update();
}
return self::$context[$requestId];
}
/**
* 清理过期上下文数据,防止内存泄漏
*/
public static function cleanExpired(): void
{
$now = microtime(true);
// 清理过期的上下文
foreach (self::$context as $requestId => $context) {
if ($now - $context->updatedAt > self::TTL_SECONDS) {
unset(self::$context[$requestId]);
}
}
}
/** ***************************************************************************************** */
/** ***************************************************************************************** */
/** ***************************************************************************************** */
/**
* 设置请求上下文
*
@@ -39,13 +107,34 @@ class RequestContext
*/
public static function set(string $key, mixed $value, ?string $requestId = null): void
{
$requestId = $requestId ?? self::getCurrentRequestId();
if ($requestId === null) {
$context = self::getCurrentRequestContext($requestId);
if ($context === null) {
return;
}
self::$context[$requestId] ??= [];
self::$context[$requestId][$key] = $value;
$context->set($key, $value);
// 概率性清理,避免频繁清理影响性能
if (mt_rand(1, 100) === 1) {
self::cleanExpired();
}
}
/**
* 批量设置上下文数据
*
* @param array<string, mixed> $data
* @param string|null $requestId
* @return void
*/
public static function setMultiple(array $data, ?string $requestId = null): void
{
$context = self::getCurrentRequestContext($requestId);
if ($context === null) {
return;
}
$context->setMultiple($data);
}
// 与 set 方法的区别是save 方法会返回传入的 value 值
@@ -65,12 +154,28 @@ class RequestContext
*/
public static function get(string $key, mixed $default = null, ?string $requestId = null): mixed
{
$requestId = $requestId ?? self::getCurrentRequestId();
if ($requestId === null) {
$context = self::getCurrentRequestContext($requestId);
if ($context === null) {
return $default;
}
return self::$context[$requestId][$key] ?? $default;
return $context->get($key, $default);
}
/**
* 获取当前请求的所有上下文数据
*
* @param string|null $requestId
* @return array<string, mixed>
*/
public static function getAll(?string $requestId = null): array
{
$context = self::getCurrentRequestContext($requestId);
if ($context === null) {
return [];
}
return $context->context ?? [];
}
/**
@@ -82,12 +187,12 @@ class RequestContext
*/
public static function has(string $key, ?string $requestId = null): bool
{
$requestId = $requestId ?? self::getCurrentRequestId();
if ($requestId === null) {
$context = self::getCurrentRequestContext($requestId);
if ($context === null) {
return false;
}
return isset(self::$context[$requestId][$key]);
return $context->has($key);
}
/**
@@ -96,9 +201,9 @@ class RequestContext
* @param string|null $requestId
* @return void
*/
public static function clear(?string $requestId = null): void
public static function clean(?string $requestId = null): void
{
$requestId = $requestId ?? self::getCurrentRequestId();
$requestId = self::getCurrentRequestId($requestId);
if ($requestId === null) {
return;
}
@@ -106,37 +211,63 @@ class RequestContext
unset(self::$context[$requestId]);
}
/**
* 获取当前请求的所有上下文数据
*
* @param string|null $requestId
* @return array<string, mixed>
*/
public static function getAll(?string $requestId = null): array
{
$requestId = $requestId ?? self::getCurrentRequestId();
if ($requestId === null) {
return [];
}
return self::$context[$requestId] ?? [];
}
/** ***************************************************************************************** */
/** ***************************************************************************************** */
/** ***************************************************************************************** */
/**
* 批量设置上下文数据
* 更新请求的基本URL
*
* @param array<string, mixed> $data
* @param string|null $requestId
* @param Request $request
* @return void
*/
public static function setMultiple(array $data, ?string $requestId = null): void
public static function updateBaseUrl($request)
{
$requestId = $requestId ?? self::getCurrentRequestId();
if ($requestId === null) {
if ($request->path() !== 'api/system/setting') {
return;
}
$schemeAndHttpHost = $request->getSchemeAndHttpHost();
if (str_contains($schemeAndHttpHost, '127.0.0.1') || str_contains($schemeAndHttpHost, 'localhost')) {
return;
}
\Cache::forever('RequestContext::base_url', $schemeAndHttpHost);
}
self::$context[$requestId] ??= [];
self::$context[$requestId] = array_merge(self::$context[$requestId], $data);
/**
* 替换请求的基本URL
*
* @param string $url
* @return string
*/
public static function replaceBaseUrl(string $url): string
{
// 先提取主机部分
$pattern = '/^(https?:\/\/[^\/?#:]+(:\d+)?)/i';
if (!preg_match($pattern, $url, $matches)) {
return $url; // 如果不是有效URL直接返回
}
$schemeAndHttpHost = $matches[1] ?? '';
if (!$schemeAndHttpHost) {
return $url;
}
// 只检查主机部分是否为本地主机
if (str_contains($schemeAndHttpHost, '127.0.0.1') || str_contains($schemeAndHttpHost, 'localhost')) {
$baseUrl = \Cache::get('RequestContext::base_url');
if ($baseUrl) {
return $baseUrl . substr($url, strlen($schemeAndHttpHost));
}
}
return $url;
}
/**
* 清除基本URL缓存
*/
public static function clearBaseUrlCache(): void
{
\Cache::forget('RequestContext::base_url');
}
}

View File

@@ -1,6 +1,8 @@
<?php
namespace App\Tasks;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Models\TaskWorker;
use App\Module\Base;
use Carbon\Carbon;

View File

@@ -6,8 +6,6 @@ use App\Models\ProjectTask;
use App\Module\Base;
use Carbon\Carbon;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
class AppPushTask extends AbstractTask
{
public function __construct()

View File

@@ -1,8 +1,6 @@
<?php
namespace App\Tasks;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Models\ProjectTask;
use App\Module\Base;
use Carbon\Carbon;

View File

@@ -12,68 +12,95 @@ use App\Models\UserDepartment;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogConfig;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Ihttp;
use App\Module\TextExtractor;
use Carbon\Carbon;
use League\HTMLToMarkdown\HtmlConverter;
use Exception;
use DB;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
/**
* 推送会话消息
* Class BotReceiveMsgTask
* 机器人消息接收处理任务
*
* @package App\Tasks
*/
class BotReceiveMsgTask extends AbstractTask
{
protected $userid; // 机器人ID
protected $msgId; // 消息ID
protected $mention; // 是否提及
protected $mention; // 是否提及(机器人被@,不含@所有人)
protected $mentionOther; // 是否提及其他人
protected $client = []; // 客户端信息(版本、语言、平台)
/**
* 构造函数
* 初始化机器人消息处理任务的相关参数
*
* @param int $userid 机器人用户ID
* @param int $msgId 消息ID
* @param array $mentions 提及的用户ID数组
* @param array $client 客户端信息(版本、语言、平台等)
*/
public function __construct($userid, $msgId, $mentions, $client = [])
{
parent::__construct(...func_get_args());
$this->userid = $userid;
$this->msgId = $msgId;
$this->mention = array_intersect([$userid], $mentions) ? 1 : 0; // 是否提及(不含@所有人)
$this->mentionOther = array_diff($mentions, [0, $userid]) ? 1 : 0; // 是否提及其他人
$this->mention = array_intersect([$userid], $mentions) ? 1 : 0;
$this->mentionOther = array_diff($mentions, [0, $userid]) ? 1 : 0;
$this->client = is_array($client) ? $client : [];
}
/**
* 任务开始执行
* 验证机器人用户和消息的有效性,然后处理机器人接收到的消息
*/
public function start()
{
// 判断是否是机器人用户
$botUser = User::whereUserid($this->userid)->whereBot(1)->first();
if (empty($botUser)) {
return;
}
// 判断消息是否存在
$msg = WebSocketDialogMsg::with(['user'])->find($this->msgId);
if (empty($msg)) {
return;
}
// 标记消息已读
$msg->readSuccess($botUser->userid);
if (!$msg->user?->bot) {
$this->botReceiveBusiness($msg, $botUser);
// 判断消息是否是机器人发送的则不处理,避免循环
if (!$msg->user || $msg->user->bot) {
return;
}
// 处理机器人消息
$this->processMessage($msg, $botUser);
}
/**
* 任务结束回调
* 当前为空实现,可在此处添加清理逻辑
*/
public function end()
{
}
/**
* 机器人处理消息
* @param WebSocketDialogMsg $msg
* @param User $botUser
* 处理机器人接收到的消息
* 根据消息类型和机器人类型执行相应的处理逻辑
*
* @param WebSocketDialogMsg $msg 接收到的消息对象
* @param User $botUser 机器人用户对象
* @return void
*/
private function botReceiveBusiness(WebSocketDialogMsg $msg, User $botUser)
private function processMessage(WebSocketDialogMsg $msg, User $botUser)
{
// 位置消息(仅支持签到机器人)
if ($msg->type === 'location') {
@@ -90,8 +117,14 @@ class BotReceiveMsgTask extends AbstractTask
}
// 提取指令
$command = $this->extractCommand($msg, $botUser->isAiBot(), $this->mention);
if (empty($command)) {
$sendText = $this->extractMessageContent($msg);
$replyText = null;
if ($msg->reply_id && $replyMsg = WebSocketDialogMsg::find($msg->reply_id)) {
$replyText = $this->extractMessageContent($replyMsg);
}
// 没有提取到指令,则不处理
if (empty($sendText) && empty($replyText)) {
return;
}
@@ -101,22 +134,22 @@ class BotReceiveMsgTask extends AbstractTask
return;
}
// 如果是群聊,未提及丹提及其他人
if ($dialog->type === 'group' && !$this->mention && $this->mentionOther) {
// 如果是群聊,@别人但是没有@自己,则不处理
if ($dialog->type === 'group' && $this->mentionOther && !$this->mention) {
return;
}
// 推送Webhook
$this->botWebhookBusiness($command, $msg, $botUser, $dialog);
$this->handleWebhookRequest($sendText, $replyText, $msg, $dialog, $botUser);
// 仅支持用户会话
// 如果不是用户对话,则只处理到这里
if ($dialog->type !== 'user') {
return;
}
// 签到机器人
if ($botUser->email === 'check-in@bot.system') {
$content = UserBot::checkinBotQuickMsg($command, $msg->userid);
$content = UserBot::checkinBotQuickMsg($sendText, $msg->userid);
if ($content) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'template', [
'type' => 'content',
@@ -127,7 +160,7 @@ class BotReceiveMsgTask extends AbstractTask
// 隐私机器人
if ($botUser->email === 'anon-msg@bot.system') {
$array = UserBot::anonBotQuickMsg($command);
$array = UserBot::anonBotQuickMsg($sendText);
if ($array) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'template', [
'type' => 'content',
@@ -138,7 +171,7 @@ class BotReceiveMsgTask extends AbstractTask
}
// 管理机器人
if (str_starts_with($command, '/')) {
if (str_starts_with($sendText, '/')) {
// 判断是否是机器人管理员
if ($botUser->email === 'bot-manager@bot.system') {
$isManager = true;
@@ -153,7 +186,7 @@ class BotReceiveMsgTask extends AbstractTask
}
// 指令处理
$array = Base::newTrim(explode(" ", "{$command} "));
$array = Base::newTrim(explode(" ", "{$sendText} "));
$type = $array[0];
$data = [];
$content = "";
@@ -189,7 +222,7 @@ class BotReceiveMsgTask extends AbstractTask
case '/hello':
case '/info':
$botId = $isManager ? $array[1] : $botUser->userid;
$data = $this->botOne($botId, $msg->userid);
$data = $this->getBotInfo($botId, $msg->userid);
if (!$data) {
$content = "机器人不存在。";
}
@@ -199,32 +232,11 @@ class BotReceiveMsgTask extends AbstractTask
* 创建
*/
case '/newbot':
if (User::select(['users.*'])
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
->where('users.bot', 1)
->where('user_bots.userid', $msg->userid)
->count() >= 50) {
$content = "超过最大创建数量。";
break;
}
if (strlen($array[1]) < 2 || strlen($array[1]) > 20) {
$content = "机器人名称由2-20个字符组成。";
break;
}
$data = User::botGetOrCreate("user-" . Base::generatePassword(), [
'nickname' => $array[1]
], $msg->userid);
if (empty($data)) {
$content = "创建失败。";
break;
}
$dialog = WebSocketDialog::checkUserDialog($data, $msg->userid);
if ($dialog) {
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => '/hello',
'title' => '创建成功。',
'data' => $data,
], $data->userid); // todo 未能在任务end事件来发送任务
$res = UserBot::newBot($msg->userid, $array[1]);
if (Base::isError($res)) {
$content = $res['msg'];
} else {
$data = $res['data'];
}
break;
@@ -238,7 +250,7 @@ class BotReceiveMsgTask extends AbstractTask
$content = "机器人名称由2-20个字符组成。";
break;
}
$data = $this->botOne($botId, $msg->userid);
$data = $this->getBotInfo($botId, $msg->userid);
if ($data) {
$data->nickname = $nameString;
$data->az = Base::getFirstCharter($nameString);
@@ -255,7 +267,7 @@ class BotReceiveMsgTask extends AbstractTask
*/
case '/deletebot':
$botId = $isManager ? $array[1] : $botUser->userid;
$data = $this->botOne($botId, $msg->userid);
$data = $this->getBotInfo($botId, $msg->userid);
if ($data) {
$data->deleteUser('delete bot');
} else {
@@ -268,7 +280,7 @@ class BotReceiveMsgTask extends AbstractTask
*/
case '/token':
$botId = $isManager ? $array[1] : $botUser->userid;
$data = $this->botOne($botId, $msg->userid);
$data = $this->getBotInfo($botId, $msg->userid);
if ($data) {
User::generateToken($data);
} else {
@@ -281,7 +293,7 @@ class BotReceiveMsgTask extends AbstractTask
*/
case '/revoke':
$botId = $isManager ? $array[1] : $botUser->userid;
$data = $this->botOne($botId, $msg->userid);
$data = $this->getBotInfo($botId, $msg->userid);
if ($data) {
$data->encrypt = Base::generatePassword(6);
$data->password = Doo::md5s(Base::generatePassword(32), $data->encrypt);
@@ -297,7 +309,7 @@ class BotReceiveMsgTask extends AbstractTask
case '/clearday':
$botId = $isManager ? $array[1] : $botUser->userid;
$clearDay = $isManager ? $array[2] : $array[1];
$data = $this->botOne($botId, $msg->userid);
$data = $this->getBotInfo($botId, $msg->userid);
if ($data) {
$userBot = UserBot::whereBotId($botId)->whereUserid($msg->userid)->first();
if ($userBot) {
@@ -318,7 +330,7 @@ class BotReceiveMsgTask extends AbstractTask
case '/webhook':
$botId = $isManager ? $array[1] : $botUser->userid;
$webhookUrl = $isManager ? $array[2] : $array[1];
$data = $this->botOne($botId, $msg->userid);
$data = $this->getBotInfo($botId, $msg->userid);
if (strlen($webhookUrl) > 255) {
$content = "webhook地址最长仅支持255个字符。";
} elseif ($data) {
@@ -341,7 +353,7 @@ class BotReceiveMsgTask extends AbstractTask
case '/dialog':
$botId = $isManager ? $array[1] : $botUser->userid;
$nameKey = $isManager ? $array[2] : $array[1];
$data = $this->botOne($botId, $msg->userid);
$data = $this->getBotInfo($botId, $msg->userid);
if ($data) {
$list = DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at'])
@@ -353,7 +365,7 @@ class BotReceiveMsgTask extends AbstractTask
->orderByDesc('u.last_at')
->take(20)
->get()
->map(function($item) use ($data) {
->map(function ($item) use ($data) {
return WebSocketDialog::synthesizeData($item, $data->userid);
})
->all();
@@ -396,6 +408,7 @@ class BotReceiveMsgTask extends AbstractTask
default => '不支持的指令',
};
if ($type == '/api') {
$msgData['email'] = $botUser->email;
$msgData['version'] = Base::getVersion();
} elseif ($type == '/help') {
$msgData['manager'] = $isManager;
@@ -406,118 +419,136 @@ class BotReceiveMsgTask extends AbstractTask
}
/**
* 机器人处理 Webhook
* @param string $command
* @param WebSocketDialogMsg $msg
* @param User $botUser
* @param WebSocketDialog $dialog
* 处理机器人Webhook请求
* 根据机器人类型AI机器人或用户机器人发送相应的Webhook请求
*
* @param string $sendText 发送的文本内容
* @param string $replyText 回复的文本内容
* @param WebSocketDialogMsg $msg 消息对象
* @param WebSocketDialog $dialog 对话对象
* @param User $botUser 机器人用户对象
* @return void
*/
private function botWebhookBusiness(string $command, WebSocketDialogMsg $msg, User $botUser, WebSocketDialog $dialog)
private function handleWebhookRequest($sendText, $replyText, WebSocketDialogMsg $msg, WebSocketDialog $dialog, User $botUser)
{
$serverUrl = 'http://' . env('APP_IPPR') . '.3';
$userBot = null;
$extras = [];
$errorContent = null;
if ($botUser->isAiBot($type)) {
// AI机器人
$setting = Base::setting('aibotSetting');
$extras = [
'model_type' => match ($type) {
'qianwen' => 'qwen',
default => $type,
},
'model_name' => $setting[$type . '_model'],
'system_message' => $setting[$type . '_system'],
'api_key' => $setting[$type . '_key'],
'base_url' => $setting[$type . '_base_url'],
'agency' => $setting[$type . '_agency'],
'server_url' => $serverUrl,
];
if ($setting[$type . '_temperature']) {
$extras['temperature'] = floatval($setting[$type . '_temperature']);
}
if ($msg->msg['model_name']) {
$extras['model_name'] = $msg->msg['model_name'];
}
if (preg_match("/(.*?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/", $extras['model_name'], $match)) {
$extras['model_name'] = $match[1];
$extras['max_tokens'] = 20000;
$extras['thinking'] = 4096;
$extras['temperature'] = 1.0;
}
if ($dialog->session_id) {
$extras['context_key'] = 'session_' . $dialog->session_id;
}
if ($type === 'wenxin') {
$extras['api_key'] .= ':' . $setting['wenxin_secret'];
}
if ($type === 'ollama') {
if (empty($extras['base_url'])) {
$errorContent = '机器人未启用。';
}
if (empty($extras['api_key'])) {
$extras['api_key'] = Base::strRandom(6);
}
}
if (empty($extras['api_key'])) {
$errorContent = '机器人未启用。';
}
if (in_array($this->client['platform'], ['win', 'mac', 'web']) && !Base::judgeClientVersion("0.41.11", $this->client['version'])) {
$errorContent = '当前客户端版本低所需版本≥v0.41.11)。';
}
$webhookUrl = null;
$extras = ['timestamp' => time()];
if ($msg->reply_id > 0) {
$replyMsg = WebSocketDialogMsg::find($msg->reply_id);
$replyCommand = null;
if ($replyMsg) {
switch ($replyMsg->type) {
case 'text':
$replyCommand = $this->extractCommand($replyMsg, true);
if ($replyCommand) {
$replyCommand = Base::cutStr($replyCommand, 20000);
}
break;
case 'file':
$msgData = Base::json2array($replyMsg->getRawOriginal('msg'));
$replyCommand = TextExtractor::getFileContent(public_path($msgData['path']));
break;
try {
if ($botUser->isAiBot($type)) {
// AI机器人
if (Base::val($msg->msg, 'forward_data.leave')) {
// AI机器人不处理带有留言的转发消息因为他要处理那条留言消息
return;
}
if (in_array($this->client['platform'], ['win', 'mac', 'web']) && !Base::judgeClientVersion("0.41.11", $this->client['version'])) {
throw new Exception('当前客户端版本低所需版本≥v0.41.11)。');
}
if (!Apps::isInstalled('ai')) {
throw new Exception('应用「AI Robot」未安装');
}
// 整理机器人参数
$setting = Base::setting('aibotSetting');
$extras = [
'model_type' => match ($type) {
'qianwen' => 'qwen',
default => $type,
},
'model_name' => $setting[$type . '_model'],
'system_message' => $setting[$type . '_system'],
'api_key' => $setting[$type . '_key'],
'base_url' => $setting[$type . '_base_url'],
'agency' => $setting[$type . '_agency'],
'server_url' => 'http://nginx',
];
if ($setting[$type . '_temperature']) {
$extras['temperature'] = floatval($setting[$type . '_temperature']);
}
if ($msg->msg['model_name']) {
$extras['model_name'] = $msg->msg['model_name'];
}
// 提取模型“思考”参数
$thinkPatterns = [
"/^(.+?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/",
"/^(.+?)\s*\(\s*(think|thinking|reasoning)\s*\)\s*$/"
];
$thinkMatch = [];
foreach ($thinkPatterns as $pattern) {
if (preg_match($pattern, $extras['model_name'], $thinkMatch)) {
break;
}
}
if ($replyCommand) {
$command = <<<EOF
if ($thinkMatch && !empty($thinkMatch[1])) {
$extras['model_name'] = $thinkMatch[1];
$extras['max_tokens'] = 20000;
$extras['thinking'] = 4096;
$extras['temperature'] = 1.0;
}
// 设定会话ID
if ($dialog->session_id) {
$extras['context_key'] = 'session_' . $dialog->session_id;
}
// 设置文心一言的API密钥
if ($type === 'wenxin') {
$extras['api_key'] .= ':' . $setting['wenxin_secret'];
}
if ($type === 'ollama') {
if (empty($extras['base_url'])) {
throw new Exception('机器人未启用。');
}
if (empty($extras['api_key'])) {
$extras['api_key'] = Base::strRandom(6);
}
}
if (empty($extras['api_key'])) {
throw new Exception('机器人未启用。');
}
$this->generateSystemPromptForAI($msg->userid, $dialog, $extras);
// 转换提及格式
$sendText = self::convertMentionForAI($sendText);
$replyText = self::convertMentionForAI($replyText);
if ($replyText) {
$sendText = <<<EOF
<quoted_content>
{$replyCommand}
{$replyText}
</quoted_content>
The content within the above quoted_content tags is a citation.
{$command}
{$sendText}
EOF;
}
$webhookUrl = "http://nginx/ai/chat";
} else {
// 用户机器人
if ($botUser->isUserBot() && str_starts_with($sendText, '/')) {
// 用户机器人不处理指令类型命令
return;
}
$userBot = UserBot::whereBotId($botUser->userid)->first();
if ($userBot) {
$userBot->webhook_num++;
$userBot->save();
$webhookUrl = $userBot->webhook_url;
}
}
$this->AIGenerateSystemMessage($msg->userid, $dialog, $extras);
$webhookUrl = "{$serverUrl}/ai/chat";
} else {
// 用户机器人
$userBot = UserBot::whereBotId($botUser->userid)->first();
$webhookUrl = $userBot?->webhook_url;
}
if ($errorContent) {
if (!preg_match("/^https?:\/\//", $webhookUrl)) {
return;
}
} catch (\Exception $e) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'template', [
'type' => 'content',
'content' => $errorContent,
'content' => $e->getMessage(),
], $botUser->userid, false, false, true); // todo 未能在任务end事件来发送任务
return;
}
if (!preg_match("/^https?:\/\//", $webhookUrl)) {
return;
}
//
try {
$data = [
'text' => $command,
'text' => $sendText,
'reply_text' => $replyText,
'token' => User::generateToken($botUser),
'session_id' => $dialog->session_id,
'dialog_id' => $dialog->id,
'dialog_type' => $dialog->type,
'msg_id' => $msg->id,
@@ -527,14 +558,25 @@ class BotReceiveMsgTask extends AbstractTask
'version' => Base::getVersion(),
'extras' => Base::array2json($extras)
];
$res = Ihttp::ihttp_post($webhookUrl, $data, 30);
if ($userBot) {
$userBot->webhook_num++;
$userBot->save();
// 添加用户信息
$userInfo = User::find($msg->userid);
if ($userInfo) {
$data['msg_user'] = [
'userid' => $userInfo->userid,
'email' => $userInfo->email,
'nickname' => $userInfo->nickname,
'profession' => $userInfo->profession,
'lang' => $userInfo->lang,
'token' => User::generateTokenNoDevice($userInfo, now()->addHour()),
];
}
if ($res['data'] && $data = Base::json2array($res['data'])) {
// 请求Webhook
$result = Ihttp::ihttp_post($webhookUrl, $data, 30);
if ($result['data'] && $data = Base::json2array($result['data'])) {
if ($data['code'] != 200 && $data['message']) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', ['text' => $res['data']['message']], $botUser->userid, false, false, true);
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', [
'text' => $result['data']['message']
], $botUser->userid, false, false, true);
}
}
} catch (\Throwable $th) {
@@ -549,139 +591,212 @@ class BotReceiveMsgTask extends AbstractTask
}
/**
* 获取机器人信息
* @param $botId
* @param $userid
* @return User
* 提取消息内容
* 根据消息类型(文件、文本等)提取相应的内容文本
*
* @param WebSocketDialogMsg $msg 消息对象
* @return string 提取出的消息文本内容
*/
private function botOne($botId, $userid)
private function extractMessageContent(WebSocketDialogMsg $msg)
{
$botId = intval($botId);
$userid = intval($userid);
if ($botId > 0) {
return User::select([
'users.*',
'user_bots.clear_day',
'user_bots.clear_at',
'user_bots.webhook_url',
'user_bots.webhook_num'
])
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
->where('users.bot', 1)
->where('user_bots.bot_id', $botId)
->where('user_bots.userid', $userid)
->first();
}
return null;
}
$reserves = [];
switch ($msg->type) {
case "file":
// 提取文件消息
$msgData = Base::json2array($msg->getRawOriginal('msg'));
$result = $this->convertMentionFormat("path", $msgData['path'], $msgData['name'], $reserves);
break;
/**
* 提取消息指令(提取消息内容)
* @param WebSocketDialogMsg $msg
* @param bool $isAiBot
* @param bool $mention
* @return string
*/
private function extractCommand(WebSocketDialogMsg $msg, bool $isAiBot = false, bool $mention = false)
{
if ($msg->type !== 'text') {
return '';
}
$original = $msg->msg['text'] ?: '';
if ($mention) {
$original = preg_replace("/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/", "", $original);
}
if (preg_match("/<span[^>]*?data-quick-key=([\"'])([^\"']+?)\\1[^>]*?>(.*?)<\/span>/is", $original, $match)) {
$command = $match[2];
if (str_starts_with($command, '%3A.')) {
$command = ":" . substr($command, 4);
}
return $command;
}
if (!$isAiBot) {
return trim(strip_tags($original));
}
$contents = [];
// 任务
if (preg_match_all("/<span class=\"mention task\" data-id=\"(\d+)\">(.*?)<\/span>/", $original, $match)) {
$taskIds = Base::newIntval($match[1]);
foreach ($taskIds as $index => $taskId) {
$taskName = addslashes($match[2][$index]) . " (ID:{$taskId})";
$taskContext = "任务状态:不存在或已删除";
$taskInfo = ProjectTask::with(['content'])->whereId($taskId)->first();
if ($taskInfo) {
$taskName = addslashes($taskInfo->name) . " (ID:{$taskId})";
$taskContext = implode("\n", $taskInfo->AIContext());
case "text":
// 提取文本消息
$result = $msg->msg['text'] ?: '';
if (empty($result)) {
return '';
}
$contents[] = "<task_content path=\"{$taskName}\">\n{$taskContext}\n</task_content>";
$original = str_replace($match[0][$index], "'{$taskName}' (see below for task_content tag)", $original);
}
}
// 文件、报告
if (preg_match_all("/<a class=\"mention ([^'\"]*)\" href=\"([^\"']+?)\"[^>]*?>[~%]([^>]*)<\/a>/", $original, $match)) {
$urlPaths = $match[2];
foreach ($urlPaths as $index => $urlPath) {
$pathTag = null;
$pathName = null;
$pathContent = null;
// 文件
if (preg_match("/single\/file\/(.*?)$/", $urlPath, $fileMatch)) {
$pathTag = "file_content";
$pathName = addslashes($match[3][$index]);
$pathContent = "文件状态:不存在或已删除";
$fileInfo = FileContent::idOrCodeToContent($fileMatch[1]);
if ($fileInfo && isset($fileInfo->content['url'])) {
$urlPath = public_path($fileInfo->content['url']);
if (file_exists($urlPath)) {
$pathName .= " (ID:{$fileInfo->id})";
$pathContent = TextExtractor::getFileContent($urlPath);
// 提取快捷键
if (preg_match("/<span[^>]*?data-quick-key=([\"'])([^\"']+?)\\1[^>]*?>(.*?)<\/span>/is", $result, $match)) {
$command = $match[2] ?? '';
$command = preg_replace("/^%3A\.?/", ":", $command);
$command = trim($command);
if ($command) {
return $command;
}
}
// 提及任务、文件、报告
$result = preg_replace_callback_array([
// 用户
"/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/" => function () {
return "";
},
// 任务
"/<span class=\"mention task\" data-id=\"(\d+)\">#?(.*?)<\/span>/" => function ($match) use (&$reserves) {
return $this->convertMentionFormat("task", $match[1], $match[2], $reserves);
},
// 文件
"/<a class=\"mention file\" href=\"([^\"']+?)\"[^>]*?>~?(.*?)<\/a>/" => function ($match) use (&$reserves) {
if (preg_match("/single\/file\/(.*?)$/", $match[1], $subMatch)) {
return $this->convertMentionFormat("file", $subMatch[1], $match[2], $reserves);
}
}
return "";
},
// 报告
"/<a class=\"mention report\" href=\"([^\"']+?)\"[^>]*?>%?(.*?)<\/a>/" => function ($match) use (&$reserves) {
if (preg_match("/single\/report\/detail\/(.*?)$/", $match[1], $subMatch)) {
return $this->convertMentionFormat("report", $subMatch[1], $match[2], $reserves);
}
return "";
},
], $result);
// 转成 markdown
if ($msg->msg['type'] !== 'md') {
$result = Base::html2markdown($result);
}
// 报告
elseif (preg_match("/single\/report\/detail\/(.*?)$/", $urlPath, $reportMatch)) {
$pathTag = "report_content";
$pathName = addslashes($match[3][$index]);
$pathContent = "报告状态:不存在或已删除";
$reportInfo = Report::idOrCodeToContent($reportMatch[1]);
if ($reportInfo) {
$pathName .= " (ID:{$reportInfo->id})";
$pathContent = $reportInfo->content;
}
}
if ($pathTag) {
$contents[] = "<{$pathTag} path=\"{$pathName}\">\n{$pathContent}\n</{$pathTag}>";
$original = str_replace($match[0][$index], "'{$pathName}' (see below for {$pathTag} tag)", $original);
}
}
break;
default:
// 其他类型消息不处理
return '';
}
if ($msg->msg['type'] !== 'md') {
// 转换为Markdown
try {
$converter = new HtmlConverter();
$original = $converter->convert($original);
} catch (\Exception) { }
// 处理 reserves
foreach ($reserves as $rand => $mention) {
$result = str_replace($rand, $mention, $result);
}
if ($contents) {
// 添加tag内容
$original .= "\n\n" . implode("\n\n", $contents);
}
return $original ?: '';
return $result;
}
/**
* 生成AI系统提示词
* @param int|null $userid
* @param WebSocketDialog $dialog
* @param array $extras
* 转换提及消息格式
* 将提及的任务、文件、报告等转换为统一的格式 [type#key#name]
*
* @param string $type 提及类型task、file、report、path
* @param string $key 提及对象的唯一标识
* @param string $name 提及对象的显示名称
* @return string 格式化后的提及字符串
*/
private function convertMentionFormat($type, $key, $name, &$reserves)
{
$key = str_replace(['#', '-->'], '', $key);
$name = str_replace(['#', '-->'], '', $name);
$rand = Base::generatePassword(12);
$reserves[$rand] = "<!--{$type}#{$key}#{$name}-->";
return $rand;
}
/**
* 为AI机器人转换提及消息格式
* 将提及的任务、文件、报告转换为AI可理解的格式并提取相关内容
*
* @param string $original 原始消息文本
* @return string 转换后的消息文本,包含相关内容的标签
* @throws Exception 当提及的对象不存在或读取失败时抛出异常
*/
public static function convertMentionForAI($original)
{
$array = [];
$original = preg_replace_callback('/<!--(.*?)#(.*?)#(.*?)-->/', function ($match) use (&$array) {
// 初始化 tag 内容
$pathTag = null;
$pathName = null;
$pathContent = null;
// 根据 type 提取 tag 内容
switch ($match[1]) {
// 任务
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereId(intval($match[2]))->first();
if (!$taskInfo) {
throw new Exception("任务不存在或已被删除");
}
$pathTag = "task_content";
$pathName = addslashes($taskInfo->name) . " (ID:{$taskInfo->id})";
$pathContent = implode("\n", $taskInfo->AIContext());
break;
// 文件
case 'file':
$fileInfo = FileContent::idOrCodeToContent($match[2]);
if (!$fileInfo || !isset($fileInfo->content['url'])) {
throw new Exception("文件不存在或已被删除");
}
$urlPath = public_path($fileInfo->content['url']);
if (!file_exists($urlPath)) {
throw new Exception("文件不存在或已被删除");
}
$fileResult = TextExtractor::extractFile($urlPath);
if (Base::isError($fileResult)) {
throw new Exception("文件读取失败:" . $fileResult['msg']);
}
$pathTag = "file_content";
$pathName = addslashes($match[3]) . " (ID:{$fileInfo->id})";
$pathContent = $fileResult['data'];
break;
// 文件路径
case 'path':
$urlPath = public_path($match[2]);
if (!file_exists($urlPath)) {
throw new Exception("文件不存在或已被删除");
}
$fileResult = TextExtractor::extractFile($urlPath);
if (Base::isError($fileResult)) {
throw new Exception("文件读取失败:" . $fileResult['msg']);
}
$pathTag = "file_content";
$pathName = addslashes($match[3]);
$pathContent = $fileResult['data'];
break;
// 报告
case 'report':
$reportInfo = Report::idOrCodeToContent($match[2]);
if (!$reportInfo) {
throw new Exception("报告不存在或已被删除");
}
$pathTag = "report_content";
$pathName = addslashes($match[3]) . " (ID:{$reportInfo->id})";
$pathContent = Base::html2markdown($reportInfo->content);
break;
}
// 如果提取到 tag 内容,则添加到 contents 数组中
if ($pathTag) {
$array[] = "<{$pathTag} path=\"{$pathName}\">\n{$pathContent}\n</{$pathTag}>";
return "`{$pathName}` (see below for {$pathTag} tag)";
}
return "";
}, $original);
// 添加 tag 内容
if ($array) {
$original .= "\n\n" . implode("\n\n", $array);
}
return $original;
}
/**
* 为AI机器人生成系统提示词
* 根据对话类型(用户对话、项目群、任务群、部门群等)生成相应的系统提示词
*
* @param int|null $userid 用户ID
* @param WebSocketDialog $dialog 对话对象
* @param array $extras 额外参数数组通过引用传递以修改system_message
* @return void
*/
private function AIGenerateSystemMessage(int|null $userid, WebSocketDialog $dialog, array &$extras)
private function generateSystemPromptForAI($userid, WebSocketDialog $dialog, array &$extras)
{
$system_messages = [];
switch ($dialog->type) {
// 用户对话
case "user":
$aiPrompt = WebSocketDialogConfig::where([
'dialog_id' => $dialog->id,
@@ -692,10 +807,13 @@ class BotReceiveMsgTask extends AbstractTask
$extras['system_message'] = $aiPrompt;
}
break;
// 群组对话
case "group":
switch ($dialog->group_type) {
// 用户群
case 'user':
break;
// 项目群
case 'project':
$projectInfo = Project::whereDialogId($dialog->id)->first();
if ($projectInfo) {
@@ -718,6 +836,7 @@ class BotReceiveMsgTask extends AbstractTask
EOF;
}
break;
// 任务群
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
if ($taskInfo) {
@@ -737,12 +856,14 @@ class BotReceiveMsgTask extends AbstractTask
EOF;
}
break;
// 部门群
case 'department':
$userDepartment = UserDepartment::whereDialogId($dialog->id)->first();
if ($userDepartment) {
$system_messages[] = "当前我在【{$userDepartment->name}】的部门群聊中";
}
break;
// 全体成员群
case 'all':
$system_messages[] = "当前我在【全体成员】的群聊中";
break;
@@ -753,7 +874,36 @@ class BotReceiveMsgTask extends AbstractTask
array_unshift($system_messages, $extras['system_message']);
}
if ($system_messages) {
$extras['system_message'] = implode("\n\n====\n\n", Base::newTrim($system_messages));
$extras['system_message'] = implode("\n\n----------------\n\n", Base::newTrim($system_messages));
}
}
/**
* 获取机器人信息
* 根据机器人ID和用户ID获取机器人的详细信息包括清理设置和webhook配置
*
* @param int $botId 机器人ID
* @param int $userid 用户ID
* @return User|null 机器人用户对象如果不存在则返回null
*/
private function getBotInfo($botId, $userid)
{
$botId = intval($botId);
$userid = intval($userid);
if ($botId > 0) {
return User::select([
'users.*',
'user_bots.clear_day',
'user_bots.clear_at',
'user_bots.webhook_url',
'user_bots.webhook_num'
])
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
->where('users.bot', 1)
->where('user_bots.bot_id', $botId)
->where('user_bots.userid', $userid)
->first();
}
return null;
}
}

View File

@@ -13,8 +13,6 @@ use App\Module\Timer;
use Cache;
use Carbon\Carbon;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
class CheckinRemindTask extends AbstractTask
{
public function __construct()

View File

@@ -9,8 +9,6 @@ use Carbon\Carbon;
use App\Models\WebSocketDialogMsg;
use Illuminate\Support\Facades\Cache;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
class CloseMeetingRoomTask extends AbstractTask
{
public function __construct()

View File

@@ -2,8 +2,6 @@
namespace App\Tasks;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Models\UserBot;
use App\Models\WebSocketDialogMsg;
use Carbon\Carbon;

View File

@@ -5,6 +5,7 @@ namespace App\Tasks;
use App\Models\File;
use App\Models\TaskWorker;
use App\Models\Tmp;
use App\Models\UserDevice;
use App\Models\WebSocketTmpMsg;
use App\Module\Base;
use Carbon\Carbon;
@@ -33,89 +34,75 @@ class DeleteTmpTask extends AbstractTask
public function start()
{
switch ($this->data) {
/**
* 表pre_tmp_msgs
*/
case 'wg_tmp_msgs':
{
WebSocketTmpMsg::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($msgs) {
/** @var WebSocketTmpMsg $msg */
foreach ($msgs as $msg) {
$msg->delete();
}
});
}
break;
/**
* 表pre_tmp
*/
case 'tmp':
{
Tmp::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($tmps) {
/** @var Tmp $tmp */
foreach ($tmps as $tmp) {
$tmp->delete();
}
});
}
break;
/**
* 表pre_task_worker
*/
case 'task_worker':
{
TaskWorker::onlyTrashed()
->where('deleted_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->forceDelete();
}
break;
/**
* 表pre_file
*/
case 'file':
{
$day = intval(env("AUTO_EMPTY_FILE_RECYCLE", 365));
if ($day <= 0) {
return;
}
File::onlyTrashed()
->where('deleted_at', '<', Carbon::now()->subHours($day))
->orderBy('id')
->chunk(500, function ($files) {
/** @var File $file */
foreach ($files as $file) {
$file->forceDeleteFile();
}
});
}
break;
/**
* tmp_file 删除临时文件
*/
case 'tmp_file':
{
$day = intval(env("AUTO_EMPTY_TEMP_FILE", 30));
if ($day <= 0) {
return;
}
$files = Base::recursiveFiles(public_path('uploads/tmp'));
foreach ($files as $file) {
$time = @filemtime($file);
if ($time && $time < time() - 3600 * 24 * $day) {
unlink($file);
case 'tmp_msgs':
WebSocketTmpMsg::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($msgs) {
/** @var WebSocketTmpMsg $msg */
foreach ($msgs as $msg) {
$msg->delete();
}
});
break;
case 'tmp':
Tmp::where('created_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($tmps) {
/** @var Tmp $tmp */
foreach ($tmps as $tmp) {
$tmp->delete();
}
});
break;
case 'task_worker':
TaskWorker::onlyTrashed()
->where('deleted_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->forceDelete();
break;
case 'file':
$day = intval(env("AUTO_EMPTY_FILE_RECYCLE", 365));
if ($day <= 0) {
return;
}
File::onlyTrashed()
->where('deleted_at', '<', Carbon::now()->subHours($day))
->orderBy('id')
->chunk(500, function ($files) {
/** @var File $file */
foreach ($files as $file) {
$file->forceDeleteFile();
}
});
break;
case 'tmp_file':
$day = intval(env("AUTO_EMPTY_TEMP_FILE", 30));
if ($day <= 0) {
return;
}
$files = Base::recursiveFiles(public_path('uploads/tmp'));
foreach ($files as $file) {
$time = @filemtime($file);
if ($time && $time < time() - 3600 * 24 * $day) {
unlink($file);
}
}
break;
case 'user_device':
UserDevice::where('expired_at', '<', Carbon::now()->subHours($this->hours))
->orderBy('id')
->chunk(500, function ($devices) {
/** @var UserDevice $device */
foreach ($devices as $device) {
UserDevice::forget($device);
}
});
break;
}
}

View File

@@ -1,39 +0,0 @@
<?php
namespace App\Tasks;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* 同步聊天数据到Elasticsearch
*/
class ElasticSearchSyncTask extends AbstractTask
{
public function __construct()
{
parent::__construct();
}
public function start()
{
// 120分钟执行一次
$time = intval(Cache::get("ElasticSearchSyncTask:Time"));
if (time() - $time < 120 * 60) {
return;
}
// 执行开始120分钟后缓存标记失效
Cache::put("ElasticSearchSyncTask:Time", time(), Carbon::now()->addMinutes(120));
// 开始执行同步
@shell_exec("php /var/www/artisan elasticsearch:sync-dialog-user-msg --i");
// 执行完成5分钟后缓存标记失效5分钟任务可重复执行
Cache::put("ElasticSearchSyncTask:Time", time(), Carbon::now()->addMinutes(5));
}
public function end()
{
}
}

View File

@@ -1,8 +1,6 @@
<?php
namespace App\Tasks;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Models\Setting;
use App\Models\User;
use App\Models\WebSocketDialogMsg;

View File

@@ -2,14 +2,11 @@
namespace App\Tasks;
use App\Module\AI;
use App\Module\Base;
use App\Module\Extranet;
use Cache;
use Carbon\Carbon;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
/**
* 获取笑话、心灵鸡汤
*
@@ -29,25 +26,27 @@ class JokeSoupTask extends AbstractTask
public function start()
{
// 判断每分钟执行一次
if (Cache::get(self::keyName("YmdHi")) == date("YmdHi")) {
// 判断每小时执行一次
if (Cache::get(self::keyName("YmdH")) == date("YmdH")) {
return;
}
Cache::put(self::keyName("YmdHi"), date("YmdHi"), Carbon::now()->addDay());
//
$array = Base::json2array(Cache::get(self::keyName("jokes")));
$data = Extranet::randJoke();
if ($data) {
$array[] = $data;
Cache::put(self::keyName("YmdH"), date("YmdH"), Carbon::now()->addDay());
// 开始生成笑话和心灵鸡汤
$result = AI::generateJokeAndSoup();
if (Base::isError($result)) {
Cache::forget(self::keyName("YmdH"));
return;
}
Cache::forever(self::keyName("jokes"), Base::array2json(array_slice($array, -200)));
//
$array = Base::json2array(Cache::get(self::keyName("soups")));
$data = Extranet::soups();
if ($data) {
$array[] = $data;
// 笑话和心灵鸡汤的缓存
foreach (['jokes', 'soups'] as $key) {
if ($result['data'][$key] && is_array($result['data'][$key])) {
$array = Base::json2array(Cache::get(self::keyName($key)));
$array = array_merge($array, $result['data'][$key]);
Cache::forever(self::keyName($key), Base::array2json(array_slice($array, -200)));
}
}
Cache::forever(self::keyName("soups"), Base::array2json(array_slice($array, -200)));
}
public function end()

View File

@@ -4,9 +4,6 @@ namespace App\Tasks;
use App\Models\WebSocket;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
/**
* 上线、离线通知
* Class LineTask

View File

@@ -8,9 +8,6 @@ use App\Models\ProjectTask;
use App\Models\ProjectTaskUser;
use Carbon\Carbon;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
/**
* 任务重复周期
*/
@@ -29,6 +26,10 @@ class LoopTask extends AbstractTask
])->chunkById(100, function ($list) {
/** @var ProjectTask $item */
foreach ($list as $item) {
if ($item->parent_id > 0) {
// 如果是子任务则不处理
continue;
}
try {
$task = $item->copyTask();
// 工作流
@@ -39,7 +40,7 @@ class LoopTask extends AbstractTask
foreach ($projectFlowItem as $flowItem) {
if ($flowItem->status == 'start') {
$task->flow_item_id = $flowItem->id;
$task->flow_item_name = $flowItem->status . "|" . $flowItem->name;
$task->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
if ($flowItem->userids) {
$userids = array_values(array_unique($flowItem->userids));
foreach ($userids as $uid) {
@@ -63,6 +64,18 @@ class LoopTask extends AbstractTask
$task->start_at = Carbon::parse($task->loop_at);
$task->end_at = $task->start_at->clone()->addSeconds($diffSecond);
}
// 处理子任务
$subTasks = ProjectTask::whereParentId($item->id)->get();
if (!$subTasks->isEmpty()) {
foreach ($subTasks as $subTask) {
$newSubTask = $subTask->copyTask();
$newSubTask->parent_id = $task->id;
$newSubTask->start_at = $task->start_at;
$newSubTask->end_at = $task->end_at;
$newSubTask->save();
}
}
//
$task->refreshLoop(true);
$task->addLog("创建任务来自周期任务ID{$item->id}", [], $task->userid);
// 清空旧周期

View File

@@ -1,8 +1,6 @@
<?php
namespace App\Tasks;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Models\WebSocket;
use App\Models\WebSocketTmpMsg;
use App\Module\Base;

View File

@@ -1,8 +1,6 @@
<?php
namespace App\Tasks;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Models\UmengAlias;
use App\Module\Base;

View File

@@ -10,8 +10,6 @@ use Carbon\Carbon;
use App\Models\WebSocketDialogMsg;
use Illuminate\Support\Facades\Cache;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
class UnclaimedTaskRemindTask extends AbstractTask
{
public function __construct()

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Tasks;
use App\Models\WebSocketDialogSession;
use App\Module\AI;
use App\Module\Base;
/**
* 通过AI接口更新对话标题
*/
class UpdateSessionTitleViaAiTask extends AbstractTask
{
protected $sessionId;
protected $msgText;
public function __construct($sessionId, $msgText)
{
parent::__construct();
$this->sessionId = $sessionId;
$this->msgText = $msgText;
}
public function start()
{
if (empty($this->sessionId) || empty($this->msgText)) {
return;
}
$session = WebSocketDialogSession::whereId($this->sessionId)->first();
if (!$session) {
return;
}
$result = AI::generateTitle($this->msgText);
if (Base::isError($result)) {
return;
}
$newTitle = $result['data']['title'];
if ($newTitle && $newTitle != $session->title) {
$session->title = Base::cutStr($newTitle, 100);
$session->save();
}
}
public function end()
{
}
}

View File

@@ -2,8 +2,6 @@
namespace App\Tasks;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
@@ -192,8 +190,10 @@ class WebSocketDialogMsgTask extends AbstractTask
$setting = Base::setting('appPushSetting');
if ($setting['push'] === 'open') {
$umengTitle = User::userid2nickname($msg->userid);
$umengBody = WebSocketDialogMsg::previewMsg($msg);
if ($dialog->type == 'group') {
$umengTitle = "{$dialog->getGroupName()} ($umengTitle)";
$umengBody = $umengTitle . ': ' . $umengBody;
$umengTitle = $dialog->getGroupName();
}
$langs = User::select(['userid', 'lang'])
->whereIn('userid', $umengUserid)
@@ -207,7 +207,7 @@ class WebSocketDialogMsgTask extends AbstractTask
Doo::setLanguage($lang);
$umengMsg = [
'title' => $umengTitle,
'body' => WebSocketDialogMsg::previewMsg($msg),
'body' => $umengBody,
'description' => "MID:{$msg->id}",
'seconds' => 3600,
'badge' => 1,

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Tasks;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Module\Apps;
use App\Module\ZincSearch\ZincSearchDialogMsg;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* 同步聊天数据到ZincSearch
*/
class ZincSearchSyncTask extends AbstractTask
{
private $action;
private $data;
public function __construct($action = null, $data = null)
{
parent::__construct(...func_get_args());
$this->action = $action;
$this->data = $data;
}
public function start()
{
if (!Apps::isInstalled("search")) {
// 如果没有安装搜索模块,则不执行
return;
}
switch ($this->action) {
case 'sync':
// 同步消息数据
ZincSearchDialogMsg::sync(WebSocketDialogMsg::fillInstance($this->data));
break;
case 'delete':
// 删除消息数据
ZincSearchDialogMsg::delete(WebSocketDialogMsg::fillInstance($this->data));
break;
case 'userSync':
// 同步用户数据
ZincSearchDialogMsg::userSync(WebSocketDialogUser::fillInstance($this->data));
break;
case 'deleteUser':
// 删除用户数据
ZincSearchDialogMsg::delete(WebSocketDialogUser::fillInstance($this->data));
break;
default:
// 增量更新
$this->incrementalUpdate();
break;
}
}
/**
* 增量更新
* @return void
*/
private function incrementalUpdate()
{
// 120分钟执行一次
$time = intval(Cache::get("ZincSearchSyncTask:Time"));
if (time() - $time < 120 * 60) {
return;
}
// 执行开始120分钟后缓存标记失效
Cache::put("ZincSearchSyncTask:Time", time(), Carbon::now()->addMinutes(120));
// 开始执行同步
@shell_exec("php /var/www/artisan zinc:sync-user-msg --i");
// 执行完成5分钟后缓存标记失效5分钟任务可重复执行
Cache::put("ZincSearchSyncTask:Time", time(), Carbon::now()->addMinutes(5));
}
public function end()
{
}
}

View File

@@ -155,8 +155,8 @@ install() {
cat >${sitePath}/ssl.conf <<EOF
server_name ${domain};
listen 443 ssl;
ssl_certificate /etc/nginx/conf.d/site/ssl/${domain}.crt;
ssl_certificate_key /etc/nginx/conf.d/site/ssl/${domain}.key;
ssl_certificate /var/www/docker/nginx/site/ssl/${domain}.crt;
ssl_certificate_key /var/www/docker/nginx/site/ssl/${domain}.key;
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
ssl_prefer_server_ciphers on;

4
bin/version.js vendored

File diff suppressed because one or more lines are too long

790
cmd

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"php": "^8.0",
"ext-curl": "*",
"ext-dom": "*",
"ext-ffi": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-imagick": "*",
@@ -20,7 +21,6 @@
"ext-simplexml": "*",
"ext-zip": "*",
"directorytree/ldaprecord-laravel": "^2.7",
"elasticsearch/elasticsearch": "^8.17",
"fideloper/proxy": "^4.4.1",
"firebase/php-jwt": "^6.9",
"fruitcake/laravel-cors": "^2.0.4",
@@ -34,9 +34,11 @@
"league/html-to-markdown": "^5.1",
"maatwebsite/excel": "^3.1.31",
"madnest/madzipper": "^v1.1.0",
"matomo/device-detector": "^6.4",
"mews/captcha": "^3.2.6",
"orangehill/iseed": "^3.0.1",
"overtrue/pinyin": "^4.0",
"phpoffice/phppresentation": "^1.1",
"phpoffice/phpword": "^1.3",
"predis/predis": "^1.1.7",
"smalot/pdfparser": "^2.11",

855
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ class AddProjectsPersonal extends Migration
});
if ($isAdd) {
// 更新数据
\App\Models\Project::whereName('个人项目')->chunkById(100, function ($lists) {
\App\Models\Project::where('name','like', '%个人项目%')->chunkById(100, function ($lists) {
/** @var \App\Models\Project $item */
foreach ($lists as $item) {
if ($item->desc == '注册时系统自动创建项目,你可以自由删除。') {

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserDevicesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_devices'))
return;
Schema::create('user_devices', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->nullable()->default(0)->comment('会员ID');
$table->string('hash')->index()->nullable()->default('')->comment('TOKEN MD5');
$table->longText('detail')->nullable()->comment('详细信息');
$table->timestamp('expired_at')->nullable()->comment('过期时间');
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_devices');
}
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddDeviceHashToUmengAliasTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('umeng_alias', function (Blueprint $table) {
if (!Schema::hasColumn('umeng_alias', 'device_hash')) {
$table->string('device_hash')->index()->nullable()->after('device')->comment('设备哈希值用于关联UserDevice表');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('umeng_alias', function (Blueprint $table) {
$table->dropColumn('device_hash');
});
}
}

View File

@@ -0,0 +1,35 @@
<?php
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Module\Base;
use Illuminate\Database\Migrations\Migration;
class CreateAiSettingsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$setting = Base::setting('aibotSetting');
Base::setting('aiSetting', [
'ai_provider' => 'openai',
'ai_api_key' => $setting['openai_key'],
'ai_api_url' => $setting['openai_base_url'],
'ai_proxy' => $setting['openai_agency'],
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// This migration does not need to be reversible
}
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddColorToProjectFlowItemsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('project_flow_items', function (Blueprint $table) {
if (!Schema::hasColumn('project_flow_items', 'color')) {
$table->string('color', 20)->nullable()->default('')->after('status')->comment('自定义颜色');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('project_flow_items', function (Blueprint $table) {
$table->dropColumn('color');
});
}
}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class UpdateFilesNameLengthTo200 extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('files', function (Blueprint $table) {
$table->string('name', 255)->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('files', function (Blueprint $table) {
//
});
}
}

View File

@@ -1,17 +1,18 @@
services:
php:
container_name: "dootask-php-${APP_ID}"
image: "kuaifan/php:swoole-8.0.rc18"
image: "kuaifan/php:swoole-8.0.rc21"
shm_size: 2G
ulimits:
core:
soft: 0
hard: 0
volumes:
- shared_data:/usr/share/dootask
- ./docker/crontab/crontab.conf:/etc/supervisor/conf.d/crontab.conf
- ./docker/php/php.conf:/etc/supervisor/conf.d/php.conf
- ./docker/php/php.ini:/usr/local/etc/php/php.ini
- ./docker/log/supervisor:/var/log/supervisor
- ./docker/logs/supervisor:/var/log/supervisor
- ./:/var/www
environment:
LANG: "C.UTF-8"
@@ -21,13 +22,18 @@ services:
MYSQL_DB_NAME: "${DB_DATABASE}"
MYSQL_USERNAME: "${DB_USERNAME}"
MYSQL_PASSWORD: "${DB_PASSWORD}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${LARAVELS_LISTEN_PORT}/health"]
interval: 5s
timeout: 5s
retries: 5
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.2"
- extnetwork
depends_on:
- mariadb
- redis
- es
mariadb:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
nginx:
@@ -35,30 +41,34 @@ services:
image: "nginx:alpine"
ports:
- "${APP_PORT}:80"
- "${APP_SSL_PORT:-}:443"
- "${APP_SSL_PORT:-0}:443"
volumes:
- ./docker/nginx:/etc/nginx/conf.d
- ./public:/var/www/public
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./:/var/www
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 5s
timeout: 5s
retries: 5
depends_on:
php:
condition: service_healthy
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.3"
links:
- php
- office
- fileview
- drawio-webapp
- drawio-export
- minder
- okr
- ai
- extnetwork
restart: unless-stopped
redis:
container_name: "dootask-redis-${APP_ID}"
image: "redis:alpine"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.4"
- extnetwork
restart: unless-stopped
mariadb:
@@ -74,176 +84,40 @@ services:
MYSQL_DATABASE: "${DB_DATABASE}"
MYSQL_USER: "${DB_USERNAME}"
MYSQL_PASSWORD: "${DB_PASSWORD}"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${DB_USERNAME}", "-p${DB_PASSWORD}"]
interval: 5s
timeout: 5s
retries: 5
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.5"
- extnetwork
restart: unless-stopped
office:
container_name: "dootask-office-${APP_ID}"
image: "onlyoffice/documentserver:8.2.2.1"
appstore:
container_name: "dootask-appstore-${APP_ID}"
privileged: true
image: "dootask/appstore:0.2.5"
volumes:
- ./docker/office/logs:/var/log/onlyoffice
- ./docker/office/data:/var/www/onlyoffice/Data
- ./docker/office/etc/documentserver/default.json:/etc/onlyoffice/documentserver/default.json
- ./docker/office/resources/require.js:/var/www/onlyoffice/documentserver/web-apps/vendor/requirejs/require.js
- ./docker/office/resources/common/main/resources/img/header:/var/www/onlyoffice/documentserver/web-apps/apps/common/main/resources/img/header
- ./docker/office/resources/documenteditor/main/resources/css/app.css:/var/www/onlyoffice/documentserver/web-apps/apps/documenteditor/main/resources/css/app.css
- ./docker/office/resources/documenteditor/mobile/css/526.caf35c11a8d72ca5ac85.css:/var/www/onlyoffice/documentserver/web-apps/apps/documenteditor/mobile/css/526.caf35c11a8d72ca5ac85.css
- ./docker/office/resources/presentationeditor/main/resources/css/app.css:/var/www/onlyoffice/documentserver/web-apps/apps/presentationeditor/main/resources/css/app.css
- ./docker/office/resources/presentationeditor/mobile/css/923.f9cf19de1a25c2e7bf8b.css:/var/www/onlyoffice/documentserver/web-apps/apps/presentationeditor/mobile/css/923.f9cf19de1a25c2e7bf8b.css
- ./docker/office/resources/spreadsheeteditor/main/resources/css/app.css:/var/www/onlyoffice/documentserver/web-apps/apps/spreadsheeteditor/main/resources/css/app.css
- ./docker/office/resources/spreadsheeteditor/mobile/css/611.1bef49f175e18fc085db.css:/var/www/onlyoffice/documentserver/web-apps/apps/spreadsheeteditor/mobile/css/611.1bef49f175e18fc085db.css
- shared_data:/usr/share/dootask
- /var/run/docker.sock:/var/run/docker.sock
- ./:/var/www
environment:
JWT_SECRET: ${APP_KEY}
HOST_PWD: "${PWD}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 5s
timeout: 5s
retries: 5
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.6"
restart: unless-stopped
fileview:
container_name: "dootask-fileview-${APP_ID}"
image: "kuaifan/fileview:4.2.0-SNAPSHOT-RC25"
environment:
KK_CONTEXT_PATH: "/fileview"
KK_OFFICE_PREVIEW_SWITCH_DISABLED: true
KK_FILE_UPLOAD_ENABLED: true
KK_MEDIA: "mp3,wav,mp4,mov,avi,wmv"
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.7"
restart: unless-stopped
drawio-webapp:
container_name: "dootask-drawio-webapp-${APP_ID}"
image: "jgraph/drawio:24.7.17"
volumes:
- ./docker/drawio/webapp/index.html:/usr/local/tomcat/webapps/draw/index.html
- ./docker/drawio/webapp/stencils:/usr/local/tomcat/webapps/draw/stencils
- ./docker/drawio/webapp/js/app.min.js:/usr/local/tomcat/webapps/draw/js/app.min.js
- ./docker/drawio/webapp/js/croppie/croppie.min.css:/usr/local/tomcat/webapps/draw/js/croppie/croppie.min.css
- ./docker/drawio/webapp/js/diagramly/ElectronApp.js:/usr/local/tomcat/webapps/draw/js/diagramly/ElectronApp.js
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.8"
depends_on:
- drawio-export
restart: unless-stopped
drawio-export:
container_name: "dootask-drawio-export-${APP_ID}"
image: "kuaifan/export-server:0.0.1"
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.9"
volumes:
- ./docker/drawio/export/fonts:/usr/share/fonts/drawio
restart: unless-stopped
minder:
container_name: "dootask-minder-${APP_ID}"
image: "kuaifan/minder:0.1.3"
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.10"
restart: unless-stopped
approve:
container_name: "dootask-approve-${APP_ID}"
image: "kuaifan/dooapprove:0.1.5"
environment:
TZ: "${TIMEZONE:-PRC}"
MYSQL_HOST: "${DB_HOST}"
MYSQL_PORT: "${DB_PORT}"
MYSQL_DBNAME: "${DB_DATABASE}"
MYSQL_USERNAME: "${DB_USERNAME}"
MYSQL_PASSWORD: "${DB_PASSWORD}"
MYSQL_Prefix: "${DB_PREFIX}approve_"
DEMO_DATA: true
KEY: ${APP_KEY}
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.11"
depends_on:
- mariadb
restart: unless-stopped
ai:
container_name: "dootask-ai-${APP_ID}"
image: "kuaifan/dootask-ai:0.3.5"
environment:
REDIS_HOST: "${REDIS_HOST}"
REDIS_PORT: "${REDIS_PORT}"
TIMEOUT: 600
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.12"
depends_on:
- redis
restart: unless-stopped
okr:
container_name: "dootask-okr-${APP_ID}"
image: "kuaifan/doookr:0.4.5"
environment:
TZ: "${TIMEZONE:-PRC}"
DOO_TASK_URL: "http://${APP_IPPR}.3"
MYSQL_HOST: "${DB_HOST}"
MYSQL_PORT: "${DB_PORT}"
MYSQL_DBNAME: "${DB_DATABASE}"
MYSQL_USERNAME: "${DB_USERNAME}"
MYSQL_PASSWORD: "${DB_PASSWORD}"
MYSQL_PREFIX: "${DB_PREFIX}"
DEMO_DATA: true
KEY: ${APP_KEY}
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.13"
depends_on:
- mariadb
restart: unless-stopped
face:
container_name: "dootask-face-${APP_ID}"
image: "hitosea2020/dooface:0.0.1"
ports:
- "7788:7788"
environment:
TZ: "${TIMEZONE:-PRC}"
STORAGE: mysql
MYSQL_HOST: "${DB_HOST}"
MYSQL_PORT: "${DB_PORT}"
MYSQL_USERNAME: "${DB_USERNAME}"
MYSQL_PASSWORD: "${DB_PASSWORD}"
MYSQL_DB_NAME: "${DB_DATABASE}"
DB_PREFIX: "${DB_PREFIX}"
REPORT_API: "http://${APP_IPPR}.3:80/api/public/checkin/report"
depends_on:
- mariadb
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.14"
restart: unless-stopped
es:
container_name: "dootask-es-${APP_ID}"
image: "elasticsearch:8.17.2"
volumes:
- ./docker/es/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
- ./docker/es/data:/usr/share/elasticsearch/data
environment:
discovery.type: single-node
xpack.security.enabled: false
ES_JAVA_OPTS: "-Xms1g -Xmx1g"
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.15"
- extnetwork
restart: unless-stopped
networks:
extnetwork:
name: "dootask-networks-${APP_ID}"
ipam:
config:
- subnet: "${APP_IPPR}.0/24"
gateway: "${APP_IPPR}.1"
volumes:
shared_data:
name: "dootask-shared-data-${APP_ID}"
redis_data:
name: "dootask-redis-data-${APP_ID}"

5
docker/appstore/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
apps/*
config/*
log/*
temp/*
!.gitkeep

View File

View File

View File

View File

@@ -1 +0,0 @@

View File

@@ -1,3 +0,0 @@
# Change
diff https://github.com/jgraph/drawio/tree/acd938b1e42cff3be3b629e6239cdec9a9baddcc

View File

@@ -1,468 +0,0 @@
<!--[if IE]><meta http-equiv="X-UA-Compatible" content="IE=5" ><![endif]-->
<!DOCTYPE html>
<html>
<head>
<title>Flowchart Maker &amp; Online Diagram Software</title>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="Description" content="draw.io is free online diagram software for making flowcharts, process diagrams, org charts, UML, ER and network diagrams">
<meta name="Keywords" content="drawio, diagram, online, flow chart, flowchart maker, uml, erd">
<meta itemprop="name" content="draw.io - free flowchart maker and diagrams online">
<meta itemprop="description" content="draw.io is a free online diagramming application and flowchart maker . You can use it to create UML, entity relationship,
org charts, BPMN and BPM, database schema and networks. Also possible are telecommunication network, workflow, flowcharts, maps overlays and GIS, electronic
circuit and social network diagrams.">
<meta itemprop="image" content="https://lh4.googleusercontent.com/-cLKEldMbT_E/Tx8qXDuw6eI/AAAAAAAAAAs/Ke0pnlk8Gpg/w500-h344-k/BPMN%2Bdiagram%2Brc2f.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="msapplication-config" content="images/browserconfig.xml">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#d89000">
<script id="geBootstrap" type="text/javascript">
window.EXPORT_URL = window.location.origin + "/drawio/export/";
window.DRAWIO_LIGHTBOX_URL = window.location.origin + "/drawio/webapp";
setInterval(function() {window.ICONSEARCH_PATH = window.location.origin + "/drawio/iconsearch";}, 1000)
/**
* URL Parameters and protocol description are here:
*
* https://www.drawio.com/doc/faq/supported-url-parameters
*
* Parameters for developers:
*
* - dev=1: For developers only
* - test=1: For developers only
* - export=URL for export: For developers only
* - ignoremime=1: For developers only (see DriveClient.js). Use Cmd-S to override mime.
* - createindex=1: For developers only (see etc/build/README)
* - filesupport=0: For developers only (see Editor.js in core)
* - savesidebar=1: For developers only (see Sidebar.js)
* - pages=1: For developers only (see Pages.js)
* - lic=email: For developers only (see LicenseServlet.java)
* --
* - networkshapes=1: For testing network shapes (temporary)
*/
var urlParams = (function()
{
var result = new Object();
var params = window.location.search.slice(1).split('&');
for (var i = 0; i < params.length; i++)
{
var idx = params[i].indexOf('=');
if (idx > 0)
{
result[params[i].substring(0, idx)] = params[i].substring(idx + 1);
}
}
return result;
})();
// Forces CDN caches by passing URL parameters via URL hash
if (window.location.hash != null && window.location.hash.substring(0, 2) == '#P')
{
try
{
urlParams = JSON.parse(decodeURIComponent(window.location.hash.substring(2)));
if (urlParams.hash != null)
{
window.location.hash = urlParams.hash;
}
}
catch (e)
{
// ignore
}
}
// Global variable for desktop
var mxIsElectron = navigator.userAgent != null && navigator.userAgent.toLowerCase().indexOf(' electron/') > -1 &&
navigator.userAgent.indexOf(' draw.io/') > -1;
// Redirects page if required
if (urlParams['dev'] != '1')
{
(function()
{
var proto = window.location.protocol;
if (!mxIsElectron)
{
var host = window.location.host;
// Redirects apex, drive and rt to www
if (host === 'draw.io' || host === 'rt.draw.io' || host === 'drive.draw.io')
{
host = 'www.draw.io';
}
var href = proto + '//' + host + window.location.href.substring(
window.location.protocol.length +
window.location.host.length + 2);
// Redirects if href changes
if (href != window.location.href)
{
window.location.href = href;
}
}
})();
}
/**
* Adds meta tag to the page.
*/
function mxmeta(name, content, httpEquiv)
{
try
{
var s = document.createElement('meta');
if (name != null)
{
s.setAttribute('name', name);
}
s.setAttribute('content', content);
if (httpEquiv != null)
{
s.setAttribute('http-equiv', httpEquiv);
}
var t = document.getElementsByTagName('meta')[0];
t.parentNode.insertBefore(s, t);
}
catch (e)
{
// ignore
}
};
/**
* Synchronously adds scripts to the page.
*/
function mxscript(src, onLoad, id, dataAppKey, noWrite, onError)
{
var defer = onLoad == null && !noWrite;
if ((urlParams['dev'] != '1' && typeof document.createElement('canvas').getContext === "function") ||
onLoad != null || noWrite)
{
var s = document.createElement('script');
s.setAttribute('type', 'text/javascript');
s.setAttribute('defer', 'true');
s.setAttribute('src', src);
if (id != null)
{
s.setAttribute('id', id);
}
if (dataAppKey != null)
{
s.setAttribute('data-app-key', dataAppKey);
}
if (onLoad != null)
{
var r = false;
s.onload = s.onreadystatechange = function()
{
if (!r && (!this.readyState || this.readyState == 'complete'))
{
r = true;
onLoad();
}
};
}
if (onError != null)
{
s.onerror = function(e)
{
onError('Failed to load ' + src, e);
};
}
var t = document.getElementsByTagName('script')[0];
if (t != null)
{
t.parentNode.insertBefore(s, t);
}
}
else
{
document.write('<script src="' + src + '"' + ((id != null) ? ' id="' + id +'" ' : '') +
((dataAppKey != null) ? ' data-app-key="' + dataAppKey +'" ' : '') + '></scr' + 'ipt>');
}
};
/**
* Asynchronously adds scripts to the page.
*/
function mxinclude(src)
{
var g = document.createElement('script');
g.type = 'text/javascript';
g.async = true;
g.src = src;
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(g, s);
};
/**
* Adds meta tags with application name (depends on offline URL parameter)
*/
(function()
{
var name = 'diagrams.net';
mxmeta('apple-mobile-web-app-title', name);
mxmeta('application-name', name);
if (mxIsElectron)
{
mxmeta(null, 'default-src \'self\'; script-src \'self\' \'sha256-6g514VrT/cZFZltSaKxIVNFF46+MFaTSDTPB8WfYK+c=\'; connect-src \'self\' https://*.draw.io https://*.diagrams.net https://fonts.googleapis.com https://fonts.gstatic.com; img-src * data:; media-src *; font-src *; frame-src \'none\'; style-src \'self\' \'unsafe-inline\' https://fonts.googleapis.com; base-uri \'none\';child-src \'self\';object-src \'none\';', 'Content-Security-Policy');
}
})();
// Checks for local storage
var isLocalStorage = false;
try
{
isLocalStorage = urlParams['local'] != '1' && typeof(localStorage) != 'undefined';
}
catch (e)
{
// ignored
}
var mxScriptsLoaded = false, mxWinLoaded = false;
function checkAllLoaded()
{
if (mxScriptsLoaded && mxWinLoaded)
{
App.main();
}
};
var t0 = new Date();
// Changes paths for local development environment
if (urlParams['dev'] == '1')
{
// Used to request grapheditor/mxgraph sources in dev mode
var mxDevUrl = '';
// Used to request draw.io sources in dev mode
var drawDevUrl = '';
var geBasePath = 'js/grapheditor';
var mxBasePath = 'mxgraph/src';
if (document.location.protocol == 'file:')
{
// Forces includes for dev environment in node.js
mxForceIncludes = true;
}
mxForceIncludes = false;
mxscript(drawDevUrl + 'js/PreConfig.js');
mxscript(drawDevUrl + 'js/diagramly/Init.js');
mxscript(geBasePath + '/Init.js');
mxscript(mxBasePath + '/mxClient.js');
// Adds all JS code that depends on mxClient. This indirection via Devel.js is
// required in some browsers to make sure mxClient.js (and the files that it
// loads asynchronously) are available when the code loaded in Devel.js runs.
mxscript(drawDevUrl + 'js/diagramly/Devel.js');
// Electron
if (mxIsElectron)
{
mxscript('js/diagramly/DesktopLibrary.js');
mxscript('js/diagramly/ElectronApp.js');
}
mxscript(drawDevUrl + 'js/PostConfig.js');
}
else
{
(function()
{
var hostName = window.location.hostname;
// Supported domains are *.draw.io and the packaged version in Quip
var supportedDomain = (hostName.substring(hostName.length - 8, hostName.length) === '.draw.io') ||
(hostName.substring(hostName.length - 13, hostName.length) === '.diagrams.net');
function loadAppJS()
{
mxscript('js/app.min.js', function()
{
mxScriptsLoaded = true;
checkAllLoaded();
// Electron
if (mxIsElectron)
{
mxscript('js/diagramly/DesktopLibrary.js', function()
{
mxscript('js/diagramly/ElectronApp.js', function()
{
mxscript('js/extensions.min.js', function()
{
mxscript('js/stencils.min.js', function()
{
mxscript('js/shapes-14-6-5.min.js', function()
{
mxscript('js/PostConfig.js');
});
});
});
});
});
}
else if (!supportedDomain || navigator.onLine)
{
mxscript('js/PostConfig.js');
}
});
};
if (!supportedDomain || mxIsElectron || navigator.onLine)
{
mxscript('js/PreConfig.js', loadAppJS);
}
else
{
loadAppJS();
}
})();
}
// Adds basic error handling
window.onerror = function()
{
var status = document.getElementById('geStatus');
if (status != null)
{
status.innerHTML = 'Page could not be loaded. Please try refreshing.';
}
};
</script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="images/apple-touch-icon.png">
<link rel="stylesheet" type="text/css" href="styles/grapheditor.css">
<link rel="stylesheet" media="(forced-colors: active)" href="styles/high-contrast.css" id="high-contrast-stylesheet">
<link rel="canonical" href="https://app.diagrams.net">
<link rel="manifest" href="images/manifest.json">
<link rel="shortcut icon" href="favicon.ico">
<style type="text/css">
body { overflow:hidden; }
div.picker { z-index: 10007; }
.geSidebarContainer .geTitle input {
font-size:8pt;
color:#606060;
}
.geBlock {
display: none;
z-index:-3;
margin:100px;
margin-top:40px;
margin-bottom:30px;
padding:20px;
text-align:center;
min-width:50%;
}
.geBlock h1, .geBlock h2 {
margin-top:0px;
padding-top:0px;
}
.geEditor *:not(.geScrollable)::-webkit-scrollbar {
width:10px;
height:10px;
}
.geEditor ::-webkit-scrollbar-track {
background-clip:padding-box;
border:solid transparent;
border-width:1px;
}
.geEditor ::-webkit-scrollbar-corner {
background-color:transparent;
}
.geEditor ::-webkit-scrollbar-thumb {
background-color:rgba(0,0,0,.1);
background-clip:padding-box;
border:solid transparent;
border-radius:10px;
}
.geEditor ::-webkit-scrollbar-thumb:hover {
background-color:rgba(0,0,0,.4);
}
.geTemplate {
border:1px solid transparent;
display:inline-block;
_display:inline;
vertical-align:top;
border-radius:3px;
overflow:hidden;
font-size:14pt;
cursor:pointer;
margin:5px;
}
</style>
<!-- Workaround for binary XHR in IE 9/10, see App.loadUrl -->
<!--[if (IE 9)|(IE 10)]><!-->
<script type="text/vbscript">
Function mxUtilsBinaryToArray(Binary)
Dim i
ReDim byteArray(LenB(Binary))
For i = 1 To LenB(Binary)
byteArray(i-1) = AscB(MidB(Binary, i, 1))
Next
mxUtilsBinaryToArray = byteArray
End Function
</script>
<!--<![endif]-->
</head>
<body class="geEditor">
<div id="geInfo">
<div class="geBlock">
<h1>Flowchart Maker and Online Diagram Software</h1>
<p>
draw.io is free online diagram software. You can use it as a flowchart maker, network diagram software, to create UML online, as an ER diagram tool,
to design database schema, to build BPMN online, as a circuit diagram maker, and more. draw.io can import .vsdx, Gliffy&trade; and Lucidchart&trade; files .
</p>
<h2 id="geStatus">Loading...</h2>
<p>
Please ensure JavaScript is enabled.
</p>
</div>
</div>
<script id="geMain" type="text/javascript">
/**
* Main
*/
if (urlParams['dev'] != '1' && typeof document.createElement('canvas').getContext === "function")
{
window.addEventListener('load', function()
{
mxWinLoaded = true;
checkAllLoaded();
});
}
else
{
App.main();
}
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More