Compare commits

...

411 Commits

Author SHA1 Message Date
kuaifan
e0d1b08e89 release: v1.7.67 2026-06-01 13:04:00 +00:00
kuaifan
6b54b7b1c5 feat(todo): 聊天待办支持提醒时间(到点引用原消息+@提及)
给消息待办增加可选「提醒时间」,到点由 todo-alert 机器人对原消息发起
reply、正文 @ 仍在群内的被指派成员,完全复用原生回复/提及链路(定向未读、
红点、绕过会话免打扰、App 推送);被指派人全部退群则跳过发送并标记已提醒。
设/改/取消提醒的权限沿用 todo_set_permission 开关与 checkTodoOwnerPermission。

后端:
- 迁移:web_socket_dialog_msg_todos 增加 remind_at/reminded_at 及索引,
  注册为日期字段
- WebSocketDialogMsgTodo::dueReminders() 选取到点(未提醒/未完成)待办(limit 500)
- WebSocketDialogMsg::setTodoRemind() 纯数据写入(改时间重置 reminded_at),
  接入 toggleTodoMsg($remindAt) 与 msg__todo 透传
- 接口 msg__todoremind 设置/修改/取消提醒(权限闸门、消息类型校验、
  pushMsg 同步 todo_done)
- TodoRemindTask 到点按消息发提醒(reminded_at 防重复、迟发补发、原消息/
  会话删除兜底),buildRemindText 生成 <span class="mention user"> 文本,
  接入 crontab;登记 todo-alert 机器人
- msgJoinGroup 从提醒文本中提取被 @ 成员

前端:
- 设待办弹窗新增「提醒时间」(预设 + 自定义 DatePicker)
- 待办详情浮层每条待办可查看/修改/取消提醒:DatePicker on-clear「清空」
  二次确认后取消,无时间时仅关闭面板不发请求
- 待办浮层窄屏(≤500px)改为 待办/完成 tab 切换,宽屏维持双列;列表为空
  展示空状态占位;提醒时间用 Icon 替换 emoji
- 时间读写对齐项目任务时间的时区约定

测试:tests/Feature/TodoRemindTest(数据/选取/写入/权限决策/buildRemindText/
text mention 提取),TodoSetPermissionTest 无回归。

任务 #124 后续增强。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:08:34 +00:00
kuaifan
adc7fb0d07 docs(claude): 补充非 REST 路由最多两段的限制说明
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:52:27 +00:00
kuaifan
f969c8145c fix(cmd): env_set 值未变化时跳过写入,避免无谓重写 .env
env_set 原先对已存在的键无条件 sed -i 重写 .env,即使新值与当前值相同也会
改变文件 mtime。开发模式下 vite 监听 .env,任何文件事件都会触发整服务重启,
导致前端反复刷新。写入前复用 env_get 比较,值未变化则直接返回。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:27:42 +00:00
kuaifan
20b5daba50 feat(manage): 团队管理支持管理员创建/批量导入员工账号(含部门、职位)
单个创建:邮箱/昵称/初始密码 + 可选首登改密、职位、部门(多选,选子部门自动补选上级,并加入对应部门群)。

批量导入:上传 Excel/CSV → 预览逐行校验 → 确认后导入。职位为模板第4列(选填,逐行解析校验),部门在预览表按行勾选后由底部设置部门到选中写入;导入按行返回结果(全成功关弹窗+成功提示;含失败留弹窗显示失败明细;仅 success>0 才刷新列表)。

后端:User::createByAdmin 选项数组化 + 校验助手 assertValidProfession/assertValidDepartments;importUsers 逐行 department/profession;UsersController createuser/import;UserImport/UserImportTemplate(含职位列)。

测试:tests/Feature/AdminCreateUserTest、tests/Unit/UserImportParseTest。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:26:34 +00:00
kuaifan
aa2e0acaba feat(dialog): 系统设置支持禁止其他人员设置/取消聊天待办
新增系统级开关 todo_set_permission(open=允许默认 / close=禁止)。
开关为禁止时,仅本人、群主/群管理员、项目负责人/项目管理员、任务负责人
可设置或取消聊天消息待办,其他人由后端拦截;默认允许,保持现有行为。

- SystemController::setting 接入开关读写(白名单 + 默认 open)
- WebSocketDialog::checkTodoOwnerPermission 角色判断(复用 isOwner 等)
- WebSocketDialogMsg::toggleTodoMsg 内权限闸门:close 且影响到他人且
  非放行角色时 retError;仅影响自己始终放行;open 时行为零变化
- SystemSetting.vue「消息相关」新增「待办设置权限」开关 UI
- 国际化文案(original-api.txt / original-web.txt)
- TodoSetPermissionTest 覆盖角色判断、闸门决策及真实拦截路径(8 用例)

任务 #124。系统后台 admin 不特殊放行;「完成待办」不在本次范围。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:12:46 +00:00
kuaifan
e57736bcc1 docs: 统一语言偏好为整段回复使用简体中文
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:40:53 +00:00
kuaifan
a8db8dde7b i18n(task): 任务只读提示文案改为"负责人视角"
将部门只读提示从"当前为负责人,并参与讨论,但不能编辑任务。"
调整为"当前为负责人视角,并参与讨论,但不能编辑任务。",
并同步更新 original-web.txt、translate.json 及各语言编译产物。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:32:24 +00:00
kuaifan
b1d5652bc7 refactor(electron): 发布存储从自建服务迁移到 Cloudflare R2
替换 UPLOAD_TOKEN/UPLOAD_URL 为 R2(S3 兼容)对象存储:
- 新增 r2.js 封装上传/复制/删除/列举等操作
- 新增 release-index.js 从文件名解析平台/架构生成下载索引
- CI 环境变量同步切换为 R2_* 系列

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:31:46 +00:00
kuaifan
025f45df0a feat(upload): 添加上传进度显示和错误处理,记录上传耗时 2026-05-22 14:55:34 +08:00
kuaifan
981a5c9f0f ci: resolve iOS build number from App Store Connect 2026-05-22 10:13:25 +08:00
kuaifan
88cfd40abe ci: fix iOS archive signing 2026-05-22 08:32:13 +08:00
kuaifan
cdcf0ff5f3 release: v1.7.55
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:07:02 +00:00
kuaifan
42e355149c chore(i18n): 补全 v1.7.29 后遗漏的原文并去重
补登 v1.7.29 至今新增的用户可见文本:
- original-web.txt 新增 16 条(部门负责人视角、项目管理员相关等)
- original-api.txt 新增 7 条(项目负责人/成员校验、群成员移出等错误消息)
同时清理两文件历史重复内容行(web -18、api -26),
translate.php 读取时本就 array_unique,去重不影响翻译产物。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:49:44 +00:00
kuaifan
518364d70d feat(translate): 支持通过 OPENAI_API_MODEL 环境变量配置翻译模型
翻译脚本不再硬编码 gpt-5.2,改为从 .env 读取 OPENAI_API_MODEL,
便于切换不同模型。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:22:11 +00:00
kuaifan
f25340c0b3 ci: update iOS publish workflow 2026-05-21 23:57:45 +08:00
kuaifan
24f607f442 fix: hide horizontal overflow in user detail 2026-05-21 19:50:38 +08:00
kuaifan
6fbddbe77c fix: run app publish in disposable eeui container 2026-05-21 19:49:50 +08:00
kuaifan
21ba2665b9 chore: 添加 .agents 到 .claude 的软链接 2026-05-21 18:17:25 +08:00
kuaifan
0888f599a4 feat(manage): 管理页侧边栏支持拖拽调整宽度并修复菜单条件渲染
- 新增 ResizeLine 组件实现侧边栏宽度拖拽调整,范围200-420px,持久化至 localStorage
- 修复 v-for/v-else-if 同级指令优先级问题,将 v-else-if 提升至 template 包裹层

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:41:00 +00:00
kuaifan
ef7293704b refactor(manage): 收口部门负责人ID规范化逻辑并简化后端对话可见性校验
- 后端:任务群/项目群统一按项目级共享判断,不再区分任务可见性
- 前端:新增 department/owner/ids/save mutation 及 normalizeIntArray 工具函数
- 前端:departmentOwnerReadonlyUrls 从 action 局部变量提升至 state
- 前端:修复 TaskDetail 提示文本多余空格

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 06:06:39 +00:00
kuaifan
8cd4669b90 refactor(manage): 部门负责人只读视角统一使用禁用态UserSelect组件
用 disabled 属性的 UserSelect 替代独立的 UserAvatar 只读展示,
消除双份渲染逻辑,负责人/协助人员/可见人员统一使用同一组件路径。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:01:15 +00:00
kuaifan
7f7a82b4b8 feat(manage): 部门负责人视角支持项目级可见开关并尊重任务可见性
新增项目级"负责人视角"开关(projects.department_owner_view,默认开启),
项目负责人可关闭,关闭后该项目及其群聊对部门负责人视角隐藏。同时将负责人
只读视角调整为尊重任务可见性:仅"全员可见"任务可被查看/进入任务群,指定
成员可见的任务仅对被指定成员开放。

- 新增 projects.department_owner_view 字段(migration)
- ProjectController::update 支持读写该开关
- UserDepartment::ownerViewContext 过滤已关闭项目,并合并为单次 JOIN 查询
- ProjectTask::findForDepartmentView / task__one / tasks 列表尊重任务可见性
- WebSocketDialog::checkDialog 任务群按可见性放行
- 前端项目设置新增开关(仅系统开启该功能时显示)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:46:11 +00:00
kuaifan
0863e5529a feat(manage): 实现部门负责人视角,支持只读查看部门成员项目与任务
部门负责人/部门管理员可通过系统配置开启,选择管理部门后只读查看
本部门及下级部门成员的全部项目和任务。前端自动根据 department_readonly
标记禁用编辑操作,后端统一注入负责人视角上下文控制数据访问边界。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:14:36 +00:00
kuaifan
e0ad8ce6c1 docs: 添加 AGENTS.md 项目指南文件 2026-05-21 00:13:09 +00:00
kuaifan
9f4e5a8335 fix(project): 修正AI自动分析开关状态判断变量名
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:18:28 +00:00
kuaifan
587db459bf feat(dialog): 聊天消息 Markdown 表格单元格强制不换行
DialogMarkdown 根节点新增 .dialog-markdown 类,统一规则放 markdown.less,删除 dialog-wrapper.scss 里仅覆盖 thead th 的旧局部规则。所有走 DialogMarkdown 组件的入口(聊天消息、bot 模板、单条消息分享页、AI 助手)一次性生效;任务描述、报告编辑等直接调用 MarkdownConver 的场景不受影响。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:40:57 +00:00
kuaifan
5b87714acf feat(project): 项目归档设置选择系统默认时显示规则提示
当 archive_method 为 'system' 时显示提示文案,告知用户将按系统设置的自动归档规则执行,避免用户误以为未生效。
2026-05-11 10:03:25 +00:00
kuaifan
bc54ac9462 feat(docs): 更新开发命令说明,明确AI不应主动执行的命令 2026-05-11 03:45:52 +00:00
kuaifan
7e5b31cfb2 feat(template): 添加共享模板功能,支持项目间模板使用控制 2026-05-11 03:26:59 +00:00
kuaifan
d81b4ed273 refactor: 优化API文档注释格式;调整AbstractModel方法注释 2026-05-11 02:50:14 +00:00
kuaifan
0c1a913134 feat(TaskAdd): 优化任务添加界面,调整模板浏览器和加载提示样式
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 02:03:50 +00:00
kuaifan
7dc641e69e feat(template): 添加跨项目任务模板支持,增加使用统计和搜索功能 2026-05-11 01:13:54 +00:00
kuaifan
18336c870e feat(docs): 添加 Playwright 测试结果存放说明 2026-05-09 15:36:31 +00:00
kuaifan
e43588c3b2 fix(multi-owner): close permission lifecycle gaps 2026-05-09 12:31:54 +00:00
kuaifan
64649b514e feat(multi-owner): 群/项目/部门支持主+副双负责人体系 2026-05-09 12:29:38 +00:00
kuaifan
24710289e1 feat(multi-owner): 群/项目/部门支持主+副双负责人体系
- 群组:新增 web_socket_dialog_users.role(1=主、2=副),主可任命/罢免副群主,副可邀请/移出普通成员
- 项目:project_users.owner 扩展为 0/1/2(成员/主/副),主独占转让和删除,副共享日常管理;任务可见性、通知、分配等下游逻辑统一用「主+副」
- 部门:新增 user_department_owners 表存储副负责人;部门群同步副群主,赋予群管理员权限
- 转移用户时副身份不替补、降级为普通成员
- 配套 migration/backfill、API、前端 UI、i18n 词条与三项 Feature 测试
- .gitignore 忽略 .playwright-mcp/
2026-05-03 00:05:31 +00:00
kuaifan
2a3f05e06f docs(ai): 注释模型名思考标记剥离规则
说明 think/thinking/reasoning 后缀的支持写法(空格、- 、_、括号),便于后续维护识别匹配范围。
2026-05-03 00:03:32 +00:00
kuaifan
0d31106b0f release: v1.7.29
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 01:23:51 +00:00
kuaifan
fbd1c829a1 fix(ai): AI助手图片压缩阈值从1024提升到1568,减少长图模糊
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:12:16 +00:00
kuaifan
82d2ca6360 feat(ai): AI助手聊天记录服务端持久化
- 图片缓存不再二次压缩,用户预览与AI收到的图片质量一致
- 新增 ai_assistant_sessions 表及 AiAssistantSession 模型
- 新增会话 API:session/list、session/save、session/delete
- 前端会话存储从 IndexedDB 切换为服务端 API,图片落盘到 uploads/assistant/{YYYYMM}/{user_id}/
- saveSessionStore 添加防抖,删除会话时同步清理内存缓存

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 04:22:35 +00:00
kuaifan
717e520556 fix(ldap): 修复 AD 环境下用户搜索失败和密码策略冲突
- objectClasses 移除 inetOrgPerson 和 organizationalPerson,仅保留 person + top
  AD 用户的 objectClass 是 user 而非 inetOrgPerson,导致 LdapRecord 搜索过滤不到用户
- LDAP 用户首次创建本地账号时使用随机密码,避免 LDAP 密码不满足本地密码策略

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 02:05:14 +00:00
kuaifan
c8ddb511cf feat(ci): 添加 Gitee 同步工作流 2026-04-16 22:26:06 +00:00
kuaifan
caf728de8d feat(ci): 添加 iOS 手动构建并提交 App Store 工作流
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:13:22 +00:00
kuaifan
a7cd4d7fa8 release: v1.7.23
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:02:06 +00:00
kuaifan
ddc0046e24 chore(claude): 将 /release 命令转换为 skill
- 新增 .claude/skills/release/SKILL.md:CSO 描述、前置检查、三步发布流程
- 删除 .claude/commands/release.md
- 补充基线测试暴露的反模式约束(禁止自动修复脏工作区、禁止 git tag、禁止 git add -A)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:13:29 +00:00
kuaifan
1059630b9d feat(ldap): 支持非邮箱用户名登录,完善 AD 兼容性
- 登录页放宽校验:登录模式允许任意账号格式,注册模式仍强制邮箱
- 登录属性新增 userPrincipalName 选项(AD 常用且通常是邮箱格式)
- LDAP 用户缺少邮箱属性时返回明确错误提示,替代误导性的"请输入正确的邮箱地址"
- LDAP 登录合并已有本地账号时记录 info 日志,便于审计

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:48:40 +00:00
kuaifan
e1c1fc030f release: v1.7.20
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 04:25:07 +00:00
kuaifan
09edb14d56 fix(ldap): 使用 LDAP Bind 认证替代 userPassword 查询,兼容 Active Directory
- 认证方式从 userPassword 属性过滤改为标准 LDAP Bind,兼容所有 LDAP 服务器
- 新增可配置的登录属性(cn/uid/mail/sAMAccountName),AD 用户选 sAMAccountName 即可
- 移除 posixAccount objectClass,兼容 AD 目录结构
- 同步创建用户时移除 POSIX 专属属性,添加 mail 属性
- 用户查找改用 findByEmail 按 mail/cn/uid/userPrincipalName 依次匹配
- initConfig 从静态变量缓存改为 RequestContext 请求级缓存,修复 Swoole 下配置变更不生效的问题
- 默认登录属性为 cn,与旧版本行为一致,确保向后兼容

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:18:36 +00:00
kuaifan
f27cef2d66 fix(build): exit with code 1 on upload/release failure
WebsitePublisher methods now throw on failure instead of silently
continuing. CLI entry points catch errors and exit(1) so GitHub
Actions correctly marks the job as failed.
2026-04-06 05:28:56 +00:00
kuaifan
07a2e6df29 fix(build): put version field before file in upload form data 2026-04-06 03:38:17 +00:00
kuaifan
f521f0df65 fix(build): print detailed error info on upload failure
Show error code/message in retry warnings and failure logs
to help diagnose upload issues in CI.
2026-04-06 03:16:32 +00:00
kuaifan
a67fcd6f02 feat: connect publish pipeline to dootask-website API
- Refactor build.js: replace androidUpload/genericPublish/published with
  unified WebsitePublisher class using Authorization Bearer auth
- New CLI commands: upload-changelog, release
- Update auto-update URL to /api/download/update (legacy compat on website)
- publish.yml: use CHANGELOG.md for GitHub Release body, replace
  PUBLISH_KEY with UPLOAD_TOKEN + UPLOAD_URL
2026-04-05 23:04:34 +00:00
kuaifan
d17f404853 release: v1.7.14
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:43:58 +00:00
kuaifan
8def4addc4 fix(chat): 修复 AI 助手(userid=-1)在多处显示异常的问题
在 UserAvatar 组件中统一处理 AI 助手虚拟用户,避免各组件重复判断;
同时修复 @提及、回复引用、转发消息等场景下的 undefined 和空白显示问题,
并过滤批量用户请求中的无效 userid。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:34:27 +00:00
kuaifan
0ecaf9740f feat(i18n): 添加用户编辑和生日相关翻译原文
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:54 +00:00
kuaifan
bc75680ee9 feat: 添加 /release 发布流程 skill
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:51:20 +00:00
kuaifan
6a71964592 feat(dialog): 重构合并转发功能
- 合并转发消息体改为存储 msg_ids + preview,不再存储完整消息列表
- 新增 mergedetail API 按需加载合并转发详情
- 详情展示从 Modal 改为 DrawerOverlay,支持完整消息渲染
- 统一不可转发消息类型过滤(tag/top/todo/notice/word-chain/vote/template)
- 合并转发标题改为前端国际化拼接
- DialogWrapper 支持 staticMsgs 静态模式用于详情渲染
- 优化多选操作栏和转发确认界面样式
2026-04-05 09:31:41 +00:00
kuaifan
00a2ea3d2f docs: 精简 CLAUDE.md 国际化规范
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:08:47 +00:00
kuaifan
95e97333b4 feat(translate): 支持自定义 OPENAI_BASE_URL 配置
在翻译脚本和版本发布脚本中增加 OPENAI_BASE_URL 环境变量支持,
允许用户配置自定义的 OpenAI API 地址。自动处理 /v1 路径重复问题。
2026-04-05 09:02:04 +00:00
kuaifan
9e65500748 refactor(ai): 简化AI模块逻辑 2026-04-04 23:18:21 +00:00
kuaifan
a2acd6f6e4 feat(install): 安装时检测 APP_ID 是否与其他实例冲突
防止复制项目目录到另一个位置安装时,因 APP_ID 相同导致容器名和网络冲突。
通过 docker inspect 对比容器挂载路径与当前工作目录判断。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:33:48 +00:00
kuaifan
ee96730268 feat(install): 安装和修改端口时检测端口是否被占用
通过 Docker 试绑定端口的方式检测占用,避免安装流程走到最后才因端口冲突失败。
仅在首次安装或端口变更时检测,重装且端口不变时跳过。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:23:17 +00:00
kuaifan
f925f238dd chore(appstore): 升级 appstore 镜像版本至 0.4.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:23:10 +00:00
kuaifan
39c6ca3e8c feat(env): 在设置环境变量时确保.env文件存在 2026-04-04 01:38:39 +00:00
kuaifan
c798faa8db feat(migration): 添加表存在检查以避免重复创建表 2026-04-04 00:58:45 +00:00
kuaifan
ed2f843815 feat(middleware): 优化 WebApi 中的 HTTPS 强制设置逻辑 2026-04-04 07:48:04 +08:00
kuaifan
984b98e4fc feat(task): 实现消息合并转发功能,支持批量选择和转发消息 2026-04-04 07:43:26 +08:00
kuaifan
4b32472d64 feat(task): 增加AI自动分析开关(系统级+项目级)
系统设置新增 task_ai_auto_analyze 开关控制全局AI任务分析;项目设置新增 ai_auto_analyze 开关,系统关闭时项目无法开启。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:51:38 +08:00
kuaifan
fc171bc71f chore: 更新子项目提交哈希 2026-04-02 14:34:06 +08:00
kuaifan
cc80fa83e0 chore: 删除 Graphiti 长期记忆集成文档 2026-03-31 16:23:52 +08:00
kuaifan
782ba4a151 docs: optimize CLAUDE.md — remove discoverable content, add critical gotchas
Remove self-description header, 38-line command listing, directory trees,
and standard Laravel patterns that Claude can infer from code.

Add 6 project-specific gotchas Claude would get wrong: non-REST routing
(InvokeController), custom response envelope (Base::retSuccess/retError),
AbstractModel::createInstance, Doo::userId auth, manual validation (no
FormRequest), and Swoole Task (not Laravel Queue).

122 lines → 46 lines.
2026-03-13 10:49:03 +00:00
kuaifan
04708cedb6 feat(task): 增加解除任务关联功能
支持用户在任务详情中解除误关联的任务,权限与修改任务一致(项目负责人、任务负责人、任务协助人)。

- 新增 ProjectTaskRelation::deleteRelation() 删除双向关联并推送 WebSocket
- 新增 API POST /api/project/task/related/delete 接口
- 前端关联任务列表 hover 显示删除按钮,点击确认后解除关联

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-09 06:38:13 +00:00
kuaifan
4068966700 feat(auth): token/expire 接口支持 refresh 参数刷新 token
- token/expire 接口新增可选参数 refresh=1,当 token 剩余有效期不足总有效期
  的 1/3 时返回新 token
- 将 users/info 移动端的硬编码 7 天刷新阈值统一改为总有效期的 1/3

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-04 14:49:41 +00:00
kuaifan
3ce8cf381a chore: 更新子项目提交哈希 2026-03-04 11:40:00 +00:00
kuaifan
f78d3f3aff feat(dialog): add send_ai_assistant endpoint for AI assistant identity messaging
New endpoint POST api/dialog/msg/send_ai_assistant sends messages
as the AI assistant identity (userid=-1). Supports both dialog_id
(direct) and task_id (with auto-creation) parameters.

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 09:05:56 +00:00
kuaifan
c60dff0950 feat(api): add with_extend param to task/lists endpoint
Supports optional `with_extend` query parameter (comma-separated).
When `project_name` or `column_name` is included, the API returns
these fields inline with each task via eager loading.

Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 02:44:05 +00:00
kuaifan
f2d49ee104 feat(task): 支持根据项目所有者筛选任务 2026-02-22 01:45:14 +00:00
kuaifan
a248d81230 build 2026-01-26 08:13:38 +08:00
kuaifan
1ac6bad2bb fix(task): 修复工作流切换时完成状态处理逻辑
- 恢复工作流切换时通过 $data['complete_at'] 设置完成状态,确保走统一处理入口
- 修复工作流切换时主任务完成状态校验被跳过的问题
- 修复工作流切换时 $updateMarking['is_update_project'] 未设置的问题
- checkAndAutoSetFlowItem 仅在用户单独提交 complete_at 时调用

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-22 07:34:09 +00:00
kuaifan
37de721df9 feat(task): 前端支持多工作流状态选择
- 处理 -4005/-4006 错误码,弹出工作流状态选择菜单
- 新增 showFlowItemSelector 方法展示可选状态列表
- 选择状态后自动更新任务的 flow_item_id 和 complete_at

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 05:32:27 +00:00
kuaifan
773eead827 feat(ai): AI 任务建议支持多语言输出
- 新增 getUserLanguageInfo 方法获取用户语言偏好
- 新增 getLocalizedTitles 方法,支持 9 种语言的标题和提示文案
- 调整 AI Prompt,根据用户语言输出对应语言的建议内容
- 相似任务检测阈值从 0.7 调整为 0.5
- 完善方法注释文档

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 05:32:20 +00:00
kuaifan
c4dd04ccb6 fix(task): 修复任务完成/取消完成时工作流状态自动切换逻辑
- 重构 flow_item_id 变更时的完成状态处理,使用 completeTask 方法替代直接赋值
- 新增 checkAndAutoSetFlowItem 方法,支持自动设置唯一的开始/结束状态
- 存在多个开始/结束状态时抛出带状态列表的错误(-4005/-4006),由前端引导用户选择
- 修复 complete_at 与 flow_item_id 同时存在时的重复处理问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 05:30:32 +00:00
kuaifan
2cdde37069 fix(observer): 修复 UserObserver 调用 private 方法 authInfo() 的错误
将 User::authInfo() 改为 User::userid(),因为 authInfo() 是 private 方法,
Observer 无法访问。userid() 是 public 方法,内部会正确调用 authInfo()。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 16:58:11 +00:00
kuaifan
f68f759418 fix(ai): 更新提示信息为本地化文本 2026-01-21 15:56:03 +00:00
kuaifan
801d0b24ab perf(ai): 缩短 AI 任务分析延迟时间至 10 秒
将 AiTaskLoopTask 的 DELAY_SECONDS 从 60 秒减少到 10 秒,
使新建任务更快获得 AI 建议。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
29be29b9cf feat(ai): 优化 AI 提示词并完善建议交互功能
- 优化后端提示词:描述生成、子任务拆分、负责人推荐,新增栏目信息,去掉无效的 similar_count
- 优化前端提示词:去掉硬性字数限制,即时消息改为简短输出
- 新增 :::ai-action{...}::: 语法处理,支持单独采纳/忽略 assignee 和 similar
- 采纳/忽略后更新消息状态显示
- 负责人改为追加模式,保留现有负责人
- 新增任务关联功能,similar 采纳时自动创建双向关联
- 相似度阈值从 0.7 调整为 0.5,搜索结果增加到 200

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
c253044f61 fix(ai): 更新 AI 助手头像显示逻辑和样式 2026-01-21 15:30:07 +00:00
kuaifan
9acf7d2046 fix(ai): 调整 AI 建议执行条件
1. subtasks: 标题长度阈值从 10 改为 5
2. similar: 启用向量搜索查找相似任务

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
3911af7b51 fix(ai): 修复描述格式和负责人重复问题
1. 描述建议:AI 返回 Markdown,前端用 MarkdownConver 转 HTML
2. 负责人推荐:排除已分配的任务成员
3. 解析负责人推荐时去重,防止 AI 返回重复用户

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
6b722b7ed7 fix(ai): 修正 AiTaskLoopTask 中 Apps 类的命名空间
App\Models\Apps -> App\Module\Apps

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
6a00b87f72 fix(ai): 修正 API 路由地址格式
将 ai-apply/ai-dismiss 改为 ai_apply/ai_dismiss,
匹配 Laravel 路由方法命名转换规则(task__ai_apply -> ai_apply)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00
kuaifan
0a97039d75 refactor(ai): 重构 AI 建议功能并完善向量搜索
1. 重构 task__ai_apply 接口:移除业务逻辑,仅负责状态更新和日志记录,
   返回建议数据由前端调用现有接口处理(taskUpdate/taskAddSub)

2. 实现 searchSimilarByEmbedding 向量搜索:
   - 使用 ManticoreBase::taskVectorSearch 进行向量搜索
   - 按 project_id 过滤同项目任务
   - 排除当前任务及其子任务
   - 设置 0.7 相似度阈值,最多返回 5 个结果

3. 更新 AI 助手头像:将文字 "AI" 替换为 SVG 图标

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:06 +00:00
kuaifan
cb56a01622 fix(ai): fix URL parsing for ai-apply/ai-dismiss links
The regex pattern (\w+) didn't match 'ai-apply' or 'ai-dismiss' because
\w doesn't include hyphens, causing all AI suggestion buttons to fail.

Fix by handling AI links before the regex match using startsWith().
Remove dead switch cases that were never reached.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:06 +00:00
kuaifan
452af4bd2f fix(ai): address issues from second code review
- Add STATUS_APPLIED and STATUS_DISMISSED constants to model
- Add markApplied() and markDismissed() methods
- Update event status after apply/dismiss actions (prevent duplicate ops)
- Validate related_task_id exists and user has permission
- Filter empty or overly long subtask names before creation

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:06 +00:00
kuaifan
75073d4320 fix(ai): address security and robustness issues from code review
Security fixes:
- Add escapeUserInput() to prevent Prompt injection via user input
- Validate msgId belongs to dialogId in updateMessageStatus()
- Add type parameter whitelist validation in ai-apply/ai-dismiss
- Add event record validation in task__ai_dismiss

