Compare commits

...

473 Commits

Author SHA1 Message Date
kuaifan
ab76185434 feat: 优化个人资料卡片 2025-10-13 06:56:44 +00:00
kuaifan
6d97bf1e88 feat: 添加个性标签管理功能 2025-10-12 23:02:34 +00:00
kuaifan
49701fcd09 feat: 会员资料窗口添加创建群组按钮 2025-10-12 15:15:34 +00:00
kuaifan
40f04d9860 feat: 添加用户生日、地址和个人简介 2025-10-12 15:07:10 +00:00
kuaifan
d58dd25dbb feat: 添加用户生日、地址和个人简介 2025-10-12 15:05:05 +00:00
kuaifan
9b2731607b feat: 优化开发环境配置 2025-10-11 10:42:49 +00:00
kuaifan
a8d2d6f13f feat: 优化开发环境配置 2025-10-11 02:53:17 +00:00
kuaifan
7c21782ab5 no message 2025-10-08 04:34:31 +00:00
kuaifan
f59bdaf5e0 feat: 添加用户机器人 webhook 事件配置,优化相关逻辑 2025-09-30 04:25:50 +00:00
kuaifan
6ffd169784 build 2025-09-28 06:54:05 +08:00
kuaifan
406f64a7c5 no message 2025-09-28 06:46:19 +08:00
kuaifan
1353a2c4c9 no message 2025-09-28 06:34:35 +08:00
kuaifan
fb88f3bd96 no message 2025-09-28 06:33:38 +08:00
kuaifan
22b3598704 feat: 优化共同群聊计数缓存 2025-09-28 06:28:24 +08:00
kuaifan
b62c580d5e no message 2025-09-28 05:55:02 +08:00
kuaifan
6a63ceaecc fix: 编辑器快捷键保存重复 2025-09-28 05:19:27 +08:00
kuaifan
591f9e61fb no message 2025-09-27 17:48:43 +08:00
kuaifan
7011c81bcd feat: 优化自动归档逻辑
- 子任务不自动归档
2025-09-27 16:38:44 +08:00
kuaifan
3cf7055122 feat: 添加任务关联功能 2025-09-27 15:53:58 +08:00
kuaifan
aba31eda83 no message 2025-09-27 07:09:08 +08:00
kuaifan
1b30582dd9 feat: 添加emoji表情删除按钮 2025-09-26 20:18:12 +08:00
kuaifan
0fb66358cc feat: 优化对话搜索时的选择状态管理 2025-09-26 19:29:13 +08:00
kuaifan
e226f444f7 feat: 优化部门选择逻辑
- 支持自动添加父级部门
2025-09-26 19:21:26 +08:00
kuaifan
95bf70f568 no message 2025-09-26 19:02:09 +08:00
kuaifan
a6597b44c3 feat: 优化Ai提示词 2025-09-26 19:00:10 +08:00
kuaifan
51c01c5445 feat: 添加文件缩略图显示 2025-09-26 14:00:53 +08:00
kuaifan
161bf75a1d feat: 添加文件拖拽选择功能 2025-09-26 13:32:11 +08:00
kuaifan
2f16e2c608 feat: 添加文件预览功能和优化文件打开逻辑 2025-09-26 12:13:38 +08:00
kuaifan
aea2e79b37 no message 2025-09-25 16:36:11 +08:00
kuaifan
f433d13a2f feat: 优化透明模式样式 2025-09-25 09:04:35 +08:00
kuaifan
e9abf6ed05 no message 2025-09-25 06:05:09 +08:00
kuaifan
0c32b25ddf no message 2025-09-25 00:14:14 +08:00
kuaifan
a03dec91c5 feat: 添加任务复制功能 2025-09-24 23:49:22 +08:00
kuaifan
7c5a966944 no message 2025-09-24 21:00:31 +08:00
kuaifan
652dc0953b feat: 添加任务模板排序功能
- 在 ProjectController 中新增 task__template_sort 方法,支持项目任务模板的排序
- 更新前端组件以支持拖拽调整任务模板顺序
- 新增数据库迁移以填充任务模板的排序字段
- 优化样式以提升用户体验
2025-09-24 20:49:09 +08:00
kuaifan
03860a6dce feat: 添加标签排序功能
- 在 ProjectController 中新增 tag__sort 方法,支持项目标签的排序
- 更新 ProjectTag 模型,添加排序字段
- 新增数据库迁移以添加标签排序字段
- 更新前端组件,支持拖拽调整标签顺序
- 优化样式以提升用户体验
2025-09-24 20:31:54 +08:00
kuaifan
c6bee25264 fix: 优化用户交接人选择逻辑
- 更新 UsersController 中的交接人选择逻辑,确保在选择交接人时进行有效性检查
- 修改前端 TeamManagement 组件,添加交接人选择提示信息
- 确保在提交数据时正确处理交接人 ID 的格式
2025-09-24 19:06:50 +08:00
kuaifan
068de0fa9f fix: 优化文件访问权限检查逻辑
- 移除冗余的游客访问权限检查代码
- 简化用户认证逻辑,确保在文件不允许游客访问时强制用户登录
- 更新返回数据结构,移除不再使用的 is_guest_access 字段
2025-09-24 19:00:07 +08:00
kuaifan
4b45d5ca26 feat: 添加会话重命名功能
- 在 DialogController 中新增 session__rename 方法,支持用户重命名会话
- 更新前端组件 DialogSessionHistory.vue,添加重命名按钮及相关逻辑
- 修改样式以支持重命名功能的交互效果
- 优化用户体验,确保重命名操作的流畅性
2025-09-24 18:39:25 +08:00
kuaifan
a268391e68 feat: 添加收藏备注功能
- 在 UsersController 中新增 favorite__remark 方法,支持用户修改收藏的备注
- 在 UserFavorite 模型中添加更新备注的逻辑
- 新增数据库迁移以添加备注字段
- 更新前端组件以支持备注的显示和编辑
- 优化收藏操作的用户体验
2025-09-24 18:15:03 +08:00
kuaifan
89bdd86f14 fix: 更新消息预览文本获取方法
- 将获取消息预览文本的方法从 previewTextMsg 更新为 previewMsg,以适应新的消息结构
- 确保在处理消息时使用最新的预览文本获取逻辑
2025-09-24 16:45:08 +08:00
kuaifan
e533bd7e35 no message 2025-09-24 15:48:48 +08:00
kuaifan
09ed978e80 no message 2025-09-24 11:54:01 +08:00
kuaifan
4b106e1f41 feat: 添加最近访问记录功能
- 在 UsersController 中新增获取和删除最近访问记录的接口
- 在相关控制器中记录用户最近访问的任务、文件和消息文件
- 新增 RecentManagement 组件,展示用户最近访问的记录
- 更新样式和图标以提升用户体验
2025-09-24 09:51:13 +08:00
kuaifan
feeeb26d94 no message 2025-09-23 19:39:13 +08:00
kuaifan
bef0d2d992 feat: 增强用户部门成员管理功能
- 在 UsersController 中新增逻辑,自动将缺失的部门成员加入 WebSocket 对话组
- 优化部门成员同步流程,提升用户体验
2025-09-23 18:49:02 +08:00
kuaifan
6e6bd8a6be build 2025-09-23 17:44:09 +08:00
kuaifan
631fa0db4e feat: 添加数据导出功能及相关样式
- 在管理页面中新增数据导出功能,支持导出任务、超期任务、审批数据和签到数据
- 更新应用页面,添加导出管理的弹出菜单
- 新增导出相关的 SVG 图标
- 优化样式以提升用户体验
2025-09-23 16:41:07 +08:00
kuaifan
65d30b7a30 no message 2025-09-23 15:32:01 +08:00
kuaifan
5ba5f27ca7 no message 2025-09-23 15:03:29 +08:00
kuaifan
acc437bf2d fix: 重置成功登录流程后的认证异常标志
- 在 actions.js 中添加逻辑,确保在成功登录后重置 ajaxAuthException 状态
- 优化用户认证体验,避免异常状态影响后续操作
2025-09-23 14:41:46 +08:00
kuaifan
5fd2505a33 feat: 优化 AI 生成交互体验
- 移除不必要的 loading 状态,简化用户交互
- 在项目和任务生成中添加取消功能,提升用户体验
- 更新相关组件以支持取消操作,确保生成过程的灵活性
2025-09-23 14:41:34 +08:00
kuaifan
7f6abc331b feat: 添加 AI 助手生成消息功能
- 在 DialogController 中新增 msg__ai_generate 接口,支持根据用户需求自动生成聊天消息
- 在 AI 模块中实现 generateMessage 方法,处理消息生成逻辑
- 更新前端 ChatInput 组件,添加 AI 生成按钮,集成消息生成请求
- 增强用户交互体验,支持输入消息主题和要点
2025-09-23 14:05:01 +08:00
kuaifan
c190aab8b9 feat: 添加 AI 助手生成项目功能
- 在 ProjectController 中新增 ai__generate 接口,支持根据用户需求自动生成项目名称及任务列表
- 在 AI 模块中实现 generateProject 方法,处理项目生成逻辑
- 更新前端管理页面,添加 AI 生成按钮,集成项目生成请求
- 增强样式以提升用户体验
2025-09-23 13:43:46 +08:00
kuaifan
0f71abdac3 no message 2025-09-23 13:13:52 +08:00
kuaifan
8ddc507bd5 feat: 添加 AI 助手生成任务功能
- 在 ProjectController 中新增 ai_generate 接口,支持根据用户输入生成任务标题和详细描述
- 在 AI 模块中实现 generateTask 方法,处理任务生成逻辑
- 更新前端 TaskAdd 组件,添加 AI 生成按钮,集成任务生成请求
- 优化 TEditor 和 TEditorTask 组件,支持设置内容格式
- 增强样式以提升用户体验
2025-09-23 13:11:33 +08:00
kuaifan
1c4bae2d91 no message 2025-09-23 10:16:57 +08:00
kuaifan
73ca4b1ea5 feat: 扩展收藏功能,支持消息类型的收藏
- 在 UserFavorite 模型中添加消息类型常量
- 更新 UsersController,支持消息的收藏、切换和状态检查
- 修改前端 Vue 组件以实现消息的收藏操作和状态显示
- 优化收藏管理界面,支持消息类型的展示与处理
2025-09-23 09:48:06 +08:00
kuaifan
18a922b5cd feat: 重构收藏功能,优化状态检查与切换逻辑
- 将文件、项目和任务的收藏状态切换逻辑统一为 toggleFavorite 方法
- 添加 checkFavoriteStatus 方法以简化收藏状态检查
- 更新相关 Vue 组件以使用新的状态管理方法,提升代码可读性和维护性
- 优化上下文菜单和操作逻辑,确保收藏状态的实时更新
2025-09-23 08:59:15 +08:00
kuaifan
11b98978c1 feat: 增强文件和项目的收藏功能
- 在 UserFavorite 模型中添加文件的 pid 字段以支持层级结构
- 更新前端 Vue 组件以实现文件和项目的收藏状态切换
- 添加检查文件和项目收藏状态的功能
- 优化上下文菜单以支持收藏操作
2025-09-22 16:35:57 +08:00
kuaifan
379d3811a8 feat: 添加用户收藏功能
- 在 UsersController 中新增获取、切换、清理用户收藏的 API 接口
- 创建 UserFavorite 模型以管理用户的收藏记录
- 更新前端 Vue 组件以支持收藏管理界面和交互
- 添加相关样式以美化收藏管理界面
2025-09-22 16:09:33 +08:00
kuaifan
0401b8a6e6 feat: 添加任务浏览历史功能
- 在 UsersController 中新增获取、记录和清理任务浏览历史的 API 接口
- 创建 UserTaskBrowse 模型以管理用户的任务浏览记录
- 更新前端 Vue 组件以支持任务浏览历史的加载和显示
- 移除不再使用的本地缓存逻辑,直接通过 API 进行数据交互
2025-09-22 07:10:12 +08:00
kuaifan
6148b996d8 no message 2025-09-22 06:27:57 +08:00
kuaifan
39781c9cd7 feat: 优化消息传递处理逻辑
- 在 DialogWrapper 组件中添加 handlerMsgTransfer 方法以简化消息传递逻辑
- 更新 TaskDetail 组件以直接使用状态管理中的 dialogMsgTransfer 数据
2025-09-22 06:01:56 +08:00
kuaifan
18758a1614 no message 2025-09-22 06:01:36 +08:00
kuaifan
b044d8d90e feat: 添加部门成员同步功能
- 在 UsersController 中新增同步部门成员的 API 接口
- 在 UserDepartment 模型中添加递归获取子部门 ID 的方法
- 在前端 TeamManagement 组件中添加同步部门成员的操作选项
2025-09-22 05:07:45 +08:00
kuaifan
02e56f87bc perf: 优化群聊消息AI处理逻辑
- 添加获取最近聊天记录功能
2025-09-21 20:00:23 +08:00
kuaifan
d9b9ee221b no message 2025-09-21 15:43:00 +08:00
kuaifan
21ec9188ca fix: 添加异常处理以确保提及格式转换的稳定性 2025-09-20 17:04:38 +08:00
kuaifan
4d768becf5 fix: 更新应用商店镜像版本至0.2.9 2025-09-20 17:04:29 +08:00
kuaifan
a27049386b no message 2025-09-20 15:11:42 +08:00
kuaifan
b23e3d7359 feat: 添加下载功能的等待状态支持 2025-09-20 14:04:44 +08:00
kuaifan
7660164583 fix: 修复在列表中未找到当前图像时的处理逻辑 2025-09-20 07:30:45 +08:00
kuaifan
5e1f3c5564 feat: 添加文件游客访问权限功能 2025-09-19 19:10:58 +08:00
kuaifan
197fa9c01c build 2025-09-02 07:27:42 +08:00
kuaifan
554e3d0c2f no message 2025-08-27 17:10:17 +08:00
kuaifan
b800cde34d no message 2025-08-26 20:21:19 +08:00
kuaifan
775fdd2be0 fix: 无法修改群组名称的问题 2025-08-26 20:19:41 +08:00
kuaifan
7908ae4258 no message 2025-08-20 18:25:51 +08:00
kuaifan
bfbd8229a1 no message 2025-08-20 16:53:29 +08:00
kuaifan
afbf8dedbf no message 2025-08-20 16:21:26 +08:00
kuaifan
569912abef no message 2025-08-20 13:39:58 +08:00
kuaifan
7c94f6bc9a perf: 支持项目调整排序 2025-08-20 08:36:19 +08:00
kuaifan
b825b5b063 perf: 支持项目调整排序 2025-08-19 23:12:10 +08:00
kuaifan
50098b5e70 no message 2025-08-19 22:19:46 +08:00
kuaifan
e237b4db1c perf: 支持项目调整排序 2025-08-19 22:18:18 +08:00
kuaifan
2a25cf3bbd no message 2025-08-19 21:43:17 +08:00
kuaifan
02275bb417 perf: 支持项目调整排序 2025-08-19 21:19:45 +08:00
kuaifan
788cae3efe no message 2025-08-19 20:06:46 +08:00
kuaifan
0dec70c53a no message 2025-08-19 20:06:38 +08:00
kuaifan
f534f012d2 perf: 优化错误页 2025-08-19 18:01:21 +08:00
kuaifan
bb83875c99 feat: 添加内置浏览器导航功能 2025-08-19 18:01:21 +08:00
kuaifan
d048aa33f7 no message 2025-08-19 18:01:21 +08:00
kuaifan
8f3e250073 perf: 优化输入框工具栏 2025-08-19 18:01:21 +08:00
kuaifan
63a792d169 no message 2025-08-19 18:01:21 +08:00
kuaifan
eb3524a22d Merge pull request #280 from nightcp/fix-ganntt-timeline-error
fix(gantt): 修复甘特图时间轴计算错误
2025-08-19 18:00:43 +08:00
nightcp
f657a24a1a fix(gantt): 修复甘特图时间轴计算错误
Closes #272
2025-08-18 21:05:26 +08:00
kuaifan
a5228448d7 Merge pull request #278 from puzzle9/pro
fix: 修复 supervisor crontab 运行状态错误
2025-08-18 17:07:50 +08:00
kuaifan
1ec4796f72 feat: 添加查看共同的群 2025-08-18 09:45:39 +08:00
kuaifan
6964158cf6 perf: 优化任务模板、任务标签 2025-08-18 08:31:22 +08:00
kuaifan
4fc4dd1b16 no message 2025-08-18 05:28:37 +08:00
kuaifan
3e851f0c3c build 2025-08-15 07:54:34 +08:00
kuaifan
b8befaa973 no message 2025-08-15 07:43:45 +08:00
kuaifan
b05046af29 perf: 优化下载工具 2025-08-15 07:22:20 +08:00
kuaifan
eecc6c9e53 perf: 优化下载工具 2025-08-15 01:02:40 +08:00
kuaifan
d4e754d601 perf: 优化下载工具 2025-08-15 00:27:34 +08:00
kuaifan
a8a54593e2 perf: 优化下载工具 2025-08-14 23:11:37 +08:00
kuaifan
5bbffc4f5c perf: 优化下载工具 2025-08-14 20:31:55 +08:00
kuaifan
0833018399 perf: 优化下载工具 2025-08-14 16:50:48 +08:00
kuaifan
f6850fc795 perf: 优化下载工具 2025-08-14 16:50:42 +08:00
kuaifan
c0b4674568 no message 2025-08-14 11:56:51 +08:00
puzzle
5a8996d90a fix: 修复 supervisor crontab 运行状态错误 2025-08-14 03:08:06 +08:00
kuaifan
548b30e5b3 build 2025-08-12 08:05:11 +08:00
kuaifan
80f9329004 no message 2025-08-12 07:38:40 +08:00
kuaifan
f672280236 no message 2025-08-12 07:37:52 +08:00
kuaifan
90a4a01de7 no message 2025-08-11 22:35:58 +08:00
kuaifan
09cebb90fe fix: 修复应用加载中无法点击胶囊 2025-08-11 20:23:55 +08:00
kuaifan
70389aab3d no message 2025-08-11 16:47:53 +08:00
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
936 changed files with 30340 additions and 56783 deletions

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- "pro"
- "dev"
jobs:
check-version:

