Compare commits

...

647 Commits

Author SHA1 Message Date
kuaifan
f36317b081 fix(operation-client): 修正 WebSocket 路径格式并优化连接 URL 生成 2026-01-19 10:52:22 +08:00
kuaifan
066a5a619c build 2026-01-19 09:43:37 +08:00
kuaifan
654793156d feat(micro-app): 添加额外的事件发射器方法以支持动态事件处理 2026-01-19 09:19:27 +08:00
kuaifan
ba65378c6b fix(package): 更新 view-design-hi 依赖版本至 4.7.0-80 2026-01-19 01:14:05 +00:00
kuaifan
cb6c50b071 fix(ai-assistant): 修复弹窗和下拉菜单被其他弹窗遮挡的问题
- 使用 window.modalTransferIndex + 1000 作为动态 z-index
  - 添加定时刷新机制:弹窗可见时 5 秒刷新,不可见时 20 秒刷新
  - modal.vue 通过 zIndex prop 接收并应用 z-index
  - float-button.vue 通过 $parent.topZIndex 获取 z-index
  - Dropdown 和 Select 使用 ViewUI 新增的 z-index prop
2026-01-19 01:13:18 +00:00
kuaifan
2cb67fafe7 feat(ai-assistant): 支持任务弹窗和对话弹窗的场景检测
- 在 page-context.js 的 getPageContext 和 getSceneKey 函数中优先检测弹窗状态
  - 当 taskId > 0 时使用 single-task 上下文
  - 当 dialogModalShow && dialogId > 0 时使用 single-dialog 上下文
  - 在 welcome-prompts.js 中添加弹窗场景检测逻辑
  - 提取 formatPrompts 辅助函数减少代码重复
  - 在 index.vue 的 welcomePromptsKey 中监听 taskId 和 dialogModalShow 变化
2026-01-18 23:52:26 +00:00
kuaifan
8eaba6f364 fix(ai-assistant): 优化流式响应期间的 loading 状态显示
- 修改 loading 显示条件,streaming 状态时继续显示 loading icon
  - SSEClient 添加可选的 onFailed 回调,处理连接失败情况
  - 修复 done 事件处理,确保状态正确转为 completed
  - 解决工具调用期间 loading 动画过早消失的问题
2026-01-18 14:44:03 +00:00
kuaifan
c4f0fb5a3d feat(ai-assistant): 合并连续工具使用的显示
在 Markdown 渲染前预处理文本,将连续的 tool-use 标签合并为一行显示:
  - 连续相同工具显示计数(如 get_page_context x 2)
  - 不同工具用逗号分隔
  - 工具间的空行不会打断合并
2026-01-18 13:36:53 +00:00
kuaifan
59ad79fa58 feat(ai-assistant): 支持上下键切换历史输入
- 按 ↑ 键切换到上一条历史输入(光标在第一行时生效)
  - 按 ↓ 键切换到下一条历史输入(光标在最后一行时生效)
  - 历史记录使用 IndexedDB 持久化存储,最多保存 50 条
  - 重复输入会移动到末尾而非重复添加
  - 弹窗关闭时自动重置导航状态
2026-01-18 13:20:13 +00:00
kuaifan
c65f0276bd feat(ai-assistant): 支持编辑历史问题并重新发送
- 鼠标悬停历史问题时显示编辑图标
  - 点击编辑后在原位置显示内联编辑器
  - 支持 Enter 发送、Shift+Enter 换行、Esc 取消
  - 发送后删除该问题及之后的对话历史,重新发送编辑后的问题
  - 正确处理中文输入法组合状态,避免误触发提交
2026-01-18 12:56:16 +00:00
kuaifan
f8b335a003 feat(ai-assistant): 增加元素向量匹配与关键词搜索能力
- 新增后端 match_elements API,使用向量相似度匹配页面元素
  - 页面上下文采集支持关键词过滤,按 name/aria-label/placeholder/title 匹配
  - 关键词匹配失败时自动降级为向量搜索
  - 改进 findElementByRef 函数,使用 selector + name 双重匹配提高准确性
2026-01-18 11:50:27 +00:00
kuaifan
0ac4b546ba feat(ai-assistant): 实现 AI 前端操作能力
新增三个 MCP 工具的前端支持:
  - get_page_context: 基于 ARIA 角色收集页面元素,支持分页和区域筛选
  - execute_action: 执行导航操作(打开任务/对话、切换项目/页面)
  - execute_element_action: 元素级操作(click/type/select/focus/scroll/hover)

  新增文件:
  - operation-client.js: WebSocket 客户端,处理与 MCP Server 的通信
  - page-context-collector.js: 页面上下文收集器,ref 系统和 cursor:pointer 扫描
  - action-executor.js: 操作执行器,支持智能解析如 open_task_123
  - operation-module.js: 模块编排,整合上述模块

  修改文件:
  - float-button.vue: 集成 operation-module,AI 助手打开时启用
  - index.vue: 发射关闭事件供 float-button 监听
2026-01-18 01:35:13 +00:00
kuaifan
07a41ca0ac feat(ai-assistant): 扩充提示词库并优化随机选择策略
- 为提示词增加 type(query/action/sync/review)和 pin 属性
  - 新增 selectPrompts 函数:优先展示 pin 提示,按类型多样化抽样
  - 各场景提示词数量扩充 2-3 倍,覆盖更多常见操作
  - 部分场景使用动态数据(如 taskName、userName、groupName)个性化提示
2026-01-17 02:24:42 +00:00
kuaifan
347465fc4d feat(ai-assistant): 按场景隔离会话存储
- 将 sessionStore 从对象改为数组,每个场景独立存储
  - sessionCacheKey 改为 sessionCacheKeyPrefix,拼接场景 key 动态生成
  - initSession 改为异步方法,切换场景时按需加载对应数据
  - 使用防抖更新 displayWelcomePrompts,避免场景切换时闪屏
  - 修复输入框文字颜色样式
2026-01-17 02:24:31 +00:00
kuaifan
acb9cd317c feat(ai-assistant): 增加 SVG 图标和随机选择提示功能 2026-01-16 14:42:04 +00:00
kuaifan
b7213f8c47 feat(ai-assistant): 添加全屏切换功能
- 添加全屏按钮,支持点击或双击标题栏切换全屏
  - 全屏时禁用拖动和调整大小
  - 全屏状态下占满视口(保留 12px 边距)
  - 关闭窗口时自动退出全屏状态
2026-01-16 10:26:57 +00:00
kuaifan
a3caf5ebdf feat(ai-assistant): 支持拖动边缘调整聊天窗口大小
- 添加 8 个方向的调整大小控制点(四边 + 四角)
  - 支持从任意边缘或角落拖动调整窗口尺寸
  - 尺寸自动保存到 IndexedDB,下次打开时恢复
  - 窗口大小限制:最小 380×400,最大 800×900
  - 视口尺寸变化时自动调整窗口大小和位置
2026-01-16 10:24:41 +00:00
kuaifan
87dd07ef23 feat(ai-assistant): 基于场景标识管理会话恢复
- 新增 getSceneKey 函数,根据路由和实体生成唯一场景标识
  - 会话初始化改为按 sceneKey 匹配历史记录,相同场景恢复会话
  - 统一全局 AI 助手打开方式,manage.vue 通过事件触发 float-button
  - resumeSession 超时时间统一为 86400 秒(1天)
2026-01-16 08:49:25 +00:00
kuaifan
0cefb7eaff feat(task): 兼容 start_at/end_at 参数,统一转换为 times
- 新增 ProjectTask::normalizeTimes() 方法统一处理时间参数
  - 支持只传 end_at 时自动补充 start_at
  - 支持只传 start_at 时保留已有 end_at
2026-01-16 08:37:32 +00:00
kuaifan
ff87de9f44 feat(manage): 优化快捷键事件处理 2026-01-16 08:28:39 +00:00
kuaifan
22de7de87c feat(manage): 优化新建菜单并添加 AI 助手快捷键
- 主按钮从「新建项目」改为「新建任务」
  - 下拉菜单首位添加「AI 助手」选项(需安装 AI 插件)
  - 添加 Ctrl/Cmd+I 快捷键打开 AI 助手
  - 键盘设置页面同步显示 AI 助手快捷键
2026-01-16 07:46:50 +00:00
kuaifan
53dd9dca0f feat(ai-assistant): 浮动按钮支持拖拽到边缘自动收起
- 拖拽按钮到屏幕边缘(≤12px)松开后自动收起为窄条
  - 鼠标悬停窄条时自动展开,离开 1 秒后收起
  - 点击收起状态的窄条直接打开 AI 助手
  - 收起/展开过渡动画平滑,按钮中心位置保持不变
  - 仅在 AI 插件安装后显示浮动按钮
2026-01-16 07:46:41 +00:00
kuaifan
12d6bbea19 feat(mcp): 增强文件工具支持文本内容读取
- get_file_detail: 添加 with_content 参数提取文本
  - 新增 fetch_file_content 工具通过路径获取内容
2026-01-16 01:41:36 +00:00
kuaifan
23b06327d6 feat(file): 添加文件内容提取 API 支持分页读取
- FileController: 新增 fetch API 通过路径获取文本内容
  - FileController: one API 支持 with_text 参数提取文本
  - ManticoreFile: 实现分页提取 extractFileContentPaginated
  - TextExtractor: 添加 truncate 参数支持内容截取
2026-01-16 01:41:28 +00:00
kuaifan
6c22e373f7 build 2026-01-16 03:11:32 +08:00
kuaifan
4ebbb387ee no message 2026-01-16 03:08:25 +08:00
kuaifan
9234fe3ed1 feat(ai-assistant): 添加欢迎界面快捷提示功能和交互优化
主要变更:
  - 新增场景化快捷提示,根据页面类型显示相关操作建议
  - 重新设计欢迎界面 UI,支持图标和可点击的提示卡片
  - 修复浮动按钮点击判断逻辑(移动距离<5px 且 按下时间<200ms)
  - 优化加载状态显示,移除冗余文案
  - 支持 base64 编码格式的文件链接
2026-01-16 02:31:13 +08:00
kuaifan
70be6619e9 refactor(chat-input): 简化任务搜索逻辑
移除项目 ID 筛选条件,统一使用 scope: 'all_project' 搜索所有项目的任务。
2026-01-16 01:23:59 +08:00
kuaifan
c8c27e808f fix(chat-input): 修复 @ 提及下拉框层级问题
设置 mention 下拉容器的 zIndex 为 modalTransferIndex + 1000,
  确保在弹窗等高层级元素中正常显示。
2026-01-16 01:14:19 +08:00
kuaifan
9cb8c92492 fix(electron): 修复客户端 loadHash 域名判断逻辑
修复当 mainDomain 为 "public" 时无法正确判断域名的问题,
  改为从缓存的 cacheServerUrl 获取实际域名进行比较。
  同时修正跳转时错误使用 url 变量的问题,改为正确的 loadHash。
2026-01-16 01:08:03 +08:00
kuaifan
f4f9ee1d3d fix(ai-assistant): 修复深色模式反转样式和交互优化
- 将 no-dark-content 类从容器移动到 SVG 元素,修复深色模式样式问题
  - 添加深色模式反转时的悬浮按钮和聊天窗口样式适配
  - 支持 Escape 键关闭聊天模式窗口
  - 移除多余空白行
2026-01-16 01:07:54 +08:00
kuaifan
138336711f no message 2026-01-16 00:20:52 +08:00
kuaifan
2163bb0bff fix(electron): 修复客户端下载功能无法启动的问题
- 将 onRenderer 参数从 mainWindow 改为 getMainWindow 函数,解决模块加载时 mainWindow 为 null 导致下载无法触发的问题
  - 处理 InterruptedError 错误,避免下载中断时抛出未处理异常
2026-01-16 00:20:52 +08:00
kuaifan
bc460f0da8 fix(ai-assistant): 修复 SSE 流式响应 done 事件错误处理
- 解析 done 事件的 payload 检查是否携带错误信息
  - 移除错误提示中对 response.error 的直接展示
2026-01-15 16:18:53 +00:00
kuaifan
ad66811f49 refactor(ai-assistant): 重构页面上下文配置,支持更多页面类型
- 简化上下文提示词,移除能力范围描述
  - 新增多个独立页面上下文支持:单任务、单对话、单文件、工作汇报等
  - 传递路由参数给上下文函数,以获取实体 ID
  - 移除不必要的 title 属性
2026-01-15 16:18:42 +00:00
kuaifan
70ad8c394a feat(ai-assistant): 添加聊天窗口模式和页面上下文感知
- 新增 chat 显示模式,支持可拖拽的悬浮聊天窗口
  - 新增 page-context.js,根据当前路由提供针对性系统提示词
  - 优化浮动按钮:添加淡入淡出动画、修复右键菜单拖动问题、更新配色
  - 重构 Modal 为独立组件,支持 modal/chat 双模式切换
  - 恢复会话时自动滚动到底部
2026-01-15 15:06:38 +00:00
kuaifan
32ffecb905 feat(ai-assistant): 为各场景添加自定义标题并优化浮动按钮显示
- 为项目创建、任务创建、消息编写、汇报编辑、汇报分析场景的 AI 助手添加专属标题
  - 在模态框显示时自动隐藏浮动按钮,避免 UI 重叠
2026-01-15 10:48:56 +00:00
kuaifan
b794ba7a6b refactor(ui): 优化客户端下载入口位置
- 将仪表盘页面的客户端下载链接移至右上角用户菜单
  - 登录页保留右下角客户端下载链接
  - 新增 clientDownloadUrl 全局状态,统一管理下载地址
  - AI 浮动按钮在登录页不显示
2026-01-15 09:09:58 +00:00
kuaifan
07360a8d2c feat(manticore): 添加同步失败自动重试机制
- 新增 ManticoreSyncFailure 模型记录同步失败的条目
  - 添加 RetryManticoreSync 命令实现失败重试逻辑
  - ManticoreBase 增加 runWithRetry 包装器,连接断开时自动重连
  - 统一 deleteVector 方法,减少重复代码
  - 修复 quoteValue 传入非字符串的类型问题
2026-01-15 08:28:55 +00:00
kuaifan
fb7731ddcd feat(ai-assistant): 添加全局浮动按钮入口
- 新增 float-button.vue 组件,支持拖拽定位和位置持久化
  - 将 AIAssistant.vue 重构为目录结构(index.vue + float-button.vue)
  - 浮动按钮位置基于四角存储,窗口缩放时保持相对位置
  - 点击浮动按钮打开 AI 助手对话框
2026-01-15 08:18:34 +00:00
kuaifan
13a25e3011 fix(manticore): 修复向量表插入时的 SQL 语法错误
- 新增 executeRaw() 方法直接执行 SQL,避免 prepared statement 解析问题
  - 新增 quoteValue() 方法安全转义 SQL 值
  - 新增通用 upsertVector() 方法统一处理所有向量表插入
  - 简化 upsertMsgVector/TaskVector/FileVector/ProjectVector/UserVector 为单行调用
  - 统一 NUMERIC_FIELDS 常量,消除代码重复
  - 更新 batchUpdateVectors() 使用统一常量
2026-01-15 00:47:33 +00:00
kuaifan
055cf53738 build 2026-01-14 22:31:27 +08:00
kuaifan
cb414b48f6 refactor: 优化窗口关闭拦截机制,采用声明式注册
- 将 onBeforeUnload 从 utils.js 移至 web-tab-manager.js
- 新增声明式拦截注册机制,前端通过 registerCloseInterceptor 声明需要拦截
- 仅对已声明拦截的页面执行 JS 检查,未声明的直接关闭
- 添加 5 秒超时保护,防止网页卡死导致无法关闭窗口
- 修复 command+w 快捷键关闭整个窗口而非当前 tab 的问题
2026-01-14 22:29:36 +08:00
kuaifan
1c27719ac4 no message 2026-01-14 20:15:48 +08:00
kuaifan
ec33327408 fix: 修复文件夹上传时数据库死锁问题
使用 Redis 分布式锁对同一用户往相同父目录的上传请求进行排队,
  避免并发上传导致的 MySQL 死锁错误 (SQLSTATE[40001])
2026-01-14 11:44:47 +00:00
kuaifan
c2c27a684b feat: 复制/周期任务时复制子任务并重置状态
- 复制任务时同时复制子任务,子任务状态重置为未完成
  - 周期任务生成时,子任务状态重置为未完成并映射到 start 工作流
  - 新增 getProjectFlowItems 方法获取项目工作流状态
  - 新增 formatFlowItemName 方法格式化工作流状态名称
  - 新增 copySubTasks 方法复制子任务到新父任务
  - 新增 moveSubTasks 方法移动子任务,重构 moveTask 复用代码
2026-01-14 11:31:28 +00:00
kuaifan
224703a6d0 feat: 支持输入法组合状态,优化输入框键盘事件处理 2026-01-14 10:11:28 +00:00
kuaifan
dd20711c04 refactor: 移除冗余日志记录,优化代码可读性 2026-01-14 09:41:06 +00:00
kuaifan
3a2b7b1400 feat: 新增 AI 提示词占位符与用户上下文注入
- 新增 PromptPlaceholder 模块,负责构建用户上下文和条件性提示块
  - 用户上下文包含:基础信息、部门、同事印象、场景角色、任务列表
  - 前端使用 {{SYSTEM_OPTIONAL_PROMPTS}} 占位符,后端统一替换为实际内容
  - 重构 BotReceiveMsgTask 和 ai.js,复用 PromptPlaceholder 逻辑
  - 任务列表支持智能排序:逾期优先 → 最近活跃 → 负责人优先
2026-01-14 09:33:20 +00:00
kuaifan
792989a504 refactor: 统一 webTab 事件分发逻辑
新增 dispatchToTabBar() 函数,封装 window 模式检查逻辑:
  - window 模式无标签栏,跳过 executeJavaScript 调用
  - 避免 did-stop-loading 监听器累积导致 MaxListenersExceededWarning
  - 统一 14 处调用点,提升代码一致性和可维护性
2026-01-14 13:41:28 +08:00
kuaifan
c0183e62fb style: 统一 webTab 主题配色风格
- 深色模式:背景 #202124,活跃Tab #323639,文字 #D6D6D7
  - 浅色模式:背景 #F1F3F4,活跃Tab #FFFFFF,文字 #5F6368
  - 同步更新 WebView 默认背景色和加载页背景色
  - 更新 earth 图标选中态颜色适配新主题
  - 删除未使用的 link 图标资源
  - 语言切换时重建预加载池
2026-01-14 11:50:15 +08:00
kuaifan
ce5bb5f187 refactor: 统一 webTab 背景色设置逻辑
- 移除 createWebTabView 中冗余的深色/浅色主题背景色判断分支
  - 统一使用 utils.getDefaultBackgroundColor() 获取默认背景色
  - 移除 did-stop-loading 事件中不必要的背景色重置逻辑
2026-01-14 10:14:31 +08:00
kuaifan
a34b0c88d5 refactor: 优化 webTab 管理和状态同步
- 封装 safeCloseWebTab 方法,复用标签关闭时的未保存数据检查逻辑
  - 添加 recreatePreloadPool,支持主题切换后重建预加载池
  - broadcastCommand 扩展到 webTab views,确保子窗口收到同步消息
  - 修复 synchTheme 和 saveDialogDraft 的跨窗口参数传递
  - IDBDel 返回 Promise 并正确 await
2026-01-14 10:11:41 +08:00
kuaifan
9c7ec58bb6 no message 2026-01-14 09:14:35 +08:00
kuaifan
067a736b57 fix: 恢复窗口/标签关闭时的未保存数据检查
恢复 onBeforeUnload 功能,防止关闭窗口或标签时丢失未保存的数据:
  - 快捷键关闭:检查当前激活标签的 onBeforeUnload
  - 点击窗口关闭按钮:依次检查所有标签,遇到拦截时激活对应标签
  - 点击 tab 关闭按钮:检查对应标签的 onBeforeUnload
  - 重构 close 事件处理,使用 early return 简化代码结构
2026-01-13 14:49:59 +00:00
kuaifan
f8f08c9d0d no message 2026-01-13 14:48:05 +00:00
kuaifan
4f2d382fd6 fix: 移除 Markdown 消息中的工具使用标签 2026-01-13 12:57:54 +00:00
kuaifan
42e4ddbd17 fix: 修复权限级联同步缺口
修复 Manticore 搜索索引在特定场景下 allowed_users 权限未能正确同步的问题:

  Observer.updated 补充:
  - ProjectUserObserver: 处理项目成员移交时的权限级联
  - ProjectTaskUserObserver: 处理任务成员移交时的权限更新

  批量操作绕过 Observer 修复(delete → remove):
  - FileUser: deleteFileAll/deleteFileUser 方法
  - ProjectTask: 可见性设置时的批量删除
  - ProjectController: 子任务升级和任务复制时的批量删除

  文件批量更新封装:
  - File 新增 updateChildFilesUserid() 方法,统一处理子文件 userid
    更新及 Manticore 同步
2026-01-13 11:55:45 +00:00
kuaifan
3026cd698f feat: 添加文本换行样式以改善审批详情的可读性 2026-01-13 10:54:13 +00:00
kuaifan
47c53a18fa fix: 修复跨项目移动任务时子任务工作流状态未更新的问题
跨项目移动任务时,子任务的 flow_item_id 和 flow_item_name 没有被正确更新,
  导致子任务在新项目中显示的工作流状态与新项目的工作流不匹配。
2026-01-13 10:50:42 +00:00
kuaifan
22926e19cd refactor: 统一 dootask:// 链接处理与资源格式指南
- 将 dootask:// 协议链接处理逻辑从 AIAssistant 迁移到 DialogMarkdown 组件
  - 新增 beforeNavigate prop 支持导航前回调(如关闭弹窗)
  - 后端 BotReceiveMsgTask 添加条件性资源格式指南提示词
  - 前端 ai.js 新增 SEARCH_AI_SYSTEM_PROMPT 和 DOOTASK_RESOURCE_FORMAT_GUIDE
  - SearchBox 改用统一的 SEARCH_AI_SYSTEM_PROMPT 常量
  - 重构 ai.js 代码组织,添加注释说明各常量用途
2026-01-13 10:31:31 +00:00
kuaifan
495b25e2b1 feat: 增强 MCP 配置助手,支持多种 AI 工具
- 新增 Tabs 组件展示多种 AI 工具的配置方式
  - 支持 Claude Code、Cursor、VS Code、Windsurf、Claude Desktop、
    Codex、Kiro、Trae、Antigravity、Opencode 等工具
  - 丰富使用示例,按任务管理、项目查询、工作汇报、团队协作、
    文件查找等分类展示
  - 优化国际化支持,使用 t() 函数替代 $L() 实现中英双语
2026-01-13 08:56:20 +00:00
kuaifan
01908b7c48 no message 2026-01-13 03:56:56 +00:00
kuaifan
b138dc580d refactor: 重构 MCP 工具并新增搜索功能
主要变更:
  - 新增 search_dialogs 工具,支持按名称搜索群聊或联系人
  - 新增 intelligent_search 统一搜索工具,支持任务/项目/文件/联系人/消息
  - 重构 send_message 工具,支持 dialog_id 或 userid 两种方式
  - 重构 get_message_list 工具,支持 dialog_id 或 userid
  - 优化 get_project 并行获取项目详情和列信息
  - 统一返回字段命名 (id -> task_id/project_id/file_id/report_id)
  - 修正 HTTP 方法 (POST 用于 add/remove 操作)
  - 精简工具描述文案
2026-01-13 02:23:14 +00:00
kuaifan
78b14f4aad feat: 添加 dialog_only 参数支持仅搜索对话
在 dialog/search 接口中增加 dialog_only 可选参数,
  启用后仅搜索会话和联系人,跳过消息内容搜索。
2026-01-12 15:03:04 +00:00
kuaifan
60387aa521 refactor: 优化注释 2026-01-12 09:09:24 +00:00
kuaifan
633826cb89 refactor: 迁移到 navigationHistory API
将已废弃的 webContents 导航方法迁移到新的 navigationHistory API:
  - canGoBack() → navigationHistory.canGoBack()
  - canGoForward() → navigationHistory.canGoForward()
  - goBack() → navigationHistory.goBack()
  - goForward() → navigationHistory.goForward()
2026-01-12 07:27:18 +00:00
kuaifan
cf6d180fc5 feat: 添加 webTab 预加载池机制
引入预加载池以优化 webTab 首屏加载性能:
  - 应用启动后延迟创建预加载 view,避免影响主窗口
  - 新建 tab 时优先复用已预加载的 view
  - 取走后自动延迟补充,保持池容量
  - 应用退出前清理预加载资源
2026-01-12 06:55:57 +00:00
kuaifan
0d85174250 feat: 添加 favicon 双层缓存机制
实现仿 Chrome 的 favicon 缓存系统:
  - 第一层:域名缓存 - 导航开始时立即查询,快速显示 favicon
  - 第二层:URL 缓存 - favicon URL 精确匹配
  - 支持内存缓存 + 文件持久化,应用启动时自动清理 30 天过期缓存
2026-01-12 05:40:57 +00:00
kuaifan
925449c66a refactor: 抽离 webTab 窗口管理为独立模块
将 electron.js 中 1000+ 行的 webTab 窗口管理逻辑抽离到
  electron/lib/web-tab-manager.js,提升代码可维护性
2026-01-12 05:15:32 +00:00
kuaifan
cd58b418af refactor: 新增 updateWindow 接口并移除废弃的预加载窗口调用
- 新增 updateWindow IPC handler,支持窗口/标签页内部导航时更新 URL 和名称
  - 将前端 updateChildWindow 调用替换为 updateWindow
  - 移除 reloadPreloadWindow 调用(预加载窗口已删除)
2026-01-12 01:44:34 +00:00
kuaifan
4cfc5e6024 refactor: 移除 userAgent 相关代码以简化窗口管理逻辑 2026-01-12 09:07:10 +08:00
kuaifan
7321ab06f0 refactor: 优化窗口尺寸和位置管理逻辑 2026-01-12 09:02:58 +08:00
kuaifan
790f5d4838 refactor: 统一 Electron 子窗口与标签页窗口管理
将原有独立子窗口 (childWindow) 和标签页窗口 (webTabWindow) 合并为统一的
  窗口管理系统,通过 mode 参数区分窗口类型:
  - mode='tab': 标签页模式(有导航栏,默认)
  - mode='window': 独立窗口模式(无导航栏)

  主要变更:
  - 移除 createChildWindow、preCreateChildWindow 等独立窗口相关代码
  - 扩展 createWebTabWindow 支持 mode 参数
  - 简化前端 openWindow 调用,将 config 对象扁平化为顶层参数
  - 更新所有调用点使用新的统一接口
2026-01-11 21:13:55 +00:00
kuaifan
731dbc5507 feat: 标签页新增更多菜单功能
- 新增更多菜单按钮替代原浏览器打开按钮
  - 实现重新加载、复制链接地址、默认浏览器打开功能
  - 实现将标签页移至新窗口功能
  - 实现打印功能
  - 菜单支持根据当前 URL 类型动态启用/禁用选项
  - 添加相关国际化文案
2026-01-10 16:35:19 +00:00
kuaifan
3b1dce6d67 feat: 标签页新增更多菜单按钮
- 将原浏览器打开按钮替换为更多菜单按钮
  - 添加 more.svg 图标并调整样式
  - 实现 webTabShowMenu 通信接口及菜单框架
2026-01-10 15:47:43 +00:00
kuaifan
4929d44ce7 refactor: 优化标签页加载状态管理与 URL 加载逻辑
- 新增 loadContentUrl 方法统一处理完整 URL 和相对路径的加载
  - 优化标签页加载状态,忽略 SPA 路由切换(isSameDocument),避免频繁闪烁
  - 添加定时检查器确保加载状态正确停止
  - windowClose/windowDestroy 支持识别 tab 页面发送者,仅关闭对应标签
  - 子窗口重启过程中不再意外销毁窗口
  - 微应用打开标签页时传递标题信息
  - isLocalHost 对空 URL 和相对路径返回 true
2026-01-10 15:44:58 +00:00
kuaifan
ce42c2a660 refactor(frontend): 统一域名获取与比较逻辑
- 新增 mainDomain() 函数,简化 mainUrl 域名获取
  - 新增 removeMainUrlPrefix() 函数,用于移除 URL 的服务器域名前缀
  - getDomain() 返回值统一转为小写,确保域名比较不受大小写影响
  - 将多处 getDomain(mainUrl()) 调用替换为 mainDomain(),提升代码可读性
2026-01-10 05:48:25 +00:00
kuaifan
16d5ffd4f9 refactor: 统一客户端窗口打开接口并支持标签页名称复用
- 合并 openChildWindow 和 openWebTabWindow 为统一的 openWindow 接口
  - 新增 webTabNameMap 映射,支持按名称查找和复用已存在的标签页
  - 标签页增加 name、titleFixed 元数据支持
  - 窗口间转移标签时同步更新名称映射
  - 重构前端 actions,统一使用 openWindow 方法,通过 mode 参数区分窗口/标签模式
  - 更新所有调用点使用新的统一接口
2026-01-10 02:08:36 +00:00
kuaifan
fc74e0d952 feat: 标签页拖拽合并时支持插入到鼠标所在位置
- getAllWebTabWindowsInfo 增加返回 tabCount 用于计算标签位置
  - attachToWindow 根据鼠标 screenX 和目标窗口标签信息计算插入位置
  - 拖拽标签合并到其他窗口时插入到鼠标位置而非总在末尾
2026-01-09 15:17:21 +00:00
kuaifan
089f219280 feat: 标签页拖拽创建新窗口时窗口定位优化及 favicon 验证
- 优化拖拽标签创建新窗口时的位置计算,使用 setPosition 确保窗口出现在鼠标位置
  - 重构 createWebTabWindowInstance 函数,仅在明确指定 x/y 时设置窗口坐标
  - 新增 fetchFaviconAsBase64 工具函数,在主进程验证 favicon 并转为 base64
  - favicon 验证后再保存和传递给前端,确保拖拽后 icon 状态与原窗口一致
  - 简化前端 favicon 处理逻辑,移除重复的图片验证代码
2026-01-09 13:58:22 +00:00
kuaifan
9d62ec1ec1 feat: 添加标签页拖拽排序功能
- 引入 Sortable.js 库以支持标签页的拖拽排序
- 实现标签页的动态插入和顺序重排
- 更新样式以适应拖拽效果
- 增加 IPC 通信以同步标签页顺序变化
- 优化标签页创建和关闭逻辑,提升用户体验
2026-01-09 15:46:02 +08:00
kuaifan
5a4e51d1e0 no message 2026-01-08 14:18:45 +00:00
kuaifan
f0982d7d9a efactor: 拆分 electron 主进程代码为独立模块
将 electron.js 中的 PDF 导出、渲染器辅助函数和工具函数拆分为独立模块:
  - electron/lib/pdf-export.js: PDF 导出相关功能
  - electron/lib/renderer.js: 渲染器辅助函数
  - electron/lib/other.js: 平台检测和 URL 验证常量

  此重构提高了代码可维护性,减少了主文件的复杂度。
2026-01-08 13:54:55 +00:00
kuaifan
1ac3a4cc96 feat: 添加 user_update hook 事件并重构用户生命周期 hook
- 新增 user_update 事件,当用户基本信息变更时触发
  - 扩展 dispatchUserHook payload 包含完整用户信息(tel、profession、birthday、address、introduction、departments)
  - 将 user_onboard/user_offboard/user_update hook 触发逻辑集中到 UserObserver
  - 区分 profile_update(用户自己修改)和 admin_update(管理员修改)事件类型
  - 修复 User::reg() 中 Manticore 索引同步遗漏问题
  - 排除机器人账号的 hook 触发
2026-01-08 11:31:16 +00:00
kuaifan
7f9c42d3d8 no message 2026-01-07 04:11:42 +00:00
kuaifan
4e99e398d6 feat: 添加动态时间提示和自动校正功能
- 在"最早可提前"下方动态显示最早可签到时间
  - 在"最晚可延后"下方动态显示最晚可签到时间(跨天显示"次日"前缀)
  - 输入值变化时自动校正到临界值,防止时间重叠
  - 调整表单布局支持换行显示提示信息
2026-01-07 04:11:32 +00:00
kuaifan
395fc155ce feat: 使用用户头像作为封面背景
在用户详情弹窗的顶部封面区域,使用用户头像作为模糊背景,
提升视觉效果和个性化体验。

- 将用户头像通过 CSS 变量传递给封面区域
- 添加背景模糊滤镜和缩放效果
- 修复容器溢出问题
2026-01-07 03:11:34 +00:00
kuaifan
6bdefc4f03 feat: 支持跨天打卡和时间重叠验证
- 允许签到"最晚可延后"时间超过 23:59:59,支持员工凌晨下班打卡
  - 凌晨打卡记录自动归属前一天
  - 前后端新增提前/延后时间重叠验证,防止产生歧义时间窗口
  - 优化导出逻辑以正确处理跨天打卡记录
  - 打卡消息提示归属日期信息
2026-01-06 12:31:41 +00:00
kuaifan
d4547cbe97 refactor: 移除语言偏好部分,简化文档内容 2026-01-06 08:57:38 +00:00
kuaifan
c9a0b7481a feat: 统一用户编辑入口为独立弹窗组件
- 新增 UserEditModal 组件,整合昵称、电话、职位、邮箱、密码、部门、个人简介、个性标签编辑
  - 签到模式下支持编辑人脸图片和 MAC 地址,并高亮显示相关字段
  - TeamManagement 移除分散的编辑入口(快捷修改、修改邮箱/密码/部门/人脸/MAC 等菜单)
  - 简化 operationUser 方法,移除冗余的 data/watch/methods
2026-01-06 08:55:04 +00:00
kuaifan
f496bc5fca feat: Optimize search functionality and AI module integration
- Refactor Manticore search classes for better performance
- Update AI module with enhanced processing capabilities
- Improve Apps module functionality
- Enhance SearchBox Vue component with new features
2026-01-06 07:25:23 +00:00
kuaifan
4ba02b9dce feat: 优化 remove_by_network 函数以批量删除容器并处理空容器情况 2026-01-06 02:13:15 +00:00
kuaifan
f821e5ad28 refactor: 移除缓存写入逻辑并简化未获取向量填充过程 2026-01-05 12:10:17 +00:00
kuaifan
425f7b6f79 fix: 修复多标签窗口关闭后事件回调导致的崩溃 2026-01-05 09:36:22 +00:00
kuaifan
61d7970b6a feat: 更新 remove_by_network 函数以删除所有状态的容器并等待网络清空 2026-01-05 09:35:39 +00:00
kuaifan
1aa9984535 fix: 会话列表待办完成消息显示最后完成者 2026-01-05 06:31:14 +00:00
kuaifan
8ab810c670 feat: 将 Manticore 相关检查更新为使用 "search" 应用 2026-01-05 05:51:48 +00:00
kuaifan
5cc3d60e15 feat: 添加交互规范,建议在提问时附带具体选项以帮助用户决策 2026-01-05 02:27:18 +00:00
kuaifan
42a2eb56c7 feat: 升级语音识别模型并优化转写逻辑
- 语音识别模型从 whisper-1 升级到 gpt-4o-mini-transcribe
   - 根据用户语言设置自动添加简繁体中文提示词
   - 录音转文字新增 dialog_id 参数,支持获取对话上下文提高识别准确率
   - 移除前端语言手动选择功能,简化用户操作
   - 添加参数空值保护
   - 优化 reasoning_effort 参数逻辑,区分 gpt-5 和 gpt-5.1+ 版本
2026-01-05 02:26:36 +00:00
kuaifan
4b0f4e388c feat: 优化 Manticore 相关描述 2026-01-04 13:30:03 +00:00
kuaifan
31045b3808 feat: 更新 Manticore 数据库插入逻辑,添加 userid 和 tags 字段;在 WebSocket 消息删除时同步 Manticore 2026-01-04 07:48:53 +00:00
kuaifan
a95f22bf42 feat: 添加 ManticoreSyncTask 的去重功能,优化任务投递逻辑 2026-01-04 07:48:32 +00:00
kuaifan
fa84f92577 feat: 添加 ProjectTaskContentObserver 以处理任务内容的创建、更新和删除事件 2026-01-04 07:24:36 +00:00
kuaifan
90a5624877 feat: 添加用户标签功能,更新用户索引以支持标签创建、更新和删除事件 2026-01-04 07:13:13 +00:00
kuaifan
f42250b8b7 feat: 重构文件管理界面,优化文件操作区域布局和样式 2026-01-04 06:13:44 +00:00
kuaifan
b9809d207d feat: 添加同步 responseSeed 方法,避免与已有响应 localId 冲突 2026-01-04 01:40:25 +00:00
kuaifan
0d8e10b60e feat: 优化 IDBClear 方法,支持保留指定键的缓存项 2026-01-04 01:40:13 +00:00
kuaifan
501ff21e55 feat: 添加数值类型转换功能,确保查询结果中的数值类型一致性 2026-01-04 00:29:29 +00:00
kuaifan
4759e28a56 feat: 在 DialogWrapper 组件中添加 search_type 属性以支持文本搜索 2026-01-03 23:20:56 +00:00
kuaifan
bd7841ac05 feat: 添加 TTY 参数检测,优化 Docker 命令执行 2026-01-03 23:09:59 +00:00
kuaifan
ea0d27fdea feat: 添加 Manticore 同步命令通用锁机制,优化信号处理与锁管理 2026-01-03 23:09:50 +00:00
kuaifan
610979f30b feat: Enhance Manticore sync commands with incremental processing and sleep options
- Updated sync commands (SyncFileToManticore, SyncMsgToManticore, SyncProjectToManticore, SyncTaskToManticore, SyncUserToManticore) to support continuous incremental updates until completion.
- Added --sleep option to allow a pause between batches in incremental mode.
- Improved signal handling to allow graceful shutdown during processing.
- Adjusted lock duration to 30 minutes for long-running processes.
- Enhanced logging for better visibility of sync progress and completion.
- Updated ManticoreSyncTask to ensure commands run continuously and check for new data every 2 minutes.
2026-01-03 22:41:49 +00:00
kuaifan
9a8304d595 feat: 增强 Manticore 向量更新逻辑,记录更新失败的 ID 2026-01-03 21:59:44 +00:00
kuaifan
e020a80020 feat: Add batch embedding retrieval and vector update methods for Manticore integration
- Implemented `getBatchEmbeddings` method in AI module for retrieving embeddings for multiple texts.
- Added vector update methods for messages, files, tasks, projects, and users in ManticoreBase.
- Enhanced ManticoreFile, ManticoreMsg, ManticoreProject, ManticoreTask, and ManticoreUser to support vector generation during sync operations.
- Introduced `generateVectorsBatch` methods for batch processing of vector generation in Manticore modules.
- Updated ManticoreSyncTask to handle incremental updates and vector generation asynchronously.
2026-01-03 15:19:23 +00:00
kuaifan
7a21a2d800 refactor: 统一搜索接口,移除 dialog/msg/search
- 前端 DialogWrapper.vue 改用 search/message 接口
  - 删除 DialogController::msg__search 方法
  - search/message 已完全覆盖原接口功能
2026-01-03 13:04:40 +00:00
kuaifan
ec0db3a76c refactor: 提取搜索逻辑到 Model Scope
- User: 新增 scopeSearchByKeyword
  - Project: 新增 scopeSearchByKeyword
  - ProjectTask: 新增 scopeSearchByKeyword
  - File: 新增 scopeSearchByKeyword, scopeSharedToUser
  - WebSocketDialogMsg: 新增 scopeSearchByKeyword, scopeAccessibleByUser
  - SearchController: 使用新的 Model Scope 简化 MySQL 回退逻辑
2026-01-03 07:58:11 +00:00
kuaifan
67fc0781e5 feat: 添加 Claude Code 配置文件
- 创建 CLAUDE.md 项目指南
  - 添加 .claude/rules/graphiti.md Graphiti 长期记忆集成规则
2026-01-03 07:33:35 +00:00
kuaifan
79c2ba140c feat: 更新搜索功能,统一搜索接口,优化请求参数 2026-01-03 04:42:15 +00:00
kuaifan
908171a977 feat: 新增对话ID参数支持,优化搜索功能以支持对话过滤 2026-01-03 03:59:51 +00:00
kuaifan
a52dc14369 feat: Enhance AIAssistant and SearchBox components with improved link handling and search functionality
- Updated AIAssistant to support parsing of additional message links in the format dootask://message/id1/id2.
- Modified search methods in SearchBox to streamline API calls and remove AI search logic, improving performance and clarity.
- Cleaned up unused AI search code and adjusted search result handling for better data presentation.
- Updated documentation to reflect new link formats for tasks, projects, files, and messages.
2026-01-02 09:48:52 +00:00
kuaifan
1e94ce501e refactor: 移除 ZincSearch,统一使用 Manticore Search
- 删除 ZincSearch 模块、任务、命令
- 对话消息搜索改用 ManticoreMsg::searchDialogs
- 移除 Observer 中的 ZincSearch 同步
- 移除定时任务中的 ZincSearch 同步
- 更新项目文档
2026-01-02 07:25:14 +00:00
kuaifan
7a5ef3a491 feat: 新增消息搜索功能
- 新增 msg_vectors 表,支持消息全文/向量/混合搜索
- 采用 MVA 权限方案,allowed_users 内联存储
- 新增 /api/search/message API
- 新增 manticore:sync-msgs 同步命令
- Observer 触发消息创建/更新/删除同步
- Observer 触发对话成员变更时更新 allowed_users
2026-01-02 06:46:18 +00:00
kuaifan
c08323e1ea feat: 迁移至 MVA 权限方案
- 表结构:为 file/project/task_vectors 添加 allowed_users MULTI 字段
- 删除关系表:file_users, project_users, task_users
- 搜索:使用 allowed_users = userid 进行权限过滤
- 同步:sync 时自动计算并写入 allowed_users
- 级联:项目成员变更异步级联 v=1 任务,任务成员变更递归更新子任务
- 覆盖场景:visibility/parent_id/project_id 变更、子任务升级主任务等
2026-01-02 02:03:21 +00:00
kuaifan
fdf5ceeaab feat: Enhance Manticore integration and AI model support
- Added support for specifying vector dimensions in AI payloads for compatible vendors.
- Updated default AI model from 'text-embedding-ada-002' to 'text-embedding-3-small'.
- Refactored ManticoreBase to bind parameters explicitly for PDO statements, improving type handling.
- Adjusted SQL queries across Manticore modules to remove content previews and ensure inline vector values.
- Updated content preview handling in ManticoreFile, ManticoreProject, ManticoreTask, and ManticoreUser to use substrings for better data management.
2026-01-01 08:59:54 +00:00
kuaifan
48ef4cfdef refactor: 使用 Manticore Search 替换 SeekDB 2026-01-01 03:17:27 +00:00
kuaifan
10c6177a9f no message 2025-12-31 16:55:33 +00:00
kuaifan
0362c83e77 feat: 支持 AI 助手输入框回车快捷操作
- 新增 onInputKeydown 方法:支持回车发送、Shift+Enter 换行,提升输入体验。
- 更新输入框组件,绑定键盘事件,实现更流畅的交互。
- 自动聚焦输入框,提升用户体验。
2025-12-31 09:57:34 +00:00
kuaifan
1af29837e2 feat: 增加增量同步功能以优化 SeekDB 用户关系同步
- 在 SyncFileToSeekDB、SyncProjectToSeekDB 和 SyncTaskToSeekDB 中实现增量同步逻辑,支持只同步新增的用户关系。
- 新增 syncFileUsersIncremental、syncProjectUsersIncremental 和 syncTaskUsersIncremental 方法,提升数据同步效率。
- 更新相关命令行输出信息,以清晰指示同步状态和进度。
2025-12-31 09:28:10 +00:00
kuaifan
986c4871df feat: Enhance AI Assistant with session management and improved UI
- Added session management capabilities to the AI Assistant, allowing users to create, load, and delete sessions.
- Improved modal UI with a new header for session actions and a footer for model selection.
- Updated input handling to support dynamic loading of session data and improved response formatting.
- Enhanced search functionality in various components to utilize the AI Assistant for generating content based on user input.
2025-12-31 08:47:03 +00:00
kuaifan
fe7a2a0e73 feat: 扩展 SeekDB 支持联系人、项目、任务的 AI 搜索
- 合并 SeekDBFileSyncTask 到 SeekDBSyncTask
- 统一 AI 搜索 API 入口
2025-12-30 07:48:00 +00:00
kuaifan
23faf28f7f feat: 集成 SeekDB AI 搜索引擎实现文件内容搜索 2025-12-30 05:49:26 +00:00
kuaifan
a8d4f261a4 no message 2025-12-30 05:49:18 +00:00
kuaifan
a336fd4a1a feat: omit content from report list APIs 2025-12-30 01:58:03 +00:00
kuaifan
8759e6fd7e build 2025-12-30 09:20:59 +08:00
kuaifan
92d23014a7 fix: avoid opening blank dialog window when dialogId is 0 2025-12-29 16:22:06 +00:00
kuaifan
7c3f33ea0d fix: avoid mutating task getter arrays in mention list 2025-12-29 16:01:37 +00:00
kuaifan
16a55de6f1 feat: 增强搜索功能,支持通过 ID、名称和其他字段搜索任务、文件和报告 2025-12-29 15:43:50 +00:00
kuaifan
869ac7d316 feat: 更新 appstore 镜像版本至 0.3.8 2025-12-27 10:29:51 +00:00
kuaifan
55303689ea feat: support configurable default priority 2025-12-26 02:42:47 +00:00
kuaifan
c69123ac92 no message 2025-12-24 09:49:21 +00:00
kuaifan
7bce5f1c1f feat: 添加迁移脚本以为相关表添加索引 2025-12-24 09:18:48 +00:00
kuaifan
989660969c feat: 添加迁移脚本以反转待办消息中的用户ID顺序 2025-12-24 07:11:01 +00:00
kuaifan
862acd0776 fix: 修复行前缀检测逻辑,确保正确判断空行 2025-12-24 06:30:43 +00:00
kuaifan
3b3ffd494f feat: 规范以斜杠开头的命令 2025-12-24 06:10:39 +00:00
kuaifan
6cf8290565 feat: 增强斜杠命令支持,添加机器人命令和行首检测功能 2025-12-24 05:58:48 +00:00
kuaifan
230ebbcfb9 feat: support slash trigger for mention/task/file/report 2025-12-24 00:59:31 +00:00
kuaifan
dc77f1cda1 build 2025-12-23 09:51:18 +08:00
kuaifan
1f791b528a fix: 更新对话ID和场景信息的描述,增加字段标识 2025-12-23 01:40:53 +00:00
kuaifan
1459d953ed feat: 更新获取消息列表MCP工具的描述,增强功能说明 2025-12-22 03:44:33 +00:00
kuaifan
719a36b275 chore: update mobile subproject commit reference 2025-12-19 22:35:57 +08:00
kuaifan
0b7a3046fe fix: align parent task subtask progress with task detail (include archived, exclude deleted) 2025-12-19 21:36:00 +08:00
kuaifan
203d107d68 fix: skip loading related tasks for subtasks to prevent request spam 2025-12-19 19:37:07 +08:00
kuaifan
17fd7f02a6 build 2025-12-19 09:13:49 +08:00
kuaifan
57ea4f2b6f feat: 自定义应用菜单新增 immersive 沉浸式开关 2025-12-19 01:07:02 +00:00
kuaifan
df431eea46 no message 2025-12-18 23:12:53 +00:00
kuaifan
ad9dd6330f feat: merge todo done notices and render done_userids 2025-12-18 23:03:11 +00:00
kuaifan
df9d291f98 feat: 优化群组资料修改逻辑,增加权限判断和名称修改提示 2025-12-18 21:53:04 +00:00
kuaifan
0cf7fc2ed2 feat: replace group name quick edit with modify trigger 2025-12-18 21:42:15 +00:00
kuaifan
e8f82baa99 feat: 添加 urlType 字段以兼容旧版本微应用配置 2025-12-18 21:06:49 +00:00
kuaifan
353a05f344 feat: 优化 openMicroApp 方法,增强参数校验和微应用 ID 解析逻辑 2025-12-18 20:59:44 +00:00
kuaifan
d94ebfe04c feat: 添加解析类型的方法,优化微应用配置逻辑 2025-12-18 08:26:42 +00:00
kuaifan
52913abb4f feat: 更新 appstore 镜像版本至 0.3.7 2025-12-18 02:47:39 +00:00
kuaifan
d77406951d feat: 更新微应用菜单配置,统一使用类型字段替代URL类型字段 2025-12-18 02:44:37 +00:00
kuaifan
8c23192eeb build 2025-12-17 09:30:53 +08:00
kuaifan
078c9c198d feat: 更新 appstore 镜像版本至 0.3.6 2025-12-16 11:32:33 +00:00
kuaifan
6cfe2d226a feat: 增加获取胶囊可见性的方法,优化胶囊显示逻辑 2025-12-16 11:31:50 +00:00
kuaifan
fee1c12357 feat: 添加导航功能,支持快捷键和鼠标手势操作 2025-12-16 18:36:11 +08:00
kuaifan
a6385b699e fix: 修复在某些情况下无法打开微应用的问题 2025-12-14 22:36:14 +00:00
kuaifan
718ed8953f no message 2025-12-14 00:23:04 +00:00
kuaifan
a1eea77b9e feat: 更新 appstore 镜像版本至 0.3.5 2025-12-12 07:12:07 +00:00
kuaifan
6eb08ac09b build 2025-12-11 10:28:18 +08:00
kuaifan
20fc2b073b no message 2025-12-11 02:09:59 +00:00
kuaifan
8c4b9e8d12 feat: 优化项目/报告控制器及任务模型 2025-12-11 02:06:13 +00:00
kuaifan
8d187f5cfc feat: 优化周报/日报模板的已完成与未完成任务规则 2025-12-11 01:35:10 +00:00
kuaifan
db07a96e97 fix: 修复任务导出状态判断及状态高亮列错位问题 2025-12-11 01:13:03 +00:00
kuaifan
7acc9227ff fix: 修复任务统计导出漏掉无计划时间已完成任务的问题 2025-12-11 00:43:54 +00:00
kuaifan
c3a71e5b07 feat: 更新 appstore 镜像版本至 0.3.4 2025-12-10 02:01:43 +00:00
kuaifan
ac9e1e5e67 feat: call appstore user lifecycle hooks from main app 2025-12-09 10:30:23 +00:00
kuaifan
c668340661 feat: 优化消息推送逻辑 2025-12-05 02:10:37 +00:00
kuaifan
ee9b6248bb fix(electron): cleanup child windows by instance instead of name 2025-12-04 11:18:47 +00:00
kuaifan
01c7f7250b fix: 修复关闭应用时加载状态未正确更新的问题 2025-12-03 12:48:33 +00:00
kuaifan
2abc5976f9 fix: 更新 iframe 的 sandbox 属性以增强安全性 2025-12-02 12:03:54 +00:00
kuaifan
3e468c74e4 fix: 修改微模态框的最小高度设置 2025-12-02 11:46:46 +00:00
kuaifan
4ef78d2c81 feat: 添加点击消息打开微应用功能 2025-12-02 06:29:45 +00:00
kuaifan
4621222fa3 build 2025-11-30 12:18:18 +08:00
kuaifan
be860f9968 fix: load fastmcp via dynamic import in electron MCP 2025-11-30 12:13:31 +08:00
kuaifan
fe0b8aed20 no message 2025-11-28 22:09:55 +00:00
kuaifan
f0e844c308 feat: 添加个人任务上限设置,限制负责人或协助人的未完成任务数量 2025-11-28 11:05:08 +00:00
kuaifan
6a7cc95b23 feat: 添加颜色工具函数,支持颜色反转和解析 2025-11-28 09:35:01 +00:00
kuaifan
7fd90b9ceb feat: 添加对话框顶部消息样式 2025-11-28 08:58:14 +00:00
kuaifan
43577073e6 fix: 调整各组件最大高度计算,考虑状态栏和导航栏高度 2025-11-28 02:27:03 +00:00
kuaifan
faeeb09a4a fix: 修复微模态组件的样式,调整为固定定位以适应全屏显示 2025-11-28 01:33:49 +00:00
kuaifan
d88349b6f7 feat: 使用 CSS 变量动态调整窗口高度,优化各组件的最大高度设置 2025-11-28 01:33:35 +00:00
kuaifan
ff53e1fac3 fix: enforce positive rounded size in normalizeSize 2025-11-27 10:40:45 +08:00
kuaifan
cf4894b7c3 no message 2025-11-27 02:24:40 +00:00
kuaifan
678dfd2d5c feat: 更新 appstore 镜像版本 2025-11-27 02:24:34 +00:00
kuaifan
bf4a62ae04 feat: 更新文档,添加前端弹窗文案处理说明 2025-11-24 01:23:39 +00:00
kuaifan
7e6f3f92cf feat: 添加 URL 输入提示,优化 iframe 测试功能的用户体验 2025-11-24 01:23:22 +00:00
kuaifan
df382dafb4 no message 2025-11-24 00:38:16 +00:00
kuaifan
10925d3a47 no message 2025-11-20 06:19:29 +00:00
kuaifan
66252072c7 feat: 添加 iframe 测试功能,支持通过 URL 加载外部内容 2025-11-20 06:18:56 +00:00
kuaifan
29918882bd no message 2025-11-19 07:54:56 +00:00
kuaifan
4983fe8feb feat: 添加自定义微应用菜单功能,支持管理员配置和保存菜单项 2025-11-19 07:54:47 +00:00
kuaifan
f65da118d7 feat: 更新 appstore 镜像版本至 0.3.2 2025-11-15 09:17:38 +00:00
kuaifan
a86bd9a05e fix: 修复桌面端部分机器新窗口任务报错的情况 2025-11-14 09:48:10 +00:00
kuaifan
f2719eb742 feat: 更新助手默认模型为 gpt-5.1-mini 2025-11-14 01:20:41 +00:00
kuaifan
4f9ee1dfa9 no message 2025-11-14 01:17:48 +00:00
kuaifan
e6ad1218bc feat: 添加一键归档列表中已完成任务 2025-11-14 01:15:19 +00:00
kuaifan
dd2cd1df9a feat: 更新 OnlyOffice 组件的主题名称;优化文件管理页面的列表渲染;调整抽屉和文件内容的圆角样式 2025-11-13 06:20:21 +08:00
kuaifan
6dcbe8ba38 build 2025-11-12 16:46:33 +08:00
kuaifan
360d4dbbe2 no message 2025-11-12 07:18:54 +00:00
kuaifan
2f32b53d19 feat: 修改 getDomain 函数以支持可选的小写转换参数;更新 getObject 函数的默认值 2025-11-12 07:07:00 +00:00
kuaifan
6a3e3c3753 feat: AI 助手增加最大响应数至50,并添加上下文窗口大小设置 2025-11-12 01:23:34 +00:00
kuaifan
5ad08d8d36 no message 2025-11-12 01:06:36 +00:00
kuaifan
b892d92614 build 2025-11-12 07:11:38 +08:00
kuaifan
b259f083d4 no message 2025-11-12 07:05:46 +08:00
kuaifan
38aa9fe2fb build 2025-11-12 00:30:39 +08:00
kuaifan
863dd3a53e no message 2025-11-11 22:42:45 +08:00
kuaifan
bea5058df8 feat: 优化错误处理逻辑,简化错误消息输出 2025-11-11 21:49:09 +08:00
kuaifan
31c157f58f no message 2025-11-11 21:40:34 +08:00
kuaifan
8af6887daa feat: 优化WebSocketDialogMsg和BotReceiveMsgTask中的消息格式,统一中文标点,增强可读性 2025-11-11 13:05:04 +00:00
kuaifan
eb9b7b4f86 feat: 更新MCP工具描述 2025-11-11 07:16:04 +00:00
kuaifan
cf78766a37 feat: 移除未使用的消息处理函数和Markdown插件任务创建功能,优化代码结构 2025-11-11 05:42:02 +00:00
kuaifan
944824b552 feat: 移除未使用的函数和代码,优化BotReceiveMsgTask和WebSocketDialogMsg的消息处理逻辑 2025-11-11 05:31:59 +00:00
kuaifan
477bb1ac8f feat: MCP增加文件管理功能,支持获取文件访问URL、文件列表和文件搜索 2025-11-11 05:23:00 +00:00
kuaifan
29df864ecb feat: MCP增加工作报告相关功能,包括获取汇报列表、获取汇报详情、生成汇报模板、创建汇报及标记已读/未读状态 2025-11-11 02:24:35 +00:00
kuaifan
bcf897b7e0 no message 2025-11-10 23:03:42 +00:00
kuaifan
e63890c755 feat: 重构隐私政策页面,优化结构和样式,增强可读性 2025-11-10 23:01:39 +00:00
kuaifan
f3725215bd feat: 简化长按指令的参数配置 2025-11-10 22:43:25 +00:00
kuaifan
c43e305ea7 feat: 优化AI输出语言策略提示词 2025-11-10 22:36:37 +00:00
kuaifan
b9215e2410 feat: 添加语言偏好提示功能到AI系统提示 2025-11-10 16:46:29 +00:00
kuaifan
19d79ab055 feat: 优化触摸设备交互
- 触摸设备取消拖动选中文件
2025-11-10 16:14:01 +00:00
kuaifan
64d4492806 feat: 优化AI助手响应构建
- 增加剔除推理块功能
2025-11-10 16:13:05 +00:00
kuaifan
0790eae8c6 no message 2025-11-10 15:20:31 +00:00
kuaifan
e10e2c27c1 feat: 优化导出菜单交互 2025-11-10 07:59:52 +00:00
kuaifan
d30b38d4b9 feat: 添加应用排序功能 2025-11-10 07:47:00 +00:00
kuaifan
f6e4ed7c60 no message
- 添加AI助手流式会话凭证生成方法
- 优化AI助手模型获取逻辑
- 更新相关接口调用
2025-11-09 22:20:38 +00:00
kuaifan
7a6bbfac75 feat: 更新AI模块的transcriptions方法,增加扩展请求头参数,优化语音识别功能 2025-11-09 04:43:17 +00:00
kuaifan
425d6f9a06 feat: 移除冗余的AI助手设置方法,优化AI模块的模型配置逻辑 2025-11-09 04:28:51 +00:00
kuaifan
58c760bb77 no message 2025-11-09 02:14:27 +00:00
kuaifan
3ffdce5e7a no message 2025-11-08 23:54:18 +00:00
kuaifan
8e518a044a feat: 优化AI助手输出界面,简化状态显示逻辑,增强用户交互体验 2025-11-08 23:43:06 +00:00
kuaifan
a5adbf80a9 feat: 重构报告分析功能,更新API接口,移除冗余代码,优化分析逻辑 2025-11-08 22:18:59 +00:00
kuaifan
0b6c478b4f feat: 优化报告AI整理功能,优化报告编辑逻辑,移除冗余代码 2025-11-08 21:53:02 +00:00
kuaifan
0434bde16f feat: 移除冗余的AI任务和项目生成逻辑,优化代码结构 2025-11-08 21:52:26 +00:00
kuaifan
0deb3113b5 feat: 引入文本提取功能,优化AI内容解析逻辑,移除冗余代码 2025-11-08 20:42:21 +00:00
kuaifan
ecb52c76b9 feat: 完善AI助手功能 2025-11-08 08:57:22 +00:00
kuaifan
69c66053b7 feat: 完善AI助手功能,新增消息提示词整理接口,优化流式消息处理逻辑,移除冗余数据表和相关代码 2025-11-07 22:25:45 +00:00
kuaifan
892ad395a7 feat: 添加额外数据处理,优化AI助手消息生成与发送逻辑 2025-11-07 20:38:06 +00:00
kuaifan
e801c09c0f feat: 增强AI助手响应处理,支持流式输出和模型缓存 2025-11-07 08:13:51 +00:00
kuaifan
ad560a8555 feat: 增强流消息处理,支持回应和会话ID 2025-11-07 08:13:41 +00:00
kuaifan
e75aa5c2b9 feat: 创建新 AI 会话时将旧会话消息批量标记已读 2025-11-07 07:54:04 +00:00
kuaifan
e83fd7af1b feat: 优化 AI 助手,支持自定义模型 2025-11-07 07:01:15 +00:00
kuaifan
eaec8ef994 no message 2025-11-07 01:00:30 +00:00
kuaifan
3339e6b442 feat: 添加文件列表滚动事件处理,优化右键菜单显示逻辑 2025-11-07 01:00:22 +00:00
kuaifan
4c2425c758 feat: 优化链接获取逻辑 2025-11-06 14:53:16 +00:00
kuaifan
80d1e6469e no message 2025-11-06 14:23:39 +00:00
kuaifan
2fad6394ee no message 2025-11-06 14:03:58 +00:00
kuaifan
4bfe33a37f feat: 优化打开会话事件接口,优化机器人webhook逻辑
- 新增 `open__event` 方法用于处理打开会话事件
- 移除旧的 `open__webhook` 方法
- 更新前端调用逻辑,使用新的事件接口
- 优化 webhook 事件推送逻辑,简化参数传递
2025-11-06 13:59:10 +00:00
kuaifan
130c8bf3b1 Merge pull request #289 from nightcp/dev
feat: 调整机器人webhook事件
2025-11-06 15:24:06 +08:00
kuaifan
b9df277104 no message 2025-11-06 07:16:29 +00:00
kuaifan
97e1f321ca feat: 优化长文本预览组件 2025-11-06 07:00:11 +00:00
王昱
4933930afd feat: 调整机器人webhook事件
- 可取消接收消息事件
- 打开机器人会话窗口时推送webhook消息,相同机器人消息缓存1分钟
2025-11-06 04:08:39 +00:00
kuaifan
ab4640382d feat: 添加会员扩展信息接口,优化用户详情和个人设置页面 2025-11-06 02:01:15 +00:00
kuaifan
e4cfa4b405 feat: 优化个性标签 2025-11-05 22:19:45 +00:00
kuaifan
789062e85e Merge pull request #288 from xxyijixx/dev-profile
Dev profile
2025-11-05 17:11:46 +08:00
kuaifan
5370bee369 Merge branch 'dev' into pro
# Conflicts:
#	CHANGELOG.md
#	cmd
#	package.json
#	public/js/build/404.5645cb91.js
#	public/js/build/404.9598cd97.js
#	public/js/build/404.a5736629.js
#	public/js/build/AceEditor.8747edb1.js
#	public/js/build/AceEditor.af35593f.js
#	public/js/build/AceEditor.e7f5b602.js
#	public/js/build/DialogWrapper.0c7cd033.js
#	public/js/build/DialogWrapper.64072671.js
#	public/js/build/DialogWrapper.7fcb5b27.js
#	public/js/build/Drawio.2ca59c31.js
#	public/js/build/Drawio.6691a6ef.js
#	public/js/build/Drawio.e3576e4e.js
#	public/js/build/FileContent.3a899bcc.js
#	public/js/build/FileContent.c311c89c.js
#	public/js/build/FileContent.d8e600e1.js
#	public/js/build/FilePreview.87ca99d9.js
#	public/js/build/FilePreview.f8134ee5.js
#	public/js/build/FilePreview.f9f90ff4.js
#	public/js/build/IFrame.02598edc.js
#	public/js/build/IFrame.2a7489ee.js
#	public/js/build/IFrame.be9780e1.js
#	public/js/build/ImgUpload.29e2d88d.js
#	public/js/build/ImgUpload.a4eff264.js
#	public/js/build/ImgUpload.e96999cf.js
#	public/js/build/Minder.2bce6c16.js
#	public/js/build/Minder.b1d1145f.js
#	public/js/build/Minder.f5bc5aca.js
#	public/js/build/OnlyOffice.31e7af4f.js
#	public/js/build/OnlyOffice.574ad560.js
#	public/js/build/OnlyOffice.9ce921ed.js
#	public/js/build/ReportEdit.5eb3a319.js
#	public/js/build/ReportEdit.9141bb93.js
#	public/js/build/ReportEdit.e3369e09.js
#	public/js/build/SearchButton.906cea81.js
#	public/js/build/SearchButton.cf201525.js
#	public/js/build/SearchButton.d41addb6.js
#	public/js/build/TEditor.7b9a9d91.js
#	public/js/build/TEditor.971af80f.js
#	public/js/build/TEditor.cc94d929.js
#	public/js/build/TaskDetail.38815236.js
#	public/js/build/TaskDetail.d1a9952e.js
#	public/js/build/TaskDetail.dfd78b4a.js
#	public/js/build/add.0cfbdd9e.js
#	public/js/build/add.3673f91c.js
#	public/js/build/add.423bc480.js
#	public/js/build/application.005cc174.js
#	public/js/build/application.5587ac3b.js
#	public/js/build/application.5b8f123b.js
#	public/js/build/apps.4e0bf65b.js
#	public/js/build/apps.b0a3d4f5.js
#	public/js/build/apps.f77a8c4e.js
#	public/js/build/calendar.31470aa0.js
#	public/js/build/calendar.ad5d85d5.js
#	public/js/build/calendar.e08e7575.js
#	public/js/build/checkin.5d4c364e.js
#	public/js/build/checkin.ab08f01e.js
#	public/js/build/checkin.c05284a9.js
#	public/js/build/dashboard.7cced7be.js
#	public/js/build/dashboard.c82415db.js
#	public/js/build/dashboard.f6ed8299.js
#	public/js/build/dayjs.495f600d.js
#	public/js/build/dayjs.71653272.js
#	public/js/build/dayjs.cf033d87.js
#	public/js/build/delete.4072c68f.js
#	public/js/build/delete.5f06c51d.js
#	public/js/build/delete.b26aa3fd.js
#	public/js/build/device.4cff22ad.js
#	public/js/build/device.66a7e05a.js
#	public/js/build/device.a13f3ef0.js
#	public/js/build/dialog.97b951ce.js
#	public/js/build/dialog.e9f6d55f.js
#	public/js/build/dialog.eb7b795a.js
#	public/js/build/editor.18a511b5.js
#	public/js/build/editor.2cca497c.js
#	public/js/build/editor.e034df4e.js
#	public/js/build/email.0643f86b.js
#	public/js/build/email.1d00cb0c.js
#	public/js/build/email.d95a35c0.js
#	public/js/build/file.4fe82c29.js
#	public/js/build/file.684a63df.js
#	public/js/build/file.9dceb82f.js
#	public/js/build/fileMsg.0a0029c2.js
#	public/js/build/fileMsg.1f4ecb0f.js
#	public/js/build/fileMsg.f99b6f61.js
#	public/js/build/fileTask.72914205.js
#	public/js/build/fileTask.bf35fb6b.js
#	public/js/build/fileTask.f4356f14.js
#	public/js/build/index.236af26f.js
#	public/js/build/index.299c9f99.js
#	public/js/build/index.2ffa8f9e.js
#	public/js/build/index.7d6e1bbe.js
#	public/js/build/index.94a5d2da.css
#	public/js/build/index.af34aeb9.js
#	public/js/build/index.b0ae9460.js
#	public/js/build/index.b69b5f25.js
#	public/js/build/index.b71c2859.js
#	public/js/build/index.c3968cad.js
#	public/js/build/index.d1ae44be.js
#	public/js/build/index.e07db7f9.css
#	public/js/build/index.edee4b6e.css
#	public/js/build/index.ef9e1e57.js
#	public/js/build/index.fe32159a.js
#	public/js/build/jquery.0909250e.js
#	public/js/build/jquery.16b446fd.js
#	public/js/build/jquery.27f590f5.js
#	public/js/build/keyboard.3f5b3ac6.js
#	public/js/build/keyboard.5de3dd2c.js
#	public/js/build/keyboard.c3ef7d49.js
#	public/js/build/language.1fadd54c.js
#	public/js/build/language.8bb72294.js
#	public/js/build/language.f3d03ece.js
#	public/js/build/license.21482fde.js
#	public/js/build/license.60871496.js
#	public/js/build/license.add318a7.js
#	public/js/build/localforage.65ac7a2a.js
#	public/js/build/localforage.be4775a0.js
#	public/js/build/localforage.dd58f5ac.js
#	public/js/build/login.7560afa5.js
#	public/js/build/login.75b3978c.js
#	public/js/build/login.aa163163.js
#	public/js/build/meeting.a60d7e8d.js
#	public/js/build/meeting.aa5510c7.js
#	public/js/build/meeting.fdb9793b.js
#	public/js/build/password.267357fd.js
#	public/js/build/password.749ce44d.js
#	public/js/build/password.e6d81eb1.js
#	public/js/build/personal.69279937.js
#	public/js/build/personal.a27cef8e.js
#	public/js/build/personal.c613af3c.js
#	public/js/build/preload.5827bd38.js
#	public/js/build/preload.8ec61a5b.js
#	public/js/build/preload.c6189d87.js
#	public/js/build/preview.29e49902.js
#	public/js/build/preview.7329f0f4.js
#	public/js/build/preview.b452b0ee.js
#	public/js/build/preview.c64402ed.js
#	public/js/build/preview.ec796a92.js
#	public/js/build/preview.ec85a43c.js
#	public/js/build/pro.2128a514.js
#	public/js/build/pro.213d8da6.js
#	public/js/build/pro.9fb60d27.js
#	public/js/build/projectInvite.0b3bf524.js
#	public/js/build/projectInvite.393920f8.js
#	public/js/build/projectInvite.e9cee390.js
#	public/js/build/reportDetail.2db50632.js
#	public/js/build/reportDetail.90aaf973.js
#	public/js/build/reportDetail.d93cc650.js
#	public/js/build/reportEdit.84a81076.js
#	public/js/build/reportEdit.8baf23d4.js
#	public/js/build/reportEdit.d008dd34.js
#	public/js/build/swipe.0c72cce1.js
#	public/js/build/swipe.4567bb5d.js
#	public/js/build/swipe.92aebd0c.js
#	public/js/build/system.67c1b700.js
#	public/js/build/system.c45c70de.js
#	public/js/build/system.f3384133.js
#	public/js/build/task.1b9e0e77.js
#	public/js/build/task.a445c89e.js
#	public/js/build/task.d43091db.js
#	public/js/build/taskContent.20b80714.js
#	public/js/build/taskContent.3ebbd2f9.js
#	public/js/build/taskContent.9dc7a121.js
#	public/js/build/theme.72d103d1.js
#	public/js/build/theme.7f1b2ffd.js
#	public/js/build/theme.df79fe8f.js
#	public/js/build/token.0ecffef5.js
#	public/js/build/token.a7f5ccf5.js
#	public/js/build/token.ece75257.js
#	public/js/build/validEmail.1462dd30.js
#	public/js/build/validEmail.17a3e0d2.js
#	public/js/build/validEmail.ee19c1f3.js
#	public/js/build/version.137935c7.js
#	public/js/build/version.1441c1fd.js
#	public/js/build/version.b0154505.js
#	public/js/build/video.03b62c93.js
#	public/js/build/video.2dc7f3c6.js
#	public/js/build/video.531c68e2.js
#	public/js/build/view.18713f1b.js
#	public/js/build/view.7770155e.js
#	public/js/build/view.8c6a0cc1.js
#	public/manifest.json
2025-11-05 16:55:17 +08:00
kuaifan
2f972488a1 Merge pull request #287 from nightcp/dev
feat: 优化用户机器人 webhook 逻辑
2025-11-05 16:30:37 +08:00
kuaifan
6f7656802f no message 2025-11-05 06:20:04 +00:00
kuaifan
7d98c5493e feat: 添加AI整理工作汇报功能 2025-11-05 04:02:29 +00:00
kuaifan
e0443aa336 feat: 添加AI分析工作汇报功能 2025-11-05 04:02:06 +00:00
kuaifan
39ff0d1516 feat: 将AI助手从gpt-5-nano更改为gpt-5-mini 2025-11-05 01:58:24 +00:00
kuaifan
1b9c0ee4b8 feat: 优化AI助手入口 2025-11-05 01:55:59 +00:00
kuaifan
d48287f93a feat: 添加判断是否为iPad的功能,并在预加载时处理安全区域 2025-11-04 13:08:23 +08:00
kuaifan
717e87cfa9 feat: 更新抽屉样式以支持横屏模式下的最大宽度设置 2025-11-04 13:06:19 +08:00
kuaifan
708b488af8 fix: 修复android分享页面元素重叠的情况 2025-11-03 16:56:20 +08:00
kuaifan
d60d3f374b feat: 调整对话框尺寸计算,避免发送消息失败的情况 2025-11-03 14:46:46 +08:00
kuaifan
8b87a2bc40 feat: 添加聊天输入历史记录功能 2025-11-03 02:12:05 +00:00
kuaifan
d0da517503 no message 2025-11-03 00:43:28 +00:00
kuaifan
754036c472 build 2025-11-03 08:05:35 +08:00
kuaifan
720438fd91 Merge commit '96106498d8c480c3ea7ec493bfb063450e11b7b5' into pro 2025-11-03 08:00:22 +08:00
kuaifan
ba76df1b00 no message 2025-11-03 08:00:15 +08:00
kuaifan
44d85c2864 feat: 增加对应用平台的 overscroll-behavior 设置
- 优化iOS15滚动超限的情况
2025-11-03 07:51:44 +08:00
kuaifan
1c8b73a381 feat: 重构胶囊缓存逻辑,增加设置和移除缓存的方法 2025-11-03 01:29:34 +08:00
kuaifan
b445af932c feat: 更新消息推送逻辑 2025-11-03 00:45:34 +08:00
kuaifan
5121739fe4 feat: 优化应用激活逻辑,增加 IndexedDB 测试失败时的提前返回处理 2025-11-03 00:34:32 +08:00
kuaifan
96106498d8 feat: 添加Umeng日志模型及数据库迁移 2025-11-01 16:15:32 +00:00
kuaifan
0116d92021 feat: 给支持角标的Android设备推送添加角标 2025-11-01 16:15:25 +00:00
kuaifan
43746634a5 no message 2025-10-31 08:27:44 +00:00
kuaifan
5183786fb0 no message 2025-10-30 20:04:41 +00:00
kuaifan
5ba0eed721 no message 2025-10-29 00:15:45 +00:00
kuaifan
7d08c735ef no message 2025-10-28 11:35:36 +00:00
kuaifan
e3067b685c no message 2025-10-28 09:23:41 +00:00
kuaifan
b219ca4c1c no message 2025-10-27 20:57:42 +00:00
kuaifan
9e5d16ff16 feat: 添加 MCP 服务器类型为 streamable-http 2025-10-27 02:49:53 +00:00
kuaifan
da630458e1 fix: 修复任务操作无法点击确定 2025-10-27 02:45:29 +00:00
kuaifan
ee2eceffb0 build 2025-10-27 06:39:25 +08:00
kuaifan
c8d22e7b5f no message 2025-10-27 06:35:27 +08:00
kuaifan
342e8725bd feat: 更新 MCP 服务器配置和工具 2025-10-27 06:34:47 +08:00
kuaifan
3ced00de1f no message 2025-10-27 06:34:47 +08:00
kuaifan
7fa075fa75 no message 2025-10-26 09:59:37 +08:00
kuaifan
95ca496691 feat: 优化获取任务子任务数据相关逻辑 2025-10-26 09:30:24 +08:00
kuaifan
50b1d93f08 no message 2025-10-26 09:21:58 +08:00
kuaifan
8958f2f234 feat: 添加MCP服务器状态切换功能 2025-10-25 16:39:50 +08:00
kuaifan
00b4d6a748 no message 2025-10-25 10:46:01 +08:00
kuaifan
f4de0d8276 feat: 更新MCP工具,添加项目管理功能及任务创建、更新接口 2025-10-25 10:45:46 +08:00
kuaifan
cfa749f4f3 feat: 优化时间范围参数 2025-10-24 23:48:35 +08:00
kuaifan
eeaff08673 feat: 桌面端添加MCP服务 2025-10-24 23:48:18 +08:00
kuaifan
0475e88dc2 feat: 添加任务移动权限检查以增强项目任务管理 2025-10-24 06:35:22 +00:00
kuaifan
e1f73a4639 feat: 为列表项添加最小高度以改善可读性 2025-10-24 05:42:57 +00:00
kuaifan
e2296a6f64 feat: 添加子任务升级为主任务功能 2025-10-24 05:38:54 +00:00
kuaifan
1a6abf4e1b feat: 在安装和更新函数中添加sudo检查 2025-10-24 03:34:22 +00:00
kuaifan
315851eb5f feat: 优化数据库还原功能
- 支持通过编号选择备份文件
2025-10-23 22:55:29 +00:00
kuaifan
0b99b4a9a0 fix: 修复用户选择在输入法预输入时误删已选项 2025-10-23 06:07:24 +00:00
王昱
66002ff401 Merge branch 'kuaifan:dev' into dev 2025-10-22 17:30:34 +08:00
nightcp
bdfc8bdd0c feat: 添加机器人消息推送参数文档,增强 webhook 事件说明 2025-10-22 17:29:32 +08:00
nightcp
98e4668969 feat: 优化用户机器人 webhook 逻辑 2025-10-21 13:53:16 +08:00
kuaifan
e8235dd0a2 feat: 优化已读消息标记逻辑,提升性能和可读性 2025-10-17 00:41:38 +00:00
kuaifan
123c74de46 feat: 优化开发环境配置 2025-10-16 23:56:48 +00:00
yatgei
c92b9bf0fb feat: 在用户详情组件中添加创建群组按钮功能 2025-10-14 18:29:21 +08:00
yatgei
b4cbfd2ae9 feat: 更新用户详情组件样式,调整布局和颜色 2025-10-14 14:01:03 +08:00
yatgei
dd7eee277e feat: 添加共同群组对话框组件并在用户详情中集成 2025-10-13 18:22:25 +08:00
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
9419ddd174 no message 2025-09-29 09:19:28 +08:00
kuaifan
0666a8f5c2 feat: 优化任务可见性推送逻辑 2025-09-29 09:04:31 +08:00
kuaifan
81c019105c no message 2025-09-28 10:40:48 +08:00
kuaifan
6584259454 build 2025-09-28 08:38:48 +08:00
kuaifan
03d0f56095 no message 2025-09-28 08:16:53 +08: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
644 changed files with 62949 additions and 16755 deletions

55
.claude/rules/graphiti.md Normal file
View File

@@ -0,0 +1,55 @@
# Graphiti 长期记忆集成
本项目使用 Graphiti 作为「长期记忆层」,用于持久化用户偏好、工作流程、重要约束和关键事实。
**统一 group_id**: `dootask-main`
## 任务开始前(读取记忆)
在进行实质性工作(写代码、设计方案、做大改动)前,应先通过 Graphiti 查询已有记忆:
- 使用节点搜索(如 `search_nodes`)在 `group_id = "dootask-main"` 下查找与当前任务相关的 Preference / Procedure / Requirement
- 使用事实搜索(如 `search_facts`)查找相关事实与实体关系
- 查询语句中可包含任务类型Bug 修复 / 重构 / 新功能等、涉及模块任务、项目、对话、WebSocket、报表等以及关键字 `dootask`
发现与当前任务高度相关的偏好 / 流程 / 约束时,应优先遵守;如存在冲突,应在回答中说明并做合理选择。
## 什么时候写入 Graphiti
| 类型 | 说明 | 示例 |
|------|------|------|
| **偏好 (Preferences)** | 用户表达持续性偏好时 | 语言、输出格式、技术选型 |
| **流程 (Procedures)** | 形成稳定的开发/发布/调试流程时 | 可复用步骤 |
| **约束 (Requirements)** | 项目长期有效的决策 | 不再支持某版本、架构约定 |
| **事实 (Facts)** | 模块边界、服务调用关系、外部集成方式 | AgoraIO、Manticore Search |
### 写入建议
- 默认使用 `source: "text"`,在 `episode_body` 中用简洁结构化自然语言描述背景、类型、范围、具体内容
- 需要结构化数据时可用 `source: "json"`,保证 `episode_body` 是合法 JSON 字符串
- 所有写入默认使用 `group_id: "dootask-main"`
## 更新与更正
- 偏好 / 流程发生变化时,新增一条 episode 说明新约定,并标明这是对旧习惯的更新
- 用户要求「忘记」某些记忆时,可通过删除或更正相关 episode / 关系的方式处理
- 尽量通过新增 episode 记录「更正 / 废弃说明」,而不是直接改写历史事实
## 使用原则
- **尊重已存偏好**:编码风格、回答结构、工具选择等应对齐已知偏好
- **遵循已有流程**:若图谱中已有与当前任务匹配的 Procedure应尽量按步骤执行
- **利用事实**:理解系统行为、模块边界、历史决策时优先查已存 Facts
- **代码优先**:如 Graphiti 与当前代码实际冲突,应以代码实际为准,并视情况新增 episode 更新事实
## 不要写入的内容
- 敏感信息(密钥、密码、隐私数据)
- 只与当前一次任务相关、未来不会复用的临时信息
- 体量巨大的原始数据(完整日志、长脚本全文),应只存摘要和关键结论
## 最佳实践
1. **先查再做**:在提出方案或改动架构前,优先查阅 Graphiti 中已有的设计、偏好和约束
2. **能复用就沉淀**:只要发现某个偏好 / 流程 / 约束未来会反复用到,就尽快写入 Graphiti
3. **保持一致**:确保 Graphiti 中的记忆与实际代码长期保持一致,避免「记忆漂移」

View File

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

53
.gitignore vendored
View File

@@ -1,32 +1,63 @@
# 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
.cursor/*
!.cursor/rules/
!.cursor/rules/**
.idea
.vscode
.vagrant
.windsurfrules
.phpunit.result.cache
# Development tools
.vagrant
Homestead.json
Homestead.yaml
# Development file
/index.html
# 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
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

File diff suppressed because it is too large Load Diff

125
CLAUDE.md Normal file
View File

@@ -0,0 +1,125 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
DooTask 是一套开源的任务/项目管理系统,支持看板、任务、子任务、评论、对话、文件、报表等协作能力。
- **后端**Laravel 8运行在 LaravelS/Swoole 常驻进程上
- **前端**Vue 2 + Vite
- **桌面端**Electron 壳,核心逻辑复用 Web 前端
## 开发命令
所有命令通过 `./cmd` 脚本执行,确保与 Docker/容器环境一致:
```bash
# 服务管理
./cmd up # 启动容器
./cmd down # 停止容器
./cmd restart # 重启容器
./cmd reup # 重新构建并启动
# 开发构建
./cmd dev # 启动前端开发服务器(需要 Node.js 20+
./cmd serve # dev 别名
./cmd prod # 构建前端生产版本
./cmd build # prod 别名
# Laravel/PHP
./cmd artisan ... # 运行 Laravel Artisan 命令
./cmd composer ... # 运行 Composer 命令
./cmd php ... # 运行 PHP 命令
# Electron
./cmd electron # 构建桌面应用
# 配置管理
./cmd port <端口> # 修改服务端口
./cmd url <地址> # 修改访问地址
./cmd env <键> <值> # 设置环境变量
./cmd debug [true|false] # 切换调试模式
# 数据库
./cmd mysql backup # 备份数据库
./cmd mysql recovery # 恢复数据库
# 其他
./cmd install # 一键安装
./cmd update # 升级项目
./cmd repassword # 重置管理员密码
./cmd doc # 生成 API 文档
./cmd https # 配置 HTTPS
```
## 代码架构
### 后端 (`app/`)
**Controller (`app/Http/Controllers/Api/`)**API 控制器,负责路由入口、参数校验、编排调用模型/模块、组装响应。保持控制器「薄」,业务异常通过 `App\Exceptions\ApiException` 抛出。
**Model (`app/Models/`)**Eloquent 模型,负责表结构映射、关系、访问器/修改器、查询 Scope。避免在模型中堆积复杂业务逻辑。
**Module (`app/Module/`)**:跨控制器/跨模型的业务逻辑与独立功能子域:
- 外部集成:`AgoraIO/``Manticore/`
- 通用工具:`Lock.php``TextExtractor.php``Image.php``AI.php`
- 复杂业务逻辑:`Base.php`(核心业务)、`Doo.php``Timer.php`
**Tasks (`app/Tasks/`)**Swoole 异步任务,用于后台处理:
- WebSocket 消息推送:`WebSocketDialogMsgTask.php``PushTask.php`
- 定时任务:`LoopTask.php``AutoArchivedTask.php`
- 搜索同步:`ManticoreSyncTask.php`
**Observers (`app/Observers/`)**Eloquent 观察者监听模型事件created/updated/deleted自动触发相关逻辑。
**Services (`app/Services/`)**:服务类,如 `WebSocketService.php``RequestContext.php`
### 前端 (`resources/assets/js/`)
```
├── app.js, App.vue # 应用入口与根组件
├── components/ # 通用与业务组件(看板、文件预览、聊天)
├── pages/ # 页面级组件(登录、项目、任务、消息、报表)
├── store/ # Vuex 状态管理
│ ├── state.js # 状态定义
│ ├── mutations.js # 同步修改
│ ├── actions.js # 异步操作(含 API 调用封装)
│ └── getters.js # 计算属性
├── routes.js # 前端路由
├── functions/ # 业务函数
├── utils/ # 工具函数
├── directives/ # Vue 自定义指令
├── mixins/ # Vue 混入
└── language/ # 国际化翻译
```
API 调用应使用 `store/actions.js` 中已有的封装,避免在组件中散落 axios/fetch。
### LaravelS/Swoole 注意事项
- **避免在静态属性、单例、全局变量中存储请求级状态**——防止请求间数据串联和内存泄漏
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
- 长生命周期逻辑WebSocket、定时器应复用现有模式避免阻塞协程/事件循环
## 数据库
- 所有表结构变更必须通过 Laravel migration禁止直接改库
- 使用 Eloquent 模型访问数据库
## 前端弹窗文案
调用 `$A.modalXXX``$A.messageXXX``$A.noticeXXX` 时,内部会自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当显式传入 `language: false` 时,才由调用方自行处理翻译。
## 交互规范
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
## 语言偏好
- 技术总结和关键结论优先使用简体中文,除非用户明确要求其他语言
## 扩展规则
详见 @.claude/rules/graphiti.md 了解 Graphiti 长期记忆集成。

View File

@@ -0,0 +1,205 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\File;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\User;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreKeyValue;
use App\Module\Manticore\ManticoreMsg;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreUser;
use Illuminate\Console\Command;
/**
* 异步向量生成命令
*
* 用于后台批量生成已索引数据的向量,与全文索引解耦
* 使用双指针追踪sync:xxxLastId全文已同步和 vector:xxxLastId向量已生成
*
* 运行模式:
* - 持续处理直到所有待处理数据完成
* - 每批处理完成后休眠几秒,避免 API 过载
* - 定时器只作为兜底触发机制
*/
class GenerateManticoreVectors extends Command
{
use ManticoreSyncLock;
protected $signature = 'manticore:generate-vectors
{--type=all : 类型 (msg/file/task/project/user/all)}
{--batch=50 : 每批 embedding 数量}
{--sleep=3 : 每批处理后休眠秒数}
{--reset : 重置向量进度指针}';
protected $description = '批量生成 Manticore 已索引数据的向量';
/**
* 类型配置
*/
private const TYPE_CONFIG = [
'msg' => [
'syncKey' => 'sync:manticoreMsgLastId',
'vectorKey' => 'vector:manticoreMsgLastId',
'class' => ManticoreMsg::class,
'model' => WebSocketDialogMsg::class,
'idField' => 'id',
],
'file' => [
'syncKey' => 'sync:manticoreFileLastId',
'vectorKey' => 'vector:manticoreFileLastId',
'class' => ManticoreFile::class,
'model' => File::class,
'idField' => 'id',
],
'task' => [
'syncKey' => 'sync:manticoreTaskLastId',
'vectorKey' => 'vector:manticoreTaskLastId',
'class' => ManticoreTask::class,
'model' => ProjectTask::class,
'idField' => 'id',
],
'project' => [
'syncKey' => 'sync:manticoreProjectLastId',
'vectorKey' => 'vector:manticoreProjectLastId',
'class' => ManticoreProject::class,
'model' => Project::class,
'idField' => 'id',
],
'user' => [
'syncKey' => 'sync:manticoreUserLastId',
'vectorKey' => 'vector:manticoreUserLastId',
'class' => ManticoreUser::class,
'model' => User::class,
'idField' => 'userid',
],
];
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
if (!Apps::isInstalled("ai")) {
$this->error("应用「AI」未安装无法生成向量");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
$type = $this->option('type');
$batchSize = intval($this->option('batch'));
$sleepSeconds = intval($this->option('sleep'));
$reset = $this->option('reset');
if ($type === 'all') {
$types = array_keys(self::TYPE_CONFIG);
} else {
if (!isset(self::TYPE_CONFIG[$type])) {
$this->error("未知类型: {$type}。可用类型: msg, file, task, project, user, all");
$this->releaseLock();
return 1;
}
$types = [$type];
}
// 持续处理直到所有类型都没有待处理数据
$round = 0;
do {
$round++;
$totalPending = 0;
foreach ($types as $t) {
if ($this->shouldStop) {
break;
}
$pending = $this->processType($t, $batchSize, $reset && $round === 1);
$totalPending += $pending;
}
// 如果还有待处理数据,休眠后继续
if ($totalPending > 0 && !$this->shouldStop) {
$this->info("\n--- 第 {$round} 轮完成,剩余 {$totalPending} 条待处理,{$sleepSeconds} 秒后继续 ---\n");
sleep($sleepSeconds);
$this->setLock(); // 刷新锁
}
} while ($totalPending > 0 && !$this->shouldStop);
$this->info("\n向量生成完成(共 {$round} 轮)");
$this->releaseLock();
return 0;
}
/**
* 处理单个类型的向量生成(每次处理一批)
*
* @param string $type 类型
* @param int $batchSize 每批数量
* @param bool $reset 是否重置进度
* @return int 剩余待处理数量
*/
private function processType(string $type, int $batchSize, bool $reset): int
{
$config = self::TYPE_CONFIG[$type];
// 获取进度指针
$syncLastId = intval(ManticoreKeyValue::get($config['syncKey'], 0));
$vectorLastId = $reset ? 0 : intval(ManticoreKeyValue::get($config['vectorKey'], 0));
if ($reset) {
ManticoreKeyValue::set($config['vectorKey'], 0);
$this->info("[{$type}] 已重置向量进度指针");
}
// 计算待处理范围
$pendingCount = $syncLastId - $vectorLastId;
if ($pendingCount <= 0) {
return 0;
}
// 获取待处理的 ID 列表(每次处理 batchSize * 5 条,让 generateVectorsBatch 内部再分批调用 API
$modelClass = $config['model'];
$idField = $config['idField'];
$fetchCount = $batchSize * 5;
$ids = $modelClass::where($idField, '>', $vectorLastId)
->where($idField, '<=', $syncLastId)
->orderBy($idField)
->limit($fetchCount)
->pluck($idField)
->toArray();
if (empty($ids)) {
return 0;
}
// 批量生成向量
$manticoreClass = $config['class'];
$successCount = $manticoreClass::generateVectorsBatch($ids, $batchSize);
$currentLastId = end($ids);
// 更新向量进度指针
ManticoreKeyValue::set($config['vectorKey'], $currentLastId);
$remaining = $pendingCount - count($ids);
$this->info("[{$type}] 处理 " . count($ids) . " 条,成功 {$successCount}ID: {$vectorLastId} -> {$currentLastId},剩余 {$remaining}");
// 刷新锁
$this->setLock();
return max(0, $remaining);
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\File;
use App\Models\ManticoreSyncFailure;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\User;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreBase;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreMsg;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreUser;
use Illuminate\Console\Command;
class RetryManticoreSync extends Command
{
use ManticoreSyncLock;
protected $signature = 'manticore:retry-failures {--limit=100 : 每次处理的最大数量} {--stats : 显示统计信息}';
protected $description = '重试 Manticore 同步失败的记录';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
// 显示统计信息
if ($this->option('stats')) {
$this->showStats();
return 0;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
$this->info('开始重试失败的同步任务...');
$limit = intval($this->option('limit'));
$failures = ManticoreSyncFailure::getPendingRetries($limit);
if ($failures->isEmpty()) {
$this->info('无待重试的记录');
$this->releaseLock();
return 0;
}
$this->info("找到 {$failures->count()} 条待重试记录");
$successCount = 0;
$failCount = 0;
foreach ($failures as $failure) {
if ($this->shouldStop) {
$this->info('收到停止信号,退出处理');
break;
}
$this->setLock();
$result = $this->retryOne($failure);
if ($result) {
$successCount++;
$this->info(" [成功] {$failure->data_type}:{$failure->data_id} ({$failure->action})");
} else {
$failCount++;
$this->warn(" [失败] {$failure->data_type}:{$failure->data_id} ({$failure->action}) - 第 {$failure->retry_count}");
}
}
$this->info("\n重试完成: 成功 {$successCount}, 失败 {$failCount}");
$this->releaseLock();
return 0;
}
/**
* 重试单条失败记录
*/
private function retryOne(ManticoreSyncFailure $failure): bool
{
$type = $failure->data_type;
$id = $failure->data_id;
$action = $failure->action;
try {
if ($action === 'delete') {
// 删除操作直接调用通用删除方法
return ManticoreBase::deleteVector($type, $id);
}
// sync 操作需要根据类型获取模型并同步
return $this->retrySyncByType($type, $id);
} catch (\Throwable $e) {
// 记录失败(会自动更新重试次数和时间)
ManticoreSyncFailure::recordFailure($type, $id, $action, $e->getMessage());
return false;
}
}
/**
* 根据类型重试同步
*/
private function retrySyncByType(string $type, int $id): bool
{
switch ($type) {
case 'msg':
$model = WebSocketDialogMsg::find($id);
if (!$model) {
// 数据已删除,移除失败记录
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreMsg::sync($model);
case 'file':
$model = File::find($id);
if (!$model) {
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreFile::sync($model);
case 'task':
$model = ProjectTask::find($id);
if (!$model) {
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreTask::sync($model);
case 'project':
$model = Project::find($id);
if (!$model) {
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreProject::sync($model);
case 'user':
$model = User::find($id);
if (!$model) {
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
return true;
}
return ManticoreUser::sync($model);
default:
return false;
}
}
/**
* 显示统计信息
*/
private function showStats(): void
{
$stats = ManticoreSyncFailure::getStats();
$this->info('Manticore 同步失败统计:');
$this->info(" 总数: {$stats['total']}");
if (!empty($stats['by_type'])) {
$this->info(' 按类型:');
foreach ($stats['by_type'] as $type => $count) {
$this->info(" - {$type}: {$count}");
}
}
if (!empty($stats['by_action'])) {
$this->info(' 按操作:');
foreach ($stats['by_action'] as $action => $count) {
$this->info(" - {$action}: {$count}");
}
}
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\File;
use App\Module\Apps;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncFileToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-files {--f} {--i} {--c} {--batch=100} {--sleep=3}';
protected $description = '同步文件数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
// 清除索引
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreKeyValue::clear();
ManticoreFile::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步文件数据...');
$this->syncFiles();
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
/**
* 同步文件数据
*/
private function syncFiles(): void
{
$lastKey = "sync:manticoreFileLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$maxFileSize = ManticoreFile::getMaxFileSize();
$round = 0;
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步文件数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步文件数据...");
}
}
$count = File::where('id', '>', $lastId)
->where('type', '!=', 'folder')
->where('size', '<=', $maxFileSize)
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 个文件");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$files = File::where('id', '>', $lastId)
->where('type', '!=', 'folder')
->where('size', '<=', $maxFileSize)
->orderBy('id')
->limit($batchSize)
->get();
if ($files->isEmpty()) {
break;
}
$num += count($files);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 文件ID {$files->first()->id} ~ {$files->last()->id}");
$this->setLock();
$syncCount = ManticoreFile::batchSync($files);
$total += $syncCount;
$lastId = $files->last()->id;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($files) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
if ($isIncremental && !$this->shouldStop) {
$newCount = File::where('id', '>', $lastId)
->where('type', '!=', 'folder')
->where('size', '<=', $maxFileSize)
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 个新文件,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break;
} while (!$this->shouldStop);
$this->info("同步文件结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引文件数量: " . ManticoreFile::getIndexedCount());
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreMsg;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncMsgToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --dialog: 指定对话ID
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-msgs {--f} {--i} {--c} {--batch=100} {--dialog=} {--sleep=3}';
protected $description = '同步消息数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
// 清除索引
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreMsg::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$dialogId = $this->option('dialog') ? intval($this->option('dialog')) : 0;
if ($dialogId > 0) {
$this->info("开始同步对话 {$dialogId} 的消息数据...");
$this->syncDialogMsgs($dialogId);
} else {
$this->info('开始同步消息数据...');
$this->syncMsgs();
}
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
/**
* 同步所有消息
*/
private function syncMsgs(): void
{
$lastKey = "sync:manticoreMsgLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$round = 0;
// 持续处理循环(增量模式下)
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步消息数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步消息数据...");
}
}
// 构建基础查询条件
$count = WebSocketDialogMsg::where('id', '>', $lastId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 条消息");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$msgs = WebSocketDialogMsg::where('id', '>', $lastId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
->orderBy('id')
->limit($batchSize)
->get();
if ($msgs->isEmpty()) {
break;
}
$num += count($msgs);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 消息ID {$msgs->first()->id} ~ {$msgs->last()->id}");
$this->setLock();
$syncCount = ManticoreMsg::batchSync($msgs);
$total += $syncCount;
$lastId = $msgs->last()->id;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($msgs) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
// 增量模式下,检查是否有新数据,有则继续
if ($isIncremental && !$this->shouldStop) {
$newCount = WebSocketDialogMsg::where('id', '>', $lastId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 条新数据,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break; // 非增量模式或无新数据,退出循环
} while (!$this->shouldStop);
$this->info("同步消息结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引消息数量: " . ManticoreMsg::getIndexedCount());
}
/**
* 同步指定对话的消息
*
* @param int $dialogId 对话ID
*/
private function syncDialogMsgs(int $dialogId): void
{
$this->info("\n同步对话 {$dialogId} 的消息数据...");
$baseQuery = WebSocketDialogMsg::where('dialog_id', $dialogId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES);
$num = 0;
$count = $baseQuery->count();
$batchSize = $this->option('batch');
$lastId = 0;
$total = 0;
$lastNum = 0;
do {
$msgs = WebSocketDialogMsg::where('dialog_id', $dialogId)
->where('id', '>', $lastId)
->whereNull('deleted_at')
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
->orderBy('id')
->limit($batchSize)
->get();
if ($msgs->isEmpty()) {
break;
}
$num += count($msgs);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
if ($progress < 100) {
$progress = number_format($progress, 2);
}
$this->info("{$num}/{$count} ({$progress}%) 正在同步消息ID {$msgs->first()->id} ~ {$msgs->last()->id} ({$total}|{$lastNum})");
$this->setLock();
$lastNum = ManticoreMsg::batchSync($msgs);
$total += $lastNum;
$lastId = $msgs->last()->id;
} while (count($msgs) == $batchSize);
$this->info("同步对话 {$dialogId} 消息结束");
$this->info("该对话已索引消息数量: " . \App\Module\Manticore\ManticoreBase::getDialogIndexedMsgCount($dialogId));
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\Project;
use App\Module\Apps;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncProjectToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-projects {--f} {--i} {--c} {--batch=100} {--sleep=3}';
protected $description = '同步项目数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreProject::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步项目数据...');
$this->syncProjects();
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
private function syncProjects(): void
{
$lastKey = "sync:manticoreProjectLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$round = 0;
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步项目数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步项目数据...");
}
}
$count = Project::where('id', '>', $lastId)
->whereNull('archived_at')
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 个项目");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$projects = Project::where('id', '>', $lastId)
->whereNull('archived_at')
->orderBy('id')
->limit($batchSize)
->get();
if ($projects->isEmpty()) {
break;
}
$num += count($projects);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 项目ID {$projects->first()->id} ~ {$projects->last()->id}");
$this->setLock();
$syncCount = ManticoreProject::batchSync($projects);
$total += $syncCount;
$lastId = $projects->last()->id;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($projects) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
if ($isIncremental && !$this->shouldStop) {
$newCount = Project::where('id', '>', $lastId)
->whereNull('archived_at')
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 个新项目,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break;
} while (!$this->shouldStop);
$this->info("同步项目结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引项目数量: " . ManticoreProject::getIndexedCount());
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\ProjectTask;
use App\Module\Apps;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncTaskToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-tasks {--f} {--i} {--c} {--batch=100} {--sleep=3}';
protected $description = '同步任务数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreTask::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步任务数据...');
$this->syncTasks();
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
private function syncTasks(): void
{
$lastKey = "sync:manticoreTaskLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$round = 0;
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步任务数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步任务数据...");
}
}
$count = ProjectTask::where('id', '>', $lastId)
->whereNull('archived_at')
->whereNull('deleted_at')
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 个任务");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$tasks = ProjectTask::where('id', '>', $lastId)
->whereNull('archived_at')
->whereNull('deleted_at')
->orderBy('id')
->limit($batchSize)
->get();
if ($tasks->isEmpty()) {
break;
}
$num += count($tasks);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 任务ID {$tasks->first()->id} ~ {$tasks->last()->id}");
$this->setLock();
$syncCount = ManticoreTask::batchSync($tasks);
$total += $syncCount;
$lastId = $tasks->last()->id;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($tasks) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
if ($isIncremental && !$this->shouldStop) {
$newCount = ProjectTask::where('id', '>', $lastId)
->whereNull('archived_at')
->whereNull('deleted_at')
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 个新任务,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break;
} while (!$this->shouldStop);
$this->info("同步任务结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引任务数量: " . ManticoreTask::getIndexedCount());
}
}

View File

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

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\User;
use App\Module\Apps;
use App\Module\Manticore\ManticoreUser;
use App\Module\Manticore\ManticoreKeyValue;
use Illuminate\Console\Command;
class SyncUserToManticore extends Command
{
use ManticoreSyncLock;
/**
* 更新数据
* --f: 全量更新 (默认)
* --i: 增量更新
*
* 清理数据
* --c: 清除索引
*
* 其他选项
* --sleep: 每批处理完成后休眠秒数
*/
protected $signature = 'manticore:sync-users {--f} {--i} {--c} {--batch=100} {--sleep=3}';
protected $description = '同步用户数据到 Manticore Search';
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
if ($this->option('c')) {
$this->info('清除索引...');
ManticoreUser::clear();
$this->info("索引删除成功");
$this->releaseLock();
return 0;
}
$this->info('开始同步用户数据...');
$this->syncUsers();
$this->info("\n同步完成");
$this->releaseLock();
return 0;
}
private function syncUsers(): void
{
$lastKey = "sync:manticoreUserLastId";
$isIncremental = $this->option('i');
$sleepSeconds = intval($this->option('sleep'));
$batchSize = $this->option('batch');
$round = 0;
do {
$round++;
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
if ($round === 1) {
if ($lastId > 0) {
$this->info("\n增量同步用户数据从ID {$lastId} 开始)...");
} else {
$this->info("\n全量同步用户数据...");
}
}
$count = User::where('userid', '>', $lastId)
->where('bot', 0)
->whereNull('disable_at')
->count();
if ($count === 0) {
if ($round === 1) {
$this->info("无待同步数据");
}
break;
}
$this->info("[第 {$round} 轮] 待同步 {$count} 个用户");
$num = 0;
$total = 0;
do {
if ($this->shouldStop) {
break;
}
$users = User::where('userid', '>', $lastId)
->where('bot', 0)
->whereNull('disable_at')
->orderBy('userid')
->limit($batchSize)
->get();
if ($users->isEmpty()) {
break;
}
$num += count($users);
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
$this->info("{$num}/{$count} ({$progress}%) 用户ID {$users->first()->userid} ~ {$users->last()->userid}");
$this->setLock();
$syncCount = ManticoreUser::batchSync($users);
$total += $syncCount;
$lastId = $users->last()->userid;
ManticoreKeyValue::set($lastKey, $lastId);
} while (count($users) == $batchSize && !$this->shouldStop);
$this->info("[第 {$round} 轮] 完成,同步 {$total}最后ID {$lastId}");
if ($isIncremental && !$this->shouldStop) {
$newCount = User::where('userid', '>', $lastId)
->where('bot', 0)
->whereNull('disable_at')
->count();
if ($newCount > 0) {
$this->info("发现 {$newCount} 个新用户,{$sleepSeconds} 秒后继续...");
sleep($sleepSeconds);
continue;
}
}
break;
} while (!$this->shouldStop);
$this->info("同步用户结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
$this->info("已索引用户数量: " . ManticoreUser::getIndexedCount());
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Console\Commands\Traits;
use Cache;
/**
* Manticore 同步命令通用锁机制
*
* 提供:
* - 锁的获取、设置、释放
* - 信号处理(优雅退出)
* - 通用的命令初始化检查
*/
trait ManticoreSyncLock
{
private bool $shouldStop = false;
/**
* 获取锁信息
*/
private function getLock(): ?array
{
$lockKey = $this->getLockKey();
return Cache::has($lockKey) ? Cache::get($lockKey) : null;
}
/**
* 设置锁30分钟有效期持续处理时需不断刷新
*/
private function setLock(): void
{
$lockKey = $this->getLockKey();
Cache::put($lockKey, ['started_at' => date('Y-m-d H:i:s')], 1800);
}
/**
* 释放锁
*/
private function releaseLock(): void
{
$lockKey = $this->getLockKey();
Cache::forget($lockKey);
}
/**
* 获取锁的缓存键
*/
private function getLockKey(): string
{
return md5($this->signature);
}
/**
* 信号处理器SIGINT/SIGTERM
*/
public function handleSignal(int $signal): void
{
$this->info("\n收到信号,将在当前批次完成后退出...");
$this->shouldStop = true;
}
/**
* 注册信号处理器
*/
private function registerSignalHandlers(): void
{
if (extension_loaded('pcntl')) {
pcntl_async_signals(true);
pcntl_signal(SIGINT, [$this, 'handleSignal']);
pcntl_signal(SIGTERM, [$this, 'handleSignal']);
}
}
/**
* 检查命令是否可以启动(锁检查)
*
* @return bool 返回 true 表示可以启动false 表示已被占用
*/
private function acquireLock(): bool
{
$lockInfo = $this->getLock();
if ($lockInfo) {
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
return false;
}
$this->setLock();
return true;
}
}

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;
@@ -23,6 +23,7 @@ use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\BillMultipleExport;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Swoole\Coroutine;
/**
* @apiDefine approve
@@ -40,7 +41,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/verifyToken 01. 验证APi登录
* @api {get} api/approve/verifyToken 验证APi登录
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -62,7 +63,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/procdef/all 02. 查询流程定义
* @api {post} api/approve/procdef/all 查询流程定义
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -89,7 +90,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/procdef/del 03. 删除流程定义
* @api {get} api/approve/procdef/del 删除流程定义
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -115,7 +116,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/start 04. 启动流程(审批中)
* @api {post} api/approve/process/start 启动流程(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -178,7 +179,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/addGlobalComment 05. 添加全局评论
* @api {post} api/approve/process/addGlobalComment 添加全局评论
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -223,7 +224,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/task/complete 06. 审批
* @api {post} api/approve/task/complete 审批
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -303,7 +304,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/task/withdraw 07. 撤回
* @api {post} api/approve/task/withdraw 撤回
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -348,7 +349,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/findTask 08. 查询需要我审批的流程(审批中)
* @api {post} api/approve/process/findTask 查询需要我审批的流程(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -391,7 +392,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/startByMyselfAll 09. 查询我启动的流程(全部)
* @api {post} api/approve/process/startByMyselfAll 查询我启动的流程(全部)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -434,7 +435,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/startByMyself 10. 查询我启动的流程(审批中)
* @api {post} api/approve/process/startByMyself 查询我启动的流程(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -472,7 +473,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/process/findProcNotify 11. 查询抄送我的流程(审批中)
* @api {post} api/approve/process/findProcNotify 查询抄送我的流程(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -516,7 +517,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/identitylink/findParticipant 12. 查询流程实例的参与者(审批中)
* @api {get} api/approve/identitylink/findParticipant 查询流程实例的参与者(审批中)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -551,7 +552,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/procHistory/findTask 13. 查询需要我审批的流程(已结束)
* @api {post} api/approve/procHistory/findTask 查询需要我审批的流程(已结束)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -594,7 +595,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/procHistory/startByMyself 14. 查询我启动的流程(已结束)
* @api {post} api/approve/procHistory/startByMyself 查询我启动的流程(已结束)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -632,7 +633,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/procHistory/findProcNotify 15. 查询抄送我的流程(已结束)
* @api {post} api/approve/procHistory/findProcNotify 查询抄送我的流程(已结束)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -676,7 +677,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/identitylinkHistory/findParticipant 16. 查询流程实例的参与者(已结束)
* @api {get} api/approve/identitylinkHistory/findParticipant 查询流程实例的参与者(已结束)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -711,7 +712,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/process/detail 17. 根据流程ID查询流程详情
* @api {get} api/approve/process/detail 根据流程ID查询流程详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -733,7 +734,7 @@ class ApproveController extends AbstractController
}
/**
* @api {post} api/approve/export 18. 导出数据
* @api {post} api/approve/export 导出数据
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -768,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)
@@ -908,7 +970,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/down 19. 下载导出的审批数据
* @api {get} api/approve/down 下载导出的审批数据
*
* @apiVersion 1.0.0
* @apiGroup approve
@@ -920,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));
}
@@ -1135,7 +1192,7 @@ class ApproveController extends AbstractController
/**
* @api {get} api/approve/user/status 20. 获取用户审批状态
* @api {get} api/approve/user/status 获取用户审批状态
*
* @apiVersion 1.0.0
* @apiGroup approve
@@ -1155,7 +1212,7 @@ class ApproveController extends AbstractController
}
/**
* @api {get} api/approve/process/doto 21. 查询需要我审批的流程数量
* @api {get} api/approve/process/doto 查询需要我审批的流程数量
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\AI;
use App\Module\Apps;
use App\Module\Base;
use Request;
/**
* @apiDefine assistant
*
* 助手
*/
class AssistantController extends AbstractController
{
public function __construct()
{
Apps::isInstalledThrow('ai');
}
/**
* @api {post} api/assistant/auth 生成授权码
*
* @apiDescription 需要token身份生成 AI 流式会话的 stream_key
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName auth
*
* @apiParam {String} model_type 模型类型
* @apiParam {String} model_name 模型名称
* @apiParam {JSON} context 上下文数组
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.stream_key 流式会话凭证
*/
public function auth()
{
$user = User::auth();
$user->checkChatInformation();
$modelType = trim(Request::input('model_type', ''));
$modelName = trim(Request::input('model_name', ''));
$contextInput = Request::input('context', []);
return AI::createStreamKey($modelType, $modelName, $contextInput);
}
/**
* @api {get} api/assistant/models 获取AI模型
*
* @apiDescription 获取所有AI机器人模型设置
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName models
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function models()
{
$setting = Base::setting('aibotSetting');
$setting = array_filter($setting, function ($value, $key) {
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
}, ARRAY_FILTER_USE_BOTH);
return Base::retSuccess('success', $setting ?: json_decode('{}'));
}
/**
* @api {post} api/assistant/match-elements 元素向量匹配
*
* @apiDescription 通过向量相似度匹配页面元素,用于智能查找与查询语义相关的元素
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName match_elements
*
* @apiParam {String} query 搜索关键词
* @apiParam {Array} elements 元素列表,每个元素包含 ref 和 name 字段
* @apiParam {Number} [top_k=10] 返回的匹配数量最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {Array} data.matches 匹配结果数组,按相似度降序排列
*/
public function match_elements()
{
User::auth();
$query = trim(Request::input('query', ''));
$elements = Request::input('elements', []);
$topK = min(intval(Request::input('top_k', 10)), 50);
if (empty($query) || empty($elements)) {
return Base::retError('参数不能为空');
}
// 获取查询向量
$queryResult = AI::getEmbedding($query);
if (Base::isError($queryResult)) {
return $queryResult;
}
$queryVector = $queryResult['data'];
// 计算相似度并排序
$scored = [];
foreach ($elements as $el) {
$name = $el['name'] ?? '';
if (empty($name)) {
continue;
}
$elResult = AI::getEmbedding($name);
if (Base::isError($elResult)) {
continue;
}
$similarity = $this->cosineSimilarity($queryVector, $elResult['data']);
$scored[] = [
'element' => $el,
'similarity' => $similarity,
];
}
// 按相似度降序排序
usort($scored, fn($a, $b) => $b['similarity'] <=> $a['similarity']);
return Base::retSuccess('success', [
'matches' => array_slice($scored, 0, $topK),
]);
}
/**
* 计算两个向量的余弦相似度
*/
private function cosineSimilarity(array $a, array $b): float
{
$dotProduct = 0;
$normA = 0;
$normB = 0;
$count = count($a);
for ($i = 0; $i < $count; $i++) {
$dotProduct += $a[$i] * $b[$i];
$normA += $a[$i] * $a[$i];
$normB += $b[$i] * $b[$i];
}
$denominator = sqrt($normA) * sqrt($normB);
if ($denominator == 0) {
return 0;
}
return $dotProduct / $denominator;
}
}

View File

@@ -10,18 +10,18 @@ use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
/**
* @apiDefine dialog
* @apiDefine complaint
*
* 投诉
*/
class ComplaintController extends AbstractController
{
/**
* @api {get} api/complaint/lists 01. 获取举报投诉列表
* @api {get} api/complaint/lists 获取举报投诉列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiGroup complaint
* @apiName lists
*
* @apiParam {Number} [type] 类型
@@ -33,6 +33,34 @@ class ComplaintController extends AbstractController
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* @apiSuccessExample {json} Success-Response-Data:
* {
* "current_page": 1,
* "data": [
* {
* "id": 1,
* "dialog_id": 100,
* "userid": 1,
* "type": 1,
* "reason": "举报原因",
* "imgs": [],
* "status": 0,
* "created_at": "2025-01-01 00:00:00",
* "updated_at": "2025-01-01 00:00:00"
* }
* ],
* "first_page_url": "http://example.com/api/complaint/lists?page=1",
* "from": 1,
* "last_page": 1,
* "last_page_url": "http://example.com/api/complaint/lists?page=1",
* "next_page_url": null,
* "path": "http://example.com/api/complaint/lists",
* "per_page": 50,
* "prev_page_url": null,
* "to": 1,
* "total": 1
* }
*/
public function lists()
{
@@ -56,21 +84,25 @@ class ComplaintController extends AbstractController
}
/**
* @api {get} api/complaint/submit 02. 举报投诉
* @api {post} api/complaint/submit 举报投诉
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiGroup complaint
* @apiName submit
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {Number} type 类型
* @apiParam {String} reason 原因
* @apiParam {String} imgs 图片
* @apiBody {Number} dialog_id 对话ID
* @apiBody {Number} type 类型
* @apiBody {String} reason 原因
* @apiBody {Object[]} [imgs] 图片数组(可选)
* @apiBody {String} imgs.path 图片路径
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* @apiSuccessExample {json} Success-Response-Data:
* []
*/
public function submit()
{
@@ -125,19 +157,22 @@ class ComplaintController extends AbstractController
}
/**
* @api {get} api/complaint/action 03. 举报投诉 - 操作
* @api {post} api/complaint/action 举报投诉 - 操作
*
* @apiDescription 需要token身份
* @apiDescription 需要token身份(管理员权限)
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiGroup complaint
* @apiName action
*
* @apiParam {Number} id ID
* @apiParam {Number} type 类型
* @apiBody {Number} id 投诉ID
* @apiBody {String} type 操作类型handle=已处理delete=删除
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*
* @apiSuccessExample {json} Success-Response-Data:
* []
*/
public function action()
{

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,14 @@ 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\Lock;
use App\Module\Timer;
use App\Module\Ihttp;
use App\Module\Manticore\ManticoreFile;
use Response;
use Session;
use Swoole\Coroutine;
use Carbon\Carbon;
use Redirect;
@@ -30,7 +33,7 @@ use ZipArchive;
class FileController extends AbstractController
{
/**
* @api {get} api/file/lists 01. 获取文件列表
* @api {get} api/file/lists 获取文件列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -53,7 +56,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/one 02. 获取单条数据
* @api {get} api/file/one 获取单条数据
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -63,6 +66,14 @@ class FileController extends AbstractController
* @apiParam {Number|String} id
* - Number 文件ID需要登录
* - String 链接码(不需要登录,用于预览)
* @apiParam {String} [with_url] 是否返回文件访问URL
* - no: 不返回(默认)
* - yes: 返回content_url字段
* @apiParam {String} [with_text] 是否提取文件文本内容用于AI阅读支持分页
* - no: 不提取(默认)
* - yes: 提取文本内容,支持 docx/xlsx/pptx/pdf/txt 等格式
* @apiParam {Number} [text_offset] with_text=yes时有效文本起始位置字符数默认0
* @apiParam {Number} [text_limit] with_text=yes时有效文本获取长度字符数默认50000最大200000
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -71,11 +82,15 @@ class FileController extends AbstractController
public function one()
{
$id = Request::input('id');
$with_url = Request::input('with_url', 'no');
$with_text = Request::input('with_text', 'no');
$text_offset = intval(Request::input('text_offset', 0));
$text_limit = intval(Request::input('text_limit', 50000));
//
$permission = 0;
if (Base::isNumber($id)) {
$user = User::auth();
$file = File::permissionFind(intval($id), $user, 0, $permission);
$file = File::permissionFind(intval($id), $user, $with_url === 'yes' ? 1 : 0, $permission);
} elseif ($id) {
$fileLink = FileLink::whereCode($id)->first();
$file = $fileLink?->file;
@@ -87,6 +102,12 @@ class FileController extends AbstractController
}
return Base::retError($msg, $data);
}
// 如果文件不允许游客访问,则需要登录
if (!$file->guest_access) {
User::auth();
}
$fileLink->increment("num");
} else {
return Base::retError('参数错误');
@@ -94,20 +115,74 @@ class FileController extends AbstractController
//
$array = $file->toArray();
$array['permission'] = $permission;
// 如果请求返回文件URL
if ($with_url === 'yes') {
$array['content_url'] = FileContent::getFileUrl($file->id);
}
// 如果请求提取文本内容
if ($with_text === 'yes') {
$array['text_content'] = ManticoreFile::extractFileContentPaginated($file, $text_offset, $text_limit);
}
return Base::retSuccess('success', $array);
}
/**
* @api {get} api/file/search 03. 搜索文件列表
* @api {get} api/file/fetch 通过路径获取文件文本内容
*
* @apiDescription 需要token身份
* @apiDescription 用于 MCP/AI 工具通过文件路径获取内容,支持分页获取大文件
* @apiVersion 1.0.0
* @apiGroup file
* @apiName fetch
*
* @apiParam {String} path 文件路径(相对于系统根目录,如 uploads/file/...
* @apiParam {Number} [offset] 起始位置字符数默认0
* @apiParam {Number} [limit] 获取长度字符数默认50000最大200000
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* - content: 文本内容
* - total_length: 完整内容总长度
* - offset: 当前起始位置
* - limit: 本次获取长度
* - has_more: 是否还有更多内容
*/
public function fetch()
{
User::auth();
//
$path = trim(Request::input('path'));
$offset = intval(Request::input('offset', 0));
$limit = intval(Request::input('limit', 50000));
if (empty($path)) {
return Base::retError('参数错误path 不能为空');
}
// 直接传入路径ManticoreFile 内部处理 URL 解析
$result = ManticoreFile::extractFileContentPaginated($path, $offset, $limit);
if (isset($result['error'])) {
return Base::retError($result['error']);
}
return Base::retSuccess('success', $result);
}
/**
* @api {get} api/file/search 搜索文件列表
*
* @apiDescription 需要token身份仅搜索文件名AI 内容搜索请使用 api/search/file
* @apiVersion 1.0.0
* @apiGroup file
* @apiName search
*
* @apiParam {String} [link] 通过分享地址搜索https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==
* @apiParam {String} [key] 关键词
* @apiParam {Number} [take] 获取数量默认50最大100
* @apiParam {String} [link] 通过分享地址搜索https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==
* @apiParam {String} [key] 关键词
* @apiParam {Number} [take] 获取数量默认50最大100
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -128,13 +203,20 @@ class FileController extends AbstractController
return Base::retSuccess('success', []);
}
}
// 搜索自己的
$builder = File::whereUserid($user->userid);
if ($id) {
$builder->where("id", $id);
}
if ($key) {
$builder->where("name", "like", "%{$key}%");
if (!$id && Base::isNumber($key)) {
$builder->where(function ($query) use ($key) {
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
});
} else {
$builder->where("name", "like", "%{$key}%");
}
}
$array = $builder->take($take)->get()->toArray();
// 搜索共享的
@@ -153,7 +235,13 @@ class FileController extends AbstractController
$builder->where("id", $id);
}
if ($key) {
$builder->where("name", "like", "%{$key}%");
if (Base::isNumber($key)) {
$builder->where(function ($query) use ($key) {
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
});
} else {
$builder->where("name", "like", "%{$key}%");
}
}
$list = $builder->take($take)->get();
if ($list->isNotEmpty()) {
@@ -171,7 +259,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/add 04. 添加、修改文件(夹)
* @api {get} api/file/add 添加、修改文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -197,8 +285,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) {
@@ -280,7 +368,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/copy 05. 复制文件(夹)
* @api {get} api/file/copy 复制文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -341,7 +429,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/move 06. 移动文件(夹)
* @api {get} api/file/move 移动文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -388,7 +476,7 @@ class FileController extends AbstractController
throw new ApiException("{$file->name} 内含有共享文件,无法移动到另一个共享文件夹内");
}
$file->userid = $toShareFile->userid;
File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $toShareFile->userid]);
$file->updateChildFilesUserid($toShareFile->userid);
}
//
$tmpId = $pid;
@@ -400,7 +488,7 @@ class FileController extends AbstractController
}
} else {
$file->userid = $user->userid;
File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $user->userid]);
$file->updateChildFilesUserid($user->userid);
}
//
$file->pid = $pid;
@@ -416,7 +504,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/remove 07. 删除文件(夹)
* @api {get} api/file/remove 删除文件(夹)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -455,7 +543,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content 08. 获取文件内容
* @api {get} api/file/content 获取文件内容
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -519,6 +607,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));
}
@@ -526,7 +624,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content/save 09. 保存文件内容
* @api {get} api/file/content/save 保存文件内容
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -621,9 +719,9 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/office/token 10. 获取token
* @api {get} api/file/office/token 获取token
*
* @apiDescription 需要token身份
* @apiDescription 用于生成office在线编辑的token
* @apiVersion 1.0.0
* @apiGroup file
* @apiName office__token
@@ -636,8 +734,6 @@ class FileController extends AbstractController
*/
public function office__token()
{
User::auth();
//
File::isNeedInstallApp('office');
//
$config = Request::input('config');
@@ -648,7 +744,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content/office 11. 保存文件内容office
* @api {get} api/file/content/office 保存文件内容office
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -704,7 +800,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content/upload 12. 保存文件内容(上传文件)
* @api {get} api/file/content/upload 保存文件内容(上传文件)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -725,14 +821,24 @@ class FileController extends AbstractController
{
$user = User::auth();
$pid = intval(Request::input('pid'));
$overwrite = intval(Request::input('cover'));
$webkitRelativePath = Request::input('webkitRelativePath');
$data = (new File)->contentUpload($user, $pid, $webkitRelativePath, $overwrite);
return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
// 同一用户往相同父目录上传时排队,避免并发导致数据库死锁
try {
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid) {
$overwrite = intval(Request::input('cover'));
$webkitRelativePath = Request::input('webkitRelativePath');
$data = (new File)->contentUpload($user, $pid, $webkitRelativePath, $overwrite);
return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
}, 120000, 120000);
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
throw new ApiException('上传繁忙,请稍后再试');
}
throw $e;
}
}
/**
* @api {get} api/file/content/history 13. 获取内容历史
* @api {get} api/file/content/history 获取内容历史
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -764,7 +870,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/content/restore 14. 恢复文件历史
* @api {get} api/file/content/restore 恢复文件历史
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -806,7 +912,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/share 15. 获取共享信息
* @api {get} api/file/share 获取共享信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -842,7 +948,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/share/update 16. 设置共享
* @api {get} api/file/share/update 设置共享
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -932,7 +1038,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/share/out 17. 退出共享
* @api {get} api/file/share/out 退出共享
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -966,7 +1072,7 @@ class FileController extends AbstractController
}
/**
* @api {get} api/file/link 18. 获取链接
* @api {get} api/file/link 获取链接
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -977,6 +1083,9 @@ class FileController extends AbstractController
* @apiParam {String} refresh 刷新链接
* - no: 只获取(默认)
* - yes: 刷新链接,之前的将失效
* @apiParam {String} guest_access 是否允许游客访问
* - no: 不允许(默认)
* - yes: 允许游客访问
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@@ -988,15 +1097,22 @@ 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);
}
/**
* @api {get} api/file/download/pack 19. 打包文件
* @api {get} api/file/download/pack 打包文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1012,17 +1128,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));
}
@@ -1087,11 +1197,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));
@@ -1100,17 +1209,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) {
@@ -1119,11 +1229,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

@@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
use App\Models\AbstractModel;
use App\Models\ProjectTask;
use App\Models\Report;
use App\Models\ReportAnalysis;
use App\Models\ReportLink;
use App\Models\ReportReceive;
use App\Models\User;
@@ -28,7 +29,7 @@ use Illuminate\Support\Facades\Validator;
class ReportController extends AbstractController
{
/**
* @api {get} api/report/my 01. 我发送的汇报
* @api {get} api/report/my 我发送的汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -50,7 +51,9 @@ class ReportController extends AbstractController
{
$user = User::auth();
//
$builder = Report::with(['receivesUser'])->whereUserid($user->userid);
$builder = Report::with(['receivesUser'])
->select(Report::LIST_FIELDS)
->whereUserid($user->userid);
$keys = Request::input('keys');
if (is_array($keys)) {
if ($keys['key']) {
@@ -58,6 +61,11 @@ class ReportController extends AbstractController
$builder->whereHas('sendUser', function ($q2) use ($keys) {
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
});
} elseif (Base::isNumber($keys['key'])) {
$builder->where(function ($query) use ($keys) {
$query->where("id", intval($keys['key']))
->orWhere("title", "LIKE", "%{$keys['key']}%");
});
} else {
$builder->where("title", "LIKE", "%{$keys['key']}%");
}
@@ -75,7 +83,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/receive 02. 我接收的汇报
* @api {get} api/report/receive 我接收的汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -98,7 +106,8 @@ class ReportController extends AbstractController
public function receive(): array
{
$user = User::auth();
$builder = Report::with(['receivesUser']);
$builder = Report::with(['receivesUser'])
->select(Report::LIST_FIELDS);
$builder->whereHas("receivesUser", function ($query) use ($user) {
$query->where("report_receives.userid", $user->userid);
});
@@ -110,7 +119,11 @@ class ReportController extends AbstractController
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
});
} elseif (Base::isNumber($keys['key'])) {
$builder->where("userid", intval($keys['key']));
$builder->where(function ($query) use ($keys) {
$query->where("userid", intval($keys['key']))
->orWhere("id", intval($keys['key']))
->orWhere("title", "LIKE", "%{$keys['key']}%");
});
} else {
$builder->where("title", "LIKE", "%{$keys['key']}%");
}
@@ -143,7 +156,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/store 03. 保存并发送工作汇报
* @api {get} api/report/store 保存并发送工作汇报
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -282,7 +295,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/template 04. 生成汇报模板
* @api {get} api/report/template 生成汇报模板
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -326,6 +339,13 @@ class ReportController extends AbstractController
$start_time->startOfWeek();
$end_time = Carbon::instance($start_time)->endOfWeek();
}
// 周报时预计算下一周期时间范围(下周)
$next_start_time = null;
$next_end_time = null;
if ($type === Report::WEEKLY) {
$next_start_time = Carbon::instance($start_time)->copy()->addWeek();
$next_end_time = Carbon::instance($end_time)->copy()->addWeek();
}
// 生成唯一标识
$sign = Report::generateSign($type, 0, Carbon::instance($start_time));
@@ -361,6 +381,10 @@ class ReportController extends AbstractController
->get();
if ($complete_task->isNotEmpty()) {
foreach ($complete_task as $task) {
// 排除取消态任务:不将已取消任务计入“已完成工作”
if (ProjectTask::isCanceledFlowName($task->flow_item_name)) {
continue;
}
$complete_at = Carbon::parse($task->complete_at);
$remark = $type == Report::WEEKLY ? ('<div style="text-align:center">[' . Doo::translate('周' . ['日', '一', '二', '三', '四', '五', '六'][$complete_at->dayOfWeek]) . ']</div>') : '&nbsp;';
$completeDatas[] = [
@@ -376,18 +400,7 @@ class ReportController extends AbstractController
// 未完成的任务
$unfinishedDatas = [];
$unfinished_task = ProjectTask::query()
->join("projects", "projects.id", "=", "project_tasks.project_id")
->whereNull("projects.archived_at")
->whereNull("project_tasks.complete_at")
->whereNotNull("project_tasks.start_at")
->where("project_tasks.end_at", "<", $end_time->toDateTimeString())
->whereHas("taskUser", function ($query) use ($user) {
$query->where("userid", $user->userid);
})
->select("project_tasks.*")
->orderByDesc("project_tasks.id")
->get();
$unfinished_task = ProjectTask::buildUnfinishedTaskQuery($user->userid, $start_time, $end_time, true)->get();
if ($unfinished_task->isNotEmpty()) {
foreach ($unfinished_task as $task) {
empty($task->end_at) || $end_at = Carbon::parse($task->end_at);
@@ -407,8 +420,10 @@ class ReportController extends AbstractController
if ($type === Report::WEEKLY) {
$title = $user->nickname . "的周报[" . $start_time->format("m/d") . "-" . $end_time->format("m/d") . "]";
$title .= "[" . $start_time->month . "月第" . $start_time->weekOfMonth . "周]";
$unfinishedTitle = '本周未完成的工作';
} else {
$title = $user->nickname . "的日报[" . $start_time->format("Y/m/d") . "]";
$unfinishedTitle = '今日未完成的工作';
}
$title = Doo::translate($title);
@@ -421,22 +436,44 @@ class ReportController extends AbstractController
])->render();
$contents[] = '<p>&nbsp;</p>';
$contents[] = '<h2>' . Doo::translate('未完成的工作') . '</h2>';
$contents[] = '<h2>' . Doo::translate($unfinishedTitle) . '</h2>';
$contents[] = view('report', [
'labels' => $labels,
'datas' => $unfinishedDatas,
])->render();
if ($type === Report::WEEKLY) {
// 下周拟定计划:基于下周时间范围预生成候选任务
$nextPlanDatas = [];
if ($next_start_time && $next_end_time) {
$next_tasks = ProjectTask::buildUnfinishedTaskQuery($user->userid, $next_start_time, $next_end_time, false)->get();
if ($next_tasks->isNotEmpty()) {
foreach ($next_tasks as $task) {
$planTime = '-';
if ($task->start_at || $task->end_at) {
$startText = $task->start_at ? Carbon::parse($task->start_at)->format('Y-m-d H:i') : '';
$endText = $task->end_at ? Carbon::parse($task->end_at)->format('Y-m-d H:i') : '';
$planTime = trim($startText . ($endText ? (' ~ ' . $endText) : ''));
}
$nextPlanDatas[] = [
'[' . $task->project->name . '] ' . $task->name,
$planTime,
$task->taskUser->where("owner", 1)->map(function ($item) {
return User::userid2nickname($item->userid);
})->implode(", "),
];
}
}
}
$contents[] = '<p>&nbsp;</p>';
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $start_time->addWeek()->format("m/d") . "-" . $end_time->addWeek()->format("m/d") . "]</h2>";
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $next_start_time->format("m/d") . "-" . $next_end_time->format("m/d") . "]</h2>";
$contents[] = view('report', [
'labels' => [
Doo::translate('计划描述'),
Doo::translate('计划时间'),
Doo::translate('负责人'),
],
'datas' => [],
'datas' => $nextPlanDatas,
])->render();
}
@@ -454,7 +491,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/detail 05. 报告详情
* @api {get} api/report/detail 报告详情
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -501,11 +538,113 @@ class ReportController extends AbstractController
$one->report_link = $link;
$link->increment("num");
}
$analysis = ReportAnalysis::query()
->whereRid($one->id)
->whereUserid($user->userid)
->first();
if ($analysis) {
$updatedAt = $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null;
$one->setAttribute('ai_analysis', [
'id' => $analysis->id,
'text' => $analysis->analysis_text,
'model' => $analysis->model,
'updated_at' => $updatedAt,
]);
} else {
$one->setAttribute('ai_analysis', null);
}
return Base::retSuccess("success", $one);
}
/**
* @api {get} api/report/mark 06. 标记已读/未读
* @api {post} api/report/analysave 保存工作汇报 AI 分析
*
* @apiDescription 需要token身份仅支持报告提交人或接收人保存分析
* @apiVersion 1.0.0
* @apiGroup report
* @apiName analysave
*
* @apiParam {Number} id 报告ID
* @apiParam {String} text 分析内容Markdown
* @apiParam {String} [model] 分析使用的模型标识(可选)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {Number} data.id 分析记录ID
* @apiSuccess {String} data.text 分析内容Markdown
* @apiSuccess {String} data.updated_at 最近更新时间
*/
public function analysave(): array
{
$user = User::auth();
$id = intval(Request::input("id"));
if ($id <= 0) {
return Base::retError("缺少ID参数");
}
$text = trim((string)Request::input('text', ''));
if ($text === '') {
return Base::retError("分析内容不能为空");
}
$model = trim((string)Request::input('model', ''));
$report = Report::getOne($id);
if (!$this->userCanAccessReport($report, $user)) {
return Base::retError("无权访问该工作汇报");
}
$analysis = ReportAnalysis::query()
->whereRid($report->id)
->whereUserid($user->userid)
->first();
if (!$analysis) {
$analysis = ReportAnalysis::fillInstance([
'rid' => $report->id,
'userid' => $user->userid,
]);
}
$viewerRole = $user->profession ?: (is_array($user->identity) && !empty($user->identity) ? implode('/', $user->identity) : null);
$focusMeta = null;
$focus = Request::input('focus');
if (is_array($focus)) {
$focusMeta = array_filter(array_map('trim', $focus));
} elseif (is_string($focus) && trim($focus) !== '') {
$focusMeta = [trim($focus)];
}
$meta = array_filter([
'viewer_role' => $viewerRole,
'viewer_name' => $user->nickname ?? null,
'focus' => $focusMeta,
], function ($value) {
if (is_array($value)) {
return !empty($value);
}
return $value !== null && $value !== '';
});
$analysis->updateInstance([
'model' => $model,
'analysis_text' => $text,
'meta' => $meta,
]);
$analysis->save();
$analysis->refresh();
return Base::retSuccess("success", [
'id' => $analysis->id,
'text' => $analysis->analysis_text,
'model' => $analysis->model,
'updated_at' => $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null,
]);
}
/**
* @api {get} api/report/mark 标记已读/未读
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -548,7 +687,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/share 07. 分享报告到消息
* @api {get} api/report/share 分享报告到消息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -610,7 +749,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/last_submitter 08. 获取最后一次提交的接收人
* @api {get} api/report/last_submitter 获取最后一次提交的接收人
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -628,7 +767,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/unread 09. 获取未读
* @api {get} api/report/unread 获取未读
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -653,7 +792,7 @@ class ReportController extends AbstractController
}
/**
* @api {get} api/report/read 10. 标记汇报已读,可批量
* @api {get} api/report/read 标记汇报已读,可批量
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -691,4 +830,22 @@ class ReportController extends AbstractController
}
return Base::retSuccess("success", $data);
}
/**
* 判断当前用户是否有权限查看/分析指定工作汇报
* @param Report $report
* @param User $user
* @return bool
*/
protected function userCanAccessReport(Report $report, User $user): bool
{
if ($report->userid === $user->userid) {
return true;
}
return ReportReceive::query()
->whereRid($report->id)
->whereUserid($user->userid)
->exists();
}
}

View File

@@ -0,0 +1,619 @@
<?php
namespace App\Http\Controllers\Api;
use Request;
use App\Models\File;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\User;
use App\Models\UserTag;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Module\Base;
use App\Module\Apps;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreUser;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreMsg;
/**
* @apiDefine search
*
* 智能搜索
*/
class SearchController extends AbstractController
{
/**
* @api {get} api/search/contact 搜索联系人
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName contact
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid仅 Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function contact()
{
User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreUser::search($key, $searchType, $take);
// 补充用户完整信息
$userids = array_column($results, 'userid');
if (!empty($userids)) {
$users = User::whereIn('userid', $userids)
->select(User::$basicField)
->get()
->keyBy('userid');
foreach ($results as &$item) {
$userData = $users->get($item['userid']);
if ($userData) {
// 标签直接从 Manticore 搜索结果获取(空格分隔的字符串转数组)
$tagsStr = $item['tags'] ?? '';
$searchTags = !empty($tagsStr) ? preg_split('/\s+/', trim($tagsStr)) : [];
$item = array_merge($userData->toArray(), [
'relevance' => $item['relevance'] ?? 0,
'introduction_preview' => $item['introduction_preview'] ?? null,
'search_tags' => $searchTags,
]);
}
}
}
} else {
// MySQL 回退搜索
$results = $this->searchContactByMysql($key, $take);
}
return Base::retSuccess('success', $results);
}
/**
* MySQL 回退搜索联系人
*
* @param string $key 搜索关键词
* @param int $take 获取数量
* @return array
*/
private function searchContactByMysql(string $key, int $take): array
{
$users = User::select(User::$basicField)
->where('bot', 0)
->whereNull('disable_at')
->searchByKeyword($key)
->orderByDesc('line_at')
->take($take)
->get();
// 获取用户标签
$userids = $users->pluck('userid')->toArray();
$userTags = $this->getUserTagsMap($userids);
return $users->map(function ($user) use ($userTags) {
return array_merge($user->toArray(), [
'relevance' => 0,
'introduction_preview' => null,
'search_tags' => $userTags[$user->userid] ?? [],
]);
})->toArray();
}
/**
* @api {get} api/search/project 搜索项目
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName project
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid仅 Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function project()
{
$user = User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreProject::search($user->userid, $key, $searchType, $take);
// 补充项目完整信息
$projectIds = array_column($results, 'project_id');
if (!empty($projectIds)) {
$projects = Project::whereIn('id', $projectIds)
->get()
->keyBy('id');
foreach ($results as &$item) {
$projectData = $projects->get($item['project_id']);
if ($projectData) {
$item = array_merge($projectData->toArray(), [
'relevance' => $item['relevance'] ?? 0,
'desc_preview' => $item['desc_preview'] ?? null,
]);
}
}
}
} else {
// MySQL 回退搜索
$results = $this->searchProjectByMysql($user->userid, $key, $take);
}
return Base::retSuccess('success', $results);
}
/**
* MySQL 回退搜索项目
*
* @param int $userid 用户ID
* @param string $key 搜索关键词
* @param int $take 获取数量
* @return array
*/
private function searchProjectByMysql(int $userid, string $key, int $take): array
{
$projects = Project::authData()
->whereNull('projects.archived_at')
->searchByKeyword($key)
->orderByDesc('projects.id')
->take($take)
->get();
return $projects->map(function ($project) {
$array = $project->toArray();
$array['relevance'] = 0;
$array['desc_preview'] = null;
return $array;
})->toArray();
}
/**
* @api {get} api/search/task 搜索任务
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName task
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid仅 Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function task()
{
$user = User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreTask::search($user->userid, $key, $searchType, $take);
// 补充任务完整信息
$taskIds = array_column($results, 'task_id');
if (!empty($taskIds)) {
$tasks = ProjectTask::with(['taskUser', 'taskTag'])
->whereIn('id', $taskIds)
->get()
->keyBy('id');
foreach ($results as &$item) {
$taskData = $tasks->get($item['task_id']);
if ($taskData) {
$item = array_merge($taskData->toArray(), [
'relevance' => $item['relevance'] ?? 0,
'desc_preview' => $item['desc_preview'] ?? null,
'content_preview' => $item['content_preview'] ?? null,
]);
}
}
}
} else {
// MySQL 回退搜索
$results = $this->searchTaskByMysql($user->userid, $key, $take);
}
return Base::retSuccess('success', $results);
}
/**
* MySQL 回退搜索任务
*
* @param int $userid 用户ID
* @param string $key 搜索关键词
* @param int $take 获取数量
* @return array
*/
private function searchTaskByMysql(int $userid, string $key, int $take): array
{
$tasks = ProjectTask::with(['taskUser', 'taskTag'])
->whereIn('project_tasks.project_id', function ($query) use ($userid) {
$query->select('project_id')
->from('project_users')
->where('userid', $userid);
})
->whereNull('project_tasks.archived_at')
->whereNull('project_tasks.deleted_at')
->searchByKeyword($key)
->orderByDesc('project_tasks.id')
->take($take)
->get();
return $tasks->map(function ($task) {
$array = $task->toArray();
$array['relevance'] = 0;
$array['desc_preview'] = null;
$array['content_preview'] = null;
return $array;
})->toArray();
}
/**
* @api {get} api/search/file 搜索文件
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName file
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid仅 Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function file()
{
$user = User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreFile::search($user->userid, $key, $searchType, 0, $take);
// 补充文件完整信息
$fileIds = array_column($results, 'file_id');
if (!empty($fileIds)) {
$files = File::whereIn('id', $fileIds)
->get()
->keyBy('id');
$formattedResults = [];
foreach ($results as $item) {
$fileData = $files->get($item['file_id']);
if ($fileData) {
$formattedResults[] = array_merge($fileData->toArray(), [
'relevance' => $item['relevance'] ?? 0,
'content_preview' => $item['content_preview'] ?? null,
]);
}
}
return Base::retSuccess('success', $formattedResults);
}
return Base::retSuccess('success', []);
} else {
// MySQL 回退搜索
$results = $this->searchFileByMysql($user->userid, $key, $take);
return Base::retSuccess('success', $results);
}
}
/**
* MySQL 回退搜索文件
*
* @param int $userid 用户ID
* @param string $key 搜索关键词
* @param int $take 获取数量
* @return array
*/
private function searchFileByMysql(int $userid, string $key, int $take): array
{
$results = [];
// 搜索用户自己的文件
$ownFiles = File::where('userid', $userid)
->searchByKeyword($key)
->take($take)
->get();
foreach ($ownFiles as $file) {
$results[] = array_merge($file->toArray(), [
'relevance' => 0,
'content_preview' => null,
]);
}
// 搜索共享给用户的文件
$remaining = $take - count($results);
if ($remaining > 0) {
$sharedFiles = File::sharedToUser($userid)
->searchByKeyword($key)
->take($remaining)
->get();
foreach ($sharedFiles as $file) {
$temp = $file->toArray();
if ($file->pshare === $file->id) {
$temp['pid'] = 0;
}
$temp['relevance'] = 0;
$temp['content_preview'] = null;
$results[] = $temp;
}
}
return $results;
}
/**
* @api {get} api/search/message 搜索消息
*
* @apiDescription 需要token身份优先使用 Manticore Search未安装则使用 MySQL 搜索
* @apiVersion 1.0.0
* @apiGroup search
* @apiName message
*
* @apiParam {String} key 搜索关键词
* @apiParam {String} [search_type] 搜索类型text/vector/hybrid默认hybrid仅 Manticore 有效)
* @apiParam {Number} [take] 获取数量默认20最大50
* @apiParam {String} [mode] 返回模式message/position/dialog默认message
* - message: 返回消息详细信息
* - position: 只返回消息ID
* - dialog: 返回对话级数据
* @apiParam {Number} [dialog_id] 对话ID筛选指定对话内的消息
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function message()
{
$user = User::auth();
$key = trim(Request::input('key'));
$searchType = Request::input('search_type', 'hybrid');
$take = Base::getPaginate(50, 20, 'take');
$mode = Request::input('mode', 'message');
$dialogId = intval(Request::input('dialog_id', 0));
// 验证 mode 参数
if (!in_array($mode, ['message', 'position', 'dialog'])) {
$mode = 'message';
}
if (empty($key)) {
return Base::retSuccess('success', []);
}
// 如果指定了 dialog_id需要验证用户有权限访问该对话
if ($dialogId > 0) {
WebSocketDialog::checkDialog($dialogId);
}
// 优先使用 Manticore 搜索
if (Apps::isInstalled('search')) {
$results = ManticoreMsg::search($user->userid, $key, $searchType, 0, $take, $dialogId);
} else {
// MySQL 回退搜索
$results = $this->searchMessageByMysql($user->userid, $key, $take, $dialogId);
}
// 根据 mode 返回不同格式的数据
return $this->formatMessageResults($results, $mode, $user->userid);
}
/**
* MySQL 回退搜索消息
*
* @param int $userid 用户ID
* @param string $key 搜索关键词
* @param int $take 获取数量
* @param int $dialogId 对话ID0表示不限制
* @return array
*/
private function searchMessageByMysql(int $userid, string $key, int $take, int $dialogId = 0): array
{
$builder = WebSocketDialogMsg::select([
'id as msg_id',
'dialog_id',
'userid',
'type',
'msg',
'created_at',
])
->accessibleByUser($userid)
->where('bot', 0)
->searchByKeyword($key);
if ($dialogId > 0) {
$builder->where('dialog_id', $dialogId);
}
$items = $builder->orderByDesc('id')
->limit($take)
->get();
return $items->map(function ($item) {
return [
'msg_id' => $item->msg_id,
'dialog_id' => $item->dialog_id,
'userid' => $item->userid,
'type' => $item->type,
'msg' => $item->msg,
'created_at' => $item->created_at,
'relevance' => 0,
'content_preview' => null,
];
})->toArray();
}
/**
* 格式化消息搜索结果
*
* @param array $results 搜索结果
* @param string $mode 返回模式
* @param int $userid 用户ID
* @return \Illuminate\Http\JsonResponse
*/
private function formatMessageResults(array $results, string $mode, int $userid)
{
switch ($mode) {
case 'position':
// 只返回消息ID
$data = array_column($results, 'msg_id');
return Base::retSuccess('success', compact('data'));
case 'dialog':
// 返回对话级数据
$list = [];
$seenDialogs = [];
foreach ($results as $item) {
$dialogIdFromResult = $item['dialog_id'];
// 每个对话只返回一次
if (isset($seenDialogs[$dialogIdFromResult])) {
continue;
}
$seenDialogs[$dialogIdFromResult] = true;
if ($dialog = WebSocketDialog::find($dialogIdFromResult)) {
$dialogData = array_merge($dialog->toArray(), [
'search_msg_id' => $item['msg_id'],
]);
$list[] = WebSocketDialog::synthesizeData($dialogData, $userid);
}
}
return Base::retSuccess('success', ['data' => $list]);
case 'message':
default:
// 返回消息详细信息(默认行为)
$msgIds = array_column($results, 'msg_id');
if (!empty($msgIds)) {
$msgs = WebSocketDialogMsg::whereIn('id', $msgIds)
->with(['user' => function ($query) {
$query->select(User::$basicField);
}])
->get()
->keyBy('id');
// 创建结果映射以保持原始顺序和额外字段
$resultsMap = [];
foreach ($results as $item) {
$resultsMap[$item['msg_id']] = $item;
}
$formattedResults = [];
foreach ($msgIds as $msgId) {
$msgData = $msgs->get($msgId);
$originalItem = $resultsMap[$msgId] ?? [];
if ($msgData) {
$formattedResults[] = [
'id' => $msgData->id,
'msg_id' => $msgData->id,
'dialog_id' => $msgData->dialog_id,
'userid' => $msgData->userid,
'type' => $msgData->type,
'msg' => $msgData->msg,
'created_at' => $msgData->created_at,
'user' => $msgData->user,
'relevance' => $originalItem['relevance'] ?? 0,
'content_preview' => $originalItem['content_preview'] ?? null,
];
}
}
return Base::retSuccess('success', $formattedResults);
}
return Base::retSuccess('success', []);
}
}
/**
* 批量获取用户标签映射
*
* @param array $userids 用户ID数组
* @return array 用户ID => 标签名称数组的映射
*/
private function getUserTagsMap(array $userids): array
{
if (empty($userids)) {
return [];
}
// 获取所有用户的标签(带认可数)
$tags = UserTag::whereIn('user_id', $userids)
->withCount('recognitions')
->get();
// 按用户分组,每个用户取 Top 10 标签
$result = [];
foreach ($userids as $userid) {
$result[$userid] = [];
}
$userTags = $tags->groupBy('user_id');
foreach ($userTags as $userid => $tagCollection) {
$result[$userid] = $tagCollection
->sortByDesc('recognitions_count')
->take(10)
->pluck('name')
->values()
->toArray();
}
return $result;
}
}

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,7 +16,6 @@ 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;
@@ -22,6 +24,7 @@ use App\Module\Apps;
use App\Module\BillMultipleExport;
use LdapRecord\LdapRecordException;
use Guanguans\Notify\Messages\EmailMessage;
use Swoole\Coroutine;
/**
* @apiDefine system
@@ -32,7 +35,7 @@ class SystemController extends AbstractController
{
/**
* @api {get} api/system/setting 01. 获取设置、保存设置
* @api {get} api/system/setting 获取设置、保存设置
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -41,7 +44,7 @@ class SystemController extends AbstractController
* @apiParam {String} type
* - get: 获取(默认)
* - all: 获取所有(需要管理员权限)
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'voice2text', 'translation', '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']
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'convert_video', 'compress_video', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'task_user_limit', '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 返回信息(错误描述)
@@ -68,8 +71,6 @@ class SystemController extends AbstractController
'project_invite',
'chat_information',
'anon_message',
'voice2text',
'translation',
'convert_video',
'compress_video',
'e2e_message',
@@ -79,6 +80,7 @@ class SystemController extends AbstractController
'archived_day',
'task_visible',
'task_default_time',
'task_user_limit',
'all_group_mute',
'all_group_autoin',
'user_private_chat_mute',
@@ -103,12 +105,6 @@ class SystemController extends AbstractController
return Base::retError('自动归档时间不可大于100天');
}
}
if ($all['voice2text'] == 'open' && !Setting::AIOpen()) {
return Base::retError('开启语音转文字功能需要在应用中开启 ChatGPT AI 机器人。');
}
if ($all['translation'] == 'open' && !Setting::AIOpen()) {
return Base::retError('开启翻译功能需要在应用中开启 ChatGPT AI 机器人。');
}
if ($all['system_alias'] == env('APP_NAME')) {
$all['system_alias'] = '';
}
@@ -135,8 +131,6 @@ class SystemController extends AbstractController
$setting['project_invite'] = $setting['project_invite'] ?: 'open';
$setting['chat_information'] = $setting['chat_information'] ?: 'optional';
$setting['anon_message'] = $setting['anon_message'] ?: 'open';
$setting['voice2text'] = $setting['voice2text'] ?: 'close';
$setting['translation'] = $setting['translation'] ?: 'close';
$setting['convert_video'] = $setting['convert_video'] ?: 'close';
$setting['compress_video'] = $setting['compress_video'] ?: 'close';
$setting['e2e_message'] = $setting['e2e_message'] ?: 'close';
@@ -155,11 +149,11 @@ class SystemController extends AbstractController
$setting['server_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion();
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/email 02. 获取邮箱设置、保存邮箱设置(限管理员)
* @api {get} api/system/setting/email 获取邮箱设置、保存邮箱设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -225,11 +219,11 @@ class SystemController extends AbstractController
$setting = array_intersect_key($setting, array_flip(['reg_verify']));
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/meeting 03. 获取会议设置、保存会议设置(限管理员)
* @api {get} api/system/setting/meeting 获取会议设置、保存会议设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -279,11 +273,21 @@ class SystemController extends AbstractController
$setting['api_secret'] = substr($setting['api_secret'], 0, 4) . str_repeat('*', strlen($setting['api_secret']) - 8) . substr($setting['api_secret'], -4);
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/aibot 04. 获取会议设置、保存AI机器人设置(限管理员)
* AI助手设置(限管理员)
*
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function setting__ai()
{
Base::checkClientVersion('1.4.35');
}
/**
* @api {get} api/system/setting/aibot 获取AI设置、保存AI机器人设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -337,70 +341,31 @@ class SystemController extends AbstractController
}
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/aibot_models 05. 获取AI模型
* 获取AI模型
*
* @apiDescription 获取所有AI机器人模型设置
* @apiVersion 1.0.0
* @apiGroup system
* @apiName aibot_models
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function setting__aibot_models()
{
$setting = Base::setting('aibotSetting');
$setting = array_filter($setting, function($value, $key) {
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
}, ARRAY_FILTER_USE_BOTH);
return Base::retSuccess('success', $setting ?: json_decode('{}'));
Base::checkClientVersion('1.4.35');
}
/**
* @api {get} api/system/setting/aibot_defmodels 06. 获取AI默认模型
* 获取AI默认模型
*
* @apiDescription 获取AI机器人默认模型
* @apiVersion 1.0.0
* @apiGroup system
* @apiName setting__aibot_defmodels
*
* @apiParam {String} type AI类型
* @apiParam {String} [base_url] 基础URL仅 type=ollama 时有效)
* @apiParam {String} [key] Key仅 type=ollama 时有效)
* @apiParam {String} [agency] 使用代理(仅 type=ollama 时有效)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function setting__aibot_defmodels()
{
$type = trim(Request::input('type'));
if ($type == 'ollama') {
$baseUrl = trim(Request::input('base_url'));
$key = trim(Request::input('key'));
$agency = trim(Request::input('agency'));
if (empty($baseUrl)) {
return Base::retError('请先填写 Base URL');
}
return Extranet::ollamaModels($baseUrl, $key, $agency);
}
$models = Setting::AIDefaultModels($type);
if (empty($models)) {
return Base::retError('未找到默认模型');
}
return Base::retSuccess('success', [
'models' => $models
]);
Base::checkClientVersion('1.4.35');
}
/**
* @api {get} api/system/setting/checkin 07. 获取签到设置、保存签到设置(限管理员)
* @api {get} api/system/setting/checkin 获取签到设置、保存签到设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -436,8 +401,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',
@@ -455,14 +425,25 @@ class SystemController extends AbstractController
}
if (is_array($all['modes'])) {
if (in_array('locat', $all['modes'])) {
if (empty($all['locat_bd_lbs_key'])) {
return Base::retError('请填写百度地图AK');
$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('请选择地图类型');
}
if (!is_array($all['locat_bd_lbs_point'])) {
$conf = $mapTypes[$type];
if (empty($all[$conf['key']])) {
return Base::retError($conf['msg']);
}
if (!is_array($all[$conf['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'])) {
$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('请选择有效的签到位置');
}
}
@@ -475,6 +456,24 @@ class SystemController extends AbstractController
if ($all['modes']) {
$all['modes'] = array_intersect($all['modes'], ['auto', 'manual', 'locat', 'face']);
}
// 验证提前和延后时间是否重叠(跨天打卡支持)
if ($all['open'] === 'open') {
$times = is_array($all['time']) ? $all['time'] : Base::json2array($all['time']);
if (count($times) >= 2) {
$startMinutes = intval(substr($times[0], 0, 2)) * 60 + intval(substr($times[0], 3, 2));
$endMinutes = intval(substr($times[1], 0, 2)) * 60 + intval(substr($times[1], 3, 2));
$shiftDuration = $endMinutes - $startMinutes;
if ($shiftDuration <= 0) {
$shiftDuration += 24 * 60; // 处理跨天班次
}
$advance = intval($all['advance']) ?: 120;
$delay = intval($all['delay']) ?: 120;
$maxAllowed = 24 * 60 - $shiftDuration;
if ($advance + $delay >= $maxAllowed) {
return Base::retError('提前和延后时间设置存在重叠,最大提前+延后时间不能超过 ' . ($maxAllowed - 1) . ' 分钟');
}
}
}
$setting = Base::setting('checkinSetting', Base::newTrim($all));
} else {
$setting = Base::setting('checkinSetting');
@@ -494,7 +493,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;
@@ -508,11 +510,11 @@ class SystemController extends AbstractController
$setting['cmd'] = base64_encode($setting['cmd']);
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/apppush 08. 获取APP推送设置、保存APP推送设置限管理员
* @api {get} api/system/setting/apppush 获取APP推送设置、保存APP推送设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -553,11 +555,11 @@ class SystemController extends AbstractController
//
$setting['push'] = $setting['push'] ?: 'close';
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/thirdaccess 09. 第三方帐号(限管理员)
* @api {get} api/system/setting/thirdaccess 第三方帐号(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -623,11 +625,11 @@ class SystemController extends AbstractController
$setting['ldap_port'] = intval($setting['ldap_port']) ?: 389;
$setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close';
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/file 10. 文件设置(限管理员)
* @api {get} api/system/setting/file 文件设置(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -663,11 +665,11 @@ class SystemController extends AbstractController
$setting = Base::setting('fileSetting');
}
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/demo 11. 获取演示帐号
* @api {get} api/system/demo 获取演示帐号
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -691,7 +693,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/priority 12. 任务优先级
* @api {post} api/system/priority 任务优先级
*
* @apiDescription 获取任务优先级、保存任务优先级
* @apiVersion 1.0.0
@@ -713,34 +715,64 @@ class SystemController extends AbstractController
if ($type == 'save') {
User::auth('admin');
$list = Request::input('list');
$array = [];
if (empty($list) || !is_array($list)) {
return Base::retError('参数错误');
}
foreach ($list AS $item) {
if (empty($item['name']) || empty($item['color']) || empty($item['priority'])) {
continue;
}
$array[] = [
'name' => $item['name'],
'color' => $item['color'],
'days' => intval($item['days']),
'priority' => intval($item['priority']),
];
}
$array = Setting::normalizeTaskPriorityList($list);
if (empty($array)) {
return Base::retError('参数为空');
}
$setting = Base::setting('priority', $array);
} else {
$setting = Base::setting('priority');
$setting = Setting::normalizeTaskPriorityList(Base::setting('priority'));
}
//
return Base::retSuccess('success', $setting);
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
/**
* @api {post} api/system/column/template 13. 创建项目模板
* @api {post} api/system/microapp_menu 自定义应用菜单
*
* @apiDescription 获取或保存自定义微应用菜单,仅管理员可配置
* @apiVersion 1.0.0
* @apiGroup system
* @apiName microapp_menu
*
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存(限管理员)
* @apiParam {Array} list 菜单列表,格式:[{id,name,version,menu_items}]
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function microapp_menu()
{
$type = trim(Request::input('type'));
$user = User::auth();
if ($type == 'save') {
User::auth('admin');
$list = Request::input('list');
if (empty($list) || !is_array($list)) {
$list = [];
}
$apps = Setting::normalizeCustomMicroApps($list);
$setting = Base::setting('microapp_menu', $apps);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
} else {
$setting = Base::setting('microapp_menu');
if (!is_array($setting)) {
$setting = [];
}
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
}
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
/**
* @api {post} api/system/column/template 创建项目模板
*
* @apiDescription 获取创建项目模板、保存创建项目模板
* @apiVersion 1.0.0
@@ -783,11 +815,11 @@ class SystemController extends AbstractController
$setting = Base::setting('columnTemplate');
}
//
return Base::retSuccess('success', $setting);
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
/**
* @api {post} api/system/license 14. License
* @api {post} api/system/license License
*
* @apiDescription 获取License信息、保存License限管理员
* @apiVersion 1.0.0
@@ -853,11 +885,11 @@ class SystemController extends AbstractController
];
}
//
return Base::retSuccess('success', $data);
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $data ?: json_decode('{}'));
}
/**
* @api {get} api/system/get/info 15. 获取终端详细信息
* @api {get} api/system/get/info 获取终端详细信息
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -876,8 +908,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(),
@@ -886,7 +916,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/ip 16. 获取IP地址
* @api {get} api/system/get/ip 获取IP地址
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -901,7 +931,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/cnip 17. 是否中国IP地址
* @api {get} api/system/get/cnip 是否中国IP地址
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -918,41 +948,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 上传图片
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1018,7 +1014,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/imgview 21. 浏览图片空间
* @api {get} api/system/get/imgview 浏览图片空间
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1115,7 +1111,7 @@ class SystemController extends AbstractController
}
/**
* @api {post} api/system/fileupload 22. 上传文件
* @api {post} api/system/fileupload 上传文件
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
@@ -1159,7 +1155,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/get/updatelog 23. 获取更新日志
* @api {get} api/system/get/updatelog 获取更新日志
*
* @apiDescription 获取更新日志
* @apiVersion 1.0.0
@@ -1202,7 +1198,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/email/check 24. 邮件发送测试(限管理员)
* @api {get} api/system/email/check 邮件发送测试(限管理员)
*
* @apiDescription 测试配置邮箱是否能发送邮件
* @apiVersion 1.0.0
@@ -1248,7 +1244,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/checkin/export 25. 导出签到数据(限管理员)
* @api {get} api/system/checkin/export 导出签到数据(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1264,7 +1260,7 @@ class SystemController extends AbstractController
*/
public function checkin__export()
{
User::auth('admin');
$user = User::auth('admin');
//
$setting = Base::setting('checkinSetting');
if ($setting['open'] !== 'open') {
@@ -1293,131 +1289,187 @@ 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");
// 获取延后时间配置(用于跨天打卡导出)
$delaySeconds = (intval($setting['delay']) ?: 120) * 60;
//
$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, $delaySeconds) {
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, $time[0]);
$firstBetween = [Carbon::createFromTimestamp($startT), Carbon::createFromTimestamp($startT + $secondEnd - 1)];
// 扩展下班打卡范围以支持跨天打卡
$lastBetween = [Carbon::createFromTimestamp($startT + $secondStart + 1), Carbon::createFromTimestamp($startT + 86400 + $delaySeconds)];
$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 下载导出的签到数据
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1429,21 +1481,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 获取版本号
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -1489,7 +1536,7 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/prefetch 28. 预加载的资源
* @api {get} api/system/prefetch 预加载的资源
*
* @apiVersion 1.0.0
* @apiGroup system

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,378 @@
# apiDoc 参数标签说明(完整速查)
apiDoc 使用内联注释为 RESTful API 自动生成文档。
以下为所有官方支持的参数与其说明。
---
## @api
**定义 API 方法的基本信息**
```js
@api {method} path title
```
- **method**:请求方法,如 `GET``POST``PUT``DELETE`
- **path**:请求路径,例如 `/user/:id`
- **title**:简短标题(显示在文档中)
📘 示例:
```js
@api {get} /user/:id Get user info
```
---
## @apiBody
**定义请求体参数**
```js
@apiBody [{type}] [field=defaultValue] [description]
```
- `{type}` 参数类型(如 String, Number, Object, String[]
- `[field]` 可选字段(方括号表示可选)
- `=defaultValue` 默认值
- `description` 参数说明
📘 示例:
```js
@apiBody {String} lastname Mandatory Lastname.
@apiBody {Object} [address] Optional address object.
@apiBody {String} [address[city]] Optional city.
```
---
## @apiDefine
**定义可复用的文档块**
```js
@apiDefine name [title] [description]
```
- `name`:唯一标识
- `title`:简短标题
- `description`:多行描述
📘 示例:
```js
@apiDefine MyError
@apiError UserNotFound The <code>id</code> of the User was not found.
```
---
## @apiDeprecated
**标记接口为弃用状态**
```js
@apiDeprecated [text]
```
- `text`:提示文本,可带链接到新方法
📘 示例:
```js
@apiDeprecated use now (#User:GetDetails)
```
---
## @apiDescription
**描述接口详细说明**
```js
@apiDescription text
```
📘 示例:
```js
@apiDescription This is the Description.
It is multiline capable.
```
---
## @apiError
**定义错误返回参数**
```js
@apiError [(group)] [{type}] field [description]
```
📘 示例:
```js
@apiError UserNotFound The id of the User was not found.
```
---
## @apiErrorExample
**定义错误返回示例**
```js
@apiErrorExample [{type}] [title]
example
```
📘 示例:
```js
@apiErrorExample {json} Error-Response:
HTTP/1.1 404 Not Found
{ "error": "UserNotFound" }
```
---
## @apiExample
**定义接口使用示例**
```js
@apiExample [{type}] title
example
```
📘 示例:
```js
@apiExample {curl} Example usage:
curl -i http://localhost/user/4711
```
---
## @apiGroup
**定义所属分组**
```js
@apiGroup name
```
📘 示例:
```js
@apiGroup User
```
---
## @apiHeader
**定义请求头参数**
```js
@apiHeader [(group)] [{type}] [field=defaultValue] [description]
```
📘 示例:
```js
@apiHeader {String} access-key Users unique access-key.
```
---
## @apiHeaderExample
**定义请求头示例**
```js
@apiHeaderExample [{type}] [title]
example
```
📘 示例:
```js
@apiHeaderExample {json} Header-Example:
{
"Accept-Encoding": "gzip, deflate"
}
```
---
## @apiIgnore
**忽略当前文档块**
```js
@apiIgnore [hint]
```
📘 示例:
```js
@apiIgnore Not finished method
```
---
## @apiName
**定义接口唯一名称**
```js
@apiName name
```
📘 示例:
```js
@apiName GetUser
```
---
## @apiParam
**定义请求参数**
```js
@apiParam [(group)] [{type}] [field=defaultValue] [description]
```
📘 示例:
```js
@apiParam {Number} id Users unique ID.
@apiParam {String} [firstname] Optional firstname.
@apiParam {String} country="DE" Mandatory with default.
```
---
## @apiParamExample
**定义参数请求示例**
```js
@apiParamExample [{type}] [title]
example
```
📘 示例:
```js
@apiParamExample {json} Request-Example:
{ "id": 4711 }
```
---
## @apiPermission
**定义权限要求**
```js
@apiPermission name
```
📘 示例:
```js
@apiPermission admin
```
---
## @apiPrivate
**标记接口为私有(可过滤)**
```js
@apiPrivate
```
---
## @apiQuery
**定义查询参数(?query**
```js
@apiQuery [{type}] [field=defaultValue] [description]
```
📘 示例:
```js
@apiQuery {Number} id Users unique ID.
@apiQuery {String} [sort="asc"] Sort order.
```
---
## @apiSampleRequest
**定义接口测试请求 URL**
```js
@apiSampleRequest url
```
📘 示例:
```js
@apiSampleRequest http://test.github.com/some_path/
```
---
## @apiSuccess
**定义成功返回参数**
```js
@apiSuccess [(group)] [{type}] field [description]
```
📘 示例:
```js
@apiSuccess {String} firstname Firstname of the User.
@apiSuccess {String} lastname Lastname of the User.
```
---
## @apiSuccessExample
**定义成功返回示例**
```js
@apiSuccessExample [{type}] [title]
example
```
📘 示例:
```js
@apiSuccessExample {json} Success-Response:
HTTP/1.1 200 OK
{ "firstname": "John", "lastname": "Doe" }
```
---
## @apiUse
**引用定义块(@apiDefine**
```js
@apiUse name
```
📘 示例:
```js
@apiDefine MySuccess
@apiSuccess {String} firstname User firstname.
@apiUse MySuccess
```
---
## @apiVersion
**定义接口版本**
```js
@apiVersion version
```
📘 示例:
```js
@apiVersion 1.6.2
```
---
# 附录:常用标签速查表
| 标签 | 作用 | 示例 |
|------|------|------|
| `@api` | 定义接口 | `@api {get} /user/:id` |
| `@apiName` | 唯一名称 | `@apiName GetUser` |
| `@apiGroup` | 所属分组 | `@apiGroup User` |
| `@apiParam` | 请求参数 | `@apiParam {Number} id Users unique ID.` |
| `@apiBody` | 请求体参数 | `@apiBody {String} name Username.` |
| `@apiQuery` | 查询参数 | `@apiQuery {String} keyword Search term.` |
| `@apiHeader` | Header 参数 | `@apiHeader {String} token Auth token.` |
| `@apiSuccess` | 成功返回字段 | `@apiSuccess {String} name Username.` |
| `@apiError` | 错误返回字段 | `@apiError NotFound User not found.` |
| `@apiVersion` | 版本号 | `@apiVersion 1.0.0` |

View File

@@ -1,89 +1,137 @@
<?php
/**
* 给apidoc项目增加顺序编号
* 给apidoc项目增加顺序编号 / 支持恢复
*/
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
$path = dirname(__FILE__). '/';
$lists = scandir($path);
//
foreach ($lists AS $item) {
$fillPath = $path . $item;
if (str_ends_with($fillPath, 'Controller.php')) {
$content = file_get_contents($fillPath);
preg_match_all("/\* @api \{(.+?)\} (.*?)\n/i", $content, $matchs);
$i = 1;
foreach ($matchs[2] AS $key=>$text) {
if (in_array(strtolower($matchs[1][$key]), array('get', 'post'))) {
$expl = explode(" ", __sRemove($text));
$end = $expl[1];
if ($expl[2]) {
$end = '';
foreach ($expl AS $k=>$v) { if ($k >= 2) { $end.= " ".$v; } }
}
$newtext = "* @api {".$matchs[1][$key]."} ".$expl[0]." ".__zeroFill($i, 2).". ".trim($end);
$content = str_replace("* @api {".$matchs[1][$key]."} ".$text, $newtext, $content);
$i++;
//
echo $newtext;
echo "\r\n";
}
}
if ($i > 1) {
file_put_contents($fillPath, $content);
}
}
}
echo "Success \n";
const NUMBER_WIDTH = 2;
/** ************************************************************** */
/** ************************************************************** */
/** ************************************************************** */
$isRestore = isset($argv[1]) && strtolower($argv[1]) === 'restore';
/**
* 替换所有空格
* @param $str
* @return mixed
*/
function __sRemove($str) {
$str = str_replace(" ", " ", $str);
if (__strExists($str, " ")) {
return __sRemove($str);
}
return $str;
$basePath = dirname(__FILE__) . '/';
$controllerFiles = glob($basePath . '*Controller.php');
if (!$controllerFiles) {
echo "No Controller.php files found\n";
exit(0);
}
foreach ($controllerFiles as $filePath) {
$original = file_get_contents($filePath);
[$updated, $linesChanged] = processFile($original, $isRestore);
if (count($linesChanged) === 0) {
continue;
}
file_put_contents($filePath, $updated);
foreach ($linesChanged as $line) {
echo $line . "\n";
}
}
echo $isRestore ? "Restore Success \n" : "Success \n";
/**
* 是否包含字符
* @param $string
* @param $find
* @return bool
* 处理单个文件内容
*
* @param string $content
* @param bool $restore
* @return array{string, array<int, string>}
*/
function __strExists($string, $find)
function processFile(string $content, bool $restore): array
{
return str_contains($string, $find);
$lineChanges = [];
$counter = 1;
$pattern = '/\* @api \{([^\}]+)\}\s+([^\s]+)([^\r\n]*)(\r?\n)/';
$updated = preg_replace_callback(
$pattern,
function (array $matches) use ($restore, &$counter, &$lineChanges) {
$method = trim($matches[1]);
if (!in_array(strtolower($method), ['get', 'post'], true)) {
return $matches[0];
}
$endpoint = trim($matches[2]);
$suffix = normalizeDescription(stripExistingNumbering($matches[3]));
if (!$restore) {
$numberedSuffix = formatNumber($counter) . '.';
if ($suffix !== '') {
$numberedSuffix .= ' ' . $suffix;
}
$counter++;
} else {
$numberedSuffix = $suffix;
}
$newLine = renderAnnotation($method, $endpoint, $numberedSuffix);
if ($newLine !== rtrim($matches[0], "\r\n")) {
$lineChanges[] = $newLine;
}
return $newLine . $matches[4];
},
$content
);
if ($updated === null) {
return [$content, []];
}
return [$updated, $lineChanges];
}
/**
* @param string $str 补零
* @param int $length
* @param int $after
* @return bool|string
* 生成格式化后的注释行
*/
function __zeroFill($str, $length = 0, $after = 1) {
if (strlen($str) >= $length) {
return $str;
function renderAnnotation(string $method, string $endpoint, string $suffix = ''): string
{
$line = "* @api {" . $method . "} " . $endpoint;
if ($suffix !== '') {
if ($suffix[0] !== ' ') {
$line .= ' ';
}
$line .= $suffix;
}
$_str = '';
for ($i = 0; $i < $length; $i++) {
$_str .= '0';
}
if ($after) {
$_ret = substr($_str . $str, $length * -1);
} else {
$_ret = substr($str . $_str, 0, $length);
}
return $_ret;
return $line;
}
/**
* 移除已有编号部分
*/
function stripExistingNumbering(string $text): string
{
$trimmed = ltrim($text);
$pattern = '/^\d+\.\s*/';
return preg_replace($pattern, '', $trimmed) ?? $trimmed;
}
/**
* 压缩多余空格
*/
function normalizeDescription(string $text): string
{
$text = trim($text);
if ($text === '') {
return '';
}
return preg_replace('/\s+/', ' ', $text) ?? $text;
}
/**
* 生成固定宽度的数字
*/
function formatNumber(int $number): string
{
return str_pad((string) $number, NUMBER_WIDTH, '0', STR_PAD_LEFT);
}

View File

@@ -21,11 +21,10 @@ use App\Tasks\AutoArchivedTask;
use App\Tasks\DeleteBotMsgTask;
use App\Tasks\CheckinRemindTask;
use App\Tasks\CloseMeetingRoomTask;
use App\Tasks\ZincSearchSyncTask;
use App\Tasks\ManticoreSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar;
use Swoole\Coroutine;
/**
@@ -62,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]);
@@ -255,6 +258,7 @@ class IndexController extends InvokeController
Task::deliver(new DeleteTmpTask('file'));
Task::deliver(new DeleteTmpTask('tmp_file', 24));
Task::deliver(new DeleteTmpTask('user_device', 24));
Task::deliver(new DeleteTmpTask('umeng_log', 24 * 3));
// 删除机器人消息
Task::deliver(new DeleteBotMsgTask());
// 周期任务
@@ -267,8 +271,8 @@ class IndexController extends InvokeController
Task::deliver(new UnclaimedTaskRemindTask());
// 关闭会议室
Task::deliver(new CloseMeetingRoomTask());
// ZincSearch 同步
Task::deliver(new ZincSearchSyncTask());
// Manticore Search 同步
Task::deliver(new ManticoreSyncTask());
return "success";
}
@@ -353,9 +357,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) {
@@ -433,13 +435,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');

View File

@@ -4,8 +4,10 @@ namespace App\Http\Middleware;
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
use App\Module\Base;
use App\Module\Doo;
use App\Services\RequestContext;
use Cache;
use Closure;
class WebApi
@@ -19,8 +21,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'));
@@ -30,6 +31,12 @@ class WebApi
// 加载Doo类
Doo::load();
// 记录 PC 端活跃时间
$userid = Doo::userId();
if ($userid > 0 && Base::isPc()) {
Cache::put("user_pc_active:{$userid}", time(), 60);
}
// 解密请求内容
$encrypt = Doo::pgpParseStr($request->header('encrypt'));
if ($request->isMethod('post')) {
@@ -76,6 +83,6 @@ class WebApi
public function terminate()
{
// 请求结束后清理上下文
RequestContext::clear();
RequestContext::clean();
}
}

View File

@@ -6,6 +6,8 @@ use Request;
use App\Module\Apps;
use App\Module\Base;
use App\Tasks\PushTask;
use App\Tasks\ManticoreSyncTask;
use App\Observers\AbstractObserver;
use App\Exceptions\ApiException;
use Illuminate\Support\Facades\DB;
use Hhxsv5\LaravelS\Swoole\Task\Task;
@@ -24,6 +26,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
@@ -39,11 +42,14 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static \Illuminate\Database\Eloquent\Builder|File query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|File searchByKeyword(string $keyword)
* @method static \Illuminate\Database\Eloquent\Builder|File sharedToUser(int $userid)
* @method static \Illuminate\Database\Eloquent\Builder|File whereCid($value)
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedAt($value)
* @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)
@@ -126,6 +132,45 @@ class File extends AbstractModel
*/
const zipMaxSize = 1024 * 1024 * 1024; // 1G
/**
* 按关键词搜索文件Scope
* 支持文件ID纯数字、文件名
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $keyword 搜索关键词
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSearchByKeyword($query, string $keyword)
{
if (is_numeric($keyword)) {
return $query->where(function ($q) use ($keyword) {
$q->where("id", intval($keyword))
->orWhere("name", "like", "%{$keyword}%");
});
}
return $query->where("name", "like", "%{$keyword}%");
}
/**
* 筛选用户可访问的共享文件Scope
* 不包括用户自己的文件,仅返回他人共享给该用户的文件
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $userid 用户ID
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSharedToUser($query, int $userid)
{
return $query->whereIn('pshare', function ($subQuery) use ($userid) {
$subQuery->select('files.id')
->from('files')
->join('file_users', 'files.id', '=', 'file_users.file_id')
->where('files.userid', '!=', $userid)
->where(function ($q) use ($userid) {
$q->whereIn('file_users.userid', [0, $userid]);
});
});
}
/**
* 获取文件列表
@@ -582,6 +627,26 @@ class File extends AbstractModel
return true;
}
/**
* 批量更新子文件的 userid 并同步到 Manticore
* @param int $userid 新的 userid
* @return int 更新的文件数量
*/
public function updateChildFilesUserid(int $userid): int
{
self::where('pids', 'like', "%,{$this->id},%")->update(['userid' => $userid]);
// 批量 update 绕过 Observer手动触发 Manticore 同步
$childFileIds = self::where('pids', 'like', "%,{$this->id},%")
->where('type', '!=', 'folder')
->pluck('id')
->toArray();
foreach ($childFileIds as $childFileId) {
AbstractObserver::taskDeliver(new ManticoreSyncTask('file_sync', ['id' => $childFileId]));
}
return count($childFileIds);
}
/**
* 获取文件分享链接
* @param $userid
@@ -642,6 +707,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
@@ -685,7 +773,7 @@ class File extends AbstractModel
/**
* code获取文件ID、名称
* @param $code
* @return File
* @return File|null
*/
public static function code2IdName($code) {
$arr = explode(",", base64_decode($code));
@@ -956,30 +1044,6 @@ class File extends AbstractModel
}
}
/**
* 文件推送消息
* @param $action
* @param array|null $data 发送内容
* @param array $userid 会员ID
*/
public static function filePushMsg($action, $data = null, $userid = null)
{
$userid = User::userid();
if (empty($userid)) {
return;
}
$msg = [
'type' => 'file',
'action' => $action,
'data' => $data,
];
$params = [
'userid' => $userid,
'msg' => $msg
];
Task::deliver(new PushTask($params));
}
/**
* 根据文件类型判断是否需要安装应用
* @param $type

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,16 +145,30 @@ 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 ]);
}
/**
* 获取文件访问URL
* @param int $fileId 文件ID
* @return string|null 返回完整的文件URL如果文件无内容则返回null
*/
public static function getFileUrl($fileId)
{
$content = self::whereFid($fileId)->orderByDesc('id')->first();
if ($content) {
$contentData = Base::json2array($content->content ?: []);
if (!empty($contentData['url'])) {
return Base::fillUrl($contentData['url']);
}
}
return null;
}
/**
* 获取文件内容
* @param $id

View File

@@ -45,7 +45,7 @@ class FileUser extends AbstractModel
} else {
FileLink::whereFileId($file_id)->delete();
}
FileUser::whereFileId($file_id)->delete();
FileUser::whereFileId($file_id)->remove();
});
}
/**
@@ -58,7 +58,7 @@ class FileUser extends AbstractModel
{
return AbstractModel::transaction(function() use ($userid, $file_id) {
FileLink::whereFileId($file_id)->whereUserid($userid)->delete();
return self::whereFileId($file_id)->whereUserid($userid)->delete();
return self::whereFileId($file_id)->whereUserid($userid)->remove();
});
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Models;
/**
* Manticore 同步失败记录
*
* @property int $id
* @property string $data_type 数据类型: msg/file/task/project/user
* @property int $data_id 数据ID
* @property string $action 操作类型: sync/delete
* @property string|null $error_message 错误信息
* @property int $retry_count 重试次数
* @property \Carbon\Carbon|null $last_retry_at 最后重试时间
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class ManticoreSyncFailure extends AbstractModel
{
protected $table = 'manticore_sync_failures';
protected $fillable = [
'data_type',
'data_id',
'action',
'error_message',
'retry_count',
'last_retry_at',
];
protected $dates = [
'last_retry_at',
'created_at',
'updated_at',
];
/**
* 记录同步失败
*
* @param string $dataType 数据类型
* @param int $dataId 数据ID
* @param string $action 操作类型 sync/delete
* @param string $errorMessage 错误信息
*/
public static function recordFailure(string $dataType, int $dataId, string $action, string $errorMessage = ''): void
{
self::updateOrCreate(
[
'data_type' => $dataType,
'data_id' => $dataId,
'action' => $action,
],
[
'error_message' => mb_substr($errorMessage, 0, 500),
'retry_count' => \DB::raw('retry_count + 1'),
'last_retry_at' => now(),
]
);
}
/**
* 删除成功记录
*
* @param string $dataType 数据类型
* @param int $dataId 数据ID
* @param string $action 操作类型
*/
public static function removeSuccess(string $dataType, int $dataId, string $action): void
{
self::where('data_type', $dataType)
->where('data_id', $dataId)
->where('action', $action)
->delete();
}
/**
* 获取待重试的记录
* 根据重试次数决定间隔1次=1分钟2次=5分钟3次=15分钟4次+=30分钟
*
* @param int $limit 数量限制
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getPendingRetries(int $limit = 100)
{
return self::where(function ($query) {
$query->whereNull('last_retry_at')
->orWhere(function ($q) {
// 根据重试次数决定间隔
$q->where(function ($sub) {
// 重试1次等待1分钟
$sub->where('retry_count', 1)
->where('last_retry_at', '<', now()->subMinutes(1));
})->orWhere(function ($sub) {
// 重试2次等待5分钟
$sub->where('retry_count', 2)
->where('last_retry_at', '<', now()->subMinutes(5));
})->orWhere(function ($sub) {
// 重试3次等待15分钟
$sub->where('retry_count', 3)
->where('last_retry_at', '<', now()->subMinutes(15));
})->orWhere(function ($sub) {
// 重试4次以上等待30分钟
$sub->where('retry_count', '>=', 4)
->where('last_retry_at', '<', now()->subMinutes(30));
});
});
})
->orderBy('last_retry_at')
->limit($limit)
->get();
}
/**
* 获取统计信息
*
* @return array
*/
public static function getStats(): array
{
return [
'total' => self::count(),
'by_type' => self::selectRaw('data_type, COUNT(*) as count')
->groupBy('data_type')
->pluck('count', 'data_type')
->toArray(),
'by_action' => self::selectRaw('action, COUNT(*) as count')
->groupBy('action')
->pluck('count', 'action')
->toArray(),
];
}
}

View File

@@ -48,6 +48,7 @@ use Request;
* @method static \Illuminate\Database\Eloquent\Builder|Project query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|Project searchByKeyword(string $keyword)
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchiveDays($value)
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchiveMethod($value)
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchivedAt($value)
@@ -129,6 +130,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 +155,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);
@@ -162,6 +165,18 @@ class Project extends AbstractModel
return $query;
}
/**
* 按关键词搜索项目Scope
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $keyword 搜索关键词
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSearchByKeyword($query, string $keyword)
{
return $query->where("projects.name", "like", "%{$keyword}%");
}
/**
* 获取任务统计数据
* @param $userid
@@ -423,24 +438,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 +464,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 +486,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 +607,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;
/**
@@ -75,6 +74,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask searchByKeyword(string $keyword)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask whereArchivedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask whereArchivedFollow($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask whereArchivedUserid($value)
@@ -157,7 +157,7 @@ class ProjectTask extends AbstractModel
return;
}
if (!isset($this->appendattrs['sub_num'])) {
$builder = self::whereParentId($this->id)->whereNull('archived_at');
$builder = self::whereParentId($this->id);
$this->appendattrs['sub_num'] = $builder->count();
$this->appendattrs['sub_complete'] = $builder->whereNotNull('complete_at')->count();
//
@@ -354,6 +354,32 @@ class ProjectTask extends AbstractModel
return $query;
}
/**
* 按关键词搜索任务Scope
* 支持任务ID纯数字、任务名称、描述
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $keyword 搜索关键词
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSearchByKeyword($query, string $keyword)
{
if (is_numeric($keyword)) {
// 纯数字匹配任务ID 或 名称/描述
return $query->where(function ($q) use ($keyword) {
$q->where("project_tasks.id", intval($keyword))
->orWhere("project_tasks.name", "like", "%{$keyword}%")
->orWhere("project_tasks.desc", "like", "%{$keyword}%");
});
}
// 普通文本:搜索名称/描述
return $query->where(function ($q) use ($keyword) {
$q->where("project_tasks.name", "like", "%{$keyword}%")
->orWhere("project_tasks.desc", "like", "%{$keyword}%");
});
}
/**
* 生成描述
* @param $content
@@ -373,6 +399,38 @@ class ProjectTask extends AbstractModel
return Base::cutStr(strip_tags($content), 100);
}
/**
* 标准化时间参数,兼容 start_at/end_at 转换为 times
* @param array $data 请求数据
* @param self|null $task 任务实例(更新时传入)
* @return array 处理后的data
*/
public static function normalizeTimes(array $data, ?self $task = null): array
{
if (isset($data['times']) || (!isset($data['start_at']) && !isset($data['end_at']))) {
return $data;
}
$startAt = $data['start_at'] ?? null;
$endAt = $data['end_at'] ?? null;
if ($endAt && !$startAt) {
// 只传 end_at保留已有 start_at否则取当前时间
$startAt = $task?->start_at
? Carbon::parse($task->start_at)->toDateTimeString()
: date('Y-m-d H:i:s');
} elseif ($startAt && !$endAt) {
// 只传 start_at必须已有 end_at
if (!$task?->end_at) {
throw new ApiException('请设置结束时间');
}
$endAt = Carbon::parse($task->end_at)->toDateTimeString();
}
$data['times'] = [$startAt, $endAt];
return $data;
}
/**
* 添加任务
* @param $data
@@ -397,6 +455,7 @@ class ProjectTask extends AbstractModel
$userid = User::userid();
$visibility = $data['visibility_appoint'] ?? $data['visibility'];
$visibility_userids = $data['visibility_appointor'] ?: [];
$taskUserLimit = intval(Base::settingFind('system', 'task_user_limit'));
//
if (ProjectTask::whereProjectId($project_id)
->whereNull('project_tasks.complete_at')
@@ -418,6 +477,22 @@ class ProjectTask extends AbstractModel
}
//
$retPre = $parent_id ? '子任务' : '任务';
// 优先级:主任务在缺省时按系统默认补齐,并尽量补全 name/color
if ($parent_id == 0) {
$priorityList = Setting::normalizeTaskPriorityList(Base::setting('priority'));
if ($p_level > 0) {
$matched = reset(array_filter($priorityList, fn($item) => intval($item['priority']) === $p_level)) ?: null;
} else {
$matched = Setting::getDefaultTaskPriorityItem($priorityList);
}
if ($matched) {
$p_level = $p_level > 0 ? $p_level : intval($matched['priority']);
$p_name = $p_name ?: $matched['name'];
$p_color = $p_color ?: $matched['color'];
}
}
$task = self::createInstance([
'parent_id' => $parent_id,
'project_id' => $project_id,
@@ -456,8 +531,8 @@ class ProjectTask extends AbstractModel
if (ProjectTask::authData($uid)
->whereNull('project_tasks.complete_at')
->whereNull('project_tasks.archived_at')
->count() > 500) {
throw new ApiException(User::userid2nickname($uid) . '负责或参与的未完成任务最多不能超过500个');
->count() > $taskUserLimit) {
throw new ApiException(User::userid2nickname($uid) . '负责或参与的未完成任务最多不能超过' . $taskUserLimit . '个');
}
$tmpArray[] = $uid;
}
@@ -486,7 +561,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 +725,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]
@@ -758,7 +833,7 @@ class ProjectTask extends AbstractModel
$this->visibility = $data["visibility"];
ProjectTask::whereParentId($data['task_id'])->change(['visibility' => $data["visibility"]]);
}
ProjectTaskVisibilityUser::whereTaskId($data['task_id'])->delete();
ProjectTaskVisibilityUser::whereTaskId($data['task_id'])->remove();
if (Arr::exists($data, 'visibility_appointor')) {
foreach ($data['visibility_appointor'] as $uid) {
if ($uid) {
@@ -1144,12 +1219,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 +1235,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;
@@ -1184,6 +1261,126 @@ class ProjectTask extends AbstractModel
});
}
/**
* 获取项目的工作流状态项start 和 end
* @param int $projectId 项目ID
* @return array ['start' => ProjectFlowItem|null, 'end' => ProjectFlowItem|null]
*/
public static function getProjectFlowItems(int $projectId): array
{
$startFlowItem = null;
$endFlowItem = null;
$projectFlow = ProjectFlow::whereProjectId($projectId)->orderByDesc('id')->first();
if ($projectFlow) {
$flowItems = ProjectFlowItem::whereFlowId($projectFlow->id)->orderBy('sort')->get();
foreach ($flowItems as $item) {
if ($item->status == 'start' && !$startFlowItem) {
$startFlowItem = $item;
}
if ($item->status == 'end' && !$endFlowItem) {
$endFlowItem = $item;
}
}
}
return ['start' => $startFlowItem, 'end' => $endFlowItem];
}
/**
* 生成工作流状态名称
* @param ProjectFlowItem|null $flowItem
* @return string
*/
public static function formatFlowItemName(?ProjectFlowItem $flowItem): string
{
return $flowItem ? ($flowItem->status . '|' . $flowItem->name . '|' . $flowItem->color) : '';
}
/**
* 复制子任务到新的父任务
* @param ProjectTask $newParentTask 新的父任务
* @param array $options 选项
* - reset_complete: 是否重置完成状态并映射到 start 工作流(默认 true
* - sync_time: 是否同步时间到父任务的时间(默认 false
* - update_project: 是否更新项目相关字段project_id、column_id默认 false
* @return array 新创建的子任务数组
*/
public function copySubTasks(ProjectTask $newParentTask, array $options = []): array
{
$resetComplete = $options['reset_complete'] ?? true;
$syncTime = $options['sync_time'] ?? false;
$updateProject = $options['update_project'] ?? false;
$newSubTasks = [];
$subTasks = self::whereParentId($this->id)->get();
if ($subTasks->isEmpty()) {
return $newSubTasks;
}
// 获取 start 工作流状态
$flowItems = $resetComplete ? self::getProjectFlowItems($newParentTask->project_id) : ['start' => null];
$startFlowItem = $flowItems['start'];
foreach ($subTasks as $subTask) {
$newSubTask = $subTask->copyTask();
$newSubTask->parent_id = $newParentTask->id;
// 同步时间
if ($syncTime) {
$newSubTask->start_at = $newParentTask->start_at;
$newSubTask->end_at = $newParentTask->end_at;
}
// 更新项目相关字段
if ($updateProject) {
$newSubTask->project_id = $newParentTask->project_id;
$newSubTask->column_id = $newParentTask->column_id;
}
// 重置完成状态
if ($resetComplete) {
$newSubTask->complete_at = null;
$newSubTask->flow_item_id = $startFlowItem?->id ?? 0;
$newSubTask->flow_item_name = self::formatFlowItemName($startFlowItem);
}
$newSubTask->save();
$newSubTasks[] = $newSubTask;
}
return $newSubTasks;
}
/**
* 移动子任务到新项目/列
* @param int $projectId 目标项目ID
* @param int $columnId 目标列ID
*/
public function moveSubTasks(int $projectId, int $columnId): void
{
$subTasks = self::whereParentId($this->id)->get();
if ($subTasks->isEmpty()) {
return;
}
$flowItems = self::getProjectFlowItems($projectId);
$startFlowItem = $flowItems['start'];
$endFlowItem = $flowItems['end'];
foreach ($subTasks as $subTask) {
$subTask->project_id = $projectId;
$subTask->column_id = $columnId;
// 根据完成状态映射工作流
if ($subTask->complete_at) {
$subTask->flow_item_id = $endFlowItem?->id ?? 0;
$subTask->flow_item_name = self::formatFlowItemName($endFlowItem);
} else {
$subTask->flow_item_id = $startFlowItem?->id ?? 0;
$subTask->flow_item_name = self::formatFlowItemName($startFlowItem);
}
$subTask->save();
}
}
/**
* 同步项目成员至聊天室
*/
@@ -1559,8 +1756,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,77 +1770,91 @@ 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();
}
//
// 获取接收会员
if ($userid === null) {
$userids = $this->project->relationUserids();
} 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;
}
//
// 按可见性分组推送
$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' => 0,
])
];
}
// 协助人
if ($assistUsers) {
$array[] = [
'userid' => $assistUsers,
'data' => array_merge($data, [
'owner' => 0,
'assist' => 1,
])
];
}
// 其他人
$otherUsers = [];
switch (intval($data['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;
}
if ($otherUsers) {
$array[] = [
'userid' => array_values($otherUsers),
'data' => array_merge($data, [
'owner' => 0,
'assist' => 0,
])
];
}
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',
@@ -1901,17 +2113,14 @@ class ProjectTask extends AbstractModel
$taskUser->save();
}
}
// 子任务
ProjectTask::whereParentId($this->id)->change([
'project_id' => $projectId,
'column_id' => $columnId,
]);
// 子任务 - 根据完成状态映射工作流
$this->moveSubTasks($projectId, $columnId);
//
if ($flowItemId) {
// 更新任务流程
$flowItem = projectFlowItem::whereProjectId($projectId)->whereId($flowItemId)->first();
$this->flow_item_id = $flowItemId;
$this->flow_item_name = $flowItem->status . "|" . $flowItem->name;
$this->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
if ($flowItem->status == 'end') {
$this->completeTask(Carbon::now(), $flowItem->name);
} else {
@@ -1932,67 +2141,6 @@ class ProjectTask extends AbstractModel
return true;
}
/**
* 生成AI上下文
* @return array
*/
public function AIContext()
{
$contexts = [];
if ($this->archived_at) {
$contexts[] = "任务状态:已归档";
$contexts[] = "归档时间:" . $this->archived_at;
} elseif ($this->complete_at) {
$contexts[] = "任务状态:已完成";
$contexts[] = "完成时间:" . $this->complete_at;
} elseif ($this->end_at && Carbon::parse($this->end_at)->lt(Carbon::now())) {
$contexts[] = "任务状态:已过期";
$contexts[] = "任务截止时间:" . $this->end_at;
} else {
$contexts[] = "任务状态:进行中";
if ($this->start_at) {
$contexts[] = "任务开始时间:" . $this->start_at;
}
if ($this->end_at) {
$contexts[] = "任务截止时间:" . $this->end_at;
}
}
$contexts[] = "当前系统时间:" . Carbon::now()->toDateTimeString();
if ($this->content) {
$taskDesc = $this->content?->getContentInfo();
if ($taskDesc) {
$converter = new HtmlConverter(['strip_tags' => true]);
$descContent = Base::cutStr($converter->convert($taskDesc['content']), 2000);
$contexts[] = <<<EOF
任务描述:
```md
{$descContent}
```
EOF;
}
}
$subTask = ProjectTask::select(['id', 'name', 'complete_at', 'end_at'])->whereParentId($this->id)->get();
if ($subTask->isNotEmpty()) {
$subTaskContent = $subTask->map(function($item) {
if ($item->complete_at) {
$status = " (已完成)";
} elseif ($item->end_at && Carbon::parse($item->end_at)->lt(Carbon::now())) {
$status = " (已过期)";
} else {
$status = " (进行中)";
}
return " - {$item->name} {$status}";
})->join("\n");
if ($subTaskContent) {
$contexts[] = <<<EOF
子任务列表:
{$subTaskContent}
EOF;
}
}
return $contexts;
}
/**
* 获取任务
* @param $task_id
@@ -2054,4 +2202,64 @@ class ProjectTask extends AbstractModel
//
return $task;
}
/**
* 构建指定周期内的未完成任务查询(用于周报/日报等)
* @param int $userid
* @param Carbon $start_time
* @param Carbon $end_time
* @param bool $includeUpdatedForNoPlan 无计划时间任务是否按周期内更新时间一并纳入
* @return \Illuminate\Database\Eloquent\Builder
*/
public static function buildUnfinishedTaskQuery(int $userid, Carbon $start_time, Carbon $end_time, bool $includeUpdatedForNoPlan = true)
{
return self::query()
->join("projects", "projects.id", "=", "project_tasks.project_id")
->whereNull("projects.archived_at")
->whereNull("project_tasks.complete_at")
->whereHas("taskUser", function ($query) use ($userid) {
$query->where("userid", $userid);
})
->where(function ($query) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
// 1) 有计划时间:计划时间与给定周期 [start_time, end_time] 有交集
$query->where(function ($q1) use ($start_time, $end_time) {
$q1->whereNotNull('project_tasks.start_at')
->whereNotNull('project_tasks.end_at')
->where(function ($q2) use ($start_time, $end_time) {
$q2->whereBetween('project_tasks.start_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
->orWhereBetween('project_tasks.end_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
->orWhere(function ($q3) use ($start_time, $end_time) {
$q3->where('project_tasks.start_at', '<=', $start_time->toDateTimeString())
->where('project_tasks.end_at', '>=', $end_time->toDateTimeString());
});
});
});
// 2) 无计划时间
$query->orWhere(function ($q1) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
$q1->whereNull('project_tasks.start_at')
->whereNull('project_tasks.end_at')
->where(function ($q2) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
$q2->whereBetween('project_tasks.created_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()]);
if ($includeUpdatedForNoPlan) {
$q2->orWhereBetween('project_tasks.updated_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()]);
}
});
});
})
->select("project_tasks.*")
->orderByDesc("project_tasks.id");
}
/**
* 判断工作流名称是否为取消态(多语言)
* @param string|null $flowItemName
* @return bool
*/
public static function isCanceledFlowName(?string $flowItemName): bool
{
if (empty($flowItemName)) {
return false;
}
return preg_match('/已取消|Cancelled|취소됨|キャンセル済み|Abgebrochen|Annulé|Dibatalkan|Отменено/', $flowItemName) === 1;
}
}

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

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use JetBrains\PhpStorm\Pure;
/**
@@ -26,6 +27,9 @@ use JetBrains\PhpStorm\Pure;
* @property string $sign 汇报唯一标识
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReportReceive> $Receives
* @property-read int|null $receives_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReportAnalysis> $aiAnalyses
* @property-read int|null $ai_analyses_count
* @property-read \App\Models\ReportAnalysis|null $aiAnalysis
* @property-read mixed $receives
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $receivesUser
* @property-read int|null $receives_user_count
@@ -55,6 +59,15 @@ class Report extends AbstractModel
const WEEKLY = "weekly";
const DAILY = "daily";
public const LIST_FIELDS = [
'id',
'title',
'type',
'userid',
'sign',
'created_at',
'updated_at',
];
protected $fillable = [
"title",
@@ -78,6 +91,16 @@ class Report extends AbstractModel
->withPivot("receive_at", "read");
}
public function aiAnalyses(): HasMany
{
return $this->hasMany(ReportAnalysis::class, 'rid');
}
public function aiAnalysis(): HasOne
{
return $this->hasOne(ReportAnalysis::class, 'rid');
}
public function sendUser()
{
return $this->hasOne(User::class, "userid", "userid");

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ReportAnalysis
*
* @property int $id
* @property int $rid 报告ID
* @property int $userid 生成分析的会员ID
* @property string $model 使用的模型名称
* @property string $analysis_text AI 分析的原始文本Markdown
* @property array|null $meta 额外的上下文信息
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Report|null $report
* @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|ReportAnalysis newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereAnalysisText($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereMeta($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereModel($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereRid($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereUserid($value)
* @mixin \Eloquent
*/
class ReportAnalysis extends AbstractModel
{
protected $table = 'report_ai_analyses';
protected $fillable = [
'rid',
'userid',
'model',
'analysis_text',
'meta',
];
protected $casts = [
'meta' => 'array',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class, 'rid');
}
}

View File

@@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Timer;
use App\Module\AI;
use Carbon\Carbon;
/**
@@ -48,23 +49,27 @@ 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';
$value['image_quality'] = min(100, max(0, intval($value['image_quality']) ?: 90));
$value['image_save_local'] = $value['image_save_local'] ?: 'open';
$value['task_user_limit'] = min(2000, max(1, intval($value['task_user_limit']) ?: 500));
if (!is_array($value['task_default_time']) || count($value['task_default_time']) != 2 || !Timer::isTime($value['task_default_time'][0]) || !Timer::isTime($value['task_default_time'][1])) {
$value['task_default_time'] = ['09:00', '18:00'];
}
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 'aibotSetting':
if ($value['claude_token'] && empty($value['claude_key'])) {
if (!empty($value['claude_token']) && empty($value['claude_key'])) {
$value['claude_key'] = $value['claude_token'];
}
$array = [];
@@ -73,20 +78,17 @@ class Setting extends AbstractModel
foreach ($aiList as $aiName) {
foreach ($fieldList as $fieldName) {
$key = $aiName . '_' . $fieldName;
$content = $value[$key] ? trim($value[$key]) : '';
$content = !empty($value[$key]) ? trim($value[$key]) : '';
switch ($fieldName) {
case 'models':
if ($content) {
$content = explode("\n", $content);
$content = array_filter($content);
}
if (empty($content)) {
$content = self::AIDefaultModels($aiName);
}
$content = implode("\n", $content);
$content = is_array($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,112 +107,110 @@ class Setting extends AbstractModel
}
/**
* 是否开启AI
* @param $ai
* @return bool
* 规范任务优先级设置(确保字段完整且仅有一个默认项)
* @param mixed $list
* @return array<int, array{name:string,color:string,days:int,priority:int,is_default:int}>
*/
public static function AIOpen($ai = 'openai')
public static function normalizeTaskPriorityList($list)
{
$array = Base::setting('aibotSetting');
return !!$array[$ai . '_key'];
if (!is_array($list)) {
return [];
}
$normalized = [];
$defaultIndex = null;
foreach ($list as $item) {
if (!is_array($item)) {
continue;
}
$name = trim((string)($item['name'] ?? ''));
$color = trim((string)($item['color'] ?? ''));
$priority = intval($item['priority'] ?? 0);
if ($name === '' || $color === '' || $priority <= 0) {
continue;
}
$days = intval($item['days'] ?? 0);
$isDefault = !empty($item['is_default']) || !empty($item['default']);
if ($defaultIndex === null && $isDefault) {
$defaultIndex = count($normalized);
}
$normalized[] = [
'name' => $name,
'color' => $color,
'days' => $days,
'priority' => $priority,
'is_default' => $isDefault ? 1 : 0,
];
}
if (!empty($normalized)) {
$defaultIndex = $defaultIndex ?? 0;
foreach ($normalized as $i => $row) {
$normalized[$i]['is_default'] = $i === $defaultIndex ? 1 : 0;
}
}
return array_values($normalized);
}
/**
* AI默认模型
* @param string $ai
* @return array
* 获取默认任务优先级(来自 settings.priority
* @param array|null $list
* @return array|null
*/
public static function AIDefaultModels($ai = 'openai')
public static function getDefaultTaskPriorityItem($list = null)
{
return match ($ai) {
'openai' => [
'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',
'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-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 | 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-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',
'glm-4-plus | GLM-4 Plus',
'glm-4-air | GLM-4 Air',
'glm-4-airx | GLM-4 AirX',
'glm-4-long | GLM-4 Long',
'glm-4-flash | GLM-4 Flash',
'glm-4v | GLM-4V',
'glm-4v-plus | GLM-4V Plus',
'glm-3-turbo | GLM-3 Turbo'
],
'qianwen' => [
'qwen-max | QWEN Max',
'qwen-max-latest | QWEN Max Latest',
'qwen-turbo | QWEN Turbo',
'qwen-turbo-latest | QWEN Turbo Latest',
'qwen-plus | QWEN Plus',
'qwen-plus-latest | QWEN Plus Latest',
'qwen-long | QWEN Long'
],
'wenxin' => [
'ernie-4.0-8k | Ernie 4.0 8K',
'ernie-4.0-8k-latest | Ernie 4.0 8K Latest',
'ernie-4.0-turbo-128k | Ernie 4.0 Turbo 128K',
'ernie-4.0-turbo-8k | Ernie 4.0 Turbo 8K',
'ernie-3.5-128k | Ernie 3.5 128K',
'ernie-3.5-8k | Ernie 3.5 8K',
'ernie-speed-128k | Ernie Speed 128K',
'ernie-speed-8k | Ernie Speed 8K',
'ernie-lite-8k | Ernie Lite 8K',
'ernie-tiny-8k | Ernie Tiny 8K'
],
default => [],
$list = $list ?? Base::setting('priority');
$list = self::normalizeTaskPriorityList($list);
if (empty($list)) {
return null;
}
foreach ($list as $item) {
if (!empty($item['is_default'])) {
return $item;
}
}
return $list[0];
}
/**
* 是否开启 AI 助手
* @return bool
*/
public static function AIOpen()
{
$setting = Base::setting('aibotSetting');
if (!is_array($setting) || empty($setting)) {
return false;
}
foreach (AI::TEXT_MODEL_PRIORITY as $vendor) {
if (self::isAIBotVendorEnabled($setting, $vendor)) {
return true;
}
}
return false;
}
/**
* 判断 AI 机器人厂商是否启用
* @param array $setting
* @param string $vendor
* @return bool
*/
protected static function isAIBotVendorEnabled(array $setting, string $vendor): bool
{
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
return match ($vendor) {
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
'wenxin' => $key !== '' && !empty($setting['wenxin_secret']),
default => $key !== '',
};
}
/**
* 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 = [];
@@ -229,6 +229,213 @@ class Setting extends AbstractModel
return $array;
}
/**
* 规范自定义微应用配置
* @param array $list
* @return array
*/
public static function normalizeCustomMicroApps($list)
{
if (!is_array($list)) {
return [];
}
$apps = [];
foreach ($list as $item) {
$app = self::normalizeCustomMicroAppItem($item);
if ($app) {
$apps[] = $app;
}
}
return $apps;
}
/**
* 根据用户身份过滤可见的自定义微应用
* @param array $apps
* @param \App\Models\User|null $user
* @return array
*/
public static function filterCustomMicroAppsForUser(array $apps, $user)
{
if (empty($apps)) {
return [];
}
$isAdmin = $user ? $user->isAdmin() : false;
$userId = $user ? intval($user->userid) : 0;
$filtered = [];
foreach ($apps as $app) {
$visible = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
if (!self::isCustomMicroVisibleTo($visible, $isAdmin, $userId)) {
continue;
}
if (empty($app['menu_items']) || !is_array($app['menu_items'])) {
continue;
}
$menus = array_values(array_filter($app['menu_items'], function ($menu) use ($isAdmin, $userId) {
if (!isset($menu['visible_to'])) {
return true;
}
$visible = self::normalizeCustomMicroVisible($menu['visible_to']);
return self::isCustomMicroVisibleTo($visible, $isAdmin, $userId);
}));
if (empty($menus)) {
continue;
}
$app['menu_items'] = $menus;
$filtered[] = $app;
}
return $filtered;
}
/**
* 将存储结构转换成 appstore 接口同款格式
* @param array $apps
* @return array
*/
public static function formatCustomMicroAppsForResponse(array $apps)
{
return array_values(array_map(function ($app) {
unset($app['visible_to']);
if (!empty($app['menu_items']) && is_array($app['menu_items'])) {
$app['menu_items'] = array_values(array_map(function ($menu) {
$menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true;
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
if (isset($menu['visible_to'])) {
unset($menu['visible_to']);
}
return $menu;
}, $app['menu_items']));
}
return $app;
}, $apps));
}
/**
* 规范自定义微应用
* @param array $item
* @return array|null
*/
protected static function normalizeCustomMicroAppItem($item)
{
if (!is_array($item)) {
return null;
}
$id = trim($item['id'] ?? '');
if ($id === '') {
return null;
}
$name = Base::newTrim($item['name'] ?? '');
$version = Base::newTrim($item['version'] ?? '') ?: 'custom';
$menuItems = [];
if (isset($item['menu_items']) && is_array($item['menu_items'])) {
$menuItems = $item['menu_items'];
} elseif (isset($item['menu']) && is_array($item['menu'])) {
$menuItems = [$item['menu']];
}
if (empty($menuItems)) {
return null;
}
$normalizedMenus = [];
foreach ($menuItems as $menu) {
$formattedMenu = self::normalizeCustomMicroMenuItem($menu, $name ?: $id);
if ($formattedMenu) {
$normalizedMenus[] = $formattedMenu;
}
}
if (empty($normalizedMenus)) {
return null;
}
return Base::newTrim([
'id' => $id,
'name' => $name,
'version' => $version,
'menu_items' => $normalizedMenus,
'visible_to' => self::normalizeCustomMicroVisible($item['visible_to'] ?? 'admin'),
]);
}
/**
* 规范自定义微应用菜单项
* @param array $menu
* @param string $fallbackLabel
* @return array|null
*/
protected static function normalizeCustomMicroMenuItem($menu, $fallbackLabel = '')
{
if (!is_array($menu)) {
return null;
}
$url = trim($menu['url'] ?? '');
if ($url === '') {
return null;
}
$location = trim($menu['location'] ?? 'application');
$label = trim($menu['label'] ?? $fallbackLabel);
$type = strtolower(trim($menu['type'] ?? 'iframe'));
$payload = [
'location' => $location,
'label' => $label,
'icon' => Base::newTrim($menu['icon'] ?? ''),
'url' => $url,
'type' => $type,
'keep_alive' => isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true,
'disable_scope_css' => (bool)($menu['disable_scope_css'] ?? false),
'auto_dark_theme' => isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true,
'transparent' => (bool)($menu['transparent'] ?? false),
];
if (!empty($menu['background'])) {
$payload['background'] = Base::newTrim($menu['background']);
}
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
$payload['capsule'] = Base::newTrim($menu['capsule']);
}
return $payload;
}
/**
* 规范自定义微应用可见范围
* @param mixed $value
* @return array
*/
protected static function normalizeCustomMicroVisible($value)
{
if (is_array($value)) {
$list = array_filter(array_map('trim', $value));
} else {
$list = array_filter(array_map('trim', explode(',', (string)$value)));
}
if (empty($list)) {
return ['admin'];
}
if (in_array('all', $list)) {
return ['all'];
}
return array_values($list);
}
/**
* 判断自定义微应用是否可见
* @param array $visible
* @param bool $isAdmin
* @param int $userId
* @return bool
*/
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
{
if (in_array('all', $visible)) {
return true;
}
if ($isAdmin && in_array('admin', $visible)) {
return true;
}
if ($userId > 0 && in_array((string)$userId, $visible, true)) {
return true;
}
return false;
}
/**
* 验证邮箱地址(过滤忽略地址)
* @param $array

View File

@@ -70,6 +70,9 @@ class UmengAlias extends AbstractModel
return;
}
$instance = null;
$responsePayload = null;
try {
switch ($first['platform']) {
case 'ios':
@@ -81,8 +84,11 @@ class UmengAlias extends AbstractModel
default:
return;
}
$instance->send($first['data']);
$responsePayload = $instance->send($first['data']);
} catch (\Exception $e) {
$responsePayload = [
'error' => $e->getMessage(),
];
$first['retry'] = intval($first['retry'] ?? 0) + 1;
if ($first['retry'] > 3) {
info("[PushMsg] fail: " . $e->getMessage());
@@ -91,6 +97,12 @@ class UmengAlias extends AbstractModel
self::$waitSend[] = $first;
}
} finally {
if ($instance !== null) {
UmengLog::create([
'request' => Base::array2json($first['data']),
'response' => Base::array2json($responsePayload),
]);
}
self::sendTask();
}
}
@@ -153,7 +165,7 @@ class UmengAlias extends AbstractModel
$description = $array['description'] ?: 'no description'; // 描述
$extra = is_array($array['extra']) ? $array['extra'] : []; // 额外参数
$seconds = intval($array['seconds']) ?: 86400; // 有效时间(单位:秒)
$badge = intval($array['badge']) ?: 0; // 角标数iOS
$badge = intval($array['badge']) ?: 0; // 角标数
//
switch ($platform) {
case 'ios':
@@ -203,6 +215,7 @@ class UmengAlias extends AbstractModel
'title' => $title,
'after_open' => 'go_app',
'play_sound' => true,
'set_badge' => min(99, $badge),
],
], $extra),
'type' => 'customizedcast',
@@ -213,12 +226,19 @@ class UmengAlias extends AbstractModel
'policy' => [
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
],
'category' => 1,
'channel_properties' => [
'main_activity' => 'com.dootask.task.WelcomeActivity',
'oppo_channel_id' => 'dootask',
'vivo_category' => 'IM',
'huawei_channel_importance' => 'NORMAL',
'huawei_channel_category' => 'IM',
'channel_fcm' => 0,
],
'local_properties' => [
'importance' => 'IMPORTANCE_DEFAULT',
'category' => 'CATEGORY_MESSAGE',
]
]
]);
break;

32
app/Models/UmengLog.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
/**
* App\Models\UmengLog
*
* @property int $id
* @property string|null $request 请求参数
* @property string|null $response 推送返回
* @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|UmengLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereRequest($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereResponse($value)
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereUpdatedAt($value)
* @mixin \Eloquent
*/
class UmengLog extends AbstractModel
{
protected $guarded = [];
}

View File

@@ -2,12 +2,14 @@
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Apps;
use App\Module\Table\OnlineData;
use App\Observers\AbstractObserver;
use App\Services\RequestContext;
use App\Tasks\ManticoreSyncTask;
use Cache;
use Carbon\Carbon;
@@ -15,15 +17,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 string|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 +39,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 语言首选项
@@ -50,7 +55,10 @@ use Carbon\Carbon;
* @method static \Illuminate\Database\Eloquent\Builder|User query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|User searchByKeyword(string $keyword)
* @method static \Illuminate\Database\Eloquent\Builder|User whereAddress($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereAz($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereBirthday($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereBot($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereChangepass($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
@@ -61,6 +69,7 @@ use Carbon\Carbon;
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerity($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereEncrypt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereIdentity($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereIntroduction($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereLang($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastIp($value)
@@ -173,10 +182,9 @@ class User extends AbstractModel
return UserDepartment::where('owner_userid', $this->userid)->exists();
}
/**
* 获取机器人所有者
* @return int|mixed
* @return int
*/
public function getBotOwner()
{
@@ -184,9 +192,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;
});
}));
}
/**
@@ -312,7 +320,7 @@ class User extends AbstractModel
*/
public function deleteUser($reason)
{
return AbstractModel::transaction(function () use ($reason) {
$ret = AbstractModel::transaction(function () use ($reason) {
// 删除原因
$userDelete = UserDelete::createInstance([
'operator' => User::userid(),
@@ -333,6 +341,7 @@ class User extends AbstractModel
//
return $this->delete();
});
return $ret;
}
/**
@@ -406,7 +415,14 @@ class User extends AbstractModel
$dialog?->joinGroup($user->userid, 0);
}
}
return $user->find($user->userid);
$createdUser = $user->find($user->userid);
if (!$createdUser->bot) {
// Manticore 索引同步
AbstractObserver::taskDeliver(new ManticoreSyncTask('user_sync', $createdUser->toArray()));
// 触发 user_onboard hook
Apps::dispatchUserHook($createdUser, 'user_onboard', 'onboard');
}
return $createdUser;
}
/**
@@ -537,6 +553,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 +755,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,39 +768,49 @@ class User extends AbstractModel
/**
* 是否机器人
* @param $userid
* @return bool|mixed
* @return bool
*/
public static function isBot($userid)
{
if (empty($userid)) {
return false;
}
$userid = intval($userid);
if (RequestContext::has("isBot_" . $userid)) {
return RequestContext::get("isBot_" . $userid);
}
return (bool)User::find($userid)?->bot;
// 这个不会有变化,所以可以使用永久缓存
return (bool)Cache::rememberForever('is-bot-user-' . $userid, function () use ($userid) {
return (bool)User::find($userid)?->bot;
});
}
/**
* 搜索用户
* @param $key
* @param $take
* @return User[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection
* 按关键词搜索用户Scope
* 支持:邮箱(含@、用户ID纯数字、昵称/拼音/职业
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $keyword 搜索关键词
* @return \Illuminate\Database\Eloquent\Builder
*/
public static function searchUser($key, $take = 20)
public function scopeSearchByKeyword($query, string $keyword)
{
return User::select(User::$basicField)
->where(function ($query) use ($key) {
if (str_contains($key, "@")) {
$query->where("email", "like", "%{$key}%");
} else {
$query->where("nickname", "like", "%{$key}%")
->orWhere("pinyin", "like", "%{$key}%")
->orWhere("profession", "like", "%{$key}%");
}
})->orderBy('userid')
->take($take)
->get();
if (str_contains($keyword, "@")) {
// 包含 @ 按邮箱搜索
return $query->where("email", "like", "%{$keyword}%");
}
if (is_numeric($keyword)) {
// 纯数字匹配用户ID 或 昵称/拼音/职业
return $query->where(function ($q) use ($keyword) {
$q->where("userid", intval($keyword))
->orWhere("nickname", "like", "%{$keyword}%")
->orWhere("pinyin", "like", "%{$keyword}%")
->orWhere("profession", "like", "%{$keyword}%");
});
}
// 普通文本:搜索昵称/拼音/职业
return $query->where(function ($q) use ($keyword) {
$q->where("nickname", "like", "%{$keyword}%")
->orWhere("pinyin", "like", "%{$keyword}%")
->orWhere("profession", "like", "%{$keyword}%");
});
}
}

102
app/Models/UserAppSort.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
namespace App\Models;
/**
* App\Models\UserAppSort
*
* @property int $id
* @property int $userid 用户ID
* @property array|null $sorts 排序配置
* @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|UserAppSort newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereSorts($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUserid($value)
* @mixin \Eloquent
*/
class UserAppSort extends AbstractModel
{
protected $fillable = [
'userid',
'sorts',
];
protected $casts = [
'sorts' => 'array',
];
/**
* 获取用户排序配置
* @param int $userid
* @return array
*/
public static function getSorts(int $userid): array
{
$record = static::whereUserid($userid)->first();
if (!$record) {
return self::normalizeSorts([]);
}
return self::normalizeSorts($record->sorts);
}
/**
* 保存排序配置
* @param int $userid
* @param array $sorts
* @return static
*/
public static function saveSorts(int $userid, array $sorts): self
{
return static::updateOrCreate(
['userid' => $userid],
['sorts' => self::normalizeSorts($sorts)]
);
}
/**
* 规范化排序数据
* @param mixed $sorts
* @return array
*/
public static function normalizeSorts($sorts): array
{
$result = [
'base' => [],
'admin' => [],
];
if (!is_array($sorts)) {
return $result;
}
foreach (['base', 'admin'] as $group) {
$list = $sorts[$group] ?? [];
if (!is_array($list)) {
$list = [];
}
$normalized = [];
foreach ($list as $value) {
if (!is_string($value)) {
continue;
}
$value = trim($value);
if ($value === '') {
continue;
}
$normalized[] = $value;
}
$result[$group] = array_values(array_unique($normalized));
}
return $result;
}
}

View File

@@ -4,11 +4,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 +21,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 $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()
@@ -38,12 +40,93 @@ use Carbon\Carbon;
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookEvents($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookNum($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookUrl($value)
* @mixin \Eloquent
*/
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 事件配置
*
* @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 $data
* @param int $timeout
* @return array|null
*/
public function dispatchWebhook(string $event, array $data, int $timeout = 30): ?array
{
if (!$this->shouldDispatchWebhook($event)) {
return null;
}
try {
$data['event'] = $event;
$result = Ihttp::ihttp_post($this->webhook_url, $data, $timeout);
$this->increment('webhook_num');
return $result;
} catch (Throwable $th) {
info(Base::array2json([
'webhook_url' => $this->webhook_url,
'data' => $data,
'error' => $th->getMessage(),
]));
return null;
}
}
/**
* 判断是否系统机器人
@@ -97,39 +180,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 +257,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 +268,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 +317,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 +328,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;
}
/**
@@ -278,16 +353,47 @@ class UserBot extends AbstractModel
$advance = (intval($setting['advance']) ?: 120) * 60;
$delay = (intval($setting['delay']) ?: 120) * 60;
//
$currentTime = Timer::time();
$nowDate = date("Y-m-d");
$nowTime = date("H:i:s");
$yesterdayDate = date("Y-m-d", strtotime("-1 day"));
//
// 今天的签到窗口
$timeStart = strtotime("{$nowDate} {$times[0]}");
$timeEnd = strtotime("{$nowDate} {$times[1]}");
$timeAdvance = max($timeStart - $advance, strtotime($nowDate));
$timeDelay = min($timeEnd + $delay, strtotime("{$nowDate} 23:59:59"));
// 移除 23:59:59 限制,允许跨天
$todayTimeDelay = $timeEnd + $delay;
//
// 昨天的延后窗口(用于判断凌晨打卡归属)
$yesterdayTimeEnd = strtotime("{$yesterdayDate} {$times[1]}");
$yesterdayTimeDelay = $yesterdayTimeEnd + $delay;
//
// 判断签到归属哪天
$targetDate = null;
$checkType = null; // 'up' 或 'down'
//
// 情况1在今天的有效窗口内
if ($currentTime >= $timeAdvance && $currentTime <= $todayTimeDelay) {
$targetDate = $nowDate;
if ($currentTime < $timeEnd) {
$checkType = 'up';
} else {
$checkType = 'down';
}
}
// 情况2凌晨时段检查是否在昨天的延后窗口内
elseif ($currentTime < $timeAdvance && $currentTime <= $yesterdayTimeDelay) {
$targetDate = $yesterdayDate;
$checkType = 'down';
}
//
// 构建错误消息
$errorTime = false;
if (Timer::time() < $timeAdvance || $timeDelay < Timer::time()) {
$errorTime = "不在有效时间内,有效时间为:" . date("H:i", $timeAdvance) . "-" . date("H:i", $timeDelay);
if (!$targetDate) {
$displayDelay = date("H:i", $todayTimeDelay % 86400);
$nextDay = ($todayTimeDelay > strtotime("{$nowDate} 23:59:59")) ? "(+1)" : "";
$errorTime = "不在有效时间内,有效时间为:" . date("H:i", $timeAdvance) . "-{$displayDelay}{$nextDay}";
}
//
$macs = explode(",", $mac);
@@ -301,7 +407,7 @@ class UserBot extends AbstractModel
$array[] = [
'userid' => $UserCheckinMac->userid,
'mac' => $UserCheckinMac->mac,
'date' => $nowDate,
'date' => $targetDate ?: $nowDate,
];
$checkins[] = [
'userid' => $UserCheckinMac->userid,
@@ -322,7 +428,7 @@ class UserBot extends AbstractModel
$array[] = [
'userid' => $UserInfo->userid,
'mac' => '00:00:00:00:00:00',
'date' => $nowDate,
'date' => $targetDate ?: $nowDate,
];
$checkins[] = [
'userid' => $UserInfo->userid,
@@ -357,7 +463,8 @@ class UserBot extends AbstractModel
}
return null;
};
$sendMsg = function($type, $checkin) use ($errorTime, $alreadyTip, $getJokeSoup, $botUser, $nowDate) {
$sendMsg = function($type, $checkin) use ($errorTime, $alreadyTip, $getJokeSoup, $botUser, $targetDate, $nowDate) {
$displayDate = $targetDate ?: $nowDate;
$dialog = WebSocketDialog::checkUserDialog($botUser, $checkin['userid']);
if (!$dialog) {
return;
@@ -374,12 +481,13 @@ class UserBot extends AbstractModel
}
return;
}
// 判断已打卡
$cacheKey = "Checkin::sendMsg-{$nowDate}-{$type}:" . $checkin['userid'];
// 判断已打卡(使用目标日期作为缓存键)
$cacheKey = "Checkin::sendMsg-{$displayDate}-{$type}:" . $checkin['userid'];
$typeContent = $type == "up" ? "上班" : "下班";
if (Cache::get($cacheKey) === "yes") {
if ($alreadyTip) {
$text = "今日已{$typeContent}打卡,无需重复打卡。";
$dateHint = ($displayDate != $nowDate) ? "({$displayDate}) " : "今日";
$text = "{$dateHint}{$typeContent}打卡,无需重复打卡。";
$text .= $checkin['remark'] ? " ({$checkin['remark']})": "";
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
@@ -393,7 +501,8 @@ class UserBot extends AbstractModel
$hi = date("H:i");
$remark = $checkin['remark'] ? " ({$checkin['remark']})": "";
$subcontent = $getJokeSoup($type, $checkin['userid']);
$title = "{$typeContent}打卡成功,打卡时间: {$hi}{$remark}";
$dateInfo = ($displayDate != $nowDate) ? " ({$displayDate})" : "";
$title = "{$typeContent}打卡成功,打卡时间: {$hi}{$remark}{$dateInfo}";
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $title,
@@ -408,14 +517,13 @@ class UserBot extends AbstractModel
],
], $botUser->userid, false, false, $type != "up");
};
if ($timeAdvance <= Timer::time() && Timer::time() < $timeEnd) {
// 上班打卡通知(从最早打卡时间 到 下班打卡时间)
// 根据打卡类型发送通知
if ($checkType === 'up') {
foreach ($checkins as $checkin) {
$sendMsg('up', $checkin);
}
}
if ($timeEnd <= Timer::time() && Timer::time() <= $timeDelay) {
// 下班打卡通知(下班打卡时间 到 最晚打卡时间)
if ($checkType === 'down') {
foreach ($checkins as $checkin) {
$sendMsg('down', $checkin);
}
@@ -445,11 +553,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 +570,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 +579,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' => '创建成功。',
@@ -477,4 +595,42 @@ class UserBot extends AbstractModel
}
return Base::retSuccess("创建成功。", $data);
}
/**
* 获取可选的 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
* @param bool $useFallback
* @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] : []);
}
}

View File

@@ -88,16 +88,32 @@ class UserCheckinRecord extends AbstractModel
/**
* 时间收集
* @param string $data
* @param array $times
* @param string $data 日期
* @param array $times 签到时间数组
* @param string|null $shiftStart 班次开始时间(如 "09:00"),用于判断跨天
* @return \Illuminate\Support\Collection
*/
public static function atCollect($data, $times)
public static function atCollect($data, $times, $shiftStart = null)
{
$sameTimes = array_map(function($time) use ($data) {
$shiftStartMinutes = null;
if ($shiftStart) {
$parts = explode(':', $shiftStart);
$shiftStartMinutes = intval($parts[0]) * 60 + intval($parts[1]);
}
$sameTimes = array_map(function($time) use ($data, $shiftStartMinutes) {
$parts = explode(':', $time);
$timeMinutes = intval($parts[0]) * 60 + intval($parts[1]);
// 如果签到时间早于班次开始时间,视为跨天打卡(属于次日凌晨)
$targetDate = $data;
if ($shiftStartMinutes !== null && $timeMinutes < $shiftStartMinutes) {
$targetDate = date("Y-m-d", strtotime($data . " +1 day"));
}
return [
"datetime" => "{$data} {$time}",
"timestamp" => strtotime("{$data} {$time}")
"datetime" => "{$targetDate} {$time}",
"timestamp" => strtotime("{$targetDate} {$time}")
];
}, $times);
return collect($sameTimes);

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(),
]
);
}
}

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

@@ -0,0 +1,115 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* App\Models\UserTag
*
* @property int $id
* @property int $user_id 被标签用户ID
* @property string $name 标签名称
* @property int $created_by 创建人
* @property int|null $updated_by 最后更新人
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\User $creator
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserTagRecognition> $recognitions
* @property-read int|null $recognitions_count
* @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|UserTag newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserTag newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserTag query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereCreatedBy($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereUpdatedBy($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereUserId($value)
* @mixin \Eloquent
*/
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,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\UserTagRecognition
*
* @property int $id
* @property int $tag_id 标签ID
* @property int $user_id 认可人ID
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\UserTag $tag
* @property-read \App\Models\User $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|UserTagRecognition newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereTagId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereUserId($value)
* @mixin \Eloquent
*/
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

@@ -10,6 +10,7 @@ namespace App\Models;
* @property string $key
* @property string|null $fd
* @property string|null $path
* @property string|null $platform 平台类型android, ios, win, mac, web
* @property int|null $userid
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
@@ -27,6 +28,7 @@ namespace App\Models;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereKey($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket wherePath($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket wherePlatform($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereUserid($value)
* @mixin \Eloquent

View File

@@ -530,6 +530,7 @@ class WebSocketDialog extends AbstractModel
}
}
//
$item->operator_id = User::userid();
$item->delete();
//
if ($pushMsg) {
@@ -551,6 +552,42 @@ class WebSocketDialog extends AbstractModel
$this->pushMsg("groupUpdate", $data);
}
/**
* 推送成员事件到机器人 webhook
* @param string $event
* @param int $memberId
* @param int $operatorId
* @return void
*/
public function dispatchMemberWebhook(string $event, int $memberId, int $operatorId): void
{
$botIds = $this->dialogUser()->where('bot', 1)->pluck('userid')->toArray();
if (empty($botIds)) {
return;
}
$userBots = UserBot::whereIn('bot_id', $botIds)->get();
if ($userBots->isEmpty()) {
return;
}
$member = User::find($memberId, ['userid', 'nickname', 'email', 'bot'])?->toArray();
$operator = $operatorId === $memberId ? $member : User::find($operatorId, ['userid', 'nickname', 'email', 'bot'])?->toArray();
$payload = [
'dialog_id' => $this->id,
'dialog_type' => $this->type,
'group_type' => $this->group_type,
'dialog_name' => $this->getGroupName(),
'member' => $member,
'operator' => $operator,
];
foreach ($userBots as $userBot) {
$userBot->dispatchWebhook($event, $payload, 10);
}
}
/**
* 删除会话
* @return bool
@@ -705,6 +742,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 +857,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 +894,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;

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;
@@ -42,6 +44,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property-read int|mixed $percentage
* @property-read \App\Models\User|null $user
* @property-read \App\Models\WebSocketDialog|null $webSocketDialog
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg accessibleByUser(int $userid)
* @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)
@@ -52,6 +55,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg searchByKeyword(string $keyword)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereBot($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereDeletedAt($value)
@@ -109,6 +113,36 @@ class WebSocketDialogMsg extends AbstractModel
return $this->hasOne(User::class, 'userid', 'userid');
}
/**
* 按关键词搜索消息Scope
* 搜索 key 字段(消息的可搜索内容)
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $keyword 搜索关键词
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSearchByKeyword($query, string $keyword)
{
return $query->where('key', 'like', "%{$keyword}%");
}
/**
* 筛选用户可访问的对话消息Scope
* 通过 web_socket_dialog_users 表验证用户对对话的访问权限
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $userid 用户ID
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeAccessibleByUser($query, int $userid)
{
return $query->whereIn('dialog_id', function ($subQuery) use ($userid) {
$subQuery->select('dialog_id')
->from('web_socket_dialog_users')
->where('userid', $userid);
});
}
/**
* 阅读占比
* @return int|mixed
@@ -315,6 +349,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 +419,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 +439,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 +454,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 +469,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 +510,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 +523,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,17 +710,25 @@ 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 '';
if ($msgData['type'] === 'md') {
$text = preg_replace('/<\/?tool-use[^>]*>/', '', $text);
$text = preg_replace("/:::\s*reasoning[\s\S]*?:::/", "", $text);
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 = preg_replace("/<img\s+class=\"emoticon\"[^>]*?alt=\"(\S+)\"[^>]*?>/", "[$1]", $text);
$text = preg_replace("/<img\s+class=\"emoticon\"[^>]*?>/", "[" . Doo::translate('动画表情') . "]", $text);
@@ -680,36 +743,6 @@ class WebSocketDialogMsg extends AbstractModel
return $text;
}
/**
* 转换任务列表
* @param $text
* @return array|string|string[]|null
*/
private static function previewConvertTaskList($text) {
$pattern = '/:::\s*(create-task-list|create-subtask-list)(.*?):::/s';
$replacement = function($matches) {
$content = $matches[2];
$lines = explode("\n", trim($content));
$result = [];
$currentTitle = '';
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
if (preg_match('/^title:\s*(.+)$/', $line, $titleMatch)) {
$currentTitle = $titleMatch[1];
$result[] = $currentTitle;
} elseif (preg_match('/^desc:\s*(.+)$/', $line, $descMatch)) {
if (!empty($currentTitle)) {
$result[] = $descMatch[1];
}
}
}
return implode("\n", $result);
};
return preg_replace_callback($pattern, $replacement, $text);
}
/**
* 预览文件消息
* @param $msg
@@ -821,6 +854,92 @@ 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":
// 提取文件消息
$result = " 文件:{$this->msg['name']}(大小:{$this->msg['size']}BURL{$this->msg['path']} ";
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 ($match) {
return "";
},
// 任务
"/<span class=\"mention task\" data-id=\"(\d+)\">#?(.*?)<\/span>/" => function ($match) {
return " 任务:{$match[2]} (任务ID{$match[1]}) ";
},
// 文件
"/<a class=\"mention file\" href=\"([^\"']+?)\"[^>]*?>~?(.*?)<\/a>/" => function ($match) use (&$reserves) {
$idOrCode = "";
if (preg_match("/single\/file\/(.*?)$/", $match[1], $subMatch)) {
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "文件ID{$subMatch[1]}" : "文件分享码:{$subMatch[1]}") . ")";
}
return " 文件:{$match[2]}{$idOrCode} ";
},
// 报告
"/<a class=\"mention report\" href=\"([^\"']+?)\"[^>]*?>%?(.*?)<\/a>/" => function ($match) use (&$reserves) {
$idOrCode = "";
if (preg_match("/single\/report\/detail\/(.*?)$/", $match[1], $subMatch)) {
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "报告ID{$subMatch[1]}" : "报告分享码:{$subMatch[1]}") . ")";
}
return " 工作汇报:{$match[2]}{$idOrCode} ";
},
], $result);
// 转成 markdown
if ($this->msg['type'] !== 'md') {
$result = Base::html2markdown($result);
}
break;
default:
// 其他类型消息不处理
return '';
}
// 截取最大长度
if ($maxLength > 0 && mb_strlen($result) > $maxLength) {
$result = mb_substr($result, 0, $maxLength);
}
// 规范以斜杠开头的命令
$result = preg_replace('/^\s*\\//', '/', $result);
return $result;
}
/**
* 处理文本消息内容,用于发送前
* @param $text
@@ -1197,6 +1316,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 +1383,7 @@ class WebSocketDialogMsg extends AbstractModel
'updated_at' => Carbon::now()->toDateTimeString('millisecond'),
]);
});
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
//
$task = new WebSocketDialogMsgTask($dialogMsg->id);
if ($push_self) {
@@ -1328,7 +1449,6 @@ class WebSocketDialogMsg extends AbstractModel
});
}
/**
* 将被@的人加入群
* @param WebSocketDialog $dialog 对话

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* App\Models\WebSocketDialogMsgRead
@@ -76,24 +77,74 @@ class WebSocketDialogMsgRead extends AbstractModel
*/
public static function onlyMarkRead($list)
{
$dialogMsg = [];
if (empty($list)) {
return;
}
$collection = collect($list);
if ($collection->isEmpty()) {
return;
}
$now = Carbon::now();
$ids = [];
$msgCounts = [];
/** @var WebSocketDialogMsgRead $item */
foreach ($list as $item) {
$item->read_at = Carbon::now();
$item->save();
if (isset($dialogMsg[$item->msg_id])) {
$dialogMsg[$item->msg_id]['readNum']++;
} else {
$dialogMsg[$item->msg_id] = [
'dialogMsg' => $item->webSocketDialogMsg,
'readNum' => 1
];
foreach ($collection as $item) {
$ids[] = $item->id;
if ($item->msg_id) {
$msgCounts[$item->msg_id] = ($msgCounts[$item->msg_id] ?? 0) + 1;
}
}
foreach ($dialogMsg as $item) {
if ($item['dialogMsg']) {
$item['dialogMsg']->increment('read', $item['readNum']);
if (!empty($ids)) {
DB::table((new self())->getTable())
->whereIn('id', $ids)
->whereNull('read_at')
->update(['read_at' => $now]);
}
if (!empty($msgCounts)) {
$cases = [];
$bindings = [];
foreach ($msgCounts as $msgId => $num) {
$cases[] = 'WHEN ? THEN ?';
$bindings[] = $msgId;
$bindings[] = $num;
}
$msgIds = array_keys($msgCounts);
$bindings = array_merge($bindings, $msgIds);
$placeholders = implode(',', array_fill(0, count($msgIds), '?'));
$table = DB::getTablePrefix() . (new WebSocketDialogMsg())->getTable();
$sql = "UPDATE {$table} SET `read` = `read` + CASE `id` " . implode(' ', $cases) . " END WHERE `deleted_at` IS NULL AND `id` IN ({$placeholders})";
DB::update($sql, $bindings);
}
}
/**
* 标记指定会话的历史消息为已读
* @param int $dialogId
* @param int $sessionId
* @param int $chunkSize
* @return void
*/
public static function markSessionMessagesAsRead(int $dialogId, int $sessionId, int $chunkSize = 100): void
{
if ($dialogId <= 0 || $sessionId <= 0) {
return;
}
self::whereDialogId($dialogId)
->whereNull('read_at')
->whereIn('msg_id', function ($query) use ($dialogId, $sessionId) {
$query->select('id')
->from((new WebSocketDialogMsg())->getTable())
->where('dialog_id', $dialogId)
->where('session_id', $sessionId);
})
->chunkById($chunkSize, function ($list) {
self::onlyMarkRead($list);
});
}
}

View File

@@ -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,6 +81,6 @@ class WebSocketDialogSession extends AbstractModel
$session->title = $title;
$session->save();
Cache::forever($cacheKey, true);
Task::deliver(new UpdateSessionTitleViaAiTask($session->id, $originalTitle));
Task::deliver(new UpdateSessionTitleViaAiTask($session->id, $dialogMsg->msg['text']));
}
}

1031
app/Module/AI.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,12 @@
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Models\UserDepartment;
use App\Services\RequestContext;
use Symfony\Component\Yaml\Yaml;
use App\Module\Base;
use App\Module\Ihttp;
class Apps
{
@@ -22,7 +26,7 @@ class Apps
$key = 'app_installed_' . $appId;
if (RequestContext::has($key)) {
return RequestContext::get($key);
return (bool) RequestContext::get($key, false);
}
$configFile = base_path('docker/appstore/config/' . $appId . '/config.yml');
@@ -44,17 +48,84 @@ class Apps
{
if (!self::isInstalled($appId)) {
$name = match ($appId) {
'ai' => 'AI Robot',
'ai' => 'AI Assistant',
'face' => 'Face check-in',
'appstore' => 'AppStore',
'approve' => 'Approval',
'office' => 'OnlyOffice',
'drawio' => 'Drawio',
'minder' => 'Minder',
'search' => 'ZincSearch',
'manticore' => 'Manticore Search',
default => $appId,
};
throw new ApiException("应用「{$name}」未安装", [], 0, false);
}
}
/**
* Dispatch user lifecycle hook to appstore (user_onboard/user_offboard/user_update).
*
* @param User $user 用户对象
* @param string $action Hook 动作: user_onboard, user_offboard, user_update
* @param string $eventType 事件类型: onboard, restore, offboarded, delete, profile_update, admin_update
* @param array $changedFields 变更字段列表(仅 user_update 时有值)
*/
public static function dispatchUserHook(User $user, string $action, string $eventType = '', array $changedFields = []): void
{
$appKey = env('APP_KEY', '');
if (empty($appKey)) {
info('[appstore_hook] APP_KEY is empty, skip dispatchUserHook');
return;
}
// 获取用户部门信息
$departments = [];
if (!empty($user->department)) {
$deptIds = is_array($user->department)
? $user->department
: array_filter(explode(',', $user->department));
if (!empty($deptIds)) {
$deptList = UserDepartment::whereIn('id', $deptIds)->get(['id', 'name']);
foreach ($deptList as $dept) {
$departments[] = [
'id' => (string) $dept->id,
'name' => (string) $dept->name,
];
}
}
}
$url = sprintf('http://appstore/api/v1/internal/hooks/%s', $action);
$payload = [
'user' => [
'id' => (string) $user->userid,
'email' => (string) $user->email,
'name' => (string) $user->nickname,
'role' => $user->isAdmin() ? 'admin' : 'normal',
'tel' => (string) ($user->tel ?? ''),
'profession' => (string) ($user->profession ?? ''),
'birthday' => $user->birthday ? (string) $user->birthday : '',
'address' => (string) ($user->address ?? ''),
'introduction' => (string) ($user->introduction ?? ''),
'departments' => $departments,
],
'event_type' => $eventType,
'changed_fields' => $changedFields,
];
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . md5($appKey),
'Version' => Base::getVersion(),
];
$resp = Ihttp::ihttp_request($url, json_encode($payload, JSON_UNESCAPED_UNICODE), $headers, 5);
if (Base::isError($resp)) {
info('[appstore_hook] dispatch fail', [
'url' => $url,
'payload' => $payload,
'error' => $resp,
]);
}
}
}

View File

@@ -1301,7 +1301,7 @@ class Base
/**
* 获取或设置
* @param $setname // 配置名称
* @param bool $array // 保存内容
* @param bool|array $array // 保存内容
* @param bool $isUpdate // 保存内容为更新模式,默认否
* @return array
*/
@@ -1404,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);
}
/**
@@ -1825,6 +1827,19 @@ class Base
return $platform;
}
/**
* 是否是PC端包括 Electron 桌面端和 Web 浏览器)
* @param string|null $platform 平台类型,不传则自动获取
* @return bool
*/
public static function isPc($platform = null)
{
if ($platform === null) {
$platform = self::platform();
}
return in_array($platform, ['win', 'mac', 'web']);
}
/**
* 是否是App移动端
* @return bool
@@ -1858,12 +1873,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);
}
/**
@@ -3040,7 +3065,7 @@ class Base
{
try {
$converter = new CommonMarkConverter();
return $converter->convert($markdown);
return $converter->convert($markdown)->getContent();
} catch (\League\CommonMark\Exception\CommonMarkException $e) {
return $markdown;
}
@@ -3049,15 +3074,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,73 +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* 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");
$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;
}
/**
@@ -77,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();
}
/**
@@ -139,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);
}
/**
@@ -167,7 +87,7 @@ class Doo
*/
public static function userId(): int
{
return intval(self::doo()->userId());
return self::load()->userId();
}
/**
@@ -176,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();
}
/**
@@ -186,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();
}
/**
@@ -196,7 +114,7 @@ class Doo
*/
public static function userEmail(): string
{
return self::string(self::doo()->userEmail());
return self::load()->userEmail();
}
/**
@@ -205,7 +123,7 @@ class Doo
*/
public static function userEncrypt(): string
{
return self::string(self::doo()->userEncrypt());
return self::load()->userEncrypt();
}
/**
@@ -214,7 +132,7 @@ class Doo
*/
public static function userToken(): string
{
return self::string(self::doo()->userToken());
return self::load()->userToken();
}
/**
@@ -225,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);
}
/**
@@ -254,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);
}
/**
@@ -264,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" => "简体中文",
@@ -334,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);
}
/**
@@ -343,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();
}
/**
@@ -359,7 +256,7 @@ class Doo
*/
public static function dooSN(): string
{
return self::string(self::doo()->dooSN());
return self::load()->dooSN();
}
/**
@@ -368,7 +265,7 @@ class Doo
*/
public static function dooVersion(): string
{
return self::string(self::doo()->version());
return self::load()->dooVersion();
}
/**
@@ -380,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);
}
/**
@@ -391,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);
}
/**
@@ -407,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);
}
/**
@@ -423,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);
}
/**
@@ -437,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);
}
/**
@@ -449,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);
}
/**
@@ -476,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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,670 @@
<?php
namespace App\Module\Manticore;
use App\Models\File;
use App\Models\FileContent;
use App\Models\FileUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\TextExtractor;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
* Manticore Search 文件搜索类
*
* 使用方法:
*
* 1. 搜索方法
* - 搜索文件: search($userid, $keyword, $searchType, $from, $size);
*
* 2. 同步方法
* - 单个同步: sync(File $file);
* - 批量同步: batchSync($files);
* - 删除索引: delete($fileId);
*
* 3. 权限更新方法
* - 更新权限: updateAllowedUsers($fileId);
*
* 4. 工具方法
* - 清空索引: clear();
*/
class ManticoreFile
{
/**
* 可搜索的文件类型
*/
public const SEARCHABLE_TYPES = ['document', 'word', 'excel', 'ppt', 'txt', 'md', 'text', 'code'];
/**
* 最大内容长度(字符)- 提取后的文本内容限制
*/
public const MAX_CONTENT_LENGTH = 100000; // 100K 字符
/**
* 不同文件类型的最大大小限制(字节)
*/
public const MAX_FILE_SIZE = [
'office' => 50 * 1024 * 1024, // 50MB - Office 文件图片占空间大但文本少
'text' => 5 * 1024 * 1024, // 5MB - 纯文本文件
'other' => 20 * 1024 * 1024, // 20MB - PDF 等其他文件
];
/**
* Office 文件扩展名
*/
public const OFFICE_EXTENSIONS = [
'doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'rtf',
'xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'ods', 'ots', 'csv', 'tsv',
'ppt', 'pptx', 'pps', 'ppsx', 'odp', 'otp'
];
/**
* 纯文本文件扩展名
*/
public const TEXT_EXTENSIONS = [
'txt', 'md', 'text', 'log', 'json', 'xml', 'html', 'htm', 'css', 'js', 'ts',
'php', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'rb', 'sh', 'bash', 'sql',
'yaml', 'yml', 'ini', 'conf', 'vue', 'jsx', 'tsx'
];
/**
* 搜索文件(支持全文、向量、混合搜索)
*
* @param int $userid 用户ID
* @param string $keyword 搜索关键词
* @param string $searchType 搜索类型: text/vector/hybrid
* @param int $from 起始位置
* @param int $size 返回数量
* @return array 搜索结果
*/
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $from = 0, int $size = 20): array
{
if (empty($keyword)) {
return [];
}
if (!Apps::isInstalled("search")) {
// 未安装 Manticore降级到 MySQL LIKE 搜索
return self::searchByMysql($userid, $keyword, $from, $size);
}
try {
switch ($searchType) {
case 'text':
// 纯全文搜索
return self::formatSearchResults(
ManticoreBase::fullTextSearch($keyword, $userid, $size, $from)
);
case 'vector':
// 纯向量搜索(需要先获取 embedding
$embedding = ManticoreBase::getEmbedding($keyword);
if (empty($embedding)) {
// embedding 获取失败,降级到全文搜索
return self::formatSearchResults(
ManticoreBase::fullTextSearch($keyword, $userid, $size, $from)
);
}
return self::formatSearchResults(
ManticoreBase::vectorSearch($embedding, $userid, $size)
);
case 'hybrid':
default:
// 混合搜索
$embedding = ManticoreBase::getEmbedding($keyword);
return self::formatSearchResults(
ManticoreBase::hybridSearch($keyword, $embedding, $userid, $size)
);
}
} catch (\Exception $e) {
Log::error('Manticore search error: ' . $e->getMessage());
return self::searchByMysql($userid, $keyword, $from, $size);
}
}
/**
* 格式化搜索结果
*
* @param array $results Manticore 返回的结果
* @return array 格式化后的结果
*/
private static function formatSearchResults(array $results): array
{
$formatted = [];
foreach ($results as $item) {
$formatted[] = [
'id' => $item['file_id'],
'file_id' => $item['file_id'],
'name' => $item['file_name'],
'type' => $item['file_type'],
'ext' => $item['file_ext'],
'userid' => $item['userid'],
'content_preview' => isset($item['content']) ? mb_substr($item['content'], 0, 500) : null,
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
];
}
return $formatted;
}
/**
* MySQL 降级搜索(仅搜索文件名)
*
* @param int $userid 用户ID
* @param string $keyword 关键词
* @param int $from 起始位置
* @param int $size 返回数量
* @return array 搜索结果
*/
private static function searchByMysql(int $userid, string $keyword, int $from, int $size): array
{
// 搜索用户自己的文件
$builder = File::where('userid', $userid)
->where('name', 'like', "%{$keyword}%")
->where('type', '!=', 'folder');
$results = $builder->skip($from)->take($size)->get();
return $results->map(function ($file) {
return [
'id' => $file->id,
'file_id' => $file->id,
'name' => $file->name,
'type' => $file->type,
'ext' => $file->ext,
'userid' => $file->userid,
'content_preview' => null,
'relevance' => 0,
];
})->toArray();
}
// ==============================
// 权限计算方法
// ==============================
/**
* 获取文件的 allowed_users 列表
*
* 有权限查看此文件的用户列表:
* - 文件所有者 (userid)
* - 共享用户FileUser 表中的 userid
* - userid=0 表示公开共享
*
* @param File $file 文件模型
* @return array 有权限的用户ID数组
*/
public static function getAllowedUsers(File $file): array
{
$userids = [$file->userid]; // 所有者
// 获取共享用户(包括 userid=0 表示公开)
$shareUsers = FileUser::where('file_id', $file->id)
->pluck('userid')
->toArray();
return array_unique(array_merge($userids, $shareUsers));
}
// ==============================
// 同步方法
// ==============================
/**
* 同步单个文件到 Manticore含 allowed_users
*
* @param File $file 文件模型
* @param bool $withVector 是否同时生成向量(默认 false向量由后台任务生成
* @return bool 是否成功
*/
public static function sync(File $file, bool $withVector = false): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
// 不处理文件夹
if ($file->type === 'folder') {
return true;
}
// 根据文件类型检查大小限制
$maxSize = self::getMaxFileSizeByExt($file->ext);
if ($file->size > $maxSize) {
// 删除可能存在的旧索引(文件更新后可能超限)
self::delete($file->id);
return true;
}
try {
// 提取文件内容
$content = self::extractFileContent($file);
// 限制提取后的内容长度
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && Apps::isInstalled('ai')) {
// 向量内容包含文件名和文件内容
$vectorContent = self::buildVectorContent($file->name, $content);
if (!empty($vectorContent)) {
$embeddingResult = ManticoreBase::getEmbedding($vectorContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
}
// 获取文件的 allowed_users
$allowedUsers = self::getAllowedUsers($file);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertFileVector([
'file_id' => $file->id,
'userid' => $file->userid,
'pshare' => $file->pshare ?? 0,
'file_name' => $file->name,
'file_type' => $file->type,
'file_ext' => $file->ext,
'content' => $content,
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
]);
return $result;
} catch (\Exception $e) {
Log::error('Manticore sync error: ' . $e->getMessage(), [
'file_id' => $file->id,
'file_name' => $file->name,
]);
return false;
}
}
/**
* 根据文件扩展名获取最大文件大小限制
*
* @param string|null $ext 文件扩展名
* @return int 最大文件大小(字节)
*/
private static function getMaxFileSizeByExt(?string $ext): int
{
$ext = strtolower($ext ?? '');
if (in_array($ext, self::OFFICE_EXTENSIONS)) {
return self::MAX_FILE_SIZE['office'];
}
if (in_array($ext, self::TEXT_EXTENSIONS)) {
return self::MAX_FILE_SIZE['text'];
}
return self::MAX_FILE_SIZE['other'];
}
/**
* 获取所有文件类型中的最大文件大小限制
*
* @return int 最大文件大小(字节)
*/
public static function getMaxFileSize(): int
{
return max(self::MAX_FILE_SIZE);
}
/**
* 批量同步文件
*
* @param iterable $files 文件列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $files, bool $withVector = false): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
foreach ($files as $file) {
if (self::sync($file, $withVector)) {
$count++;
}
}
return $count;
}
/**
* 删除文件索引
*
* @param int $fileId 文件ID
* @return bool 是否成功
*/
public static function delete(int $fileId): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::deleteFileVector($fileId);
}
/**
* 提取文件内容(支持分页)
*
* @param File|string $fileOrPath 文件模型 或 文件路径/URL
* @param int $offset 起始位置(字符数),默认 0
* @param int $limit 获取长度(字符数),默认 50000最大 200000
* @return array 包含 content, total_length, offset, limit, has_more, 或 error
*/
public static function extractFileContentPaginated(File|string $fileOrPath, int $offset = 0, int $limit = 50000): array
{
$offset = max(0, $offset);
$limit = min(max(1, $limit), 200000);
// 根据参数类型获取完整内容
if ($fileOrPath instanceof File) {
if ($fileOrPath->type === 'folder') {
return ['error' => '文件夹无法提取内容'];
}
$fullContent = self::extractFileContent($fileOrPath);
} else {
$fullContent = self::extractFileContentFromPath($fileOrPath);
if (is_array($fullContent)) {
return $fullContent; // 返回错误信息
}
}
if (empty($fullContent)) {
return ['error' => '无法提取文件内容'];
}
// 分页处理
$totalLength = mb_strlen($fullContent);
if ($offset >= $totalLength) {
return [
'content' => '',
'total_length' => $totalLength,
'offset' => $offset,
'limit' => $limit,
'has_more' => false,
];
}
$content = mb_substr($fullContent, $offset, $limit);
$hasMore = ($offset + mb_strlen($content)) < $totalLength;
return [
'content' => $content,
'total_length' => $totalLength,
'offset' => $offset,
'limit' => $limit,
'has_more' => $hasMore,
];
}
/**
* 通过路径/URL 提取完整内容
* @return string|array 内容字符串,或错误数组
*/
private static function extractFileContentFromPath(string $pathOrUrl): string|array
{
// 从 URL 中提取相对路径
if (str_starts_with($pathOrUrl, 'http://') || str_starts_with($pathOrUrl, 'https://')) {
$parsed = parse_url($pathOrUrl);
$pathOrUrl = ltrim($parsed['path'] ?? '', '/');
}
if (preg_match('/^.*?(uploads\/.*)$/', $pathOrUrl, $matches)) {
$pathOrUrl = $matches[1];
}
// 安全检查:只允许 uploads 目录
if (!str_starts_with($pathOrUrl, 'uploads/')) {
return ['error' => '不支持的文件路径'];
}
return self::extractFromPath($pathOrUrl);
}
/**
* 提取文件内容(内部使用,返回完整内容)
*
* @param File $file 文件模型
* @return string 文件内容文本
*/
private static function extractFileContent(File $file): string
{
// 1. 先尝试从 FileContent 的 text 字段获取(已提取的文本内容)
$fileContent = FileContent::where('fid', $file->id)->orderByDesc('id')->first();
if (!$fileContent) {
return '';
}
if (!empty($fileContent->text)) {
return $fileContent->text;
}
// 2. 尝试从 FileContent 的 content 字段获取
if (!empty($fileContent->content)) {
$contentData = Base::json2array($fileContent->content);
// 2.1 某些文件类型直接存储内容
if (!empty($contentData['content']) && is_string($contentData['content'])) {
return $contentData['content'];
}
// 2.2 通过路径提取
$filePath = $contentData['url'] ?? null;
if ($filePath && str_starts_with($filePath, 'uploads/')) {
$result = self::extractFromPath($filePath);
if (is_string($result)) {
return $result;
}
}
}
return '';
}
/**
* 从文件路径提取内容(核心方法)
* @return string|array 内容字符串,或错误数组
*/
private static function extractFromPath(string $relativePath): string|array
{
$fullPath = public_path($relativePath);
if (!file_exists($fullPath)) {
return ['error' => '文件不存在'];
}
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
$maxFileSize = self::getMaxFileSizeByExt($ext);
$result = TextExtractor::extractFile(
$fullPath,
(int) ($maxFileSize / 1024),
(int) (self::MAX_CONTENT_LENGTH / 1024)
);
if (!Base::isSuccess($result)) {
return ['error' => $result['msg'] ?? '无法提取文件内容'];
}
return $result['data'] ?? '';
}
/**
* 构建用于生成向量的内容
* 包含文件名和文件内容,确保语义搜索能匹配文件名
*
* @param string $fileName 文件名
* @param string $content 文件内容
* @return string 用于生成向量的文本
*/
private static function buildVectorContent(string $fileName, string $content): string
{
$parts = [];
if (!empty($fileName)) {
$parts[] = $fileName;
}
if (!empty($content)) {
$parts[] = $content;
}
return implode(' ', $parts);
}
/**
* 清空所有索引
*
* @return bool 是否成功
*/
public static function clear(): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::clearAllFileVectors();
}
/**
* 获取已索引文件数量
*
* @return int 数量
*/
public static function getIndexedCount(): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
return ManticoreBase::getIndexedFileCount();
}
// ==============================
// 权限更新方法
// ==============================
/**
* 更新文件的 allowed_users 权限列表
* 从 MySQL 获取最新的共享用户并更新到 Manticore
*
* @param int $fileId 文件ID
* @return bool 是否成功
*/
public static function updateAllowedUsers(int $fileId): bool
{
if (!Apps::isInstalled("search") || $fileId <= 0) {
return false;
}
try {
$file = File::find($fileId);
if (!$file) {
return false;
}
$userids = self::getAllowedUsers($file);
return ManticoreBase::updateFileAllowedUsers($fileId, $userids);
} catch (\Exception $e) {
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['file_id' => $fileId]);
return false;
}
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成文件向量
* 用于后台异步处理,将已索引文件的向量批量生成
*
* @param array $fileIds 文件ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $fileIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($fileIds)) {
return 0;
}
try {
// 1. 查询文件信息
$files = File::whereIn('id', $fileIds)
->where('type', '!=', 'folder')
->get();
if ($files->isEmpty()) {
return 0;
}
// 2. 提取每个文件的内容(包含文件名)
$fileContents = [];
foreach ($files as $file) {
// 检查文件大小限制
$maxSize = self::getMaxFileSizeByExt($file->ext);
if ($file->size > $maxSize) {
continue;
}
$content = self::extractFileContent($file);
// 向量内容包含文件名和文件内容
$vectorContent = self::buildVectorContent($file->name, $content);
if (!empty($vectorContent)) {
// 限制内容长度
$vectorContent = mb_substr($vectorContent, 0, self::MAX_CONTENT_LENGTH);
$fileContents[$file->id] = $vectorContent;
}
}
if (empty($fileContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($fileContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $fileId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$fileId] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateFileVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreFile generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Module\Manticore;
use App\Module\Apps;
use Illuminate\Support\Facades\Log;
/**
* Manticore Search 键值存储类
*
* 用于存储同步进度等配置信息
*/
class ManticoreKeyValue
{
/**
* 获取值
*
* @param string $key 键
* @param mixed $default 默认值
* @return mixed 值
*/
public static function get(string $key, $default = null)
{
if (!Apps::isInstalled("search")) {
return $default;
}
$instance = new ManticoreBase();
$result = $instance->queryOne(
"SELECT v FROM key_values WHERE k = ?",
[$key]
);
return $result ? $result['v'] : $default;
}
/**
* 设置值
*
* @param string $key 键
* @param mixed $value 值
* @return bool 是否成功
*/
public static function set(string $key, $value): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
$instance = new ManticoreBase();
// 先删除已存在的记录
$instance->execute("DELETE FROM key_values WHERE k = ?", [$key]);
// 生成唯一 ID基于 key 的 hash
$id = abs(crc32($key));
// 插入新记录
return $instance->execute(
"INSERT INTO key_values (id, k, v) VALUES (?, ?, ?)",
[$id, $key, (string)$value]
);
}
/**
* 删除值
*
* @param string $key 键
* @return bool 是否成功
*/
public static function delete(string $key): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
$instance = new ManticoreBase();
return $instance->execute("DELETE FROM key_values WHERE k = ?", [$key]);
}
/**
* 清空所有键值
*
* @return bool 是否成功
*/
public static function clear(): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
$instance = new ManticoreBase();
return $instance->execute("TRUNCATE TABLE key_values");
}
/**
* 检查键是否存在
*
* @param string $key 键
* @return bool 是否存在
*/
public static function exists(string $key): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
$instance = new ManticoreBase();
$result = $instance->queryOne(
"SELECT id FROM key_values WHERE k = ?",
[$key]
);
return $result !== null;
}
/**
* 获取所有键值对
*
* @return array 键值对数组
*/
public static function all(): array
{
if (!Apps::isInstalled("search")) {
return [];
}
$instance = new ManticoreBase();
$results = $instance->query("SELECT k, v FROM key_values");
$data = [];
foreach ($results as $row) {
$data[$row['k']] = $row['v'];
}
return $data;
}
}

View File

@@ -0,0 +1,561 @@
<?php
namespace App\Module\Manticore;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Carbon\Carbon;
use DB;
use Illuminate\Support\Facades\Log;
/**
* Manticore Search 消息搜索类
*
* 使用方法:
*
* 1. 搜索方法
* - 搜索消息: search($userid, $keyword, $searchType, $from, $size);
*
* 2. 同步方法
* - 单个同步: sync(WebSocketDialogMsg $msg);
* - 批量同步: batchSync($msgs);
* - 删除索引: delete($msgId);
*
* 3. 权限更新方法
* - 更新对话权限: updateDialogAllowedUsers($dialogId);
*
* 4. 工具方法
* - 清空索引: clear();
* - 判断是否索引: shouldIndex($msg);
*/
class ManticoreMsg
{
/**
* 可索引的消息类型
*/
public const INDEXABLE_TYPES = ['text', 'file', 'record', 'meeting', 'vote'];
/**
* 最大内容长度(字符)
*/
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
/**
* 判断消息是否应该被索引
*
* @param WebSocketDialogMsg $msg 消息模型
* @return bool 是否应该索引
*/
public static function shouldIndex(WebSocketDialogMsg $msg): bool
{
// 1. 排除机器人消息
if ($msg->bot === 1) {
return false;
}
// 2. 检查消息类型
if (!in_array($msg->type, self::INDEXABLE_TYPES)) {
return false;
}
// 3. 排除 key 为空的消息
if (empty($msg->key)) {
return false;
}
return true;
}
/**
* 搜索消息(支持全文、向量、混合搜索)
*
* @param int $userid 用户ID
* @param string $keyword 搜索关键词
* @param string $searchType 搜索类型: text/vector/hybrid
* @param int $from 起始位置
* @param int $size 返回数量
* @param int $dialogId 对话ID0表示不限制
* @return array 搜索结果
*/
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $from = 0, int $size = 20, int $dialogId = 0): array
{
if (empty($keyword)) {
return [];
}
if (!Apps::isInstalled("search")) {
return [];
}
try {
switch ($searchType) {
case 'text':
// 纯全文搜索
return self::formatSearchResults(
ManticoreBase::msgFullTextSearch($keyword, $userid, $size, $from, $dialogId)
);
case 'vector':
// 纯向量搜索(需要先获取 embedding
$embedding = ManticoreBase::getEmbedding($keyword);
if (empty($embedding)) {
// embedding 获取失败,降级到全文搜索
return self::formatSearchResults(
ManticoreBase::msgFullTextSearch($keyword, $userid, $size, $from, $dialogId)
);
}
return self::formatSearchResults(
ManticoreBase::msgVectorSearch($embedding, $userid, $size, $dialogId)
);
case 'hybrid':
default:
// 混合搜索
$embedding = ManticoreBase::getEmbedding($keyword);
return self::formatSearchResults(
ManticoreBase::msgHybridSearch($keyword, $embedding, $userid, $size, $dialogId)
);
}
} catch (\Exception $e) {
Log::error('Manticore msg search error: ' . $e->getMessage());
return [];
}
}
/**
* 格式化搜索结果
*
* @param array $results Manticore 返回的结果
* @return array 格式化后的结果
*/
private static function formatSearchResults(array $results): array
{
$formatted = [];
foreach ($results as $item) {
$formatted[] = [
'id' => $item['msg_id'],
'msg_id' => $item['msg_id'],
'dialog_id' => $item['dialog_id'],
'userid' => $item['userid'],
'msg_type' => $item['msg_type'],
'content_preview' => isset($item['content']) ? mb_substr($item['content'], 0, 200) : null,
'created_at' => $item['created_at'] ?? null,
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
];
}
return $formatted;
}
/**
* 按对话搜索消息(用于对话列表搜索)
*
* 返回包含匹配消息的对话列表,每个对话只返回一次
* 当 Manticore 未安装时,回退到 MySQL LIKE 搜索
*
* @param int $userid 用户ID
* @param string $keyword 搜索关键词
* @param int $from 起始位置
* @param int $size 返回数量
* @return array 对话列表
*/
public static function searchDialogs(int $userid, string $keyword, int $from = 0, int $size = 20): array
{
if (empty($keyword)) {
return [];
}
// 未安装 Manticore 时使用 MySQL 回退搜索
if (!Apps::isInstalled("search")) {
return self::searchDialogsByMysql($userid, $keyword, $from, $size);
}
try {
// 使用全文搜索获取更多结果,然后按对话分组
$results = ManticoreBase::msgFullTextSearch($keyword, $userid, 100, 0);
if (empty($results)) {
return [];
}
// 收集所有对话ID
$dialogIds = array_unique(array_column($results, 'dialog_id'));
// 获取用户在这些对话中的信息
$dialogUsers = WebSocketDialogUser::where('userid', $userid)
->whereIn('dialog_id', $dialogIds)
->get()
->keyBy('dialog_id');
// 按对话分组,每个对话只保留最相关的消息
$msgs = [];
$seenDialogs = [];
foreach ($results as $item) {
$dialogId = $item['dialog_id'];
// 每个对话只取第一条(最相关的)
if (isset($seenDialogs[$dialogId])) {
continue;
}
$seenDialogs[$dialogId] = true;
// 获取用户在该对话的信息
$dialogUser = $dialogUsers->get($dialogId);
if (!$dialogUser) {
continue;
}
$msgs[] = [
'id' => $dialogId,
'search_msg_id' => $item['msg_id'],
'user_at' => $dialogUser->updated_at ? Carbon::parse($dialogUser->updated_at)->format('Y-m-d H:i:s') : null,
'mark_unread' => $dialogUser->mark_unread,
'silence' => $dialogUser->silence,
'hide' => $dialogUser->hide,
'color' => $dialogUser->color,
'top_at' => $dialogUser->top_at ? Carbon::parse($dialogUser->top_at)->format('Y-m-d H:i:s') : null,
'last_at' => $dialogUser->last_at ? Carbon::parse($dialogUser->last_at)->format('Y-m-d H:i:s') : null,
];
// 已达到需要的数量
if (count($msgs) >= $from + $size) {
break;
}
}
// 应用分页
return array_slice($msgs, $from, $size);
} catch (\Exception $e) {
Log::error('Manticore searchDialogs error: ' . $e->getMessage());
// 出错时回退到 MySQL 搜索
return self::searchDialogsByMysql($userid, $keyword, $from, $size);
}
}
/**
* MySQL 回退搜索(按对话搜索消息)
*
* 通过联表查询获取用户有权限的对话中匹配的消息
*
* @param int $userid 用户ID
* @param string $keyword 搜索关键词
* @param int $from 起始位置
* @param int $size 返回数量
* @return array 对话列表
*/
private static function searchDialogsByMysql(int $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;
}
// ==============================
// 权限计算方法
// ==============================
/**
* 获取消息的 allowed_users 列表
*
* 对话的所有成员都有权限查看该对话的消息
*
* @param WebSocketDialogMsg $msg 消息模型
* @return array 有权限的用户ID数组
*/
public static function getAllowedUsers(WebSocketDialogMsg $msg): array
{
return self::getDialogUserIds($msg->dialog_id);
}
/**
* 获取对话的所有成员ID
*
* @param int $dialogId 对话ID
* @return array 成员用户ID数组
*/
public static function getDialogUserIds(int $dialogId): array
{
if ($dialogId <= 0) {
return [];
}
return WebSocketDialogUser::where('dialog_id', $dialogId)
->pluck('userid')
->toArray();
}
// ==============================
// 同步方法
// ==============================
/**
* 同步单个消息到 Manticore含 allowed_users
*
* @param WebSocketDialogMsg $msg 消息模型
* @param bool $withVector 是否同时生成向量(默认 false向量由后台任务生成
* @return bool 是否成功
*/
public static function sync(WebSocketDialogMsg $msg, bool $withVector = false): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
// 检查是否应该索引
if (!self::shouldIndex($msg)) {
// 不符合索引条件,尝试删除已存在的索引
return ManticoreBase::deleteMsgVector($msg->id);
}
try {
// 提取消息内容(使用 key 字段)
$content = $msg->key ?? '';
// 限制内容长度
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($content) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($content);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 获取消息的 allowed_users
$allowedUsers = self::getAllowedUsers($msg);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertMsgVector([
'msg_id' => $msg->id,
'dialog_id' => $msg->dialog_id,
'userid' => $msg->userid,
'msg_type' => $msg->type,
'content' => $content,
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
]);
return $result;
} catch (\Exception $e) {
Log::error('Manticore msg sync error: ' . $e->getMessage(), [
'msg_id' => $msg->id,
'dialog_id' => $msg->dialog_id,
]);
return false;
}
}
/**
* 批量同步消息
*
* @param iterable $msgs 消息列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $msgs, bool $withVector = false): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
foreach ($msgs as $msg) {
if (self::sync($msg, $withVector)) {
$count++;
}
}
return $count;
}
/**
* 批量生成向量(供后台任务调用)
*
* @param array $msgIds 消息ID数组
* @param int $batchSize 每批 embedding 数量
* @return int 成功生成向量的数量
*/
public static function generateVectorsBatch(array $msgIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled('ai') || empty($msgIds)) {
return 0;
}
$count = 0;
// 分批处理
foreach (array_chunk($msgIds, $batchSize) as $batchIds) {
// 获取消息
$msgs = WebSocketDialogMsg::whereIn('id', $batchIds)
->whereIn('type', self::INDEXABLE_TYPES)
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->get()
->keyBy('id');
if ($msgs->isEmpty()) {
continue;
}
// 准备文本
$texts = [];
$idsArray = [];
foreach ($batchIds as $id) {
if (isset($msgs[$id])) {
$content = mb_substr($msgs[$id]->key ?? '', 0, self::MAX_CONTENT_LENGTH);
if (!empty($content)) {
$texts[] = $content;
$idsArray[] = $id;
}
}
}
if (empty($texts)) {
continue;
}
// 批量获取 embeddings
$result = AI::getBatchEmbeddings($texts);
if (Base::isError($result)) {
continue;
}
$embeddings = $result['data'] ?? [];
// 构建批量更新数据 [msg_id => vectorStr]
$vectorData = [];
foreach ($embeddings as $index => $embedding) {
if (empty($embedding) || !is_array($embedding)) {
continue;
}
$msgId = $idsArray[$index] ?? null;
if (!$msgId) {
continue;
}
$vectorData[$msgId] = '[' . implode(',', $embedding) . ']';
}
// 批量更新向量(优化:减少数据库操作次数)
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateMsgVectors($vectorData);
$count += $batchCount;
}
}
return $count;
}
/**
* 删除消息索引
*
* @param int $msgId 消息ID
* @return bool 是否成功
*/
public static function delete(int $msgId): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::deleteMsgVector($msgId);
}
/**
* 清空所有索引
*
* @return bool 是否成功
*/
public static function clear(): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::clearAllMsgVectors();
}
/**
* 获取已索引消息数量
*
* @return int 数量
*/
public static function getIndexedCount(): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
return ManticoreBase::getIndexedMsgCount();
}
// ==============================
// 权限更新方法
// ==============================
/**
* 更新对话下所有消息的 allowed_users 权限列表
* 从 MySQL 获取最新的对话成员并更新到 Manticore
*
* @param int $dialogId 对话ID
* @return int 更新的消息数量
*/
public static function updateDialogAllowedUsers(int $dialogId): int
{
if (!Apps::isInstalled("search") || $dialogId <= 0) {
return 0;
}
try {
$userids = self::getDialogUserIds($dialogId);
return ManticoreBase::updateDialogAllowedUsers($dialogId, $userids);
} catch (\Exception $e) {
Log::error('Manticore updateDialogAllowedUsers error: ' . $e->getMessage(), ['dialog_id' => $dialogId]);
return 0;
}
}
}

View File

@@ -0,0 +1,369 @@
<?php
namespace App\Module\Manticore;
use App\Models\Project;
use App\Models\ProjectUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
* Manticore Search 项目搜索类
*
* 使用方法:
*
* 1. 搜索方法
* - 搜索项目: search($userid, $keyword, $searchType, $limit);
*
* 2. 同步方法
* - 单个同步: sync(Project $project);
* - 批量同步: batchSync($projects);
* - 删除索引: delete($projectId);
*
* 3. 权限更新方法
* - 更新权限: updateAllowedUsers($projectId);
*
* 4. 工具方法
* - 清空索引: clear();
*/
class ManticoreProject
{
/**
* 搜索项目(支持全文、向量、混合搜索)
*
* @param int $userid 用户ID权限过滤
* @param string $keyword 搜索关键词
* @param string $searchType 搜索类型: text/vector/hybrid
* @param int $limit 返回数量
* @return array 搜索结果
*/
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $limit = 20): array
{
if (empty($keyword)) {
return [];
}
if (!Apps::isInstalled("search")) {
return [];
}
try {
switch ($searchType) {
case 'text':
return self::formatSearchResults(
ManticoreBase::projectFullTextSearch($keyword, $userid, $limit, 0)
);
case 'vector':
$embedding = ManticoreBase::getEmbedding($keyword);
if (empty($embedding)) {
return self::formatSearchResults(
ManticoreBase::projectFullTextSearch($keyword, $userid, $limit, 0)
);
}
return self::formatSearchResults(
ManticoreBase::projectVectorSearch($embedding, $userid, $limit)
);
case 'hybrid':
default:
$embedding = ManticoreBase::getEmbedding($keyword);
return self::formatSearchResults(
ManticoreBase::projectHybridSearch($keyword, $embedding, $userid, $limit)
);
}
} catch (\Exception $e) {
Log::error('Manticore project search error: ' . $e->getMessage());
return [];
}
}
/**
* 格式化搜索结果
*
* @param array $results Manticore 返回的结果
* @return array 格式化后的结果
*/
private static function formatSearchResults(array $results): array
{
$formatted = [];
foreach ($results as $item) {
$formatted[] = [
'project_id' => $item['project_id'],
'id' => $item['project_id'],
'userid' => $item['userid'],
'personal' => $item['personal'],
'name' => $item['project_name'],
'desc_preview' => isset($item['project_desc']) ? mb_substr($item['project_desc'], 0, 300) : null,
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
];
}
return $formatted;
}
// ==============================
// 同步方法
// ==============================
/**
* 获取项目的 allowed_users 列表
*
* @param int $projectId 项目ID
* @return array 有权限的用户ID数组
*/
public static function getAllowedUsers(int $projectId): array
{
return ProjectUser::where('project_id', $projectId)
->pluck('userid')
->toArray();
}
/**
* 同步单个项目到 Manticore含 allowed_users
*
* @param Project $project 项目模型
* @param bool $withVector 是否同时生成向量(默认 false向量由后台任务生成
* @return bool 是否成功
*/
public static function sync(Project $project, bool $withVector = false): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
// 已归档的项目不索引
if ($project->archived_at) {
return self::delete($project->id);
}
try {
// 构建用于搜索的文本内容
$searchableContent = self::buildSearchableContent($project);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 获取项目成员列表(作为 allowed_users
$allowedUsers = self::getAllowedUsers($project->id);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertProjectVector([
'project_id' => $project->id,
'userid' => $project->userid ?? 0,
'personal' => $project->personal ?? 0,
'project_name' => $project->name ?? '',
'project_desc' => $project->desc ?? '',
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
]);
return $result;
} catch (\Exception $e) {
Log::error('Manticore project sync error: ' . $e->getMessage(), [
'project_id' => $project->id,
'project_name' => $project->name,
]);
return false;
}
}
/**
* 构建可搜索的文本内容
*
* @param Project $project 项目模型
* @return string 可搜索的文本
*/
private static function buildSearchableContent(Project $project): string
{
$parts = [];
if (!empty($project->name)) {
$parts[] = $project->name;
}
if (!empty($project->desc)) {
$parts[] = $project->desc;
}
return implode(' ', $parts);
}
/**
* 批量同步项目
*
* @param iterable $projects 项目列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $projects, bool $withVector = false): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
foreach ($projects as $project) {
if (self::sync($project, $withVector)) {
$count++;
}
}
return $count;
}
/**
* 删除项目索引
*
* @param int $projectId 项目ID
* @return bool 是否成功
*/
public static function delete(int $projectId): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::deleteProjectVector($projectId);
}
/**
* 清空所有索引
*
* @return bool 是否成功
*/
public static function clear(): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::clearAllProjectVectors();
}
/**
* 获取已索引项目数量
*
* @return int 数量
*/
public static function getIndexedCount(): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
return ManticoreBase::getIndexedProjectCount();
}
// ==============================
// 权限更新方法
// ==============================
/**
* 更新项目的 allowed_users 权限列表
* 从 MySQL 获取最新的项目成员并更新到 Manticore
*
* @param int $projectId 项目ID
* @return bool 是否成功
*/
public static function updateAllowedUsers(int $projectId): bool
{
if (!Apps::isInstalled("search") || $projectId <= 0) {
return false;
}
try {
$userids = self::getAllowedUsers($projectId);
return ManticoreBase::updateProjectAllowedUsers($projectId, $userids);
} catch (\Exception $e) {
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['project_id' => $projectId]);
return false;
}
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成项目向量
* 用于后台异步处理,将已索引项目的向量批量生成
*
* @param array $projectIds 项目ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $projectIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($projectIds)) {
return 0;
}
try {
// 1. 查询项目信息
$projects = Project::whereIn('id', $projectIds)
->whereNull('archived_at')
->get();
if ($projects->isEmpty()) {
return 0;
}
// 2. 提取每个项目的内容
$projectContents = [];
foreach ($projects as $project) {
$searchableContent = self::buildSearchableContent($project);
if (!empty($searchableContent)) {
$projectContents[$project->id] = $searchableContent;
}
}
if (empty($projectContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($projectContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $projectId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$projectId] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateProjectVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreProject generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@@ -0,0 +1,593 @@
<?php
namespace App\Module\Manticore;
use App\Models\ProjectTask;
use App\Models\ProjectTaskContent;
use App\Models\ProjectTaskUser;
use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
* Manticore Search 任务搜索类
*
* 权限逻辑说明:
* - visibility = 1: 项目人员可见,通过项目成员计算 allowed_users
* - visibility = 2: 任务人员可见,通过任务成员计算 allowed_users
* - visibility = 3: 指定成员可见,通过任务成员 + 可见性成员计算 allowed_users
* - 子任务继承父任务的 allowed_users
*
* 使用方法:
*
* 1. 搜索方法
* - 搜索任务: search($userid, $keyword, $searchType, $limit);
*
* 2. 同步方法
* - 单个同步: sync(ProjectTask $task);
* - 批量同步: batchSync($tasks);
* - 删除索引: delete($taskId);
*
* 3. 权限更新方法
* - 更新权限: updateAllowedUsers($taskId);
* - 项目成员变更级联更新: cascadeUpdateByProject($projectId);
* - 父任务变更级联到子任务: cascadeToChildren($taskId);
*
* 4. 工具方法
* - 清空索引: clear();
*/
class ManticoreTask
{
/**
* 最大内容长度(字符)
*/
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
/**
* 搜索任务(支持全文、向量、混合搜索)
*
* @param int $userid 用户ID权限过滤
* @param string $keyword 搜索关键词
* @param string $searchType 搜索类型: text/vector/hybrid
* @param int $limit 返回数量
* @return array 搜索结果
*/
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $limit = 20): array
{
if (empty($keyword)) {
return [];
}
if (!Apps::isInstalled("search")) {
return [];
}
try {
switch ($searchType) {
case 'text':
return self::formatSearchResults(
ManticoreBase::taskFullTextSearch($keyword, $userid, $limit, 0)
);
case 'vector':
$embedding = ManticoreBase::getEmbedding($keyword);
if (empty($embedding)) {
return self::formatSearchResults(
ManticoreBase::taskFullTextSearch($keyword, $userid, $limit, 0)
);
}
return self::formatSearchResults(
ManticoreBase::taskVectorSearch($embedding, $userid, $limit)
);
case 'hybrid':
default:
$embedding = ManticoreBase::getEmbedding($keyword);
return self::formatSearchResults(
ManticoreBase::taskHybridSearch($keyword, $embedding, $userid, $limit)
);
}
} catch (\Exception $e) {
Log::error('Manticore task search error: ' . $e->getMessage());
return [];
}
}
/**
* 格式化搜索结果
*
* @param array $results Manticore 返回的结果
* @return array 格式化后的结果
*/
private static function formatSearchResults(array $results): array
{
$formatted = [];
foreach ($results as $item) {
$formatted[] = [
'task_id' => $item['task_id'],
'id' => $item['task_id'],
'project_id' => $item['project_id'],
'userid' => $item['userid'],
'visibility' => $item['visibility'],
'name' => $item['task_name'],
'desc_preview' => isset($item['task_desc']) ? mb_substr($item['task_desc'], 0, 300) : null,
'content_preview' => isset($item['task_content']) ? mb_substr($item['task_content'], 0, 500) : null,
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
];
}
return $formatted;
}
// ==============================
// 权限计算方法
// ==============================
/**
* 获取任务的 allowed_users 列表
*
* 根据 visibility 计算有权限查看此任务的用户列表:
* - visibility=1: 项目成员
* - visibility=2: 任务成员(负责人/协作人)
* - visibility=3: 任务成员 + 可见性指定成员
* - 子任务: 还需要继承父任务的成员
*
* @param ProjectTask $task 任务模型
* @param int $depth 递归深度(防止无限递归)
* @param array $visited 已访问的任务ID防止循环引用
* @return array 有权限的用户ID数组
*/
public static function getAllowedUsers(ProjectTask $task, int $depth = 0, array $visited = []): array
{
// 防止无限递归深度超过10层或循环引用
if ($depth > 10 || in_array($task->id, $visited)) {
return [];
}
$visited[] = $task->id;
$userids = [];
// 1. 根据 visibility 获取基础成员
if ($task->visibility == 1) {
// visibility=1: 项目成员
$userids = ProjectUser::where('project_id', $task->project_id)
->pluck('userid')
->toArray();
} else {
// visibility=2,3: 任务成员(负责人/协作人)
$userids = ProjectTaskUser::where('task_id', $task->id)
->orWhere('task_pid', $task->id)
->pluck('userid')
->toArray();
// visibility=3: 加上可见性指定成员
if ($task->visibility == 3) {
$visUsers = ProjectTaskVisibilityUser::where('task_id', $task->id)
->pluck('userid')
->toArray();
$userids = array_merge($userids, $visUsers);
}
}
// 2. 如果是子任务,继承父任务成员
if ($task->parent_id > 0) {
$parentTask = ProjectTask::find($task->parent_id);
if ($parentTask) {
$parentUsers = self::getAllowedUsers($parentTask, $depth + 1, $visited);
$userids = array_merge($userids, $parentUsers);
}
}
return array_unique($userids);
}
// ==============================
// 同步方法
// ==============================
/**
* 同步单个任务到 Manticore含 allowed_users
*
* @param ProjectTask $task 任务模型
* @param bool $withVector 是否同时生成向量(默认 false向量由后台任务生成
* @return bool 是否成功
*/
public static function sync(ProjectTask $task, bool $withVector = false): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
// 已归档或已删除的任务不索引
if ($task->archived_at || $task->deleted_at) {
return self::delete($task->id);
}
try {
// 获取任务详细内容
$taskContent = self::getTaskContent($task);
// 构建用于搜索的文本内容
$searchableContent = self::buildSearchableContent($task, $taskContent);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 获取任务的 allowed_users
$allowedUsers = self::getAllowedUsers($task);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertTaskVector([
'task_id' => $task->id,
'project_id' => $task->project_id ?? 0,
'userid' => $task->userid ?? 0,
'visibility' => $task->visibility ?? 1,
'task_name' => $task->name ?? '',
'task_desc' => $task->desc ?? '',
'task_content' => $taskContent,
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
]);
return $result;
} catch (\Exception $e) {
Log::error('Manticore task sync error: ' . $e->getMessage(), [
'task_id' => $task->id,
'task_name' => $task->name,
]);
return false;
}
}
/**
* 获取任务详细内容
*
* @param ProjectTask $task 任务模型
* @return string 任务内容
*/
private static function getTaskContent(ProjectTask $task): string
{
try {
$content = ProjectTaskContent::where('task_id', $task->id)->first();
if (!$content) {
return '';
}
// 解析内容
$contentData = Base::json2array($content->content);
$text = '';
// 提取文本内容(内容可能是 blocks 格式)
if (is_array($contentData)) {
$text = self::extractTextFromContent($contentData);
} elseif (is_string($contentData)) {
$text = $contentData;
}
// 限制内容长度
return mb_substr($text, 0, self::MAX_CONTENT_LENGTH);
} catch (\Exception $e) {
return '';
}
}
/**
* 从内容数组中提取文本
*
* @param array $contentData 内容数据
* @return string 提取的文本
*/
private static function extractTextFromContent(array $contentData): string
{
$texts = [];
// 处理 blocks 格式
if (isset($contentData['blocks']) && is_array($contentData['blocks'])) {
foreach ($contentData['blocks'] as $block) {
if (isset($block['text'])) {
$texts[] = $block['text'];
}
if (isset($block['data']['text'])) {
$texts[] = $block['data']['text'];
}
}
}
// 处理其他格式
if (isset($contentData['text'])) {
$texts[] = $contentData['text'];
}
return implode(' ', $texts);
}
/**
* 构建可搜索的文本内容
*
* @param ProjectTask $task 任务模型
* @param string $taskContent 任务详细内容
* @return string 可搜索的文本
*/
private static function buildSearchableContent(ProjectTask $task, string $taskContent): string
{
$parts = [];
if (!empty($task->name)) {
$parts[] = $task->name;
}
if (!empty($task->desc)) {
$parts[] = $task->desc;
}
if (!empty($taskContent)) {
$parts[] = $taskContent;
}
return implode(' ', $parts);
}
/**
* 批量同步任务
*
* @param iterable $tasks 任务列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $tasks, bool $withVector = false): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
foreach ($tasks as $task) {
if (self::sync($task, $withVector)) {
$count++;
}
}
return $count;
}
/**
* 删除任务索引
*
* @param int $taskId 任务ID
* @return bool 是否成功
*/
public static function delete(int $taskId): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::deleteTaskVector($taskId);
}
/**
* 清空所有索引
*
* @return bool 是否成功
*/
public static function clear(): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::clearAllTaskVectors();
}
/**
* 获取已索引任务数量
*
* @return int 数量
*/
public static function getIndexedCount(): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
return ManticoreBase::getIndexedTaskCount();
}
// ==============================
// 权限更新方法
// ==============================
/**
* 更新任务的 allowed_users 权限列表
* 重新计算并更新 Manticore 中的权限
*
* @param int $taskId 任务ID
* @return bool 是否成功
*/
public static function updateAllowedUsers(int $taskId): bool
{
if (!Apps::isInstalled("search") || $taskId <= 0) {
return false;
}
try {
$task = ProjectTask::find($taskId);
if (!$task) {
return false;
}
$userids = self::getAllowedUsers($task);
return ManticoreBase::updateTaskAllowedUsers($taskId, $userids);
} catch (\Exception $e) {
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['task_id' => $taskId]);
return false;
}
}
/**
* 级联更新项目下所有 visibility=1 任务的 allowed_users
* 当项目成员变更时调用
*
* @param int $projectId 项目ID
* @return int 更新的任务数量
*/
public static function cascadeUpdateByProject(int $projectId): int
{
if (!Apps::isInstalled("search") || $projectId <= 0) {
return 0;
}
try {
// 获取项目成员
$projectUsers = ProjectUser::where('project_id', $projectId)
->pluck('userid')
->toArray();
// 分批更新该项目下所有 visibility=1 的任务
$count = 0;
ProjectTask::where('project_id', $projectId)
->where('visibility', 1)
->whereNull('deleted_at')
->whereNull('archived_at')
->chunk(100, function ($tasks) use ($projectUsers, &$count) {
foreach ($tasks as $task) {
// 对于子任务,需要合并父任务成员
$allowedUsers = $projectUsers;
if ($task->parent_id > 0) {
$parentTask = ProjectTask::find($task->parent_id);
if ($parentTask) {
$parentUsers = self::getAllowedUsers($parentTask);
$allowedUsers = array_unique(array_merge($allowedUsers, $parentUsers));
}
}
ManticoreBase::updateTaskAllowedUsers($task->id, $allowedUsers);
$count++;
}
});
return $count;
} catch (\Exception $e) {
Log::error('Manticore cascadeUpdateByProject error: ' . $e->getMessage(), ['project_id' => $projectId]);
return 0;
}
}
/**
* 级联更新所有子任务的 allowed_users
* 当父任务的成员变更时调用
*
* @param int $taskId 父任务ID
* @return void
*/
public static function cascadeToChildren(int $taskId): void
{
if (!Apps::isInstalled("search") || $taskId <= 0) {
return;
}
try {
ProjectTask::where('parent_id', $taskId)
->whereNull('deleted_at')
->whereNull('archived_at')
->each(function ($child) {
$allowedUsers = self::getAllowedUsers($child);
ManticoreBase::updateTaskAllowedUsers($child->id, $allowedUsers);
// 递归处理子任务的子任务
self::cascadeToChildren($child->id);
});
} catch (\Exception $e) {
Log::error('Manticore cascadeToChildren error: ' . $e->getMessage(), ['task_id' => $taskId]);
}
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成任务向量
* 用于后台异步处理,将已索引任务的向量批量生成
*
* @param array $taskIds 任务ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $taskIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($taskIds)) {
return 0;
}
try {
// 1. 查询任务信息
$tasks = ProjectTask::whereIn('id', $taskIds)
->whereNull('deleted_at')
->whereNull('archived_at')
->get();
if ($tasks->isEmpty()) {
return 0;
}
// 2. 提取每个任务的内容
$taskContents = [];
foreach ($tasks as $task) {
$taskContent = self::getTaskContent($task);
$searchableContent = self::buildSearchableContent($task, $taskContent);
if (!empty($searchableContent)) {
// 限制内容长度
$searchableContent = mb_substr($searchableContent, 0, self::MAX_CONTENT_LENGTH);
$taskContents[$task->id] = $searchableContent;
}
}
if (empty($taskContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($taskContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $taskId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$taskId] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateTaskVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreTask generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@@ -0,0 +1,362 @@
<?php
namespace App\Module\Manticore;
use App\Models\User;
use App\Models\UserTag;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
* Manticore Search 用户搜索类(联系人搜索)
*
* 使用方法:
*
* 1. 搜索方法
* - 搜索用户: search($keyword, $searchType, $limit);
*
* 2. 同步方法
* - 单个同步: sync(User $user);
* - 批量同步: batchSync($users);
* - 删除索引: delete($userid);
*
* 3. 工具方法
* - 清空索引: clear();
*/
class ManticoreUser
{
/**
* 搜索用户(支持全文、向量、混合搜索)
*
* @param string $keyword 搜索关键词
* @param string $searchType 搜索类型: text/vector/hybrid
* @param int $limit 返回数量
* @return array 搜索结果
*/
public static function search(string $keyword, string $searchType = 'hybrid', int $limit = 20): array
{
if (empty($keyword)) {
return [];
}
if (!Apps::isInstalled("search")) {
return [];
}
try {
switch ($searchType) {
case 'text':
return self::formatSearchResults(
ManticoreBase::userFullTextSearch($keyword, $limit, 0)
);
case 'vector':
$embedding = ManticoreBase::getEmbedding($keyword);
if (empty($embedding)) {
return self::formatSearchResults(
ManticoreBase::userFullTextSearch($keyword, $limit, 0)
);
}
return self::formatSearchResults(
ManticoreBase::userVectorSearch($embedding, $limit)
);
case 'hybrid':
default:
$embedding = ManticoreBase::getEmbedding($keyword);
return self::formatSearchResults(
ManticoreBase::userHybridSearch($keyword, $embedding, $limit)
);
}
} catch (\Exception $e) {
Log::error('Manticore user search error: ' . $e->getMessage());
return [];
}
}
/**
* 格式化搜索结果
*
* @param array $results Manticore 返回的结果
* @return array 格式化后的结果
*/
private static function formatSearchResults(array $results): array
{
$formatted = [];
foreach ($results as $item) {
$formatted[] = [
'userid' => $item['userid'],
'nickname' => $item['nickname'],
'email' => $item['email'],
'profession' => $item['profession'],
'tags' => $item['tags'] ?? '',
'introduction_preview' => isset($item['introduction']) ? mb_substr($item['introduction'], 0, 200) : null,
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
];
}
return $formatted;
}
// ==============================
// 同步方法
// ==============================
/**
* 获取用户的标签按认可数排序最多10个
*
* @param int $userid 用户ID
* @return string 标签名称,空格分隔
*/
public static function getUserTags(int $userid): string
{
$tags = UserTag::where('user_id', $userid)
->withCount('recognitions')
->orderByDesc('recognitions_count')
->limit(10)
->pluck('name')
->toArray();
return implode(' ', $tags);
}
/**
* 同步单个用户到 Manticore
*
* @param User $user 用户模型
* @param bool $withVector 是否同时生成向量(默认 false向量由后台任务生成
* @return bool 是否成功
*/
public static function sync(User $user, bool $withVector = false): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
// 不处理机器人账号
if ($user->bot) {
return true;
}
// 不处理已禁用的账号
if ($user->disable_at) {
return self::delete($user->userid);
}
try {
// 获取用户标签Top 10
$tags = self::getUserTags($user->userid);
// 构建用于搜索的文本内容
$searchableContent = self::buildSearchableContent($user, $tags);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 写入 Manticore
$result = ManticoreBase::upsertUserVector([
'userid' => $user->userid,
'nickname' => $user->nickname ?? '',
'email' => $user->email ?? '',
'profession' => $user->profession ?? '',
'tags' => $tags,
'introduction' => $user->introduction ?? '',
'content_vector' => $embedding,
]);
return $result;
} catch (\Exception $e) {
Log::error('Manticore user sync error: ' . $e->getMessage(), [
'userid' => $user->userid,
'nickname' => $user->nickname,
]);
return false;
}
}
/**
* 构建可搜索的文本内容
*
* @param User $user 用户模型
* @param string $tags 用户标签(空格分隔)
* @return string 可搜索的文本
*/
private static function buildSearchableContent(User $user, string $tags = ''): string
{
$parts = [];
if (!empty($user->nickname)) {
$parts[] = $user->nickname;
}
if (!empty($user->email)) {
$parts[] = $user->email;
}
if (!empty($user->profession)) {
$parts[] = $user->profession;
}
if (!empty($tags)) {
$parts[] = $tags;
}
if (!empty($user->introduction)) {
$parts[] = $user->introduction;
}
return implode(' ', $parts);
}
/**
* 批量同步用户
*
* @param iterable $users 用户列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $users, bool $withVector = false): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
foreach ($users as $user) {
if (self::sync($user, $withVector)) {
$count++;
}
}
return $count;
}
/**
* 删除用户索引
*
* @param int $userid 用户ID
* @return bool 是否成功
*/
public static function delete(int $userid): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::deleteUserVector($userid);
}
/**
* 清空所有索引
*
* @return bool 是否成功
*/
public static function clear(): bool
{
if (!Apps::isInstalled("search")) {
return false;
}
return ManticoreBase::clearAllUserVectors();
}
/**
* 获取已索引用户数量
*
* @return int 数量
*/
public static function getIndexedCount(): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
return ManticoreBase::getIndexedUserCount();
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成用户向量
* 用于后台异步处理,将已索引用户的向量批量生成
*
* @param array $userIds 用户ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $userIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($userIds)) {
return 0;
}
try {
// 1. 查询用户信息
$users = User::whereIn('userid', $userIds)
->where('bot', 0)
->whereNull('disable_at')
->get();
if ($users->isEmpty()) {
return 0;
}
// 2. 提取每个用户的内容(包含标签)
$userContents = [];
foreach ($users as $user) {
$tags = self::getUserTags($user->userid);
$searchableContent = self::buildSearchableContent($user, $tags);
if (!empty($searchableContent)) {
$userContents[$user->userid] = $searchableContent;
}
}
if (empty($userContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($userContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $userid) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$userid] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateUserVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreUser generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@@ -0,0 +1,300 @@
<?php
namespace App\Module;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\ProjectTaskUser;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\UserDepartment;
use App\Models\UserTag;
use App\Models\WebSocketDialog;
use Cache;
use Carbon\Carbon;
use DB;
/**
* AI 提示词模块
*
* 提供用户上下文和条件性提示块的构建能力
*/
class PromptPlaceholder
{
/**
* 构建条件性提示块(用户上下文 + 格式指南)
*
* @param int|null $userid
* @param WebSocketDialog|null $dialog
* @return string
*/
public static function buildOptionalPrompts($userid, ?WebSocketDialog $dialog = null): string
{
$blocks = [];
// 用户上下文块
if ($userid && $userid > 0) {
$userContext = self::buildUserContext($userid, $dialog);
if ($userContext) {
$blocks[] = <<<EOF
<optional-user-context>
以下是当前对话用户的背景信息,当需要了解用户身份、工作职责或任务情况时可参考:
{$userContext}
注意:此上下文仅供参考,用于理解用户背景和提供个性化帮助。如果与当前对话无关,请忽略。
</optional-user-context>
EOF;
}
}
// 格式指南块
$blocks[] = <<<'EOF'
<optional-format-guide>
当你的回答中包含 DooTask 系统资源(任务、项目、文件等)时,建议使用以下链接格式使其可点击:
- 任务: [任务名称](dootask://task/{task_id}/{parent_id}),其中 parent_id 为主任务ID主任务时为 0
- 项目: [项目名称](dootask://project/{project_id})
- 文件: [文件名称](dootask://file/{file_id})
- 联系人: [用户名](dootask://contact/{userid})
- 消息: [消息预览](dootask://message/{dialog_id}/{msg_id})
注意:此格式指南不影响正常对话,仅在涉及上述资源时参考。如果与当前对话无关,请忽略。
</optional-format-guide>
EOF;
return implode("\n\n", $blocks);
}
/**
* 构建完整用户上下文
*/
private static function buildUserContext(int $userid, ?WebSocketDialog $dialog = null): string
{
$lines = [];
// 基础信息
$basicInfo = self::getUserBasicInfo($userid);
$nickname = $basicInfo['nickname'] ?? '';
if ($nickname) {
$basicLine = "与您对话的用户:{$nickname}";
if ($basicInfo['profession'] ?? '') {
$basicLine .= "{$basicInfo['profession']}";
}
$lines[] = "{$basicLine}user_id: {$userid}";
}
if ($basicInfo['department'] ?? '') {
$lines[] = "所属部门:{$basicInfo['department']}";
}
if ($basicInfo['introduction'] ?? '') {
$lines[] = "个人简介:{$basicInfo['introduction']}";
}
// 同事印象
$tags = self::getUserTags($userid);
if ($tags) {
$lines[] = "同事印象:{$tags}";
}
// 场景角色
if ($dialog) {
$role = self::getUserRole($userid, $dialog);
if ($role) {
$lines[] = $role;
}
}
// 进行中任务
$inProgressTasks = self::getInProgressTasks($userid);
if ($inProgressTasks) {
$lines[] = "\n进行中的任务:\n{$inProgressTasks}";
}
// 最近完成
$completedTasks = self::getCompletedTasks($userid);
if ($completedTasks) {
$lines[] = "\n最近完成:\n{$completedTasks}";
}
return implode("\n", $lines);
}
/**
* 获取用户基础信息
*/
private static function getUserBasicInfo(int $userid): array
{
$user = User::find($userid);
if (!$user) {
return [];
}
return [
'nickname' => $user->nickname ?: '',
'profession' => $user->profession ?: '',
'introduction' => $user->introduction ? mb_substr($user->introduction, 0, 100) : '',
'department' => $user->getDepartmentName() ?: '',
];
}
/**
* 获取用户标签 Top 5
*/
private static function getUserTags(int $userid): string
{
$tags = UserTag::where('user_id', $userid)
->withCount(['recognitions as recognition_total'])
->orderByDesc('recognition_total')
->orderBy('id')
->take(5)
->pluck('name')
->toArray();
return implode('、', $tags);
}
/**
* 获取用户在场景中的角色
*/
private static function getUserRole(int $userid, WebSocketDialog $dialog): string
{
if ($dialog->type !== 'group') {
return '';
}
switch ($dialog->group_type) {
case 'project':
$project = Project::whereDialogId($dialog->id)->first();
if ($project) {
$projectUser = ProjectUser::whereProjectId($project->id)->whereUserid($userid)->first();
if ($projectUser?->owner) {
return '该用户是此项目的负责人';
}
}
break;
case 'task':
$task = ProjectTask::whereDialogId($dialog->id)->first();
if ($task) {
$taskUser = ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->first();
if ($taskUser) {
return $taskUser->owner ? '该用户是此任务的负责人' : '该用户是此任务的协助人';
}
}
break;
case 'department':
$department = UserDepartment::whereDialogId($dialog->id)->first();
if ($department?->owner_userid === $userid) {
return '该用户是此部门的负责人';
}
break;
}
return '';
}
/**
* 获取进行中的任务(缓存 3 分钟)
*
* 排序策略:逾期优先 → 最近活跃优先 → 负责人优先 → 高优先级优先 → 截止时间近优先
*/
private static function getInProgressTasks(int $userid): string
{
$cacheKey = "prompt:tasks:in_progress:{$userid}";
return Cache::remember($cacheKey, 180, function () use ($userid) {
$now = Carbon::now();
$threeDaysAgo = $now->copy()->subDays(3);
// orderByRaw 中的表名需要带前缀
$prefix = DB::getTablePrefix();
$t = $prefix . 'project_tasks';
$du = $prefix . 'web_socket_dialog_users';
$tasks = ProjectTask::query()
->select([
'project_tasks.id',
'project_tasks.name',
'project_tasks.p_name',
'project_tasks.end_at',
'project_task_users.owner'
])
->join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
->leftJoin('web_socket_dialog_users', function ($join) use ($userid) {
$join->on('project_tasks.dialog_id', '=', 'web_socket_dialog_users.dialog_id')
->where('web_socket_dialog_users.userid', '=', $userid);
})
->where('project_task_users.userid', $userid)
->where('project_tasks.visibility', 1)
->whereNull('project_tasks.complete_at')
->whereNull('project_tasks.archived_at')
->whereNull('project_tasks.deleted_at')
->orderByRaw("CASE WHEN {$t}.end_at IS NOT NULL AND {$t}.end_at < ? THEN 0 ELSE 1 END", [$now])
->orderByRaw("CASE WHEN {$du}.last_at >= ? THEN 0 ELSE 1 END", [$threeDaysAgo])
->orderByDesc('web_socket_dialog_users.last_at')
->orderByDesc('project_task_users.owner')
->orderByDesc('project_tasks.p_level')
->orderByRaw("CASE WHEN {$t}.end_at IS NULL THEN 1 ELSE 0 END")
->orderBy('project_tasks.end_at')
->take(20)
->get();
return self::formatTaskList($tasks, $now);
});
}
/**
* 获取最近完成的任务(缓存 3 分钟)
*/
private static function getCompletedTasks(int $userid): string
{
$cacheKey = "prompt:tasks:completed:{$userid}";
return Cache::remember($cacheKey, 180, function () use ($userid) {
$tasks = ProjectTask::query()
->select([
'project_tasks.id',
'project_tasks.name'
])
->join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', $userid)
->where('project_tasks.visibility', 1)
->whereNotNull('project_tasks.complete_at')
->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(7))
->whereNull('project_tasks.deleted_at')
->orderByDesc('project_tasks.complete_at')
->take(5)
->get();
if ($tasks->isEmpty()) {
return '';
}
return $tasks->map(fn($task) => "- {$task->name} (task:{$task->id})")->implode("\n");
});
}
/**
* 格式化任务列表
*/
private static function formatTaskList($tasks, Carbon $now): string
{
if ($tasks->isEmpty()) {
return '';
}
return $tasks->map(function ($task) use ($now) {
$line = '- ';
if ($task->p_name) {
$line .= "[{$task->p_name}] ";
}
$line .= "{$task->name} (task_id:{$task->id})";
if ($task->end_at && Carbon::parse($task->end_at)->lt($now)) {
$line .= ' ⚠️逾期';
}
return $line;
})->implode("\n");
}
}

View File

@@ -233,11 +233,12 @@ class TextExtractor
/**
* 获取文件内容
* @param $filePath
* @param int $fileMaxSize 最大文件大小,单位字节默认1024KB
* @param int $contentMaxSize 最大内容大小,单位字节默认300KB
* @param int $fileMaxSize 最大文件大小,单位KB默认1024KB
* @param int $contentMaxSize 最大内容大小,单位KB默认300KB
* @param bool $truncate 超过contentMaxSize时是否截取默认true截取false返回错误
* @return array
*/
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300): array
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300, bool $truncate = true): array
{
if (!file_exists($filePath) || !is_file($filePath)) {
return Base::retError("Failed to read contents of {$filePath}");
@@ -248,8 +249,13 @@ class TextExtractor
try {
$extractor = new self($filePath);
$content = $extractor->extractContent();
if (strlen($content) > $contentMaxSize * 1024) {
return Base::retError("Content size exceeds " . Base::readableBytes($contentMaxSize * 1024) . ", unable to display content");
$maxBytes = $contentMaxSize * 1024;
if (strlen($content) > $maxBytes) {
if ($truncate) {
$content = mb_substr($content, 0, $maxBytes);
} else {
return Base::retError("Content size exceeds " . Base::readableBytes($maxBytes) . ", unable to display content");
}
}
return Base::retSuccess("success", $content);
} catch (Exception $e) {

View File

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

View File

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

View File

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

View File

@@ -3,17 +3,46 @@
namespace App\Observers;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Illuminate\Support\Facades\Cache;
class AbstractObserver
{
/**
* 任务去重窗口时间(秒)
* 同一个 action+id 在此时间内只投递一次
*/
private const DEDUP_WINDOW = 10;
/**
* 投递异步任务(带去重)
*
* @param $task
* @return void
*/
public static function taskDeliver($task)
{
if (app()->bound('swoole')) {
Task::deliver($task);
if (!app()->bound('swoole')) {
return;
}
// 对 ManticoreSyncTask 进行去重
if ($task instanceof \App\Tasks\ManticoreSyncTask) {
$action = $task->getAction();
$dataId = $task->getDataId();
if ($action && $dataId) {
$cacheKey = "manticore_task:{$action}:{$dataId}";
// 如果已有相同任务在等待,跳过本次投递
if (Cache::has($cacheKey)) {
return;
}
// 标记任务已投递
Cache::put($cacheKey, true, self::DEDUP_WINDOW);
}
}
Task::deliver($task);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Observers;
use App\Models\File;
use App\Tasks\ManticoreSyncTask;
class FileObserver extends AbstractObserver
{
/**
* Handle the File "created" event.
*
* @param \App\Models\File $file
* @return void
*/
public function created(File $file)
{
// 文件夹不需要同步
if ($file->type === 'folder') {
return;
}
self::taskDeliver(new ManticoreSyncTask('file_sync', $file->toArray()));
}
/**
* Handle the File "updated" event.
*
* @param \App\Models\File $file
* @return void
*/
public function updated(File $file)
{
// 检查共享设置是否变化(影响子文件的 pshare
if ($file->type === 'folder' && $file->isDirty('share')) {
// 共享文件夹的 share 字段变化,需要批量更新子文件的 pshare
// 注意updateShare 方法会批量更新,但不会触发 Observer
$newPshare = $file->share ? $file->id : 0;
$childFileIds = File::where('pids', 'like', "%,{$file->id},%")
->where('type', '!=', 'folder')
->pluck('id')
->toArray();
if (!empty($childFileIds)) {
self::taskDeliver(new ManticoreSyncTask('file_pshare_update', [
'file_ids' => $childFileIds,
'pshare' => $newPshare,
]));
}
return;
}
// 文件夹不需要同步内容
if ($file->type === 'folder') {
return;
}
self::taskDeliver(new ManticoreSyncTask('file_sync', $file->toArray()));
}
/**
* Handle the File "deleted" event.
*
* @param \App\Models\File $file
* @return void
*/
public function deleted(File $file)
{
self::taskDeliver(new ManticoreSyncTask('file_delete', $file->toArray()));
}
/**
* Handle the File "restored" event.
*
* @param \App\Models\File $file
* @return void
*/
public function restored(File $file)
{
// 文件夹不需要同步
if ($file->type === 'folder') {
return;
}
self::taskDeliver(new ManticoreSyncTask('file_sync', $file->toArray()));
}
/**
* Handle the File "force deleted" event.
*
* @param \App\Models\File $file
* @return void
*/
public function forceDeleted(File $file)
{
self::taskDeliver(new ManticoreSyncTask('file_delete', $file->toArray()));
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Observers;
use App\Models\FileUser;
use App\Tasks\ManticoreSyncTask;
/**
* FileUser 观察者
*/
class FileUserObserver extends AbstractObserver
{
/**
* Handle the FileUser "created" event.
*
* @param \App\Models\FileUser $fileUser
* @return void
*/
public function created(FileUser $fileUser)
{
// 更新文件权限
self::taskDeliver(new ManticoreSyncTask('update_file_allowed_users', [
'file_id' => $fileUser->file_id,
]));
}
/**
* Handle the FileUser "updated" event.
*
* @param \App\Models\FileUser $fileUser
* @return void
*/
public function updated(FileUser $fileUser)
{
// 更新文件权限
self::taskDeliver(new ManticoreSyncTask('update_file_allowed_users', [
'file_id' => $fileUser->file_id,
]));
}
/**
* Handle the FileUser "deleted" event.
*
* @param \App\Models\FileUser $fileUser
* @return void
*/
public function deleted(FileUser $fileUser)
{
// 更新文件权限
self::taskDeliver(new ManticoreSyncTask('update_file_allowed_users', [
'file_id' => $fileUser->file_id,
]));
}
}

View File

@@ -5,8 +5,9 @@ namespace App\Observers;
use App\Models\Deleted;
use App\Models\Project;
use App\Models\ProjectUser;
use App\Tasks\ManticoreSyncTask;
class ProjectObserver
class ProjectObserver extends AbstractObserver
{
/**
* Handle the Project "created" event.
@@ -16,7 +17,7 @@ class ProjectObserver
*/
public function created(Project $project)
{
//
self::taskDeliver(new ManticoreSyncTask('project_sync', $project->toArray()));
}
/**
@@ -35,6 +36,24 @@ class ProjectObserver
Deleted::forget('project', $project->id, $userids);
}
}
// 检查是否有搜索相关字段变化
$searchableFields = ['name', 'desc', 'archived_at'];
$isDirty = false;
foreach ($searchableFields as $field) {
if ($project->isDirty($field)) {
$isDirty = true;
break;
}
}
if ($isDirty) {
if ($project->archived_at) {
self::taskDeliver(new ManticoreSyncTask('project_delete', ['project_id' => $project->id]));
} else {
self::taskDeliver(new ManticoreSyncTask('project_sync', $project->toArray()));
}
}
}
/**
@@ -46,6 +65,7 @@ class ProjectObserver
public function deleted(Project $project)
{
Deleted::record('project', $project->id, $this->userids($project));
self::taskDeliver(new ManticoreSyncTask('project_delete', ['project_id' => $project->id]));
}
/**
@@ -57,6 +77,7 @@ class ProjectObserver
public function restored(Project $project)
{
Deleted::forget('project', $project->id, $this->userids($project));
self::taskDeliver(new ManticoreSyncTask('project_sync', $project->toArray()));
}
/**
@@ -67,7 +88,7 @@ class ProjectObserver
*/
public function forceDeleted(Project $project)
{
//
self::taskDeliver(new ManticoreSyncTask('project_delete', ['project_id' => $project->id]));
}
/**

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Observers;
use App\Models\ProjectTask;
use App\Models\ProjectTaskContent;
use App\Tasks\ManticoreSyncTask;
class ProjectTaskContentObserver extends AbstractObserver
{
/**
* Handle the ProjectTaskContent "created" event.
* 任务内容创建时,触发任务索引更新
*
* @param \App\Models\ProjectTaskContent $content
* @return void
*/
public function created(ProjectTaskContent $content)
{
$this->syncTaskToManticore($content->task_id);
}
/**
* Handle the ProjectTaskContent "updated" event.
* 任务内容更新时,触发任务索引更新
*
* @param \App\Models\ProjectTaskContent $content
* @return void
*/
public function updated(ProjectTaskContent $content)
{
// 只有内容变化时才需要更新
if ($content->isDirty('content')) {
$this->syncTaskToManticore($content->task_id);
}
}
/**
* Handle the ProjectTaskContent "deleted" event.
* 任务内容删除时,触发任务索引更新
*
* @param \App\Models\ProjectTaskContent $content
* @return void
*/
public function deleted(ProjectTaskContent $content)
{
$this->syncTaskToManticore($content->task_id);
}
/**
* 触发任务同步到 Manticore
*
* @param int|null $taskId 任务ID
* @return void
*/
private function syncTaskToManticore(?int $taskId)
{
if (!$taskId || $taskId <= 0) {
return;
}
$task = ProjectTask::find($taskId);
if (!$task || $task->archived_at || $task->deleted_at) {
return;
}
self::taskDeliver(new ManticoreSyncTask('task_sync', $task->toArray()));
}
}

View File

@@ -7,8 +7,9 @@ use App\Models\ProjectTask;
use App\Models\ProjectTaskUser;
use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectUser;
use App\Tasks\ManticoreSyncTask;
class ProjectTaskObserver
class ProjectTaskObserver extends AbstractObserver
{
/**
* Handle the ProjectTask "created" event.
@@ -18,7 +19,7 @@ class ProjectTaskObserver
*/
public function created(ProjectTask $projectTask)
{
//
self::taskDeliver(new ManticoreSyncTask('task_sync', $projectTask->toArray()));
}
/**
@@ -39,6 +40,28 @@ class ProjectTaskObserver
Deleted::forget('projectTask', $projectTask->id, self::userids($projectTask));
}
}
// 检查是否有搜索相关字段变化或权限相关字段变化
// visibility 变化会影响 allowed_users 来源
// parent_id 变化会影响子任务继承
// project_id 变化会影响 visibility=1 的任务权限
$searchableFields = ['name', 'desc', 'archived_at', 'project_id', 'visibility', 'parent_id'];
$isDirty = false;
foreach ($searchableFields as $field) {
if ($projectTask->isDirty($field)) {
$isDirty = true;
break;
}
}
if ($isDirty) {
if ($projectTask->archived_at) {
self::taskDeliver(new ManticoreSyncTask('task_delete', ['task_id' => $projectTask->id]));
} else {
// 重新同步任务(会重新计算 allowed_users
self::taskDeliver(new ManticoreSyncTask('task_sync', $projectTask->toArray()));
}
}
}
/**
@@ -50,6 +73,7 @@ class ProjectTaskObserver
public function deleted(ProjectTask $projectTask)
{
Deleted::record('projectTask', $projectTask->id, self::userids($projectTask));
self::taskDeliver(new ManticoreSyncTask('task_delete', ['task_id' => $projectTask->id]));
}
/**
@@ -61,6 +85,7 @@ class ProjectTaskObserver
public function restored(ProjectTask $projectTask)
{
Deleted::forget('projectTask', $projectTask->id, self::userids($projectTask));
self::taskDeliver(new ManticoreSyncTask('task_sync', $projectTask->toArray()));
}
/**
@@ -71,7 +96,7 @@ class ProjectTaskObserver
*/
public function forceDeleted(ProjectTask $projectTask)
{
//
self::taskDeliver(new ManticoreSyncTask('task_delete', ['task_id' => $projectTask->id]));
}
/**

View File

@@ -5,8 +5,9 @@ namespace App\Observers;
use App\Models\Deleted;
use App\Models\ProjectTaskUser;
use App\Models\ProjectUser;
use App\Tasks\ManticoreSyncTask;
class ProjectTaskUserObserver
class ProjectTaskUserObserver extends AbstractObserver
{
/**
* Handle the ProjectTaskUser "created" event.
@@ -20,6 +21,17 @@ class ProjectTaskUserObserver
if ($projectTaskUser->task_pid) {
Deleted::forget('projectTask', $projectTaskUser->task_pid, $projectTaskUser->userid);
}
// 更新任务权限
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
'task_id' => $projectTaskUser->task_id,
]));
// 如果是子任务,也更新父任务
if ($projectTaskUser->task_pid) {
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
'task_id' => $projectTaskUser->task_pid,
]));
}
}
/**
@@ -30,7 +42,18 @@ class ProjectTaskUserObserver
*/
public function updated(ProjectTaskUser $projectTaskUser)
{
//
// userid 变更时需要更新任务权限(移交场景)
if ($projectTaskUser->isDirty('userid')) {
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
'task_id' => $projectTaskUser->task_id,
]));
// 如果是子任务,也更新父任务
if ($projectTaskUser->task_pid) {
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
'task_id' => $projectTaskUser->task_pid,
]));
}
}
}
/**
@@ -44,6 +67,11 @@ class ProjectTaskUserObserver
if (!ProjectUser::whereProjectId($projectTaskUser->project_id)->whereUserid($projectTaskUser->userid)->exists()) {
Deleted::record('projectTask', $projectTaskUser->task_id, $projectTaskUser->userid);
}
// 更新任务权限
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
'task_id' => $projectTaskUser->task_id,
]));
}
/**

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Observers;
use App\Models\ProjectTaskVisibilityUser;
use App\Tasks\ManticoreSyncTask;
/**
* ProjectTaskVisibilityUser 观察者
*
* 用于处理任务 visibility=3指定成员可见时的成员变更同步
*/
class ProjectTaskVisibilityUserObserver extends AbstractObserver
{
/**
* Handle the ProjectTaskVisibilityUser "created" event.
*
* @param \App\Models\ProjectTaskVisibilityUser $visibilityUser
* @return void
*/
public function created(ProjectTaskVisibilityUser $visibilityUser)
{
// 更新任务权限
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
'task_id' => $visibilityUser->task_id,
]));
}
/**
* Handle the ProjectTaskVisibilityUser "updated" event.
*
* @param \App\Models\ProjectTaskVisibilityUser $visibilityUser
* @return void
*/
public function updated(ProjectTaskVisibilityUser $visibilityUser)
{
// 更新任务权限
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
'task_id' => $visibilityUser->task_id,
]));
}
/**
* Handle the ProjectTaskVisibilityUser "deleted" event.
*
* @param \App\Models\ProjectTaskVisibilityUser $visibilityUser
* @return void
*/
public function deleted(ProjectTaskVisibilityUser $visibilityUser)
{
// 更新任务权限
self::taskDeliver(new ManticoreSyncTask('update_task_allowed_users', [
'task_id' => $visibilityUser->task_id,
]));
}
/**
* Handle the ProjectTaskVisibilityUser "restored" event.
*
* @param \App\Models\ProjectTaskVisibilityUser $visibilityUser
* @return void
*/
public function restored(ProjectTaskVisibilityUser $visibilityUser)
{
//
}
/**
* Handle the ProjectTaskVisibilityUser "force deleted" event.
*
* @param \App\Models\ProjectTaskVisibilityUser $visibilityUser
* @return void
*/
public function forceDeleted(ProjectTaskVisibilityUser $visibilityUser)
{
//
}
}

View File

@@ -4,8 +4,9 @@ namespace App\Observers;
use App\Models\Deleted;
use App\Models\ProjectUser;
use App\Tasks\ManticoreSyncTask;
class ProjectUserObserver
class ProjectUserObserver extends AbstractObserver
{
/**
* Handle the ProjectUser "created" event.
@@ -16,6 +17,15 @@ class ProjectUserObserver
public function created(ProjectUser $projectUser)
{
Deleted::forget('project', $projectUser->project_id, $projectUser->userid);
// 更新项目权限
self::taskDeliver(new ManticoreSyncTask('update_project_allowed_users', [
'project_id' => $projectUser->project_id,
]));
// 异步级联更新该项目下所有 visibility=1 的任务
self::taskDeliver(new ManticoreSyncTask('cascade_project_users', [
'project_id' => $projectUser->project_id,
]));
}
/**
@@ -26,7 +36,15 @@ class ProjectUserObserver
*/
public function updated(ProjectUser $projectUser)
{
//
// userid 变更时需要更新项目权限和级联任务权限(移交场景)
if ($projectUser->isDirty('userid')) {
self::taskDeliver(new ManticoreSyncTask('update_project_allowed_users', [
'project_id' => $projectUser->project_id,
]));
self::taskDeliver(new ManticoreSyncTask('cascade_project_users', [
'project_id' => $projectUser->project_id,
]));
}
}
/**
@@ -38,6 +56,15 @@ class ProjectUserObserver
public function deleted(ProjectUser $projectUser)
{
Deleted::record('project', $projectUser->project_id, $projectUser->userid);
// 更新项目权限
self::taskDeliver(new ManticoreSyncTask('update_project_allowed_users', [
'project_id' => $projectUser->project_id,
]));
// 异步级联更新该项目下所有 visibility=1 的任务
self::taskDeliver(new ManticoreSyncTask('cascade_project_users', [
'project_id' => $projectUser->project_id,
]));
}
/**

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Observers;
use App\Models\User;
use App\Module\Apps;
use App\Tasks\ManticoreSyncTask;
class UserObserver extends AbstractObserver
{
/**
* 搜索相关字段Manticore 同步)
*/
private static array $searchableFields = [
'nickname', 'email', 'profession', 'introduction', 'disable_at'
];
/**
* 需要监控并触发 user_update hook 的字段
*/
private static array $hookMonitoredFields = [
'email', 'tel', 'nickname', 'profession',
'birthday', 'address', 'introduction', 'department'
];
/**
* Handle the User "created" event.
*
* @param \App\Models\User $user
* @return void
*/
public function created(User $user)
{
// 机器人账号不同步
if ($user->bot) {
return;
}
self::taskDeliver(new ManticoreSyncTask('user_sync', $user->toArray()));
}
/**
* Handle the User "updated" event.
*
* @param \App\Models\User $user
* @return void
*/
public function updated(User $user)
{
// 机器人账号不处理
if ($user->bot) {
return;
}
// 检查是否有搜索相关字段变化Manticore 同步)
$isDirty = false;
foreach (self::$searchableFields as $field) {
if ($user->isDirty($field)) {
$isDirty = true;
break;
}
}
if ($isDirty) {
// 如果用户被禁用,删除索引;否则更新索引
if ($user->disable_at) {
self::taskDeliver(new ManticoreSyncTask('user_delete', ['userid' => $user->userid]));
} else {
self::taskDeliver(new ManticoreSyncTask('user_sync', $user->toArray()));
}
}
// 检测 onboard/offboard 场景disable_at 变化)
if ($user->isDirty('disable_at')) {
$originalDisableAt = $user->getOriginal('disable_at');
$currentDisableAt = $user->disable_at;
if ($originalDisableAt && !$currentDisableAt) {
// disable_at 从有值变为 null → 取消离职 (restore)
Apps::dispatchUserHook($user, 'user_onboard', 'restore');
} elseif (!$originalDisableAt && $currentDisableAt) {
// disable_at 从 null 变为有值 → 离职 (offboarded)
Apps::dispatchUserHook($user, 'user_offboard', 'offboarded');
}
return;
}
// 排除仅 identity 变化的场景
if ($user->isDirty('identity')) {
return;
}
// 检测监控字段变更,触发 user_update hook
$changedFields = [];
foreach (self::$hookMonitoredFields as $field) {
if ($user->isDirty($field)) {
$changedFields[] = $field;
}
}
if (!empty($changedFields)) {
// 判断是用户自己修改还是管理员修改
$currentUser = User::authInfo();
$eventType = ($currentUser && $currentUser->userid === $user->userid)
? 'profile_update'
: 'admin_update';
Apps::dispatchUserHook($user, 'user_update', $eventType, $changedFields);
}
}
/**
* Handle the User "deleted" event.
*
* @param \App\Models\User $user
* @return void
*/
public function deleted(User $user)
{
// Manticore 索引删除
self::taskDeliver(new ManticoreSyncTask('user_delete', ['userid' => $user->userid]));
// 触发 user_offboard (delete) hook
if (!$user->bot) {
Apps::dispatchUserHook($user, 'user_offboard', 'delete');
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Observers;
use App\Models\User;
use App\Models\UserTag;
use App\Tasks\ManticoreSyncTask;
class UserTagObserver extends AbstractObserver
{
/**
* Handle the UserTag "created" event.
* 标签创建时,触发用户索引更新
*
* @param \App\Models\UserTag $userTag
* @return void
*/
public function created(UserTag $userTag)
{
$this->syncUserToManticore($userTag->user_id);
}
/**
* Handle the UserTag "updated" event.
* 标签更新时,触发用户索引更新
*
* @param \App\Models\UserTag $userTag
* @return void
*/
public function updated(UserTag $userTag)
{
// 只有标签名称变化时才需要更新
if ($userTag->isDirty('name')) {
$this->syncUserToManticore($userTag->user_id);
}
}
/**
* Handle the UserTag "deleted" event.
* 标签删除时,触发用户索引更新
*
* @param \App\Models\UserTag $userTag
* @return void
*/
public function deleted(UserTag $userTag)
{
$this->syncUserToManticore($userTag->user_id);
}
/**
* 触发用户同步到 Manticore
*
* @param int $userid 用户ID
* @return void
*/
private function syncUserToManticore(int $userid)
{
if ($userid <= 0) {
return;
}
$user = User::find($userid);
if (!$user || $user->bot || $user->disable_at) {
return;
}
self::taskDeliver(new ManticoreSyncTask('user_sync', $user->toArray()));
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Observers;
use App\Models\User;
use App\Models\UserTag;
use App\Models\UserTagRecognition;
use App\Tasks\ManticoreSyncTask;
class UserTagRecognitionObserver extends AbstractObserver
{
/**
* Handle the UserTagRecognition "created" event.
* 认可创建时,标签排序可能变化,触发用户索引更新
*
* @param \App\Models\UserTagRecognition $recognition
* @return void
*/
public function created(UserTagRecognition $recognition)
{
$this->syncUserByTagId($recognition->tag_id);
}
/**
* Handle the UserTagRecognition "deleted" event.
* 认可删除时,标签排序可能变化,触发用户索引更新
*
* @param \App\Models\UserTagRecognition $recognition
* @return void
*/
public function deleted(UserTagRecognition $recognition)
{
$this->syncUserByTagId($recognition->tag_id);
}
/**
* 根据标签ID触发用户同步
*
* @param int $tagId 标签ID
* @return void
*/
private function syncUserByTagId(int $tagId)
{
if ($tagId <= 0) {
return;
}
$tag = UserTag::find($tagId);
if (!$tag) {
return;
}
$user = User::find($tag->user_id);
if (!$user || $user->bot || $user->disable_at) {
return;
}
self::taskDeliver(new ManticoreSyncTask('user_sync', $user->toArray()));
}
}

View File

@@ -3,7 +3,9 @@
namespace App\Observers;
use App\Models\WebSocketDialogMsg;
use App\Tasks\ZincSearchSyncTask;
use App\Module\Apps;
use App\Module\Manticore\ManticoreMsg;
use App\Tasks\ManticoreSyncTask;
class WebSocketDialogMsgObserver extends AbstractObserver
{
@@ -15,7 +17,10 @@ class WebSocketDialogMsgObserver extends AbstractObserver
*/
public function created(WebSocketDialogMsg $webSocketDialogMsg)
{
self::taskDeliver(new ZincSearchSyncTask('sync', $webSocketDialogMsg->toArray()));
// Manticore 同步(仅在安装 Manticore 且符合索引条件时)
if (Apps::isInstalled('search') && ManticoreMsg::shouldIndex($webSocketDialogMsg)) {
self::taskDeliver(new ManticoreSyncTask('msg_sync', ['msg_id' => $webSocketDialogMsg->id]));
}
}
/**
@@ -26,7 +31,10 @@ class WebSocketDialogMsgObserver extends AbstractObserver
*/
public function updated(WebSocketDialogMsg $webSocketDialogMsg)
{
self::taskDeliver(new ZincSearchSyncTask('sync', $webSocketDialogMsg->toArray()));
// Manticore 同步(更新可能使消息符合或不再符合索引条件,由 sync 方法处理)
if (Apps::isInstalled('search')) {
self::taskDeliver(new ManticoreSyncTask('msg_sync', ['msg_id' => $webSocketDialogMsg->id]));
}
}
/**
@@ -37,7 +45,10 @@ class WebSocketDialogMsgObserver extends AbstractObserver
*/
public function deleted(WebSocketDialogMsg $webSocketDialogMsg)
{
self::taskDeliver(new ZincSearchSyncTask('delete', $webSocketDialogMsg->toArray()));
// Manticore 删除
if (Apps::isInstalled('search')) {
self::taskDeliver(new ManticoreSyncTask('msg_delete', ['msg_id' => $webSocketDialogMsg->id]));
}
}
/**
@@ -59,6 +70,9 @@ class WebSocketDialogMsgObserver extends AbstractObserver
*/
public function forceDeleted(WebSocketDialogMsg $webSocketDialogMsg)
{
//
// Manticore 删除
if (Apps::isInstalled('search')) {
self::taskDeliver(new ManticoreSyncTask('msg_delete', ['msg_id' => $webSocketDialogMsg->id]));
}
}
}

View File

@@ -3,8 +3,10 @@
namespace App\Observers;
use App\Models\Deleted;
use App\Models\UserBot;
use App\Models\WebSocketDialogUser;
use App\Tasks\ZincSearchSyncTask;
use App\Module\Apps;
use App\Tasks\ManticoreSyncTask;
use Carbon\Carbon;
class WebSocketDialogUserObserver extends AbstractObserver
@@ -30,7 +32,19 @@ class WebSocketDialogUserObserver extends AbstractObserver
}
}
Deleted::forget('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
self::taskDeliver(new ZincSearchSyncTask('userSync', $webSocketDialogUser->toArray()));
// Manticore: 更新对话下所有消息的 allowed_users
if (Apps::isInstalled('search')) {
self::taskDeliver(new ManticoreSyncTask('update_dialog_allowed_users', [
'dialog_id' => $webSocketDialogUser->dialog_id
]));
}
//
$dialog = $webSocketDialogUser->webSocketDialog;
if ($dialog) {
$dialog->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_JOIN, $webSocketDialogUser->userid, intval($webSocketDialogUser->inviter));
}
}
/**
@@ -41,7 +55,7 @@ class WebSocketDialogUserObserver extends AbstractObserver
*/
public function updated(WebSocketDialogUser $webSocketDialogUser)
{
self::taskDeliver(new ZincSearchSyncTask('userSync', $webSocketDialogUser->toArray()));
//
}
/**
@@ -53,7 +67,20 @@ class WebSocketDialogUserObserver extends AbstractObserver
public function deleted(WebSocketDialogUser $webSocketDialogUser)
{
Deleted::record('dialog', $webSocketDialogUser->dialog_id, $webSocketDialogUser->userid);
self::taskDeliver(new ZincSearchSyncTask('deleteUser', $webSocketDialogUser->toArray()));
// Manticore: 更新对话下所有消息的 allowed_users
if (Apps::isInstalled('search')) {
self::taskDeliver(new ManticoreSyncTask('update_dialog_allowed_users', [
'dialog_id' => $webSocketDialogUser->dialog_id
]));
}
//
$dialog = $webSocketDialogUser->webSocketDialog;
if ($dialog) {
$operatorId = $webSocketDialogUser->operator_id ?? 0;
$dialog->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_LEAVE, $webSocketDialogUser->userid, intval($operatorId));
}
}
/**

View File

@@ -2,17 +2,31 @@
namespace App\Providers;
use App\Models\File;
use App\Models\FileUser;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\ProjectTaskContent;
use App\Models\ProjectTaskUser;
use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\UserTag;
use App\Models\UserTagRecognition;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Observers\FileObserver;
use App\Observers\FileUserObserver;
use App\Observers\ProjectObserver;
use App\Observers\ProjectTaskContentObserver;
use App\Observers\ProjectTaskObserver;
use App\Observers\ProjectTaskUserObserver;
use App\Observers\ProjectTaskVisibilityUserObserver;
use App\Observers\ProjectUserObserver;
use App\Observers\UserObserver;
use App\Observers\UserTagObserver;
use App\Observers\UserTagRecognitionObserver;
use App\Observers\WebSocketDialogMsgObserver;
use App\Observers\WebSocketDialogObserver;
use App\Observers\WebSocketDialogUserObserver;
@@ -40,10 +54,17 @@ class EventServiceProvider extends ServiceProvider
*/
public function boot()
{
File::observe(FileObserver::class);
FileUser::observe(FileUserObserver::class);
Project::observe(ProjectObserver::class);
ProjectTask::observe(ProjectTaskObserver::class);
ProjectTaskContent::observe(ProjectTaskContentObserver::class);
ProjectTaskUser::observe(ProjectTaskUserObserver::class);
ProjectTaskVisibilityUser::observe(ProjectTaskVisibilityUserObserver::class);
ProjectUser::observe(ProjectUserObserver::class);
User::observe(UserObserver::class);
UserTag::observe(UserTagObserver::class);
UserTagRecognition::observe(UserTagRecognitionObserver::class);
WebSocketDialog::observe(WebSocketDialogObserver::class);
WebSocketDialogMsg::observe(WebSocketDialogMsgObserver::class);
WebSocketDialogUser::observe(WebSocketDialogUserObserver::class);

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);
}
/** ***************************************************************************************** */
/** ***************************************************************************************** */
/** ***************************************************************************************** */

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