Robustness fixes:
- Use atomic update for markProcessing to prevent concurrent processing
- Add subtask count limit check before creation (max 50)
- Disable similar task feature until vector search is implemented
- Fix Promise anti-pattern in frontend actions

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:06 +00:00
kuaifan
d4d7a0d69f feat(ai): add AI::invoke() method for task suggestions
- Add generic invoke() static method to AI module for custom chat completion
- Fix AiTaskSuggestion::callAi() to properly handle AI::invoke() response
- Fix findSimilarTasks() to properly handle AI::getEmbedding() response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:30:06 +00:00
kuaifan
165ad03024 feat(ai): add ai-apply/ai-dismiss protocol handlers 2026-01-21 15:30:06 +00:00
kuaifan
3603cf9889 feat(ai): display AI assistant avatar for userid=-1
When a message has userid=-1 (AI assistant), display a special AI avatar
with gradient styling instead of the regular UserAvatar component.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:30:06 +00:00
kuaifan
027662ebab feat(ai): add ai-apply and ai-dismiss API endpoints 2026-01-21 15:30:06 +00:00
kuaifan
106465b932 feat(ai): add AiTaskLoopTask timer and register to crontab 2026-01-21 15:30:06 +00:00
kuaifan
eef4c6fbe5 feat(ai): add AiTaskAnalyzeTask async task 2026-01-21 15:30:06 +00:00
kuaifan
916ae97ca7 feat(ai): add AiTaskSuggestion module with prompt templates
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:30:06 +00:00
kuaifan
841405505d feat(ai): add ProjectTaskAiEvent model 2026-01-21 15:30:06 +00:00
kuaifan
22a653bb0f feat(ai): add project_task_ai_events migration 2026-01-21 15:30:06 +00:00
kuaifan
3482e4b1a8 fix(file): 修复日期格式文件名被误转换导致创建失败的问题
newDateString 函数在处理请求参数时会将所有符合日期格式的字符串
(如 "2026-01-15")转换为完整日期时间格式("2026-01-15 00:00:00"),
导致文件名中出现冒号,触发后端文件名校验错误。

修复方案:
- 直接调用时(key=null),保持原有行为用于显示格式化
- 递归处理对象属性时,仅对白名单字段(times、*_at)进行转换
- 其他字段(如 name)保持原值不转换

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:33:45 +00:00
kuaifan
9097369b0c fix(ai-assistant): 修复图片预览调用不存在方法的错误
将 $A.previewFile 替换为 this.$store.dispatch("previewImage"),
解决 TypeError: $A.previewFile is not a function 错误。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:58:37 +00:00
kuaifan
95c6b53f10 fix(ai-assistant): 优化图片压缩逻辑避免重复质量压缩
- 新增 forceCompress 参数控制是否强制质量压缩
- compressImageForAI: 始终进行质量压缩(发送给 AI)
- saveImageToCache: 仅在需要缩小尺寸时才压缩(避免已压缩图片被重复压缩)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:52:57 +00:00
kuaifan
f7d5040b02 feat(ai-assistant): 支持拖放和粘贴上传图片
- 新增拖放上传:可将图片拖放到对话窗口任意位置
- 新增粘贴上传:在输入框中可直接粘贴剪贴板图片
- 提取 handleImageFiles 通用方法供多种上传方式复用
- 添加拖放时的视觉反馈(虚线边框 + 提示遮罩)
- 使用计数器方式正确处理嵌套元素和拖出窗口的情况

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:45:38 +00:00
kuaifan
26b7f83d35 no message 2026-01-20 14:45:02 +00:00
kuaifan
07b99c6e75 fix(ai-assistant): 修复 SSE 连接失败时状态未正确更新的问题
当 SSE 连接一开始就失败时,响应状态保持 'waiting' 而非 'streaming',
导致 onFailed 回调不会更新状态,UI 一直显示 loading。

现在同时处理 'streaming' 和 'waiting' 状态,并标记为错误状态显示失败提示。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:20:50 +00:00
kuaifan
cb5e7e2cc7 refactor(ai): 优化 AI 提示词构建逻辑
- withLanguagePreferencePrompt: 修复无语言标签时占位符未添加的问题
- handleBeforeSend: 简化操作会话提示词,移除冗余的工具名称说明

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 13:47:25 +00:00
kuaifan
2180998e81 feat(ai-assistant): 添加图片发送功能支持多模态对话
- 支持上传图片并压缩(当前消息 1024px,历史 512px)
- 图片独立缓存存储,使用占位符 [IMG:xxx] 替代 base64
- 新增 prompt-image.vue 组件展示历史图片缩略图
- 后端 AI.php 支持多模态消息格式处理
- 添加图片缓存清理机制(删除会话时同步清理)
- 优化 parsePromptContent 避免重复调用
- 会话标题自动过滤图片占位符

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:31:34 +00:00
kuaifan
478876ddc1 feat(workflow): 在工作流配置中添加规则摘要展示
在工作流展开后的配置表格上方添加规则摘要区块,根据实际配置动态展示:
- 状态负责人规则:区分添加模式、流转模式、剔除模式的不同描述
- 限制负责人规则:显示仅限任务负责人和项目管理员修改状态
- 关联列表规则:显示流转时自动移动至指定列表

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 01:55:00 +00:00
kuaifan
ae021fd148 fix(push): 修复友盟延迟推送已读检查失效的问题
消息ID取值路径错误,导致延迟推送时无法正确判断消息已读状态,
用户在PC端阅读消息后APP仍会收到重复推送。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 00:44:45 +00:00
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
王昱
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
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
6ffd169784 build 2025-09-28 06:54:05 +08:00
527 changed files with 54214 additions and 12780 deletions

1
.agents Symbolic link
View File

@@ -0,0 +1 @@
.claude

View File

@@ -0,0 +1,83 @@
---
name: release
description: Use when releasing a new DooTask frontend version from the `pro` branch. Rigid sequential workflow (translate → version → build → commit → push) with strict pre-checks (branch, clean worktree, Node 20+) and per-step user confirmation. Use when user says "发布新版本", "release", "出新版本", "打版本". Stop on any failure; do NOT auto-fix dirty worktree, do NOT add tag step, do NOT use `git add -A`.
---
# DooTask 发布流程
**刚性技能**——严格按顺序执行,每步向用户确认,任何一步失败立即停止。
## 核心原则
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并或重排步骤。
## 前置检查(全部通过才能继续)
执行任何发布步骤前,依次检查:
1. **分支**:必须是 `pro`,否则停止,提示用户切换
2. **工作区**`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理
3. **Node.js**:必须 ≥ 20否则停止
检查通过后汇报结果,用户确认后再开始执行。
## 发布步骤
**每步执行前**向用户确认;**每步执行后**报告结果。
### Step 1: 翻译
```shell
npm run translate
```
更新多语言翻译文件。
### Step 2: 版本号
```shell
npm run version
```
更新版本号。
### Step 3: 构建前端
```shell
npm run build
```
构建前端生产版本。
## 最终:提交并推送
所有步骤完成后:
1. 通过 `git diff` + `git status` 汇总所有变更,向用户报告摘要
2. **询问用户是否提交并推送**
3. 用户明确确认后才执行 `git add``git commit``git push`
4. 未确认一律不执行
提交规范:
- 提交信息使用 `release: v<新版本号>`(与历史提交风格一致,参见 `git log --oneline | grep '^release:'`
- **只 add 本次发布相关改动**,按文件名显式添加(例如 `git add package.json public/js/...`**不要用 `git add -A``git add .`**,以免卷入未跟踪的本地实验文件
## 失败处理
- 任何步骤失败立即停止,报告错误信息
- **不要**自动重试
- **不要**自动跳过失败步骤
- 由用户决定如何处理
## 禁止项(基线测试暴露的反模式)
| 错误做法 | 正确做法 |
|---------|---------|
| 遇到脏工作区主动提出修复方案(加 `.gitignore`、先 push 等) | **停下**,报告脏工作区事实,交用户决定 |
| 增加 `git tag v1.7.xx` 步骤 | DooTask 现行发布流程**不打 tag**,不要擅自添加 |
| `git add -A` / `git add .` | 按文件名显式添加发布相关改动 |
| 一次性 add + commit + push不给确认机会 | 摘要 → 问确认 → 再 add/commit/push 三步分离 |
| 把 translate/version/build 顺序自作主张调整 | 顺序固定为 translate → version → build |
| 失败后"我再试一次"或"跳过这步" | 立即停止,交还给用户 |
## Red Flags —— 出现这些念头立即停下
- "这个脏工作区我来帮 TA 搞定一下" → 停下,交用户
- "顺便打个 tag 吧" → 不,没有这一步
- "`git add -A` 省事" → 不,显式 add
- "翻译这步没改动可以跳" → 不,按顺序执行、执行后报告结果即可
- "一起 commit + push 一气呵成" → 必须先让用户确认

432
.github/workflows/ios-publish.yml vendored Normal file
View File

@@ -0,0 +1,432 @@
name: "iOS Publish"
# Required GitHub Secrets:
#
# IOS_CERTIFICATE_BASE64 - Apple distribution certificate (.p12) encoded in base64
# IOS_CERTIFICATE_PASSWORD - Password for the .p12 certificate
# IOS_PROVISION_PROFILE_BASE64 - App Store provisioning profile (.mobileprovision) encoded in base64
# IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 - Share extension App Store provisioning profile (.mobileprovision) encoded in base64
# ASC_API_KEY_P8_BASE64 - App Store Connect API key (.p8) encoded in base64
# ASC_API_KEY_ID - App Store Connect API Key ID
# ASC_ISSUER_ID - App Store Connect Issuer ID
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ios-publish-${{ github.ref }}
cancel-in-progress: false
jobs:
prepare-assets:
name: Prepare iOS Assets
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Get version from package.json
id: get-version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Install electron dependencies
run: |
pushd electron
npm install
popd
- name: Init mobile submodule
run: |
git submodule init
git submodule update --remote "resources/mobile"
- name: Build app assets
run: ./cmd appbuild publish
- name: Upload iOS platform artifacts
uses: actions/upload-artifact@v4
with:
name: ios-platform
path: resources/mobile/platforms/ios/
retention-days: 1
build-ios:
name: Build & Submit iOS
needs: prepare-assets
runs-on: macos-26
timeout-minutes: 60
environment: build
steps:
- uses: actions/checkout@v4
- name: Init mobile submodule
run: |
git submodule init
git submodule update --remote "resources/mobile"
- name: Download prepared assets
uses: actions/download-artifact@v4
with:
name: ios-platform
path: resources/mobile/platforms/ios/
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Install CocoaPods
run: |
if [ -f "resources/mobile/platforms/ios/eeuiApp/Podfile" ]; then
cd resources/mobile/platforms/ios/eeuiApp
pod install
fi
- name: Import signing certificate
env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
run: |
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -hex 20)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Import certificate
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH"
security import "$CERTIFICATE_PATH" \
-P "$IOS_CERTIFICATE_PASSWORD" \
-A \
-t cert \
-f pkcs12 \
-k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
- name: Import provisioning profile
env:
IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }}
run: |
set -euo pipefail
APP_PROFILE_PATH=$RUNNER_TEMP/app.mobileprovision
SHARE_PROFILE_PATH=$RUNNER_TEMP/share-extension.mobileprovision
APP_PROFILE_PLIST=$RUNNER_TEMP/app-profile.plist
SHARE_PROFILE_PLIST=$RUNNER_TEMP/share-extension-profile.plist
echo "$IOS_PROVISION_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH"
echo "$IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64" | base64 --decode > "$SHARE_PROFILE_PATH"
security cms -D -i "$APP_PROFILE_PATH" > "$APP_PROFILE_PLIST"
security cms -D -i "$SHARE_PROFILE_PATH" > "$SHARE_PROFILE_PLIST"
APP_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$APP_PROFILE_PLIST")
SHARE_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$SHARE_PROFILE_PLIST")
IOS_TEAM_ID=$(/usr/libexec/PlistBuddy -c "Print :TeamIdentifier:0" "$APP_PROFILE_PLIST")
APP_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$APP_PROFILE_PLIST")
SHARE_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$SHARE_PROFILE_PLIST")
if [ "$APP_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task" ]; then
echo "Expected app profile for $IOS_TEAM_ID.com.dootask.task, got $APP_PROFILE_APP_ID"
exit 1
fi
if [ "$SHARE_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task.shareExtension" ]; then
echo "Expected share extension profile for $IOS_TEAM_ID.com.dootask.task.shareExtension, got $SHARE_PROFILE_APP_ID"
exit 1
fi
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$APP_PROFILE_PLIST" >/dev/null; then
echo "The DooTask app profile must include Push Notifications."
exit 1
fi
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$APP_PROFILE_PLIST" | grep -q "group.im.dootask"; then
echo "The DooTask app profile must include App Group group.im.dootask."
exit 1
fi
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$SHARE_PROFILE_PLIST" | grep -q "group.im.dootask"; then
echo "The share extension profile must include App Group group.im.dootask."
exit 1
fi
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp "$APP_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
cp "$SHARE_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
echo "APP_PROFILE_NAME=$APP_PROFILE_NAME" >> $GITHUB_ENV
echo "SHARE_PROFILE_NAME=$SHARE_PROFILE_NAME" >> $GITHUB_ENV
echo "IOS_TEAM_ID=$IOS_TEAM_ID" >> $GITHUB_ENV
- name: Configure manual signing
run: |
set -euo pipefail
ruby <<'RUBY'
require 'xcodeproj'
project_path = 'resources/mobile/platforms/ios/eeuiApp/eeuiApp.xcodeproj'
project = Xcodeproj::Project.open(project_path)
{
'DooTask' => ENV.fetch('APP_PROFILE_NAME'),
'ShareExtension' => ENV.fetch('SHARE_PROFILE_NAME')
}.each do |target_name, profile_name|
target = project.targets.find { |item| item.name == target_name }
abort "Target #{target_name} not found in #{project_path}" unless target
target.build_configurations.each do |config|
next unless config.name == 'Release'
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
config.build_settings['DEVELOPMENT_TEAM'] = ENV.fetch('IOS_TEAM_ID')
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile_name
end
end
project.save
RUBY
- name: Resolve iOS build number
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
run: |
set -euo pipefail
ruby <<'RUBY'
require 'base64'
require 'json'
require 'net/http'
require 'openssl'
require 'uri'
BUNDLE_ID = 'com.dootask.task'
VERSION_CONFIG_PATH = 'resources/mobile/platforms/ios/eeuiApp/Config/Version.xcconfig'
def base64url(value)
Base64.urlsafe_encode64(value).delete('=')
end
def jwt_es256_signature(private_key, unsigned)
der_signature = private_key.sign('SHA256', unsigned)
sequence = OpenSSL::ASN1.decode(der_signature)
sequence.value.map { |integer|
integer.value.to_s(2).rjust(32, "\0")[-32, 32]
}.join
end
def asc_token
key_id = ENV.fetch('ASC_API_KEY_ID')
issuer_id = ENV.fetch('ASC_ISSUER_ID')
private_key = OpenSSL::PKey.read(Base64.decode64(ENV.fetch('ASC_API_KEY_P8_BASE64')))
now = Time.now.to_i
header = { alg: 'ES256', kid: key_id, typ: 'JWT' }
payload = {
iss: issuer_id,
iat: now,
exp: now + 20 * 60,
aud: 'appstoreconnect-v1'
}
unsigned = "#{base64url(header.to_json)}.#{base64url(payload.to_json)}"
signature = jwt_es256_signature(private_key, unsigned)
"#{unsigned}.#{base64url(signature)}"
end
def asc_get(path, params, token)
uri = URI::HTTPS.build(
host: 'api.appstoreconnect.apple.com',
path: path,
query: URI.encode_www_form(params)
)
request_uri = uri
loop do
response = Net::HTTP.start(request_uri.host, request_uri.port, use_ssl: true) do |http|
request = Net::HTTP::Get.new(request_uri)
request['Authorization'] = "Bearer #{token}"
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
abort "App Store Connect API request failed: #{response.code} #{response.body}"
end
parsed = JSON.parse(response.body)
yield parsed
next_link = parsed.dig('links', 'next')
break unless next_link
request_uri = URI(next_link)
end
end
token = asc_token
app_id = nil
asc_get('/v1/apps', { 'filter[bundleId]' => BUNDLE_ID, 'limit' => 1 }, token) do |page|
app_id = page.fetch('data').first&.fetch('id')
end
abort "App Store Connect app not found for bundle id #{BUNDLE_ID}" unless app_id
existing_versions = []
asc_get('/v1/builds', {
'filter[app]' => app_id,
'fields[builds]' => 'version',
'limit' => 200
}, token) do |page|
existing_versions.concat(
page.fetch('data').map { |build| build.dig('attributes', 'version').to_s }
)
end
max_build_number = existing_versions
.select { |version| version.match?(/\A\d+\z/) }
.map(&:to_i)
.max || 0
next_build_number = max_build_number + 1
config_content = File.exist?(VERSION_CONFIG_PATH) ? File.read(VERSION_CONFIG_PATH) : ''
if config_content.match?(/^VERSION_CODE\s*=/)
config_content = config_content.gsub(/^VERSION_CODE\s*=.*$/, "VERSION_CODE = #{next_build_number}")
else
config_content = "#{config_content.rstrip}\nVERSION_CODE = #{next_build_number}\n"
end
File.write(VERSION_CONFIG_PATH, config_content)
File.open(ENV.fetch('GITHUB_ENV'), 'a') { |file| file.puts "IOS_BUILD_NUMBER=#{next_build_number}" }
puts "Latest App Store Connect build number: #{max_build_number}"
puts "Resolved iOS build number: #{next_build_number}"
RUBY
- name: Build archive
run: |
set -euo pipefail
cd resources/mobile/platforms/ios/eeuiApp
xcodebuild archive \
-workspace eeuiApp.xcworkspace \
-scheme eeuiApp \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
-allowProvisioningUpdates \
DEVELOPMENT_TEAM=$IOS_TEAM_ID \
CODE_SIGN_IDENTITY="Apple Distribution" \
CODE_SIGN_STYLE=Manual \
| xcpretty
if [ ! -d "$RUNNER_TEMP/eeuiApp.xcarchive" ]; then
echo "Archive was not created at $RUNNER_TEMP/eeuiApp.xcarchive"
exit 1
fi
- name: Export IPA
run: |
set -euo pipefail
cd resources/mobile/platforms/ios/eeuiApp
# Generate ExportOptions.plist
cat > $RUNNER_TEMP/ExportOptions.plist << PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>signingStyle</key>
<string>manual</string>
<key>teamID</key>
<string>${IOS_TEAM_ID}</string>
<key>provisioningProfiles</key>
<dict>
<key>com.dootask.task</key>
<string>${APP_PROFILE_NAME}</string>
<key>com.dootask.task.shareExtension</key>
<string>${SHARE_PROFILE_NAME}</string>
</dict>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
-exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \
-exportPath $RUNNER_TEMP/ipa-output \
-allowProvisioningUpdates \
| xcpretty
- name: Submit to App Store Connect
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
run: |
set -euo pipefail
# Prepare API key
mkdir -p ~/private_keys
echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > ~/private_keys/AuthKey_${ASC_API_KEY_ID}.p8
# Find and upload IPA
IPA_PATH=$(find $RUNNER_TEMP/ipa-output -name "*.ipa" | head -1)
if [ -z "$IPA_PATH" ]; then
echo "No IPA file found in $RUNNER_TEMP/ipa-output"
exit 1
fi
echo "Uploading: $IPA_PATH"
xcrun altool --upload-app \
-f "$IPA_PATH" \
--type ios \
--apiKey "$ASC_API_KEY_ID" \
--apiIssuer "$ASC_ISSUER_ID"
- name: Clean up
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true
rm -f $RUNNER_TEMP/certificate.p12
rm -f $RUNNER_TEMP/app.mobileprovision
rm -f $RUNNER_TEMP/share-extension.mobileprovision
rm -f $RUNNER_TEMP/app-profile.plist
rm -f $RUNNER_TEMP/share-extension-profile.plist
rm -rf ~/private_keys

View File

@@ -52,53 +52,18 @@ jobs:
uses: actions/github-script@v7
with:
script: |
// 获取最新的 tag
const { data: tags } = await github.rest.repos.listTags({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 1
});
const fs = require('fs');
const version = '${{ needs.check-version.outputs.version }}';
// 获取提交日志
// 从 CHANGELOG.md 提取当前版本段落
let changelog = '';
if (tags.length > 0) {
const { data: commits } = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base: tags[0].name,
head: 'HEAD'
});
// 按类型分组提交
const groups = {
'feat:': { title: '## Features', commits: new Set() },
'fix:': { title: '## Bug Fixes', commits: new Set() },
'perf:': { title: '## Performance Improvements', commits: new Set() }
};
// 分类收集提交,使用 Set 去重
commits.commits.forEach(commit => {
const message = commit.commit.message.split('\n')[0].trim();
for (const [prefix, group] of Object.entries(groups)) {
if (message.startsWith(prefix)) {
// 移除前缀后添加到对应分组
const cleanMessage = message.slice(prefix.length).trim();
group.commits.add(cleanMessage); // 使用 Set.add 自动去重
break;
}
}
});
// 生成更新日志
const sections = [];
for (const group of Object.values(groups)) {
if (group.commits.size > 0) {
sections.push(`${group.title}\n\n${Array.from(group.commits).map(msg => `- ${msg}`).join('\n')}`);
}
}
if (sections.length > 0) {
changelog = '# Changelog\n\n' + sections.join('\n\n');
const changelogPath = 'CHANGELOG.md';
if (fs.existsSync(changelogPath)) {
const content = fs.readFileSync(changelogPath, 'utf-8');
const regex = new RegExp(`## \\[${version.replace(/\./g, '\\.')}\\][\\s\\S]*?(?=\\n## \\[|$)`);
const match = content.match(regex);
if (match) {
changelog = match[0].trim();
}
}
@@ -106,8 +71,8 @@ jobs:
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `v${{ needs.check-version.outputs.version }}`,
name: `${{ needs.check-version.outputs.version }}`,
tag_name: `v${version}`,
name: version,
body: changelog || 'No significant changes in this release.',
draft: true,
prerelease: false
@@ -215,7 +180,11 @@ jobs:
- name: (Android) Upload File
if: matrix.build_type == 'android'
env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
run: |
node ./electron/build.js android-upload
@@ -253,7 +222,11 @@ jobs:
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
@@ -263,7 +236,11 @@ jobs:
- name: (Windows) Build Client
if: matrix.build_type == 'windows'
env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
shell: bash
@@ -294,11 +271,16 @@ jobs:
prerelease: false
})
- name: Publish Official
- name: Upload Changelog & Publish to Website
env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
run: |
pushd electron || exit
npm install
popd || exit
node ./electron/build.js published
node ./electron/build.js upload-changelog
node ./electron/build.js release