48
.gitignore vendored
View File

@@ -1,32 +1,58 @@
# Dependencies
/node_modules
/vendor
# Build and temporary files
/build
/public/hot
/public/tmp
/tmp
# Uploads and user-generated content
/public/summary
/public/uploads/*
/public/.well-known
/public/.user.ini
/storage/*.key
# Storage and configuration
/config/LICENSE
/vendor
/build
/tmp
._*
/storage/*.key
# Environment and configuration
.env
vars.yaml
# IDE and editor files
.idea
.vscode
.vagrant
.windsurfrules
.phpunit.result.cache
# Development tools
.vagrant
Homestead.json
Homestead.yaml
# Testing
.phpunit.result.cache
test.*
# Logs and debug files
npm-debug.log
yarn-error.log
test.*
# Lock files
dootask.lock
package-lock.json
# Laravel/Swoole specific
laravels-timer-process.pid
.DS_Store
vars.yaml
laravels.conf
laravels.pid
# System files
._*
.DS_Store
# Documentation
AGENTS.md
README_LOCAL.md
dootask.lock

View File

@@ -1,13 +0,0 @@
# This configuration file was automatically generated by Gitpod.
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
# and commit this file to your remote git repository to share the goodness with others.
tasks:
- init: sudo ./cmd install
command: ./cmd dev
ports:
- port: 2222
visibility: public
- port: 22222
visibility: public

View File

@@ -2,6 +2,309 @@
All notable changes to this project will be documented in this file.
## [1.3.15]
### Bug Fixes
- 编辑器快捷键保存重复
- 优化用户交接人选择逻辑
- 优化文件访问权限检查逻辑
- 更新消息预览文本获取方法
### Features
- 优化共同群聊计数缓存
- 优化自动归档逻辑
- 添加任务关联功能
- 添加emoji表情删除按钮
- 优化对话搜索时的选择状态管理
- 优化部门选择逻辑
- 优化Ai提示词
- 添加文件缩略图显示
- 添加文件拖拽选择功能
- 添加文件预览功能和优化文件打开逻辑
- 优化透明模式样式
- 添加任务复制功能
- 添加任务模板排序功能
- 添加标签排序功能
- 添加会话重命名功能
- 添加收藏备注功能
- 添加最近访问记录功能
- 增强用户部门成员管理功能
## [1.2.75]
### Bug Fixes
- 重置成功登录流程后的认证异常标志
- 添加异常处理以确保提及格式转换的稳定性
- 更新应用商店镜像版本至0.2.9
- 修复在列表中未找到当前图像时的处理逻辑
### Features
- 添加数据导出功能及相关样式
- 优化 AI 生成交互体验
- 添加 AI 助手生成消息功能
- 添加 AI 助手生成项目功能
- 添加 AI 助手生成任务功能
- 扩展收藏功能,支持消息类型的收藏
- 重构收藏功能,优化状态检查与切换逻辑
- 增强文件和项目的收藏功能
- 添加用户收藏功能
- 添加任务浏览历史功能
- 优化消息传递处理逻辑
- 添加部门成员同步功能
- 添加下载功能的等待状态支持
- 添加文件游客访问权限功能
### Performance
- 优化群聊消息AI处理逻辑
## [1.2.49]
### Bug Fixes
- 无法修改群组名称的问题
- 修复甘特图时间轴计算错误
### Features
- 添加内置浏览器导航功能
- 添加查看共同的群
### Performance
- 支持项目调整排序
- 优化错误页
- 优化输入框工具栏
- 优化任务模板、任务标签
## [1.2.21]
### Bug Fixes
- 修复 supervisor crontab 运行状态错误
- 修复应用加载中无法点击胶囊
### Performance
- 优化下载工具
## [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

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

@@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\ZincSearch\ZincSearchKeyValue;
use App\Module\ZincSearch\ZincSearchDialogMsg;
use Cache;
@@ -27,6 +28,11 @@ class SyncUserMsgToZincSearch extends Command
*/
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「ZincSearch」未安装");
return 1;
}
// 注册信号处理器仅在支持pcntl扩展的环境下
if (extension_loaded('pcntl')) {
pcntl_async_signals(true); // 启用异步信号处理

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

@@ -5,11 +5,14 @@ namespace App\Http\Controllers\Api;
use DB;
use Request;
use Redirect;
use Cache;
use Carbon\Carbon;
use App\Tasks\PushTask;
use App\Module\AI;
use App\Module\Doo;
use App\Models\File;
use App\Models\User;
use App\Models\UserBot;
use App\Module\Base;
use App\Module\Timer;
use App\Models\Setting;
@@ -27,8 +30,10 @@ use App\Models\WebSocketDialogMsgRead;
use App\Models\WebSocketDialogMsgTodo;
use App\Models\WebSocketDialogMsgTranslate;
use App\Models\WebSocketDialogSession;
use App\Models\UserRecentItem;
use App\Module\Table\OnlineData;
use App\Module\ZincSearch\ZincSearchDialogMsg;
use App\Tasks\BotReceiveMsgTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
@@ -329,8 +334,6 @@ class DialogController extends AbstractController
]);
}
/**
* @api {get} api/dialog/hide 09. 会话隐藏
*
@@ -441,6 +444,29 @@ class DialogController extends AbstractController
return Base::retError('打开会话失败');
}
$data = WebSocketDialog::synthesizeData($dialog->id, $user->userid);
if ($userid > 0) {
$botTarget = User::whereUserid($userid)->whereBot(1)->first();
if ($botTarget) {
$userBot = UserBot::whereBotId($botTarget->userid)->first();
if ($userBot) {
$userBot->dispatchWebhook(UserBot::WEBHOOK_EVENT_DIALOG_OPEN, [
'dialog_id' => $dialog->id,
'dialog_type' => $dialog->type,
'session_id' => $dialog->session_id,
'dialog_name' => $dialog->getGroupName(),
'user' => [
'userid' => $user->userid,
'email' => $user->email,
'nickname' => $user->nickname,
],
], 10, [
'dialog' => $dialog->id,
'operator' => $user->userid,
]);
}
}
}
return Base::retSuccess('success', $data);
}
@@ -545,6 +571,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;
@@ -989,7 +1016,133 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendtext 21. 发送消息
* @api {post} api/dialog/msg/ai_generate 21. 使用 AI 助手生成消息
*
* @apiDescription 需要token身份根据上下文自动生成拟发送的聊天消息
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__ai_generate
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {String} content 消息需求描述
* @apiParam {String} [draft] 当前草稿内容HTML 格式)
* @apiParam {Number} [quote_id] 引用消息ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.text AI 生成的消息文本Markdown 格式)
* @apiSuccess {String} data.html AI 生成的消息内容HTML 格式)
*/
public function msg__ai_generate()
{
$user = User::auth();
$user->checkChatInformation();
//
$dialog_id = intval(Request::input('dialog_id'));
$content = trim(Request::input('content', ''));
if ($dialog_id <= 0) {
return Base::retError('参数错误');
}
if ($content === '') {
return Base::retError('消息需求描述不能为空');
}
$dialog = WebSocketDialog::checkDialog($dialog_id);
// 基本信息
$context = [
'dialog_name' => $dialog->name ?: '',
'dialog_type' => $dialog->type ?: '',
'group_type' => $dialog->group_type ?: '',
];
// 当前草稿
$draft = Request::input('draft', '');
if (is_string($draft) && trim($draft) !== '') {
$context['current_draft'] = Base::html2markdown($draft);
}
// 引用消息
$quote_id = intval(Request::input('quote_id'));
if ($quote_id > 0) {
$quote = WebSocketDialogMsg::whereDialogId($dialog_id)
->whereId($quote_id)
->with('user')
->first();
if ($quote) {
$context['quote_summary'] = WebSocketDialogMsg::previewMsg($quote);
$context['quote_user'] = $quote->user->nickname ?? '';
}
}
// 成员列表
$members = WebSocketDialogUser::whereDialogId($dialog_id)
->join('users', 'users.userid', '=', 'web_socket_dialog_users.userid')
->orderBy('web_socket_dialog_users.id')
->limit(10)
->pluck('users.nickname')
->filter()
->values()
->all();
if (!empty($members)) {
$context['members'] = $members;
}
// 最近消息
$recentMessagesQuery = WebSocketDialogMsg::whereDialogId($dialog_id)
->orderByDesc('id')
->with('user');
$recentMessages = (clone $recentMessagesQuery)->take(15)->get();
if ($recentMessages->isNotEmpty()) {
$formatRecentMessages = function ($messages) {
return $messages->reverse()->map(function ($msg) {
return [
'sender' => $msg->user->nickname ?? ('用户' . $msg->userid),
'summary' => $msg->extractMessageContent(500),
];
})->filter(function ($item) {
return !empty($item['summary']);
})->values();
};
$formattedRecentMessages = $formatRecentMessages($recentMessages);
$summaryLength = $formattedRecentMessages->sum(function ($item) {
return mb_strlen($item['summary']);
});
if ($summaryLength < 500 && $recentMessages->count() === 15) {
$lastMessageId = optional($recentMessages->last())->id;
$additionalMessages = collect();
if ($lastMessageId) {
$additionalMessages = (clone $recentMessagesQuery)
->where('id', '<', $lastMessageId)
->take(10)
->get();
}
if ($additionalMessages->isNotEmpty()) {
$recentMessages = $recentMessages->concat($additionalMessages);
$formattedRecentMessages = $formatRecentMessages($recentMessages);
}
}
if ($formattedRecentMessages->isNotEmpty()) {
$context['recent_messages'] = $formattedRecentMessages->all();
}
}
// 生成消息
$result = AI::generateMessage($content, $context);
if (Base::isError($result)) {
return Base::retError('生成消息失败', $result);
}
return Base::retSuccess('生成消息成功', $result['data']);
}
/**
* @api {post} api/dialog/msg/sendtext 22. 发送消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1134,7 +1287,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendnotice 22. 发送通知
* @api {post} api/dialog/msg/sendnotice 23. 发送通知
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1187,7 +1340,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendtemplate 23. 发送模板消息
* @api {post} api/dialog/msg/sendtemplate 24. 发送模板消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1256,7 +1409,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendrecord 24. 发送语音
* @api {post} api/dialog/msg/sendrecord 25. 发送语音
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1304,7 +1457,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/convertrecord 25. 录音转文字
* @api {post} api/dialog/msg/convertrecord 26. 录音转文字
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1357,26 +1510,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 26. 文件上传
* @api {post} api/dialog/msg/sendfile 27. 文件上传
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1408,7 +1562,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendfiles 27. 群发文件上传
* @api {post} api/dialog/msg/sendfiles 28. 群发文件上传
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1464,7 +1618,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/sendfileid 28. 通过文件ID发送文件
* @api {get} api/dialog/msg/sendfileid 29. 通过文件ID发送文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1504,7 +1658,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/sendtaskid 29. 通过任务ID发送任务
* @api {get} api/dialog/msg/sendtaskid 30. 通过任务ID发送任务
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1543,7 +1697,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendanon 30. 发送匿名消息
* @api {post} api/dialog/msg/sendanon 31. 发送匿名消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1599,7 +1753,89 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/sendlocation 31. 发送位置消息
* @api {post} api/dialog/msg/sendbot 32. 发送机器人消息
*
* @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 33. 发送位置消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1608,7 +1844,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 位置名称
@@ -1643,7 +1881,7 @@ class DialogController extends AbstractController
//
WebSocketDialog::checkDialog($dialog_id);
//
if ($type == 'bd') {
if (in_array($type, ['baidu', 'amap', 'tencent'])) {
$msgData = [
'type' => $type,
'lng' => $lng,
@@ -1659,7 +1897,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/readlist 32. 获取消息阅读情况
* @api {get} api/dialog/msg/readlist 34. 获取消息阅读情况
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1688,7 +1926,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/detail 33. 消息详情
* @api {get} api/dialog/msg/detail 35. 消息详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1706,7 +1944,7 @@ class DialogController extends AbstractController
*/
public function msg__detail()
{
User::auth();
$user =User::auth();
//
$msg_id = intval(Request::input('msg_id'));
$only_update_at = Request::input('only_update_at', 'no');
@@ -1744,11 +1982,21 @@ class DialogController extends AbstractController
}
}
//
if ($dialogMsg->type === 'file') {
UserRecentItem::record(
$user->userid,
UserRecentItem::TYPE_MESSAGE_FILE,
$dialogMsg->id,
UserRecentItem::SOURCE_DIALOG,
$dialogMsg->dialog_id
);
}
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/dialog/msg/download 34. 文件下载
* @api {get} api/dialog/msg/download 36. 文件下载
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1772,12 +2020,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') {
@@ -1789,7 +2033,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/withdraw 35. 聊天消息撤回
* @api {get} api/dialog/msg/withdraw 37. 聊天消息撤回
*
* @apiDescription 消息撤回限制24小时内需要token身份
* @apiVersion 1.0.0
@@ -1820,7 +2064,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/voice2text 36. 语音消息转文字
* @api {get} api/dialog/msg/voice2text 38. 语音消息转文字
*
* @apiDescription 将语音消息转文字需要token身份
* @apiVersion 1.0.0
@@ -1859,20 +2103,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 39. 翻译消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1925,15 +2172,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();
//
@@ -1941,7 +2188,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/mark 38. 消息标记操作
* @api {get} api/dialog/msg/mark 40. 消息标记操作
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2005,7 +2252,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/silence 39. 消息免打扰
* @api {get} api/dialog/msg/silence 41. 消息免打扰
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2068,7 +2315,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/forward 40. 转发消息给
* @api {get} api/dialog/msg/forward 42. 转发消息给
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2109,7 +2356,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/emoji 41. emoji回复
* @api {get} api/dialog/msg/emoji 43. emoji回复
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2130,7 +2377,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("参数错误");
}
//
@@ -2144,7 +2406,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/tag 42. 标注/取消标注
* @api {get} api/dialog/msg/tag 44. 标注/取消标注
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2173,7 +2435,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/todo 43. 设待办/取消待办
* @api {get} api/dialog/msg/todo 45. 设待办/取消待办
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2216,7 +2478,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/todolist 44. 获取消息待办情况
* @api {get} api/dialog/msg/todolist 46. 获取消息待办情况
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2246,7 +2508,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/done 45. 完成待办
* @api {get} api/dialog/msg/done 47. 完成待办
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2288,6 +2550,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,
]);
}
@@ -2299,7 +2562,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/color 46. 设置颜色
* @api {get} api/dialog/msg/color 48. 设置颜色
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2340,7 +2603,34 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/add 47. 新增群组
* @api {post} api/dialog/msg/webhookmsg2ai 49. 转换为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 50. 新增群组
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2402,7 +2692,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/edit 48. 修改群组
* @api {get} api/dialog/group/edit 51. 修改群组
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2443,8 +2733,9 @@ class DialogController extends AbstractController
$avatar = $avatar ? Base::unFillUrl(is_array($avatar) ? $avatar[0]['path'] : $avatar) : '';
$data['avatar'] = Base::fillUrl($array['avatar'] = $avatar);
}
if (Request::exists('chat_name') && $dialog->group_type === 'user') {
$chatName = trim(Request::input('chat_name'));
$existName = Request::exists('chat_name') || Request::exists('name');
if ($existName && $dialog->group_type === 'user') {
$chatName = trim(Request::input('chat_name') ?: Request::input('name'));
if (mb_strlen($chatName) < 2) {
return Base::retError('群名称至少2个字');
}
@@ -2464,7 +2755,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/adduser 49. 添加群成员
* @api {get} api/dialog/group/adduser 52. 添加群成员
*
* @apiDescription 需要token身份
* - 有群主时:只有群主可以邀请
@@ -2500,7 +2791,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/deluser 50. 移出(退出)群成员
* @api {get} api/dialog/group/deluser 53. 移出(退出)群成员
*
* @apiDescription 需要token身份
* - 只有群主、邀请人可以踢人
@@ -2544,7 +2835,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/transfer 51. 转让群组
* @api {get} api/dialog/group/transfer 54. 转让群组
*
* @apiDescription 需要token身份
* - 只有群主且是个人类型群可以解散
@@ -2593,7 +2884,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/disband 52. 解散群组
* @api {get} api/dialog/group/disband 55. 解散群组
*
* @apiDescription 需要token身份
* - 只有群主且是个人类型群可以解散
@@ -2621,7 +2912,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/group/searchuser 53. 搜索个人群(仅限管理员)
* @api {get} api/dialog/group/searchuser 56. 搜索个人群(仅限管理员)
*
* @apiDescription 需要token身份用于创建部门搜索个人群组
* @apiVersion 1.0.0
@@ -2650,7 +2941,84 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/okr/add 54. 创建OKR评论会话
* @api {get} api/dialog/common/list 57. 共同群组群聊
*
* @apiDescription 需要token身份按置顶时间、用户在群组中的最后活跃时间倒序排列
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName common__list
*
* @apiParam {Number} [target_userid] 目标用户ID和谁的共同群组不传则获取自己所有群组
* @apiParam {Number} [page] 当前页数默认为1
* @apiParam {Number} [pagesize] 每页显示条数默认为20最大100
* @apiParam {String} [only_count] 是否只返回数量,传入 'yes' 则只返回数量不返回列表
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* - 当 only_count=yes 时:
* @apiSuccess {Number} data.total 群组数量
*
* - 当获取列表时,返回 Laravel 标准分页格式:
* @apiSuccess {Array} data.data 群组列表数据
* @apiSuccess {Number} data.current_page 当前页数
* @apiSuccess {Number} data.per_page 每页显示条数
* @apiSuccess {Number} data.total 总数量
* @apiSuccess {String} data.first_page_url 第一页链接
* @apiSuccess {String} data.last_page_url 最后页链接
* @apiSuccess {String} data.next_page_url 下一页链接
* @apiSuccess {String} data.prev_page_url 上一页链接
*/
public function common__list()
{
$user = User::auth();
//
$target_userid = intval(Request::input('target_userid'));
$only_count = trim(Request::input('only_count')) === 'yes';
// 参考getDialogList的查询模式
$builder = 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.type', 'group')
->where('d.group_type', 'user')
->whereNull('d.deleted_at');
if ($target_userid) {
// 获取与目标用户的共同群组
$builder->whereExists(function($query) use ($target_userid) {
$query->select(DB::raw(1))
->from('web_socket_dialog_users as du2')
->whereColumn('du2.dialog_id', 'd.id')
->where('du2.userid', $target_userid);
});
}
if ($only_count) {
// 只返回数量
return Base::retSuccess('success', [
'total' => $builder->count()
]);
}
// 返回分页列表参考getDialogList的排序逻辑
$list = $builder
->orderByDesc('u.top_at')
->orderByDesc('u.last_at')
->paginate(Base::getPaginate(100, 20));
// 处理分页数据与getDialogList保持一致的处理方式
$list->transform(function ($item) use ($user) {
return WebSocketDialog::synthesizeData($item, $user->userid);
});
return Base::retSuccess('success', $list);
}
/**
* @api {post} api/dialog/okr/add 58. 创建OKR评论会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2689,7 +3057,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/okr/push 55. 推送OKR相关信息
* @api {post} api/dialog/okr/push 59. 推送OKR相关信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2725,7 +3093,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/wordchain 56. 发送接龙消息
* @api {post} api/dialog/msg/wordchain 60. 发送接龙消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2811,7 +3179,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/msg/vote 57. 发起投票
* @api {post} api/dialog/msg/vote 61. 发起投票
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2927,7 +3295,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/top 58. 置顶/取消置顶
* @api {get} api/dialog/msg/top 62. 置顶/取消置顶
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -2987,7 +3355,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/topinfo 59. 获取置顶消息
* @api {get} api/dialog/msg/topinfo 63. 获取置顶消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3014,7 +3382,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/msg/applied 60. 标记消息已应用
* @api {get} api/dialog/msg/applied 64. 标记消息已应用
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3063,7 +3431,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/sticker/search 61. 搜索在线表情
* @api {get} api/dialog/sticker/search 65. 搜索在线表情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3087,7 +3455,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/config 62. 获取会话配置
* @api {get} api/dialog/config 66. 获取会话配置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3123,7 +3491,7 @@ class DialogController extends AbstractController
}
/**
* @api {post} api/dialog/config/save 63. 保存会话配置
* @api {post} api/dialog/config/save 67. 保存会话配置
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3169,7 +3537,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/session/create 64. AI-开启新会话
* @api {get} api/dialog/session/create 68. AI-开启新会话
*
* @apiDescription 需要token身份仅限与AI用户会话
* @apiVersion 1.0.0
@@ -3196,15 +3564,7 @@ class DialogController extends AbstractController
$dialog = WebSocketDialog::checkDialog($dialog_id);
}
//
if ($dialog->type != 'user') {
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) {
if (!$dialog->isSessionDialog()) {
return Base::retError('当前对话不支持');
}
//
@@ -3217,8 +3577,6 @@ class DialogController extends AbstractController
//
$session = WebSocketDialogSession::create([
'dialog_id' => $dialog->id,
'status' => 1,
'title' => '',
]);
$session->save();
$dialog->session_id = $session->id;
@@ -3228,7 +3586,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/session/list 65. AI-获取会话列表
* @api {get} api/dialog/session/list 69. AI-获取会话列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3268,7 +3626,7 @@ class DialogController extends AbstractController
}
/**
* @api {get} api/dialog/session/open 66. AI-打开会话
* @api {get} api/dialog/session/open 70. AI-打开会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -3299,4 +3657,51 @@ class DialogController extends AbstractController
//
return Base::retSuccess('success', $session);
}
/**
* @api {post} api/dialog/session/rename 71. AI-重命名会话
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName session_rename
*
* @apiParam {Number} session_id 会话ID
* @apiParam {String} title 会话名称
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function session__rename()
{
User::auth();
//
$session_id = intval(Request::input('session_id'));
$title = trim((string)Request::input('title'));
//
if ($session_id <= 0) {
return Base::retError('参数错误');
}
if ($title === '') {
return Base::retError('请输入会话名称');
}
//
$session = WebSocketDialogSession::whereId($session_id)->first();
if (empty($session)) {
return Base::retError('会话不存在或已被删除');
}
//
$dialog = WebSocketDialog::checkDialog($session->dialog_id);
if (!$dialog->isSessionDialog()) {
return Base::retError('当前对话不支持');
}
//
$session->title = Base::cutStr($title, 100);
$session->save();
$session->refresh();
Cache::forever('dialog_session_title_' . $session->id, true);
//
return Base::retSuccess('重命名成功', $session);
}
}

View File

@@ -11,11 +11,12 @@ use App\Models\FileContent;
use App\Models\FileLink;
use App\Models\FileUser;
use App\Models\User;
use App\Models\UserRecentItem;
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;
@@ -87,6 +88,12 @@ class FileController extends AbstractController
}
return Base::retError($msg, $data);
}
// 如果文件不允许游客访问,则需要登录
if (!$file->guest_access) {
User::auth();
}
$fileLink->increment("num");
} else {
return Base::retError('参数错误');
@@ -134,7 +141,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 +208,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 +514,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,
@@ -515,6 +530,16 @@ class FileController extends AbstractController
$builder->whereId($history_id);
}
$content = $builder->orderByDesc('id')->first();
if (isset($user)) {
UserRecentItem::record(
$user->userid,
UserRecentItem::TYPE_FILE,
$file->id,
UserRecentItem::SOURCE_FILESYSTEM,
intval($file->pid)
);
}
if ($down === 'preview') {
return Redirect::to(FileContent::formatPreview($file, $content?->content));
}
@@ -577,10 +602,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':
@@ -617,7 +644,7 @@ class FileController extends AbstractController
/**
* @api {get} api/file/office/token 10. 获取token
*
* @apiDescription 需要token身份
* @apiDescription 用于生成office在线编辑的token
* @apiVersion 1.0.0
* @apiGroup file
* @apiName office__token
@@ -630,7 +657,7 @@ class FileController extends AbstractController
*/
public function office__token()
{
User::auth();
File::isNeedInstallApp('office');
//
$config = Request::input('config');
$token = \Firebase\JWT\JWT::encode($config, env('APP_KEY') ,'HS256');
@@ -657,6 +684,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');
@@ -777,6 +806,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('历史数据不存在或已被删除');
@@ -965,6 +996,9 @@ class FileController extends AbstractController
* @apiParam {String} refresh 刷新链接
* - no: 只获取(默认)
* - yes: 刷新链接,之前的将失效
* @apiParam {String} guest_access 是否允许游客访问
* - no: 不允许(默认)
* - yes: 允许游客访问
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -976,9 +1010,16 @@ class FileController extends AbstractController
//
$id = intval(Request::input('id'));
$refresh = Request::input('refresh', 'no');
$guestAccess = Request::input('guest_access', 'no');
//
$file = File::permissionFind($id, $user);
// 更新文件的游客访问权限
$file->guest_access = $guestAccess === 'yes' ? 1 : 0;
$file->save();
$fileLink = $file->getShareLink($user->userid, $refresh == 'yes');
$fileLink['guest_access'] = $file->guest_access;
//
return Base::retSuccess('success', $fileLink);
}
@@ -1000,17 +1041,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 +1110,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 +1122,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 +1142,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', [

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,11 @@
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;
@@ -13,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
@@ -40,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', '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', '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 返回信息(错误描述)
@@ -87,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',
@@ -104,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'] = '';
@@ -149,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();
//
@@ -285,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
@@ -304,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');
@@ -341,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
@@ -362,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
@@ -388,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('未找到默认模型');
}
@@ -400,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
@@ -436,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',
@@ -453,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');
}
}
}
@@ -488,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;
@@ -506,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
@@ -551,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
@@ -621,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
@@ -661,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
@@ -685,7 +755,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/priority 12. 任务优先级
* @api {post} api/system/priority 13. 任务优先级
*
* @apiDescription 获取任务优先级、保存任务优先级
* @apiVersion 1.0.0
@@ -734,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
@@ -781,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
@@ -812,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' => []
];
@@ -820,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'])) {
@@ -850,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
@@ -869,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(),
@@ -879,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
@@ -894,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
@@ -911,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
@@ -1011,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
@@ -1108,7 +1143,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/fileupload 22. 上传文件
* @api {post} api/system/fileupload 21. 上传文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1152,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
@@ -1195,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
@@ -1241,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
@@ -1257,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') {
@@ -1287,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
@@ -1422,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
@@ -1482,7 +1565,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/prefetch 28. 预加载的资源
* @api {get} api/system/prefetch 27. 预加载的资源
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1522,11 +1605,13 @@ class SystemController extends AbstractController
}
// 添加office资源
$officePath = '';
$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 (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) {
@@ -1542,14 +1627,16 @@ class SystemController extends AbstractController
});
}
// 添加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;
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;
}
}
}