45
.github/workflows/sync-gitee.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: "Sync to Gitee"
# Required GitHub Secrets:
#
# GITEE_SSH_PRIVATE_KEY - SSH private key with push access to gitee.com/aipaw/dootask
on:
workflow_run:
workflows: ["Publish"]
types:
- completed
jobs:
sync:
name: Push to Gitee
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup SSH key
env:
GITEE_SSH_PRIVATE_KEY: ${{ secrets.GITEE_SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$GITEE_SSH_PRIVATE_KEY" > ~/.ssh/gitee_key
chmod 600 ~/.ssh/gitee_key
cat >> ~/.ssh/config << EOF
Host gitee.com
HostName gitee.com
IdentityFile ~/.ssh/gitee_key
StrictHostKeyChecking no
EOF
- name: Push to Gitee
run: |
git remote add gitee git@gitee.com:aipaw/dootask.git
git push gitee pro
- name: Clean up
if: always()
run: rm -rf ~/.ssh/gitee_key

7
.gitignore vendored
View File

@@ -23,6 +23,9 @@
vars.yaml
# IDE and editor files
.cursor/*
!.cursor/rules/
!.cursor/rules/**
.idea
.vscode
.windsurfrules
@@ -57,5 +60,7 @@ laravels.pid
.DS_Store
# Documentation
AGENTS.md
README_LOCAL.md
# playwright
.playwright-mcp/

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -2,6 +2,286 @@
All notable changes to this project will be documented in this file.
## [1.7.67]
### Features
- 聊天待办现在可以设置提醒时间,到点会引用原消息并提醒相关人员,避免遗漏重要事项。
- 团队管理支持管理员创建或批量导入员工账号,并可填写部门、职位等信息,添加成员更方便。
- 系统设置新增聊天待办权限控制,可限制其他人员设置或取消聊天待办。
### Bug Fixes
- 设置内容没有变化时不再重复保存,减少无效操作,让使用更稳定。
### Documentation
- 补充路由使用限制说明,帮助使用者更清楚地了解规则。
- 统一回复语言偏好说明,确保整段回复使用简体中文。
## [1.7.55]
### Features
- 新增部门负责人只读视角,可查看部门成员的项目和任务,并按可见性设置控制展示范围。
- 群组、项目和部门支持主负责人 + 副负责人,协作管理更灵活。
- 新增共享任务模板,支持跨项目使用、搜索和使用统计,复用常用任务更方便。
- 管理页侧边栏支持拖拽调整宽度,使用不同屏幕时更顺手。
- 优化任务添加界面,模板浏览和加载提示更清晰。
- 项目归档设置选择系统默认规则时,会显示对应提示,减少误操作。
- 聊天消息中的表格显示更稳定,单元格内容不再随意换行。
- 支持按需调整翻译使用的模型,便于适配不同使用场景。
### Bug Fixes
- 修复权限变更过程中可能出现的可见性或访问异常。
- 修复 AI 自动分析开关状态判断不准确的问题。
- 修复用户详情页在部分情况下出现横向滚动的问题。
- 优化应用发布流程,提升发布稳定性。
## [1.7.29]
### Features
- AI 助手聊天记录现在可自动保存,换设备或重新打开后也能继续查看历史对话。
### Bug Fixes
- 改善 AI 助手中长图的显示清晰度,减少图片被压缩后变模糊的问题。
- 修复部分企业账号环境下用户搜索失败、密码规则异常的问题。
## [1.7.23]
### Features
- 支持使用非邮箱形式的用户名登录,登录方式更灵活,也更适合接入常见的企业账号环境。
- 进一步优化与 Active Directory 的兼容性,企业用户接入和登录更顺畅。
### Bug Fixes
- 修复部分企业账号环境下的登录问题,提升账号验证的稳定性和成功率。
- 修复上传或发布失败时提示不明确的问题,方便更快发现并处理失败情况。
## [1.7.20]
### Bug Fixes
- 优化了 LDAP 登录方式,更好兼容 Active Directory企业账号登录更稳定。
## [1.7.14]
### Features
- 新增消息合并转发,支持批量选择后一次转发,分享聊天内容更方便。
- 现在可以按项目负责人筛选任务,查找和整理任务更省时。
- 支持解除任务关联,调整任务关系更灵活。
- 新增 AI 自动分析开关,可按需开启或关闭,使用起来更可控。
- 安装和修改设置时会自动检查应用编号与端口是否冲突,减少配置出错和无法启动的情况。
- 支持自定义 AI 服务地址,连接和接入方式更灵活。
### Bug Fixes
- 修复了 AI 助手在部分页面显示异常的问题,查看和使用时更稳定。
## [1.6.89]
### Features
- AI 助手支持拖放、粘贴上传图片,并可直接发送图片参与对话,交流更直观
- AI 任务建议支持多语言输出,跨语言使用更顺畅
- 工作流配置新增规则摘要展示,规则一眼看懂,减少来回查看
### Bug Fixes
- 修复工作流在切换、完成/取消完成任务时状态不同步的问题,避免状态错乱
- 修复 AI 建议触发条件与头像显示异常,展示更稳定、体验更一致
- 修复部分提示文案显示不正确的问题,信息更清晰
- 修复描述格式与负责人重复显示的问题,页面更整洁
- 修复点击 AI 相关链接时解析失败的问题,打开更可靠
- 修复因日期格式导致文件名被误处理而创建失败的问题,上传更稳定
- 修复网络连接异常时状态未正确更新的问题,避免卡住
- 修复延迟推送的已读检查偶发失效的问题,提醒更准确
## [1.6.51]
### AI 助手更新
- 新增全屏模式,支持拖动边缘调整聊天窗口大小
- 新增可拖拽浮动按钮,支持自动贴边收起
- 支持 ↑ / ↓ 快速切换历史输入,并可编辑后重新发送
- 按使用场景保存并恢复会话,切换不串内容
- 连续操作过程展示更清晰
- 新增更多提示词与随机推荐,获取灵感更方便
- 文件能力增强:支持读取文本内容与大文件分段读取
- 搜索能力提升:支持更灵活的匹配与关键词查找
- 新增 AI 协助部分前端操作
- 新建菜单优化,新增 AI 助手快捷入口
- 优化内容逐步输出时的加载提示显示
### 其他更新
- 修复弹窗、下拉菜单可能被遮挡的问题
- 优化快捷键响应与事件处理,操作更流畅稳定
- 优化其他已知问题
## [1.6.27]
### Features
- 新增全局悬浮AI入口随时更快打开常用功能
- 新增AI助手窗口模式并能根据当前页面自动更贴合你的使用场景
- 支持为不同场景设置AI自定义标题界面展示更清晰
- 同步失败时会自动重试,减少手动操作和中断
### Bug Fixes
- 修复下载功能偶尔无法启动的问题
- 修复深色模式下部分显示异常,并优化相关操作体验
- 修复「@ 提及」下拉列表偶尔被遮挡的问题
- 修复部分情况下连接判断不准确导致的问题
## [1.6.10]
### Features
- 新增“消息搜索”,找聊天记录更方便、更快
- 搜索支持按对话筛选,快速定位到某个会话里的内容
- 文件内容也能参与搜索,查资料更省事
- AI 助手体验升级:支持回车快捷发送,并优化链接处理与界面使用感
- 标签页功能增强:支持拖拽排序、拖拽合并插入到指定位置,且新增“更多”菜单入口
- 文件管理界面改版:操作区域更清晰,使用更顺手
- 语音转文字升级:识别更稳定,转写更顺畅
- 复制任务/周期任务时可同时复制子任务,并自动重置状态,便于快速复用
- 打卡支持跨天,并增加时间重叠提醒,记录更准确
- 审批详情支持更清晰的换行显示,阅读更轻松
### Bug Fixes
- 修复上传文件夹时可能卡死的问题
- 修复关闭窗口/标签页时未保存内容提示失效的问题,避免误丢内容
- 修复多标签页关闭后可能引发崩溃的问题
- 修复跨项目移动任务后,子任务状态未同步更新的问题
- 修复权限同步不完整导致的异常问题
- 优化会话列表待办完成提示,显示实际最后完成的人
### Performance
- 标签页加载与预加载优化,打开网页/切换标签更顺畅
- 常用图标加载优化,显示更快、更稳定
- 搜索与 AI 相关流程优化,整体响应更快
## [1.5.18]
### Features
- 搜索更好用:可通过 ID、名称等信息快速找到任务、文件和报告
- 可设置默认优先级,新建任务更省心
- 斜杠命令体验提升:输入更规范,支持更多指令,并在输入时提供更准确的触发与提示
### Bug Fixes
- 修复部分情况下会弹出空白窗口的问题
- 修复提及列表在某些场景下显示异常的问题
- 修复行前缀识别不准导致空行判断错误的问题
### Performance
- 提升数据处理效率,任务与消息相关操作更流畅
## [1.5.5]
### Features
- 优化消息列表工具的说明,让你更容易理解和使用相关功能。
### Bug Fixes
- 调整任务与子任务的进度展示方式,让进度显示更加准确一致。
- 优化子任务相关数据的加载方式,减少不必要的请求,提升使用流畅度。
- 修复 Android 16 系统返回键不能正常使用的问题,现在返回操作更加顺畅友好。
## [1.4.99]
### Features
- 优化群组资料修改方式,增加权限校验和名称修改提醒,减少误改、改错的情况。
- 调整群组名称编辑入口,改为更明显的修改按钮,更好理解也更好用。
- 优化微应用菜单和配置逻辑,兼容旧版本配置,减少升级后菜单不显示或打不开的问题。
## [1.4.88]
### Features
- 新增导航功能,支持快捷键和鼠标手势,操作更顺手高效
- 优化顶部胶囊区域的显示逻辑,显示更智能更贴合使用场景
- 更新内置应用商店版本,带来更稳定的应用安装与更新体验
### Bug Fixes
- 修复部分情况下无法打开微应用的问题,使用更稳定
## [1.4.81]
### Features
- 优化周报、日报中已完成和未完成任务的统计规则,任务数据更准确。
- 改进消息推送逻辑,并支持点击消息直接打开相关微应用,查看内容更便捷。
- 更新内置应用商店版本,提升整体可用性和稳定性。
### Bug Fixes
- 修复任务导出时状态判断错误和状态高亮列错位的问题,导出结果更清晰。
- 修复任务统计导出时,部分无计划时间但已完成的任务会被漏掉的问题,统计更完整。
- 修复关闭应用时加载状态未及时更新的问题,应用退出过程更自然。
- 调整弹窗的最小高度设置,显示更加美观。
- 提升内嵌页面的安全设置,使用更放心。
## [1.4.67]
### Features
- 支持为每个人设置「未完成任务上限」,避免一次接太多任务,方便控制工作节奏。
- 任务列表支持“一键归档已完成任务”,让列表更清爽。
- 支持自定义微应用菜单,可按实际需要配置和保存菜单项,入口更符合团队习惯。
- 调整窗口和各组件的高度表现,在不同窗口大小下内容显示更合理。
- 更新默认智能助手模型为更高效版本,回答速度和质量更均衡。
- 优化文件管理页面展示效果:列表更清晰,文件内容与侧边抽屉的外观更统一。
### Bug Fixes
- 修复桌面端部分设备在打开新窗口时可能出现的报错问题,使用更稳定。
- 修复部分组件在全屏或窗口变化时高度计算不准确的问题,避免内容被遮挡或留白过多。
- 修正微模态弹窗的定位问题,在全屏场景下显示更正常。
## [1.4.43]
### Bug Fixes
- 修复登录后出现404的问题
## [1.4.35]
### Features
- 工作报告全面升级支持创建、模板生成、查看列表与详情AI 一键整理与分析,并可标记已读/未读。
- AI 助手更好用:可自选偏好的 AI 模型,默认模型升级,支持语言偏好设置,界面与入口更清晰。
- 新增聊天输入历史,可使用上下方向键切换历史记录。
- 支持应用排序,可以自定义应用列表顺序。
- 文本与内容处理增强:自动提取文本,内容解析更准确;长文本预览更友好。
- 资料与社交完善:新增生日、地址、个人简介;个性标签管理;在用户详情页可创建群组、查看共同群组,页面样式更统一。
- 设备与交互优化触屏体验更顺手iPad 适配与横屏显示更合理;导出菜单、链接打开、列表滚动和右键菜单体验更好;隐私政策页面更易读。
### Bug Fixes
- 修复 Android 分享页元素重叠,内容显示更清晰。
- 修正对话框尺寸计算,避免偶发的发送失败。
### Performance
- AI 响应更快,等待时间更短。
- 语音识别更稳定、更准确。
- 滚动与交互更顺滑,操作更流畅。
## [1.3.55]
### Features

62
CLAUDE.md Normal file
View File

@@ -0,0 +1,62 @@
## 项目概述
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
## 开发命令
所有命令通过 `./cmd` 脚本执行(不要直接运行 `php artisan` 等):
- `./cmd artisan ...` / `./cmd composer ...` / `./cmd php ...` — PHP 相关命令
### AI 不要主动执行的命令
以下命令仅由用户人工触发AI 不要主动跑——包括"任务完成后 sanity check"、"看下能不能编译"等场景:
- `./cmd dev` — 用户已自行运行 dev server改完会自己 reloadAI 再跑会争抢进程
- `./cmd prod` / `./cmd build` — 发版才用,走 `/release` 流程
前端代码改动只做 Edit/Write不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
## Gotchas
### LaravelS/Swoole
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
- 长生命周期逻辑WebSocket、定时器应复用现有模式避免阻塞协程/事件循环
### 后端
- **非 REST 路由**API 控制器(继承 `InvokeController`)在 `routes/web.php` 按资源注册路由URL 段映射为控制器方法(如 `api/project/lists``lists()`,带 action 则用双下划线:`api/project/invite/join``invite__join()`
- 路由最多两段:方法名最多一个双下划线(`method__action`),不支持 `method__action__xxx`(无对应路由,访问 404
- **响应格式**:统一使用 `Base::retSuccess($msg, $data)` / `Base::retError($msg)`,返回 `{"ret": 1, "msg": "...", "data": {...}}`——不要用 `response()->json()`
- 业务异常通过 `App\Exceptions\ApiException` 抛出,不要用通用 Exception
- 模型继承 `AbstractModel`,使用 `Model::createInstance($params)` 创建——不要用 `new Model()``Model::create()`
- 认证使用 `Doo::userId()`——不要用 `auth()->user()`
- 参数校验在控制器方法中手动进行——不要创建 FormRequest 类
- 异步任务使用 Swoole Task`app/Tasks/`)——不要用 Laravel Queue
- `app/Module/` 存放跨控制器/跨模型的业务逻辑(非标准 Laravel 目录)
- 所有表结构变更必须通过 Laravel migration禁止直接改库
### 前端
- API 调用使用 `store.dispatch("call", params)`,不要在组件中直接 axios/fetch
- `$A.modalXXX``$A.messageXXX``$A.noticeXXX` 内部自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当传入 `language: false` 时由调用方自行处理翻译
### 国际化
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
## Playwright 测试
- Playwright 测试结果放在 `tests/playwright-results/`,包含测试环境、测试用例、结果截图等信息
## 交互规范
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
## 语言偏好
- 回复一律使用简体中文,除非用户明确要求其他语言

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

@@ -0,0 +1,307 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\AiAssistantSession;
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;
}
/**
* 获取会话列表
*/
public function session__list()
{
$user = User::auth();
$sessionKey = trim(Request::input('session_key', 'default'));
$sessions = AiAssistantSession::where('userid', $user->userid)
->where('session_key', $sessionKey)
->orderByDesc('updated_at')
->get();
$list = [];
foreach ($sessions as $session) {
$data = Base::json2array($session->data);
$images = Base::json2array($session->images);
foreach ($images as $imageId => $path) {
$images[$imageId] = Base::fillUrl($path);
}
$list[] = [
'id' => $session->session_id,
'title' => $session->title,
'responses' => $data,
'images' => $images,
'sceneKey' => $session->scene_key,
'createdAt' => $session->created_at ? $session->created_at->getTimestampMs() : 0,
'updatedAt' => $session->updated_at ? $session->updated_at->getTimestampMs() : 0,
];
}
return Base::retSuccess('success', $list);
}
/**
* 保存会话
*/
public function session__save()
{
$user = User::auth();
$sessionKey = trim(Request::input('session_key', 'default'));
$sessionId = trim(Request::input('session_id', ''));
$sceneKey = trim(Request::input('scene_key', ''));
$title = trim(Request::input('title', ''));
$data = Request::input('data', []);
$newImages = Request::input('new_images', []);
if (empty($sessionId)) {
return Base::retError('session_id 不能为空');
}
$newImageUrls = [];
if (is_array($newImages)) {
$path = 'uploads/assistant/' . date('Ym') . '/' . $user->userid . '/';
foreach ($newImages as $img) {
$imageId = $img['imageId'] ?? '';
$dataUrl = $img['dataUrl'] ?? '';
if (empty($imageId) || empty($dataUrl)) {
continue;
}
$result = Base::image64save([
'image64' => $dataUrl,
'path' => $path,
'autoThumb' => false,
]);
if (Base::isSuccess($result)) {
$newImageUrls[$imageId] = $result['data']['path'];
}
}
}
$session = AiAssistantSession::where('userid', $user->userid)
->where('session_key', $sessionKey)
->where('session_id', $sessionId)
->first();
$imageMap = $newImageUrls;
if ($session) {
$existingImages = Base::json2array($session->images);
$imageMap = array_merge($existingImages, $newImageUrls);
}
$session = AiAssistantSession::createInstance([
'userid' => $user->userid,
'session_key' => $sessionKey,
'session_id' => $sessionId,
'scene_key' => $sceneKey,
'title' => mb_substr($title, 0, 255),
'data' => Base::array2json(is_array($data) ? $data : []),
'images' => Base::array2json($imageMap),
], $session?->id);
$session->save();
// 仅返回本次新增的图片URL
$urls = [];
foreach ($newImageUrls as $imageId => $path) {
$urls[$imageId] = Base::fillUrl($path);
}
return Base::retSuccess('success', [
'image_urls' => $urls,
]);
}
/**
* 删除会话
*/
public function session__delete()
{
$user = User::auth();
$sessionKey = trim(Request::input('session_key', 'default'));
$sessionId = trim(Request::input('session_id', ''));
$clearAll = Request::input('clear_all', false);
$query = AiAssistantSession::where('userid', $user->userid)
->where('session_key', $sessionKey);
if ($clearAll) {
$sessions = $query->get();
foreach ($sessions as $session) {
$this->deleteSessionImages($session);
}
$query->delete();
} else {
if (empty($sessionId)) {
return Base::retError('session_id 不能为空');
}
$session = $query->where('session_id', $sessionId)->first();
if ($session) {
$this->deleteSessionImages($session);
$session->delete();
}
}
return Base::retSuccess('success');
}
private function deleteSessionImages(AiAssistantSession $session)
{
$images = Base::json2array($session->images);
foreach ($images as $path) {
$fullPath = public_path($path);
if (file_exists($fullPath)) {
@unlink($fullPath);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,10 @@ 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 Swoole\Coroutine;
use Carbon\Carbon;
@@ -64,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 返回信息(错误描述)
@@ -72,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;
@@ -88,12 +102,12 @@ class FileController extends AbstractController
}
return Base::retError($msg, $data);
}
// 如果文件不允许游客访问,则需要登录
if (!$file->guest_access) {
User::auth();
}
$fileLink->increment("num");
} else {
return Base::retError('参数错误');
@@ -101,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/fetch 通过路径获取文件文本内容
*
* @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身份
* @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 返回信息(错误描述)
@@ -135,6 +203,7 @@ class FileController extends AbstractController
return Base::retSuccess('success', []);
}
}
// 搜索自己的
$builder = File::whereUserid($user->userid);
if ($id) {
@@ -142,7 +211,9 @@ class FileController extends AbstractController
}
if ($key) {
if (!$id && Base::isNumber($key)) {
$builder->where("id", $key);
$builder->where(function ($query) use ($key) {
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
});
} else {
$builder->where("name", "like", "%{$key}%");
}
@@ -164,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()) {
@@ -399,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;
@@ -411,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;
@@ -744,10 +821,20 @@ 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;
}
}
/**

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;
@@ -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']}%");
}
@@ -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']}%");
}
@@ -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();
}
@@ -501,9 +538,111 @@ 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 {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 标记已读/未读
*
@@ -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

@@ -44,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 返回信息(错误描述)
@@ -71,8 +71,6 @@ class SystemController extends AbstractController
'project_invite',
'chat_information',
'anon_message',
'voice2text',
'translation',
'convert_video',
'compress_video',
'e2e_message',
@@ -82,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',
@@ -94,6 +93,9 @@ class SystemController extends AbstractController
'file_upload_limit',
'unclaimed_task_reminder',
'unclaimed_task_reminder_time',
'task_ai_auto_analyze',
'department_owner_project_view',
'todo_set_permission',
])) {
unset($all[$key]);
}
@@ -106,12 +108,6 @@ class SystemController extends AbstractController
return Base::retError('自动归档时间不可大于100天');
}
}
if ($all['voice2text'] == 'open' && !Setting::AIOpen()) {
return Base::retError('开启语音转文字功能需要先设置 AI 助理。');
}
if ($all['translation'] == 'open' && !Setting::AIOpen()) {
return Base::retError('开启翻译功能需要先设置 AI 助理。');
}
if ($all['system_alias'] == env('APP_NAME')) {
$all['system_alias'] = '';
}
@@ -138,8 +134,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';
@@ -149,12 +143,15 @@ class SystemController extends AbstractController
$setting['archived_day'] = floatval($setting['archived_day']) ?: 7;
$setting['task_visible'] = $setting['task_visible'] ?: 'close';
$setting['all_group_mute'] = $setting['all_group_mute'] ?: 'open';
$setting['todo_set_permission'] = $setting['todo_set_permission'] ?: 'open';
$setting['all_group_autoin'] = $setting['all_group_autoin'] ?: 'yes';
$setting['user_private_chat_mute'] = $setting['user_private_chat_mute'] ?: 'open';
$setting['user_group_chat_mute'] = $setting['user_group_chat_mute'] ?: 'open';
$setting['file_upload_limit'] = $setting['file_upload_limit'] ?: '';
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
$setting['server_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion();
//
@@ -286,49 +283,17 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/ai AI助手设置限管理员
* AI助手设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
* @apiName setting__ai
*
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存设置(参数:['ai_provider', 'ai_api_key', 'ai_api_url', 'ai_proxy']
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
*/
public function setting__ai()
{
User::auth('admin');
//
$type = trim(Request::input('type'));
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
return Base::retError('当前环境禁止修改');
}
$all = Base::newTrim(Request::input());
foreach ($all as $key => $value) {
if (!in_array($key, [
'ai_provider',
'ai_api_key',
'ai_api_url',
'ai_proxy',
])) {
unset($all[$key]);
}
}
$setting = Base::setting('aiSetting', Base::newTrim($all));
} else {
$setting = Base::setting('aiSetting');
}
//
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
Base::checkClientVersion('1.4.35');
}
/**
* @api {get} api/system/setting/aibot 获取会议设置、保存AI机器人设置限管理员
* @api {get} api/system/setting/aibot 获取AI设置、保存AI机器人设置限管理员
*
* @apiVersion 1.0.0
* @apiGroup system
@@ -386,62 +351,23 @@ class SystemController extends AbstractController
}
/**
* @api {get} api/system/setting/aibot_models 获取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 获取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 AI::ollamaModels($baseUrl, $key, $agency);
}
$models = Setting::AIBotDefaultModels($type);
if (empty($models)) {
return Base::retError('未找到默认模型');
}
return Base::retSuccess('success', [
'models' => $models
]);
Base::checkClientVersion('1.4.35');
}
/**
@@ -536,6 +462,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');
@@ -672,6 +616,7 @@ class SystemController extends AbstractController
'ldap_password',
'ldap_user_dn',
'ldap_base_dn',
'ldap_login_attr',
'ldap_sync_local'
])) {
unset($all[$key]);
@@ -685,6 +630,7 @@ class SystemController extends AbstractController
//
$setting['ldap_open'] = $setting['ldap_open'] ?: 'close';
$setting['ldap_port'] = intval($setting['ldap_port']) ?: 389;
$setting['ldap_login_attr'] = $setting['ldap_login_attr'] ?: 'cn';
$setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close';
//
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
@@ -777,32 +723,62 @@ 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($type == 'save' ? '保存成功' : 'success', $setting);
}
/**
* @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 创建项目模板
*
@@ -1321,6 +1297,8 @@ 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;
//
$botUser = User::botGetOrCreate('system-msg');
if (empty($botUser)) {
@@ -1329,7 +1307,7 @@ class SystemController extends AbstractController
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
//
$doo = Doo::load();
go(function () use ($doo, $secondStart, $secondEnd, $time, $userid, $date, $user, $botUser, $dialog) {
go(function () use ($doo, $secondStart, $secondEnd, $time, $userid, $date, $user, $botUser, $dialog, $delaySeconds) {
Coroutine::sleep(1);
//
$headings = [];
@@ -1366,9 +1344,10 @@ class SystemController extends AbstractController
$index++;
$sameDate = date("Y-m-d", $startT);
$sameTimes = $recordTimes[$sameDate] ?? [];
$sameCollect = UserCheckinRecord::atCollect($sameDate, $sameTimes);
$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)];
// 扩展下班打卡范围以支持跨天打卡
$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;

View File

@@ -34,10 +34,16 @@ use App\Models\WebSocketDialogUser;
use App\Models\UserTaskBrowse;
use App\Models\UserFavorite;
use App\Models\UserRecentItem;
use App\Models\UserTag;
use App\Models\UserTagRecognition;
use App\Models\UserAppSort;
use Illuminate\Support\Facades\DB;
use App\Models\UserEmailVerification;
use App\Module\AgoraIO\AgoraTokenGenerator;
use Swoole\Coroutine;
use App\Module\UserImport;
use App\Module\UserImportTemplate;
use Maatwebsite\Excel\Facades\Excel;
/**
* @apiDefine users
@@ -297,6 +303,8 @@ class UsersController extends AbstractController
* @apiGroup users
* @apiName token__expire
*
* @apiParam {Number} [refresh] 是否刷新 token1=是token 剩余有效期不足总有效期的 1/3 时才会刷新
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
@@ -304,10 +312,11 @@ class UsersController extends AbstractController
* @apiSuccess {Number|null} data.remaining_seconds 距离过期剩余秒数(负值表示已过期)
* @apiSuccess {Boolean} data.expired token 是否已过期
* @apiSuccess {String} data.server_time 当前服务器时间
* @apiSuccess {String} [data.token] 刷新后的新 token仅当 refresh=1 且 token 即将过期时返回)
*/
public function token__expire()
{
User::auth();
$user = User::auth();
$expiredAt = Doo::userExpiredAt();
$expired = Doo::userExpired();
$expiredAtCarbon = $expiredAt ? Carbon::parse($expiredAt) : null;
@@ -317,6 +326,14 @@ class UsersController extends AbstractController
'expired' => $expired,
'server_time' => Carbon::now()->toDateTimeString(),
];
// 请求刷新 token剩余有效期不足总有效期的 1/3 时才刷新
if (Request::input('refresh') && $expiredAtCarbon) {
$tokenValidDays = max(1, intval(Base::settingFind('system', 'token_valid_days', 30)));
$refreshThresholdDays = ceil($tokenValidDays / 3);
if ($expiredAtCarbon->isBefore(Carbon::now()->addDays($refreshThresholdDays))) {
$data['token'] = User::generateToken($user, true);
}
}
return Base::retSuccess('success', $data);
}
@@ -374,10 +391,14 @@ class UsersController extends AbstractController
//
$refreshToken = false;
if (in_array(Base::platform(), ['ios', 'android'])) {
// 移动端token还剩7天到期时获取新的token
// 移动端token剩余有效期不足总有效期的1/3时获取新的token
$expiredAt = Doo::userExpiredAt();
if ($expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now()->addDays(7))) {
$refreshToken = true;
if ($expiredAt) {
$tokenValidDays = max(1, intval(Base::settingFind('system', 'token_valid_days', 30)));
$refreshThresholdDays = ceil($tokenValidDays / 3);
if (Carbon::parse($expiredAt)->isBefore(Carbon::now()->addDays($refreshThresholdDays))) {
$refreshToken = true;
}
}
}
User::generateToken($user, $refreshToken);
@@ -386,9 +407,22 @@ class UsersController extends AbstractController
$data['nickname_original'] = $user->getRawOriginal('nickname');
$data['department_name'] = $user->getDepartmentName();
$data['department_owner'] = UserDepartment::where('parent_id',0)->where('owner_userid', $user->userid)->exists(); // 适用默认部门下第1级负责人才能添加部门OKR
$data['managed_departments'] = UserDepartment::getManagedDepartments($user->userid)->toArray();
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/users/info/managed_departments 获取我可切换负责人视角的部门列表
*/
public function info__managed_departments()
{
$user = User::auth();
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
return Base::retSuccess('success', []);
}
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
}
/**
* @api {get} api/users/info/departments 获取我的部门列表
*
@@ -446,6 +480,9 @@ class UsersController extends AbstractController
* @apiParam {String} [tel] 电话
* @apiParam {String} [nickname] 昵称
* @apiParam {String} [profession] 职位/职称
* @apiParam {String} [birthday] 生日格式YYYY-MM-DD
* @apiParam {String} [address] 地址
* @apiParam {String} [introduction] 个人简介
* @apiParam {String} [lang] 语言比如zh/en
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
@@ -509,6 +546,40 @@ class UsersController extends AbstractController
$upLdap['employeeType'] = $profession;
}
}
// 生日
if (Arr::exists($data, 'birthday')) {
$birthday = trim((string) Request::input('birthday'));
if ($birthday === '') {
$user->birthday = null;
} else {
try {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthday)) {
$birthdayDate = Carbon::createFromFormat('Y-m-d', $birthday);
} else {
$birthdayDate = Carbon::parse($birthday);
}
} catch (\Exception $e) {
return Base::retError('生日格式错误');
}
$user->birthday = $birthdayDate->format('Y-m-d');
}
}
// 地址
if (Arr::exists($data, 'address')) {
$address = trim((string) Request::input('address'));
if (mb_strlen($address) > 100) {
return Base::retError('地址最多只能设置100个字');
}
$user->address = $address ?: null;
}
// 个人简介
if (Arr::exists($data, 'introduction')) {
$introduction = trim((string) Request::input('introduction'));
if (mb_strlen($introduction) > 500) {
return Base::retError('个人简介最多只能设置500个字');
}
$user->introduction = $introduction ?: null;
}
// 语言
if (Arr::exists($data, 'lang')) {
$lang = trim(Request::input('lang'));
@@ -622,7 +693,12 @@ class UsersController extends AbstractController
if (str_contains($keys['key'], "@")) {
$builder->where("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("nickname", "like", "%{$keys['key']}%")
->orWhere("pinyin", "like", "%{$keys['key']}%")
->orWhere("profession", "like", "%{$keys['key']}%");
});
} else {
$builder->where(function($query) use ($keys) {
$query->where("nickname", "like", "%{$keys['key']}%")
@@ -786,13 +862,75 @@ class UsersController extends AbstractController
$basic = UserDelete::userid2basic($id);
}
if ($basic) {
//
$retArray[] = $basic;
}
}
return Base::retSuccess('success', $retArray);
}
/**
* @api {get} api/users/extra 获取会员扩展信息
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName extra
*
* @apiParam {Number} [userid] 会员ID不传默认为当前用户
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function extra()
{
$user = User::auth();
//
$userid = intval(Request::input('userid'));
if ($userid <= 0) {
$userid = $user->userid;
}
if ($userid <= 0) {
return Base::retError('会员不存在');
}
$user = User::query()
->select(['userid', 'birthday', 'address', 'introduction'])
->whereUserid($userid)
->first();
$birthday = null;
$address = null;
$introduction = null;
if ($user) {
$birthday = $user->birthday;
$address = $user->address;
$introduction = $user->introduction;
} else {
$deleted = UserDelete::whereUserid($userid)->first();
if (empty($deleted) || empty($deleted->cache)) {
return Base::retError('会员不存在');
}
$birthday = $deleted->cache['birthday'] ?? null;
$address = $deleted->cache['address'] ?? null;
$introduction = $deleted->cache['introduction'] ?? null;
}
$tagMeta = UserTag::listWithMeta($userid, $user);
$data = [
'userid' => $userid,
'birthday' => $birthday,
'address' => $address,
'introduction' => $introduction,
'personal_tags' => $tagMeta['top'],
'personal_tags_total' => $tagMeta['total'],
];
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/users/lists 会员列表(限管理员)
*
@@ -1128,7 +1266,7 @@ class UsersController extends AbstractController
User::passwordPolicy($password);
$upArray['encrypt'] = Base::generatePassword(6);
$upArray['password'] = Doo::md5s($password, $upArray['encrypt']);
$upArray['changepass'] = 1;
$upArray['changepass'] = intval($data['changepass'] ?? 1) === 1 ? 1 : 0;
$upLdap['userPassword'] = $password;
}
// 昵称
@@ -1205,6 +1343,98 @@ class UsersController extends AbstractController
return Base::retSuccess($msg, $userInfo);
}
/**
* @api {post} api/users/createuser 创建用户(管理员)
*
* @apiDescription 需要token身份管理员
* @apiVersion 1.0.0
* @apiGroup users
* @apiName createuser
*
* @apiParam {String} email 邮箱
* @apiParam {String} password 初始密码
* @apiParam {String} nickname 昵称
* @apiParam {String} [profession] 职位/职称可选2-20字
* @apiParam {Array} [department] 部门ID列表可选最多10个
*/
public function createuser()
{
User::auth('admin');
$email = trim(Request::input('email'));
$password = trim(Request::input('password'));
$nickname = trim(Request::input('nickname'));
$changePass = intval(Request::input('changepass', 1)) === 1;
$profession = trim((string)Request::input('profession', ''));
$department = Request::input('department', []);
$user = User::createByAdmin($email, $password, $nickname, [
'changePass' => $changePass,
'profession' => $profession,
'department' => is_array($department) ? $department : [],
]);
return Base::retSuccess('创建成功', $user);
}
/**
* @api {post} api/users/import/preview 批量导入预览(管理员)
*
* @apiDescription 需要token身份管理员。上传 Excel/CSV列顺序邮箱、昵称、初始密码、职位(选填)),仅解析+校验、不创建账号
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import__preview
*/
public function import__preview()
{
User::auth('admin');
$file = Request::file('file');
if (empty($file)) {
return Base::retError('请选择文件');
}
$ext = strtolower($file->getClientOriginalExtension());
if (!in_array($ext, ['xls', 'xlsx', 'csv'])) {
return Base::retError('仅支持 xls/xlsx/csv 文件');
}
$sheets = Excel::toArray(new UserImport, $file);
$sheet = $sheets[0] ?? [];
$rows = User::parseImportRows($sheet);
if (empty($rows)) {
return Base::retError('文件中没有可导入的数据');
}
return Base::retSuccess('解析完成', User::importPreview($rows));
}
/**
* @api {post} api/users/import 批量导入用户(管理员)
*
* @apiDescription 需要token身份管理员。提交预览确认后的行数据 rows每行 {email,nickname,password,profession},可选 department[])进行创建
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import
*/
public function import()
{
User::auth('admin');
$rows = Request::input('rows');
if (!is_array($rows) || empty($rows)) {
return Base::retError('没有可导入的数据');
}
$changePass = intval(Request::input('changepass', 1)) === 1;
$result = User::importUsers($rows, $changePass);
return Base::retSuccess('导入完成', $result);
}
/**
* @api {get} api/users/import/template 下载批量导入模板(管理员)
*
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import__template
*/
public function import__template()
{
User::auth('admin');
return Excel::download(new UserImportTemplate, 'user_import_template.xlsx');
}
/**
* @api {get} api/users/email/verification 邮箱验证
*
@@ -1450,6 +1680,228 @@ class UsersController extends AbstractController
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/users/tags/lists 获取个性标签列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName tags__lists
*
* @apiParam {Number} [userid] 会员ID不传默认为当前用户
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccessExample {json} data:
{
"list": [
{
"id": 1,
"name": "认真负责",
"recognition_total": 3,
"recognized": true,
"can_edit": true,
"can_delete": true
}
],
"top": [ ],
"total": 1
}
*/
public function tags__lists()
{
$viewer = User::auth();
$userid = intval(Request::input('userid')) ?: $viewer->userid;
$target = User::whereUserid($userid)->first();
if (empty($target)) {
return Base::retError('会员不存在');
}
return Base::retSuccess('success', UserTag::listWithMeta($target->userid, $viewer));
}
/**
* @api {post} api/users/tags/add 新增个性标签
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName tags__add
*
* @apiParam {Number} [userid] 会员ID不传默认为当前用户
* @apiParam {String} name 标签名称1-20个字符
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据,同“获取个性标签列表”
*/
public function tags__add()
{
$viewer = User::auth();
$userid = intval(Request::input('userid')) ?: $viewer->userid;
$target = User::whereUserid($userid)->first();
if (empty($target)) {
return Base::retError('会员不存在');
}
$name = trim((string) Request::input('name'));
if ($name === '') {
return Base::retError('请输入个性标签');
}
if (mb_strlen($name) > 20) {
return Base::retError('标签名称最多只能设置20个字');
}
if (UserTag::where('user_id', $userid)->where('name', $name)->exists()) {
return Base::retError('标签已存在');
}
if (UserTag::where('user_id', $userid)->count() >= 100) {
return Base::retError('每位会员最多添加100个标签');
}
$tag = UserTag::create([
'user_id' => $userid,
'name' => $name,
'created_by' => $viewer->userid,
'updated_by' => $viewer->userid,
]);
$tag->save();
return Base::retSuccess('添加成功', UserTag::listWithMeta($userid, $viewer));
}
/**
* @api {post} api/users/tags/update 修改个性标签
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName tags__update
*
* @apiParam {Number} tag_id 标签ID
* @apiParam {String} name 标签名称1-20个字符
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据,同“获取个性标签列表”
*/
public function tags__update()
{
$viewer = User::auth();
$tagId = intval(Request::input('tag_id'));
$name = trim((string) Request::input('name'));
if ($tagId <= 0) {
return Base::retError('参数错误');
}
if ($name === '') {
return Base::retError('请输入个性标签');
}
if (mb_strlen($name) > 20) {
return Base::retError('标签名称最多只能设置20个字');
}
$tag = UserTag::find($tagId);
if (empty($tag)) {
return Base::retError('标签不存在');
}
if (!$tag->canManage($viewer)) {
return Base::retError('无权操作该标签');
}
if ($name !== $tag->name && UserTag::where('user_id', $tag->user_id)->where('name', $name)->where('id', '!=', $tag->id)->exists()) {
return Base::retError('标签已存在');
}
if ($name !== $tag->name) {
$tag->updateInstance([
'name' => $name,
'updated_by' => $viewer->userid,
]);
} else {
$tag->updateInstance([
'updated_by' => $viewer->userid,
]);
}
$tag->save();
return Base::retSuccess('保存成功', UserTag::listWithMeta($tag->user_id, $viewer));
}
/**
* @api {post} api/users/tags/delete 删除个性标签
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName tags__delete
*
* @apiParam {Number} tag_id 标签ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据,同“获取个性标签列表”
*/
public function tags__delete()
{
$viewer = User::auth();
$tagId = intval(Request::input('tag_id'));
if ($tagId <= 0) {
return Base::retError('参数错误');
}
$tag = UserTag::find($tagId);
if (empty($tag)) {
return Base::retError('标签不存在');
}
if (!$tag->canManage($viewer)) {
return Base::retError('无权操作该标签');
}
$userId = $tag->user_id;
$tag->delete();
return Base::retSuccess('删除成功', UserTag::listWithMeta($userId, $viewer));
}
/**
* @api {post} api/users/tags/recognize 认可个性标签
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName tags__recognize
*
* @apiParam {Number} tag_id 标签ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据,同“获取个性标签列表”
*/
public function tags__recognize()
{
$viewer = User::auth();
$tagId = intval(Request::input('tag_id'));
if ($tagId <= 0) {
return Base::retError('参数错误');
}
$tag = UserTag::find($tagId);
if (empty($tag)) {
return Base::retError('标签不存在');
}
$recognition = UserTagRecognition::where('tag_id', $tagId)
->where('user_id', $viewer->userid)
->first();
if ($recognition) {
$recognition->delete();
$message = '已取消认可';
} else {
UserTagRecognition::create([
'tag_id' => $tagId,
'user_id' => $viewer->userid,
]);
$message = '认可成功';
}
return Base::retSuccess($message, UserTag::listWithMeta($tag->user_id, $viewer));
}
/**
* @api {get} api/users/meeting/link 【会议】获取分享链接
*
@@ -1801,6 +2253,65 @@ class UsersController extends AbstractController
return Base::retSuccess($id > 0 ? '保存成功' : '新建成功');
}
/**
* @api {post} api/users/department/adddeputy 任命部门管理员(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName department__adddeputy
*
* @apiParam {Number} id 部门 id
* @apiParam {Number} userid 部门管理员 userid
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
*/
public function department__adddeputy()
{
User::auth('admin');
$id = intval(Request::input('id'));
$userid = intval(Request::input('userid'));
$dept = UserDepartment::find($id);
if (empty($dept)) {
return Base::retError('部门不存在或已被删除');
}
// ApiException 由框架统一捕获并 retError 转换
$dept->addDeputy($userid);
Cache::forever("UserDepartment::rand", Base::generatePassword());
return Base::retSuccess('任命成功');
}
/**
* @api {post} api/users/department/deldeputy 罢免部门管理员(限管理员)
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName department__deldeputy
*
* @apiParam {Number} id 部门 id
* @apiParam {Number} userid 要罢免的部门管理员 userid
*/
public function department__deldeputy()
{
User::auth('admin');
$id = intval(Request::input('id'));
$userid = intval(Request::input('userid'));
$dept = UserDepartment::find($id);
if (empty($dept)) {
return Base::retError('部门不存在或已被删除');
}
$dept->delDeputy($userid);
Cache::forever("UserDepartment::rand", Base::generatePassword());
return Base::retSuccess('罢免成功');
}
/**
* @api {get} api/users/department/del 删除部门(限管理员)
*
@@ -2181,7 +2692,8 @@ class UsersController extends AbstractController
'users.nickname',
'users.userimg',
'user_bots.clear_day',
'user_bots.webhook_url'
'user_bots.webhook_url',
'user_bots.webhook_events'
])
->orderByDesc('id')
->get()
@@ -2191,6 +2703,7 @@ class UsersController extends AbstractController
$bot['name'] = $bot['nickname'];
$bot['avatar'] = $bot['userimg'];
$bot['system_name'] = UserBot::systemBotName($bot['name']);
$bot['webhook_events'] = UserBot::normalizeWebhookEvents($bot['webhook_events'] ?? null, empty($bot['webhook_events']));
unset($bot['userid'], $bot['nickname'], $bot['userimg']);
}
@@ -2242,11 +2755,13 @@ class UsersController extends AbstractController
'avatar' => $botUser->userimg,
'clear_day' => 0,
'webhook_url' => '',
'webhook_events' => [UserBot::WEBHOOK_EVENT_MESSAGE],
'system_name' => UserBot::systemBotName($botUser->email),
];
if ($userBot) {
$data['clear_day'] = $userBot->clear_day;
$data['webhook_url'] = $userBot->webhook_url;
$data['webhook_events'] = $userBot->webhook_events;
}
return Base::retSuccess('success', $data);
}
@@ -2327,6 +2842,9 @@ class UsersController extends AbstractController
if (Arr::exists($data, 'webhook_url')) {
$upBot['webhook_url'] = trim($data['webhook_url']);
}
if (Arr::exists($data, 'webhook_events')) {
$upBot['webhook_events'] = UserBot::normalizeWebhookEvents($data['webhook_events'], false);
}
//
if ($upUser) {
$botUser->updateInstance($upUser);
@@ -2343,11 +2861,13 @@ class UsersController extends AbstractController
'avatar' => $botUser->userimg,
'clear_day' => 0,
'webhook_url' => '',
'webhook_events' => [UserBot::WEBHOOK_EVENT_MESSAGE],
'system_name' => UserBot::systemBotName($botUser->email),
];
if ($userBot) {
$data['clear_day'] = $userBot->clear_day;
$data['webhook_url'] = $userBot->webhook_url;
$data['webhook_events'] = $userBot->webhook_events;
}
return Base::retSuccess($botId ? '修改成功' : '添加成功', $data);
}
@@ -2491,7 +3011,11 @@ class UsersController extends AbstractController
$dialogIds[] = $dialog['id'];
}
if ($key && count($dialogList) < $dialogTake) {
$dialogUsers = User::searchUser($key, $dialogTake - count($dialogList));
$dialogUsers = User::select(User::$basicField)
->searchByKeyword($key)
->orderBy('userid')
->take($dialogTake - count($dialogList))
->get();
foreach ($dialogUsers as $item) {
$dialog = WebSocketDialog::getUserDialog($user->userid, $item->userid, now()->addDay());
if ($dialog && !in_array($dialog->id, $dialogIds)) {
@@ -2856,7 +3380,7 @@ class UsersController extends AbstractController
return Base::retError('参数错误');
}
//
ProjectTask::userTask($task_id, null, null);
ProjectTask::findForDepartmentView($task_id, null, null);
//
UserTaskBrowse::recordBrowse($user->userid, $task_id);
//
@@ -3124,6 +3648,51 @@ class UsersController extends AbstractController
return Base::retSuccess('删除成功');
}
/**
* @api {get} api/users/appsort 获取个人应用排序
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName appsort
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function appsort()
{
$user = User::auth();
$sorts = UserAppSort::getSorts($user->userid);
return Base::retSuccess('success', [
'sorts' => $sorts,
]);
}
/**
* @api {post} api/users/appsort/save 保存个人应用排序
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName appsort__save
*
* @apiParam {Object} sorts 排序配置,示例:{"base":["micro:calendar"],"admin":["system:ldap"]}
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function appsort__save()
{
$user = User::auth();
$sorts = UserAppSort::normalizeSorts(Request::input('sorts'));
$record = UserAppSort::saveSorts($user->userid, $sorts);
return Base::retSuccess('保存成功', [
'sorts' => $record->sorts ?? $sorts,
]);
}
/**
* @api {get} api/users/favorites 获取用户收藏列表
*
@@ -3347,4 +3916,5 @@ class UsersController extends AbstractController
//
return Base::retSuccess('success', ['favorited' => $isFavorited]);
}
}

View File

@@ -21,8 +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 App\Tasks\TodoRemindTask;
use App\Tasks\AiTaskLoopTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar;
@@ -269,10 +271,14 @@ class IndexController extends InvokeController
Task::deliver(new JokeSoupTask());
// 未领取任务通知
Task::deliver(new UnclaimedTaskRemindTask());
// 待办提醒
Task::deliver(new TodoRemindTask());
// 关闭会议室
Task::deliver(new CloseMeetingRoomTask());
// ZincSearch 同步
Task::deliver(new ZincSearchSyncTask());
// Manticore Search 同步
Task::deliver(new ManticoreSyncTask());
// AI 任务建议
Task::deliver(new AiTaskLoopTask());
return "success";
}

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
@@ -23,12 +25,26 @@ class WebApi
RequestContext::set('start_time', microtime(true));
RequestContext::set('header_language', $request->header('language'));
// 强制 https
$APP_SCHEME = env('APP_SCHEME', 'auto');
if (in_array(strtolower($APP_SCHEME), ['https', 'on', 'ssl', '1', 'true', 'yes'], true)) {
$request->server->set('HTTPS', 'on');
$request->headers->set('X-Forwarded-Proto', 'https');
$request->setTrustedProxies([$request->getClientIp()], $request::HEADER_X_FORWARDED_PROTO);
}
// 更新请求的基本URL
RequestContext::updateBaseUrl($request);
// 加载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')) {
@@ -48,12 +64,6 @@ class WebApi
}
}
// 强制 https
$APP_SCHEME = env('APP_SCHEME', 'auto');
if (in_array(strtolower($APP_SCHEME), ['https', 'on', 'ssl', '1', 'true', 'yes'], true)) {
$request->setTrustedProxies([$request->getClientIp()], $request::HEADER_X_FORWARDED_PROTO);
}
// 执行下一个中间件
$response = $next($request);

View File

@@ -2,8 +2,10 @@
namespace App\Ldap;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Module\Base;
use App\Services\RequestContext;
use LdapRecord\Configuration\ConfigurationException;
use LdapRecord\Container;
use LdapRecord\LdapRecordException;
@@ -11,20 +13,18 @@ use LdapRecord\Models\Model;
class LdapUser extends Model
{
protected static $init = null;
/**
* The object classes of the LDAP model.
*
* @var array
*/
public static $objectClasses = [
'inetOrgPerson',
'organizationalPerson',
'person',
'top',
'posixAccount',
];
private static $emailAttrs = ['mail', 'cn', 'uid', 'userPrincipalName'];
/**
* @return mixed|null
*/
@@ -68,19 +68,29 @@ class LdapUser extends Model
return Base::settingFind('thirdAccessSetting', 'ldap_sync_local') === 'open';
}
/**
* 获取登录属性名
* @return string
*/
public static function getLoginAttr(): string
{
$attr = Base::settingFind('thirdAccessSetting', 'ldap_login_attr');
return in_array($attr, ['cn', 'uid', 'mail', 'sAMAccountName', 'userPrincipalName']) ? $attr : 'cn';
}
/**
* 初始化配置
* @return bool
*/
public static function initConfig()
{
if (is_bool(self::$init)) {
return self::$init;
if (RequestContext::has('ldap_init')) {
return RequestContext::get('ldap_init');
}
//
$setting = Base::setting('thirdAccessSetting');
if ($setting['ldap_open'] !== 'open') {
return self::$init = false;
return RequestContext::save('ldap_init', false);
}
//
$connection = Container::getDefaultConnection();
@@ -92,15 +102,15 @@ class LdapUser extends Model
"username" => $setting['ldap_user_dn'],
"password" => $setting['ldap_password'],
]);
return self::$init = true;
return RequestContext::save('ldap_init', true);
} catch (ConfigurationException $e) {
info($e->getMessage());
return self::$init = false;
return RequestContext::save('ldap_init', false);
}
}
/**
* 获取
* 通过管理员绑定搜索用户,然后用用户 DN 做 Bind 认证
* @param $username
* @param $password
* @return Model|null
@@ -111,16 +121,68 @@ class LdapUser extends Model
return null;
}
try {
return self::static()
->where([
'cn' => $username,
'userPassword' => $password
])->first();
$loginAttr = self::getLoginAttr();
$row = self::static()
->whereRaw($loginAttr, '=', $username)
->first();
if (!$row) {
return null;
}
$connection = Container::getDefaultConnection();
if (!$connection->auth()->attempt($row->getDn(), $password)) {
return null;
}
// Swoole 下连接共享,必须恢复管理员绑定
$connection->auth()->attempt(
$connection->getConfiguration()->get('username'),
$connection->getConfiguration()->get('password')
);
return $row;
} catch (\Exception $e) {
info("[LDAP] auth fail: " . $e->getMessage());
return null;
}
}
/**
* 通过邮箱查找 LDAP 用户
* @param $email
* @return Model|null
*/
public static function findByEmail($email): ?Model
{
if (!self::initConfig()) {
return null;
}
try {
foreach (self::$emailAttrs as $attr) {
$row = self::static()->whereRaw($attr, '=', $email)->first();
if ($row) {
return $row;
}
}
return null;
} catch (\Exception) {
return null;
}
}
/**
* 获取用户的邮箱(从 LDAP 记录中提取)
* @param Model $row
* @return string|null
*/
public static function getUserEmail(Model $row): ?string
{
foreach (self::$emailAttrs as $attr) {
$val = $row->getFirstAttribute($attr);
if ($val && Base::isEmail($val)) {
return $val;
}
}
return null;
}
/**
* 登录
* @param $username
@@ -138,7 +200,18 @@ class LdapUser extends Model
return null;
}
if (empty($user)) {
$user = User::reg($username, $password);
$email = self::getUserEmail($row);
if (empty($email)) {
throw new ApiException('LDAP 用户缺少邮箱属性,请联系管理员配置');
}
$user = User::whereEmail($email)->first();
if (empty($user)) {
// LDAP 用户通过 LDAP 认证,本地密码用随机值以满足密码策略
$localPassword = Base::generatePassword(16) . 'Aa1!';
$user = User::reg($email, $localPassword);
} elseif (!$user->isLdap()) {
info("[LDAP] merged with existing local account: userid={$user->userid}, email={$email}");
}
}
if ($user) {
$userimg = $row->getPhoto();
@@ -173,7 +246,7 @@ class LdapUser extends Model
}
//
if (self::isSyncLocal()) {
$row = self::userFirst($user->email, $password);
$row = self::findByEmail($user->email);
if ($row) {
return;
}
@@ -184,17 +257,18 @@ class LdapUser extends Model
} else {
$userimg = '';
}
self::static()->create([
$attrs = [
'cn' => $user->email,
'gidNumber' => 0,
'homeDirectory' => '/home/ldap/dootask/' . env("APP_NAME"),
'sn' => $user->email,
'uid' => $user->email,
'uidNumber' => $user->userid,
'userPassword' => $password,
'displayName' => $user->nickname,
'jpegPhoto' => $userimg,
]);
'mail' => $user->email,
];
if ($userimg) {
$attrs['jpegPhoto'] = $userimg;
}
self::static()->create($attrs);
$user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap']));
$user->save();
} catch (LdapRecordException $e) {
@@ -205,11 +279,11 @@ class LdapUser extends Model
/**
* 更新
* @param $username
* @param $email
* @param $array
* @return void
*/
public static function userUpdate($username, $array)
public static function userUpdate($email, $array)
{
if (empty($array)) {
return;
@@ -218,10 +292,7 @@ class LdapUser extends Model
return;
}
try {
$row = self::static()
->where([
'cn' => $username,
])->first();
$row = self::findByEmail($email);
$row?->update($array);
} catch (\Exception $e) {
info("[LDAP] update fail: " . $e->getMessage());
@@ -230,19 +301,16 @@ class LdapUser extends Model
/**
* 删除
* @param $username
* @param $email
* @return void
*/
public static function userDelete($username)
public static function userDelete($email)
{
if (!self::initConfig()) {
return;
}
try {
$row = self::static()
->where([
'cn' => $username,
])->first();
$row = self::findByEmail($email);
$row?->delete();
} catch (\Exception $e) {
info("[LDAP] delete fail: " . $e->getMessage());

View File

@@ -20,9 +20,7 @@ use Illuminate\Support\Facades\DB;
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelAppend()
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|static with($relations)
* @method static \Illuminate\Database\Query\Builder|static select($columns = [])
* @method static \Illuminate\Database\Query\Builder|static whereIn($column, $values, $boolean = 'and', $not = false)
* @method static \Illuminate\Database\Query\Builder|static whereNotIn($column, $values, $boolean = 'and')
* @method static \Illuminate\Pagination\LengthAwarePaginator paginate(callable $callback)
* @method int change(array $array)
* @method int remove()
* @mixin \Eloquent
@@ -53,6 +51,8 @@ class AbstractModel extends Model
'read_at',
'done_at',
'remind_at',
'reminded_at',
'created_at',
'updated_at',

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
/**
* AI 助手会话
*
* @property int $id
* @property int $userid
* @property string $session_key
* @property string $session_id
* @property string $scene_key
* @property string $title
* @property string|null $data
* @property string|null $images
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class AiAssistantSession extends AbstractModel
{
protected $table = 'ai_assistant_sessions';
}

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;
@@ -40,6 +42,8 @@ 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)
@@ -128,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]);
});
});
}
/**
* 获取文件列表
@@ -584,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
@@ -710,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));

View File

@@ -152,6 +152,23 @@ class FileContent extends AbstractModel
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

@@ -22,6 +22,9 @@ use Request;
* @property int|null $personal 是否个人项目
* @property string|null $archive_method 自动归档方式
* @property int|null $archive_days 自动归档天数
* @property string|null $ai_auto_analyze AI自动分析
* @property string|null $task_template_share 共享模板开关
* @property string|null $department_owner_view 部门负责人视角可见开关
* @property string|null $user_simple 成员总数|1,2,3
* @property int|null $dialog_id 聊天会话ID
* @property \Illuminate\Support\Carbon|null $archived_at 归档时间
@@ -48,6 +51,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)
@@ -76,6 +80,7 @@ class Project extends AbstractModel
protected $appends = [
'owner_userid',
'deputy_userids',
];
/**
@@ -91,6 +96,58 @@ class Project extends AbstractModel
return $this->appendattrs['owner_userid'];
}
/**
* 项目管理员 userid 列表
* @return array
*/
public function getDeputyUseridsAttribute(): array
{
if (empty($this->id)) {
return [];
}
return ProjectUser::whereProjectId($this->id)
->whereOwner(ProjectUser::OWNER_DEPUTY)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
}
/**
* 是否项目负责人(与 project_users.owner=1 一致)
*/
public function isPrimaryOwner($userid): bool
{
if (empty($this->id) || $userid <= 0) {
return false;
}
return ProjectUser::whereProjectId($this->id)
->whereUserid($userid)
->whereOwner(ProjectUser::OWNER_PRIMARY)
->exists();
}
/**
* 是否项目管理员(与 project_users.owner=2 一致)
*/
public function isDeputyOwner($userid): bool
{
if (empty($this->id) || $userid <= 0) {
return false;
}
return ProjectUser::whereProjectId($this->id)
->whereUserid($userid)
->whereOwner(ProjectUser::OWNER_DEPUTY)
->exists();
}
/**
* 是否负责人(含项目管理员)
*/
public function isOwner($userid): bool
{
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
@@ -164,6 +221,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
@@ -214,21 +283,40 @@ class Project extends AbstractModel
return;
}
AbstractModel::transaction(function() {
$userids = $this->relationUserids();
// 拉所有项目成员 + 各自 owner 值
$userOwnerMap = ProjectUser::whereProjectId($this->id)
->pluck('owner', 'userid');
$userids = $userOwnerMap->keys()->map(fn($v) => (int)$v)->toArray();
foreach ($userids as $userid) {
$owner = (int)$userOwnerMap[$userid];
// 巧合:编码完全一致 owner 0/1/2 → role 0/1/2
$role = $owner;
WebSocketDialogUser::updateInsert([
'dialog_id' => $this->dialog_id,
'userid' => $userid,
], [
'important' => 1
], function () use ($userid) {
'important' => 1,
'role' => $role,
], function () use ($userid, $role) {
return [
'important' => 1,
'role' => $role,
'bot' => User::isBot($userid) ? 1 : 0,
];
});
}
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
WebSocketDialogUser::whereDialogId($this->dialog_id)
->whereNotIn('userid', $userids)
->whereImportant(1)
->remove();
// 同步 dialog.owner_id 到主负责人owner=1前端「群主」标签依赖此字段
// 必须随项目主负责人变更(含用户离职转移)一起刷新,否则会显示已离职用户
$primaryUserid = $userOwnerMap->search(ProjectUser::OWNER_PRIMARY);
if ($primaryUserid !== false && (int)$primaryUserid > 0) {
WebSocketDialog::whereId($this->dialog_id)
->where('owner_id', '!=', (int)$primaryUserid)
->update(['owner_id' => (int)$primaryUserid]);
}
});
}
@@ -365,7 +453,7 @@ class Project extends AbstractModel
// 处理所有者权限
if (isset($data['owner'])) {
$owners = ProjectUser::whereProjectId($data['id'])
->whereOwner(1)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')
->toArray();
$recipients = [
@@ -586,7 +674,7 @@ class Project extends AbstractModel
$column['project_id'] = $project->id;
ProjectColumn::createInstance($column)->save();
}
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project');
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project', $project->userid);
if (empty($dialog)) {
throw new ApiException('创建项目聊天室失败');
}
@@ -608,7 +696,9 @@ class Project extends AbstractModel
* 获取项目信息(用于判断会员是否存在项目内)
* @param int $project_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool $mustOwner true:仅限项目负责人, false:仅限非项目负责人, null:不限制
* @param null|bool|string $mustOwner true:负责人或项目管理员都可(共享操作);
* 'primary':仅负责人(转让/删除/任命项目管理员等独占操作);
* false:仅限非负责人null:不限制
* @return self
*/
public static function userProject($project_id, $archived = true, $mustOwner = null)
@@ -626,9 +716,39 @@ class Project extends AbstractModel
if ($mustOwner === true && !$project->owner) {
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
}
if ($mustOwner === 'primary' && (int)$project->owner !== 1) {
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
}
if ($mustOwner === false && $project->owner) {
throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]);
}
return $project;
}
/**
* 获取项目(含部门负责人只读视角兜底)
* @param int $project_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool|string $mustOwner 仅限 null 时尝试部门只读视角
* @return self
*/
public static function findForDepartmentView($project_id, $archived = true, $mustOwner = null)
{
$user = User::auth();
$departmentView = UserDepartment::ownerViewContext($user, true);
if (UserDepartment::isDepartmentReadonlyProject($departmentView, intval($project_id)) && $mustOwner === null) {
$project = self::allData()->where('projects.id', intval($project_id))->first();
if (empty($project)) {
throw new ApiException('项目不存在或已被删除', [ 'project_id' => $project_id ], -4001);
}
if ($archived === true && $project->archived_at != null) {
throw new ApiException('项目已归档', [ 'project_id' => $project_id ], -4001);
}
if ($archived === false && $project->archived_at == null) {
throw new ApiException('项目未归档', [ 'project_id' => $project_id ]);
}
return $project;
}
return self::userProject($project_id, $archived, $mustOwner);
}
}

View File

@@ -74,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)
@@ -156,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();
//
@@ -353,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
@@ -372,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
@@ -396,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')
@@ -417,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,
@@ -455,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;
}
@@ -674,12 +750,22 @@ class ProjectTask extends AbstractModel
if ($this->complete_at) {
throw new ApiException('任务已完成');
}
$this->completeTask(Carbon::now(), isset($newFlowItem) ? $newFlowItem->name : null);
// 只有用户单独提交 complete_at 时才自动设置工作流状态
if (!Arr::exists($data, 'flow_item_id')) {
$flowItemName = $this->checkAndAutoSetFlowItem('end', -4005);
} else {
$flowItemName = isset($newFlowItem) ? $newFlowItem->name : null;
}
$this->completeTask(Carbon::now(), $flowItemName);
} else {
// 标记未完成
if (!$this->complete_at) {
throw new ApiException('未完成任务');
}
// 只有用户单独提交 complete_at 时才自动设置工作流状态
if (!Arr::exists($data, 'flow_item_id')) {
$this->checkAndAutoSetFlowItem('start', -4006);
}
$this->completeTask(null);
}
$updateMarking['is_update_project'] = true;
@@ -757,7 +843,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) {
@@ -1185,6 +1271,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();
}
}
/**
* 同步项目成员至聊天室
*/
@@ -1343,6 +1549,49 @@ class ProjectTask extends AbstractModel
return $this->appendattrs['has_owner'];
}
/**
* 检查并自动设置工作流状态
* @param string $status 目标状态类型 ('start' 或 'end')
* @param int $errorCode 多状态时的错误码 (-4005 或 -4006)
* @return string|null 自动设置的状态名称,无状态时返回 null
*/
private function checkAndAutoSetFlowItem(string $status, int $errorCode): ?string
{
$flowItems = ProjectFlowItem::whereProjectId($this->project_id)
->whereStatus($status)
->get(['id', 'name', 'status', 'color']);
if ($flowItems->count() > 1) {
$msg = $status === 'end' ? '存在多个结束状态,请选择要使用的状态' : '存在多个开始状态,请选择要使用的状态';
throw new ApiException($msg, [
'task_id' => $this->id,
'flow_items' => $flowItems->toArray(),
], $errorCode);
}
if ($flowItems->count() == 1) {
$autoFlowItem = $flowItems->first();
$oldFlowItemId = $this->flow_item_id;
$oldFlowItemName = $this->flow_item_name;
$this->flow_item_id = $autoFlowItem->id;
$this->flow_item_name = $autoFlowItem->status . "|" . $autoFlowItem->name . "|" . $autoFlowItem->color;
if ($oldFlowItemId != $this->flow_item_id) {
ProjectTaskFlowChange::createInstance([
'task_id' => $this->id,
'userid' => User::userid(),
'before_flow_item_id' => $oldFlowItemId,
'before_flow_item_name' => $oldFlowItemName,
'after_flow_item_id' => $this->flow_item_id,
'after_flow_item_name' => $this->flow_item_name,
])->save();
}
return $autoFlowItem->name;
}
return null;
}
/**
* 标记已完成、未完成
* @param Carbon|null $complete_at 完成时间
@@ -1742,7 +1991,9 @@ class ProjectTask extends AbstractModel
'dialog_id' => $this->dialog_id,
];
//
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->toArray(); // 项目负责人(含项目管理员)
//
$array = [];
if (empty($userids)) {
@@ -1917,11 +2168,8 @@ class ProjectTask extends AbstractModel
$taskUser->save();
}
}
// 子任务
ProjectTask::whereParentId($this->id)->change([
'project_id' => $projectId,
'column_id' => $columnId,
]);
// 子任务 - 根据完成状态映射工作流
$this->moveSubTasks($projectId, $columnId);
//
if ($flowItemId) {
// 更新任务流程
@@ -1948,66 +2196,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) {
$descContent = Base::cutStr(Base::html2markdown($taskDesc['content'], ['strip_tags' => true]), 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
@@ -2069,4 +2257,98 @@ class ProjectTask extends AbstractModel
//
return $task;
}
/**
* 获取任务(含部门负责人只读视角兜底)
* @param int $task_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool $trashed true:仅限未删除, false:仅限已删除, null:不限制
* @param array $with
* @return self
*/
public static function findForDepartmentView($task_id, $archived = true, $trashed = true, $with = [])
{
$user = User::auth();
$departmentView = UserDepartment::ownerViewContext($user, true);
if ($departmentView['enabled']) {
$builder = self::with($with)->allData()->where('project_tasks.id', intval($task_id));
if ($trashed === false) {
$builder->onlyTrashed();
} elseif ($trashed === null) {
$builder->withTrashed();
}
$task = $builder->first();
// 仅"全员可见"(visibility=1)的任务走负责人只读视角;指定成员可见的任务交由 userTask 按可见性校验
if (!empty($task) && intval($task->visibility) === 1 && UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id))) {
if ($archived === true && $task->archived_at != null) {
throw new ApiException('任务已归档', ['task_id' => $task_id]);
}
if ($archived === false && $task->archived_at == null) {
throw new ApiException('任务未归档', ['task_id' => $task_id]);
}
return $task;
}
}
return self::userTask($task_id, $archived, $trashed, $with);
}
/**
* 构建指定周期内的未完成任务查询(用于周报/日报等)
* @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 Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ProjectTaskAiEvent
*
* @property int $id
* @property int $task_id 任务ID
* @property string $event_type 事件类型
* @property string $status 状态
* @property int $retry_count 重试次数
* @property array|null $result 执行结果
* @property string|null $error 错误信息
* @property int $msg_id 消息ID
* @property \Illuminate\Support\Carbon|null $executed_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
class ProjectTaskAiEvent extends AbstractModel
{
const EVENT_DESCRIPTION = 'description';
const EVENT_SUBTASKS = 'subtasks';
const EVENT_ASSIGNEE = 'assignee';
const EVENT_SIMILAR = 'similar';
const STATUS_PENDING = 'pending';
const STATUS_PROCESSING = 'processing';
const STATUS_COMPLETED = 'completed';
const STATUS_FAILED = 'failed';
const STATUS_SKIPPED = 'skipped';
const STATUS_APPLIED = 'applied';
const STATUS_DISMISSED = 'dismissed';
const MAX_RETRY = 3;
protected $table = 'project_task_ai_events';
protected $fillable = [
'task_id',
'event_type',
'status',
'retry_count',
'result',
'error',
'msg_id',
'executed_at',
];
protected $casts = [
'result' => 'array',
'executed_at' => 'datetime',
];
/**
* 关联任务
*/
public function task(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'task_id', 'id');
}
/**
* 获取所有事件类型
*/
public static function getEventTypes(): array
{
return [
self::EVENT_DESCRIPTION,
self::EVENT_SUBTASKS,
self::EVENT_ASSIGNEE,
self::EVENT_SIMILAR,
];
}
/**
* 标记为处理中
*/
public function markProcessing(): bool
{
return $this->update([
'status' => self::STATUS_PROCESSING,
]);
}
/**
* 标记为完成
*/
public function markCompleted(array $result, int $msgId = 0): bool
{
return $this->update([
'status' => self::STATUS_COMPLETED,
'result' => $result,
'msg_id' => $msgId,
'executed_at' => now(),
]);
}
/**
* 标记为失败
*/
public function markFailed(string $error): bool
{
return $this->update([
'status' => self::STATUS_FAILED,
'retry_count' => $this->retry_count + 1,
'error' => $error,
'executed_at' => now(),
]);
}
/**
* 标记为跳过
*/
public function markSkipped(string $reason = ''): bool
{
return $this->update([
'status' => self::STATUS_SKIPPED,
'error' => $reason,
'executed_at' => now(),
]);
}
/**
* 是否可以重试
*/
public function canRetry(): bool
{
return $this->status === self::STATUS_FAILED
&& $this->retry_count < self::MAX_RETRY;
}
/**
* 标记为已采纳
*/
public function markApplied(): bool
{
return $this->update([
'status' => self::STATUS_APPLIED,
]);
}
/**
* 标记为已忽略
*/
public function markDismissed(): bool
{
return $this->update([
'status' => self::STATUS_DISMISSED,
]);
}
}