File diff suppressed because it is too large Load Diff

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;
@@ -27,7 +25,6 @@ use App\Tasks\ZincSearchSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar;
use Swoole\Coroutine;
/**
@@ -64,6 +61,10 @@ class IndexController extends InvokeController
$array = Base::json2array(file_get_contents($hotFile));
$style = null;
$script = preg_replace("/^(\/\/(.*?))(:\d+)?\//i", "$1:" . $array['APP_DEV_PORT'] . "/", asset_main("resources/assets/js/app.js"));
$proxyUri = Base::liveEnv('VSCODE_PROXY_URI');
if (is_string($proxyUri) && preg_match('/^https?:\/\//i', $proxyUri)) {
$script = preg_replace('/^(https?:\/\/|\/\/)[^\/]+/', rtrim($proxyUri, '/'), $script, 1);
}
} else {
$array = Base::json2array(file_get_contents($manifestFile));
$style = asset_main($array['resources/assets/js/app.js']['css'][0]);
@@ -86,6 +87,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
@@ -346,9 +356,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) {
@@ -426,13 +434,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');
@@ -502,43 +506,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,8 +19,7 @@ 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'));
@@ -76,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,15 +228,23 @@ 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 ($insert instanceof \Closure) {

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;
@@ -23,6 +24,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $size 大小(B)
* @property int|null $userid 拥有者ID
* @property int|null $share 是否共享
* @property int|null $guest_access 是否允许游客访问
* @property int|null $pshare 所属分享ID
* @property int|null $created_id 创建者
* @property \Illuminate\Support\Carbon|null $created_at
@@ -43,6 +45,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedId($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereExt($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereGuestAccess($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|File wherePid($value)
@@ -641,6 +644,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 +982,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

@@ -129,6 +129,7 @@ class Project extends AbstractModel
'projects.*',
'project_users.owner',
'project_users.top_at',
'project_users.sort',
])
->leftJoin('project_users', function ($leftJoin) use ($userid) {
$leftJoin
@@ -153,6 +154,7 @@ class Project extends AbstractModel
'projects.*',
'project_users.owner',
'project_users.top_at',
'project_users.sort',
])
->join('project_users', 'projects.id', '=', 'project_users.project_id')
->where('project_users.userid', $userid);
@@ -423,24 +425,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([
@@ -448,8 +451,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,
@@ -469,7 +473,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;
}
}
}
@@ -590,7 +594,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 流转模式
@@ -30,6 +31,7 @@ use App\Module\Base;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColumnid($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereFlowId($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

@@ -10,6 +10,7 @@ namespace App\Models;
* @property string $name 标签名称
* @property string|null $desc 标签描述
* @property string|null $color 颜色
* @property int $sort 排序
* @property int $userid 创建人
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
@@ -29,6 +30,7 @@ namespace App\Models;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUserid($value)
* @mixin \Eloquent
@@ -36,7 +38,6 @@ namespace App\Models;
class ProjectTag extends AbstractModel
{
protected $hidden = [
'created_at',
'updated_at',
];
@@ -50,6 +51,7 @@ class ProjectTag extends AbstractModel
'name',
'desc',
'color',
'sort',
'userid'
];

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]
@@ -1144,12 +1143,14 @@ class ProjectTask extends AbstractModel
*/
public function copyTask()
{
if ($this->parent_id > 0) {
throw new ApiException('子任务禁止复制');
$source = $this->fresh(['content', 'taskFile', 'taskUser']);
if (!$source) {
throw new ApiException('任务不存在');
}
return AbstractModel::transaction(function() {
// 复制任务
$task = $this->replicate();
return AbstractModel::transaction(function () use ($source) {
// 复制任务(使用最新数据,避免复制临时字段)
$task = $source->replicate();
$task->dialog_id = 0;
$task->archived_at = null;
$task->archived_userid = 0;
@@ -1158,21 +1159,21 @@ class ProjectTask extends AbstractModel
$task->created_at = Carbon::now();
$task->save();
// 复制任务内容
if ($this->content) {
$tmp = $this->content->replicate();
if ($source->content) {
$tmp = $source->content->replicate();
$tmp->task_id = $task->id;
$tmp->created_at = Carbon::now();
$tmp->save();
}
// 复制任务附件
foreach ($this->taskFile as $taskFile) {
foreach ($source->taskFile as $taskFile) {
$tmp = $taskFile->replicate();
$tmp->task_id = $task->id;
$tmp->created_at = Carbon::now();
$tmp->save();
}
// 复制任务成员
foreach ($this->taskUser as $taskUser) {
foreach ($source->taskUser as $taskUser) {
$tmp = $taskUser->replicate();
$tmp->task_id = $task->id;
$tmp->task_pid = $task->id;
@@ -1559,8 +1560,9 @@ class ProjectTask extends AbstractModel
* @param string $action
* @param array|self $data 发送内容,默认为[id, parent_id, project_id, column_id, dialog_id]
* @param array $userid 指定会员,默认为项目所有成员
* @param bool $ignoreSelf 是否忽略当前连接
*/
public function pushMsg($action, $data = null, $userid = null)
public function pushMsg($action, $data = null, $userid = null, $ignoreSelf = true)
{
if (!$this->project) {
return;
@@ -1572,6 +1574,7 @@ class ProjectTask extends AbstractModel
'project_id' => $this->project_id,
'column_id' => $this->column_id,
'dialog_id' => $this->dialog_id,
'visibility' => $this->visibility,
];
} elseif ($data instanceof self) {
$data = $data->toArray();
@@ -1582,67 +1585,75 @@ class ProjectTask extends AbstractModel
} else {
$userids = is_array($userid) ? $userid : [$userid];
}
//
$array = [];
if (Arr::exists($data, 'owner') || Arr::exists($data, 'assist')) {
$taskUser = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->get();
// 负责人
$owners = $taskUser->where('owner', 1)->pluck('userid')->toArray();
$owners = array_intersect($userids, $owners);
if ($owners) {
$array[] = [
'userid' => array_values($owners),
'data' => array_merge($data, [
'owner' => 1,
'assist' => 1,
])
];
}
// 协助人
$assists = $taskUser->where('owner', 0)->pluck('userid')->toArray();
$assists = array_intersect($userids, $assists);
if ($assists) {
$array[] = [
'userid' => array_values($assists),
'data' => array_merge($data, [
'owner' => 0,
'assist' => 1,
])
];
}
// 其他人
switch ($data['visibility']) {
case 1:
// 项目人员,除了负责人、协助人项目其他人
$userids = array_diff($userids, $owners, $assists);
break;
case 2:
// 任务人员,除了负责人、协助人
$userids = [];
break;
case 3:
// 指定成员
$specifys = ProjectTaskVisibilityUser::select(['userid'])->whereTaskId($data['id'])->pluck('userid')->toArray();
$userids = array_diff($specifys, $owners, $assists);
break;
default:
$userids = [];
break;
}
if ($userids) {
$array[] = [
'userid' => array_values($userids),
'data' => array_merge($data, [
'owner' => 0,
'assist' => 0,
])
];
}
$userids = array_values(array_unique(array_map('intval', $userids)));
if (empty($userids)) {
return;
}
//
if (!Arr::exists($data, 'visibility')) {
$data['visibility'] = $this->visibility;
}
$visibility = intval($data['visibility']);
$taskUser = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->get();
$ownerList = $taskUser->where('owner', 1)->pluck('userid')->toArray();
$assistList = $taskUser->where('owner', 0)->pluck('userid')->toArray();
$ownerUsers = array_values(array_intersect($userids, $ownerList));
$assistUsers = array_values(array_diff(array_intersect($userids, $assistList), $ownerUsers));
$array = [];
if ($ownerUsers) {
$array[] = [
'userid' => $ownerUsers,
'data' => array_merge($data, [
'owner' => 1,
'assist' => 1,
])
];
}
if ($assistUsers) {
$array[] = [
'userid' => $assistUsers,
'data' => array_merge($data, [
'owner' => 0,
'assist' => 1,
])
];
}
$otherUsers = [];
switch ($visibility) {
case 1:
$otherUsers = array_diff($userids, $ownerUsers, $assistUsers);
break;
case 2:
$otherUsers = [];
break;
case 3:
$specifys = ProjectTaskVisibilityUser::select(['userid'])->whereTaskId($data['id'])->pluck('userid')->toArray();
$otherUsers = array_diff(array_intersect($userids, $specifys), $ownerUsers, $assistUsers);
break;
default:
$otherUsers = array_diff($userids, $ownerUsers, $assistUsers);
break;
}
if ($otherUsers) {
$array[] = [
'userid' => array_values($otherUsers),
'data' => $data
];
}
if (empty($array)) {
return;
}
foreach ($array as $item) {
$params = [
'ignoreFd' => Request::header('fd'),
'ignoreFd' => $ignoreSelf ? Request::header('fd') : null,
'userid' => $item['userid'],
'msg' => [
'type' => 'projectTask',
@@ -1911,7 +1922,7 @@ class ProjectTask extends AbstractModel
// 更新任务流程
$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 {
@@ -1961,8 +1972,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

@@ -0,0 +1,154 @@
<?php
namespace App\Models;
use App\Module\Base;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ProjectTaskRelation
*
* @property int $id
* @property int $task_id 任务ID
* @property int $related_task_id 关联任务ID
* @property string $direction 关系方向: mention/mentioned_by
* @property int|null $dialog_id 来源会话ID
* @property int|null $msg_id 来源消息ID
* @property int|null $userid 提及人
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectTask|null $relatedTask
* @property-read \App\Models\ProjectTask|null $task
* @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|ProjectTaskRelation newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDialogId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDirection($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereRelatedTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUserid($value)
* @mixin \Eloquent
*/
class ProjectTaskRelation extends AbstractModel
{
public const DIRECTION_MENTION = 'mention';
public const DIRECTION_MENTIONED_BY = 'mentioned_by';
protected $fillable = [
'task_id',
'related_task_id',
'direction',
'dialog_id',
'msg_id',
'userid',
];
public function task(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'task_id');
}
public function relatedTask(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'related_task_id');
}
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
{
if ($msg->type !== 'text') {
return;
}
$payload = $msg->msg;
if (!is_array($payload)) {
$payload = Base::json2array($msg->getRawOriginal('msg'));
}
$text = $payload['text'] ?? '';
if (!$text || !preg_match_all('/<span class="mention task" data-id="(\d+)">#?(.*?)<\/span>/i', $text, $matches)) {
return;
}
$targetIds = array_values(array_unique(array_filter(array_map('intval', $matches[1] ?? []))));
if (empty($targetIds)) {
return;
}
$sourceTasks = ProjectTask::with('project')->whereDialogId($msg->dialog_id)->get();
if ($sourceTasks->isEmpty()) {
return;
}
$targetTasks = ProjectTask::with('project')->whereIn('id', $targetIds)->get()->keyBy('id');
if ($targetTasks->isEmpty()) {
return;
}
$pushTasks = [];
foreach ($sourceTasks as $sourceTask) {
foreach ($targetIds as $targetId) {
if ($targetId === $sourceTask->id) {
continue;
}
$targetTask = $targetTasks->get($targetId);
if (!$targetTask) {
continue;
}
$mentionRelation = static::updateOrCreate(
[
'task_id' => $sourceTask->id,
'related_task_id' => $targetTask->id,
'direction' => self::DIRECTION_MENTION,
],
[
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
'userid' => $msg->userid,
]
);
if ($mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()) {
$pushTasks[$sourceTask->id] = $sourceTask;
}
$reverseRelation = static::updateOrCreate(
[
'task_id' => $targetTask->id,
'related_task_id' => $sourceTask->id,
'direction' => self::DIRECTION_MENTIONED_BY,
],
[
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
'userid' => $msg->userid,
]
);
if ($reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged()) {
$pushTasks[$targetTask->id] = $targetTask;
}
}
}
foreach ($pushTasks as $task) {
$task->loadMissing('project');
if (!$task->project) {
continue;
}
$task->pushMsg('relation', null, null, false);
}
}
}

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

@@ -12,6 +12,7 @@ use App\Module\Base;
* @property int|null $userid 成员ID
* @property int|null $owner 是否负责人
* @property \Illuminate\Support\Carbon|null $top_at 置顶时间
* @property int|null $sort 排序(ASC)
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Project|null $project
@@ -28,6 +29,7 @@ use App\Module\Base;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereOwner($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereTopAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUserid($value)
@@ -74,7 +76,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

@@ -47,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
@@ -90,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'] ?: ''); // 标题
@@ -110,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;
@@ -15,15 +14,18 @@ use Carbon\Carbon;
* App\Models\User
*
* @property int $userid
* @property array $identity
* @property array $department
* @property array $identity 身份
* @property array $department 所属部门
* @property string|null $az A-Z
* @property string|null $pinyin 拼音(主要用于搜索)
* @property string|null $email
* @property string|null $email 邮箱
* @property string|null $tel 联系电话
* @property string $nickname
* @property string|null $profession
* @property string $userimg
* @property string $nickname 昵称
* @property string|null $profession 职位/职称
* @property \Illuminate\Support\Carbon|null $birthday 生日
* @property string|null $address 地址
* @property string|null $introduction 个人简介
* @property string $userimg 头像
* @property string|null $encrypt
* @property string|null $password 登录密码
* @property int|null $changepass 登录需要修改密码
@@ -34,7 +36,7 @@ use Carbon\Carbon;
* @property \Illuminate\Support\Carbon|null $line_at 最后在线时间(接口)
* @property int|null $task_dialog_id 最后打开的任务会话ID
* @property string|null $created_ip 注册IP
* @property \Illuminate\Support\Carbon|null $disable_at
* @property \Illuminate\Support\Carbon|null $disable_at 禁用时间(离职时间)
* @property int|null $email_verity 邮箱是否已验证
* @property int|null $bot 是否机器人
* @property string|null $lang 语言首选项
@@ -90,7 +92,7 @@ class User extends AbstractModel
public static $defaultAvatarMode = 'auto';
// 基本信息的字段
public static $basicField = ['userid', 'email', 'nickname', 'profession', 'department', 'userimg', 'bot', 'az', 'pinyin', 'line_at', 'disable_at'];
public static $basicField = ['userid', 'email', 'nickname', 'profession', 'birthday', 'address', 'introduction', 'department', 'userimg', 'bot', 'az', 'pinyin', 'line_at', 'disable_at'];
/**
* 昵称
@@ -173,10 +175,9 @@ class User extends AbstractModel
return UserDepartment::where('owner_userid', $this->userid)->exists();
}
/**
* 获取机器人所有者
* @return int|mixed
* @return int
*/
public function getBotOwner()
{
@@ -184,9 +185,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;
});
}));
}
/**
@@ -537,6 +538,22 @@ class User extends AbstractModel
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
@@ -723,11 +740,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;
@@ -736,18 +753,17 @@ 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;
});
}
/**

View File

@@ -5,10 +5,12 @@ namespace App\Models;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Extranet;
use App\Module\Ihttp;
use App\Module\Timer;
use App\Tasks\JokeSoupTask;
use Cache;
use Carbon\Carbon;
use Throwable;
/**
* App\Models\UserBot
@@ -20,6 +22,7 @@ use Carbon\Carbon;
* @property \Illuminate\Support\Carbon|null $clear_at 下一次清理时间
* @property string|null $webhook_url 消息webhook地址
* @property int|null $webhook_num 消息webhook请求次数
* @property array|null $webhook_events Webhook事件配置
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
@@ -44,6 +47,131 @@ use Carbon\Carbon;
*/
class UserBot extends AbstractModel
{
public const WEBHOOK_EVENT_MESSAGE = 'message';
public const WEBHOOK_EVENT_DIALOG_OPEN = 'dialog_open';
public const WEBHOOK_EVENT_MEMBER_JOIN = 'member_join';
public const WEBHOOK_EVENT_MEMBER_LEAVE = 'member_leave';
protected $casts = [
'webhook_events' => 'array',
];
/**
* 获取可选的 webhook 事件
*
* @return string[]
*/
public static function webhookEventOptions(): array
{
return [
self::WEBHOOK_EVENT_MESSAGE,
self::WEBHOOK_EVENT_DIALOG_OPEN,
self::WEBHOOK_EVENT_MEMBER_JOIN,
self::WEBHOOK_EVENT_MEMBER_LEAVE,
];
}
/**
* 标准化 webhook 事件配置
*
* @param mixed $events
* @return array
*/
public static function normalizeWebhookEvents(mixed $events, bool $useFallback = true): array
{
if (is_string($events)) {
$events = Base::json2array($events);
}
if ($events === null) {
$events = [];
}
if (!is_array($events)) {
$events = [$events];
}
$events = array_filter(array_map('strval', $events));
$events = array_values(array_intersect($events, self::webhookEventOptions()));
return $events ?: ($useFallback ? [self::WEBHOOK_EVENT_MESSAGE] : []);
}
/**
* 获取 webhook 事件配置
*
* @param mixed $value
* @return array
*/
public function getWebhookEventsAttribute(mixed $value): array
{
if ($value === null || $value === '') {
return self::normalizeWebhookEvents(null, true);
}
return self::normalizeWebhookEvents($value, false);
}
/**
* 设置 webhook 事件配置
*
* @param mixed $value
* @return void
*/
public function setWebhookEventsAttribute(mixed $value): void
{
$useFallback = $value === null;
$this->attributes['webhook_events'] = Base::array2json(self::normalizeWebhookEvents($value, $useFallback));
}
/**
* 判断是否需要触发指定 webhook 事件
*
* @param string $event
* @return bool
*/
public function shouldDispatchWebhook(string $event): bool
{
if (!$this->webhook_url) {
return false;
}
if (!preg_match('/^https?:\/\//', $this->webhook_url)) {
return false;
}
return in_array($event, $this->webhook_events ?? [], true);
}
/**
* 发送 webhook
*
* @param string $event
* @param array $payload
* @param int $timeout
* @param array $context
* @return array|null
*/
public function dispatchWebhook(string $event, array $payload, int $timeout = 30, array $context = []): ?array
{
if (!$this->shouldDispatchWebhook($event)) {
return null;
}
$payload = array_merge([
'event' => $event,
'timestamp' => time(),
'bot_uid' => $this->bot_id,
'owner_uid' => $this->userid,
], $payload);
try {
$result = Ihttp::ihttp_post($this->webhook_url, $payload, $timeout);
$this->increment('webhook_num');
return $result;
} catch (Throwable $th) {
info(Base::array2json(array_merge($context, [
'bot_userid' => $this->bot_id,
'event' => $event,
'webhook_url' => $this->webhook_url,
'error' => $th->getMessage(),
])));
return null;
}
}
/**
* 判断是否系统机器人
@@ -97,39 +225,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[] = [
@@ -180,28 +302,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('开启新会话'),
@@ -211,6 +313,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 [];
}
@@ -239,7 +362,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') {
@@ -251,16 +373,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;
}
/**
@@ -445,11 +565,12 @@ class UserBot extends AbstractModel
/**
* 创建我的机器人
* @param $userid
* @param $botName
* @param int $userid 创建人userid
* @param string $botName 机器人名称
* @param bool $sessionSupported 是否支持会话
* @return array
*/
public static function newbot($userid, $botName)
public static function newBot($userid, $botName, $sessionSupported = false)
{
if (User::select(['users.*'])
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
@@ -461,7 +582,8 @@ class UserBot extends AbstractModel
if (strlen($botName) < 2 || strlen($botName) > 20) {
return Base::retError("机器人名称由2-20个字符组成。");
}
$data = User::botGetOrCreate("user-" . Base::generatePassword(), [
$botType = ($sessionSupported ? "user-session-" : "user-normal-") . Base::generatePassword();
$data = User::botGetOrCreate($botType, [
'nickname' => $botName
], $userid);
if (empty($data)) {
@@ -469,6 +591,14 @@ class UserBot extends AbstractModel
}
$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' => '创建成功。',

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 = "";
@@ -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,16 +84,14 @@ 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) {
private static function deleteDeviceUser($userid)
{
$url = "http://face:7788/user/delete";
$data = [
'enrollid' => $userid,
@@ -99,10 +99,9 @@ class UserCheckinFace extends AbstractModel
];
$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()

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Exceptions\ApiException;
use Cache;
/**
* App\Models\UserDepartment
@@ -168,4 +169,67 @@ class UserDepartment extends AbstractModel
}
});
}
/**
* 递归获取所有子部门ID
* @param int $departmentId
* @return array
*/
public static function getAllSubDepartmentIds($departmentId)
{
$subIds = [];
$directSubs = self::whereParentId($departmentId)->pluck('id')->toArray();
foreach ($directSubs as $subId) {
$subIds[] = $subId;
// 递归获取子部门的子部门
$subSubIds = self::getAllSubDepartmentIds($subId);
$subIds = array_merge($subIds, $subSubIds);
}
return array_unique($subIds);
}
/**
* 获取部门基本信息缓存时间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();
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Lock;
use Cache;
use Carbon\Carbon;
use DeviceDetector\DeviceDetector;
@@ -78,7 +79,7 @@ class UserDevice extends AbstractModel
* @param string $hash
* @return string
*/
private static function ck(string $hash): string
public static function ck(string $hash): string
{
return "user_devices:{$hash}";
}
@@ -218,13 +219,8 @@ class UserDevice extends AbstractModel
self::record();
return $hash;
}
// 没有记录,尝试创建一个(防止升级后所有登录都失效,保证留一个可以保持登录) // todo 后期删除
return AbstractModel::transaction(function () use ($hash, $userid) {
if (self::whereUserid($userid)->withoutTrashed()->lockForUpdate()->exists()) {
return null;
}
return self::record() ? $hash : null;
});
// 没有记录
return null;
}
/**
@@ -234,46 +230,48 @@ class UserDevice extends AbstractModel
*/
public static function record(string $token = null): ?self
{
return AbstractModel::transaction(function () use ($token) {
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);
$row = self::whereHash($hash)->lockForUpdate()->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);
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();
$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;
Cache::put(self::ck($hash), $row->userid, now()->addHour());
return $row;
});
});
}

340
app/Models/UserFavorite.php Normal file
View File

@@ -0,0 +1,340 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use App\Models\File;
/**
* App\Models\UserFavorite
*
* @property int $id
* @property int|null $userid 用户ID
* @property string|null $favoritable_type 收藏类型(比如task/project/file/message)
* @property int|null $favoritable_id 收藏对象ID
* @property string $remark 收藏备注
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Model|\Eloquent $favoritable
* @property-read \App\Models\User|null $user
* @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|UserFavorite newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableType($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereRemark($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUserid($value)
* @mixin \Eloquent
*/
class UserFavorite extends AbstractModel
{
const TYPE_TASK = 'task';
const TYPE_PROJECT = 'project';
const TYPE_FILE = 'file';
const TYPE_MESSAGE = 'message';
protected $fillable = [
'userid',
'favoritable_type',
'favoritable_id',
'remark',
];
/**
* 关联用户
*/
public function user()
{
return $this->belongsTo(User::class, 'userid', 'userid');
}
/**
* 多态关联
*/
public function favoritable()
{
return $this->morphTo();
}
/**
* 切换收藏状态
* @param int $userid 用户ID
* @param string $type 收藏类型
* @param int $id 收藏对象ID
* @return array ['favorited' => bool, 'action' => 'added'|'removed']
*/
public static function toggleFavorite($userid, $type, $id)
{
$favorite = self::whereUserid($userid)
->whereFavoritableType($type)
->whereFavoritableId($id)
->first();
if ($favorite) {
// 取消收藏
$favorite->delete();
return ['favorited' => false, 'action' => 'removed', 'remark' => ''];
}
// 添加收藏
$favorite = self::create([
'userid' => $userid,
'favoritable_type' => $type,
'favoritable_id' => $id,
]);
return ['favorited' => true, 'action' => 'added', 'remark' => $favorite->remark ?? ''];
}
/**
* 更新收藏备注
* @param int $userid
* @param string $type
* @param int $id
* @param string $remark
* @return static|null
*/
public static function updateRemark($userid, $type, $id, $remark)
{
$favorite = self::whereUserid($userid)
->whereFavoritableType($type)
->whereFavoritableId($id)
->first();
if (!$favorite) {
return null;
}
$favorite->remark = $remark;
$favorite->save();
return $favorite;
}
/**
* 检查是否已收藏
* @param int $userid 用户ID
* @param string $type 收藏类型
* @param int $id 收藏对象ID
* @return bool
*/
public static function isFavorited($userid, $type, $id)
{
return self::whereUserid($userid)
->whereFavoritableType($type)
->whereFavoritableId($id)
->exists();
}
/**
* 获取用户收藏列表
* @param int $userid 用户ID
* @param string|null $type 收藏类型过滤
* @param int $page 页码
* @param int $pageSize 每页数量
* @return array
*/
public static function getUserFavorites($userid, $type = null, $page = 1, $pageSize = 20)
{
$query = self::whereUserid($userid)->orderByDesc('created_at');
if ($type) {
$query->whereFavoritableType($type);
}
$favorites = $query->paginate($pageSize, ['*'], 'page', $page);
$data = [
'tasks' => [],
'projects' => [],
'files' => [],
'messages' => []
];
// 分组收集ID
$taskIds = [];
$projectIds = [];
$fileIds = [];
$messageIds = [];
foreach ($favorites->items() as $favorite) {
switch ($favorite->favoritable_type) {
case self::TYPE_TASK:
$taskIds[] = $favorite->favoritable_id;
break;
case self::TYPE_PROJECT:
$projectIds[] = $favorite->favoritable_id;
break;
case self::TYPE_FILE:
$fileIds[] = $favorite->favoritable_id;
break;
case self::TYPE_MESSAGE:
$messageIds[] = $favorite->favoritable_id;
break;
}
}
// 批量查询具体数据
if (!empty($taskIds)) {
$tasks = ProjectTask::select([
'project_tasks.id',
'project_tasks.name',
'project_tasks.project_id',
'project_tasks.complete_at',
'project_tasks.created_at',
'project_tasks.flow_item_id',
'project_tasks.flow_item_name',
'projects.name as project_name'
])
->leftJoin('projects', 'project_tasks.project_id', '=', 'projects.id')
->whereIn('project_tasks.id', $taskIds)
->get()
->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_TASK && isset($tasks[$favorite->favoritable_id])) {
$task = $tasks[$favorite->favoritable_id];
// 解析 flow_item_name 字段格式status|name|color
$flowItemParts = explode('|', $task->flow_item_name ?: '');
$flowItemStatus = $flowItemParts[0] ?? '';
$flowItemName = $flowItemParts[1] ?? $task->flow_item_name;
$flowItemColor = $flowItemParts[2] ?? '';
$data['tasks'][] = [
'id' => $task->id,
'name' => $task->name,
'project_id' => $task->project_id,
'project_name' => $task->project_name,
'complete_at' => $task->complete_at,
'flow_item_id' => $task->flow_item_id,
'flow_item_name' => $flowItemName,
'flow_item_status' => $flowItemStatus,
'flow_item_color' => $flowItemColor,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
'remark' => $favorite->remark,
];
}
}
}
if (!empty($projectIds)) {
$projects = Project::select([
'id', 'name', 'desc', 'archived_at', 'created_at'
])->whereIn('id', $projectIds)->get()->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_PROJECT && isset($projects[$favorite->favoritable_id])) {
$project = $projects[$favorite->favoritable_id];
$data['projects'][] = [
'id' => $project->id,
'name' => $project->name,
'desc' => $project->desc,
'archived_at' => $project->archived_at,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
'remark' => $favorite->remark,
];
}
}
}
if (!empty($fileIds)) {
$files = File::select([
'id', 'name', 'ext', 'size', 'pid', 'created_at'
])->whereIn('id', $fileIds)->get()->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_FILE && isset($files[$favorite->favoritable_id])) {
$file = $files[$favorite->favoritable_id];
$fileData = File::handleImageUrl(array_merge(
$file->only(['id', 'ext']),
[
'name' => $file->name,
'size' => $file->size,
'pid' => $file->pid,
]
));
$data['files'][] = [
'id' => $file->id,
'name' => $file->name,
'ext' => $file->ext,
'size' => $file->size,
'pid' => $file->pid,
'image_url' => $fileData['image_url'] ?? null,
'image_width' => $fileData['image_width'] ?? null,
'image_height' => $fileData['image_height'] ?? null,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
'remark' => $favorite->remark,
];
}
}
}
if (!empty($messageIds)) {
$messages = WebSocketDialogMsg::select([
'id', 'dialog_id', 'userid', 'type', 'msg', 'created_at'
])->whereIn('id', $messageIds)->get()->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_MESSAGE && isset($messages[$favorite->favoritable_id])) {
$message = $messages[$favorite->favoritable_id];
// 使用 previewMsg 获取消息预览文本
$previewText = '';
if ($message->msg && is_array($message->msg)) {
$previewText = WebSocketDialogMsg::previewMsg($message);
}
// 如果没有预览文本,使用消息类型作为标题
if (empty($previewText)) {
$previewText = '[' . ucfirst($message->type) . ']';
}
$data['messages'][] = [
'id' => $message->id,
'name' => $previewText,
'dialog_id' => $message->dialog_id,
'userid' => $message->userid,
'type' => $message->type,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
'remark' => $favorite->remark,
];
}
}
}
return [
'data' => $data,
'total' => $favorites->total(),
'current_page' => $favorites->currentPage(),
'per_page' => $favorites->perPage(),
'last_page' => $favorites->lastPage(),
];
}
/**
* 清理用户收藏
* @param int $userid 用户ID
* @param string|null $type 收藏类型null表示全部类型
* @return int 删除的记录数
*/
public static function cleanUserFavorites($userid, $type = null)
{
$query = self::whereUserid($userid);
if ($type) {
$query->whereFavoritableType($type);
}
return $query->delete();
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\UserRecentItem
*
* @property int $id
* @property int $userid 用户ID
* @property string $target_type 目标类型(task/file/task_file/message_file 等)
* @property int $target_id 目标ID
* @property string $source_type 来源类型(project/filesystem/project_task/dialog 等)
* @property int $source_id 来源ID
* @property \Illuminate\Support\Carbon|null $browsed_at 浏览时间
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @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|UserRecentItem newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereBrowsedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereSourceId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereSourceType($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereTargetId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereTargetType($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereUserid($value)
* @mixin \Eloquent
*/
class UserRecentItem extends AbstractModel
{
public const TYPE_TASK = 'task';
public const TYPE_FILE = 'file';
public const TYPE_TASK_FILE = 'task_file';
public const TYPE_MESSAGE_FILE = 'message_file';
public const SOURCE_PROJECT = 'project';
public const SOURCE_FILESYSTEM = 'filesystem';
public const SOURCE_PROJECT_TASK = 'project_task';
public const SOURCE_DIALOG = 'dialog';
protected $fillable = [
'userid',
'target_type',
'target_id',
'source_type',
'source_id',
'browsed_at',
];
protected $dates = [
'browsed_at',
];
public static function record(int $userid, string $targetType, int $targetId, string $sourceType = '', int $sourceId = 0): self
{
return self::updateOrCreate(
[
'userid' => $userid,
'target_type' => $targetType,
'target_id' => $targetId,
'source_type' => $sourceType,
'source_id' => $sourceId,
],
[
'browsed_at' => Carbon::now(),
]
);
}
}

84
app/Models/UserTag.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class UserTag extends AbstractModel
{
protected $table = 'user_tags';
protected $fillable = [
'user_id',
'name',
'created_by',
'updated_by',
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by', 'userid')
->select(['userid', 'nickname']);
}
public function recognitions(): HasMany
{
return $this->hasMany(UserTagRecognition::class, 'tag_id');
}
public function canManage(User $viewer): bool
{
return $viewer->isAdmin()
|| $viewer->userid === $this->user_id
|| $viewer->userid === $this->created_by;
}
public static function listWithMeta(int $targetUserId, ?User $viewer): array
{
$query = static::query()
->where('user_id', $targetUserId)
->with(['creator'])
->withCount(['recognitions as recognition_total'])
->orderByDesc('recognition_total')
->orderBy('id');
$tags = $query->get();
$viewerId = $viewer?->userid ?? 0;
$viewerIsAdmin = $viewer?->isAdmin() ?? false;
$viewerIsOwner = $viewerId > 0 && $viewerId === $targetUserId;
$recognizedIds = [];
if ($viewerId > 0 && $tags->isNotEmpty()) {
$recognizedIds = UserTagRecognition::query()
->where('user_id', $viewerId)
->whereIn('tag_id', $tags->pluck('id'))
->pluck('tag_id')
->all();
}
$recognizedLookup = array_flip($recognizedIds);
$list = $tags->map(function (self $tag) use ($viewerId, $viewerIsAdmin, $viewerIsOwner, $recognizedLookup) {
$canManage = $viewerIsAdmin || $viewerIsOwner || $viewerId === $tag->created_by;
return [
'id' => $tag->id,
'user_id' => $tag->user_id,
'name' => $tag->name,
'created_by' => $tag->created_by,
'created_by_name' => $tag->creator?->nickname ?: '',
'recognition_total' => (int) $tag->recognition_total,
'recognized' => isset($recognizedLookup[$tag->id]),
'can_edit' => $canManage,
'can_delete' => $canManage,
];
})->values()->toArray();
return [
'list' => $list,
'top' => array_slice($list, 0, 10),
'total' => count($list),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserTagRecognition extends AbstractModel
{
protected $table = 'user_tag_recognitions';
protected $fillable = [
'tag_id',
'user_id',
];
public function tag(): BelongsTo
{
return $this->belongsTo(UserTag::class, 'tag_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'userid')
->select(['userid', 'nickname']);
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\UserTaskBrowse
*
* @property int $id
* @property int|null $userid 用户ID
* @property int|null $task_id 任务ID
* @property \Illuminate\Support\Carbon|null $browsed_at 浏览时间
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectTask|null $task
* @property-read \App\Models\User|null $user
* @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|UserTaskBrowse newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereBrowsedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereUserid($value)
* @mixin \Eloquent
*/
class UserTaskBrowse extends AbstractModel
{
protected $fillable = [
'userid',
'task_id',
'browsed_at',
];
protected $dates = [
'browsed_at',
];
/**
* 关联用户
*/
public function user()
{
return $this->belongsTo(User::class, 'userid', 'userid');
}
/**
* 关联任务
*/
public function task()
{
return $this->belongsTo(ProjectTask::class, 'task_id', 'id');
}
/**
* 记录用户浏览任务
* @param int $userid 用户ID
* @param int $task_id 任务ID
* @return UserTaskBrowse
*/
public static function recordBrowse($userid, $task_id)
{
$record = self::updateOrCreate(
[
'userid' => $userid,
'task_id' => $task_id,
],
[
'browsed_at' => Carbon::now(),
]
);
UserRecentItem::record(
$userid,
UserRecentItem::TYPE_TASK,
$task_id,
UserRecentItem::SOURCE_PROJECT,
0
);
return $record;
}
/**
* 获取用户浏览历史
* @param int $userid 用户ID
* @param int $limit 获取数量
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getUserBrowseHistory($userid, $limit = 20)
{
return self::with(['task' => function ($query) {
$query->select([
'id', 'name', 'project_id', 'column_id', 'parent_id',
'flow_item_id', 'flow_item_name',
'complete_at', 'archived_at'
]);
}])
->whereUserid($userid)
->whereHas('task', function ($query) {
// 只获取存在且未被删除的任务
$query->whereNull('archived_at');
})
->orderByDesc('browsed_at')
->limit($limit)
->get();
}
/**
* 清理用户浏览历史
* @param int $userid 用户ID
* @param int $keepCount 保留数量0表示全部删除
* @return int 删除的记录数
*/
public static function cleanUserBrowseHistory($userid, $keepCount = 100)
{
if ($keepCount === 0) {
return self::whereUserid($userid)->delete();
}
$keepIds = self::whereUserid($userid)
->orderByDesc('browsed_at')
->limit($keepCount)
->pluck('id');
return self::whereUserid($userid)
->whereNotIn('id', $keepIds)
->delete();
}
}

View File

@@ -461,7 +461,8 @@ class WebSocketDialog extends AbstractModel
*/
public function joinGroup($userid, $inviter, $important = null)
{
AbstractModel::transaction(function () use ($important, $inviter, $userid) {
$addedUserIds = [];
AbstractModel::transaction(function () use ($important, $inviter, $userid, &$addedUserIds) {
foreach (is_array($userid) ? $userid : [$userid] as $value) {
if ($value > 0) {
$updateData = [
@@ -480,6 +481,7 @@ class WebSocketDialog extends AbstractModel
]);
}, $isInsert);
if ($isInsert) {
$addedUserIds[] = $value;
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
'notice' => User::userid2nickname($value) . " 已加入群组"
], $inviter, true, true);
@@ -490,6 +492,16 @@ class WebSocketDialog extends AbstractModel
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
if ($addedUserIds) {
$meta = ['action' => 'join'];
if ($inviter > 0) {
$actor = $this->getUserSnapshots([$inviter]);
if (!empty($actor)) {
$meta['actor'] = $actor[0];
}
}
$this->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_JOIN, $addedUserIds, $meta);
}
return true;
}
@@ -503,14 +515,15 @@ class WebSocketDialog extends AbstractModel
public function exitGroup($userid, $type = 'exit', $checkDelete = true, $pushMsg = true)
{
$typeDesc = $type === 'remove' ? '移出' : '退出';
AbstractModel::transaction(function () use ($pushMsg, $checkDelete, $typeDesc, $type, $userid) {
$removedUserIds = [];
AbstractModel::transaction(function () use ($pushMsg, $checkDelete, $typeDesc, $type, $userid, &$removedUserIds) {
$builder = WebSocketDialogUser::whereDialogId($this->id);
if (is_array($userid)) {
$builder->whereIn('userid', $userid);
} else {
$builder->whereUserid($userid);
}
$builder->chunkById(100, function($list) use ($pushMsg, $checkDelete, $typeDesc, $type) {
$builder->chunkById(100, function($list) use ($pushMsg, $checkDelete, $typeDesc, $type, &$removedUserIds) {
/** @var WebSocketDialogUser $item */
foreach ($list as $item) {
if ($checkDelete) {
@@ -531,6 +544,7 @@ class WebSocketDialog extends AbstractModel
}
//
$item->delete();
$removedUserIds[] = $item->userid;
//
if ($pushMsg) {
if ($type === 'remove') {
@@ -549,6 +563,87 @@ class WebSocketDialog extends AbstractModel
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
if ($removedUserIds) {
$meta = ['action' => $type];
$operatorId = User::userid();
if ($operatorId > 0) {
$actor = $this->getUserSnapshots([$operatorId]);
if (!empty($actor)) {
$meta['actor'] = $actor[0];
}
}
$this->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_LEAVE, $removedUserIds, $meta);
}
}
/**
* 获取用户快照
* @param array $userIds
* @return array
*/
protected function getUserSnapshots(array $userIds): array
{
$userIds = array_values(array_unique(array_filter($userIds)));
if (empty($userIds)) {
return [];
}
return User::whereIn('userid', $userIds)
->get(['userid', 'nickname', 'email', 'bot'])
->map(function (User $user) {
return [
'userid' => $user->userid,
'nickname' => $user->nickname,
'email' => $user->email,
'is_bot' => (bool)$user->bot,
];
})
->values()
->all();
}
/**
* 推送成员事件到机器人 webhook
* @param string $event
* @param array $memberIds
* @param array $meta
* @return void
*/
protected function dispatchMemberWebhook(string $event, array $memberIds, array $meta = []): void
{
$memberIds = array_values(array_unique(array_filter($memberIds)));
if (empty($memberIds)) {
return;
}
$botIds = $this->dialogUser()->where('bot', 1)->pluck('userid')->toArray();
if (empty($botIds)) {
return;
}
$userBots = UserBot::whereIn('bot_id', $botIds)->get();
if ($userBots->isEmpty()) {
return;
}
$members = $this->getUserSnapshots($memberIds);
if (empty($members)) {
return;
}
$payload = array_merge([
'dialog_id' => $this->id,
'dialog_type' => $this->type,
'group_type' => $this->group_type,
'dialog_name' => $this->getGroupName(),
'members' => $members,
], array_filter($meta, fn ($value) => $value !== null));
foreach ($userBots as $userBot) {
$userBot->dispatchWebhook($event, $payload, 10, [
'dialog' => $this->id,
'event_members' => $memberIds,
]);
}
}
/**
@@ -705,6 +800,48 @@ class WebSocketDialog extends AbstractModel
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
@@ -778,6 +915,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();
@@ -814,17 +952,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;
@@ -891,7 +1029,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) {
@@ -916,40 +1054,41 @@ class WebSocketDialog extends AbstractModel
"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,11 +2,13 @@
namespace App\Models;
use Cache;
use Carbon\Carbon;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Image;
use App\Tasks\PushTask;
use App\Models\ProjectTaskRelation;
use App\Exceptions\ApiException;
use App\Tasks\WebSocketDialogMsgTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
@@ -315,6 +317,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,23 +387,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);
//
$retData = [
'add' => [],
'update' => $upData
];
$addData = [];
if ($cancel) {
$res = self::sendMsg(null, $this->dialog_id, 'todo', [
'action' => 'remove',
@@ -395,7 +407,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();
}
}
@@ -410,7 +422,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)) {
@@ -425,8 +437,18 @@ class WebSocketDialogMsg extends AbstractModel
}
}
//
$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 ? '设置成功' : '取消成功', $retData);
//
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
'add' => $addData,
'update' => $upData,
]);
}
/**
@@ -456,27 +478,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 +491,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 +678,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 +687,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);
@@ -821,6 +852,111 @@ class WebSocketDialogMsg extends AbstractModel
return $msg;
}
/**
* 提取消息内容
* 根据消息类型(文件、文本等)提取相应的内容文本
*
* @param int $maxLength 最大长度超过则截取0表示不限制
* @return string 提取出的消息文本内容
*/
public function extractMessageContent(int $maxLength = 0): string
{
$reserves = [];
switch ($this->type) {
case "file":
// 提取文件消息
$msgData = Base::json2array($this->getRawOriginal('msg'));
$result = $this->convertMentionFormat("path", $msgData['path'], $msgData['name'], $reserves);
break;
case "text":
// 提取文本消息
$result = $this->msg['text'] ?: '';
if (empty($result)) {
return '';
}
// 提取快捷键
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 ($this->msg['type'] !== 'md') {
$result = Base::html2markdown($result);
}
break;
default:
// 其他类型消息不处理
return '';
}
// 处理 reserves
foreach ($reserves as $rand => $mention) {
$result = str_replace($rand, $mention, $result);
}
// 截取最大长度
if ($maxLength > 0 && mb_strlen($result) > $maxLength) {
$result = mb_substr($result, 0, $maxLength);
}
return $result;
}
/**
* 转换提及消息格式
* 将提及的任务、文件、报告等转换为统一的格式 [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;
}
/**
* 处理文本消息内容,用于发送前
* @param $text
@@ -1197,6 +1333,7 @@ class WebSocketDialogMsg extends AbstractModel
];
$dialogMsg->updateInstance($updateData);
$dialogMsg->generateKeyAndSave($search_key);
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
//
WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($sender)->whereHide(1)->change([
'hide' => 0, // 修改消息时,显示会话(仅自己)
@@ -1263,6 +1400,7 @@ class WebSocketDialogMsg extends AbstractModel
'updated_at' => Carbon::now()->toDateTimeString('millisecond'),
]);
});
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
//
$task = new WebSocketDialogMsgTask($dialogMsg->id);
if ($push_self) {
@@ -1328,7 +1466,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']));
}
}

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

@@ -0,0 +1,933 @@
<?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-5-nano",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名资深的专业翻译专家,专门从事项目任务管理系统的多语言本地化工作。
翻译任务:将提供的文本内容翻译为 {$targetLanguage}
专业要求:
1. 术语一致性:确保项目管理、任务管理、团队协作等专业术语的准确翻译
2. 上下文理解:根据项目管理场景选择最合适的表达方式
3. 格式保持:严格保持原文的格式、结构、标点符号和排版
4. 语言规范:使用目标语言的标准表达,符合该语言的语法和习惯
5. 专业性:体现项目管理领域的专业水准和准确性
6. 简洁性:避免冗余表达,保持语言简洁明了
注意事项:
- 保留所有HTML标签、特殊符号、数字、日期格式
- 对于专有名词(如软件名称、品牌名)保持原文
- 确保翻译后的文本自然流畅,符合目标语言的表达习惯
- 如遇到歧义表达,优先选择项目管理场景下的含义
请直接返回翻译结果,不要包含任何解释或标记。
EOF
],
[
"role" => "user",
"content" => "请将以下内容翻译为 {$targetLanguage}\n\n{$text}"
]
],
]);
$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-5-nano",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一个专业的标题生成器,专门为项目任务管理系统的对话内容生成精准、简洁的标题。
要求:
1. 标题要准确概括文本的核心内容和主要意图
2. 标题长度控制在5-20个字符之间
3. 语言简洁明了,避免冗余词汇
4. 适合在项目管理场景中使用
5. 不要包含引号或特殊符号
6. 如果是技术讨论,突出技术要点
7. 如果是项目管理内容,突出关键动作或目标
8. 如果是需求讨论,突出需求的核心点
请直接返回标题,不要包含任何解释或其他内容。
EOF
],
[
"role" => "user",
"content" => "请为以下内容生成一个合适的标题:\n\n" . $text
]
],
]);
$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 string $text 用户提供的提示词
* @param array $context 上下文信息
* @return array
*/
public static function generateTask($text, $context = [])
{
// 构建上下文提示信息
$contextPrompt = self::buildTaskContextPrompt($context);
$post = json_encode([
"model" => "gpt-5-nano",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一个专业的任务管理专家,擅长将想法和需求转化为清晰、可执行的项目任务。
任务生成要求:
1. 根据输入内容分析并生成合适的任务标题和详细描述
2. 标题要简洁明了准确概括任务核心目标长度控制在8-30个字符
3. 描述需覆盖任务背景、具体要求、交付标准、风险提示等关键信息
4. 描述内容使用Markdown格式合理组织标题、列表、加粗等结构
5. 内容需适配项目管理系统,表述专业、逻辑清晰,并与用户输入语言保持一致
6. 优先遵循用户在输入中给出的风格、长度或复杂度要求默认情况下将详细描述控制在120-200字内如用户要求简单或简短则控制在80-120字内
7. 当任务具有多个执行步骤、阶段或协作角色时,请拆解出 2-6 个关键子任务;如无必要,可返回空数组
8. 子任务应聚焦单一可执行动作名称控制在8-30个字符内避免重复和含糊表述
返回格式要求:
必须严格按照以下 JSON 结构返回,禁止输出额外文字或 Markdown 代码块标记;即使某项为空,也保留对应字段:
{
"title": "任务标题",
"content": "任务的详细描述内容使用Markdown格式根据实际情况组织结构",
"subtasks": [
"子任务名称1",
"子任务名称2"
]
}
内容格式建议(非强制):
- 可以使用标题、列表、加粗等Markdown格式
- 可以包含任务背景、具体要求、验收标准等部分
- 根据任务性质灵活组织内容结构
- 仅在确有必要时生成子任务,并确保每个子任务都是独立、可执行、便于追踪的动作
- 若用户明确要求简洁或简单,保持描述紧凑,避免添加冗余段落或重复信息
上下文信息处理指南:
- 如果已有标题和内容,优先考虑优化改进而非完全重写
- 如果使用了任务模板,严格按照模板的结构和格式要求生成
- 如果已设置负责人或时间计划,在任务描述中体现相关要求
- 根据优先级等级调整任务的紧急程度和详细程度
注意事项:
- 标题要体现任务的核心动作和目标
- 描述要包含足够的细节让执行者理解任务
- 如果涉及技术开发,要明确技术要求和实现方案
- 如果涉及设计,要说明设计要求和期望效果
- 如果涉及测试,要明确测试范围和验收标准
EOF
],
[
"role" => "user",
"content" => $contextPrompt . "\n\n请根据以上上下文并结合以下提示词生成一个完整的项目任务(包含标题和详细描述):\n\n" . $text
]
],
]);
$ai = new self($post);
$ai->setTimeout(60);
$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['title']) || !isset($parsedData['content'])) {
return Base::retError("任务生成格式错误", $content);
}
$title = trim($parsedData['title']);
$markdownContent = trim($parsedData['content']);
$rawSubtasks = $parsedData['subtasks'] ?? [];
if (empty($title) || empty($markdownContent)) {
return Base::retError("生成的任务标题或内容为空", $parsedData);
}
$subtasks = [];
if (is_array($rawSubtasks)) {
foreach ($rawSubtasks as $raw) {
if (is_array($raw)) {
$name = trim($raw['title'] ?? $raw['name'] ?? '');
} else {
$name = trim($raw);
}
if (!empty($name)) {
$subtasks[] = $name;
}
if (count($subtasks) >= 8) {
break;
}
}
}
return Base::retSuccess("success", [
'title' => $title,
'content' => Base::markdown2html($markdownContent), // 将 Markdown 转换为 HTML
'subtasks' => $subtasks
]);
}
/**
* 通过 openAI 生成项目名称与任务列表
* @param string $text 用户提供的提示词
* @param array $context 上下文信息
* @return array
*/
public static function generateProject($text, $context = [])
{
$text = trim((string)$text);
if ($text === '') {
return Base::retError("项目提示词不能为空");
}
$context['current_name'] = trim($context['current_name'] ?? '');
$context['current_columns'] = self::normalizeProjectColumns($context['current_columns'] ?? []);
if (!empty($context['template_examples']) && is_array($context['template_examples'])) {
$examples = [];
foreach ($context['template_examples'] as $item) {
$name = trim($item['name'] ?? '');
$columns = self::normalizeProjectColumns($item['columns'] ?? []);
if (empty($columns)) {
continue;
}
$examples[] = [
'name' => $name,
'columns' => $columns,
];
if (count($examples) >= 6) {
break;
}
}
$context['template_examples'] = $examples;
} else {
$context['template_examples'] = [];
}
$contextPrompt = self::buildProjectContextPrompt($context);
$post = json_encode([
"model" => "gpt-5-nano",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名资深的项目规划顾问,帮助团队快速搭建符合需求的项目。
生成要求:
1. 产出一个简洁、有辨识度的项目名称不超过18个汉字或36个字符
2. 给出 3 - 8 个项目任务列表,用于看板列或阶段分组
3. 任务列表名称保持 4 - 12 个字符,聚焦阶段或责任划分,避免冗长描述
4. 结合用户描述的业务特征,必要时可包含里程碑或交付节点
5. 尽量参考上下文提供的现有内容或模板,不要与之完全重复
输出格式:
必须严格返回 JSON禁止携带额外说明或 Markdown 代码块,结构如下:
{
"name": "项目名称",
"columns": ["列表1", "列表2", "列表3"]
}
校验标准:
- 列表名称应当互不重复且语义明确
- 若上下文包含已有名称或列表,请在此基础上迭代优化
EOF
],
[
"role" => "user",
"content" => ($contextPrompt ? $contextPrompt . "\n\n" : "") . "请根据以上信息,并结合以下提示词生成适合的项目名称和任务列表:\n\n" . $text
],
],
]);
$ai = new self($post);
$ai->setTimeout(45);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("项目生成失败", $res);
}
$content = $res['data'];
$content = preg_replace('/^\s*```json\s*/', '', $content);
$content = preg_replace('/\s*```\s*$/', '', $content);
if (empty($content)) {
return Base::retError("项目生成结果为空");
}
$parsedData = Base::json2array($content);
if (!$parsedData || !isset($parsedData['name'])) {
return Base::retError("项目生成格式错误", $content);
}
$name = trim($parsedData['name']);
$columns = self::normalizeProjectColumns($parsedData['columns'] ?? []);
if ($name === '') {
return Base::retError("生成的项目名称为空", $parsedData);
}
if (empty($columns)) {
$columns = $context['current_columns'];
}
return Base::retSuccess("success", [
'name' => $name,
'columns' => $columns,
]);
}
/**
* 通过 openAI 生成聊天消息
* @param string $text 用户提供的提示词
* @param array $context 上下文信息
* @return array
*/
public static function generateMessage($text, $context = [])
{
$text = trim((string)$text);
if ($text === '') {
return Base::retError("消息提示词不能为空");
}
$contextPrompt = self::buildMessageContextPrompt($context);
$post = json_encode([
"model" => "gpt-5-nano",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名专业的沟通助手,协助用户编写得体、清晰且具行动指向的即时消息。
写作要求:
1. 根据用户提供的需求与上下文生成完整消息,语气需符合业务沟通场景,保持真诚、礼貌且高效
2. 默认使用简洁的短段落,可使用 Markdown 基础格式(加粗、列表、引用)增强结构,但不要输出代码块或 JSON
3. 如果上下文包含引用信息或草稿,请在消息中自然呼应相关要点
4. 如无特别说明,将消息长度控制在 60-180 字;若需更短或更长,遵循用户描述
5. 如需提出行动或问题,请明确表达,避免含糊
输出规范:
- 仅返回可直接发送的消息内容
- 禁止在内容前后添加额外说明、标签或引导语
EOF
],
[
"role" => "user",
"content" => ($contextPrompt ? $contextPrompt . "\n\n" : "") . "请根据以上信息,并结合以下提示词生成一条待发送的消息:\n\n" . $text
],
],
]);
$ai = new self($post);
$ai->setTimeout(45);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("消息生成失败", $res);
}
$content = trim($res['data']);
$content = preg_replace('/^\s*```(?:markdown|md|text)?\s*/i', '', $content);
$content = preg_replace('/\s*```\s*$/', '', $content);
$content = trim($content);
if ($content === '') {
return Base::retError("消息生成结果为空");
}
return Base::retSuccess("success", [
'text' => $content,
'html' => Base::markdown2html($content),
]);
}
/**
* 构建任务生成的上下文提示信息
* @param array $context 上下文信息
* @return string
*/
private static function buildTaskContextPrompt($context)
{
$prompts = [];
// 当前任务信息
if (!empty($context['current_title']) || !empty($context['current_content'])) {
$prompts[] = "## 当前任务信息";
if (!empty($context['current_title'])) {
$prompts[] = "当前标题:" . $context['current_title'];
}
if (!empty($context['current_content'])) {
$prompts[] = "当前内容:" . $context['current_content'];
}
$prompts[] = "请在此基础上优化改进,而不是完全重写。";
}
// 任务模板信息
if (!empty($context['template_name']) || !empty($context['template_content'])) {
$prompts[] = "## 任务模板要求";
if (!empty($context['template_name'])) {
$prompts[] = "模板名称:" . $context['template_name'];
}
if (!empty($context['template_content'])) {
$prompts[] = "模板内容结构:" . $context['template_content'];
}
$prompts[] = "请严格按照此模板的结构和格式要求生成内容。";
}
// 项目状态信息
$statusInfo = [];
if (!empty($context['has_owner'])) {
$statusInfo[] = "已设置负责人";
}
if (!empty($context['has_time_plan'])) {
$statusInfo[] = "已设置计划时间";
}
if (!empty($context['priority_level'])) {
$statusInfo[] = "优先级:" . $context['priority_level'];
}
if (!empty($statusInfo)) {
$prompts[] = "## 任务状态";
$prompts[] = implode("", $statusInfo);
$prompts[] = "请在任务描述中体现相应的要求和约束。";
}
return empty($prompts) ? "" : implode("\n", $prompts);
}
private static function buildProjectContextPrompt($context)
{
$prompts = [];
if (!empty($context['current_name']) || !empty($context['current_columns'])) {
$prompts[] = "## 当前项目草稿";
if (!empty($context['current_name'])) {
$prompts[] = "已有名称:" . $context['current_name'];
}
if (!empty($context['current_columns'])) {
$prompts[] = "现有任务列表:" . implode("", $context['current_columns']);
}
$prompts[] = "请在此基础上进行优化和补充。";
}
if (!empty($context['template_examples'])) {
$prompts[] = "## 常用模板示例";
foreach ($context['template_examples'] as $example) {
$line = '';
if (!empty($example['name'])) {
$line .= $example['name'] . "";
}
$line .= implode("", $example['columns']);
$prompts[] = "- " . $line;
}
$prompts[] = "可以借鉴以上结构,但要结合用户需求生成更贴合的方案。";
}
return empty($prompts) ? "" : implode("\n", $prompts);
}
private static function buildMessageContextPrompt($context)
{
$prompts = [];
if (!empty($context['dialog_name']) || !empty($context['dialog_type']) || !empty($context['group_type'])) {
$prompts[] = "## 会话信息";
if (!empty($context['dialog_name'])) {
$prompts[] = "名称:" . Base::cutStr($context['dialog_name'], 60);
}
if (!empty($context['dialog_type'])) {
$typeMap = ['group' => '群聊', 'user' => '单聊'];
$prompts[] = "类型:" . ($typeMap[$context['dialog_type']] ?? $context['dialog_type']);
}
if (!empty($context['group_type'])) {
$prompts[] = "分类:" . Base::cutStr($context['group_type'], 60);
}
}
if (!empty($context['members']) && is_array($context['members'])) {
$members = array_slice(array_filter($context['members']), 0, 10);
if (!empty($members)) {
$prompts[] = "## 会话成员";
$prompts[] = implode("", array_map(fn($name) => Base::cutStr($name, 30), $members));
}
}
if (!empty($context['recent_messages']) && is_array($context['recent_messages'])) {
$prompts[] = "## 最近消息";
foreach ($context['recent_messages'] as $item) {
$sender = Base::cutStr(trim($item['sender'] ?? ''), 40) ?: '成员';
$summary = Base::cutStr(trim($item['summary'] ?? ''), 120);
if ($summary !== '') {
$prompts[] = "- {$sender}{$summary}";
}
}
}
if (!empty($context['quote_summary'])) {
$prompts[] = "## 引用消息";
$quoteUser = Base::cutStr(trim($context['quote_user'] ?? ''), 40);
$quoteText = Base::cutStr(trim($context['quote_summary']), 200);
if ($quoteUser !== '') {
$prompts[] = "{$quoteUser}{$quoteText}";
} else {
$prompts[] = $quoteText;
}
}
if (!empty($context['current_draft'])) {
$prompts[] = "## 当前草稿";
$prompts[] = Base::cutStr(trim($context['current_draft']), 200);
}
return empty($prompts) ? "" : implode("\n", $prompts);
}
private static function normalizeProjectColumns($columns)
{
if (is_string($columns)) {
$columns = preg_split('/[\n\r,;|]/u', $columns);
}
$normalized = [];
if (is_array($columns)) {
foreach ($columns as $item) {
if (is_array($item)) {
$item = $item['name'] ?? $item['title'] ?? reset($item);
}
$item = trim((string)$item);
if ($item === '') {
continue;
}
$item = mb_substr($item, 0, 30);
if (!in_array($item, $normalized)) {
$normalized[] = $item;
}
if (count($normalized) >= 8) {
break;
}
}
}
return $normalized;
}
/**
* 通过 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-5-nano",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一个专业的内容生成器。
要求:
1. 笑话要幽默风趣,适合职场环境,内容积极正面
2. 心灵鸡汤要励志温暖,适合职场人士阅读
3. 每个笑话和鸡汤都要简洁明了尽量不超过100字
4. 必须严格按照以下JSON格式返回不要markdown格式不要包含任何其他内容
{
"jokes": [
"笑话内容1",
"笑话内容2",
...
],
"soups": [
"心灵鸡汤内容1",
"心灵鸡汤内容2",
...
]
}
EOF
],
[
"role" => "user",
"content" => "请生成20个职场笑话和20个心灵鸡汤"
]
],
]);
$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

@@ -351,7 +351,7 @@ class Base
/**
* 删除文件夹及文件夹下所有的文件
* @param $dirName
* @param bool $undeleteDir 不删除文件夹(只删除文件)
* @param bool $undeleteDir 不删除文件夹本身(只删除文件夹里面的内容
*/
public static function deleteDirAndFile($dirName, $undeleteDir = false)
{
@@ -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,13 @@ 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);
if (Request::header('Content-Type') !== 'application/json') {
$translateMsg = Doo::translate($msg);
abort($abortCode, $translateMsg, [
'X-Error-Message-Base64' => base64_encode($translateMsg),
]);
}
return Base::retError($msg, $data, $ret);
}
/**
@@ -1851,12 +1860,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);
}
/**
@@ -2574,22 +2593,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
@@ -3018,7 +3052,7 @@ class Base
{
try {
$converter = new CommonMarkConverter();
return $converter->convert($markdown);
return $converter->convert($markdown)->getContent();
} catch (\League\CommonMark\Exception\CommonMarkException $e) {
return $markdown;
}
@@ -3027,15 +3061,73 @@ class Base
/**
* html 转 MD(markdown)
* @param $html
* @param array $options
* @return mixed|string
*/
public static function html2markdown($html)
public static function html2markdown($html, $options = [])
{
try {
$converter = new HtmlConverter();
$converter = new HtmlConverter($options);
return $converter->convert($html);
} catch (\Exception) {
return $html;
}
}
/**
* 实时读取 .env 配置(不受配置缓存影响)
* @param string $key 配置键名
* @param mixed $default 默认值
* @return mixed
*/
public static function liveEnv($key, $default = null)
{
$envFile = base_path('.env');
if (!file_exists($envFile)) {
return $default;
}
$envContent = file_get_contents($envFile);
$lines = explode("\n", $envContent);
foreach ($lines as $line) {
$line = trim($line);
// 跳过注释和空行
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
// 解析 KEY=VALUE
if (str_contains($line, '=')) {
[$envKey, $envValue] = explode('=', $line, 2);
$envKey = trim($envKey);
if ($envKey === $key) {
$envValue = trim($envValue);
// 移除引号
if (preg_match('/^(["\'])(.*)\1$/', $envValue, $matches)) {
$envValue = $matches[2];
}
// 处理布尔值
$lowerValue = strtolower($envValue);
if ($lowerValue === 'true') {
return true;
}
if ($lowerValue === 'false') {
return false;
}
if ($lowerValue === 'null' || $lowerValue === '(null)') {
return null;
}
return $envValue;
}
}
}
return $default;
}
}

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,8 +96,7 @@ class Doo
*/
public static function userExpired(): bool
{
$expiredAt = self::userExpiredAt();
return $expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now());
return self::load()->userExpired();
}
/**
@@ -185,8 +105,7 @@ class Doo
*/
public static function userExpiredAt(): ?string
{
$expiredAt = self::string(self::doo()->userExpiredAt());
return $expiredAt === 'forever' ? null : $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,40 +166,42 @@ class Doo
*/
public static function tokenDecode($token): array
{
$array = Base::json2array(self::string(self::doo()->tokenDecode($token)));
$array['expired_at'] = $array['expired_at'] === 'forever' ? null : $array['expired_at'];
return $array;
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" => "简体中文",
@@ -333,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);
}
/**
@@ -342,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();
}
/**
@@ -358,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();
}
/**
@@ -370,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);
}
/**
@@ -381,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);
}
/**
@@ -397,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);
}
/**
@@ -413,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);
}
/**
@@ -427,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);
}
/**
@@ -439,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);
}
/**
@@ -466,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

@@ -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

@@ -2,6 +2,9 @@
namespace App\Module\ZincSearch;
use App\Module\Apps;
use App\Module\Doo;
/**
* ZincSearch 公共类
*/
@@ -28,6 +31,13 @@ class ZincSearchBase
*/
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);
@@ -38,7 +48,6 @@ class ZincSearchBase
$headers = ['Content-Type: application/json'];
if ($method === 'BULK') {
$headers = ['Content-Type: text/plain'];
$method = 'POST';
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

View File

@@ -4,9 +4,10 @@ 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;
use Swoole\Coroutine;
/**
* ZincSearch 会话消息类
@@ -129,6 +130,11 @@ class ZincSearchDialogMsg
*/
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' => [
@@ -144,7 +150,6 @@ class ZincSearchDialogMsg
['updated_at' => 'desc']
]
];
try {
$result = ZincSearchBase::elasticSearch(self::$indexNameMsg, $searchParams);
$hits = $result['data']['hits']['hits'] ?? [];
@@ -184,6 +189,48 @@ class ZincSearchDialogMsg
}
}
/**
* 根据用户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
@@ -228,7 +275,7 @@ class ZincSearchDialogMsg
}
// ==============================
// 基本方法
// 生成内容
// ==============================
/**
@@ -287,8 +334,12 @@ class ZincSearchDialogMsg
];
}
// ==============================
// 基本方法
// ==============================
/**
* 同步消息
* 同步消息(建议在异步进程中使用)
*
* @param WebSocketDialogMsg $dialogMsg
* @return bool
@@ -345,7 +396,7 @@ class ZincSearchDialogMsg
}
/**
* 批量同步消息
* 批量同步消息(建议在异步进程中使用)
*
* @param WebSocketDialogMsg[] $dialogMsgs
* @return int 成功同步的消息数
@@ -423,7 +474,7 @@ class ZincSearchDialogMsg
}
/**
* 同步用户
* 同步用户(建议在异步进程中使用)
* @param WebSocketDialogUser $dialogUser
* @return bool
*/
@@ -459,32 +510,28 @@ class ZincSearchDialogMsg
// 用户不存在,同步消息
if (empty($hits)) {
go(function () use ($dialogUser) {
Coroutine::sleep(0.1);
$lastId = 0; // 上次同步的最后ID
$batchSize = 500; // 每批处理的消息数量
$lastId = 0; // 上次同步的最后ID
$batchSize = 500; // 每批处理的消息数量
// 分批同步消息
do {
// 获取一批
$dialogMsgs = WebSocketDialogMsg::whereDialogId($dialogUser->dialog_id)
->where('id', '>', $lastId)
->orderBy('id')
->limit($batchSize)
->get();
// 分批同步消息
do {
// 获取一批
$dialogMsgs = WebSocketDialogMsg::whereDialogId($dialogUser->dialog_id)
->where('id', '>', $lastId)
->orderBy('id')
->limit($batchSize)
->get();
if ($dialogMsgs->isEmpty()) {
break;
}
if ($dialogMsgs->isEmpty()) {
break;
}
// 同步数据
ZincSearchDialogMsg::batchSync($dialogMsgs);
// 同步数据
ZincSearchDialogMsg::batchSync($dialogMsgs);
// 更新最后ID
$lastId = $dialogMsgs->last()->id;
} while (count($dialogMsgs) == $batchSize);
});
// 更新最后ID
$lastId = $dialogMsgs->last()->id;
} while (count($dialogMsgs) == $batchSize);
}
return true;
@@ -495,7 +542,7 @@ class ZincSearchDialogMsg
}
/**
* 删除
* 删除(建议在异步进程中使用)
*
* @param WebSocketDialogMsg|WebSocketDialogUser|int $data
* @return int

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\ZincSearch\ZincSearchDialogMsg;
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)
{
ZincSearchDialogMsg::sync($webSocketDialogMsg);
self::taskDeliver(new ZincSearchSyncTask('sync', $webSocketDialogMsg->toArray()));
}
/**
@@ -26,7 +26,7 @@ class WebSocketDialogMsgObserver
*/
public function updated(WebSocketDialogMsg $webSocketDialogMsg)
{
ZincSearchDialogMsg::sync($webSocketDialogMsg);
self::taskDeliver(new ZincSearchSyncTask('sync', $webSocketDialogMsg->toArray()));
}
/**
@@ -37,7 +37,7 @@ class WebSocketDialogMsgObserver
*/
public function deleted(WebSocketDialogMsg $webSocketDialogMsg)
{
ZincSearchDialogMsg::delete($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\ZincSearch\ZincSearchDialogMsg;
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);
ZincSearchDialogMsg::userSync($webSocketDialogUser);
self::taskDeliver(new ZincSearchSyncTask('userSync', $webSocketDialogUser->toArray()));
}
/**
@@ -41,7 +41,7 @@ class WebSocketDialogUserObserver
*/
public function updated(WebSocketDialogUser $webSocketDialogUser)
{
ZincSearchDialogMsg::userSync($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);
ZincSearchDialogMsg::delete($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,40 +211,6 @@ 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] ?? [];
}
/**
* 批量设置上下文数据
*
* @param array<string, mixed> $data
* @param string|null $requestId
* @return void
*/
public static function setMultiple(array $data, ?string $requestId = null): void
{
$requestId = $requestId ?? self::getCurrentRequestId();
if ($requestId === null) {
return;
}
self::$context[$requestId] ??= [];
self::$context[$requestId] = array_merge(self::$context[$requestId], $data);
}
/** ***************************************************************************************** */
/** ***************************************************************************************** */
/** ***************************************************************************************** */

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;
@@ -44,6 +42,7 @@ class AutoArchivedTask extends AbstractTask
->whereNotNull('project_tasks.complete_at')
->where('project_tasks.complete_at', '<=', Carbon::now()->subDays($archivedDay))
->where('project_tasks.archived_userid', 0)
->where('project_tasks.parent_id', 0)
->whereNull('project_tasks.archived_at')
->where('projects.archive_method', '!=', 'custom')
->take(100)
@@ -65,6 +64,7 @@ class AutoArchivedTask extends AbstractTask
->join('projects', 'projects.id', '=', 'project_tasks.project_id')
->whereNotNull('project_tasks.complete_at')
->where('project_tasks.archived_userid', 0)
->where('project_tasks.parent_id', 0)
->whereNull('project_tasks.archived_at')
->where('projects.archive_method', 'custom')
->whereRaw("DATEDIFF(NOW(), {$prefix}project_tasks.complete_at) >= {$prefix}projects.archive_days")

File diff suppressed because it is too large Load Diff

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

@@ -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

@@ -2,6 +2,10 @@
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;
@@ -10,12 +14,57 @@ use Illuminate\Support\Facades\Cache;
*/
class ZincSearchSyncTask extends AbstractTask
{
public function __construct()
private $action;
private $data;
public function __construct($action = null, $data = null)
{
parent::__construct();
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"));

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

774
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": "*",

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,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

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddSortFieldToProjectUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('project_users', function (Blueprint $table) {
// 添加一个排序sort字段
if (!Schema::hasColumn('project_users', 'sort')) {
$table->integer('sort')->nullable()->default(0)->after('top_at')->comment('排序(ASC)');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('project_users', function (Blueprint $table) {
// 删除排序sort字段
if (Schema::hasColumn('project_users', 'sort')) {
$table->dropColumn('sort');
}
});
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddGuestAccessToFilesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$isAdd = false;
Schema::table('files', function (Blueprint $table) use (&$isAdd) {
if (!Schema::hasColumn('files', 'guest_access')) {
$table->tinyInteger('guest_access')->nullable()->default(0)->comment('是否允许游客访问')->after('share');
$isAdd = true;
}
});
if ($isAdd) {
// 更新现有记录的guest_access字段为0默认不允许游客访问
\DB::table('files')->whereNull('guest_access')->update(['guest_access' => 0]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('files', function (Blueprint $table) {
if (Schema::hasColumn('files', 'guest_access')) {
$table->dropColumn('guest_access');
}
});
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserTaskBrowsesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_task_browses'))
return;
Schema::create('user_task_browses', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->nullable()->default(0)->comment('用户ID');
$table->bigInteger('task_id')->index()->nullable()->default(0)->comment('任务ID');
$table->timestamp('browsed_at')->index()->nullable()->comment('浏览时间');
$table->timestamps();
// 复合索引用户ID + 浏览时间(用于按时间排序获取用户浏览历史)
$table->index(['userid', 'browsed_at']);
// 唯一索引用户ID + 任务ID防止重复记录相同任务会更新浏览时间
$table->unique(['userid', 'task_id']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_task_browses');
}
}

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserRecentItemsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_recent_items')) {
return;
}
Schema::create('user_recent_items', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->default(0)->comment('用户ID');
$table->string('target_type', 50)->default('')->comment('目标类型(task/file/task_file/message_file 等)');
$table->bigInteger('target_id')->default(0)->comment('目标ID');
$table->string('source_type', 50)->default('')->comment('来源类型(project/filesystem/project_task/dialog 等)');
$table->bigInteger('source_id')->default(0)->comment('来源ID');
$table->timestamp('browsed_at')->nullable()->index()->comment('浏览时间');
$table->timestamps();
$table->index(['userid', 'browsed_at']);
$table->unique(['userid', 'target_type', 'target_id', 'source_type', 'source_id'], 'recent_unique');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_recent_items');
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserFavoritesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_favorites'))
return;
Schema::create('user_favorites', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->nullable()->default(0)->comment('用户ID');
$table->string('favoritable_type', 50)->index()->nullable()->default('')->comment('收藏类型(比如task/project/file/message)');
$table->bigInteger('favoritable_id')->index()->nullable()->default(0)->comment('收藏对象ID');
$table->timestamps();
// 复合索引用户ID + 收藏类型(用于按类型获取收藏列表)
$table->index(['userid', 'favoritable_type']);
// 唯一索引用户ID + 收藏类型 + 收藏对象ID防止重复收藏
$table->unique(['userid', 'favoritable_type', 'favoritable_id'], 'user_favorites_unique');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_favorites');
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddRemarkToUserFavoritesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('user_favorites', function (Blueprint $table) {
if (!Schema::hasColumn('user_favorites', 'remark')) {
$table->string('remark', 255)->default('')->after('favoritable_id')->comment('收藏备注');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('user_favorites', function (Blueprint $table) {
if (Schema::hasColumn('user_favorites', 'remark')) {
$table->dropColumn('remark');
}
});
}
}

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddSortToProjectTagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$added = false;
Schema::table('project_tags', function (Blueprint $table) use (&$added) {
if (!Schema::hasColumn('project_tags', 'sort')) {
$table->unsignedInteger('sort')->default(0)->after('color')->comment('排序');
$added = true;
}
});
if ($added) {
\App\Models\ProjectTag::query()
->select('project_id')
->distinct()
->orderBy('project_id')
->chunk(100, function ($projectIds) {
foreach ($projectIds as $project) {
$tags = \App\Models\ProjectTag::query()
->where('project_id', $project->project_id)
->orderByDesc('id')
->get(['id']);
$index = 0;
foreach ($tags as $tag) {
\App\Models\ProjectTag::where('id', $tag->id)->update(['sort' => $index++]);
}
}
});
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('project_tags', function (Blueprint $table) {
if (Schema::hasColumn('project_tags', 'sort')) {
$table->dropColumn('sort');
}
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
class BackfillSortProjectTaskTemplates extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (!Schema::hasTable('project_task_templates') || !Schema::hasColumn('project_task_templates', 'sort')) {
return;
}
\App\Models\ProjectTaskTemplate::query()
->select('project_id')
->distinct()
->orderBy('project_id')
->chunk(100, function ($projects) {
foreach ($projects as $project) {
$templates = \App\Models\ProjectTaskTemplate::query()
->where('project_id', $project->project_id)
->orderByDesc('id')
->get(['id']);
$index = 0;
foreach ($templates as $template) {
\App\Models\ProjectTaskTemplate::where('id', $template->id)->update(['sort' => $index++]);
}
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// no-op
}
}

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('project_task_relations', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('task_id')->comment('任务ID');
$table->unsignedBigInteger('related_task_id')->comment('关联任务ID');
$table->string('direction', 32)->default('mention')->comment('关系方向: mention/mentioned_by');
$table->unsignedBigInteger('dialog_id')->nullable()->comment('来源会话ID');
$table->unsignedBigInteger('msg_id')->nullable()->comment('来源消息ID');
$table->unsignedBigInteger('userid')->nullable()->comment('提及人');
$table->timestamps();
$table->unique(['task_id', 'related_task_id', 'direction'], 'project_task_relations_unique');
$table->index(['task_id', 'direction']);
$table->index('related_task_id');
$table->index('dialog_id');
$table->index('msg_id');
});
}
public function down(): void
{
Schema::dropIfExists('project_task_relations');
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_bots', function (Blueprint $table) {
if (!Schema::hasColumn('user_bots', 'webhook_events')) {
$table->text('webhook_events')->nullable()->after('webhook_num')->comment('Webhook事件配置');
}
});
DB::table('user_bots')
->where(function ($query) {
$query->whereNull('webhook_events')->orWhere('webhook_events', '');
})
->update(['webhook_events' => json_encode(['message'])]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_bots', function (Blueprint $table) {
if (Schema::hasColumn('user_bots', 'webhook_events')) {
$table->dropColumn('webhook_events');
}
});
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->date('birthday')->nullable()->after('profession');
$table->string('address', 255)->nullable()->after('birthday');
$table->text('introduction')->nullable()->after('address');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['birthday', 'address', 'introduction']);
});
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserTagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_tags', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id')->index()->comment('被标签用户ID');
$table->string('name', 50)->comment('标签名称');
$table->unsignedBigInteger('created_by')->index()->comment('创建人');
$table->unsignedBigInteger('updated_by')->nullable()->comment('最后更新人');
$table->timestamps();
$table->unique(['user_id', 'name'], 'user_tags_unique_name');
$table->foreign('user_id')->references('userid')->on('users')->onDelete('cascade');
$table->foreign('created_by')->references('userid')->on('users')->onDelete('cascade');
$table->foreign('updated_by')->references('userid')->on('users')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_tags');
}
}

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserTagRecognitionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_tag_recognitions', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tag_id')->index()->comment('标签ID');
$table->unsignedBigInteger('user_id')->index()->comment('认可人ID');
$table->timestamps();
$table->unique(['tag_id', 'user_id'], 'user_tag_recognitions_unique');
$table->foreign('tag_id')->references('id')->on('user_tags')->onDelete('cascade');
$table->foreign('user_id')->references('userid')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_tag_recognitions');
}
}

View File

@@ -1,7 +1,7 @@
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:
@@ -12,7 +12,7 @@ services:
- ./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"
@@ -22,9 +22,13 @@ 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:
condition: service_healthy
@@ -37,13 +41,20 @@ 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"
- extnetwork
restart: unless-stopped
redis:
@@ -57,8 +68,7 @@ services:
timeout: 5s
retries: 5
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.4"
- extnetwork
restart: unless-stopped
mariadb:
@@ -80,175 +90,34 @@ services:
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.3.0"
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.4.0-4"
environment:
KK_CONTEXT_PATH: "/fileview"
KK_OFFICE_PREVIEW_SWITCH_DISABLED: true
KK_MEDIA_CONVERT_DISABLE: true
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"
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"
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"
restart: unless-stopped
okr:
container_name: "dootask-okr-${APP_ID}"
image: "kuaifan/doookr:0.4.5"
environment:
TZ: "${TIMEZONE:-PRC}"
DOO_TASK_URL: "http://nginx"
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"
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://nginx/api/public/checkin/report"
networks:
extnetwork:
ipv4_address: "${APP_IPPR}.14"
restart: unless-stopped
search:
container_name: "dootask-search-${APP_ID}"
image: "public.ecr.aws/zinclabs/zincsearch:0.4.10"
volumes:
- search_data:/data
environment:
ZINC_DATA_PATH: "/data"
ZINC_FIRST_ADMIN_USER: "${DB_USERNAME}"
ZINC_FIRST_ADMIN_PASSWORD: "${DB_PASSWORD}"
deploy:
resources:
limits:
cpus: '2'
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}"
search_data:
name: "dootask-search-data-${APP_ID}"

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

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

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