View File

@@ -63,6 +63,121 @@ class ProjectTaskRelation extends AbstractModel
return $this->belongsTo(ProjectTask::class, 'related_task_id');
}
/**
* 创建双向任务关联
*
* @param int $sourceTaskId 源任务ID
* @param int $targetTaskId 目标任务ID
* @param int|null $dialogId 来源对话ID
* @param int|null $msgId 来源消息ID
* @param int|null $userid 操作人
* @param bool $push 是否推送更新
* @return bool 是否创建成功
*/
public static function createRelation(
int $sourceTaskId,
int $targetTaskId,
?int $dialogId = null,
?int $msgId = null,
?int $userid = null,
bool $push = true
): bool {
if ($sourceTaskId === $targetTaskId) {
return false;
}
$sourceTask = ProjectTask::with('project')->find($sourceTaskId);
$targetTask = ProjectTask::with('project')->find($targetTaskId);
if (!$sourceTask || !$targetTask) {
return false;
}
if ($sourceTask->deleted_at || $targetTask->deleted_at) {
return false;
}
// 创建正向关联:源任务提及目标任务
$mentionRelation = static::updateOrCreate(
[
'task_id' => $sourceTaskId,
'related_task_id' => $targetTaskId,
'direction' => self::DIRECTION_MENTION,
],
[
'dialog_id' => $dialogId,
'msg_id' => $msgId,
'userid' => $userid,
]
);
// 创建反向关联:目标任务被源任务提及
$reverseRelation = static::updateOrCreate(
[
'task_id' => $targetTaskId,
'related_task_id' => $sourceTaskId,
'direction' => self::DIRECTION_MENTIONED_BY,
],
[
'dialog_id' => $dialogId,
'msg_id' => $msgId,
'userid' => $userid,
]
);
// 推送关联更新
if ($push) {
$needPush = $mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()
|| $reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged();
if ($needPush) {
if ($sourceTask->project) {
$sourceTask->pushMsg('relation', null, null, false);
}
if ($targetTask->project) {
$targetTask->pushMsg('relation', null, null, false);
}
}
}
return true;
}
/**
* 删除双向任务关联
*
* @param int $taskId 任务ID
* @param int $relatedTaskId 关联任务ID
* @return bool 是否删除成功
*/
public static function deleteRelation(int $taskId, int $relatedTaskId): bool
{
// 删除正向关联
$deleted1 = static::whereTaskId($taskId)
->whereRelatedTaskId($relatedTaskId)
->delete();
// 删除反向关联
$deleted2 = static::whereTaskId($relatedTaskId)
->whereRelatedTaskId($taskId)
->delete();
if ($deleted1 || $deleted2) {
// 推送关联更新
$sourceTask = ProjectTask::with('project')->find($taskId);
$targetTask = ProjectTask::with('project')->find($relatedTaskId);
if ($sourceTask?->project) {
$sourceTask->pushMsg('relation', null, null, false);
}
if ($targetTask?->project) {
$targetTask->pushMsg('relation', null, null, false);
}
return true;
}
return false;
}
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
{
if ($msg->type !== 'text') {
@@ -84,71 +199,25 @@ class ProjectTaskRelation extends AbstractModel
return;
}
$sourceTasks = ProjectTask::with('project')->whereDialogId($msg->dialog_id)->get();
if ($sourceTasks->isEmpty()) {
$sourceTaskIds = ProjectTask::whereDialogId($msg->dialog_id)
->whereNull('deleted_at')
->pluck('id')
->toArray();
if (empty($sourceTaskIds)) {
return;
}
$targetTasks = ProjectTask::with('project')->whereIn('id', $targetIds)->get()->keyBy('id');
if ($targetTasks->isEmpty()) {
return;
}
$pushTasks = [];
foreach ($sourceTasks as $sourceTask) {
foreach ($sourceTaskIds as $sourceTaskId) {
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,
]
self::createRelation(
$sourceTaskId,
$targetId,
$msg->dialog_id,
$msg->id,
$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

@@ -13,6 +13,8 @@ namespace App\Models;
* @property int $sort 排序
* @property int $is_default 是否默认模板
* @property int $userid 创建人
* @property int $use_count 累计使用次数
* @property \Illuminate\Support\Carbon|null $last_used_at 最近一次使用时间
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Project $project
@@ -52,7 +54,18 @@ class ProjectTaskTemplate extends AbstractModel
'content',
'sort',
'is_default',
'userid'
'userid',
'use_count',
'last_used_at'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'last_used_at' => 'datetime',
];
/**
@@ -74,4 +87,17 @@ class ProjectTaskTemplate extends AbstractModel
{
return $this->belongsTo(User::class, 'userid');
}
/**
* 原子递增使用次数并刷新最近使用时间。
*/
public function incrementUsage(): void
{
$this->newQuery()
->where('id', $this->id)
->update([
'use_count' => \DB::raw('use_count + 1'),
'last_used_at' => now(),
]);
}
}

View File

@@ -37,6 +37,36 @@ use App\Module\Base;
*/
class ProjectUser extends AbstractModel
{
/** @var int 普通成员编码 */
const OWNER_MEMBER = 0;
/** @var int 项目负责人编码 */
const OWNER_PRIMARY = 1;
/** @var int 项目管理员编码 */
const OWNER_DEPUTY = 2;
/**
* 是否项目负责人owner=1
*/
public function isPrimaryOwner(): bool
{
return (int)$this->owner === self::OWNER_PRIMARY;
}
/**
* 是否项目管理员owner=2
*/
public function isDeputyOwner(): bool
{
return (int)$this->owner === self::OWNER_DEPUTY;
}
/**
* 是否负责人(含项目管理员)
*/
public function isOwner(): bool
{
return $this->isPrimaryOwner() || $this->isDeputyOwner();
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasOne
@@ -61,12 +91,19 @@ class ProjectUser extends AbstractModel
foreach ($list as $item) {
$row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first();
if ($row) {
// 已存在则删除原数据,判断改变已存在的数据
$row->owner = max($row->owner, $item->owner);
// 已存在仅当离职用户是项目负责人owner=1时把接收人升为项目负责人
// 离职用户是项目管理员owner=2时不传项目管理员身份给接收人spec项目管理员不替补
if ((int)$item->owner === self::OWNER_PRIMARY) {
$row->owner = self::OWNER_PRIMARY;
}
// owner=2/0保留接收人原有 owner 值不变
$row->save();
$item->delete();
} else {
// 不存在则改变原数据
// 不存在:转移时如果离职用户是项目管理员,降级为普通成员(不带项目管理员身份过户给接收人)
if ((int)$item->owner === self::OWNER_DEPUTY) {
$item->owner = self::OWNER_MEMBER;
}
$item->userid = $newUserid;
$item->save();
}

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;
/**
@@ -54,6 +55,7 @@ class Setting extends AbstractModel
$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'];
}
@@ -65,17 +67,9 @@ class Setting extends AbstractModel
$value['permission_pack_userids'] = is_array($value['permission_pack_userids']) ? $value['permission_pack_userids'] : [];
break;
// AI 助手设置
case 'aiSetting':
$value['ai_provider'] = $value['ai_provider'] ?: 'openai';
$value['ai_api_key'] = $value['ai_api_key'] ?: '';
$value['ai_api_url'] = $value['ai_api_url'] ?: '';
$value['ai_proxy'] = $value['ai_proxy'] ?: '';
break;
// AI 机器人设置
case 'aibotSetting':
if ($value['claude_token'] && empty($value['claude_key'])) {
if (!empty($value['claude_token']) && empty($value['claude_key'])) {
$value['claude_key'] = $value['claude_token'];
}
$array = [];
@@ -84,17 +78,14 @@ 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::AIBotDefaultModels($aiName);
}
$content = implode("\n", $content);
$content = is_array($content) ? implode("\n", $content) : '';
break;
case 'model':
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
@@ -116,100 +107,99 @@ class Setting extends AbstractModel
}
/**
* 是否开启 AI 助理
* 规范任务优先级设置(确保字段完整且仅有一个默认项)
* @param mixed $list
* @return array<int, array{name:string,color:string,days:int,priority:int,is_default:int}>
*/
public static function normalizeTaskPriorityList($list)
{
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);
}
/**
* 获取默认任务优先级(来自 settings.priority
* @param array|null $list
* @return array|null
*/
public static function getDefaultTaskPriorityItem($list = null)
{
$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()
{
return !!Base::settingFind('aiSetting', 'ai_api_key');
$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 string $ai
* @return array
* 判断 AI 机器人厂商是否启用
* @param array $setting
* @param string $vendor
* @return bool
*/
public static function AIBotDefaultModels($ai = 'openai')
protected static function isAIBotVendorEnabled(array $setting, string $vendor): bool
{
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 => [],
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
return match ($vendor) {
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
default => $key !== '',
};
}
@@ -238,6 +228,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
@@ -303,7 +500,7 @@ class Setting extends AbstractModel
}
$limitTime = Carbon::parse($dialogMsg->created_at)->addMinutes($limitNum);
if ($limitTime->lt(Carbon::now())) {
throw new ApiException('已超过' . Doo::translate(Base::forumMinuteDay($limitNum)) . '' . $error);
throw new ApiException('已超过' . Base::forumMinuteDay($limitNum) . '' . $error);
}
}
}

View File

@@ -5,8 +5,11 @@ 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;
@@ -22,6 +25,9 @@ use Carbon\Carbon;
* @property string|null $tel 联系电话
* @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 登录密码
@@ -49,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)
@@ -60,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)
@@ -79,6 +89,8 @@ use Carbon\Carbon;
*/
class User extends AbstractModel
{
const IMPORT_MAX = 500;
protected $primaryKey = 'userid';
protected $hidden = [
@@ -310,7 +322,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(),
@@ -331,6 +343,7 @@ class User extends AbstractModel
//
return $this->delete();
});
return $ret;
}
/**
@@ -404,7 +417,291 @@ 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;
}
/**
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
* @param string $email
* @param string $password
* @param string $nickname
* @param array $options changePass(bool,默认true) / department(int[]) / profession(string)
* @return self
* @throws ApiException
*/
public static function createByAdmin(string $email, $password, string $nickname, array $options = []): self
{
$nickname = trim($nickname);
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
throw new ApiException('昵称需为2-20个字');
}
$changePass = ($options['changePass'] ?? true) ? 1 : 0;
$profession = trim((string)($options['profession'] ?? ''));
// 校验前置reg 之前快速失败,且可在无 Swoole 环境单测)
self::assertValidProfession($profession);
$departmentIds = self::assertValidDepartments($options['department'] ?? []);
// 复用 reg邮箱校验/查重、passwordPolicy、Doo::userCreate、az/pinyin、全员群、索引同步、user_onboard hook
$user = self::reg($email, $password, ['nickname' => $nickname]);
// 管理员显式创建的账号视为正式员工,去除系统 reg_identity 可能带上的 temp
if (in_array('temp', $user->identity)) {
$user->identity = Base::arrayImplode(array_diff($user->identity, ['temp']));
}
$user->changepass = $changePass; // 复用现有首登强制改密机制
if ($profession !== '') {
$user->profession = $profession;
}
if ($departmentIds) {
$user->department = Base::arrayImplode($departmentIds);
}
$user->save();
// 设置了部门 → 加入对应部门群(复刻 operation 的 type=department 入群逻辑)
if ($departmentIds) {
$departments = UserDepartment::whereIn('id', $departmentIds)->get();
foreach ($departments as $department) {
try {
if ($department->dialog_id > 0 && $dialog = WebSocketDialog::find($department->dialog_id)) {
$dialog->joinGroup([$user->userid], 0, true);
$dialog->pushMsg("groupJoin", null, [$user->userid]);
}
} catch (\Throwable $e) {
// 部门入群为尽力投递:单个部门失败不影响账号创建与其他部门
\Log::warning('createByAdmin: 部门入群失败', [
'userid' => $user->userid,
'department_id' => $department->id,
'error' => $e->getMessage(),
]);
}
}
}
return $user;
}
/**
* 将上传表格Excel::toArray 的二维数组)归一化为导入行
* @param array $sheet
* @return array [{line, email, nickname, password}]
*/
public static function parseImportRows(array $sheet): array
{
$rows = [];
foreach ($sheet as $index => $cells) {
if ($index === 0) {
continue; // 表头
}
$email = trim((string)($cells[0] ?? ''));
$nickname = trim((string)($cells[1] ?? ''));
$password = trim((string)($cells[2] ?? ''));
$profession = trim((string)($cells[3] ?? ''));
if ($email === '' && $nickname === '' && $password === '') {
continue; // 空行(仅职位有值也视为空行跳过)
}
$rows[] = [
'line' => $index + 1, // 电子表格行号(从 1 开始)
'email' => $email,
'nickname' => $nickname,
'password' => $password,
'profession' => $profession,
];
}
return $rows;
}
/**
* 校验单条导入行
* @param array $row ['email'=>,'nickname'=>,'password'=>,'profession'=>(选填)]
* @return string|null 错误文案null 表示通过
*/
public static function validateImportRow(array $row): ?string
{
$email = trim((string)($row['email'] ?? ''));
$nickname = trim((string)($row['nickname'] ?? ''));
$password = trim((string)($row['password'] ?? ''));
if ($email === '' || $nickname === '' || $password === '') {
return '邮箱、昵称、初始密码均为必填';
}
if (!Base::isEmail($email)) {
return '邮箱格式不正确';
}
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
return '昵称需为2-20个字';
}
try {
self::passwordPolicy($password);
} catch (ApiException $e) {
return $e->getMessage();
}
// 职位/职称选填,填写则校验 2-20 字
try {
self::assertValidProfession((string)($row['profession'] ?? ''));
} catch (ApiException $e) {
return $e->getMessage();
}
return null;
}
/**
* 校验职位/职称:非空时必须 2-20 字(复用 operation 的现有文案)
* @param string $profession
* @return void
* @throws ApiException
*/
public static function assertValidProfession(string $profession): void
{
$profession = trim($profession);
if ($profession === '') {
return;
}
if (mb_strlen($profession) < 2) {
throw new ApiException('职位/职称不可以少于2个字');
}
if (mb_strlen($profession) > 20) {
throw new ApiException('职位/职称最多只能设置20个字');
}
}
/**
* 规整并校验部门 ID 列表:转正整数去重、最多 10 个、且每个必须存在
* @param mixed $ids
* @return int[]
* @throws ApiException
*/
public static function assertValidDepartments($ids): array
{
if (!is_array($ids)) {
$ids = [];
}
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
if (count($ids) > 10) {
throw new ApiException('最多只可加入10个部门');
}
if ($ids) {
$existing = UserDepartment::whereIn('id', $ids)->pluck('id')->map(fn($v) => (int)$v)->all();
if (count($existing) < count($ids)) {
throw new ApiException('修改部门不存在');
}
}
return $ids;
}
/**
* 批量导入用户(部门/职位逐行department 来自前端逐行设置profession 来自 Excel 行)
* @param array $rows 每行含 email/nickname/password/profession可选 department(int[])
* @param bool $changePass 是否要求首登改密(对本批所有账号生效)
* @return array ['total'=>int, 'success'=>int, 'failed'=>[['line','email','reason']]]
* @throws ApiException 行数超限
*/
public static function importUsers(array $rows, bool $changePass = true): array
{
if (count($rows) > self::IMPORT_MAX) {
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
}
$success = 0;
$failed = [];
$seen = [];
foreach ($rows as $row) {
$error = self::validateImportRow($row);
if ($error === null) {
$emailLower = strtolower(trim((string)$row['email']));
if (isset($seen[$emailLower])) {
$error = '文件内邮箱重复';
} else {
$seen[$emailLower] = true;
}
}
if ($error === null) {
try {
self::createByAdmin($row['email'], $row['password'], $row['nickname'], [
'changePass' => $changePass,
'department' => $row['department'] ?? [],
'profession' => $row['profession'] ?? '',
]);
$success++;
continue;
} catch (ApiException $e) {
$error = $e->getMessage();
}
}
$failed[] = [
'line' => $row['line'] ?? 0,
'email' => $row['email'] ?? '',
'reason' => $error,
];
}
return [
'total' => count($rows),
'success' => $success,
'failed' => $failed,
];
}
/**
* 批量导入预览(只解析+校验,不创建任何账号)
* 逐行判定 ok/error必填/邮箱格式/昵称长度/密码策略、文件内邮箱重复、系统中邮箱已存在
* @param array $rows parseImportRows 的输出
* @return array ['total'=>int,'valid'=>int,'invalid'=>int,'rows'=>[['line','email','nickname','password','status','reason']]]
*/
public static function importPreview(array $rows): array
{
if (count($rows) > self::IMPORT_MAX) {
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
}
// 预查系统中已存在的邮箱(小写比较)
$emails = [];
foreach ($rows as $row) {
$e = strtolower(trim((string)($row['email'] ?? '')));
if ($e !== '') {
$emails[$e] = true;
}
}
$existing = [];
if ($emails) {
foreach (self::whereIn('email', array_keys($emails))->pluck('email') as $em) {
$existing[strtolower($em)] = true;
}
}
$seen = [];
$valid = 0;
$list = [];
foreach ($rows as $row) {
$reason = self::validateImportRow($row);
$emailLower = strtolower(trim((string)($row['email'] ?? '')));
if ($reason === null) {
if (isset($seen[$emailLower])) {
$reason = '文件内邮箱重复';
} else {
$seen[$emailLower] = true;
if (isset($existing[$emailLower])) {
$reason = '邮箱地址已存在';
}
}
}
$ok = $reason === null;
if ($ok) {
$valid++;
}
$list[] = [
'line' => $row['line'] ?? 0,
'email' => $row['email'] ?? '',
'nickname' => $row['nickname'] ?? '',
'password' => $row['password'] ?? '',
'profession' => $row['profession'] ?? '',
'status' => $ok ? 'ok' : 'error',
'reason' => $reason ?? '',
];
}
return [
'total' => count($rows),
'valid' => $valid,
'invalid' => count($rows) - $valid,
'rows' => $list,
];
}
/**
@@ -764,24 +1061,35 @@ class User extends AbstractModel
}
/**
* 搜索用户
* @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;
}
}
/**
* 判断是否系统机器人
@@ -68,6 +151,7 @@ class UserBot extends AbstractModel
$name = match ($name) {
'system-msg' => '系统消息',
'task-alert' => '任务提醒',
'todo-alert' => '待办提醒',
'check-in' => '签到打卡',
'anon-msg' => '匿名消息',
'approval-alert' => '审批',
@@ -270,16 +354,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);
@@ -293,7 +408,7 @@ class UserBot extends AbstractModel
$array[] = [
'userid' => $UserCheckinMac->userid,
'mac' => $UserCheckinMac->mac,
'date' => $nowDate,
'date' => $targetDate ?: $nowDate,
];
$checkins[] = [
'userid' => $UserCheckinMac->userid,
@@ -314,7 +429,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,
@@ -349,7 +464,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;
@@ -366,12 +482,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',
@@ -385,7 +502,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,
@@ -400,14 +518,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);
}
@@ -479,4 +596,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

@@ -3,7 +3,9 @@
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use Cache;
use Request;
/**
* App\Models\UserDepartment
@@ -35,6 +37,10 @@ use Cache;
*/
class UserDepartment extends AbstractModel
{
protected $appends = [
'deputy_userids',
];
/**
* 获取所有父级部门
* @return array
@@ -50,6 +56,55 @@ class UserDepartment extends AbstractModel
return $parents;
}
/**
* 部门管理员 userid 列表
* @return array
*/
public function getDeputyUseridsAttribute(): array
{
if (empty($this->id)) {
return [];
}
return \DB::table('user_department_owners')
->where('department_id', $this->id)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
}
/**
* 是否部门负责人(与 owner_userid 一致)
*/
public function isPrimaryOwner($userid): bool
{
if (empty($this->id) || $userid <= 0) {
return false;
}
return (int)$this->owner_userid === (int)$userid;
}
/**
* 是否部门管理员(在 user_department_owners 表里)
*/
public function isDeputyOwner($userid): bool
{
if (empty($this->id) || $userid <= 0) {
return false;
}
return \DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', $userid)
->exists();
}
/**
* 是否负责人(含部门管理员)
*/
public function isOwner($userid): bool
{
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
}
/**
* 保存部门
* @param $data
@@ -65,18 +120,38 @@ class UserDepartment extends AbstractModel
}
$this->updateInstance($data);
//
// 防御:新负责人若残留在 user_department_owners 中(如曾是该部门管理员),清理掉
// 否则后续 delDeputy / 罢免接口会把当前部门负责人误移出部门
if ($this->id && (int)$this->owner_userid > 0) {
\DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', (int)$this->owner_userid)
->delete();
}
//
if ($this->dialog_id > 0) {
// 已有群
$dialog = WebSocketDialog::find($this->dialog_id);
if ($dialog) {
$oldOwnerId = (int)$dialog->owner_id;
$dialog->name = $this->name;
$dialog->owner_id = $this->owner_userid;
if ($dialog->save()) {
$dialog->joinGroup($this->owner_userid, 0, true);
// 同步 role原负责人 role=0、新负责人 role=1部门管理员 role=2 保留不动)
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $oldOwnerId)
->update(['role' => 0]);
}
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $this->owner_userid)
->update(['role' => 1]);
$dialog->pushMsg("groupUpdate", [
'id' => $dialog->id,
'name' => $dialog->name,
'owner_id' => $dialog->owner_id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
}
@@ -86,16 +161,33 @@ class UserDepartment extends AbstractModel
if (empty($dialog)) {
throw new ApiException("选择现有聊天群不存在");
}
$oldOwnerId = (int)$dialog->owner_id;
$dialog->name = $this->name;
$dialog->owner_id = $this->owner_userid;
$dialog->group_type = 'department';
if ($dialog->save()) {
$dialog->joinGroup($this->owner_userid, 0, true);
// 同步 role原负责人 role=0、新负责人 role=1、原部门管理员 role=0
// 原部门管理员清零:避免 dialog_users.role=2 与 user_department_owners 不一致
// (部门管理员关系不带过来,须通过 addDeputy 显式重新任命)
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $oldOwnerId)
->update(['role' => 0]);
}
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', '!=', $this->owner_userid)
->where('role', 2)
->update(['role' => 0]);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $this->owner_userid)
->update(['role' => 1]);
$dialog->pushMsg("groupUpdate", [
'id' => $dialog->id,
'name' => $dialog->name,
'owner_id' => $dialog->owner_id,
'group_type' => $dialog->group_type,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'notice', [
'notice' => User::nickname() . " 将此群改为部门群"
@@ -116,6 +208,12 @@ class UserDepartment extends AbstractModel
$oldUser->department = array_diff($oldUser->department, [$this->id]);
$oldUser->department = "," . implode(",", $oldUser->department) . ",";
$oldUser->save();
// 原主从 users.department 移除后也要退出部门群(保持成员关系=群关系一致)
// checkDelete=false业务流程跳过 owner_id/important 校验
if ($this->dialog_id > 0) {
$dialog = WebSocketDialog::find($this->dialog_id);
$dialog?->exitGroup($oldUser->userid, 'remove', false, true);
}
}
if ($newUser) {
$newUser->department = array_diff($newUser->department, [$this->id]);
@@ -126,6 +224,123 @@ class UserDepartment extends AbstractModel
});
}
/**
* 任命部门管理员
* - 部门管理员自动加入 users.department成为部门成员与负责人对齐
* - 部门管理员自动加入部门群 + 设 role=2
* - 幂等(已是部门管理员不报错)
*
* @param int $userid
* @return void
* @throws ApiException
*/
public function addDeputy($userid)
{
if ($userid <= 0) {
throw new ApiException('请选择有效的成员');
}
$user = User::whereUserid($userid)->first();
if (!$user) {
throw new ApiException('该用户不存在');
}
if ((int)$this->owner_userid === (int)$userid) {
throw new ApiException('不能将部门负责人任命为部门管理员');
}
AbstractModel::transaction(function () use ($userid, $user) {
// 写部门管理员表unique key 自动幂等)
\DB::table('user_department_owners')->insertOrIgnore([
'department_id' => $this->id,
'userid' => $userid,
]);
// 加入 users.department成为部门成员与负责人对齐
$userDeptIds = $user->department; // accessor 返回数组
if (!in_array($this->id, $userDeptIds)) {
$userDeptIds = array_merge($userDeptIds, [$this->id]);
$user->department = "," . implode(",", $userDeptIds) . ",";
$user->save();
}
// 加部门管理员入部门群 + 设 role=2 + important=true
if ($this->dialog_id > 0) {
$dialog = WebSocketDialog::find($this->dialog_id);
if ($dialog) {
// joinGroup($userid, $inviter, $important=null, $pushMsg=true)
// important=true部门管理员成员关系不可被普通群操作打散
$dialog->joinGroup($userid, 0, true, true);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $userid)
->update(['role' => 2]);
$dialog->pushMsg('groupUpdate', [
'id' => $dialog->id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
}
});
}
/**
* 罢免部门管理员
* - 删部门管理员表记录
* - 从 users.department 移除该部门 ID与负责人"离开部门"对齐)
* - 退出部门群(成员关系=群关系一致)
* - 幂等
*
* @param int $userid
* @return void
*/
public function delDeputy($userid)
{
if ($userid <= 0) {
return;
}
// 防御当前部门负责人不能被罢免saveDepartment 应已清理残留,此处兜底)
// 仅清理 user_department_owners 中的悬挂记录,绝不联动移除其部门成员关系/部门群成员
if ((int)$this->owner_userid === (int)$userid) {
\DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', $userid)
->delete();
return;
}
AbstractModel::transaction(function () use ($userid) {
$deleted = \DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', $userid)
->delete();
if ($deleted > 0) {
// 从 users.department 移除该部门 ID
$user = User::whereUserid($userid)->first();
if ($user) {
$userDeptIds = $user->department;
if (in_array($this->id, $userDeptIds)) {
$userDeptIds = array_diff($userDeptIds, [$this->id]);
$user->department = "," . implode(",", $userDeptIds) . ",";
$user->save();
}
}
// 退出部门群exitGroup 会清除 dialog_users 记录role 随之消失)
if ($this->dialog_id > 0) {
$dialog = WebSocketDialog::find($this->dialog_id);
if ($dialog) {
// checkDelete=false业务流程跳过 owner_id/important 校验
$dialog->exitGroup($userid, 'remove', false, true);
$dialog->pushMsg('groupUpdate', [
'id' => $dialog->id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
}
}
});
}
/**
* 删除部门
* @return void
@@ -148,6 +363,8 @@ class UserDepartment extends AbstractModel
// 解散群组
$dialog = WebSocketDialog::find($this->dialog_id);
$dialog?->deleteDialog();
// 清理部门管理员记录(防悬挂)
\DB::table('user_department_owners')->where('department_id', $this->id)->delete();
//
$this->delete();
}
@@ -160,6 +377,7 @@ class UserDepartment extends AbstractModel
*/
public static function transfer($originalUserid, $newUserid)
{
// 部门负责人转让(保持现有逻辑)
self::whereOwnerUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid) {
/** @var self $item */
foreach ($list as $item) {
@@ -168,6 +386,11 @@ class UserDepartment extends AbstractModel
]);
}
});
// 部门管理员离职清理(新增):直接删除离职用户的所有部门管理员记录
// 不需要清群 role —— UserTransfer::exitDialog 会把人踢出所有群role 随成员关系一起消失
\DB::table('user_department_owners')
->where('userid', $originalUserid)
->delete();
}
/**
@@ -190,6 +413,93 @@ class UserDepartment extends AbstractModel
return array_unique($subIds);
}
/**
* 获取用户可切换负责人视角的部门(正负责人 + 部门管理员)
* @param int $userid
* @return \Illuminate\Support\Collection
*/
public static function getManagedDepartments($userid)
{
$userid = intval($userid);
if ($userid <= 0) {
return collect();
}
$deputyDepartmentIds = \DB::table('user_department_owners')
->where('userid', $userid)
->pluck('department_id')
->map(fn($v) => intval($v))
->toArray();
return self::select(['id', 'name', 'parent_id', 'owner_userid'])
->where(function ($query) use ($userid, $deputyDepartmentIds) {
$query->where('owner_userid', $userid);
if ($deputyDepartmentIds) {
$query->orWhereIn('id', $deputyDepartmentIds);
}
})
->orderBy('id')
->get();
}
/**
* 获取用户选择的负责人视角部门范围(含所有下级部门)
* @param int $userid
* @param array|string|null $selectedIds all/空表示全部可管理部门
* @return array
*/
public static function getManagedDepartmentScopeIds($userid, $selectedIds = null): array
{
$managedIds = self::getManagedDepartments($userid)->pluck('id')->map(fn($v) => intval($v))->toArray();
if (empty($managedIds)) {
return [];
}
if ($selectedIds === 'all' || $selectedIds === null || $selectedIds === '' || $selectedIds === []) {
$selected = $managedIds;
} else {
if (!is_array($selectedIds)) {
$selectedIds = explode(',', (string)$selectedIds);
}
$selected = array_values(array_intersect(
array_map('intval', $selectedIds),
$managedIds
));
}
if (empty($selected)) {
return [];
}
$scopeIds = [];
foreach ($selected as $departmentId) {
$scopeIds[] = $departmentId;
$scopeIds = array_merge($scopeIds, self::getAllSubDepartmentIds($departmentId));
}
return array_values(array_unique(array_map('intval', $scopeIds)));
}
/**
* 获取负责人视角可管理的成员 userid
* @param int $userid
* @param array|string|null $selectedIds
* @return array
*/
public static function getManagedMemberUserids($userid, $selectedIds = null): array
{
$departmentIds = self::getManagedDepartmentScopeIds($userid, $selectedIds);
if (empty($departmentIds)) {
return [];
}
return User::select(['userid'])
->where(function ($query) use ($departmentIds) {
foreach ($departmentIds as $departmentId) {
$query->orWhere('department', 'like', "%,{$departmentId},%");
}
})
->pluck('userid')
->map(fn($v) => intval($v))
->unique()
->values()
->toArray();
}
/**
* 获取部门基本信息缓存时间1小时
* @param int|array $ids
@@ -232,4 +542,77 @@ class UserDepartment extends AbstractModel
return is_array($ids) ? $result : $result->first();
}
/**
* 部门负责人视角上下文(只读)。
* $defaultAll=true 用于项目内只读辅助接口兜底:前端漏传部门选择时按全部可管理部门判断。
*/
public static function ownerViewContext(User $user, bool $defaultAll = false): array
{
$ids = Request::input('department_owner_ids', Request::input('department_ids'));
if (($ids === null || $ids === '') && $defaultAll) {
$ids = 'all';
}
$empty = [
'enabled' => false,
'member_userids' => [],
'project_ids' => [],
'project_id_map' => [],
'own_project_ids' => [],
'own_project_id_map' => [],
];
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
return $empty;
}
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
if (empty($memberUserids)) {
return $empty;
}
// 项目可单独关闭"部门负责人视角可见",关闭后对负责人隐藏(含项目和任务群聊)
$projectIds = ProjectUser::whereIn('project_users.userid', $memberUserids)
->join('projects', 'projects.id', '=', 'project_users.project_id')
->whereNull('projects.deleted_at')
->where(function ($query) {
$query->where('projects.department_owner_view', '<>', 'close')
->orWhereNull('projects.department_owner_view');
})
->distinct()
->pluck('projects.id')
->map(fn($v) => intval($v))
->values()
->toArray();
$ownProjectIds = ProjectUser::whereUserid($user->userid)
->pluck('project_id')
->map(fn($v) => intval($v))
->unique()
->values()
->toArray();
return [
'enabled' => !empty($projectIds),
'member_userids' => $memberUserids,
'project_ids' => $projectIds,
'project_id_map' => array_fill_keys($projectIds, true),
'own_project_ids' => $ownProjectIds,
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
];
}
/**
* 判断项目是否属于部门只读范围(非本人项目)
*/
public static function isDepartmentReadonlyProject(array $context, int $projectId): bool
{
return !empty($context['enabled'])
&& isset($context['project_id_map'][$projectId])
&& !isset($context['own_project_id_map'][$projectId]);
}
/**
* 为项目数据附加部门只读标记
*/
public static function appendDepartmentReadonlyProject(array $project, array $context): array
{
$project['department_readonly'] = self::isDepartmentReadonlyProject($context, intval($project['id']));
return $project;
}
}

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

@@ -90,9 +90,15 @@ class UserTransfer extends AbstractModel
$dialog->owner_id = $this->new_userid;
if ($dialog->save()) {
$dialog->joinGroup($this->new_userid, 0);
// 同步 role=1保证 deputy_ids 与 owner_id 一致
// 若 new_userid 之前是群管理员role=2升为群主后必须从 deputy 列表移出
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $this->new_userid)
->update(['role' => 1]);
$dialog->pushMsg("groupUpdate", [
'id' => $dialog->id,
'owner_id' => $dialog->owner_id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
}

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

@@ -62,6 +62,8 @@ class WebSocketDialog extends AbstractModel
{
use SoftDeletes;
protected $appends = ['deputy_ids'];
/**
* 头像地址
* @param $value
@@ -260,6 +262,15 @@ class WebSocketDialog extends AbstractModel
$data[$field] = $data[$field] ?? null;
}
}
// DB::table 列表/search/beyond 渠道进入的是 stdClass不会触发 Eloquent $appends。
// 这里统一补齐 deputy_ids保证群管理员入口和标识在所有会话来源中一致。
if (($data['type'] ?? null) === 'group' && !array_key_exists('deputy_ids', $data)) {
$data['deputy_ids'] = WebSocketDialogUser::whereDialogId($data['id'])
->where('role', 2)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
}
$data['avatar'] = Base::fillUrl($data['avatar']);
// 会员必要字段
@@ -457,11 +468,12 @@ class WebSocketDialog extends AbstractModel
* @param int|array $userid 加入的会员ID或会员ID组
* @param int $inviter 邀请人
* @param bool|null $important 重要人员(null不修改、bool修改)
* @param bool $pushMsg 是否推送消息
* @return bool
*/
public function joinGroup($userid, $inviter, $important = null)
public function joinGroup($userid, $inviter, $important = null, $pushMsg = true)
{
AbstractModel::transaction(function () use ($important, $inviter, $userid) {
AbstractModel::transaction(function () use ($important, $inviter, $userid, $pushMsg) {
foreach (is_array($userid) ? $userid : [$userid] as $value) {
if ($value > 0) {
$updateData = [
@@ -479,7 +491,7 @@ class WebSocketDialog extends AbstractModel
'bot' => User::isBot($value) ? 1 : 0
]);
}, $isInsert);
if ($isInsert) {
if ($isInsert && $pushMsg) {
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
'notice' => User::userid2nickname($value) . " 已加入群组"
], $inviter, true, true);
@@ -487,9 +499,11 @@ class WebSocketDialog extends AbstractModel
}
}
});
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
if ($pushMsg) {
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
}
return true;
}
@@ -515,11 +529,40 @@ class WebSocketDialog extends AbstractModel
foreach ($list as $item) {
if ($checkDelete) {
if ($type === 'remove') {
// 移出时:如果是全员群仅允许管理员操作,其他群仅群主或邀请人可以操作
// 移出时:如果是全员群仅允许管理员操作,其他群主/群管理员/邀请人可以操作
if ($this->group_type === 'all') {
User::auth("admin");
} elseif (!in_array(User::userid(), [$this->owner_id, $item->inviter])) {
throw new ApiException('只有群主或邀请人可以移出成员');
} else {
$actor = User::userid();
// 未认证时拒绝
if ($actor <= 0) {
throw new ApiException('只有群主或邀请人可以移出成员');
}
// 目标是群主或群管理员时的保护
$targetIsPrimaryOwner = $this->isPrimaryOwner($item->userid);
$targetIsDeputyOwner = $this->isDeputyOwner($item->userid);
if ($targetIsPrimaryOwner || $targetIsDeputyOwner) {
// 普通邀请人不能移出群主或群管理员
$actorIsPrimaryOwner = $this->isPrimaryOwner($actor);
$actorIsDeputyOwner = $this->isDeputyOwner($actor);
if (!$actorIsPrimaryOwner && !$actorIsDeputyOwner) {
throw new ApiException('普通成员不能移出群主或群管理员');
}
// 群管理员不能移出群主或其他群管理员
if ($actorIsDeputyOwner && !$actorIsPrimaryOwner) {
throw new ApiException('群管理员不能移出群主或其他群管理员');
}
}
// 普通成员:群主、群管理员、邀请人可移出
$allowedActor = $this->isOwner($actor) || $actor === (int)$item->inviter;
if (!$allowedActor) {
throw new ApiException('只有群主、群管理员或邀请人可以移出成员');
}
}
}
if ($item->userid == $this->owner_id) {
@@ -530,6 +573,7 @@ class WebSocketDialog extends AbstractModel
}
}
//
$item->operator_id = User::userid();
$item->delete();
//
if ($pushMsg) {
@@ -546,9 +590,47 @@ class WebSocketDialog extends AbstractModel
});
});
//
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
if ($pushMsg) {
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$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);
}
}
/**
@@ -598,6 +680,89 @@ class WebSocketDialog extends AbstractModel
}
}
/**
* 是否群主(与 owner_id 一致)
*/
public function isPrimaryOwner($userid): bool
{
return $userid > 0 && (int)$this->owner_id === (int)$userid;
}
/**
* 是否群管理员(仅 web_socket_dialog_users.role=2
*/
public function isDeputyOwner($userid): bool
{
if ($userid <= 0) {
return false;
}
return WebSocketDialogUser::where('dialog_id', $this->id)
->where('userid', $userid)
->where('role', 2)
->exists();
}
/**
* 是否群主(含群管理员)
*/
public function isOwner($userid): bool
{
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
}
/**
* 是否有权限设置/取消本会话内「他人」的待办
* 放行:群主/群管理员、关联项目负责人/项目管理员、关联任务负责人(及任务所属项目负责人/管理员)
*
* @param int $userid
* @return bool
*/
public function checkTodoOwnerPermission($userid): bool
{
$userid = intval($userid);
if ($userid <= 0) {
return false;
}
// 群主 / 群管理员
if ($this->isOwner($userid)) {
return true;
}
// 关联项目(项目群)负责人 / 项目管理员
$project = Project::whereDialogId($this->id)->first();
if ($project && $project->isOwner($userid)) {
return true;
}
// 关联任务(任务群)负责人,及任务所属项目负责人 / 管理员
$task = ProjectTask::whereDialogId($this->id)->first();
if ($task) {
if (ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->whereOwner(1)->exists()) {
return true;
}
$taskProject = Project::find($task->project_id);
if ($taskProject && $taskProject->isOwner($userid)) {
return true;
}
}
return false;
}
/**
* 群管理员 userid 列表
*
* @return array
*/
public function getDeputyIdsAttribute(): array
{
if (!$this->id) {
return [];
}
return WebSocketDialogUser::where('dialog_id', $this->id)
->where('role', 2)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
}
/**
* 检查禁言
* @param $userid
@@ -783,6 +948,13 @@ class WebSocketDialog extends AbstractModel
if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) {
return $dialog;
}
// 部门负责人只读视角:项目/任务群按项目级共享放行(任务数据另按可见性校验,与普通成员一致)
if ($projectId > 0 && $checkOwner === false) {
$departmentView = UserDepartment::ownerViewContext(User::auth(), true);
if (UserDepartment::isDepartmentReadonlyProject($departmentView, $projectId)) {
return $dialog;
}
}
break;
case 'okr':
@@ -820,6 +992,7 @@ class WebSocketDialog extends AbstractModel
WebSocketDialogUser::createInstance([
'dialog_id' => $dialog->id,
'userid' => $value,
'role' => ($owner_id > 0 && (int)$value === (int)$owner_id) ? 1 : 0,
'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,

View File

@@ -44,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)
@@ -54,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)
@@ -111,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
@@ -382,7 +414,7 @@ class WebSocketDialogMsg extends AbstractModel
* @param array $userids 设置给指定会员
* @return mixed
*/
public function toggleTodoMsg($sender, $userids = [])
public function toggleTodoMsg($sender, $userids = [], $remindAt = false)
{
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
return Base::retError('此消息不支持设待办');
@@ -391,6 +423,14 @@ class WebSocketDialogMsg extends AbstractModel
$current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray();
$cancel = array_diff($current, $userids);
$setup = array_diff($userids, $current);
// 待办操作权限管控(系统开关:禁止其他人员设置/取消待办)
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
$affected = array_unique(array_merge($cancel, $setup)); // 本次真正影响到的用户
$others = array_diff($affected, [$sender]); // 排除"自己"
if ($others && !$dialog->checkTodoOwnerPermission($sender)) {
return Base::retError('仅群主、项目/任务负责人可设置或取消他人待办');
}
}
//
$this->todo = $setup || count($current) > count($cancel) ? $sender : 0;
$this->save();
@@ -445,12 +485,39 @@ class WebSocketDialogMsg extends AbstractModel
];
$dialog->pushMsg('update', $upData);
//
// 提醒时间仅当调用方显式传入时处理false=不传则不动既有提醒)
if ($remindAt !== false) {
$this->setTodoRemind($userids, $remindAt ?: null);
}
//
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
'add' => $addData,
'update' => $upData,
]);
}
/**
* 设置/取消本消息指定成员待办的提醒时间(纯数据,无推送)。
* 改动会把 reminded_at 重置为 null使其可再次到点提醒。
*
* @param array $userids 目标成员
* @param string|null $remindAt 提醒时间字符串null/空 表示取消提醒
* @return int 受影响行数
*/
public function setTodoRemind(array $userids, $remindAt = null)
{
$userids = array_values(array_filter(array_map('intval', $userids)));
if (empty($userids)) {
return 0;
}
return WebSocketDialogMsgTodo::whereMsgId($this->id)
->whereIn('userid', $userids)
->update([
'remind_at' => $remindAt ?: null,
'reminded_at' => null,
]);
}
/**
* 转发消息
* @param array|int $dialogids
@@ -460,9 +527,58 @@ class WebSocketDialogMsg extends AbstractModel
* @param string $leaveMessage 转发留言
* @return mixed
*/
/**
* 收集目标对话
* @param array|int $userids 转发给的成员ID
* @param array|int $dialogids 转发给的对话ID
* @param User $user 当前用户
* @return array
*/
private static function collectTargetDialogs($userids, $dialogids, $user)
{
$dialogs = [];
if ($userids) {
if (!is_array($userids)) {
$userids = [$userids];
}
foreach ($userids as $userid) {
if (!User::whereUserid($userid)->exists()) {
continue;
}
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
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;
}
}
}
return $dialogs;
}
/**
* 不支持转发的消息类型
*/
public static $unforwardableTypes = ['tag', 'top', 'todo', 'notice', 'word-chain', 'vote', 'template'];
public function forwardMsg($dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
{
return AbstractModel::transaction(function () use ($dialogids, $user, $userids, $showSource, $leaveMessage) {
if (in_array($this->type, self::$unforwardableTypes)) {
throw new ApiException('此类型消息不支持转发');
}
$msgData = Base::json2array($this->getRawOriginal('msg'));
$forwardData = is_array($msgData['forward_data']) ? $msgData['forward_data'] : [];
$forwardId = $forwardData['id'] ?: $this->id;
@@ -481,35 +597,7 @@ class WebSocketDialogMsg extends AbstractModel
'leave' => $leaveMessage ? 1 : 0, // 是否留言用于判断是否发给AI
];
$msgs = [];
$dialogs = [];
if ($userids) {
if (!is_array($userids)) {
$userids = [$userids];
}
foreach ($userids as $userid) {
if (!User::whereUserid($userid)->exists()) {
continue;
}
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
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;
}
}
}
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
foreach ($dialogs as $dialog) {
$res = self::sendMsg('forward-' . $forwardId, $dialog->id, $this->type, $msgData, $user->userid);
if (Base::isSuccess($res)) {
@@ -532,6 +620,105 @@ class WebSocketDialogMsg extends AbstractModel
});
}
/**
* 合并转发消息
* @param array $msgIds 消息ID数组
* @param array|int $dialogids 转发给的对话ID
* @param array|int $userids 转发给的成员ID
* @param User $user 当前用户
* @param int $showSource 是否显示原发送者信息
* @param string $leaveMessage 转发留言
* @return array
*/
public static function mergeForwardMsg($msgIds, $dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
{
return AbstractModel::transaction(function () use ($msgIds, $dialogids, $userids, $user, $showSource, $leaveMessage) {
// 查询并验证所有消息
$msgs = self::whereIn('id', $msgIds)->orderBy('created_at')->get();
if ($msgs->isEmpty()) {
throw new ApiException('消息不存在或已被删除');
}
// 验证所有消息属于同一对话
$dialogId = $msgs->first()->dialog_id;
if ($msgs->pluck('dialog_id')->unique()->count() > 1) {
throw new ApiException('只能合并转发同一对话的消息');
}
WebSocketDialog::checkDialog($dialogId);
// 过滤不支持转发的消息类型
$msgs = $msgs->filter(function ($msg) {
return !in_array($msg->type, self::$unforwardableTypes);
});
if ($msgs->isEmpty()) {
throw new ApiException('所选消息均不支持转发');
}
// 收集发送者信息
$senderIds = $msgs->pluck('userid')->unique()->values()->toArray();
$senderNames = User::whereIn('userid', array_slice($senderIds, 0, 2))
->pluck('nickname')
->toArray();
// 组装预览列表前4条精简字段
$msgIds = $msgs->pluck('id')->toArray();
$preview = [];
foreach ($msgs->take(4) as $msg) {
$preview[] = [
'userid' => $msg->userid,
'type' => $msg->type,
'msg' => self::buildPreviewMsg($msg->type, Base::json2array($msg->getRawOriginal('msg'))),
];
}
// 构建合并转发消息体
$msgData = [
'sender_names' => $senderNames,
'sender_total' => count($senderIds),
'msg_ids' => $msgIds,
'preview' => $preview,
'count' => count($msgIds),
'forward_data' => [
'show' => $showSource,
'leave' => $leaveMessage ? 1 : 0,
],
];
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
// 发送到每个目标对话
$result = [];
foreach ($dialogs as $dialog) {
$res = self::sendMsg(null, $dialog->id, 'merge-forward', $msgData, $user->userid);
if (Base::isSuccess($res)) {
$result[] = $res['data'];
}
if ($leaveMessage) {
$res = self::sendMsg(null, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid);
if (Base::isSuccess($res)) {
$result[] = $res['data'];
}
}
}
return Base::retSuccess('转发成功', [
'msgs' => $result
]);
});
}
/**
* 构建预览消息(精简字段)
* @param string $type
* @param array $msg
* @return array
*/
private static function buildPreviewMsg($type, $msg)
{
switch ($type) {
case 'text':
return ['text' => $msg['text'] ?? ''];
case 'file':
return ['name' => $msg['name'] ?? '', 'ext' => $msg['ext'] ?? ''];
case 'location':
return ['title' => $msg['title'] ?? ''];
default:
return [];
}
}
/**
* 删除消息
* @param array|int $ids
@@ -663,6 +850,9 @@ class WebSocketDialogMsg extends AbstractModel
case 'template':
return self::previewTemplateMsg($data['msg']);
case 'merge-forward':
return "[" . Doo::translate("聊天记录") . "]";
case 'preview':
return $data['msg']['preview'];
@@ -683,6 +873,7 @@ class WebSocketDialogMsg extends AbstractModel
$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('思考中...');
@@ -695,7 +886,6 @@ class WebSocketDialogMsg extends AbstractModel
$text = $title;
} else {
$text = Base::markdown2html($text);
$text = self::previewConvertTaskList($text);
}
}
$text = preg_replace("/<img\s+class=\"emoticon\"[^>]*?alt=\"(\S+)\"[^>]*?>/", "[$1]", $text);
@@ -711,36 +901,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
@@ -865,8 +1025,7 @@ class WebSocketDialogMsg extends AbstractModel
switch ($this->type) {
case "file":
// 提取文件消息
$msgData = Base::json2array($this->getRawOriginal('msg'));
$result = $this->convertMentionFormat("path", $msgData['path'], $msgData['name'], $reserves);
$result = " 文件:{$this->msg['name']}(大小:{$this->msg['size']}BURL{$this->msg['path']} ";
break;
case "text":
@@ -889,29 +1048,31 @@ class WebSocketDialogMsg extends AbstractModel
// 提及任务、文件、报告
$result = preg_replace_callback_array([
// 用户
"/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/" => function () {
"/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/" => function ($match) {
return "";
},
// 任务
"/<span class=\"mention task\" data-id=\"(\d+)\">#?(.*?)<\/span>/" => function ($match) use (&$reserves) {
return $this->convertMentionFormat("task", $match[1], $match[2], $reserves);
"/<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)) {
return $this->convertMentionFormat("file", $subMatch[1], $match[2], $reserves);
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "文件ID{$subMatch[1]}" : "文件分享码:{$subMatch[1]}") . ")";
}
return "";
return " 文件:{$match[2]}{$idOrCode} ";
},
// 报告
"/<a class=\"mention report\" href=\"([^\"']+?)\"[^>]*?>%?(.*?)<\/a>/" => function ($match) use (&$reserves) {
$idOrCode = "";
if (preg_match("/single\/report\/detail\/(.*?)$/", $match[1], $subMatch)) {
return $this->convertMentionFormat("report", $subMatch[1], $match[2], $reserves);
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "报告ID{$subMatch[1]}" : "报告分享码:{$subMatch[1]}") . ")";
}
return "";
return " 工作汇报:{$match[2]}{$idOrCode} ";
},
], $result);
@@ -926,35 +1087,15 @@ class WebSocketDialogMsg extends AbstractModel
return '';
}
// 处理 reserves
foreach ($reserves as $rand => $mention) {
$result = str_replace($rand, $mention, $result);
}
// 截取最大长度
if ($maxLength > 0 && mb_strlen($result) > $maxLength) {
$result = mb_substr($result, 0, $maxLength);
}
return $result;
}
// 规范以斜杠开头的命令
$result = preg_replace('/^\s*\\//', '/', $result);
/**
* 转换提及消息格式
* 将提及的任务、文件、报告等转换为统一的格式 [type#key#name]
*
* @param string $type 提及类型task、file、report、path
* @param string $key 提及对象的唯一标识
* @param string $name 提及对象的显示名称
* @return string 格式化后的提及字符串
*/
private function convertMentionFormat($type, $key, $name, &$reserves)
{
$key = str_replace(['#', '-->'], '', $key);
$name = str_replace(['#', '-->'], '', $name);
$rand = Base::generatePassword(12);
$reserves[$rand] = "<!--{$type}#{$key}#{$name}-->";
return $rand;
return $result;
}
/**
@@ -1279,6 +1420,9 @@ class WebSocketDialogMsg extends AbstractModel
$msg['height'] = $imageSize[1];
}
}
if ($type === 'merge-forward') {
$mtype = 'merge-forward';
}
if ($push_silence === null) {
$push_silence = !in_array($type, ["text", "file", "record", "meeting"]);
}

View File

@@ -121,4 +121,30 @@ class WebSocketDialogMsgRead extends AbstractModel
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

@@ -2,6 +2,8 @@
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\WebSocketDialogMsgTodo
*
@@ -50,4 +52,21 @@ class WebSocketDialogMsgTodo extends AbstractModel
}
return $this->appendattrs['msgData'];
}
/**
* 取到点待提醒的待办行:有提醒时间、未提醒、未完成、提醒时间已到。
* 纯查询,无副作用,供 TodoRemindTask 使用。
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function dueReminders()
{
return self::whereNotNull('remind_at')
->whereNull('reminded_at')
->whereNull('done_at')
->where('remind_at', '<=', Carbon::now())
->orderBy('msg_id')
->orderBy('id')
->limit(500)
->get();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,858 @@
<?php
namespace App\Module;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Models\ProjectTaskUser;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreBase;
use Cache;
use Carbon\Carbon;
class AiTaskSuggestion
{
/**
* AI 助手的 userid
*/
const AI_ASSISTANT_USERID = -1;
/**
* 相似度阈值
*/
const SIMILAR_THRESHOLD = 0.5;
/**
* 检查是否满足执行条件
*/
public static function shouldExecute(ProjectTask $task, string $eventType): bool
{
switch ($eventType) {
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
// 描述为空或长度 < 20
$content = trim($task->content ?? '');
return empty($content) || mb_strlen($content) < 20;
case ProjectTaskAiEvent::EVENT_SUBTASKS:
// 无子任务且标题长度 > 5
$hasSubtasks = ProjectTask::where('parent_id', $task->id)->exists();
return !$hasSubtasks && mb_strlen($task->name) > 5;
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
// 未指定负责人
$hasOwner = ProjectTaskUser::where('task_id', $task->id)->where('owner', 1)->exists();
return !$hasOwner;
case ProjectTaskAiEvent::EVENT_SIMILAR:
// 需要安装 search 插件才能使用向量搜索
return Apps::isInstalled('search');
default:
return false;
}
}
/**
* 生成任务描述建议
* @param ProjectTask $task 任务对象
*/
public static function generateDescription(ProjectTask $task): ?array
{
$language = self::getUserLanguageInfo($task->userid)['name'];
$prompt = self::buildDescriptionPrompt($task, $language);
$result = self::callAi($prompt);
if (empty($result)) {
return null;
}
return [
'type' => 'description',
'content' => $result,
];
}
/**
* 生成子任务拆分建议
* @param ProjectTask $task 任务对象
*/
public static function generateSubtasks(ProjectTask $task): ?array
{
$language = self::getUserLanguageInfo($task->userid)['name'];
$prompt = self::buildSubtasksPrompt($task, $language);
$result = self::callAi($prompt);
if (empty($result)) {
return null;
}
// 解析返回的子任务列表
$subtasks = self::parseSubtasksList($result);
if (empty($subtasks)) {
return null;
}
return [
'type' => 'subtasks',
'content' => $subtasks,
];
}
/**
* 生成负责人推荐
* @param ProjectTask $task 任务对象
*/
public static function generateAssignee(ProjectTask $task): ?array
{
// 获取当前任务已有的成员(负责人和协助人)
$existingUserIds = ProjectTaskUser::where('task_id', $task->id)
->pluck('userid')
->toArray();
// 获取项目成员,排除已有任务成员
$members = self::getProjectMembersInfo($task->project_id);
$members = array_filter($members, function ($member) use ($existingUserIds) {
return !in_array($member['userid'], $existingUserIds);
});
$members = array_values($members); // 重新索引
if (empty($members)) {
return null;
}
$language = self::getUserLanguageInfo($task->userid)['name'];
$prompt = self::buildAssigneePrompt($task, $members, $language);
$result = self::callAi($prompt);
if (empty($result)) {
return null;
}
// 解析推荐结果
$recommendations = self::parseAssigneeRecommendations($result, $members);
if (empty($recommendations)) {
return null;
}
return [
'type' => 'assignee',
'content' => $recommendations,
];
}
/**
* 搜索相似任务
* @param ProjectTask $task 任务对象
*/
public static function findSimilarTasks(ProjectTask $task): ?array
{
// 使用 AI 模块的 Embedding 搜索
$searchText = $task->name;
if (empty($searchText)) {
return null;
}
try {
$result = AI::getEmbedding($searchText);
if (Base::isError($result) || empty($result['data'])) {
return null;
}
$embedding = $result['data'];
// 搜索相似任务(排除自己和子任务)
$similarTasks = self::searchSimilarByEmbedding(
$embedding,
$task->project_id,
$task->id
);
if (empty($similarTasks)) {
return null;
}
// 获取用户语言对应的文案
$lang = self::getUserLanguageInfo($task->userid)['code'];
return [
'type' => 'similar',
'lang' => $lang,
'content' => $similarTasks,
];
} catch (\Exception $e) {
\Log::error('AiTaskSuggestion::findSimilarTasks error: ' . $e->getMessage());
return null;
}
}
/**
* 获取用户语言信息
* @param int $userid 用户ID
* @return array ['code' => 语言代码, 'name' => 语言名称]
*/
private static function getUserLanguageInfo(int $userid): array
{
$user = User::find($userid);
$code = $user->lang ?? 'zh';
$name = Doo::getLanguages($code) ?: '简体中文';
return ['code' => $code, 'name' => $name];
}
/**
* 获取多语言标题和提示文案
* @param string $lang 语言代码
* @return array
*/
private static function getLocalizedTitles(string $lang): array
{
$titles = [
'zh' => [
'description' => '建议补充任务描述',
'subtasks' => '建议拆分子任务',
'assignee' => '推荐负责人',
'similar' => '发现相似任务',
'similar_hint' => '以下任务与当前任务内容相似,可能是重复任务或可作为参考:',
],
'zh-CHT' => [
'description' => '建議補充任務描述',
'subtasks' => '建議拆分子任務',
'assignee' => '推薦負責人',
'similar' => '發現相似任務',
'similar_hint' => '以下任務與當前任務內容相似,可能是重複任務或可作為參考:',
],
'en' => [
'description' => 'Suggested Task Description',
'subtasks' => 'Suggested Subtasks',
'assignee' => 'Recommended Assignee',
'similar' => 'Similar Tasks Found',
'similar_hint' => 'The following tasks are similar and may be duplicates or references:',
],
'ko' => [
'description' => '작업 설명 추가 제안',
'subtasks' => '하위 작업 분할 제안',
'assignee' => '추천 담당자',
'similar' => '유사한 작업 발견',
'similar_hint' => '다음 작업은 현재 작업과 유사하며 중복되거나 참고할 수 있습니다:',
],
'ja' => [
'description' => 'タスク説明の追加を提案',
'subtasks' => 'サブタスクの分割を提案',
'assignee' => '推奨担当者',
'similar' => '類似タスクを発見',
'similar_hint' => '以下のタスクは現在のタスクと類似しており、重複している可能性があります:',
],
'de' => [
'description' => 'Vorgeschlagene Aufgabenbeschreibung',
'subtasks' => 'Vorgeschlagene Unteraufgaben',
'assignee' => 'Empfohlener Verantwortlicher',
'similar' => 'Ähnliche Aufgaben gefunden',
'similar_hint' => 'Die folgenden Aufgaben sind ähnlich und könnten Duplikate oder Referenzen sein:',
],
'fr' => [
'description' => 'Description de tâche suggérée',
'subtasks' => 'Sous-tâches suggérées',
'assignee' => 'Responsable recommandé',
'similar' => 'Tâches similaires trouvées',
'similar_hint' => 'Les tâches suivantes sont similaires et peuvent être des doublons ou des références:',
],
'id' => [
'description' => 'Saran Deskripsi Tugas',
'subtasks' => 'Saran Pembagian Subtugas',
'assignee' => 'Penanggung Jawab yang Direkomendasikan',
'similar' => 'Tugas Serupa Ditemukan',
'similar_hint' => 'Tugas berikut mirip dengan tugas saat ini dan mungkin duplikat atau referensi:',
],
'ru' => [
'description' => 'Предлагаемое описание задачи',
'subtasks' => 'Предлагаемые подзадачи',
'assignee' => 'Рекомендуемый ответственный',
'similar' => 'Найдены похожие задачи',
'similar_hint' => 'Следующие задачи похожи на текущую и могут быть дубликатами или справочными:',
],
];
return $titles[$lang] ?? $titles['zh'];
}
/**
* 转义用户输入以防止 Prompt 注入
*/
private static function escapeUserInput(string $input, int $length = 500): string
{
// 移除可能影响 AI Prompt 解析的特殊字符
$input = str_replace(['```', '---', '==='], '', $input);
// 截断过长的输入
return mb_substr(trim($input), 0, $length);
}
/**
* 构建描述生成 Prompt
* @param ProjectTask $task 任务对象
* @param string $language 输出语言名称
*/
private static function buildDescriptionPrompt(ProjectTask $task, string $language): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
return <<<PROMPT
你是一名任务规划助手,擅长根据任务标题推断并补充任务描述。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
你的任务:
根据标题、项目和栏目信息,推断任务意图并生成实用的任务描述。
生成原则:
1. 基于标题关键词和上下文进行合理推断,内容要具体、可执行
2. 使用 Markdown 格式,根据任务性质灵活组织结构(可包含目标、要求、验收标准等)
3. 简单任务保持简洁,复杂任务可适当展开,避免空泛的套话
输出语言:与任务标题的语言保持一致,如无法确定则使用{$language}
输出要求:
- 仅返回 Markdown 格式的描述内容
- 禁止输出额外说明、引导语或与任务无关的内容
PROMPT;
}
/**
* 构建子任务拆分 Prompt
* @param ProjectTask $task 任务对象
* @param string $language 输出语言名称
*/
private static function buildSubtasksPrompt(ProjectTask $task, string $language): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
$content = self::escapeUserInput($task->content ?? '');
return <<<PROMPT
你是一名任务拆解助手,擅长将复杂任务分解为可执行的子任务。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$content}
你的任务:
分析任务内容,拆解出关键的执行步骤作为子任务。
拆解原则:
1. 每个子任务聚焦单一可执行动作,避免含糊或重复
2. 根据任务复杂度灵活决定数量(通常 2-5 个),简单任务少拆,复杂任务多拆
3. 子任务之间保持合理的执行顺序或逻辑关系
4. 子任务名称简洁明了,控制在 8-30 个字符内
输出语言:与任务标题的语言保持一致,如无法确定则使用{$language}
输出格式:
1. [子任务名称]
2. [子任务名称]
...
输出要求:
- 仅返回子任务列表,禁止输出额外说明或引导语
PROMPT;
}
/**
* 构建负责人推荐 Prompt
* @param ProjectTask $task 任务对象
* @param array $members 成员列表
* @param string $language 输出语言名称
*/
private static function buildAssigneePrompt(ProjectTask $task, array $members, string $language): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
$taskContent = self::escapeUserInput($task->content ?? '');
$membersText = '';
foreach ($members as $member) {
$nickname = self::escapeUserInput($member['nickname'], 20);
$membersText .= "- {$nickname}ID:{$member['userid']}";
if (!empty($member['profession'])) {
$profession = self::escapeUserInput($member['profession'], 50);
$membersText .= ",职位:{$profession}";
}
$membersText .= ",进行中:{$member['in_progress_count']}";
$membersText .= ",近期完成:{$member['completed_count']}";
$membersText .= "\n";
}
return <<<PROMPT
你是一名任务分配助手,根据任务内容和成员情况推荐合适的负责人。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$taskContent}
可选成员:
{$membersText}
推荐原则:
1. 分析任务内容,匹配成员职位或专业方向
2. 优先推荐进行中任务较少的成员,平衡工作负载
3. 近期完成任务多说明执行力强,可作为参考
输出语言:推荐理由的语言与任务标题保持一致,如无法确定则使用{$language}
输出格式:
1. [userid]|[推荐理由]
2. [userid]|[推荐理由]
输出要求:
- 推荐 1-2 名最合适的负责人,按优先级排序
- 推荐理由需具体说明为何此人适合该任务,不超过 20 字
- 仅返回推荐列表,禁止输出额外说明
PROMPT;
}
/**
* 调用 AI 接口
*/
private static function callAi(string $prompt): ?string
{
try {
// 使用 AI 模块调用
$result = AI::invoke([
['system', '你是 DooTask 任务管理系统的 AI 助手,帮助用户管理任务。'],
['user', $prompt],
]);
if (Base::isError($result)) {
\Log::error('AiTaskSuggestion::callAi error: ' . ($result['msg'] ?? 'Unknown error'));
return null;
}
return $result['data']['content'] ?? null;
} catch (\Exception $e) {
\Log::error('AiTaskSuggestion::callAi error: ' . $e->getMessage());
return null;
}
}
/**
* 获取项目成员信息
*/
private static function getProjectMembersInfo(int $projectId): array
{
$projectUsers = ProjectUser::where('project_id', $projectId)->get();
$members = [];
foreach ($projectUsers as $pu) {
$user = User::find($pu->userid);
if (!$user || $user->bot || $user->disable_at) {
continue;
}
// 获取进行中任务数量
$inProgressCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', $user->userid)
->whereNull('project_tasks.complete_at')
->whereNull('project_tasks.archived_at')
->whereNull('project_tasks.deleted_at')
->count();
// 获取近期完成任务数量
$completedCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', $user->userid)
->whereNotNull('project_tasks.complete_at')
->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(30))
->whereNull('project_tasks.deleted_at')
->count();
$members[] = [
'userid' => $user->userid,
'nickname' => $user->nickname,
'profession' => $user->profession ?? '',
'in_progress_count' => $inProgressCount,
'completed_count' => $completedCount,
];
}
return $members;
}
/**
* 解析子任务列表
*/
private static function parseSubtasksList(string $text): array
{
$lines = explode("\n", trim($text));
$subtasks = [];
foreach ($lines as $line) {
$line = trim($line);
// 移除序号前缀
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
if (!empty($line) && mb_strlen($line) <= 100) {
$subtasks[] = $line;
}
}
return array_slice($subtasks, 0, 5); // 最多5个
}
/**
* 解析负责人推荐结果
*/
private static function parseAssigneeRecommendations(string $text, array $members): array
{
$memberMap = [];
foreach ($members as $m) {
$memberMap[$m['userid']] = $m;
}
$lines = explode("\n", trim($text));
$recommendations = [];
$addedUserIds = []; // 记录已添加的用户ID防止重复
foreach ($lines as $line) {
$line = trim($line);
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
if (preg_match('/^(\d+)\|(.+)$/', $line, $matches)) {
$userid = intval($matches[1]);
$reason = trim($matches[2]);
// 跳过已添加的用户
if (in_array($userid, $addedUserIds)) {
continue;
}
if (isset($memberMap[$userid])) {
$recommendations[] = [
'userid' => $userid,
'nickname' => $memberMap[$userid]['nickname'],
'reason' => $reason,
];
$addedUserIds[] = $userid;
}
}
}
return array_slice($recommendations, 0, 2); // 最多2个
}
/**
* 通过 Embedding 搜索相似任务
*
* @param array $embedding 任务内容的向量表示
* @param int $projectId 项目ID用于过滤同项目任务
* @param int $excludeTaskId 排除的任务ID当前任务
* @return array 相似任务列表
*/
private static function searchSimilarByEmbedding(array $embedding, int $projectId, int $excludeTaskId): array
{
if (empty($embedding)) {
return [];
}
try {
// 使用 ManticoreBase 进行向量搜索
// userid=0 跳过权限过滤,我们通过 project_id 过滤
$results = ManticoreBase::taskVectorSearch($embedding, 0, 200);
if (empty($results)) {
return [];
}
// 获取当前任务的子任务ID列表
$childTaskIds = ProjectTask::where('parent_id', $excludeTaskId)
->whereNull('deleted_at')
->pluck('id')
->toArray();
// 过滤:同项目、排除当前任务及其子任务、相似度阈值
$similarTasks = [];
foreach ($results as $item) {
// 过滤不同项目的任务
if ($item['project_id'] != $projectId) {
continue;
}
// 排除当前任务
if ($item['task_id'] == $excludeTaskId) {
continue;
}
// 排除子任务
if (in_array($item['task_id'], $childTaskIds)) {
continue;
}
// 相似度阈值
$similarity = $item['similarity'] ?? 0;
if ($similarity < self::SIMILAR_THRESHOLD) {
continue;
}
$similarTasks[] = [
'task_id' => $item['task_id'],
'name' => $item['task_name'] ?? '',
'similarity' => round($similarity, 2),
];
// 最多返回 5 个相似任务
if (count($similarTasks) >= 5) {
break;
}
}
return $similarTasks;
} catch (\Exception $e) {
\Log::error('searchSimilarByEmbedding error: ' . $e->getMessage());
return [];
}
}
/**
* 构建 Markdown 消息
* @param int $taskId 任务ID
* @param array $suggestions 建议列表
* @param int $msgId 消息ID
* @param string $lang 语言代码
*/
public static function buildMarkdownMessage(int $taskId, array $suggestions, int $msgId = 0, string $lang = 'zh'): string
{
$parts = [];
$titles = self::getLocalizedTitles($lang);
foreach ($suggestions as $suggestion) {
// 如果 suggestion 中有 lang使用它similar 类型)
$suggestionLang = $suggestion['lang'] ?? $lang;
$suggestionTitles = ($suggestionLang !== $lang) ? self::getLocalizedTitles($suggestionLang) : $titles;
switch ($suggestion['type']) {
case 'description':
$parts[] = self::buildDescriptionMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
break;
case 'subtasks':
$parts[] = self::buildSubtasksMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
break;
case 'assignee':
$parts[] = self::buildAssigneeMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
break;
case 'similar':
$parts[] = self::buildSimilarMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
break;
}
}
return implode("\n\n---\n\n", $parts);
}
/**
* 构建描述建议 Markdown
* @param int $taskId 任务ID
* @param int $msgId 消息ID
* @param string $content 描述内容
* @param array $titles 本地化标题
*/
private static function buildDescriptionMarkdown(int $taskId, int $msgId, string $content, array $titles): string
{
$title = $titles['description'];
return <<<MD
### {$title}
{$content}
:::ai-action{type="description" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
/**
* 构建子任务建议 Markdown
* @param int $taskId 任务ID
* @param int $msgId 消息ID
* @param array $subtasks 子任务列表
* @param array $titles 本地化标题
*/
private static function buildSubtasksMarkdown(int $taskId, int $msgId, array $subtasks, array $titles): string
{
$title = $titles['subtasks'];
$list = '';
foreach ($subtasks as $i => $name) {
$num = $i + 1;
$list .= "{$num}. {$name}\n";
}
return <<<MD
### {$title}
{$list}
:::ai-action{type="subtasks" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
/**
* 构建负责人建议 Markdown
* @param int $taskId 任务ID
* @param int $msgId 消息ID
* @param array $recommendations 推荐列表
* @param array $titles 本地化标题
*/
private static function buildAssigneeMarkdown(int $taskId, int $msgId, array $recommendations, array $titles): string
{
$title = $titles['assignee'];
$list = '';
foreach ($recommendations as $rec) {
$stUserId = $rec['userid'];
$viewUrl = "dootask://contact/{$stUserId}";
$list .= "- **[{$rec['nickname']}]({$viewUrl})** - {$rec['reason']} :::ai-action{type=\"assignee\" task=\"{$taskId}\" msg=\"{$msgId}\" userid=\"{$stUserId}\"}:::\n";
}
return <<<MD
### {$title}
{$list}
MD;
}
/**
* 构建相似任务 Markdown
* @param int $taskId 任务ID
* @param int $msgId 消息ID
* @param array $similarTasks 相似任务列表
* @param array $titles 本地化标题
*/
private static function buildSimilarMarkdown(int $taskId, int $msgId, array $similarTasks, array $titles): string
{
$title = $titles['similar'];
$hint = $titles['similar_hint'];
$list = '';
foreach ($similarTasks as $i => $st) {
$num = $i + 1;
$stTaskId = $st['task_id'];
$viewUrl = "dootask://task/{$stTaskId}";
$list .= "{$num}. **[#{$stTaskId}]({$viewUrl})** {$st['name']} :::ai-action{type=\"similar\" task=\"{$taskId}\" msg=\"{$msgId}\" related=\"{$stTaskId}\"}:::\n";
}
return <<<MD
### {$title}
{$hint}
{$list}
MD;
}
/**
* 发送建议消息
* @param ProjectTask $task 任务对象
* @param array $suggestions 建议列表
*/
public static function sendSuggestionMessage(ProjectTask $task, array $suggestions): ?int
{
if (empty($suggestions)) {
return null;
}
// 如果任务没有对话,自动创建
if (!$task->dialog_id) {
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
if ($dialog) {
$task->dialog_id = $dialog->id;
$task->save();
$task->pushMsg('dialog');
} else {
return null;
}
}
// 获取用户语言
$lang = self::getUserLanguageInfo($task->userid)['code'];
// 先发送消息获取 msg_id然后更新消息内容带上 msg_id
$tempMarkdown = self::buildMarkdownMessage($task->id, $suggestions, 0, $lang);
$result = WebSocketDialogMsg::sendMsg(
null,
$task->dialog_id,
'text',
['text' => $tempMarkdown, 'type' => 'md'],
self::AI_ASSISTANT_USERID,
true, // push_self
false, // push_retry
true // push_silence
);
if (Base::isError($result)) {
return null;
}
$msgId = $result['data']->id ?? 0;
if (empty($msgId)) {
return null;
}
// 更新消息,带上真实的 msg_id
$finalMarkdown = self::buildMarkdownMessage($task->id, $suggestions, $msgId, $lang);
WebSocketDialogMsg::sendMsg(
'change-' . $msgId,
$task->dialog_id,
'text',
['text' => $finalMarkdown, 'type' => 'md'],
self::AI_ASSISTANT_USERID,
true, // push_self
);
return $msgId;
}
/**
* 更新消息状态(采纳/忽略后)
*
* @param int $msgId 消息ID
* @param int $dialogId 对话ID
* @param string $type 建议类型
* @param string $status 状态applied/dismissed
* @param int $userid 用户IDassignee类型单独处理时使用
* @param int $related 关联任务IDsimilar类型单独处理时使用
* @return array 更新后的消息数据
*/
public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status, int $userid = 0, int $related = 0): array
{
// 验证消息存在且属于指定对话
$msg = WebSocketDialogMsg::where('id', $msgId)
->where('dialog_id', $dialogId)
->first();
if (!$msg) {
return Base::retError('消息不存在');
}
$content = $msg->msg['text'] ?? '';
if (empty($content)) {
return Base::retError('消息内容为空');
}
// 根据类型和参数构建匹配模式,添加 status 属性
if ($type === 'assignee' && $userid > 0) {
$pattern = '/(:::ai-action\{type="assignee"[^}]*userid="' . $userid . '"[^}]*)\}:::/';
} elseif ($type === 'similar' && $related > 0) {
$pattern = '/(:::ai-action\{type="similar"[^}]*related="' . $related . '"[^}]*)\}:::/';
} else {
$pattern = '/(:::ai-action\{type="' . preg_quote($type, '/') . '"[^}]*)\}:::/';
}
$newContent = preg_replace($pattern, '$1 status="' . $status . '"}:::', $content);
// 更新消息并返回结果
return WebSocketDialogMsg::sendMsg(
'change-' . $msgId,
$dialogId,
'text',
['text' => $newContent, 'type' => 'md'],
self::AI_ASSISTANT_USERID
);
}
}

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
*/
@@ -1827,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

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) {

13
app/Module/UserImport.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
namespace App\Module;
use Maatwebsite\Excel\Concerns\ToArray;
class UserImport implements ToArray
{
public function array(array $array)
{
return $array;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Module;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
class UserImportTemplate implements FromArray, WithHeadings
{
public function array(): array
{
return [
['employee@example.com', '张三', 'Abc123456', '工程师'],
];
}
public function headings(): array
{
return ['邮箱(必填)', '昵称(必填,2-20字)', '初始密码(必填,6-32位)', '职位(选填,2-20字)'];
}
}

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]));
}
/**
@@ -88,7 +113,9 @@ class ProjectTaskObserver
return ProjectUser::whereProjectId($projectTask->project_id)->pluck('userid')->toArray();
}
if (in_array('projectOwnerUser', $dataType)) {
return ProjectUser::whereProjectId($projectTask->project_id)->where('owner', 1)->pluck('userid')->toArray();
return ProjectUser::whereProjectId($projectTask->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->toArray();
}
$array = [];
if (in_array('task', $dataType)) {

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)) {
// 判断是用户自己修改还是管理员修改
$currentUserid = User::userid();
$eventType = ($currentUserid > 0 && $currentUserid === $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

@@ -64,7 +64,7 @@ class WebSocketService implements WebSocketHandlerInterface
'ud' => $userid,
],
]));
$this->userOn($fd, $userid);
$this->userOn($fd, $userid, $get['platform']);
} else {
// 用户不存在
$server->push($fd, Base::array2json([
@@ -105,6 +105,11 @@ class WebSocketService implements WebSocketHandlerInterface
// 握手信息
case 'handshake':
// 更新 PC 端活跃时间
$row = WebSocket::whereFd($frame->fd)->first();
if ($row && Base::isPc($row->platform)) {
Cache::put("user_pc_active:{$row->userid}", time(), 60);
}
break;
// 访问状态
@@ -166,17 +171,27 @@ class WebSocketService implements WebSocketHandlerInterface
* 用户上线
* @param $fd
* @param $userid
* @param $platform
* @return void
*/
private function userOn($fd, $userid)
private function userOn($fd, $userid, $platform = 'web')
{
// 校验平台类型
if (!in_array($platform, ['android', 'ios', 'win', 'mac', 'web'])) {
$platform = 'web';
}
WebSocket::updateInsert([
'key' => md5($fd . '@' . $userid)
], [
'fd' => $fd,
'userid' => $userid,
'platform' => $platform,
]);
OnlineData::online($userid);
// PC 端上线时更新活跃时间
if (Base::isPc($platform)) {
Cache::put("user_pc_active:{$userid}", time(), 60);
}
}
/**

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Tasks;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Module\AiTaskSuggestion;
/**
* AI 任务分析异步任务
* 处理单个任务的所有 AI 事件
*/
class AiTaskAnalyzeTask extends AbstractTask
{
protected int $taskId;
public function __construct(int $taskId)
{
parent::__construct();
$this->taskId = $taskId;
}
public function start()
{
$task = ProjectTask::with('project')->find($this->taskId);
if (!$task || $task->deleted_at) {
return;
}
// 获取该任务的所有待处理事件
$events = ProjectTaskAiEvent::where('task_id', $this->taskId)
->whereIn('status', [
ProjectTaskAiEvent::STATUS_PENDING,
ProjectTaskAiEvent::STATUS_FAILED,
])
->get()
->keyBy('event_type');
$suggestions = [];
// 遍历所有事件类型
foreach (ProjectTaskAiEvent::getEventTypes() as $eventType) {
$event = $events->get($eventType);
// 如果没有记录,跳过
if (!$event) {
continue;
}
// 如果是失败状态但不能重试,跳过
if ($event->status === ProjectTaskAiEvent::STATUS_FAILED && !$event->canRetry()) {
continue;
}
// 使用原子操作标记为处理中(防止并发重复处理)
$updated = ProjectTaskAiEvent::where('id', $event->id)
->whereIn('status', [ProjectTaskAiEvent::STATUS_PENDING, ProjectTaskAiEvent::STATUS_FAILED])
->update(['status' => ProjectTaskAiEvent::STATUS_PROCESSING]);
if (!$updated) {
// 已被其他进程处理
continue;
}
$event->status = ProjectTaskAiEvent::STATUS_PROCESSING;
try {
// 检查是否满足执行条件
$shouldExecute = AiTaskSuggestion::shouldExecute($task, $eventType);
if (!$shouldExecute) {
$event->markSkipped('不满足执行条件');
continue;
}
// 执行对应的分析
$result = $this->executeAnalysis($task, $eventType);
if ($result === null) {
$event->markSkipped('未生成有效建议');
continue;
}
// 收集建议
$suggestions[] = $result;
$event->markCompleted($result);
} catch (\Exception $e) {
$event->markFailed($e->getMessage());
\Log::error("AiTaskAnalyzeTask error: task={$this->taskId}, type={$eventType}, error={$e->getMessage()}");
}
}
// 如果有建议,发送消息
if (!empty($suggestions)) {
$msgId = AiTaskSuggestion::sendSuggestionMessage($task, $suggestions);
// 更新所有事件的 msg_id
if ($msgId) {
ProjectTaskAiEvent::where('task_id', $this->taskId)
->where('status', ProjectTaskAiEvent::STATUS_COMPLETED)
->update(['msg_id' => $msgId]);
}
}
}
/**
* 执行具体的分析
* @param ProjectTask $task 任务对象
* @param string $eventType 事件类型
*/
private function executeAnalysis(ProjectTask $task, string $eventType): ?array
{
switch ($eventType) {
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
return AiTaskSuggestion::generateDescription($task);
case ProjectTaskAiEvent::EVENT_SUBTASKS:
return AiTaskSuggestion::generateSubtasks($task);
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
return AiTaskSuggestion::generateAssignee($task);
case ProjectTaskAiEvent::EVENT_SIMILAR:
return AiTaskSuggestion::findSimilarTasks($task);
default:
return null;
}
}
public function end()
{
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Tasks;
use App\Module\Apps;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Module\Base;
use Carbon\Carbon;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
* AI 任务建议定时任务
* 扫描新建任务并投递分析任务
*/
class AiTaskLoopTask extends AbstractTask
{
/**
* 单次处理任务数量上限
*/
const BATCH_SIZE = 5;
/**
* 任务创建后多久开始分析(秒)
*/
const DELAY_SECONDS = 10;
public function __construct()
{
parent::__construct();
}
public function start()
{
// 检查 AI 插件是否安装
if (!Apps::isInstalled('ai')) {
return;
}
// 检查系统级 AI 自动分析开关
if (Base::settingFind('system', 'task_ai_auto_analyze', 'open') === 'close') {
return;
}
// 查询待处理的任务
$tasks = $this->findPendingTasks();
foreach ($tasks as $task) {
// 检查项目级 AI 自动分析开关
if ($task->project && $task->project->ai_auto_analyze === 'close') {
continue;
}
// 为任务创建事件记录
$this->createEventRecords($task);
// 投递异步分析任务
Task::deliver(new AiTaskAnalyzeTask($task->id));
}
}
/**
* 查找待处理的任务
*/
private function findPendingTasks(): \Illuminate\Support\Collection
{
$delayTime = Carbon::now()->subSeconds(self::DELAY_SECONDS);
// 子查询:已经有 AI 事件记录的任务
$processedTaskIds = ProjectTaskAiEvent::select('task_id')
->distinct()
->pluck('task_id');
// 查询新建任务(未处理过的)
$newTasks = ProjectTask::with('project')
->where('parent_id', 0) // 只处理主任务
->whereNull('deleted_at')
->whereNull('archived_at')
->where('created_at', '<=', $delayTime) // 创建超过延迟时间
->where('created_at', '>=', Carbon::now()->subDays(1)) // 只处理1天内的
->whereNotIn('id', $processedTaskIds)
->orderBy('created_at', 'asc')
->take(self::BATCH_SIZE)
->get();
// 查询需要重试的任务(优先处理较早失败的)
$retryTaskIds = ProjectTaskAiEvent::where('status', ProjectTaskAiEvent::STATUS_FAILED)
->where('retry_count', '<', ProjectTaskAiEvent::MAX_RETRY)
->select('task_id')
->distinct()
->orderBy('updated_at', 'asc')
->take(self::BATCH_SIZE - $newTasks->count())
->pluck('task_id');
$retryTasks = ProjectTask::with('project')
->whereIn('id', $retryTaskIds)
->whereNull('deleted_at')
->get();
return $newTasks->merge($retryTasks)->take(self::BATCH_SIZE);
}
/**
* 为任务创建事件记录
*/
private function createEventRecords(ProjectTask $task): void
{
foreach (ProjectTaskAiEvent::getEventTypes() as $eventType) {
ProjectTaskAiEvent::firstOrCreate(
[
'task_id' => $task->id,
'event_type' => $eventType,
],
[
'status' => ProjectTaskAiEvent::STATUS_PENDING,
'retry_count' => 0,
]
);
}
}
public function end()
{
}
}

View File

@@ -17,6 +17,7 @@ use App\Module\Base;
use App\Module\Doo;
use App\Module\Ihttp;
use App\Module\TextExtractor;
use App\Module\PromptPlaceholder;
use Carbon\Carbon;
use Exception;
use DB;
@@ -427,6 +428,7 @@ class BotReceiveMsgTask extends AbstractTask
private function handleWebhookRequest($sendText, $replyText, WebSocketDialogMsg $msg, WebSocketDialog $dialog, User $botUser)
{
$webhookUrl = null;
$userBot = null;
$extras = ['timestamp' => time()];
try {
@@ -445,7 +447,7 @@ class BotReceiveMsgTask extends AbstractTask
}
// 判断AI应用是否安装
if (!Apps::isInstalled('ai')) {
throw new Exception('应用「AI Robot」未安装');
throw new Exception('应用「AI Assistant」未安装');
}
// 整理机器人参数
$setting = Base::setting('aibotSetting');
@@ -488,10 +490,6 @@ class BotReceiveMsgTask extends AbstractTask
if ($dialog->session_id) {
$extras['context_key'] = 'session_' . $dialog->session_id;
}
// 设置文心一言的API密钥
if ($type === 'wenxin') {
$extras['api_key'] .= ':' . $setting['wenxin_secret'];
}
// 群聊清理上下文(群聊不使用上下文)
if ($dialog->type === 'group') {
$extras['before_clear'] = 1;
@@ -507,17 +505,15 @@ class BotReceiveMsgTask extends AbstractTask
if (empty($extras['api_key'])) {
throw new Exception('机器人未启用。');
}
$this->generateSystemPromptForAI($msg->userid, $dialog, $extras);
$this->generateSystemPromptForAI($msg->userid, $dialog, $botUser, $extras);
// 转换提及格式
$sendText = self::convertMentionForAI($sendText);
$replyText = self::convertMentionForAI($replyText);
if ($replyText) {
$sendText = <<<EOF
<quoted_content>
{$replyText}
</quoted_content>
The content within the above quoted_content tags is a citation.
上述 quoted_content 标签中的内容为引用。
{$sendText}
EOF;
@@ -530,15 +526,10 @@ class BotReceiveMsgTask extends AbstractTask
return;
}
$userBot = UserBot::whereBotId($botUser->userid)->first();
if ($userBot) {
$userBot->webhook_num++;
$userBot->save();
$webhookUrl = $userBot->webhook_url;
if (!$userBot || !$userBot->shouldDispatchWebhook(UserBot::WEBHOOK_EVENT_MESSAGE)) {
return;
}
}
if (!preg_match("/^https?:\/\//", $webhookUrl)) {
return;
}
} catch (\Exception $e) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'template', [
'type' => 'content',
@@ -546,145 +537,60 @@ class BotReceiveMsgTask extends AbstractTask
], $botUser->userid, false, false, true); // todo 未能在任务end事件来发送任务
return;
}
//
try {
$data = [
'text' => $sendText,
'reply_text' => $replyText,
'token' => User::generateToken($botUser),
'session_id' => $dialog->session_id,
'dialog_id' => $dialog->id,
'dialog_type' => $dialog->type,
'msg_id' => $msg->id,
'msg_uid' => $msg->userid,
'mention' => $this->mention ? 1 : 0,
'bot_uid' => $botUser->userid,
'version' => Base::getVersion(),
'extras' => Base::array2json($extras)
// 基本请求数据
$data = [
'event' => UserBot::WEBHOOK_EVENT_MESSAGE,
'text' => $sendText,
'reply_text' => $replyText,
'token' => User::generateToken($botUser),
'session_id' => $dialog->session_id,
'dialog_id' => $dialog->id,
'dialog_type' => $dialog->type,
'msg_id' => $msg->id,
'msg_uid' => $msg->userid,
'mention' => $this->mention ? 1 : 0,
'bot_uid' => $botUser->userid,
'extras' => Base::array2json($extras),
'version' => Base::getVersion(),
'timestamp' => time(),
];
// 添加用户信息
$userInfo = User::find($msg->userid);
if ($userInfo) {
$data['msg_user'] = [
'userid' => $userInfo->userid,
'email' => $userInfo->email,
'nickname' => $userInfo->nickname,
'profession' => $userInfo->profession,
'lang' => $userInfo->lang,
'token' => User::generateTokenNoDevice($userInfo, now()->addHour()),
];
// 添加用户信息
$userInfo = User::find($msg->userid);
if ($userInfo) {
$data['msg_user'] = [
'userid' => $userInfo->userid,
'email' => $userInfo->email,
'nickname' => $userInfo->nickname,
'profession' => $userInfo->profession,
'lang' => $userInfo->lang,
'token' => User::generateTokenNoDevice($userInfo, now()->addHour()),
];
}
// 请求Webhook
$result = Ihttp::ihttp_post($webhookUrl, $data, 30);
if ($result['data'] && $data = Base::json2array($result['data'])) {
if ($data['code'] != 200 && $data['message']) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', [
'text' => $result['data']['message']
], $botUser->userid, false, false, true);
}
}
} catch (\Throwable $th) {
info(Base::array2json([
'bot_userid' => $botUser->userid,
'dialog' => $dialog->id,
'msg' => $msg->id,
'webhook_url' => $webhookUrl,
'error' => $th->getMessage(),
]));
}
}
/**
* 为AI机器人转换提及消息格式
* 将提及的任务、文件、报告转换为AI可理解的格式并提取相关内容
*
* @param string $original 原始消息文本
* @return string 转换后的消息文本,包含相关内容的标签
* @throws Exception 当提及的对象不存在或读取失败时抛出异常
*/
public static function convertMentionForAI($original)
{
$array = [];
$original = preg_replace_callback('/<!--(.*?)#(.*?)#(.*?)-->/', function ($match) use (&$array) {
// 初始化 tag 内容
$pathTag = null;
$pathName = null;
$pathContent = null;
// 根据 type 提取 tag 内容
switch ($match[1]) {
// 任务
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereId(intval($match[2]))->first();
if (!$taskInfo) {
throw new Exception("任务不存在或已被删除");
}
$pathTag = "task_content";
$pathName = addslashes($taskInfo->name) . " (ID:{$taskInfo->id})";
$pathContent = implode("\n", $taskInfo->AIContext());
break;
// 文件
case 'file':
$fileInfo = FileContent::idOrCodeToContent($match[2]);
if (!$fileInfo || !isset($fileInfo->content['url'])) {
throw new Exception("文件不存在或已被删除");
}
$urlPath = public_path($fileInfo->content['url']);
if (!file_exists($urlPath)) {
throw new Exception("文件不存在或已被删除");
}
$fileResult = TextExtractor::extractFile($urlPath);
if (Base::isError($fileResult)) {
throw new Exception("文件读取失败:" . $fileResult['msg']);
}
$pathTag = "file_content";
$pathName = addslashes($match[3]) . " (ID:{$fileInfo->id})";
$pathContent = $fileResult['data'];
break;
// 文件路径
case 'path':
$urlPath = public_path($match[2]);
if (!file_exists($urlPath)) {
throw new Exception("文件不存在或已被删除");
}
$fileResult = TextExtractor::extractFile($urlPath);
if (Base::isError($fileResult)) {
throw new Exception("文件读取失败:" . $fileResult['msg']);
}
$pathTag = "file_content";
$pathName = addslashes($match[3]);
$pathContent = $fileResult['data'];
break;
// 报告
case 'report':
$reportInfo = Report::idOrCodeToContent($match[2]);
if (!$reportInfo) {
throw new Exception("报告不存在或已被删除");
}
$pathTag = "report_content";
$pathName = addslashes($match[3]) . " (ID:{$reportInfo->id})";
$pathContent = Base::html2markdown($reportInfo->content);
break;
}
// 如果提取到 tag 内容,则添加到 contents 数组中
if ($pathTag) {
$array[] = "<{$pathTag} path=\"{$pathName}\">\n{$pathContent}\n</{$pathTag}>";
return "`{$pathName}` (see below for {$pathTag} tag)";
}
return "";
}, $original);
// 添加 tag 内容
if ($array) {
$original .= "\n\n" . implode("\n\n", $array);
}
return $original;
$result = null;
if ($userBot) {
$result = $userBot->dispatchWebhook(UserBot::WEBHOOK_EVENT_MESSAGE, $data);
} else {
try {
$result = Ihttp::ihttp_post($webhookUrl, $data, 30);
} catch (\Throwable $th) {
info(Base::array2json([
'webhook_url' => $webhookUrl,
'data' => $data,
'error' => $th->getMessage(),
]));
}
}
if ($result && isset($result['data'])) {
$responseData = Base::json2array($result['data']);
if (($responseData['code'] ?? 0) === 200 && !empty($responseData['message'])) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', [
'text' => $responseData['message']
], $botUser->userid, false, false, true);
}
}
}
/**
@@ -693,158 +599,83 @@ class BotReceiveMsgTask extends AbstractTask
*
* @param int|null $userid 用户ID
* @param WebSocketDialog $dialog 对话对象
* @param User $botUser 机器人用户对象
* @param array $extras 额外参数数组通过引用传递以修改system_message
* @return void
*/
private function generateSystemPromptForAI($userid, WebSocketDialog $dialog, array &$extras)
private function generateSystemPromptForAI($userid, WebSocketDialog $dialog, User $botUser, array &$extras)
{
// 构建结构化的系统提示词
$sections = [];
// 基础角色设定(如果有)
if (!empty($extras['system_message'])) {
$sections[] = <<<EOF
<role_setting>
{$extras['system_message']}
</role_setting>
EOF;
}
// 上下文信息(项目、任务、部门等)+ 操作指令
switch ($dialog->type) {
// 用户对话
case "user":
$aiPrompt = WebSocketDialogConfig::where([
'dialog_id' => $dialog->id,
'userid' => $userid,
'type' => 'ai_prompt',
])->value('value');
if ($aiPrompt) {
return $aiPrompt;
}
break;
// 群组对话
case "group":
switch ($dialog->group_type) {
// 用户群
case 'user':
break;
// 项目群
case 'project':
$projectInfo = Project::whereDialogId($dialog->id)->first();
if ($projectInfo) {
$projectDesc = $projectInfo->desc ?: "-";
$projectStatus = $projectInfo->archived_at ? '已归档' : '正在进行中';
$sections[] = <<<EOF
<context_info>
当前我在项目【{$projectInfo->name}】中
项目描述:{$projectDesc}
项目状态:{$projectStatus}
</context_info>
EOF;
$sections[] = <<<EOF
<instructions>
如果你判断我想要或需要添加任务,请按照以下格式回复:
::: create-task-list
title: 任务标题1
desc: 任务描述1
title: 任务标题2
desc: 任务描述2
:::
</instructions>
EOF;
}
break;
// 任务群
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
if ($taskInfo) {
$taskContext = implode("\n", $taskInfo->AIContext());
$sections[] = <<<EOF
<context_info>
当前我在任务【{$taskInfo->name}】中
当前时间:{$taskInfo->updated_at}
任务ID{$taskInfo->id}
{$taskContext}
</context_info>
EOF;
$sections[] = <<<EOF
<instructions>
如果你判断我想要或需要添加子任务,请按照以下格式回复:
::: create-subtask-list
title: 子任务标题1
title: 子任务标题2
:::
</instructions>
EOF;
}
break;
// 部门群
case 'department':
$userDepartment = UserDepartment::whereDialogId($dialog->id)->first();
if ($userDepartment) {
$sections[] = <<<EOF
<context_info>
当前我在【{$userDepartment->name}】的部门群聊中
</context_info>
EOF;
}
break;
// 全体成员群
case 'all':
$sections[] = <<<EOF
<context_info>
当前我在【全体成员】的群聊中
</context_info>
EOF;
break;
}
// 聊天历史
if ($dialog->type === 'group') {
$chatHistory = $this->getRecentChatHistory($dialog, 15);
if ($chatHistory) {
$sections[] = <<<EOF
<chat_history>
{$chatHistory}
</chat_history>
EOF;
}
}
break;
}
// 更新系统提示词
if (!empty($sections)) {
$extras['system_message'] = implode("\n\n", $sections);
// 用户自定义提示词(私聊场景优先使用)
$customPrompt = null;
if ($dialog->type === 'user') {
$customPrompt = WebSocketDialogConfig::where([
'dialog_id' => $dialog->id,
'userid' => $userid,
'type' => 'ai_prompt',
])->value('value');
}
// 添加标签说明
$tagDescs = [
'role_setting' => '你的基础角色和行为定义',
'instructions' => '特定功能的操作指令',
'context_info' => '当前环境和状态信息',
'chat_history' => '最近的对话历史记录',
$prompt = [];
// 1. 基础角色(自定义提示词优先)
if ($customPrompt) {
$prompt[] = $customPrompt;
} elseif (!empty($extras['system_message'])) {
$prompt[] = $extras['system_message'];
}
// 2. 上下文信息
$currentTime = Carbon::now()->toDateTimeString();
$contextLines = [
"您是:{$botUser->nickname}ID: {$botUser->userid}",
"当前对话ID(dialog_id){$dialog->id}",
"当前系统时间(now){$currentTime}",
];
$useTags = [];
foreach ($tagDescs as $tag => $desc) {
if (str_contains($extras['system_message'], '<' . $tag . '>')) {
$useTags[] = '- <' . $tag . '>: ' . $desc;
if ($dialog->type === 'group') {
switch ($dialog->group_type) {
case 'project':
$projectInfo = Project::whereDialogId($dialog->id)->first();
if ($projectInfo) {
$contextLines[] = "场景:项目群聊「{$projectInfo->name}project_id: {$projectInfo->id}";
}
break;
case 'task':
$taskInfo = ProjectTask::with(['content'])->whereDialogId($dialog->id)->first();
if ($taskInfo) {
$contextLines[] = "场景:任务群聊「{$taskInfo->name}task_id: {$taskInfo->id}";
}
break;
case 'department':
$userDepartment = UserDepartment::whereDialogId($dialog->id)->first();
if ($userDepartment) {
$contextLines[] = "场景:部门群聊「{$userDepartment->name}";
}
break;
case 'all':
$contextLines[] = "场景:全体成员群聊";
break;
}
// 3. 聊天历史(仅群聊)
$chatHistory = $this->getRecentChatHistory($dialog, 15);
if ($chatHistory) {
$prompt[] = implode("\n", $contextLines);
$prompt[] = "最近的对话记录:\n{$chatHistory}";
} else {
$prompt[] = implode("\n", $contextLines);
}
} else {
$prompt[] = implode("\n", $contextLines);
}
if (!empty($useTags)) {
$extras['system_message'] = "以下信息按标签组织:\n" . implode("\n", $useTags) . "\n\n" . $extras['system_message'];
}
// 4. 条件性提示块(用户上下文 + 格式指南)
$prompt[] = PromptPlaceholder::buildOptionalPrompts($userid, $dialog);
$extras['system_message'] = implode("\n----\n", array_filter($prompt));
}
/**
@@ -881,14 +712,14 @@ class BotReceiveMsgTask extends AbstractTask
// 使用XML标签格式确保AI能清晰识别边界
// 对用户名进行HTML转义防止特殊字符破坏格式
$safeUserName = htmlspecialchars($userName, ENT_QUOTES, 'UTF-8');
return "<message user=\"{$safeUserName}\">\n{$content}\n</message>";
return "<message userid=\"{$message->userid}\" nickname=\"{$safeUserName}\">\n{$content}\n</message>";
})
->reverse() // 反转集合,让时间顺序正确(最早的在前)
->filter() // 过滤掉空内容的消息
->values() // 重新索引数组
->toArray();
return empty($chatMessages) ? null : implode("\n\n", $chatMessages);
return empty($chatMessages) ? null : implode("\n", $chatMessages);
}
/**

View File

@@ -65,16 +65,10 @@ class LoopTask extends AbstractTask
$task->end_at = $task->start_at->clone()->addSeconds($diffSecond);
}
// 处理子任务
$subTasks = ProjectTask::whereParentId($item->id)->get();
if (!$subTasks->isEmpty()) {
foreach ($subTasks as $subTask) {
$newSubTask = $subTask->copyTask();
$newSubTask->parent_id = $task->id;
$newSubTask->start_at = $task->start_at;
$newSubTask->end_at = $task->end_at;
$newSubTask->save();
}
}
$item->copySubTasks($task, [
'reset_complete' => true,
'sync_time' => true,
]);
//
$task->refreshLoop(true);
$task->addLog("创建任务来自周期任务ID{$item->id}", [], $task->userid);

View File

@@ -0,0 +1,283 @@
<?php
namespace App\Tasks;
use App\Models\File;
use App\Models\User;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreBase;
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;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* 通用 Manticore Search 同步任务MVA 权限方案)
*
* 支持文件、用户、项目、任务的同步操作
* 使用 MVA (Multi-Value Attribute) 内联权限过滤
*/
class ManticoreSyncTask extends AbstractTask
{
private $action;
private $data;
public function __construct($action = null, $data = null)
{
parent::__construct(...func_get_args());
$this->action = $action;
$this->data = $data;
}
/**
* 获取任务动作类型(用于去重)
*
* @return string|null
*/
public function getAction(): ?string
{
return $this->action;
}
/**
* 获取数据ID用于去重
*
* @return int|null
*/
public function getDataId(): ?int
{
if (!is_array($this->data)) {
return null;
}
// 根据不同的 action 类型提取对应的 ID
return $this->data['id']
?? $this->data['userid']
?? $this->data['file_id']
?? $this->data['project_id']
?? $this->data['task_id']
?? $this->data['msg_id']
?? $this->data['dialog_id']
?? null;
}
public function start()
{
if (!Apps::isInstalled("search")) {
return;
}
switch ($this->action) {
// ==============================
// 文件同步动作
// ==============================
case 'file_sync':
$file = File::find($this->data['id'] ?? 0);
if ($file) {
ManticoreFile::sync($file);
}
break;
case 'file_delete':
$fileId = $this->data['id'] ?? 0;
if ($fileId > 0) {
ManticoreFile::delete($fileId);
}
break;
case 'file_pshare_update':
$fileIds = $this->data['file_ids'] ?? [];
$pshare = $this->data['pshare'] ?? 0;
if (!empty($fileIds)) {
ManticoreBase::batchUpdatePshare($fileIds, $pshare);
}
break;
case 'update_file_allowed_users':
// 更新文件权限
$fileId = $this->data['file_id'] ?? 0;
if ($fileId > 0) {
ManticoreFile::updateAllowedUsers($fileId);
}
break;
// ==============================
// 用户同步动作
// ==============================
case 'user_sync':
$user = User::find($this->data['userid'] ?? 0);
if ($user) {
ManticoreUser::sync($user);
}
break;
case 'user_delete':
$userid = $this->data['userid'] ?? 0;
if ($userid > 0) {
ManticoreUser::delete($userid);
}
break;
// ==============================
// 项目同步动作
// ==============================
case 'project_sync':
$project = Project::find($this->data['id'] ?? 0);
if ($project) {
ManticoreProject::sync($project);
}
break;
case 'project_delete':
$projectId = $this->data['project_id'] ?? 0;
if ($projectId > 0) {
ManticoreProject::delete($projectId);
}
break;
case 'update_project_allowed_users':
// 更新项目权限
$projectId = $this->data['project_id'] ?? 0;
if ($projectId > 0) {
ManticoreProject::updateAllowedUsers($projectId);
}
break;
case 'cascade_project_users':
// 项目成员变更时,级联更新该项目下所有 visibility=1 的任务
// 异步执行,避免阻塞
$projectId = $this->data['project_id'] ?? 0;
if ($projectId > 0) {
ManticoreTask::cascadeUpdateByProject($projectId);
}
break;
// ==============================
// 任务同步动作
// ==============================
case 'task_sync':
$task = ProjectTask::find($this->data['id'] ?? 0);
if ($task) {
ManticoreTask::sync($task);
}
break;
case 'task_delete':
$taskId = $this->data['task_id'] ?? 0;
if ($taskId > 0) {
ManticoreTask::delete($taskId);
}
break;
case 'update_task_allowed_users':
// 更新任务权限
$taskId = $this->data['task_id'] ?? 0;
if ($taskId > 0) {
ManticoreTask::updateAllowedUsers($taskId);
// 级联更新子任务
ManticoreTask::cascadeToChildren($taskId);
}
break;
// ==============================
// 消息同步动作
// ==============================
case 'msg_sync':
$msg = WebSocketDialogMsg::find($this->data['msg_id'] ?? 0);
if ($msg) {
ManticoreMsg::sync($msg);
}
break;
case 'msg_delete':
$msgId = $this->data['msg_id'] ?? 0;
if ($msgId > 0) {
ManticoreMsg::delete($msgId);
}
break;
case 'update_dialog_allowed_users':
// 更新对话消息权限
$dialogId = $this->data['dialog_id'] ?? 0;
if ($dialogId > 0) {
ManticoreMsg::updateDialogAllowedUsers($dialogId);
}
break;
default:
// 增量更新
$this->incrementalUpdate();
break;
}
}
/**
* 增量更新(定时执行 - 兜底机制)
*
* 命令本身会持续处理直到完成,定时器只是确保命令在运行
* 如果命令正在运行(有锁),则跳过本次触发
*
* @return void
*/
private function incrementalUpdate()
{
// 兜底触发:每 2 分钟检查一次,如果命令没在运行则启动
$time = intval(Cache::get("ManticoreSyncTask:CheckTime"));
if (time() - $time < 2 * 60) {
return;
}
Cache::put("ManticoreSyncTask:CheckTime", time(), Carbon::now()->addMinutes(5));
// 执行增量全文索引同步
$this->runIncrementalSync();
// 执行向量生成
$this->runVectorGeneration();
}
/**
* 执行增量全文索引同步(兜底触发)
*
* 命令内部有锁机制,如果已在运行会自动跳过
* 命令会持续处理直到无新数据,然后自动退出
*/
private function runIncrementalSync(): void
{
// 启动各类型的增量同步命令
@shell_exec("php /var/www/artisan manticore:sync-files --i 2>&1 &");
@shell_exec("php /var/www/artisan manticore:sync-users --i 2>&1 &");
@shell_exec("php /var/www/artisan manticore:sync-projects --i 2>&1 &");
@shell_exec("php /var/www/artisan manticore:sync-tasks --i 2>&1 &");
@shell_exec("php /var/www/artisan manticore:sync-msgs --i 2>&1 &");
// 启动失败重试命令
@shell_exec("php /var/www/artisan manticore:retry-failures 2>&1 &");
}
/**
* 执行向量生成(兜底触发)
*
* 命令内部有锁机制,如果已在运行会自动跳过
* 命令会持续处理直到无待处理数据,然后自动退出
*/
private function runVectorGeneration(): void
{
if (!Apps::isInstalled("ai")) {
return;
}
// 启动向量生成命令
@shell_exec("php /var/www/artisan manticore:generate-vectors --type=all --batch=50 2>&1 &");
}
public function end()
{
}
}

View File

@@ -2,7 +2,10 @@
namespace App\Tasks;
use App\Models\UmengAlias;
use App\Models\WebSocketDialogMsgRead;
use App\Module\Base;
use Cache;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
* 推送友盟消息
@@ -11,6 +14,7 @@ class PushUmengMsg extends AbstractTask
{
protected $userid = 0;
protected $array = [];
protected $endPush = []; // 需要在 end() 方法中处理的延迟推送列表
/**
* @param array|int $userid
@@ -32,11 +36,68 @@ class PushUmengMsg extends AbstractTask
if ($setting['push'] !== 'open') {
return;
}
UmengAlias::pushMsgToUserid($this->userid, $this->array);
// 消息ID
$msgId = isset($this->array['extra']['msg_id']) ? intval($this->array['extra']['msg_id']) : 0;
// 处理用户列表
$userids = is_array($this->userid) ? $this->userid : [$this->userid];
$directPushUsers = []; // 直接推送的用户
$delayedPushUsers = []; // 需要延迟推送的用户
foreach ($userids as $uid) {
if ($this->getDelay() > 0) {
// 已经延迟过,检查消息是否已读
if ($msgId > 0) {
$isRead = WebSocketDialogMsgRead::whereMsgId($msgId)
->whereUserid($uid)
->whereNotNull('read_at')
->exists();
if ($isRead) {
// 已读,跳过推送
continue;
}
}
// 未读或无法判断,执行推送
$directPushUsers[] = $uid;
} else {
// 首次推送,检查 PC 端是否活跃
$lastActive = Cache::get("user_pc_active:{$uid}");
$isPcActive = $lastActive && (time() - $lastActive) < 60;
if ($isPcActive) {
// PC 端活跃,需要延迟推送
$delayedPushUsers[] = $uid;
} else {
// PC 端不活跃,直接推送
$directPushUsers[] = $uid;
}
}
}
// 直接推送
if ($directPushUsers) {
UmengAlias::pushMsgToUserid($directPushUsers, $this->array);
}
// 创建延迟推送任务
if ($delayedPushUsers) {
$this->endPush[] = [
'userid' => $delayedPushUsers,
'array' => $this->array,
];
}
}
public function end()
{
if (empty($this->endPush)) {
return;
}
foreach ($this->endPush as $item) {
$task = new PushUmengMsg($item['userid'], $item['array']);
$task->delay(10);
Task::deliver($task);
}
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Tasks;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogMsgTodo;
use App\Module\Doo;
use Carbon\Carbon;
/**
* 待办提醒:到点由 todo-alert 机器人在原会话发一条「引用原消息 + @被指派成员」的普通文本
* (同一消息同批到点的成员合并一条)。
*/
class TodoRemindTask extends AbstractTask
{
public function __construct()
{
parent::__construct();
}
/**
* 构造提醒文本:每个被提醒成员一个 @ span + 提示语。
* 直接拼 <span class="mention user" data-id> 是因为 sendMsg 不会调用 formatMsg
* 文本会原样入库msgJoinGroup 据此 span 正则提取 @。
*/
public static function buildRemindText(array $mentionUserids): string
{
$nicknames = User::whereIn('userid', $mentionUserids)->pluck('nickname', 'userid');
$mentionText = '';
foreach ($mentionUserids as $uid) {
$name = $nicknames[$uid] ?? $uid;
$mentionText .= "<span class=\"mention user\" data-id=\"{$uid}\">@{$name}</span> ";
}
return $mentionText . Doo::translate('你有一条待办到提醒时间啦');
}
public function start()
{
$rows = WebSocketDialogMsgTodo::dueReminders();
if ($rows->isEmpty()) {
return;
}
$botUser = User::botGetOrCreate('todo-alert');
if (empty($botUser)) {
return;
}
foreach ($rows->groupBy('msg_id') as $msgId => $group) {
$rowIds = $group->pluck('id')->toArray();
$userids = $group->pluck('userid')->map('intval')->values()->toArray();
//
$msg = WebSocketDialogMsg::find($msgId);
$dialog = $msg ? WebSocketDialog::find($msg->dialog_id) : null;
if (empty($msg) || empty($dialog)) {
// 原消息/会话已不存在:标记已提醒,避免空转重复扫描
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
continue;
}
//
$memberIds = $dialog->dialogUser->pluck('userid')->map('intval')->values()->toArray();
$mentionUserids = array_values(array_intersect($userids, $memberIds));
if (empty($mentionUserids)) {
// 被指派人都已退群:没人可 @,标记已提醒避免空转重复扫描
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
continue;
}
$res = WebSocketDialogMsg::sendMsg(
"reply-{$msg->id}", // 引用原消息 → reply_data 自动填充
$dialog->id,
'text', // 普通文本
['text' => self::buildRemindText($mentionUserids)],
$botUser->userid,
false, false, false // push_self / push_retry / push_silence
);
//
if (\App\Module\Base::isSuccess($res)) {
WebSocketDialogMsgTodo::whereIn('id', $rowIds)->update(['reminded_at' => Carbon::now()]);
}
}
}
public function end()
{
}
}

View File

@@ -140,7 +140,7 @@ class WebSocketDialogMsgTask extends AbstractTask
];
// 机器人收到消处理
$botUser = User::whereUserid($userid)->whereBot(1)->first();
if ($botUser) {
if ($botUser) { // 避免机器人处理自己发送的消息
$this->endArray[] = new BotReceiveMsgTask($botUser->userid, $msg->id, $mentions, $this->client);
}
}
@@ -211,6 +211,10 @@ class WebSocketDialogMsgTask extends AbstractTask
'description' => "MID:{$msg->id}",
'seconds' => 3600,
'badge' => 1,
'extra' => [
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
]
];
$this->endArray[] = new PushUmengMsg($uids->toArray(), $umengMsg);
}

View File

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

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