Compare commits

...

133 Commits
v1.7.55 ... pro

Author SHA1 Message Date
kuaifan
08da0b8976 fix: 修复项目目录和静态文件权限 2026-07-24 16:20:10 +08:00
kuaifan
b914a99b62 fix(dashboard): 更新仪表盘警告信息样式,使用粘性定位 2026-07-21 19:38:40 +08:00
kuaifan
4c9951c667 feat: 默认开启部门负责人视角 2026-07-21 09:06:36 +00:00
kuaifan
93c604a554 release: v1.8.89 2026-07-20 14:21:51 +00:00
kuaifan
e4e1cd9b82 fix(dashboard): 优化移动端个人视角布局 2026-07-20 14:08:12 +00:00
kuaifan
6f22fd2706 fix(ai-assistant): respect model visibility settings 2026-07-20 10:48:33 +00:00
kuaifan
44bd541d00 fix: 仪表盘待完成默认展开 2026-07-20 09:13:32 +00:00
kuaifan
95b20325be fix(i18n): normalize common action translations 2026-07-20 09:06:37 +00:00
kuaifan
df4658cddf feat(i18n): support contextual translation keys 2026-07-20 07:25:10 +00:00
kuaifan
df6c1fc04d refactor(i18n): 完成前端国际化改造 2026-07-20 02:35:07 +00:00
kuaifan
c9b3515aef refactor(manticore): 简化向量批量分块收尾 2026-07-19 07:01:19 +00:00
kuaifan
48c2c16111 docs(ai-kb): 同步仪表盘与负责人视角 2026-07-19 00:21:42 +00:00
kuaifan
71ca6ba249 feat(dashboard): 完善负责人视角界面与范围切换 2026-07-18 23:43:10 +00:00
kuaifan
7ad27e3326 feat(dashboard): 增加负责人视角真实数据接口 2026-07-18 23:42:58 +00:00
kuaifan
4b92ecb422 feat(dashboard): 完善个人视角空状态 2026-07-18 04:47:12 +00:00
kuaifan
6666605f7d feat: 完善管理后台团队仪表盘 2026-07-18 01:13:43 +00:00
kuaifan
844db09c12 fix(dialog): 撤回占位预存防闪动,重新编辑后占位保留至过期
- 确认撤回时先预存占位记录(原消息在列表时被去重压制不显示),消息移除的同一次重算中占位原地顶上,消除 WS 推送与接口响应竞争导致的先删后加闪动
- 撤回失败且消息仍在列表时才清除预存记录,兼容"接口报错但服务端实际已撤回"的情况
- 点"重新编辑"不再删除占位,保留至 5 分钟过期,期间可反复回填(对齐微信行为),同步更新 ai-kb 文档
2026-07-16 22:11:04 +00:00
kuaifan
f7bc724e13 feat(dialog): 撤回文本消息后本地显示"重新编辑"占位
- 撤回自己的文本/MD 消息后,原位置显示仅本人可见的"你撤回了一条消息 重新编辑"占位(仿微信)
- 占位纯本地存储(IndexedDB 持久化,Electron 多窗口 syncDispatch 同步),5 分钟过期自动清除
- 点"重新编辑"回填原内容到输入框(富文本还原 @提及/表情,先退出引用/编辑模式),占位随即移除
- 占位沿用原消息 id 排序定位,仅在已加载消息范围内合并显示,不干扰翻页与已读上报
- 同步更新 ai-kb recall 文档与前端语言文件
2026-07-16 11:20:37 +00:00
kuaifan
966c7b4ef0 feat(microapp): iframe 同源 HTTP 5xx 检测与内联错误层,错误态强制显示胶囊
- iframe.vue 读 responseStatus 检测同源 5xx(宁可漏判不误判),与原生 error 统一走 load-error
- index.vue 新增可读的内联错误层(重试/关闭),覆盖深色下被反色成一条线的错误页
- 检测到错误时强制显示胶囊,保证关闭/更多入口
2026-07-16 06:26:39 +00:00
kuaifan
f8eaab5a50 fix(search): 评审修复——重建自愈加探针表与锁内复检,向量模型变更自动重建
- 重建前用探针表验证 ai 端点,探不通不动真表不写 marker(下次自愈重试)
- 锁内复检 marker,消除并发进程重复重建窗口
- 检测 embedding 模型变更(vector:model 对比查询侧缓存)自动触发重建
- Ihttp 返回体 code 非 0/200 时视为失败(401/500 不再被当成功)
- 查询向量缓存键掺入模型指纹,换模型后不再命中旧维度缓存
- 批量写入按行数+字节双预算分块、块内延迟渲染 SQL,降低峰值内存
- user 同步加脏检查,字段未变不重复触发向量重算
- 日志截断超长 SQL;文档与注释同步 KB_INGEST_TOKEN 退役、截断长度对齐
2026-07-15 14:17:01 +00:00
kuaifan
746337c2f6 feat(search): 语义搜索迁移至 Manticore Auto Embeddings,向量走 ai 插件免费模型
- embedding 统一改走 ai 插件 /embeddings(免费向量模型,1024 维),删除第三方 key 分支
- 5 张向量表启用引擎 Auto Embeddings(MODEL_NAME/FROM/API_URL),写入/更新时引擎自动生成向量
- 表定义使用派生密钥 sha256(APP_KEY:embeddings),避免主密钥进入引擎元数据
- 写入改为 REPLACE(单行原子)与多行批量 REPLACE(整语句一次向量化调用,失败回退逐行)
- 结构指纹 vector:schema 标记:版本/模型/端点/APP_KEY 变化时自动清空重建并重置同步指针
- 退役 PHP 向量管道:删除 generate-vectors 命令、双指针、withVector 分支与批量向量生成方法
- 根治缺陷:全文重同步不再抹掉向量;内容编辑后向量由引擎自动重算,不再过期
2026-07-15 10:45:20 +00:00
kuaifan
e4fbf9693a fix(http): Ihttp 响应头解析前初始化 headers 数组,消除未定义索引告警 2026-07-15 10:45:01 +00:00
kuaifan
13ba1d3bd7 feat(file): 我的文件纳入已共享文件,修复板块切换缓存丢失
- 「我的文件」改为展示我拥有的全部(含已共享出去的),与「共享文件」在“我共享的”上刻意重叠
- 缓存改为 stale-while-revalidate:按板块懒加载,切回该板块/目录先读缓存再拉接口静态刷新
- getFiles 清理过期行按 scope 限定范围,避免刷新一个板块误删另一板块(同为 pid=0)的缓存
- 微调 file-tabs 来源筛选字号与面包屑上边距
- 同步 ai-kb 文件入口说明(last_verified v1.8.69)
2026-07-14 12:16:22 +00:00
kuaifan
f1ea0ed3dc release: v1.8.69 2026-07-14 09:47:03 +00:00
kuaifan
7b25f243ab feat(file): 文件页拆分「我的/共享」两个板块并合并工具栏为单行
- 顶部 Tab 切换:我的文件(私有 share=0)/ 共享文件(双向,含我共享出去的 + 别人共享给我的),共享板块支持「全部/我共享的/共享给我的」二次筛选
- 后端 File::getFileList 增加 scope 参数,按板块拆分根目录查询;FileController::lists 透传并校验
- 前端用 board/sharedSrc 取代原「仅显示我的」复选框,board 持久化到 IDB
- Tab、子筛选、视图切换合并到同一行(flex 自动换行,视图切换靠最右)
- 根目录不再重复显示板块标题,仅进入子文件夹时显示面包屑(板块名为可点击回根入口)
- 同步更新 ai-kb 文件入口说明
2026-07-14 03:49:59 +00:00
kuaifan
9b14120055 fix(application): 自定义应用可见范围统一 app 顶层,修复全员可见失效与编辑回填重置
前后端对 visible_to 存放层级约定不一致导致两个 bug:
- 新增应用设为全员可见,普通成员仍看不到(前端存在 menu 级,后端只认 app 级,落库恒回落为 admin)
- 再次编辑时可见范围被重置为"仅管理员"(响应无条件剥离 visible_to,回填丢失)

统一以 app 顶层为唯一权威来源:
- 前端保存时提到 app 顶层,回填时从 app 顶层读回(数组转下拉框字符串)
- 后端响应新增 keepVisible,管理员保存/读取场景保留 visible_to,普通成员仍剥离

同步 ai-kb application/menu-admin last_verified。
2026-07-13 23:54:23 +00:00
kuaifan
e72758f313 fix(task): 已删除/已归档项目不再生成周期任务,残留任务详情可打开可删除
- LoopTask 周期生成仅处理未删除、未归档项目的任务,从源头杜绝残留繁殖
- userTask 兜底改用 withTrashed,任务相关成员可打开已删项目下的任务详情
- task/one 返回 project_deleted/project_archived 标记,详情顶部横幅提示并提供删除按钮
- task/remove 对已删除/已归档项目放行删除(限项目负责人、任务负责人/协助人/创建人)
- 顺手修复 addTask 优先级匹配 reset() 传临时值的 notice 隐患,改用 collect()->first()
- 同步 ai-kb(recurring/delete-restore)与国际化文案
2026-07-13 23:04:36 +00:00
kuaifan
746a0bf9a1 feat(ai-bot): 增加 AIBotModels2Array 方法的可见性过滤选项 2026-07-13 13:33:11 +00:00
kuaifan
18163e1e82 release: v1.8.64 2026-07-02 14:01:34 +00:00
kuaifan
9a3d9d3b9c feat(license): 多授权选择登录 + 离线 license SN/MAC 绑定校验加固
- 在线授权登录时账号名下有多条可用授权则弹窗选择,选定后复用验证码确认签发(login/confirm)
- 新增 Doo::licenseDecode / licenseBindingError;保存离线 license 与创建用户前校验付费档 SN/MAC 与本机匹配
- 诊断展示与校验门槛纳入不限人数(people==0)档
2026-07-02 13:29:51 +00:00
kuaifan
c9f5296b73 feat(license): 在线异常浮告警卡+离线SN/MAC标签化,统一两Tab排版
- 在线:正常态只显示核心信息,提醒/过期/设备不匹配时浮出告警卡,SN/MAC 收进折叠诊断;frozen 状态文案统一为「已过期」
- 离线:SN/MAC 改行尾标签+失配整行标红,IP/域名/创建时间折叠进「更多」
- 统一在线/离线信息行的字号/行高/间距/label
- 清理死样式 .information 与死变量 onlineIng
- 同步 ai-kb online.md 状态措辞并登记新增文案
2026-07-02 07:45:16 +00:00
kuaifan
f45f86601e fix(ai-assistant): Modal 模式移动端恢复居中弹框,不再沉浸全屏
移动端仅聊天(chat)浮窗保留全屏沉浸,Modal 模式两端统一为居中模态,
回退 f6067d1bd 对 Modal 的移动端全屏处理;删除随之失效的
.ai-assistant-modal .is-mobile-fullscreen 样式块。
2026-07-01 07:39:50 +00:00
kuaifan
184fb27680 feat(upload): 分片上传统一链路,5 场景突破单文件 1G 限制
- 新增 api/upload/{init,chunk,merge} 入口与 ChunkUpload 模块(5MB 分片、3 并发、Redis 状态机)
- 5 场景接入:文件柜 / 聊天 / 任务附件 / 头像&系统图片 / 编辑器粘贴
- 秒传:同用户同 hash 复用 FileContent 物理文件,零传输
- 续传:Redis + localStorage 双索引,24h TTL
- 与老接口对齐:pid 锁、≤300 上限、webkit_relative_path 目录递归、overwrite 替换语义
- init 阶段读 file_upload_limit 拦截超限,避免传完分片才报错
- DeleteTmpTask 加 tmp_chunks case 兜底清理 24h 未合并目录
- files 表新增 hash 列(migration)
- 前端 chunkedUpload wrapper:主线程 spark-md5 流式 + 指数退避重试
- ai-kb 同步:upload / file-upload-limit / upload-size-limit 三个 chunk
2026-06-30 04:30:09 +00:00
kuaifan
c04187fe47 fix(appstore): 更新应用商店镜像版本至 0.5.4 2026-06-29 15:45:46 +00:00
kuaifan
c067991e3e fix(users): 登录验证码图片在 intervention/image v4 下返回 {} 无法加载
mews/captcha 3.5 的 toDataUri() 在 intervention/image v4 返回 DataUri 对象而非
字符串,json 序列化成 {} 导致前端 img 加载失败。控制器侧强制转为字符串修复。
2026-06-29 13:56:30 +00:00
kuaifan
b589307ebb perf(apps): 角标快照判定改用 isInstalled,自定义微应用一次性建 set
userBadges 原先用 Apps::appMenuConfig() 判定应用是否存在,每个不重复
app_id 都要解析两个 yaml(loadInstalledConfig + readPluginMenus),且
自定义微应用每行都重新 foreach setting。

- 插件应用走 Apps::isInstalled(只读一个 yaml + RequestContext 请求级缓存)
- 自定义微应用循环外一次性建 id set,O(1) 命中
- 行为不变(已在线实测):installed 时正常返回;status=uninstalled 时过滤
2026-06-29 13:38:05 +00:00
kuaifan
4a18ff0315 fix(apps): 角标快照过滤已卸载应用的残留行
应用卸载后 app_badges 表行未被清理(主程序侧无卸载回调),
导致 hydrate 后父级『应用』入口聚合仍把残留 count 算进去。

userBadges 返回前用 Apps::appMenuConfig() 判定应用是否仍存在
(同时覆盖插件应用与自定义微应用),不存在则跳过;按 app_id 缓
存判定结果,避免逐行重复读 yaml。
2026-06-29 12:16:19 +00:00
kuaifan
09439b555c fix(apps): 插件菜单角标补初始同步接口 badge/list
插件应用菜单来自 appstore installed(不含 count/dot),原先只有自定义微
应用经 microapp_menu 注入角标,导致插件角标只能靠实时 WS、刷新即丢。

- 新增 GET api/apps/badge/list(用户 token 鉴权)返回 userBadges 快照
  (插件 + 自定义统一覆盖),前端 updateMicroAppsStatus 拉取后 hydrateMap
- appBadges store hydrate -> hydrateMap(按角标 map 初始化,不再依赖 menu_items)
- 删除已冗余的 Badge::attachMenuBadges(调用 + 方法定义)
2026-06-29 11:57:18 +00:00
kuaifan
420d46d5cc feat(apps): 新增应用菜单角标(数字/红点,per-user 实时推送)
插件/微应用可在自己的菜单入口显示数字或红点角标,插件未打开也生效。

- 后端:新增 app_badges 表 + AppBadge 模型 + Module/Badge 业务编排 +
  AppsController(badge__set 应用密钥鉴权 / badge__clear 用户鉴权)
- 每应用独立密钥 APP_SECRET:按 appid 持久化于 appstore config.yml,鉴权校验
- 推送:复用 PushTask 下发 appBadge WS 消息;microapp_menu 附带初始角标
- 前端:appBadges Vuex module + WS 处理 + 三处菜单渲染(应用卡片/主菜单入口/
  父『应用』入口聚合)+ 移动端 Tabbar + 打开即清(badge_clear_on_open)
- 用户离职级联清理;同步 ai-kb 角标知识
2026-06-29 02:32:19 +00:00
kuaifan
eb672eaef1 fix(file): 文件管理删除入口收敛为所有者/创建者,拦截只读用户
- file.vue:右键「删除」项及工具栏批量「删除」按钮对非所有者/创建者禁用,
  与后端 file/remove 的 permissionFind(...,1000) 规则对齐(修复只读用户可见可点删除入口)
- 同步 ai-kb:delete-restore chunk 更正前置权限为「所有者或创建者」并补只读无权限说明
2026-06-27 03:11:01 +00:00
kuaifan
bc0fe9c748 feat(ai): AI 助手模型下拉框将 Doo AI 分组排在最前
调整 AIBotMap 键顺序,使 dooai 分组优先于其他服务商展示。
2026-06-26 02:33:01 +00:00
kuaifan
c4904bdbe2 chore(appstore): 升级 appstore 镜像至 0.5.3 2026-06-26 01:26:04 +00:00
kuaifan
b2d54576d0 fix(system): demo 环境豁免 dooai_key 打码,修复 /ai/gateway/* 401
system_setting=disabled 时按 _key 结尾统一打码会误伤官方网关 token
(dooai_key),前端读出打码串后调 /ai/gateway/me 等接口被网关判为非法
token 返回 401。将 dooai_key 从打码规则中单独豁免,原样返回供鉴权。
2026-06-26 01:16:43 +00:00
kuaifan
da36d3b319 fix(cmd): 修复安装 nginx 未自动启动、卸载残留命名卷、root 更新被 git 拦截
- install/update: 新增 wait_php_healthy,等 php 健康后兜底拉起 nginx,
  规避 depends_on 时序竞态导致"显示完成却访问不到"
- uninstall: down 增加 --volumes,清除 shared_data/redis_data 命名卷
- update: git 操作前加幂等 safe.directory 白名单,规避以 root 操作普通
  用户克隆仓库时被 git 归属检查拦截

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:51:21 +00:00
kuaifan
e5a88c2957 feat: 新增 bin/install 一键安装/升级脚本并支持 CLI 双语
- bin/install:curl 一行命令按当前目录自动判断 空目录安装 / 续装 / 升级;升级时取线上最新 cmd 执行,规避旧 cmd 导致的两次升级;输出按 locale 中英双语,判空时忽略系统垃圾文件
- cmd:所有用户可见输出按 locale 中英双语(msg 查表 + (*) 占位),业务逻辑不变
- README / README_CN:安装段补充一键脚本命令、升级段补充一键命令并移除升级重试提示;删除 0.x 迁移到 1.x 章节

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 02:45:21 +00:00
kuaifan
0896f09878 fix: 覆盖一次性 PHP 容器入口命令 2026-06-23 15:55:23 +00:00
kuaifan
7ca85bfe6b fix: 使用一次性 PHP 容器执行更新命令 2026-06-23 15:15:56 +00:00
kuaifan
0c75d52be0 release: v1.8.45
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:46:56 +00:00
kuaifan
bc8a2c9ded refactor(license): 统一离线授权判断并优化在线绑定交互
- 在线/离线 Tab 套用 setting-component-item,提交按钮固定到底部,与其它设置子页一致
- 抽出 offlineValid 统一「有效离线授权」口径(非试用、sn/mac 匹配本机),用于默认 Tab 切换与替换确认,替代原先仅判断 license 非空的宽松逻辑
- 将替换有效离线授权的二次确认前置到发送验证码(流程起点),登录/试用不再重复确认
- 邮箱输入沿用 setting/email 的内联发送按钮样式

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:20:37 +00:00
kuaifan
889aca311a feat(license): 在线授权数据缓存秒开 + 请求 loading
- 进页面读 localStorage 缓存即时渲染在线授权数据(key 按站点 host 隔离),
  后台并发 license/refresh + system/license 刷新,右上角显示「刷新中」转圈,
  数据回来静默替换并更新缓存;非在线状态自动清除缓存
- 首次无缓存显示骨架占位 + 加载中
- 发送验证码改回 setting/email 内联样式(iView search + enter-button +
  spinner 全局 loading),去除独立按钮
2026-06-23 07:10:25 +00:00
kuaifan
fa9e56944a feat(license): 在线授权 UI 优化 + 邮件语言透传
- license.vue:在线授权 Tab 移到离线前,默认 Tab 智能选择(未绑在线但已设
  离线→离线,其余在线优先);按钮级 loading 互斥(onlineAction:发码/登录/
  试用/退出各自 loading、其余禁用);登录/试用失败清空验证码并解除重发倒计时;
  邮箱行改「Input + 独立发送按钮」承载 loading
- OnlineLicense.php:新增 lang() 透传请求语言到 appstore,邮件按语言渲染
2026-06-23 06:15:47 +00:00
kuaifan
87e05ef9c9 style(setting): 统一设置页底部按钮间距
各设置页底部按钮去除内联 margin-left:8px,改由 .setting-footer
统一 column-gap:12px。
2026-06-23 01:21:20 +00:00
kuaifan
7c6b8ce6f4 feat(license): 在线授权登录与试用改为邮箱+验证码
配合 appstore 账号体系统一,在线授权去除 App Store 账号+密码:
- OnlineLicense 新增 emailSend,login/trial 改 (email, code),call payload
  去 account/password、加 email/code(fingerprint 续传)
- LicenseController 新增 email__send,login/trial 读 email/code
- license.vue 在线 Tab 改邮箱+发码+验证码(与试用复用状态防串台),
  离线 Tab 与互斥链路不动
- 同步 i18n 文案与 ai-kb license/online、api-map
2026-06-23 01:21:11 +00:00
kuaifan
7c6dfe8a25 chore(deploy): 内部 appstore 镜像升级 0.5.1 → 0.5.2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:52:01 +00:00
kuaifan
389cb6d709 feat(license): 在线授权前端与上报优化
- 替换离线授权二次确认:仅当离线为真实付费(people>3)且 sn/mac 匹配本机时才提示
- 上报 url 改用真实外网地址(RequestContext::replaceBaseUrl)、version 改用应用版本(Base::getVersion)
- trialSend 携带实例指纹,便于 appstore 发码前做试用资格校验

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:52:01 +00:00
kuaifan
4ca7fc10d1 feat(license): 新增在线授权(App Store 账号自助签发 + 自动续期)
- OnlineLicense 模块:登录/试用/续期/释放/状态机,离线↔在线互斥(last-write-wins)
- LicenseController + 动态路由;容器内 supervisor 独立进程定时续期(不依赖 LARAVELS_TIMER)
- license.vue 双 Tab:在线授权 + 离线绑定二次确认,已绑定在线时离线页提示+按需绑定
- 进入授权页静默刷新;同步 ai-kb 在线授权知识库

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:22:26 +00:00
kuaifan
6a4f815d5a style(ai-assistant): 调整输出占位符的行高和内边距 2026-06-21 23:34:02 +00:00
kuaifan
31729933be feat(ai): AI厂商 DooTask→Doo AI(标识符 dootask→dooai)
- 显示名 ai.js AIBotMap / UserBot.php → Doo AI
- vendor key dootask→dooai:Setting.php aiList、AI.php TEXT_MODEL_PRIORITY;bot 邮箱随之变为 ai-dooai@bot.system
- bot 头像接入 default_dooai.png(User.php)
- 未发版,无迁移文件;已有 bot 用户数据直接更新

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:06:37 +00:00
kuaifan
4a6403d17f feat(ai): 新增 dootask 官方厂商类型
注册 dootask 为 AI 厂商类型(TEXT_MODEL_PRIORITY / aiList / systemBotName / AIBotMap),
其余沿用现有 OpenAI 兼容管线:provider 由 buildProviderConfig 默认分支构建(key+base_url),
按模型思考档位透传复用,bot 由 botGetOrCreate 按名动态创建。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:24:05 +00:00
kuaifan
f8ad608c54 docs(api-map): 回填 468abe9..HEAD 期间的路由对照漂移
按 CLAUDE.md 质量门禁重新生成 doc:api-map 对照表。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:07:15 +00:00
kuaifan
97718bc22a feat(ai-kb): 系统应用知识库迁出核心库,改由应用自带
主程序核心 RAG 知识库不再内置系统应用文档,改由各应用安装时自带并同步到 overlay。

- 删除 approve/okr/minder/drawio/office/fileview/search/ai-assistant 等系统应用共 125 个核心 chunk
- _meta/feature-map.yaml 移除已迁出 feature 块;tool-binding.yaml 清理失效 feature 引用
- 13 个保留文件的跨库死链 [[xref]] 降级为纯文本
- docker/appstore/.gitignore 忽略应用 KB overlay 目录(ai-kb/*,保留 .gitkeep)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:07:07 +00:00
kuaifan
aa872773f5 feat(ai-assistant): 添加用户消息展开功能,支持点击显示全部内容 2026-06-18 13:20:43 +00:00
kuaifan
8e591503dd feat(ai-assistant): 统一 AI 回复内链接导航时的浮窗策略
- 移动端:点任意 in-app 链接(task/project/file/contact/message/深链)一律关闭浮窗,避免全屏遮挡目标
- 桌面端:一律保留浮窗以便继续对话;若处于全屏则退出全屏让目标露出
- modal 模式:点链接一律关闭(居中弹窗会挡住目标)
- 策略集中到 AssistantModal.prepareForNavigate,DialogMarkdown 各链接类型统一调用 beforeNavigate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:10:13 +00:00
kuaifan
7453b79c8d feat(ai-assistant): chat 浮窗接入全局模态栈支持 ESC/滑动返回关闭
- modal.vue: chat 模式新增空助理 Modal(mask=false 不挡背后操作),
  进入 ViewUI 模态栈,由 removeLast() 统一管理 ESC/滑动返回/返回键/Electron 关窗
- index.vue: 移除 onInputKeydown 内重复的 chat ESC 处理,改由全局栈统一处理
- modal.vue: 捕获阶段抢先拦截 ESC,chat 全屏时退全屏而非关闭栈顶 Modal
  (AI 浮窗 z-index 恒最高但未必是栈顶,故不能依赖 ViewUI 冒泡 ESC)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:50:18 +00:00
kuaifan
7844f5500b chore(mobile): 更新子项目提交引用 2026-06-18 17:19:06 +08:00
kuaifan
f0e48d98e4 feat(messenger): 移动端发送按钮默认改为显示发送按钮
移动端 send_button_app 默认值由 enter 改为 button,
默认显示独立发送按钮(桌面端保持 enter 回车发送)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:01:50 +08:00
kuaifan
333f9caa5e chore(web): 移除无用的 addExtraStyle 空方法
addExtraStyle 仅返回空字符串,无实际作用,一并移除其在深色样式中的调用。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:21:27 +08:00
kuaifan
135b419572 fix(micro-apps): 修复深色主题下微应用自定义背景色显示错误
深色主题通过 html 整体 invert+hue-rotate 反色,导致配置的背景色被反成错误颜色。
改为在 iframe 铺底伪元素上承载真实背景色,并在深色下对该伪元素施加同款反向 filter
抵消整体反转,使背景与内容区反色行为一致,不再用 JS 近似反相。

- modal.vue:bodyStyle 通过 CSS 变量 --micro-body-background-color 传递真实色
- iframe.vue:.micro-app-iframe::before 铺底背景层 + 深色反向 filter,并加 isolation 隔离 z-index
- 移除不再需要的 utils/color.js 颜色反相工具

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:21:19 +08:00
kuaifan
a19822a617 feat(ai-assistant): 新增 close_app 动作支持关闭当前应用窗口
打开微应用后 AI 页面操作默认锁定在该应用 iframe 内部,无法关闭应用本身
(关闭控件在主程序外壳层)。新增外壳层 close_app 动作绕过 iframe 作用域:

- action-executor 注册 close_app,先用 store 状态判断有无打开应用(无则报错
  不假报成功),再经事件总线 observeMicroApp:close 投递给 MicroApps 组件。
- MicroApps 复用现成 onAssistClose(findLast(isOpen)+closeByName)作为该事件
  处理器,关闭最前打开的应用,零重复逻辑。
- page-context-collector 在有微应用打开时向模型注入 close_app 可用动作。
- 同步 ai-kb page-action chunk 与 events-map。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:09:38 +00:00
kuaifan
ac2d4c6c4f chore(docs): 取消跟踪 AI 深链插件规格文档
deeplink 方案主程序侧 + 插件侧(dootask-ai)均已落地,该跨仓库
改动规格使命完成,从版本控制移除,文件本地保留(docs/.gitignore)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:24:46 +00:00
kuaifan
a3d5bdf93c feat(ai-assistant): 重构页面操作为统一执行器(Web DOM floor + Electron CDP 双后端)
页面操作(doo page → /ws → 浮窗 operation-module → action-executor)原用手写
iView-only 选择器 + 朴素 value= 赋值,在 React+Radix 同源 iframe 业务表单上几乎
填不进。重构为「复用 Playwright 注入脚本的描述层 + 按环境选后端的执行层」:

描述层(共用):
- 新增 aria/aria-bundle.js,vendoring Playwright ariaSnapshot/roleUtils/domUtils
  子集预构建为 ESM(Apache-2.0 NOTICE + 固定上游 commit),产出 YAML 快照与活的
  ref→Element Map。
- page-context-collector 改用 buildSnapshot 采集,operation-module 经 setRefMap
  以 Map 直接持有元素取代 selector 反查。

执行层(按环境选后端,失败回退):
- web-backend:原生 prototype value setter + beforeinput/input/change/blur 序列
  + 回读校验(不符报 value_not_applied,绝不假报成功);自定义下拉/日期 open→click。
- electron-backend + electron/lib/page-input.js:经 webContents.debugger 走 CDP
  Input 域产生 isTrusted=true 可信输入(insertText/dispatchMouseEvent/KeyEvent),
  任一步失败回退 Web 后端。
- 复杂控件(动态明细表/成员选择器/文件上传)如实返回 unsupported_widget。

协议与链路(doo page、active-context 失效守卫、导航类 action)保持不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:03:59 +00:00
kuaifan
b0a4fe6646 feat(ai-assistant): AI 回复增加复制/时间/反馈取消,历史删除二次确认
- 反馈行新增复制按钮(去推理段落)与回复时间(规则参考 formatMessageTime)
- 点赞/点踩支持再点取消;修复 feedbackLoading 持久化导致重载后无法再点
- 历史删除改为二次确认(垃圾桶→红勾),离开/3秒/下拉关闭自动复位,触摸端常显
- 回复完成后贴底用户自动滚动到底部,露出反馈按钮
- 后端 feedback__save 支持空 feedback 取消(删除记录),同步 ai-kb

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:58:50 +00:00
kuaifan
97bd58312e refactor(approve): 移除主仓库内置审批功能,收敛到插件/微应用
删除 ApproveController、ApproveProcInstHistory/ApproveProcMsg 模型、approve
前端页面与导出组件,移除 approve 路由与 flow_url 配置;审批消息模板改为对接
插件侧能力。版本号 1.7.90 → 1.7.91。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:35:49 +00:00
kuaifan
2f8dee44c2 refactor(mail): 邮件发送弃用 guanguans/notify 改用 symfony/mailer
guanguans/notify 在本项目仅用于 SMTP 发信,但其 1.x 线已停更、email 渠道
自 3.x 起被上游移除(无升级路径)。改用项目已自带的 symfony/mailer(Laravel
13 传递依赖),零新增依赖,并一并移除孤儿依赖 overtrue/http、symfony/options-resolver。

- EmailNoticeTask / UserEmailVerification / SystemController 三处发信改为
  new Mailer(Transport::fromDsn(...)) + new Email();API 1:1 等价
  (from/to/subject/html 同名,verify_peer=0 仍受 symfony 8.x 支持,
  notify 本就裸调 symfony 故异常透传不变、getCode()===550 仍成立)
- 移除 UserTransfer 未使用的 notify import
- 顺带修复既有 bug:超时判断字面量 "Timed Out" 与 symfony 实际消息
  "timed out" 大小写不匹配,改 stripos 大小写不敏感

验证:phpstan 0 错误、composer audit 无公告;邮箱验证码、系统邮件测试两条
链路实测发信成功。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:10:47 +00:00
kuaifan
468abe9902 chore(upgrade): 重新生成 api-map 同步路由收敛 + 删除过时升级报告
- doc:api-map 重新生成:8d9082f7a 将 ApproveController 6 个内部方法收敛为
  protected 后漏同步对照表,移除 6 条失效路由(现访问 404),接口数 325→319
- 删除 UPGRADE-L13-REPORT.md:内容已过时(称 light-spring 未 push、一次性栈
  仍在运行、仅 3 个 commits),升级已合入 dev

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 04:35:38 +00:00
kuaifan
27c65cc582 fix(frontend): TagInput 体质优化与多分隔符支持 + 后端 saveOrIgnore 加固
TagInput:
- cut prop 支持字符串或字符串数组,多分隔符任一匹配即切;
  v-model 写回用第一个分隔符 (joinChar) 拼接保持向后兼容
- 修复 max 仅在 cut 分隔符路径生效,回车/blur/paste 路径未查 max 的 bug
- 修复 data() 初始化与 watch(value) 用硬编码 ',' 切分,
  统一走 joinChar (单一输出分隔符) 还原 v-model,避免含空格的列名
  (如项目模板 'Sprint 1') 被切碎
- 修复 watch(value) 空值/null 时不清空 disSource 的潜在残留数据
- v-for 加 :key='text',配合初始化去重避免拖拽错位
- 重复 tag 由静默忽略改为提示「该标签已存在」
- 编辑 tag 加 dedup 检查,重命名成已有 tag 文本时阻止关闭
- 粘贴改为按 cut 切分逐个 addTag;addTag 返回 boolean,
  pasteText 在 max 满时 break 短路
- splitByCuts/parseValue 拆分:回切用 joinChar,paste/输入用 cutPattern
- 拼接翻译 '最多只能添加X个' 改为 $L('最多只能添加(*)个', max);
  新增「最多只能添加(*)个」「该标签已存在」到 language/original-web.txt

manage.vue:
- 创建项目任务列表启用 :cut=\"[',', ',', ' ']\",支持半角/全角逗号/空格

IME (compositionstart/end 时序不可靠,统一走 @on-keydown + W3C 标准 229 守卫):
- login/ProjectPanel/TaskAdd/UserTagsModal 的 onXxxKeydown 加
  isComposing || key=='Process' || keyCode==229 三重守卫,
  与 TagInput.downEnter 风格统一
- file.vue 块模式重命名 onKeydown 用 \$nextTick 包 onEnter,
  避免 keydown 早于 input 事件 commit v-model 导致少一个字符

AbstractModel.performInsertOrIgnore:
- \$uniqueBy 不为 null 时抛 InvalidArgumentException;
  MySQL INSERT IGNORE 无法按指定列 scope 冲突,与框架 ON CONFLICT 语义不一致
- lastInsertId 仅在 > 0 时回填主键;
  避免在无 auto_increment 列的表上把业务设置的 PK 覆盖为 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 01:26:55 +00:00
kuaifan
3f5078ec9b fix(frontend): 修复多处输入框 IME 合成期回车/Backspace 误触发提交
- file.vue 块模式文件重命名输入框 @on-keyup → @on-keydown,
  避免拼音选词回车的 keyup 阶段误触发提交
- TagInput 回车添加 tag、Backspace 删除 tag 增加 IME 守卫
  (isComposing / key=Process / keyCode=229),避免拼音选词回车
  误加 tag、合成期 Backspace 误删上一个 tag
- login / ProjectPanel / UserTagsModal / TaskAdd 的 @on-enter
  改用 @on-keydown 包装 (keyCode===13),绕开 iview Input
  handleEnter 走 keyup.enter 在 IME 选词回车会误触发提交的问题

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 23:03:01 +00:00
kuaifan
cf6f6041b6 fix(micro-app): 浏览器独立窗口(popout)关闭应用时关闭窗口
assistShow watch 此前只为 Electron 子窗口发送 windowDestroy 销毁窗口,
浏览器以 window.open 打开的 popout 独立窗口(single/apps.vue)关闭应用后
只隐藏内容、窗口空挂不关。补全浏览器分支:windowType 为 popout 时调用
window.close()。主程序内嵌的 embed 窗口(默认值)不进入此分支,不受影响。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 05:30:31 +00:00
kuaifan
e17a520599 fix(micro-app): 修复 resolveType 调用不存在的 $A.platformType 报错
$A 上从未定义 platformType 方法,resolveType 解析微应用类型时
必抛 "$A.platformType is not a function"。改用既有 $A.Platform
属性判断桌面端(web/mac/win),保持 desktop/mobile 语义不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 04:26:33 +00:00
kuaifan
b544c9d7f6 fix(model): 覆写 performInsertOrIgnore 兼容 MySQL
L13 默认走 insertOrIgnoreReturning(INSERT ... ON CONFLICT ... RETURNING),
MySQL/MariaDB grammar 不支持该变体,导致全仓 saveOrIgnore() 调用抛异常。
改用 INSERT IGNORE + lastInsertId() 回填自增ID,保持与框架一致的返回语义。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:09:30 +00:00
kuaifan
8d9082f7a1 fix(upgrade): 修复 L13/PHP8.4 升级回归的 3 处致命异常 + 收敛动态路由暴露面
巡检全量 323 接口 + laravel.log PHP 致命异常普查发现:

- Base::getSchemeAndHost(): 非请求上下文(Task/导出闭包)request() 非
  Request 实例,调 getHttpHost() 致命错误(导出超期任务等)。加 instanceof 守卫。
- FileController::office__token(): php-jwt v7 强制 array payload,
  config 缺失为 null 触发 TypeError。校验为数组,否则 retError。
- DialogController::msg__translation(): 空 language 传入
  Doo::getLanguages(bool|string) 触发 TypeError。前置拦截空值。
- InvokeController: 动态路由改为仅暴露 public 方法,protected/private
  视为内部方法返回 404,堵住内部辅助方法被裸访问触发 ArgumentCountError。
- ApproveController: 6 个内部辅助方法(getProcessById 等)收敛为 protected。

验证: 复扫 323 接口 0 个 5xx,phpstan 无错误,真 public 端点回归正常。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 04:05:31 +00:00
kuaifan
6bbcb702dc fix(deps): 补回 symfony/yaml 显式依赖,修复升级后微应用接口 500
Laravel 8→13 升级后依赖树变化导致 symfony/yaml 不再被传递引入,
而 app/Module/Apps.php 直接 use Symfony\Component\Yaml\Yaml 解析微应用
config.yml。Apps::isInstalledThrow() 在所有微应用控制器构造函数中调用,
缺类导致审批中心等应用中心微应用接口及 AiTaskLoopTask 全部 500。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 02:49:16 +00:00
kuaifan
53dadabca0 ci: tests 工作流增加 dev 分支 push 触发
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 01:26:18 +00:00
kuaifan
5d2701b0be chore(release): publish.yml PHP 升至 8.4,PHP 镜像切正式 tag swoole-8.4
- publish.yml pack-vendor 的 setup-php 8.0 → 8.4(composer.json 已要求 ^8.3,不改发版必挂)
- docker-compose.yml 与 CI tests.yml 统一使用 kuaifan/php:swoole-8.4(已验证:PHP 8.4.21 + swoole + /usr/lib/doo/doo.so)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 01:21:47 +00:00
kuaifan
c3ebfcefd1 chore(ide-helper): 模型 @property 对齐迁移 + _ide_helper.php 刷新至 Laravel 13
- ide-helper:models --write:13 个模型补 125 行 @property(纯增量,无删除)
- ide-helper:generate:重新生成 _ide_helper.php(Laravel 13 facade 签名)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 01:21:47 +00:00
kuaifan
04aa60b574 refactor(config): app 内 env() 直读收敛到 config()
- 新增 config/dootask.php 承载 11 个无 config 对应的 env key(默认值随迁)
- 29 处 env() 替换:APP_KEY→config('app.key')、APP_NAME→config('app.name')、其余→config('dootask.*')
- 语义逐处保持一致(?:/三元留在调用点);消除 config:cache 后 env() 返回 null 的隐患
- 注:User.php / Setting.php 同时含本批 ide-helper 生成的 @property 注释增量
- 验证:grep env( app/ 归零;composer stan 通过;phpunit 146 测试全过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 01:21:47 +00:00
kuaifan
383664aef7 chore(quality): 引入 phpstan/ESLint/CI 门禁、Claude hooks 与代码检索地图
- phpstan(larastan ^3.10, level 1 + baseline 封存 86 个存量错误),composer stan / stan-baseline
- ESLint 9 flat config(vue2-essential,存量违规降 warn,error 基线为 0),npm run lint
- CI:.github/workflows/tests.yml(static-checks + phpunit,phpunit 用 kuaifan/php 镜像跑,FFI doo.so 不在仓库)
- Claude Code hooks:编辑 app/ 下 PHP 后自动单文件 phpstan,失败回灌
- 检索地图:routes/api-map.md(doc:api-map 生成,325 接口)、docs/events-map.md(events:map)、types/dootask-globals.d.ts($A 207 方法)、npm run check:lang(存量缺失 93 条,CI 暂非阻塞)
- CLAUDE.md:版本号更正 Laravel 13/PHP 8.4,新增质量门禁、检索地图、架构增量规则章节

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 01:21:22 +00:00
kuaifan
8fb6d331f8 chore(upgrade): 收尾——compose 镜像 tag、php-jwt 7、升级报告
- docker-compose.yml php 镜像指向 kuaifan/php:8.4-swoole-8.0.rc21
 (dockerfile 仓库 phpswoole/8.4.Dockerfile 的 CI 变体产物,本地已构建同名 tag)
- firebase/php-jwt ^6.9 → ^7.1:消除 CVE-2025-45769(composer audit 清零);
  唯一调用点 FileController JWT::encode(HS256) 实测兼容
 (注意:7.x 强制 HMAC 密钥 ≥32 字节,标准 APP_KEY 51 字符无虞)
- 新增 UPGRADE-L13-REPORT.md:改动清单、依赖矩阵、验证证据、遗留风险

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:55:13 +00:00
kuaifan
cbe00f1284 refactor(skeleton): 平移 Laravel 13 新目录结构
- bootstrap/app.php 改为 Application::configure() 链式配置:
  withRouting(web/api/console) + withMiddleware + withExceptions
- 删除 app/Http/Kernel.php、app/Console/Kernel.php:全局/分组中间件
  归并到 13 默认栈,定制项经 trustProxies/trimStrings/
  validateCsrfTokens/throttleApi/alias(webapi) 配置 API 表达
- 删除 app/Exceptions/Handler.php:ApiException/ModelNotFound 渲染、
  ApiException 条件日志(report->stop)迁入 withExceptions;
  图片动态裁剪逻辑抽为 App\Exceptions\ImagePathHandler
- 删除 RouteServiceProvider/EventServiceProvider/AuthServiceProvider/
  BroadcastServiceProvider:限流、14 个模型观察者、Registered 监听
  迁入 AppServiceProvider::boot;新增 bootstrap/providers.php
- 删除 7 个框架默认中间件子类(TrustProxies/TrimStrings/VerifyCsrfToken/
  EncryptCookies/Authenticate/RedirectIfAuthenticated/
  PreventRequestsDuringMaintenance)与未启用的 TrustHosts,
  保留自定义 WebApi
- config/app.php 移除 providers/aliases 数组(改用框架默认集 +
  bootstrap/providers.php,补齐 9~13 新增的框架 provider)
- artisan、public/index.php 换 13 骨架版(handleCommand/handleRequest)

验证:LaravelS 正常拉起,/health、登录、token 认证、WebSocket 握手、
头像、裁剪(经 withExceptions)、404 兜底全过;php artisan test
145 passed/1 skipped;migrate:fresh 213 全过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:51:19 +00:00
kuaifan
645cb02757 chore(upgrade): Laravel 8 直升 13(旧结构跑通)+ PHP 8.4 + 依赖升级与兼容修复
- composer: framework ^13.0、php ^8.3、laravel-s ~3.8.0、predis ^2.3、
  phpunit ^11.5、tinker ^3、excel ^3.1.69、captcha ^3.5、avatar ^6.5、
  ldaprecord-laravel ^4、pinyin ^5.3、notify 锁 ~1.28.0;
  移除 fideloper/proxy、fruitcake/laravel-cors、facade/ignition、
  laravel/sail、madnest/madzipper、手动钉的 symfony/mailer;
  symfony/console 锁 ^7.4(LaravelS Portal 与 console 8 的
  configure(): void 类型断言不兼容)
- $dates 移除:AbstractModel 改 getCasts() 合并默认 datetime 列,
  3 个子模型改 $casts
- Carbon 3:4 处 diffInSeconds 补 absolute 参数并取整
- LdapRecord v4:config use_ssl/use_tls→use_tls/use_starttls(env 变量名不变),
  LdapUser::$objectClasses 补类型声明
- Madzipper→原生 ZipArchive(Base::zipAddFiles,4 处调用)
- pinyin v5 静态 API(Base::getFirstCharter/cn2pinyin)
- laravolt/avatar 6.5:PatchedAvatar 修上游纵向对齐 bug
 (intervention 4.1.3 枚举无 middle),avatar 响应改 response()->file()
- TrustProxies 改框架内置基类,CORS 改 Illuminate\Http\Middleware\HandleCors
- Symfony Console 8 兼容:ManticoreSyncLock::handleSignal 新签名,
  pcntl 回调解耦
- 非 Swoole 运行时守卫:AbstractTask::task / PushTask::push /
  AbstractData(swoole table),artisan/测试上下文不再炸
  Target class [swoole] does not exist
- Laravel 11+ change() 丢修饰符:2023_12_07 与 2025_08_10 迁移重申
  nullable/default/comment(修复 fresh 安装)
- Setting/Ihttp 缺键访问加 ?? 守卫(PHP 8 警告在测试中转异常)
- phpunit.xml 迁移 11 schema;UserImportParseTest 改为自建部门数据

验证:8.4 容器内 migrate:fresh --seed 213 全过;php artisan test
145 passed/1 skipped;LaravelS(Swoole 6.2.1) /health 200、登录、
token 认证、WebSocket 握手、Task 投递、头像、图片裁剪冒烟全过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:42:12 +00:00
kuaifan
bfa0920579 feat(ai-assistant): 采集器补 tabindex/ARIA 角色覆盖 + label 去重
借鉴 browser-use clickable_elements 规则补齐页面元素采集边界:
- 用已定义但闲置的 INTERACTIVE_ROLES 补全所有 ARIA 可交互角色选择器(原仅扫部分 role)
- 选择器加 [tabindex]:not([tabindex="-1"]),捕捉自定义可聚焦/可交互元素
- 第二遍扫描跳过 label[for]/包裹控件的 label,避免与其表单控件重复采集

纯 DOM 增量、零风险(只多采不误删);真机验证无报错、label 去重生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:18:24 +00:00
kuaifan
88fed0744c feat(ai-assistant): 页面操作穿透到当前微应用 iframe 内部
- 新增 active-context.js:按 microApps 解析最前的同源微应用 iframe(src 匹配 + contentDocument 同源探测),给出 doc/frameKey
- 采集器/执行器从只认主 document 泛化为按 el.ownerDocument 自适应,覆盖主文档与 iframe;视口与事件构造器取元素所在 window
- operation-module 加 scope(auto/main/app)、跨源/未就绪降级、frame 标注,并存活动上下文供失效守卫
- ai-kb 同步 page-action / page-context-tool / element-action 三 chunk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:02:35 +00:00
kuaifan
e74142e58d feat(ai-assistant): auth 注入用户 fd + doo_enabled,供 AI 经 doo 操作
P2 后端:把当前用户 WebSocket fd 透传给 AI 会话,让 AI 助手能经 doo CLI
驱动本人浏览器做页面操作。
- AssistantController::auth() 读 header fd 并做归属校验(复用 operation
  __dispatch 同款 WebSocket::whereFd 校验),传给 createStreamKey
- AI::createStreamKey() 透传 fd + doo_enabled=1 到 /ai/invoke/auth
- ai-kb tools.concept 补「doo 命令行工具」能力来源

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:42:12 +00:00
kuaifan
da095a1a80 feat(ai-assistant): 页面操作搬到主程序 /ws + doo task notify + 删 OCR
页面操作传输层从 MCP 独立 WebSocket 迁到主程序常驻 /ws:
- 后端 AssistantController 加 operation__dispatch/result(fd 归属校验 +
  PushTask 精推 + Cache 轮询取结果),WebSocketService onMessage 加
  operationResult 回包分支
- 前端 actions.js 加 case "operation" 经 emitter 桥接到浮窗执行后回包;
  float-button.vue 接 aiOperationRequest;operation-module.js 解耦、
  executor 惰性化;删除 operation-client.js(不再单连 MCP WS)

ai-kb 同步:tool-binding.yaml 去掉 4 个工具(3 页面 + OCR),相关 chunk
reconcile(措辞去 MCP 化、OCR 改多模态识图),工具数 33→29

i18n:后端新增文案登记 original-api.txt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:31:21 +00:00
kuaifan
9b41330413 fix(ai-assistant): 深链点击仅移动端收起浮窗,桌面端保留对话
桌面端 AI 浮窗为侧栏不遮挡页面,点深链后应保留以便继续对话;
仅移动端全屏浮窗会遮挡目标页,需收起。按 windowPortrait 区分。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:44:16 +00:00
kuaifan
39b9a72b16 refactor(ai-assistant): 用内联深链替代 driver.js 页面引导
「带我去」分步引导(driver.js + 四级元素定位 + show_guide)定位不稳、
二级菜单不可达、点高亮按钮触发跳转即被杀,体验差。改为 AI 回复正文里把
可定位的页面/面板渲染成可点深链 chip,点击直达那一屏。

- 新增深链目录:_meta/page-links.yaml(语义,供 AI 选择)+ deep-links.js
  (可执行映射,21 个无需运行时 id 的导航目的地),两端 id 一致性由
  tests/deep-links-parity.mjs 校验
- markdown.js 加 processDeepLinks:[文字](dootask://link/<id>) 合法 id →
  chip,非法 id → 纯文字(绝不渲染死链);复用现有 dootask:// 点击拦截链路
- DialogMarkdown 加 link 分支调 openDeepLink;system.vue 支持 query.tab
  初始化,系统设置二级 Tab 可深链直达
- 彻底移除 driver.js 引导:删 guide-renderer.js/guide.css、show_guide
  前端通道、aiGuideStarted 监听、driver.js 依赖
- 同步 ai-kb:改写 guide/start-guide 两 chunk、tool-binding 去 show_guide
- 插件侧 prompt 改动规格见 docs/ai-deeplink-plugin-spec.md(独立仓库实施)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:55:09 +00:00
kuaifan
4de6c69972 feat(ai-assistant): RAG 反馈闭环 + driver.js 页面操作引导
方向7 反馈闭环:
- 新增 ai_assistant_search_logs / ai_assistant_feedbacks 两表 + 模型
- AssistantController 加 log__search / feedback__save 端点,auth 透传 session_id→context_key
- 前端 AI 回复下方 👍/👎(可改票、随会话持久化回显),extractSourceIds 解析引用
- ai-kb:feedback chunk + README 运营 SQL 口径(低分 top query / 👎 source)

方向4 页面操作引导:
- 渲染层用 driver.js(高亮可点击挖洞 + 稳定定位),编排层自研
  (脚本 schema / 四级元素定位 / 跨页 pre_action 导航 / 找不到降级文字)
- leave-semantics:跳转动作在点「下一步」后才执行
- markdown.js 渲染 ```ai-guide 围栏为「带我去」按钮;DialogMarkdown 点击启动
- 近义词归一化(创建/新建/新增/添加互通)提升 target 命中
- ai-kb:guide/start-guide chunk + tool-binding 加 show_guide

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:07:08 +00:00
kuaifan
e6ef85e176 feat(ai): AI 模型列表支持 JSON 格式与按模型思考档位
- Setting::AIBotModels2Array 解析 JSON 数组(含 thinking)并兼容旧 id|name 格式,
  新增 AIBotModelThinking() 取模型思考档位;aibotSetting 规范化对 JSON 串透传
- BotReceiveMsgTask 据所选模型档位向 AI 插件透传 thinking_effort,
  旧 (thinking)/-reasoning 后缀降级为 medium 兜底
- 前端 AIModelNames 解析器兼容 JSON 与旧 id|name 格式

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:45:41 +00:00
kuaifan
ec1ab31b0e refactor(ai-kb): 索引同步改为容器启动 reconcile
- 删除 CI reindex workflow(ai-kb-reindex.yml):内容入库不再依赖 CI
  远程触发,AI 插件容器每次启动按文件 hash 对账(reconcile)自动增量
  收敛,免重启即时生效时可手动调 POST /kb/reindex(默认 mode=reconcile)
- ai-kb README.md 同步更新贡献流程与内容同步机制说明
- CLAUDE.md 精简 ai-kb 同步规则:去除与 _schema/README 重复的写作规范
  与背景说明,保留同步时机、操作指引、改完无需触发索引三条
- 另:auth 接口 locale 检索语种缺省改为由请求语言推导(含 zh 视为 zh,
  否则 en),删除 config/ai.php 的 rag_supported_locales 与回退逻辑,
  前端改用 getLanguage() 统一映射,同步更新 ai-kb auth.md
- appstore 镜像升级 0.4.3 -> 0.5.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:46:58 +00:00
kuaifan
af206480fb feat(ai-kb): 落地 RAG 知识库与灰度链路(48 feature / 546 chunk) 2026-06-10 04:08:17 +00:00
kuaifan
f6067d1bd5 feat(ai-assistant): AI 浮窗/浮按钮移动端适配与流式不中断 2026-06-10 04:05:09 +00:00
kuaifan
20c3fa91fb refactor(https): 协议识别下沉到 nginx,TrustProxies 只信 X-Forwarded-Proto
- nginx 经 APP_SCHEME 环境变量(envsubst 模板)统一控制 X-Forwarded-Proto
- TrustProxies 信任内网代理但仅采信 X-Forwarded-Proto,防 Host 注入
- 移除 WebApi 中间件的硬编码强制 https
- getSchemeAndHost 优先用当前请求 scheme/host,保留非请求上下文兜底
- cmd https 切换后改用 compose up -d 重建 nginx 容器使 envsubst 生效

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:52:38 +00:00
kuaifan
c03867304e release: v1.7.90
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:06:30 +00:00
kuaifan
b595120d62 fix(base): readableBytes 补类型声明并修正拼接类型告警
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:06:19 +00:00
kuaifan
8e66f0bfb3 feat(dialog): 管理员可设置全员群名称
- 后端 group__edit 放开全员群改名(仅系统管理员 admin=1)
- formatData/getGroupName 用 ALL_GROUP_DEFAULT_NAME 哨兵区分"未自定义",
  避免回退逻辑被默认种子名短路导致 i18n 丢失
- 前端 canModifyName/编辑入口对全员群管理员放开,改名请求带 admin=1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:57:49 +00:00
kuaifan
e9ea1adc5d chore(electron): drawio submodule 升级 24.7.17 -> 30.0.4
桌面端打包以 resources/drawio submodule 为完整基底,再覆盖 AppStore 下载的
drawio 插件定制文件。将基底从 jgraph/drawio v24.7.17 升到 v30.0.4,与
system-plugins 的 drawio 30.0.4 插件对齐(30.0.4 的 bootstrap.js 仍加载
PreConfig.js,electron/drawio.js 的 EXPORT_URL 注入无需改动)。

注意:桌面端重新打包需在 drawio 插件 30.0.4 发布到 AppStore 之后进行,
否则 build 时下载的「latest」仍是 24.7.17,会与 30.0.4 基底版本错配。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:57:37 +00:00
kuaifan
2eee171a50 feat(project): 系统设置新增「创建项目」权限开关
在系统设置「项目相关」新增「创建项目」权限范围(复选):
所有人 / 部门负责人(含部门管理员)/ 指定人员,三者与「所有人」互斥;
系统管理员始终可创建,不受开关限制。未授权用户隐藏「新建项目」入口
(顶部下拉、快捷键、移动端、应用宫格),后端 Project::userCanCreate()
兜底拦截,个人项目(注册自动创建)不受限。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:57:24 +00:00
kuaifan
fd6a8a3650 feat(user): 会员卡片支持查看该会员参与的项目和任务
- 新增权限闸门 UserDepartment::userWorksContext(本人/管理员/部门负责人只读,排除机器人与系统账号)
- 新增接口 project/user/projects、project/user/tasks、project/user/counts
- users/extra 返回 works_visible 标记控制入口显隐
- 会员卡片新增「项目与任务」入口,弹出 UserWorksModal(项目/待办/已完成三 Tab、角标计数、工作流状态徽章、懒加载)
- 部门只读视角下任务仅展示全员可见(visibility=1),与 findForDepartmentView 对齐
- 补充 i18n 文案与暗色样式

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:24:50 +00:00
kuaifan
84a90b7760 feat(approve): 审批详情支持删除审批
- ApproveController 新增 process__delById 代理,转发至审批插件 process/delById;
  服务端注入 is_admin(仅发起人或管理员可删)
- 审批详情页新增「删除」按钮(仅已结束的审批可见),删除后独立路由返回上一页、
  嵌入模式刷新列表

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:44:37 +00:00
kuaifan
7335c59b68 chore(release): 移除被 dootask-release 技能取代的翻译/版本脚本
- 删 language/translate.php(OpenAI 翻译)+ composer.json/lock + README
- 删 bin/version.js(git-cliff + OpenAI 更新日志)+ cliff.toml
- package.json 去掉 version/translate 两条 script,cmd 去掉 translate 子命令
- README_PUBLISH 指向 dootask-release 技能

翻译/版本号/更新日志改由 dootask-release 技能完成;CI 不受影响(只读 package.json version + CHANGELOG.md)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:54:37 +00:00
kuaifan
035c9d9d3d feat(skill): 新增 dootask-release 发版技能
翻译与更新日志在技能内直接产出,版本号计算/差异检测/语言文件生成等
机械步骤交给本地脚本(language.php、version_bump.js,host 直跑、不进容器)。
language.php 用 php 以字节级对齐项目原生产物;脚本相对自身定位项目根,与 cwd 无关。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:19:23 +00:00
kuaifan
36da18af79 release: v1.7.81 2026-06-03 02:20:11 +00:00
kuaifan
363badbc97 chore(appstore): 升级 appstore 镜像至 0.4.3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:54:46 +00:00
kuaifan
9be6265220 fix(download): 大文件下载改用 BinaryFileResponse 走 sendfile
StreamedResponse 在 LaravelS/Swoole 下被 DynamicResponse 用 ob_start/
ob_get_clean 整体缓冲进 PHP 内存,约 700MB 文件会撞 memory_limit 导致
下载失败;且每次请求对整文件 md5_file 生成 ETag 开销巨大。

改为返回 BinaryFileResponse,由 LaravelS StaticResponse 走 Swoole 原生
sendfile(),OS 级零拷贝、不占 PHP 内存,可支持任意大小文件。去掉 ETag
全文件哈希改用 mtime。Swoole 环境下关闭 Range 分段(sendfile 只能整文件
发送,避免 206 头与整文件 body 错位),非 Swoole 环境保留原生 Range。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:37:52 +00:00
kuaifan
be53e6c6ac feat(skill): 新增 dootask-backup 数据备份技能
- 备份数据库(必须) + public/uploads(排除 tmp,可选) + docker/appstore/config(可选)
- 汇总到 tmp/ 临时目录并附 README 说明,打包到 backup/ 按日期命名
- 只读取源数据、绝不删改,失败即停
- .gitignore 忽略 /backup,避免归档进入版本库

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:04:29 +00:00
kuaifan
4eab130313 feat(electron-mcp): 对齐 dootask-mcp,新增 send_task_ai_message 等
- 新增 send_task_ai_message 工具(dialog/msg/send_ai_assistant),支持
  自定义发送者昵称 nickname(≤20)与 silence
- complete_task 增加 flow_item_id 参数及多结束状态(-4005)重选处理
- update_task 增加 flow_item_id 参数及多结束/开始状态(-4005/-4006)处理
- request() 捕获 ret/data 并对 -4005/-4006 放行交工具处理(向后兼容)
- 同步头部工具清单注释(27→29)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 07:10:36 +00:00
kuaifan
c706c515ee fix(dialog): AI 助手消息推送补全发送者身份
AI 助手为虚拟用户(userid=-1)无会员记录,userid2nickname 返回空串,
导致群聊推送拼成 ": 内容"。友盟 App 推送与前端桌面/移动通知改为取
msg.nickname 或默认"AI 助手"作为发送者名,显示为 "名称: 内容"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 06:10:02 +00:00
kuaifan
8a576595ce feat(dialog): send_ai_assistant 支持自定义发送者昵称
接口新增可选 nickname 参数(最长20字),写入消息体 msg.nickname;
前端群聊昵称与回复预览对 AI 助手消息(userid=-1)显示自定义昵称,
留空回退默认"AI 助手"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 06:09:50 +00:00
kuaifan
8c809bbff1 feat(team): 团队管理支持标记成员邮箱认证状态
- 成员行操作菜单新增「标记邮箱为已认证/未认证」,复用 users.email_verity
  字段与 api/users/operation 接口,新增 setverity/clearverity 操作类型
- 创建用户:邮箱下方新增「标记邮箱为已认证」复选框(默认勾选),
  「首次登录需改密」复选框移到初始密码下方
- 批量导入:预览列表邮箱右侧显示主题色已认证图标(错误行不显示),
  支持勾选行后批量标记已/未认证;部门与认证批量行加标签对齐、
  三个批量按钮样式随选中状态统一
- createByAdmin 新增 emailVerity 选项,createuser/import 透传逐行认证状态
- 新增导入预览默认认证状态单测

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 05:49:33 +00:00
kuaifan
08ed396444 feat(skill): 新增安装/更新/修复权限技能,四技能描述精简为命令触发
- dootask-install:前置检查 + sudo ./cmd install(建库 + migrate --seed)
- dootask-update:前置检查 + sudo ./cmd update,本地改动停下交用户决定
- dootask-fix-permission:对齐 install 赋权逻辑,chown + chmod 775,赋权不删数据
- 四个技能均为命令显式触发,description 去掉关键词堆砌,精简为一句功能说明

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:14:36 +00:00
kuaifan
f5eb84589f docs(skill): release 改名 dootask-release,补 CI 确认/iOS/uploads 赋权
- 新增 push 后确认 Publish 工作流、可选 iOS 发布(gh workflow run)
- 构建 EACCES:改为 chown 赋权整个 public/uploads(不删数据),删除仅限 tmp
- 同步 Red Flags;目录与 name 统一为 dootask-release

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:19:29 +00:00
kuaifan
daca384822 feat(todo): 待办设置权限放开系统管理员(任意群可设/取消他人待办)
- checkTodoOwnerPermission 最高优先级放行系统管理员,覆盖无群主的全员群等场景
- 同步设/取消待办、到点提醒接口报错文案与系统设置描述
- 补充管理员放行测试(user/project/全员群)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:02:20 +00:00
kuaifan
0a6e944a9a
Merge pull request #301 from robertsilen/pro
docs: mention MariaDB in README
2026-06-01 22:20:35 +08:00
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
Robert Silén
635f6e5d5a
Merge branch 'kuaifan:pro' into pro 2026-05-25 15:04:30 +03:00
Robert Silén
4875574c6e add MariaDB to README 2026-05-25 15:04:16 +03:00
932 changed files with 83497 additions and 28610 deletions

53
.claude/hooks/php-stan-check.sh Executable file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Claude Code PostToolUse hook:Edit/Write 改动 app/ 下的 PHP 文件后,自动在 PHP 容器内
# 对该文件跑 phpstan 单文件分析。失败时 exit 2,把错误回灌给 Claude 修复。
# 任何环境不满足(无 python3 / 容器未运行 / 未装 phpstan)都静默放行,绝不阻塞编辑。
set -u
INPUT=$(cat)
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
command -v python3 >/dev/null 2>&1 || exit 0
command -v docker >/dev/null 2>&1 || exit 0
FILE_PATH=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" 2>/dev/null) || exit 0
[ -n "$FILE_PATH" ] || exit 0
case "$FILE_PATH" in
*.php) ;;
*) exit 0 ;;
esac
REL_PATH="${FILE_PATH#"$PROJECT_DIR"/}"
case "$REL_PATH" in
app/*) ;;
*) exit 0 ;;
esac
[ -f "$PROJECT_DIR/$REL_PATH" ] || exit 0
# 定位挂载本项目的 PHP 容器:
# ① 环境变量 DOOTASK_PHP_CONTAINER;② .env 的 APP_ID;③ 扫描 /var/www 挂载源为本项目的容器
CONTAINER="${DOOTASK_PHP_CONTAINER:-}"
if [ -z "$CONTAINER" ] && [ -f "$PROJECT_DIR/.env" ]; then
APP_ID=$(grep -E '^APP_ID=' "$PROJECT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2 | tr -d '"' | tr -d "'")
if [ -n "$APP_ID" ] && docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "dootask-php-$APP_ID"; then
CONTAINER="dootask-php-$APP_ID"
fi
fi
if [ -z "$CONTAINER" ]; then
RUNNING=$(docker ps -q 2>/dev/null)
[ -n "$RUNNING" ] && CONTAINER=$(docker inspect --format '{{.Name}}|{{range .Mounts}}{{if eq .Destination "/var/www"}}{{.Source}}{{end}}{{end}}' $RUNNING 2>/dev/null \
| awk -F'|' -v dir="$PROJECT_DIR" '$2 == dir {gsub(/^\//, "", $1); print $1; exit}')
fi
[ -n "$CONTAINER" ] || exit 0
docker exec "$CONTAINER" test -f /var/www/vendor/bin/phpstan 2>/dev/null || exit 0
OUTPUT=$(docker exec "$CONTAINER" sh -c "cd /var/www && php vendor/bin/phpstan analyse --no-progress --error-format=raw --memory-limit=-1 '$REL_PATH'" 2>&1)
if [ $? -ne 0 ]; then
{
echo "phpstan 检查未通过($REL_PATH),请修复以下问题:"
printf '%s\n' "$OUTPUT" | grep -v '^Note:' | tail -30
} >&2
exit 2
fi
exit 0

16
.claude/settings.json Normal file
View File

@ -0,0 +1,16 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/php-stan-check.sh",
"timeout": 120
}
]
}
]
}
}

View File

@ -0,0 +1,119 @@
---
description: 备份 DooTask 数据:数据库(必须)+ public/uploads排除 tmp可选+ docker/appstore/config可选。汇总到临时目录并附 README 说明,打包到 backup/ 按日期命名。只读取源数据、绝不删改,失败即停。
---
# DooTask 数据备份
**刚性技能**——前置检查 → 选可选项 → 确认 → 执行 → 报告。只读取源数据生成归档,**绝不删除或修改任何源数据/既有备份**。任何一步失败立即停止。
## 备份范围
| 项 | 来源 | 是否必须 | 说明 |
|----|------|---------|------|
| 数据库 | `./cmd mysql backup` 产出的 `.sql.gz` | **必须** | 脚本内部用 mysqldump 导出当前库 |
| 上传文件 | `public/uploads`**排除 `public/uploads/tmp`** | 可选 | 头像/聊天/任务/文件等真实上传数据;`tmp` 是临时目录,可重建,不备份 |
| 应用配置 | `docker/appstore/config` | 可选 | 应用市场各应用的配置;含 **root 属主子目录**,收集时可能需 sudo |
> `docker/appstore/apps` **不在备份范围**——可从应用市场重新安装,无需备份。
## 前置检查(全部通过才能继续)
1. **工作目录**:在项目根(存在 `cmd``docker-compose.yml`
2. **数据库容器**`mariadb` 容器在跑DB 备份依赖它;不在则提示用户先 `./cmd up` 起服务)
3. **磁盘空间**:确认 `backup/` 所在盘空间足够(数据库 dump 可能较大)
4. **选可选项**:询问用户本次是否包含 `public/uploads``docker/appstore/config`**默认两个都含**
检查通过、可选项确定后,汇报本次将备份哪些项,**向用户确认一次**再执行。
## 执行
用一个统一时间戳贯穿全程:`TS=$(date +%Y%m%d_%H%M%S)`,临时目录 `WORK="tmp/dootask-backup-${TS}"`
### 1) 建临时工作目录
```shell
mkdir -p "$WORK"
```
`tmp/` 已被 gitignore安全
### 2) 数据库(必须)
```shell
./cmd mysql backup
```
脚本会把 dump 写到 `docker/mysql/backup/<库名>_<时间戳>.sql.gz` 并打印「备份文件:...」。**取该次产出的最新 dump** 复制进工作目录(不用关心它原始落在哪):
```shell
DB_FILE=$(ls -t docker/mysql/backup/*.sql.gz | head -1)
cp "$DB_FILE" "$WORK/"
```
### 3) public/uploads可选排除 tmp
```shell
rsync -a --exclude='tmp' public/uploads/ "$WORK/uploads/"
```
> 无 rsync 时用 tar 管道:`mkdir -p "$WORK/uploads" && tar cf - --exclude='./tmp' -C public/uploads . | tar xf - -C "$WORK/uploads"`
### 4) docker/appstore/config可选
```shell
cp -a docker/appstore/config "$WORK/appstore-config"
```
> 含 root 属主子目录,若报 `permission denied`:改用 `sudo cp -a ...`,随后把整个工作目录属主归还当前用户,保证后续打包/清理不受阻:
> ```shell
> sudo chown -R "$(id -u):$(id -g)" "$WORK"
> ```
### 5) 写 README.md备份说明
`$WORK/README.md` 写明本次备份信息,便于日后识别与还原。模板:
```markdown
# DooTask 备份 — <TS>
- 备份时间:<人类可读时间>
- DooTask 版本:<取自 package.json version>
- 包含内容:
- 数据库:<DB dump 文件名>(来源 mysqldump 当前库)
- 上传文件uploads/(来源 public/uploads已排除 tmp ← 未选则写「未包含」
- 应用配置appstore-config/(来源 docker/appstore/config ← 未选则写「未包含」
- 各项大小:<du -sh 列出工作目录内各项>
## 还原提示
- 数据库:`gunzip < <db>.sql.gz | mysql -u<user> -p<pass> <库名>`,或用 `./cmd mysql recovery` 选对应文件还原。
- 上传文件:将 uploads/ 内容覆盖回项目 public/uploads/。
- 应用配置:将 appstore-config/ 覆盖回 docker/appstore/config/。
```
### 6) 打包到 backup/,清理临时目录
```shell
mkdir -p backup
tar czf "backup/dootask_backup_${TS}.tar.gz" -C tmp "dootask-backup-${TS}"
rm -rf "$WORK"
```
## 报告
向用户报告:
- 最终归档路径:`backup/dootask_backup_<TS>.tar.gz`
- 归档大小(`ls -lh`
- 实际包含了哪些项(数据库 + 视选择含/不含 uploads、appstore-config
## 失败处理
- 任何步骤失败立即停止,原样报告错误
- **不要**自动重试、不要静默跳过某一项(可选项是否包含由前置确认决定,不在执行中临时变更)
- DB 备份失败(如 mariadb 未运行)→ 停止,提示用户起服务后重试
- 打包前若工作目录有 root 属主残留导致 tar/rm 失败 → `sudo chown` 归还属主后继续,不要删源数据
## 禁止项
| 错误做法 | 正确做法 |
|---------|---------|
| 为"省空间"删除源数据或既有备份 | 只读取源数据生成归档,源数据一律不动 |
| 备份 `public/uploads/tmp` | 排除 tmp临时、可重建 |
| 把 `docker/appstore/apps` 也打进去 | 不在范围,可从应用市场重装 |
| 遇 config 的 root 子目录就跳过该项 | `sudo` 收集后 chown 归还,完整备份 |
| 不写 README 直接打包 | 每个归档自带 README便于日后识别还原 |
| 把归档写进 git | 归档放 `backup/`(已 gitignore不提交 |
## Red Flags —— 出现这些念头立即停下
- "源数据太大,删点旧的再备份" → 不,备份只读不删
- "config 有 root 目录,跳过算了" → 不sudo 收集后归还属主
- "apps 也一起备了更全" → 不apps 不在范围
- "tmp 里临时文件顺手也备了" → 不,明确排除 `public/uploads/tmp`

View File

@ -0,0 +1,95 @@
---
name: dootask-fix-permission
description: 修复 DooTask 整个项目的目录和文件权限:根目录 chmod 755bootstrap/cache、docker、public、storage chown 回调用用户且目录 chmod 775public 文件补充所有用户读权限。用于 Nginx 静态文件 403/Permission denied、install/build EACCES 或可写目录检测失败;优先使用 sudo ./cmd permission赋权不删数据。
---
# DooTask 目录权限修复
项目根目录如果缺少 `x` 穿越权限Nginx 即使看到 `public` 中的文件也无法访问。容器内进程还常以 **root** 写入挂载目录(`storage``public/uploads``bootstrap/cache` 等),导致宿主机当前用户没有写权限。常见现象:
- Nginx 日志出现 `stat() failed (13: Permission denied)`,静态文件请求被回退到 Laravel 首页
- `./cmd install` 报「目录【xxx】权限不足」/目录权限检测失败
- `./cmd build`vite`EACCES: permission denied, copyfile`
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
对齐 `./cmd permission`/`./cmd install` 的赋权逻辑:项目根目录设为 `755`;四个可写目录做 `chmod 775`(仅目录)+ `chown` 回调用 sudo 的用户;`public` 普通文件用 `a+r` 补充 Nginx 所需读权限。
## 适用目录
```text
. # 项目根目录,只修本层为 755
bootstrap/cache
docker
public # 目录 775普通文件 a+r含真实上传数据
storage
```
## 核心原则:赋权,不删数据
`public/uploads` 含真实上传文件。永远优先 `chown` 改属主,不要删数据。即便用户说“清理一下”,也只允许清理临时目录 `public/uploads/tmp`,切勿删除 uploads 下其他内容。
## 前置检查
1. 在项目根目录执行,确认存在 `cmd` 和上述四个目录。
2. 用 `ls -ld .` 检查项目根目录是否缺少组/其他用户的 `x` 穿越权限。
3. 确认可使用 sudo改 root 属主的文件或目录需要 root 权限。
4. 用 `find public -type f ! -perm -004 -print` 检查 Nginx 用户可能无法读取的静态文件。
5. 默认修复项目根目录、四个可写目录和 `public` 文件;若用户只想解决 build 的 uploads 报错,可只处理 `public/uploads`
检查通过后,汇报将执行的命令,向用户确认一次再执行。
## 执行
确认后优先执行独立权限修复命令(不依赖 Docker 正在运行):
```shell
sudo ./cmd permission
```
旧版 `cmd` 没有 `permission` 命令时,手动执行:
```shell
# 1) 保证 Nginx 可穿越项目根目录(不递归)
sudo chmod 755 .
# 2) 可写目录属主修回当前用户(递归)
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
# 3) 可写目录权限 775仅目录
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
# 4) public 普通文件只补充读权限,保留现有写入/执行位
find public -type f -exec chmod a+r {} \;
```
只想解决 build 的 uploads 报错时,可只执行:
```shell
sudo chown -R "$(id -u):$(id -g)" public/uploads
```
执行后用 `ls -ld . bootstrap/cache docker public storage` 抽查目录,并用 `find public -type f ! -perm -004 -print` 确认不再有缺少 others 读权限的静态文件。然后重试之前失败的静态文件访问、install/build/update。
## 失败处理
- `chmod`/`chown` 报权限不足:立即停止,提示使用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过。
- 任何步骤失败都立即停止并报告,不自动重试。
## 禁止项
| 错误做法 | 正确做法 |
|---------|---------|
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
| 对文件无差别 `chmod 777` | 可写目录 `chmod 775` + `chown` 回当前用户 |
| 把 `public` 所有文件强制改为 `644` | 用 `chmod a+r` 只补读权限,保留现有权限位 |
| 递归 `chmod 755` 整个项目 | 只对项目根目录执行 `chmod 755 .` |
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
## Red Flags
- “uploads 复制失败,删掉再 build” → 不,`chown` 赋权,不丢数据。
- “777 一把梭最省事” → 不,按 install 的 775目录+ chown。
- “根目录不可穿越,递归 chmod 全仓库” → 不,只修项目根目录为 755。
- “静态文件 403给整个项目所有文件加读权限” → 不,只对 `public` 普通文件执行 `a+r`
- “权限不够就跳过这个目录” → 不,报告交用户处理 sudo。

View File

@ -0,0 +1,74 @@
---
name: dootask-install
description: 首次部署 DooTask前置检查后执行 `sudo ./cmd install`(建库 + migrate --seed 的重操作),刚性流程、单次确认、失败即停。
---
# DooTask 安装流程
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
## 核心原则
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并步骤,不要为"省事"绕过 sudo 或确认。
`./cmd install` 已把整套安装封装为单条命令(赋权→起容器→`composer install``key:generate``migrate --seed``up -d`)。本技能的职责是**安装前把关、选对参数、执行前确认、已知失败处理**,而不是把脚本逻辑拆开重做。
## 前置检查(全部通过才能继续)
执行前依次确认:
1. **工作目录**:必须在项目根(存在 `cmd``docker-compose.yml``.env.docker`
2. **Docker**`docker``docker-compose`/`docker compose`(v2+) 可用且 daemon 在跑(脚本 `check_docker` 也会查,但提前确认能更早报错)
3. **Node.js ≥ 20**(脚本 `check_node` 会查)
4. **APP_ID 不冲突**:若 `.env` 已有 `APP_ID` 且被其他实例占用,脚本 `check_instance` 会报错——此时**停止**,提示用户先清空 `.env` 里的 `APP_ID``APP_IPPR` 再装
5. **sudo**`./cmd install` 需 root`check_sudo`),用 `sudo ./cmd install` 执行
⚠️ **这是重操作**:会创建数据库并执行 `migrate --seed`(灌入种子数据)。在已有数据的环境上重装前务必和用户确认,避免覆盖。
检查通过后汇报结果,**向用户确认一次**再执行。
## 参数选择
| 参数 | 作用 | 何时用 |
|------|------|--------|
| `--port <端口>` | 指定 HTTP 端口(脚本会做端口占用检测) | 用户要自定义端口,或默认端口被占 |
| `--relock` | 删除 `node_modules`/`package-lock.json`/`vendor`/`composer.lock` 后重装 | **谨慎**:仅在依赖锁损坏、用户明确要求重建锁时用,会拖慢安装 |
不确定时不要自作主张加参数,按需询问用户。
## 执行
确认后执行(按用户选择带上参数):
```shell
sudo ./cmd install
# 或: sudo ./cmd install --port 8080
```
成功后脚本会输出访问地址并调用 `repassword.sh`。执行完向用户报告:访问地址(`http://127.0.0.1:<APP_PORT>`)、以及数据库密码提示。
## 失败处理
- 任何步骤失败立即停止,原样报告错误信息
- **不要**自动重试,**不要**自动跳过
- 常见失败与对应处理:
- `APP_IDxxx已被其他实例使用` → 停止,让用户清空 `.env``APP_ID`/`APP_IPPR` 再装
- `端口 xxx 已被占用` → 停止,让用户换 `--port`
- `目录【xxx】权限不足` / 目录权限检测失败 → 这是目录属主/权限问题,引导用户用 **dootask-fix-permission** 技能修复后重装
- `安装依赖失败`composer→ 报告,交用户决定(常因网络/镜像源)
## 禁止项
| 错误做法 | 正确做法 |
|---------|---------|
| 不加 sudo 直接 `./cmd install` | 用 `sudo ./cmd install`(脚本强制 root |
| 失败后"我再试一次"或自动跳过 | 立即停止,交还用户 |
| 在已有数据环境上不问就重装 | 先确认会 `migrate --seed`,可能影响现有数据 |
| 遇权限报错自己乱 `chmod`/`chown` | 走 dootask-fix-permission 技能统一处理 |
| 不问就加 `--relock` | 默认不加;仅用户明确要求或锁损坏时用 |
## Red Flags —— 出现这些念头立即停下
- "端口/权限报错了我顺手帮 TA 改一下别的" → 停下,只处理本次报的问题,按指引走对应技能
- "种子数据应该没事,直接重装" → 不,先确认是否会覆盖现有数据
- "sudo 麻烦,先试试不加" → 不install 必须 root

View File

@ -0,0 +1,215 @@
---
name: dootask-release
description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本号/更新日志 → 构建 → 提交推送,刚性顺序、每步确认、失败即停。
---
# DooTask 发布流程
**刚性技能**——严格按顺序执行,每步向用户确认,任何一步失败立即停止。
## 核心原则
按固定顺序执行不增删、合并或重排步骤。翻译Step 1和更新日志Step 2由你直接产出脚本只做确定性机械工作算版本号、检测差异、字节级生成语言文件
## 前置检查(全部通过才能继续)
执行任何发布步骤前,依次检查:
1. **分支**:必须是 `pro`,否则停止,提示用户切换
2. **工作区**`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理
3. **Node.js**`node --version` 必须 ≥ 20
4. **PHP**`php --version` 必须可用Step 1 的脚本依赖本地 php无需容器。若 host 无 php停止并提示用户
检查通过后汇报结果,用户确认后再开始执行。
## 发布步骤
**每步执行前**向用户确认;**每步执行后**报告结果。
开始前先把这份清单复制到你的回复里,逐项勾选、跟踪进度:
```
发布进度:
- [ ] 前置检查(分支 pro / 工作区干净 / node≥20 / php 可用)
- [ ] Step 1 翻译diff → 翻译 → apply → generate
- [ ] Step 2 版本号 + CHANGELOG
- [ ] Step 3 构建(./cmd prod
- [ ] 汇总变更 → 用户确认 → commit + push
- [ ] 确认 GitHub Actions Publish 工作流 success
```
---
### Step 1: 翻译
多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`
上下文翻译键用于保持短文案的界面宽度,同时区分不同语义,格式固定为 `[lower_snake_case].原文`,例如 `[weekday].一``[task_unit].个`。上下文前缀只参与翻译查找,不参与显示;运行时完整键缺失时会回退普通原文键。普通 key 的 `zh` 留空,上下文 key 的 `zh` 必须填写去掉前缀后的原文。
**1.1 检测差异**
```shell
php .claude/skills/dootask-release/scripts/language.php diff
```
输出 JSON
- `formatErrorCount > 0`translate.json **已有条目**含 raw `(*)`/`(**)` key、字段结构错误、非法/不连续的参数编号或规范化重复 → **停止**,报告 `formatErrors`,交用户修复
- `regexErrorCount > 0`translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改)
- `redundantCount > 0`translate.json 里有、但原文已删除的条目 → 仅作提示apply 时会自动剔除,不致命)
- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成**
- `needsCount > 0``needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`);上下文 key 还会带自动填充的 `zh` → 进入 1.2
**1.2 翻译**
`needs` 里的每个 `key`,翻成 8 种语言(`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`。普通 key 的 `zh` 留空;上下文 key 的 `zh` 使用 `needs` 已给出的原文,不得留空或改写。
要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整:
| 原文 | 翻成英语 |
|---|---|
| (%T1)的周报[(%T2)][(%T3)月第(%T4)周] | Weekly report of (%T1) [(%T2)] [Week (%T4) of month (%T3)] |
| (%T1)提交的「(%M2)」待你审批 | '(%M2)' submitted by (%T1) is waiting for your approval |
把结果写成一个 JSON 数组文件(建议放 `/tmp/dootask-release-translated.json`,避免污染工作区),每个元素含全部 10 个字段,顺序为:
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru`
```json
[
{"key":"...(%T1)...","zh":"","zh-CHT":"...","en":"...","ko":"...","ja":"...","de":"...","fr":"...","id":"...","ru":"..."}
]
```
上下文短词示例:
```json
[
{"key":"[task_unit].个","zh":"个","zh-CHT":"個","en":"tasks","ko":"개 작업","ja":"タスク","de":"Aufgaben","fr":"tâches","id":"tugas","ru":"задач"}
]
```
**1.3 合并进 translate.json**
```shell
php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json
```
脚本会校验字段完整性、占位符完整性与上下文键格式,追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。参数化 key 必须使用 `(%T1)`/`(%M1)` 形式,禁止输入 raw `(*)`/`(**)`;编号须按出现顺序从 1 连续递增,各语言值可调整占位符顺序,但占位符的类型、编号和数量必须与 key 完全一致。上下文 key 必须使用 `[lower_snake_case].原文`,且 `zh` 必须与原文部分完全一致。输入 JSON 内相同或规范化后相同的 key 会被拒绝;已存在于 `translate.json` 的非待补项也会被拒绝,避免覆盖歧义。任一条不合格会明确报错并停止,按提示修正翻译后重试。
**1.4 生成前端/后端语言文件**
```shell
php .claude/skills/dootask-release/scripts/language.php generate
```
`translate.json` 字节级重新生成 `public/language/web/*.js``public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。生成前会再次执行格式与占位符校验;存在 raw `(*)`/`(**)` key 或占位符错误时不会写入生成文件。
**1.5 报告**:用 `git status --short language public/language` 汇总本步改动,向用户报告新增了多少条翻译。
---
### Step 2: 版本号 + 更新日志
**2.1 计算并写入版本号**
```shell
node .claude/skills/dootask-release/scripts/version_bump.js
```
脚本据 git 历史算出新 `version``codeVerson` 并写入 `package.json`,输出 JSON 含:`version``prevVersion``changelogRange`(如 `<上次release提交>..HEAD`,用于下一步圈定本次更新范围)。
**2.2 撰写 CHANGELOG**
读取本次区间的提交:
```shell
git log <changelogRange> --stat
```
`--stat` 会带上每个提交的完整描述正文 + 改动文件清单;光看标题不够时用 `git show <hash>` 看具体代码改动。
`CHANGELOG.md` 现有格式,在文件顶部 `# Changelog` 说明段之后、紧挨上一个 `## [...]` 之前,插入新版本区段:
```markdown
## [<version>]
### Features
- ...
### Bug Fixes
- ...
### Performance
- ...
```
撰写要求(对齐项目历史风格):
- 小节标题用**英文 Title Case**`Features` / `Bug Fixes` / `Performance` / `Documentation` / `Security` / `Miscellaneous`**不要译成中文****没有内容的小节整段省略**。
- 条目正文用**通俗友好的简体中文**,面向**普通用户**描述更新带来的直接好处,**避免技术术语**(如 refactor、merge branch、commit lint、bump deps 等)。
- 过滤掉对用户无意义的提交(纯构建/依赖/CI/合并提交、本技能自身的脚手架改动等)。
- 仅凭提交标题无法判断是否对用户有价值时,结合提交的完整描述正文和实际代码改动(`git show <hash>`)再决定,不要只看一行就下结论。
- 合并相似项;每个小节内**按用户价值与影响范围排序,重要的在前**。
**2.3 报告**:展示新版本号与你写的 changelog 区段,请用户过目。
---
### Step 3: 构建前端
```shell
./cmd prod
```
构建前端生产版本。用 `./cmd prod`,不要换成裸跑 vite它还负责 node 检查、清 `public/js/build`、debug 切换)。
> **已知失败**build 报 `public/uploads/...``EACCES: permission denied, copyfile`,是 vite 复制 `public/` 时撞到 root 属主的运行时上传文件(不限于 `tmp``avatar` 等都可能)。补救是赋权、不是删数据——把 uploads 属主改回当前用户后重试:
> ```shell
> sudo chown -R "$(id -u):$(id -g)" public/uploads
> ```
> `public/uploads` 是真实上传数据,**不要删**;即便要清也只清 `public/uploads/tmp`
---
## 最终:提交并推送
所有步骤完成后:
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 CHANGELOG.md language/translate.json public/language public/js`),不要用 `git add -A` / `git add .`,以免卷入未跟踪的本地实验文件
- 不打 git tag现行发布流程不使用 tag
- 确认前先核对:`/tmp/dootask-release-translated.json` 等临时文件不在仓库内,工作区不应残留发布无关的未跟踪文件
## push 之后确认发布工作流CI 才是真正出包)
push 到 `pro` 只是触发器,真正的构建/出包由 GitHub Actions 完成——**push 成功 ≠ 发布完成**
- **Publish**`.github/workflows/publish.yml`push→pro 触发)跑完才算出包;成功后会自动触发 **Sync to Gitee**(镜像同步)。
- push 完成后**主动确认** Publish 工作流 `conclusion=success`。优先用 `gh`(未装可临时装;公开仓库也可用 GitHub REST API 免鉴权读取 runs
```shell
gh run list --workflow=publish.yml -R kuaifan/dootask -L 1
gh run view <run-id> -R kuaifan/dootask --json status,conclusion,url
```
- 工作流仍在跑时,挂后台轮询、结束即通知用户,**不要在前台死等**。
### iOS 发布(询问后决定)
`ios-publish.yml` 是**独立的手动工作流**`workflow_dispatch`),不随 push 触发。Publish 成功后,用 options 或 AskUserQuestion 形式提问是否同时发布 iOS选项发布 iOS / 不发布):
- 选「发布 iOS」才执行
```shell
gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask
```
`gh` 已登录且 token 含 `workflow` 权限;触发后可挂后台轮询结果。
- 选「不发布」则结束。
## 失败处理
任何步骤失败立即停止、报告错误信息,交用户决定;不要自动重试或跳过。

View File

@ -0,0 +1,383 @@
<?php
// DooTask 发布——翻译流水线(纯本地 phphost 直接跑,不进容器、不调 OpenAI、不需 autoload
// 逐行对齐 language/translate.php 的检测/保存/生成逻辑,唯独把"调用外部模型翻译"那一段抽走,
// 翻译改在技能流程内完成。用 php 而非 node 的唯一原因array_multisort + json_encode
// 的逐字节产物必须与项目原生工具一致,否则每次发版都会产生大面积排序/转义噪声 diff已验证 host php 可字节级复现)。
//
// 子命令:
// language.php diff
// —— 输出 JSONneeds(待翻译key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示)
// / formatErrors(raw 占位符、字段或编号错误,致命) / regexErrors(各语言占位符错乱,致命)
// language.php apply <translated.json>
// —— 把新翻译合并进 translate.json追加 + 剔除冗余),不生成 public 文件
// language.php generate
// —— 由 translate.json 重新生成 public/language/{web,api}/*
//
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
$ROOT = dirname(__DIR__, 4);
$LANG_DIR = $ROOT . '/language';
$LANG_FIELDS = ['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'];
if (!is_dir($LANG_DIR)) {
fwrite(STDERR, "未找到 language 目录($LANG_DIR。\n");
exit(1);
}
chdir($LANG_DIR);
$cmd = $argv[1] ?? '';
// ---- 公共:读取 original-*.txt ----
function read_generateds(): array
{
$originals = [];
$generateds = [];
foreach (['web', 'api'] as $type) {
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
$array = array_values(array_filter(array_unique(explode("\n", $content))));
$generateds[$type] = $array;
$originals = array_merge($originals, $array);
}
return [$originals, $generateds];
}
// ---- 公共:占位符与条目结构校验 ----
function parameter_tokens(string $value): array
{
preg_match_all('/\(%[TM]\d+\)/', $value, $matches);
return $matches[0];
}
function normalize_key(string $key): string
{
return preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $key);
}
function context_source_key(string $key): ?string
{
if (preg_match('/^\[([a-z][a-z0-9_]*)]\.([\s\S]+)$/', $key, $matches)) {
return $matches[2];
}
return null;
}
function validate_original_contexts(array $originals): array
{
$errors = [];
foreach ($originals as $index => $key) {
if (preg_match('/^\[[^\]]+]\./', $key) && context_source_key($key) === null) {
$errors[] = [
'location' => "originals[index $index]",
'message' => "上下文翻译键格式错误:$key",
];
}
}
return $errors;
}
function validate_entry(array $obj, string $location, bool $requireTranslations): array
{
$formatErrors = [];
$regexErrors = [];
foreach ($GLOBALS['LANG_FIELDS'] as $field) {
if (!array_key_exists($field, $obj)) {
$formatErrors[] = ['location' => "$location.$field", 'message' => "缺少字段 $field"];
} elseif (!is_string($obj[$field])) {
$formatErrors[] = ['location' => "$location.$field", 'message' => "字段 $field 必须是字符串"];
} elseif ($requireTranslations && $field !== 'key' && $field !== 'zh' && $obj[$field] === '') {
$formatErrors[] = ['location' => "$location.$field", 'message' => "字段 $field 不得为空"];
}
}
if (!isset($obj['key']) || !is_string($obj['key'])) {
return [$formatErrors, $regexErrors];
}
$key = $obj['key'];
if (preg_match('/^\[[^\]]+]\./', $key)) {
$sourceKey = context_source_key($key);
if ($sourceKey === null) {
$formatErrors[] = ['location' => "$location.key", 'message' => "上下文翻译键格式错误:$key"];
} elseif (($obj['zh'] ?? null) !== $sourceKey) {
$formatErrors[] = [
'location' => "$location.zh",
'message' => "上下文翻译键必须填写原文:$sourceKey",
];
}
}
if (preg_match('/\(\*{1,2}\)/', $key)) {
$formatErrors[] = [
'location' => "$location.key",
'message' => "key 不得包含 raw (*)/(**),必须使用 (%T1)/(%M1)$key",
];
}
$withoutValidParameters = preg_replace('/\(%[TM]\d+\)/', '', $key);
if (str_contains($withoutValidParameters, '(%')) {
$formatErrors[] = ['location' => "$location.key", 'message' => "存在非法参数占位符:$key"];
}
$keyTokens = parameter_tokens($key);
foreach ($keyTokens as $index => $token) {
preg_match('/\d+/', $token, $number);
if ((int)$number[0] !== $index + 1) {
$formatErrors[] = [
'location' => "$location.key",
'message' => "参数编号必须按出现顺序从 1 连续递增:$key",
];
break;
}
}
$expected = $keyTokens;
sort($expected);
foreach ($GLOBALS['LANG_FIELDS'] as $field) {
if ($field === 'key' || !isset($obj[$field]) || !is_string($obj[$field]) || $obj[$field] === '') {
continue;
}
$actual = parameter_tokens($obj[$field]);
sort($actual);
if ($actual !== $expected) {
$regexErrors[] = [
'location' => "$location.$field",
'key' => $key,
'field' => $field,
'value' => $obj[$field],
'expected' => $expected,
'actual' => $actual,
'message' => "参数占位符缺失、类型或编号不一致",
];
}
}
return [$formatErrors, $regexErrors];
}
function print_validation_errors(array $formatErrors, array $regexErrors): void
{
foreach ($formatErrors as $error) {
fwrite(STDERR, "格式错误 {$error['location']}{$error['message']}\n");
}
foreach ($regexErrors as $error) {
fwrite(STDERR, "占位符错误 {$error['location']}{$error['message']}key={$error['key']}\n");
}
}
// ---- 公共:构建 translations 映射normalizedKey -> obj并收集冗余/格式/占位符错乱 ----
function build_translations(array $originals): array
{
$translations = [];
$redundants = [];
$regexErrors = [];
$formatErrors = validate_original_contexts($originals);
if (!file_exists("translate.json")) {
fwrite(STDERR, "translate.json not exists\n");
exit(1);
}
$tmps = json_decode(file_get_contents("translate.json"), true);
if (!is_array($tmps)) {
$formatErrors[] = ['location' => 'translate.json', 'message' => '根数据必须是 JSON 数组'];
return [$translations, $redundants, $regexErrors, $formatErrors];
}
foreach ($tmps as $index => $obj) {
$location = "translate.json[index $index]";
if (!is_array($obj)) {
$formatErrors[] = ['location' => $location, 'message' => '条目必须是对象'];
continue;
}
[$entryFormatErrors, $entryRegexErrors] = validate_entry($obj, $location, false);
$formatErrors = array_merge($formatErrors, $entryFormatErrors);
$regexErrors = array_merge($regexErrors, $entryRegexErrors);
if (!isset($obj['key']) || !is_string($obj['key'])) {
continue;
}
$currentKey = $obj['key'];
$originalKey = normalize_key($currentKey);
if (!in_array($originalKey, $originals)) {
$redundants[$originalKey] = $obj;
continue;
}
if (isset($translations[$originalKey])) {
$formatErrors[] = [
'location' => $location,
'message' => "规范化后 key 重复:$originalKey",
];
continue;
}
$translations[$originalKey] = $obj;
}
return [$translations, $redundants, $regexErrors, $formatErrors];
}
// ---- 公共:由 translate.json + originals 重新生成 public 文件 ----
function generate(array $generateds, array $translations): void
{
foreach ($generateds as $type => $array) {
$datas = [];
foreach ($array as $text) {
$text = trim($text);
if (isset($translations[$text])) {
$datas[] = $translations[$text];
}
}
$inOrder = [];
foreach ($datas as $index => $item) {
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
$inOrder[$index] = strlen($item['key']);
} else {
$inOrder[$index] = strlen($item['key']) + 10000000000;
}
}
array_multisort($inOrder, SORT_DESC, $datas);
$results = [];
foreach ($datas as $items) {
foreach ($items as $kk => $item) {
$results[$kk][] = $item;
}
}
if ($type === 'api') {
if (!is_dir("../public/language/api")) {
mkdir("../public/language/api", 0777, true);
}
foreach ($results as $kk => $item) {
file_put_contents("../public/language/api/$kk.json", json_encode($item, JSON_UNESCAPED_UNICODE));
}
} elseif ($type === 'web') {
if (!is_dir("../public/language/web")) {
mkdir("../public/language/web", 0777, true);
}
foreach ($results as $kk => $item) {
file_put_contents("../public/language/web/$kk.js", "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
}
}
echo "[$type] total: " . count($results['key']) . "\n";
}
}
if ($cmd === 'diff') {
[$originals, $generateds] = read_generateds();
[$translations, $redundants, $regexErrors, $formatErrors] = build_translations($originals);
// 需要翻译的数据(对齐 translate.php 150-169占位符按单一计数器编号
$needs = [];
foreach ($originals as $text) {
$key = trim($text);
if ($key === '') {
continue;
}
if (!isset($translations[$key])) {
$needs[$key] = $key;
}
}
$needsOut = [];
foreach ($needs as $key) {
$c = 1;
$converted = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
$label = strlen($m[1]) > 1 ? "M" : "T";
return "(%" . $label . $c++ . ")";
}, $key);
$need = ['key' => $converted];
$contextSource = context_source_key($converted);
if ($contextSource !== null) {
$need['zh'] = $contextSource;
}
$needsOut[] = $need;
}
echo json_encode([
'needsCount' => count($needsOut),
'redundantCount' => count($redundants),
'formatErrorCount' => count($formatErrors),
'regexErrorCount' => count($regexErrors),
'needs' => $needsOut,
'redundants' => array_keys($redundants),
'formatErrors' => array_values($formatErrors),
'regexErrors' => array_values($regexErrors),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
if (count($formatErrors) > 0 || count($regexErrors) > 0) {
exit(2); // 已有数据格式或占位符错乱,需先修复
}
exit(0);
}
if ($cmd === 'apply') {
$file = $argv[2] ?? '';
if ($file === '' || !file_exists($file)) {
fwrite(STDERR, "用法apply <translated.json>(文件不存在)\n");
exit(1);
}
[$originals, $generateds] = read_generateds();
[$translations, $redundants, $regexErrors, $formatErrors] = build_translations($originals);
if (count($formatErrors) > 0 || count($regexErrors) > 0) {
print_validation_errors($formatErrors, $regexErrors);
fwrite(STDERR, "translate.json 已有条目格式或占位符错误,请先修复再发版。\n");
exit(2);
}
$incoming = json_decode(file_get_contents($file), true);
if (!is_array($incoming)) {
fwrite(STDERR, "translated.json 必须是数组\n");
exit(1);
}
$originalSet = array_flip($originals);
$incomingSeen = [];
$itemsToAdd = [];
foreach ($incoming as $index => $raw) {
if (!is_array($raw)) {
fwrite(STDERR, "新翻译 translated.json[index $index] 必须是对象\n");
exit(1);
}
[$incomingFormatErrors, $incomingRegexErrors] = validate_entry($raw, "translated.json[index $index]", true);
if (count($incomingFormatErrors) > 0 || count($incomingRegexErrors) > 0) {
print_validation_errors($incomingFormatErrors, $incomingRegexErrors);
exit(1);
}
// 规范化:固定字段顺序;上下文翻译键保留 zh 作为实际显示原文
$item = [];
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
$item[$f] = $f === 'zh' && context_source_key($raw['key']) === null ? '' : $raw[$f];
}
$originalKey = normalize_key($item['key']);
if (!isset($originalSet[$originalKey])) {
fwrite(STDERR, "新翻译 key 不在 original-web.txt/original-api.txt{$item['key']}\n");
exit(1);
}
if (isset($incomingSeen[$originalKey])) {
fwrite(STDERR, "新翻译输入内部重复translated.json[index {$incomingSeen[$originalKey]}] 与 translated.json[index $index] 规范化后均为「$originalKey」\n");
exit(1);
}
if (isset($translations[$originalKey])) {
fwrite(STDERR, "新翻译 key 已存在于 translate.json非待补项或存在覆盖歧义{$item['key']}(规范化:$originalKey\n");
exit(1);
}
$incomingSeen[$originalKey] = $index;
$itemsToAdd[$originalKey] = $item;
}
foreach ($itemsToAdd as $originalKey => $item) {
$translations[$originalKey] = $item;
}
// array_values现有条目去冗余在前新条目追加在后
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
echo json_encode([
'added' => count($itemsToAdd),
'total' => count($translations),
'droppedRedundant' => count($redundants),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
exit(0);
}
if ($cmd === 'generate') {
[$originals, $generateds] = read_generateds();
[$translations, $redundants, $regexErrors, $formatErrors] = build_translations($originals);
if (count($formatErrors) > 0 || count($regexErrors) > 0) {
print_validation_errors($formatErrors, $regexErrors);
fwrite(STDERR, "translate.json 存在格式或占位符错误,已停止生成。\n");
exit(2);
}
generate($generateds, $translations);
exit(0);
}
fwrite(STDERR, "未知子命令:'$cmd'。可用diff | apply <file> | generate\n");
exit(1);

View File

@ -0,0 +1,47 @@
#!/usr/bin/env node
// 计算并写入新版本号到 package.jsonversion + codeVerson算法对齐 bin/version.js。
// 不生成 CHANGELOG在技能流程内撰写只输出版本号与 changelog 的提交区间。
//
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const ROOT = path.resolve(__dirname, '../../../..');
const pkgFile = path.join(ROOT, 'package.json');
const verOffset = 6394; // 版本号偏移量(与 bin/version.js 一致)
const codeOffset = 35; // 代码版本号偏移量
function git(cmd) {
return execSync(cmd, { cwd: ROOT, maxBuffer: 1024 * 1024 * 10 }).toString().trim();
}
const verCount = parseInt(git('git rev-list --count HEAD'), 10);
const codeCount = parseInt(git("git tag --merged pro -l 'v*' | wc -l"), 10);
const num = verOffset + verCount;
if (Number.isNaN(num)) {
console.error(`版本计算失败rev-list count=${verCount}`);
process.exit(1);
}
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
const codeVersion = codeOffset + codeCount;
let pkg = fs.readFileSync(pkgFile, 'utf8');
const prevVersion = (pkg.match(/"version":\s*"(.*?)"/) || [])[1] || '';
pkg = pkg.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
pkg = pkg.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
fs.writeFileSync(pkgFile, pkg, 'utf8');
// 上一个 release 提交作为 changelog 区间下界
let prevReleaseCommit = '';
try {
prevReleaseCommit = git("git log --grep='^release: v' -n 1 --pretty=format:%H");
} catch (e) { /* ignore */ }
console.log(JSON.stringify({
version,
codeVersion,
prevVersion,
prevReleaseCommit,
changelogRange: prevReleaseCommit ? `${prevReleaseCommit}..HEAD` : '(未找到上一个 release 提交,需人工确定区间)',
}, null, 2));

View File

@ -0,0 +1,83 @@
---
name: dootask-update
description: 更新已部署的 DooTask前置检查后执行 `sudo ./cmd update`(拉代码 + composer + 迁移 + 重启),本地有改动时停下交用户决定,不自动强制、失败即停。
---
# DooTask 更新流程
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
## 核心原则
**违反字面规则 = 违反流程精神。** 不要擅自加步骤、绕过 sudo/确认,**尤其不要替用户决定强制更新**(会丢本地改动)。
`./cmd update` 已封装整套更新(检测本地改动→`git fetch`→必要时备份库→`git pull/reset``composer install``migrate`→重启 php+nginx→写 `UPDATE_TIME`)。本技能职责是**更新前把关、选对参数、处理本地改动这一关键岔路、执行前确认**。
## 前置检查(全部通过才能继续)
1. **已安装**:必须存在 `vendor/autoload.php`(脚本会查,没装则报"请先执行安装命令"——此时引导用户走 dootask-install
2. **工作目录**:在项目根
3. **当前分支 / 目标分支**:默认更新当前分支;用户要切分支用 `--branch <分支>`。若用户没说,确认是否就更新当前分支
4. **本地改动**(关键):`git status` 看是否有未提交改动
5. **sudo**`sudo ./cmd update` 需 root
检查通过后汇报结果,**向用户确认一次**再执行。
## 关键岔路:本地有改动
脚本检测到本地改动时会询问是否强制更新。**强制更新 = `git reset --hard origin/<分支>`,会丢弃所有本地改动。**
- 发现本地有改动 → **停下**,把改动清单报告用户,让**用户决定**:先提交/暂存改动,还是确认强制更新
- **不要**替用户选 `--force`
- 只有用户明确说"丢掉改动强制更新"时,才带 `--force`
## 参数选择
| 参数 | 作用 | 何时用 |
|------|------|--------|
| `--branch <分支>` | 切到指定分支再更新 | 用户要换分支(如切 `dev`/`pro` |
| `--force` | 强制更新:`git checkout -f` + `git reset --hard` | **危险**:仅用户明确接受"丢弃本地改动"后 |
| `--local` | 本地更新模式:只备份库 + `migrate` + 重启,不拉远程代码 | 代码已就位(如手动改过/CI 拉过),只需迁移+重启 |
## 数据库
- 远程模式下,脚本检测到 `database/` 目录有迁移变动会**自动备份数据库**再继续——这是脚本内置的,无需手动。
- 但若是大版本升级或用户在意数据,执行前提醒用户:本次可能含库迁移,已有自动备份兜底;如需可先 `./cmd mysql backup` 额外备份。
## 执行
确认(含本地改动决策)后执行:
```shell
sudo ./cmd update
# 切分支: sudo ./cmd update --branch pro
# 强制(丢改动,用户确认后): sudo ./cmd update --force
# 本地模式: sudo ./cmd update --local
```
成功后报告:更新到的分支、是否做了库备份/迁移、服务是否重启完成。
## 失败处理
- 任何步骤失败立即停止,原样报告错误
- **不要**自动重试、不要自动跳过、不要因为 `git pull` 失败就自己改成 `--force`
- 常见失败:
- `请先执行安装命令` → 走 dootask-install
- `代码拉取失败,可能存在冲突` → 报告,让用户决定是否 `--force`(丢改动)或先处理冲突
- 重启服务失败 → 脚本会尝试 `down` 后重起;若仍失败,报告交用户
## 禁止项
| 错误做法 | 正确做法 |
|---------|---------|
| 检测到本地改动就自动 `--force` | 停下,报告改动,交用户决定 |
| `git pull` 失败就自动改用 `--force` | 报告冲突,交用户 |
| 不加 sudo | `sudo ./cmd update` |
| 未装就更新 | 先走 dootask-install |
| 失败后自动重试/跳过 | 立即停止 |
## Red Flags —— 出现这些念头立即停下
- "有点本地改动,强制更新一下就好了" → 不,`--force` 会丢改动,必须用户拍板
- "拉取冲突了,我 reset 一下" → 不,交用户决定
- "已经装过了吧,直接更新" → 先确认 `vendor/autoload.php`

View File

@ -1,83 +0,0 @@
---
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 一气呵成" → 必须先让用户确认

View File

@ -92,7 +92,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.0'
php-version: '8.4'
extensions: mbstring, intl, gd, xml, zip, swoole
tools: composer:v2

79
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,79 @@
name: Tests
on:
push:
branches: [pro, master, dev]
pull_request:
jobs:
static-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, intl, gd, xml, zip, swoole, redis
tools: composer:v2
- name: Install Composer Dependencies
run: composer install --prefer-dist --no-interaction
- name: PHPStan
run: composer stan
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install NPM Dependencies
run: npm install --no-audit --no-fund
- name: ESLint
run: npm run lint
- name: Language Check
run: npm run check:lang
phpunit:
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:10.7.3
env:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: dootask
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -ptest"
--health-interval=10s
--health-timeout=5s
--health-retries=10
redis:
image: redis:alpine
ports:
- 6379:6379
steps:
- name: Checkout
uses: actions/checkout@v4
# 用项目自身的 PHP 镜像跑测试(内含 /usr/lib/doo/doo.so FFI 库,裸 runner 无法运行)
- name: Run PHPUnit in DooTask PHP image
run: |
docker run --rm --network host -v "$GITHUB_WORKSPACE":/var/www -w /var/www \
kuaifan/php:swoole-8.4 sh -c '
composer install --prefer-dist --no-interaction &&
cp .env.example .env &&
sed -i "s/^DB_HOST=.*/DB_HOST=127.0.0.1/" .env &&
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=dootask/" .env &&
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=test/" .env &&
sed -i "s/^REDIS_HOST=.*/REDIS_HOST=127.0.0.1/" .env &&
php artisan key:generate &&
php artisan migrate --seed --force &&
php vendor/bin/phpunit
'

2
.gitignore vendored
View File

@ -7,6 +7,7 @@
/public/hot
/public/tmp
/tmp
/backup
# Uploads and user-generated content
/public/summary
@ -64,3 +65,4 @@ README_LOCAL.md
# playwright
.playwright-mcp/
/.phpunit.cache

View File

@ -158,11 +158,6 @@ drawio/webapp/js/app.min.js
drawio/webapp/js/extensions.min.js
drawio/webapp/js/shapes-14-6-5.min.js
drawio/webapp/js/stencils.min.js
drawio/webapp/math/es5/core.js
drawio/webapp/math/es5/input/asciimath.js
drawio/webapp/math/es5/input/tex.js
drawio/webapp/math/es5/output/svg.js
drawio/webapp/math/es5/output/svg/fonts/tex.js
drawio/webapp/styles/grapheditor.css
minder/css/chunk-vendors.fe9c56c6.css

View File

@ -2,6 +2,129 @@
All notable changes to this project will be documented in this file.
## [1.8.89]
### Features
- 管理后台仪表盘新增负责人视角,可按部门查看团队任务;个人视角的待办、逾期、今日和即将到期任务展示也更清晰。
- 语义搜索升级为更智能的自动向量检索,搜索相关内容更准确,内容更新后会自动保持索引同步。
- 撤回聊天文字或 Markdown 消息后支持「重新编辑」,可在有效期内恢复原内容继续修改。
- 微应用加载失败时会显示清晰的错误提示,并提供重试和关闭入口。
- 「我的文件」现在包含已共享文件,切换板块时优先展示缓存内容,查找文件更顺畅。
### Bug Fixes
- 修复 AI 助手模型隐藏设置未完全生效的问题,正在使用的模型被隐藏时会自动切换到可用模型。
- 修复仪表盘待完成分组默认展开状态不一致的问题。
- 修复撤回消息、文件切换及部分网络异常场景下的显示和状态同步问题。
### Performance
- 优化仪表盘移动端布局与任务分组样式,减少页面拥挤,浏览和操作更顺手。
## [1.8.69]
### Features
- 文件页全新分区:拆分「我的文件」与「共享文件」两个板块,共享板块还能进一步筛选「我共享的」和「共享给我的」;上方工具栏合并为一行,查找和切换更清爽。
- AI 助手支持隐藏模型:可将部分模型对普通用户隐藏,选择列表更简洁;当正在使用的模型被隐藏时,会自动切换到可用模型,避免选择异常。
### Bug Fixes
- 修复自定义应用「全员可见」设置有时不生效、以及再次编辑时可见范围被重置为「仅管理员」的问题。
- 修复已删除 / 已归档的项目仍会继续生成周期任务的问题;对这些项目下遗留的任务,相关成员现在也能正常打开详情并删除。
## [1.8.64]
### Features
- 大文件上传全面升级:上传单个文件不再受 1G 限制,文件柜、聊天、任务附件、头像与系统图片、编辑器粘贴等场景统一支持;相同文件可「秒传」无需重复上传,上传中断后也能从断点继续,更快更稳。
- 新增应用菜单角标:插件与微应用可在菜单入口显示数字或红点提醒,应用未打开也能收到,打开后自动清除,手机端底部导航同样支持。
- 登录支持多授权选择:当账号名下有多个可用授权时,登录时可弹窗选择本次要使用的授权。
- 授权页面重新排版:正常状态只展示核心信息,出现到期提醒、已过期或设备不匹配时会浮出醒目提示,授权详情可展开查看,状态更清晰。
- 新增一键安装 / 升级脚本:一行命令即可完成部署或升级,命令行提示同时支持中文与英文。
- AI 助手选择模型时将官方「Doo AI」分组排到下拉框最前选择更方便。
### Bug Fixes
- 修复部分环境下登录验证码图片无法显示的问题。
- 修复只读成员仍能看到并点击文件「删除」入口的问题,删除权限现与实际权限保持一致。
- 修复手机端 AI 助手在弹窗模式下显示异常的问题,恢复为居中弹框。
- 修复安装后网页服务偶尔未自动启动、卸载后残留数据、以管理员身份更新被拦截等部署问题。
### Performance
- 优化应用菜单角标的计算方式,减少重复读取,加载更快。
## [1.8.45]
### Features
- AI 助手全面升级:现在能直接带你跳转页面、协助完成操作;回复可一键复制、查看时间、点赞或点踩反馈;较长的提问支持点击展开查看全部内容;对话浮窗支持手势返回 / ESC 快捷关闭,并完善了手机端适配与流式回复体验。
- AI 助手接入产品知识库,回答更贴合 DooTask 的实际功能与使用方法。
- 新增官方 AI 服务「Doo AI」并支持为不同模型设置思考深度按需选择更合适的模型。
- 新增「在线授权」:通过邮箱验证码即可自助开通或申请试用,到期自动续期;在线授权与离线授权可一键切换、互不冲突,授权状态一目了然。
- 手机端聊天默认显示发送按钮,发送消息更顺手。
### Bug Fixes
- 修复使用中文、日文等输入法打字时,回车或删除键偶尔会误触发送 / 提交的问题。
- 修复深色主题下部分微应用自定义背景色显示异常的问题。
- 修复在独立窗口打开的微应用,关闭应用后窗口未一同关闭的问题。
- 修复个别微应用打开时报错、无法正常加载的问题。
- 修复部分反向代理 / HTTPS 环境下访问地址协议识别错误的问题。
- 优化标签输入框,支持多种分隔符录入,输入更顺畅。
- 优化邮件发送的稳定性,修复发信超时判断不准确的问题。
### Performance
- 全面升级底层运行框架Laravel 13 + PHP 8.4),整体运行更快、更稳定、更安全。
### Miscellaneous
- 内置审批功能已从主程序移除,改由应用中心的审批应用 / 微应用提供;原有审批能力可通过安装对应应用继续使用。
## [1.7.90]
### Features
- 系统设置新增「创建项目」权限开关,可指定由所有人、部门负责人或特定人员创建项目,未授权时自动隐藏新建入口,管理更清晰。
- 会员卡片新增「项目与任务」入口,可直接查看该成员参与的项目、待办与已完成任务,团队协作一目了然。
- 审批详情支持删除已结束的审批,由发起人或管理员清理无用记录更方便。
- 管理员现在可以设置全员群的群名称,便于统一团队群组的展示。
## [1.7.81]
### Features
- 团队管理中可标记成员邮箱认证状态,成员信息更易管理。
- 系统管理员可在任意群组中设置或取消他人的待办,协作管理更灵活。
### Bug Fixes
- 修复 AI 助手消息推送中发送者身份不完整的问题。
### Performance
- 优化大文件下载方式,下载更稳定、更高效。
## [1.7.67]
### Features
- 聊天待办现在可以设置提醒时间,到点会引用原消息并提醒相关人员,避免遗漏重要事项。
- 团队管理支持管理员创建或批量导入员工账号,并可填写部门、职位等信息,添加成员更方便。
- 系统设置新增聊天待办权限控制,可限制其他人员设置或取消聊天待办。
### Bug Fixes
- 设置内容没有变化时不再重复保存,减少无效操作,让使用更稳定。
### Documentation
- 补充路由使用限制说明,帮助使用者更清楚地了解规则。
- 统一回复语言偏好说明,确保整段回复使用简体中文。
## [1.7.55]
### Features

View File

@ -1,6 +1,6 @@
## 项目概述
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
Laravel 13 (LaravelS/Swoole, PHP 8.4) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
## 开发命令
@ -17,18 +17,39 @@ Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管
前端代码改动只做 Edit/Write不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
### 质量门禁改完代码必须自查CI 同步在跑,见 .github/workflows/tests.yml
- `./cmd composer stan` — phpstanlevel 1 + baseline存量已封存新增错误必须清零
- `npm run lint` — ESLinterror 必须为 0warn 是存量遗留,见 eslint.config.mjs 注释)
- `npm run check:lang` — 校验前端 `$L()` 字面量是否已登记到 `language/original-web.txt`
- 改动控制器 public 方法或路由后跑 `./cmd artisan doc:api-map` 重新生成对照表
## 代码检索地图(先查表,再 grep
- API URL ↔ 控制器方法对照:`routes/api-map.md`(生成式文件,勿手改)
- 前端事件总线mitt收发对照`docs/events-map.md``npm run events:map` 重新生成)
- `$A` / `$L` 全局工具类型声明:`types/dootask-globals.d.ts`(新增 `$A` 方法须同步此文件)
## 架构增量规则(只约束新增代码,存量"动到哪迁到哪"
- **巨型文件冻结**:不再往 `ProjectController``UsersController``DialogController``app/Module/Base.php``resources/assets/js/store/actions.js` 新增方法/函数;新功能领域开新控制器或新模块文件(动态路由天然支持多控制器)
- **业务编排归层**:跨模型的业务流程写在 `app/Module/`(或 `app/Services/`模型只保留数据访问与自身状态变更Swoole Task 只做投递与调用,不直接编排业务
- **配置读取**:业务代码禁止直接 `env()`,统一走 `config()`(项目自有配置集中在 `config/dootask.php`
## Gotchas
### LaravelS/Swoole
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
- 要存请求级状态,用 `RequestContext::save('key', $value)` / `RequestContext::get('key')`(参考 `User::authInfo()` 的用法,见 `app/Services/RequestContext.php`
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
- 长生命周期逻辑WebSocket、定时器应复用现有模式避免阻塞协程/事件循环
### 后端
- **非 REST 路由**:所有 API 通过 `Route::any('api/{resource}/{method}')` 路由到 `InvokeController`URL 段映射为控制器方法(如 `api/project/lists``lists()`,带 action 则用双下划线:`api/project/invite/join``invite__join()`
- **非 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()`
@ -47,6 +68,15 @@ Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
- 单字或短词因上下文不同可能产生歧义时,使用 `[lower_snake_case].原文` 上下文键,例如 `$L('[weekday].一')``$L('[task_unit].个')`;前缀只参与翻译查找,界面仍显示原文。此类键须完整登记到 `original-web.txt`,且 `translate.json``zh` 必须填写去除前缀后的原文;普通键的 `zh` 仍留空
## ai-kb 同步规则
`resources/ai-kb/` 是产品内 AI 助手 RAG 检索的功能知识库(目录结构、写作规范、索引机制见其 `README.md``_schema/`)。
- **同步时机**:改动用户可见的功能/菜单/按钮/流程/字段、API 行为(错误码、参数含义、返回结构)、插件/微应用、权限/角色定义时,必须在同一次提交中同步更新 ai-kb不要把 ai-kb 改动单独拆成一个提交
- **怎么改**:在 `_meta/feature-map.yaml` 找到对应 feature 的 chunk 清单,按 `_schema/chunk-style.md``_schema/frontmatter.md` 修改或新建 chunk并把 frontmatter 的 `last_verified` 更新为当前主程序版本号
- **改完即止**:无需触发任何索引操作,插件容器启动时会自动对账收敛
## Playwright 测试
@ -58,4 +88,4 @@ Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管
## 语言偏好
- 技术总结和关键结论优先使用简体中文,除非用户明确要求其他语言
- 回复一律使用简体中文,除非用户明确要求其他语言

View File

@ -9,23 +9,26 @@ English | **[中文文档](./README_CN.md)**
- Group Number: `546574618`
## 📍 Migration from 0.x to 1.x
- Please ensure to back up your data before upgrading!
- If the upgrade fails, try running `./cmd update` multiple times.
- If you encounter "Container xxx not found" during upgrade, run `./cmd reup` and then execute `./cmd update`.
- If you see a 502 error after upgrading, run `./cmd reup` to restart the services.
- If you encounter "Application 'xxx' not installed" after upgrading, log in with the admin account and install the relevant applications from the App Store.
## Installation Requirements
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems
- Hardware Recommendation: 2+ cores, 4GB+ memory
- Database: MariaDB (provided by the default Docker Compose `mariadb` service)
- Special Note: Windows users can install Linux environment using WSL2 before installing DooTask.
### Deploy Project
**Option 1: One-line script (recommended)**
Run it in an empty directory to clone and install automatically; run it inside an existing installation to check and upgrade:
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
**Option 2: Manual deployment**
```bash
# 1、Clone the project to your local machine or server
@ -104,24 +107,33 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
**Note: Please backup your data before upgrading!**
Recommended: use the one-line script (run it inside an existing installation; it pulls the latest code and finishes the upgrade in a single run):
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
Or use the local command:
```bash
./cmd update
```
* Please retry if upgrade fails across major versions.
* If you encounter 502 errors after upgrade, run `./cmd reup` to restart services.
## Project Migration
After installing the new project, follow these steps to complete migration:
1、Backup original database
1、Backup the MariaDB database
```bash
# Run command in the old project
./cmd mysql backup
```
> `./cmd mysql` is the CLI subcommand name; backups run against the MariaDB container.
2、Copy the following files and directories from old project to the same paths in new project
- `Database backup file`

View File

@ -9,23 +9,26 @@
- QQ群号: `546574618`
## 📍 0.x 迁移到 1.x
- 升级时请务必备份好数据!
- 如果升级失败请尝试执行 `./cmd update` 重试几次。
- 如果升级中出现 `没有找到 xxx 容器` 的提示,请运行 `./cmd reup` 后再执行 `./cmd update`
- 如果升级后出现502错误请运行 `./cmd reup` 重启服务即可。
- 如果升级后出现 `应用「xxx」未安装` 的提示,请使用管理员账号进入应用商店安装相关应用。
## 安装程序
- 必须安装:`Docker v20.10+``Docker Compose v2.0+`
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统
- 硬件建议2核4G以上
- 数据库MariaDB默认 Docker Compose 中的 `mariadb` 服务)
- 特别说明Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
### 部署项目
**方式一:一键脚本(推荐)**
在空目录中执行即自动克隆并安装;在已安装目录中执行则自动检查并升级:
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
**方式二:手动部署**
```bash
# 1、克隆项目到您的本地或服务器
@ -104,24 +107,33 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
**注意:在升级之前请备份好你的数据!**
推荐使用一键脚本升级(在已安装目录中执行,自动拉取最新代码并完成升级,无需重复执行):
```bash
curl -fsSL https://raw.githubusercontent.com/kuaifan/dootask/pro/bin/install | bash
```
或使用本地命令:
```bash
./cmd update
```
* 跨越大版本升级失败时请重试执行一次。
* 如果升级后出现502请运行 `./cmd reup` 重启服务即可。
## 迁移项目
在新项目安装好之后按照以下步骤完成项目迁移:
1、备份数据库
1、备份 MariaDB 数据库
```bash
# 在旧的项目下执行指令
./cmd mysql backup
```
> `./cmd mysql` 为 CLI 子命令名称,实际操作的是 MariaDB 容器。
2、将旧项目以下文件和目录拷贝至新项目同路径位置
- `数据库备份文件`

View File

@ -9,9 +9,9 @@
## 发布版本
> 翻译、版本号、更新日志改由 `dootask-release` 技能完成(见 `.claude/skills/dootask-release/`)。
```shell
npm run translate # 翻译(可选)
npm run version # 生成版本
npm run build # 编译前端
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,143 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Route;
use ReflectionClass;
use ReflectionMethod;
class DocApiMap extends Command
{
protected $signature = 'doc:api-map';
protected $description = '生成 API 路由对照表routes/api-map.md';
public function handle(): int
{
$controllers = $this->collectControllers();
if (empty($controllers)) {
$this->error('未从路由中解析到任何 api 控制器');
return 1;
}
$total = 0;
$sections = [];
foreach ($controllers as $prefix => $class) {
$rows = $this->collectMethods($prefix, $class);
$total += count($rows);
$sections[] = $this->renderSection($prefix, $class, $rows);
}
$path = base_path('routes/api-map.md');
file_put_contents($path, $this->renderHeader($total) . implode("\n", $sections));
$this->info("已生成: routes/api-map.md控制器 " . count($controllers) . " 个,接口 {$total} 个)");
return 0;
}
/**
* 从已注册路由中收集 api 前缀与控制器的映射
* 匹配 routes/web.php 中的动态路由api/{prefix}/{method}
* @return array [prefix => 控制器类名]
*/
private function collectControllers(): array
{
$controllers = [];
foreach (Route::getRoutes() as $route) {
if (!preg_match('/^api\/(\w+)\/\{method}$/', $route->uri())) {
continue;
}
preg_match('/^api\/(\w+)\/\{method}$/', $route->uri(), $match);
$class = $route->getAction('controller');
if ($class && class_exists($class)) {
$controllers[$match[1]] = $class;
}
}
return $controllers;
}
/**
* 反射收集控制器的接口方法
* @param string $prefix 路由前缀(如 project
* @param string $class 控制器类名
* @return array [['url' => ..., 'method' => ..., 'http' => ..., 'title' => ...], ...]
*/
private function collectMethods(string $prefix, string $class): array
{
$rows = [];
$reflection = new ReflectionClass($class);
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
// 仅保留本类声明的实例方法,排除 __invoke/__before/__construct 等魔术/框架方法
if ($method->getDeclaringClass()->getName() !== $class
|| $method->isStatic()
|| str_starts_with($method->getName(), '__')) {
continue;
}
[$http, $title] = $this->parseApiDoc($method);
$rows[] = [
'url' => "api/{$prefix}/" . str_replace('__', '/', $method->getName()),
'method' => $method->getName() . '()',
'http' => $http,
'title' => $title,
];
}
return $rows;
}
/**
* 解析方法 docblock 中的 @api 注释行
* 格式如:@api {get} api/project/lists 获取项目列表
* @return array [HTTP 方法, 标题],无 @api 注释时为 ['any', '']
*/
private function parseApiDoc(ReflectionMethod $method): array
{
$doc = $method->getDocComment();
if ($doc && preg_match('/@api\s+\{(\w+)}\s+(\S+)(?:[ \t]+(.+))?/', $doc, $match)) {
return [strtolower($match[1]), trim($match[3] ?? '')];
}
return ['any', ''];
}
/**
* 生成文件头说明
*/
private function renderHeader(int $total): string
{
return <<<MD
# API 路由对照表
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数:{$total}
## 路由规则
API 使用动态路由(见 `routes/web.php`URL 段映射为控制器方法名:
- `api/{controller}/{method}` `{method}()`,如 `api/project/lists` `ProjectController::lists()`
- `api/{controller}/{method}/{action}` `{method}__{action}()`(双下划线连接),如 `api/project/invite/join` `ProjectController::invite__join()`
- 路由最多两段,方法名最多一个双下划线
MD;
}
/**
* 生成单个控制器的表格段落
*/
private function renderSection(string $prefix, string $class, array $rows): string
{
$short = class_basename($class);
$lines = [
"## {$prefix}{$short}",
'',
'| URL | 方法名 | HTTP | 说明 |',
'| --- | --- | --- | --- |',
];
foreach ($rows as $row) {
$lines[] = "| {$row['url']} | {$row['method']} | {$row['http']} | {$row['title']} |";
}
$lines[] = '';
return implode("\n", $lines);
}
}

View File

@ -1,205 +0,0 @@
<?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,29 @@
<?php
namespace App\Console\Commands;
use App\Module\OnlineLicense;
use Illuminate\Console\Command;
/**
* 在线授权续期(容器内独立进程按小时调用,无需 LARAVELS_TIMER、不经过 HTTP 转发)。
*
* php 容器 supervisor 程序 [program:license] 循环调用:
* while true; do php artisan online-license:renew; sleep 3600; done
*/
class OnlineLicenseRenew extends Command
{
protected $signature = 'online-license:renew';
protected $description = '在线授权:本地状态机推进 + 租约将尽时自动续期';
public function handle(): int
{
if (!OnlineLicense::enabled()) {
return 0;
}
OnlineLicense::cron();
$status = OnlineLicense::status();
$this->info('online-license: ' . ($status['status'] ?? 'offline') . ' lease=' . ($status['lease_expired_at'] ?? '-'));
return 0;
}
}

View File

@ -52,9 +52,18 @@ trait ManticoreSyncLock
}
/**
* 信号处理器SIGINT/SIGTERM
* 信号处理器SIGINT/SIGTERM,签名须兼容 Symfony Console Command::handleSignal
*/
public function handleSignal(int $signal): void
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
$this->markShouldStop();
return false; // 继续执行,由批次循环优雅退出
}
/**
* 标记优雅退出pcntl 回调第二参是 siginfo不能直接复用 handleSignal
*/
private function markShouldStop(): void
{
$this->info("\n收到信号,将在当前批次完成后退出...");
$this->shouldStop = true;
@ -67,8 +76,8 @@ trait ManticoreSyncLock
{
if (extension_loaded('pcntl')) {
pcntl_async_signals(true);
pcntl_signal(SIGINT, [$this, 'handleSignal']);
pcntl_signal(SIGTERM, [$this, 'handleSignal']);
pcntl_signal(SIGINT, fn () => $this->markShouldStop());
pcntl_signal(SIGTERM, fn () => $this->markShouldStop());
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
//
];
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
// $schedule->command('inspire')->hourly();
}
/**
* Register the commands for the application.
*
* @return void
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View File

@ -4,94 +4,18 @@ namespace App\Exceptions;
use App\Module\Base;
use App\Module\Image;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class Handler extends ExceptionHandler
/**
* 图片路径处理(原 Exceptions\Handler::ImagePathHandler新结构下由 bootstrap/app.php
* withExceptions NotFoundHttpException 时调用)
*/
class ImagePathHandler
{
/**
* A list of the exception types that are not reported.
*
* @var array
* @param \Illuminate\Http\Request $request
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse|null 命中返回图片响应,未命中返回 null(继续默认 404
*/
protected $dontReport = [
//
];
/**
* A list of the inputs that are never flashed for validation exceptions.
*
* @var array
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*
* @return void
*/
public function register()
{
$this->reportable(function (Throwable $e) {
//
});
}
/**
* 将异常转换为 HTTP 响应。
* @param $request
* @param Throwable $e
* @return array|\Illuminate\Http\JsonResponse|\Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response
* @throws Throwable
*/
public function render($request, Throwable $e)
{
if ($e instanceof NotFoundHttpException) {
if ($result = $this->ImagePathHandler($request)) {
return $result;
}
}
if ($e instanceof ApiException) {
return response()->json(Base::retError($e->getMessage(), $e->getData(), $e->getCode()));
} elseif ($e instanceof ModelNotFoundException) {
return response()->json(Base::retError('Interface error'));
}
return parent::render($request, $e);
}
/**
* 重写report优雅记录
* @param Throwable $e
* @throws Throwable
*/
public function report(Throwable $e)
{
if ($e instanceof ApiException) {
if ($e->isWriteLog()) {
Log::error($e->getMessage(), [
'code' => $e->getCode(),
'data' => $e->getData(),
'exception' => ' at ' . $e->getFile() . ':' . $e->getLine()
]);
}
} else {
parent::report($e);
}
}
/**
* 图片路径处理
* @param $request
* @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\BinaryFileResponse|null
*/
private function ImagePathHandler($request)
public static function render($request)
{
$path = $request->path();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Badge;
use App\Module\Base;
use Request;
/**
* 插件 / 微应用相关接口。
*
* 动态路由routes/web.php
* api/apps/badge/set -> badge__set() 应用密钥鉴权,绝对设置/清除角标
* api/apps/badge/clear -> badge__clear() 当前用户 token 鉴权,清除自己的角标
* api/apps/badge/list -> badge__list() 当前用户 token 鉴权,拉取自己全部角标(初始同步)
*/
class AppsController extends AbstractController
{
/**
* @api {post} api/apps/badge/set 设置角标(应用密钥鉴权)
*
* @apiDescription 由插件服务端使用 APP_SECRET 调用,对 (appid, 菜单, 每个 userid) 绝对设置角标(幂等覆盖)。
* @apiVersion 1.0.0
* @apiGroup apps
* @apiName badge__set
*
* @apiParam {String} appid 应用ID
* @apiParam {String} secret 应用密钥APP_SECRET
* @apiParam {Number|Number[]} userid 目标用户ID单个或数组
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
* @apiParam {Number} [count=0] 角标数字
* @apiParam {Boolean} [dot=false] 是否显示红点count=0 dot=false 即清除)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 返回数据
*/
public function badge__set()
{
return Base::retSuccess('success', Badge::set(
trim(Request::input('appid', '')),
trim(Request::input('secret', '')),
Request::input('userid'),
trim(Request::input('menu_key', '')),
Request::input('count', 0),
Request::input('dot', false)
));
}
/**
* @api {post} api/apps/badge/clear 清除角标(当前用户 token 鉴权)
*
* @apiDescription 供前端在 badge_clear_on_open=true 的菜单打开时调用,清除当前用户在该应用该菜单的角标。
* @apiVersion 1.0.0
* @apiGroup apps
* @apiName badge__clear
*
* @apiParam {String} appid 应用ID
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 返回数据
*/
public function badge__clear()
{
$user = User::auth();
return Base::retSuccess('success', Badge::clearForUser(
(int)$user->userid,
trim(Request::input('appid', '')),
trim(Request::input('menu_key', ''))
));
}
/**
* @api {get} api/apps/badge/list 拉取自己全部角标
*
* @apiDescription 供前端初始同步:返回当前用户全部应用(插件 + 自定义微应用)的角标快照。
* 数据结构 app_id => menu_key => {count, dot},与前端 store map 结构一致。
* @apiVersion 1.0.0
* @apiGroup apps
* @apiName badge__list
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 返回数据
*/
public function badge__list()
{
$user = User::auth();
return Base::retSuccess('success', Badge::userBadges((int)$user->userid));
}
}

View File

@ -2,11 +2,17 @@
namespace App\Http\Controllers\Api;
use App\Models\AiAssistantFeedback;
use App\Models\AiAssistantSearchLog;
use App\Models\AiAssistantSession;
use App\Models\User;
use App\Models\WebSocket;
use App\Module\AI;
use App\Module\Apps;
use App\Module\Base;
use App\Tasks\PushTask;
use Cache;
use Illuminate\Support\Str;
use Request;
/**
@ -32,6 +38,8 @@ class AssistantController extends AbstractController
* @apiParam {String} model_type 模型类型
* @apiParam {String} model_name 模型名称
* @apiParam {JSON} context 上下文数组
* @apiParam {String} [locale] ai-kb 检索语种zh、en缺省取请求语言 language包含 zh 视为 zh否则 en
* @apiParam {String} [session_id] 前端会话ID透传给 AI 服务作 context_key用于检索打点关联
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -46,8 +54,21 @@ class AssistantController extends AbstractController
$modelType = trim(Request::input('model_type', ''));
$modelName = trim(Request::input('model_name', ''));
$contextInput = Request::input('context', []);
$locale = trim(Request::input('locale', '')) ?: trim(Base::headerOrInput('language'));
$locale = str_contains(strtolower($locale), 'zh') ? 'zh' : 'en';
$contextKey = mb_substr(trim(Request::input('session_id', '')), 0, 100);
return AI::createStreamKey($modelType, $modelName, $contextInput);
// 当前用户 WebSocket fd供 AI 经 doo page 操作本人浏览器(页面操作用)。
// 复用 operation__dispatch 同款归属校验:在表即在线、归属即本人,否则置 0。
$fd = intval(Base::headerOrInput('fd'));
if ($fd > 0 && intval(WebSocket::whereFd($fd)->value('userid')) !== intval($user->userid)) {
$fd = 0;
}
// 灰度判定(参考 config/ai.php总开关 + canary 白名单
$ragEnabled = AI::ragEnabledFor((int) $user->userid);
return AI::createStreamKey($modelType, $modelName, $contextInput, $locale, $ragEnabled, $contextKey, $fd);
}
/**
@ -157,6 +178,247 @@ class AssistantController extends AbstractController
return $dotProduct / $denominator;
}
/**
* @api {post} api/assistant/log/search 记录帮助知识库检索日志
*
* @apiDescription 需要token身份AI 插件透传用户 token 服务端回调)。记录一次 search_help_docs 检索,用于分析检索质量、反哺 ai-kb 内容迭代
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName log__search
*
* @apiParam {String} query 检索query
* @apiParam {String} [locale] 语种 zh|en
* @apiParam {String} [source] 来源 chat|invoke
* @apiParam {String} [context_key] 上下文标识
* @apiParam {Number} [dialog_id] 对话ID
* @apiParam {Array} [source_ids] 命中source id列表
* @apiParam {Number} [top_score] 最高相似度
* @apiParam {Number} [result_count] 命中数量
* @apiParam {Number} [duration_ms] 检索耗时毫秒
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
*/
public function log__search()
{
$user = User::auth();
$query = mb_substr(trim(Request::input('query', '')), 0, 500);
$locale = trim(Request::input('locale', ''));
$source = trim(Request::input('source', ''));
$contextKey = mb_substr(trim(Request::input('context_key', '')), 0, 191);
$dialogId = intval(Request::input('dialog_id', 0));
$sourceIds = Request::input('source_ids', []);
$topScore = floatval(Request::input('top_score', 0));
$resultCount = intval(Request::input('result_count', 0));
$durationMs = intval(Request::input('duration_ms', 0));
if ($query === '') {
return Base::retError('参数错误');
}
if (!in_array($source, ['chat', 'invoke'])) {
$source = '';
}
if (!is_array($sourceIds)) {
$sourceIds = [];
}
$log = AiAssistantSearchLog::createInstance([
'userid' => $user->userid,
'dialog_id' => max(0, $dialogId),
'context_key' => $contextKey,
'source' => $source,
'query' => $query,
'locale' => in_array($locale, ['zh', 'en']) ? $locale : '',
'source_ids' => Base::array2json(array_slice(array_values($sourceIds), 0, 10)),
'top_score' => max(0, min(1, $topScore)),
'result_count' => max(0, $resultCount),
'duration_ms' => max(0, $durationMs),
'empty' => $resultCount > 0 ? 0 : 1,
]);
$log->save();
return Base::retSuccess('success');
}
/**
* @api {post} api/assistant/feedback/save 保存回复反馈
*
* @apiDescription 需要token身份。保存用户对一条 AI 回复的 👍/👎 反馈,同一条回复可改票(覆盖更新);传空 feedback 表示取消反馈(删除记录)
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName feedback__save
*
* @apiParam {String} session_key 场景分类key
* @apiParam {String} session_id 前端会话ID
* @apiParam {Number} local_id 回复条目localId
* @apiParam {String} feedback like|dislike空字符串表示取消反馈
* @apiParam {String} [prompt] 用户问题
* @apiParam {String} [answer] 回复摘录
* @apiParam {Array} [source_ids] 回复引用的kb source id列表
* @apiParam {String} [model] 模型名
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.feedback 已保存的反馈值
*/
public function feedback__save()
{
$user = User::auth();
$sessionKey = mb_substr(trim(Request::input('session_key', 'default')), 0, 100);
$sessionId = mb_substr(trim(Request::input('session_id', '')), 0, 100);
$localId = intval(Request::input('local_id', 0));
$feedback = trim(Request::input('feedback', ''));
$prompt = mb_substr(trim(Request::input('prompt', '')), 0, 1000);
$answer = mb_substr(trim(Request::input('answer', '')), 0, 2000);
$sourceIds = Request::input('source_ids', []);
$model = mb_substr(trim(Request::input('model', '')), 0, 100);
if (empty($sessionId) || $localId <= 0) {
return Base::retError('参数错误');
}
if (!in_array($feedback, ['', 'like', 'dislike'])) {
return Base::retError('反馈类型错误');
}
if (!is_array($sourceIds)) {
$sourceIds = [];
}
$exist = AiAssistantFeedback::where('userid', $user->userid)
->where('session_key', $sessionKey)
->where('session_id', $sessionId)
->where('local_id', $localId)
->first();
// 空反馈表示取消:删除已有记录
if ($feedback === '') {
$exist?->delete();
return Base::retSuccess('success', [
'feedback' => '',
]);
}
$row = AiAssistantFeedback::createInstance([
'userid' => $user->userid,
'session_key' => $sessionKey,
'session_id' => $sessionId,
'local_id' => $localId,
'feedback' => $feedback,
'prompt' => $prompt,
'answer' => $answer,
'answer_digest' => md5($answer),
'source_ids' => Base::array2json(array_slice(array_values($sourceIds), 0, 10)),
'model' => $model,
], $exist?->id);
$row->save();
return Base::retSuccess('success', [
'feedback' => $feedback,
]);
}
/**
* @api {post} api/assistant/operation/dispatch 派发页面操作
*
* @apiDescription 需要token身份。通过用户常驻 WebSocket/ws向其浏览器派发一次页面操作获取页面上下文 / 执行动作 / 操作元素),由前端 AI 助手执行后经 operationResult 回传,结果写入缓存供 operation/result 轮询取走。复用主程序 /ws无需为页面操作另开 WebSocket。
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName operation__dispatch
*
* @apiParam {Number} fd 目标会话 fd须为当前用户在线的 WebSocket 连接)
* @apiParam {String} action 操作类型,如 get_page_context|execute_action|execute_element_action
* @apiParam {Object} [payload] 操作参数
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.requestId 本次操作的请求ID用于轮询 operation/result
*/
public function operation__dispatch()
{
$user = User::auth();
$fd = intval(Base::headerOrInput('fd'));
$action = trim(Request::input('action', ''));
$payload = Request::input('payload', []);
if ($fd <= 0 || $action === '') {
return Base::retError('参数错误');
}
if (!is_array($payload)) {
$payload = [];
}
// fd 归属校验:在表即在线,归属即本人
$ownerId = WebSocket::whereFd($fd)->value('userid');
if (intval($ownerId) !== intval($user->userid)) {
return Base::retError('会话不存在或无权限');
}
$requestId = Str::random(24);
// 精确推送到该 fd不补发离线消息
PushTask::push([
'fd' => $fd,
'msg' => [
'type' => 'operation',
'data' => [
'requestId' => $requestId,
'action' => $action,
'payload' => $payload,
],
],
], false);
return Base::retSuccess('success', [
'requestId' => $requestId,
]);
}
/**
* @api {get} api/assistant/operation/result 取页面操作结果
*
* @apiDescription 需要token身份。轮询取走 operation/dispatch 派发的一次页面操作结果(取走即删);未回传时返回 status=pending。
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName operation__result
*
* @apiParam {String} request_id 操作请求ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.status ready|pending
*/
public function operation__result()
{
$user = User::auth();
$requestId = trim(Base::headerOrInput('request_id'));
if ($requestId === '') {
return Base::retError('参数错误');
}
$row = Cache::get("ai_op_result:{$requestId}");
if (!is_array($row)) {
return Base::retSuccess('success', ['status' => 'pending']);
}
// 命中后校验归属再取走,避免越权读取他人结果
if (intval($row['userid']) !== intval($user->userid)) {
return Base::retError('无权限');
}
Cache::forget("ai_op_result:{$requestId}");
return Base::retSuccess('success', [
'status' => 'ready',
'success' => !empty($row['success']),
'result' => $row['result'] ?? null,
'error' => $row['error'] ?? null,
]);
}
/**
* 获取会话列表
*/

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Base;
use App\Module\DashboardTeam;
use Request;
/**
* @apiDefine dashboard
*
* 仪表盘
*/
class DashboardController extends AbstractController
{
/**
* @api {get} api/dashboard/team/stats 负责人视角统计
*
* @apiDescription 需要token身份。返回所选管理部门含下级部门的团队任务统计。
* @apiVersion 1.0.0
* @apiGroup dashboard
* @apiName team__stats
*
* @apiParam {String} [department_owner_ids] 所选管理部门ID逗号分隔不传表示全部
* @apiParam {Number} [refresh] 1 时主动刷新当前统计缓存
*
* @apiSuccess {Number} ret 返回状态码
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 团队统计数据
*/
public function team__stats()
{
$user = User::auth();
$context = DashboardTeam::context($user, Request::input('department_owner_ids'));
return Base::retSuccess('success', DashboardTeam::stats($context, intval(Request::input('refresh')) === 1));
}
/**
* @api {get} api/dashboard/team/tasks 负责人视角任务列表
*
* @apiDescription 需要token身份。按关注类型、成员或优先级分页返回团队任务。
* @apiVersion 1.0.0
* @apiGroup dashboard
* @apiName team__tasks
*
* @apiParam {String} [department_owner_ids] 所选管理部门ID逗号分隔不传表示全部
* @apiParam {String} [type] 任务类型uncompleted/overdue/soon/hi/noowner
* @apiParam {Number} [member_id] 成员ID传入后优先于 type
* @apiParam {Number} [level] 优先级;-1 表示未设置,传入后优先于 type
* @apiParam {Number} [page] 当前页
* @apiParam {Number} [pagesize] 每页数量默认20最大50
*
* @apiSuccess {Number} ret 返回状态码
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 分页任务数据
*/
public function team__tasks()
{
$user = User::auth();
$context = DashboardTeam::context($user, Request::input('department_owner_ids'));
$memberId = intval(Request::input('member_id'));
$levelValue = Request::input('level');
$level = $levelValue !== null && $levelValue !== '' ? intval($levelValue) : null;
$type = trim((string)Request::input('type'));
if ($memberId > 0) {
if (!in_array($memberId, $context['member_userids'], true)) {
return Base::retError('参数错误');
}
} elseif ($level !== null) {
if ($level !== -1 && !in_array($level, DashboardTeam::priorityLevels(), true)) {
return Base::retError('参数错误');
}
} elseif (!in_array($type, ['uncompleted', 'overdue', 'soon', 'hi', 'noowner'], true)) {
return Base::retError('参数错误');
}
return Base::retSuccess('success', DashboardTeam::tasks($context, [
'type' => $type,
'member_id' => $memberId,
'level' => $level,
]));
}
}

View File

@ -1257,6 +1257,51 @@ class DialogController extends AbstractController
return $result;
}
/**
* @api {post} api/dialog/msg/sendapprove 发送审批通知卡片
*
* @apiDescription 需要token身份。以「审批助手」机器人身份向指定用户发送审批模板卡片
* (由 approve 插件调用,卡片仅展示、不与旧审批系统有数据关联)。
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__sendapprove
*
* @apiParam {Number} to_userid 接收用户ID
* @apiParam {String} type 卡片类型approve_reviewer / approve_notifier / approve_submitter / approve_comment_notifier
* @apiParam {String} [action] 动作start / pass / refuse / withdraw按类型取用
* @apiParam {Number} [is_finished] 是否已结束0/1
* @apiParam {Object} data 卡片数据
* @apiParam {String} [title] 消息标题(会话列表预览用)
*/
public function msg__sendapprove()
{
$user = User::auth();
$toUserid = intval(Request::input('to_userid'));
$type = trim(Request::input('type'));
$action = trim(Request::input('action'));
$isFinished = intval(Request::input('is_finished'));
$data = Base::json2array(Request::input('data'));
$title = trim(Request::input('title'));
//
$allow = ['approve_reviewer', 'approve_notifier', 'approve_submitter', 'approve_comment_notifier'];
if ($toUserid <= 0 || !in_array($type, $allow)) {
return Base::retError('参数错误');
}
$botUser = User::botGetOrCreate('approval-alert');
$dialog = WebSocketDialog::checkUserDialog($botUser, $toUserid);
if (empty($dialog)) {
return Base::retError('无法创建对话');
}
$msgData = [
'type' => $type,
'action' => $action ?: null,
'is_finished' => $isFinished,
'data' => $data,
'title' => $title,
];
return WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', $msgData, $botUser->userid, false, false, true);
}
/**
* @api {post} api/dialog/msg/sendrecord 发送语音
*
@ -1670,6 +1715,7 @@ class DialogController extends AbstractController
if (!in_array($botType, [
'system-msg',
'task-alert',
'todo-alert',
'check-in',
'approval-alert',
'meeting-alert',
@ -1715,6 +1761,7 @@ class DialogController extends AbstractController
* @apiParam {String} text 消息内容
* @apiParam {String} [text_type=md] 消息格式md html
* @apiParam {String} [silence=no] 是否静默发送yes/no
* @apiParam {String} [nickname] 自定义发送者昵称最多20字留空则显示"AI 助手"
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -1729,6 +1776,7 @@ class DialogController extends AbstractController
$text = trim(Request::input('text'));
$text_type = strtolower(trim(Request::input('text_type'))) ?: 'md';
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
$nickname = trim(Request::input('nickname'));
$markdown = in_array($text_type, ['md', 'markdown']);
//
if (empty($dialog_id) && empty($task_id)) {
@ -1740,6 +1788,9 @@ class DialogController extends AbstractController
if (mb_strlen($text) > 200000) {
return Base::retError('消息内容最大不能超过200000字');
}
if (mb_strlen($nickname) > 20) {
return Base::retError('发送者昵称最多不能超过20字');
}
//
if ($dialog_id) {
// Direct dialog mode: verify user is a member
@ -1786,6 +1837,9 @@ class DialogController extends AbstractController
if ($markdown) {
$msgData['type'] = 'md';
}
if ($nickname !== '') {
$msgData['nickname'] = $nickname;
}
//
$result = WebSocketDialogMsg::sendMsg(
null,
@ -2116,6 +2170,9 @@ class DialogController extends AbstractController
$msg_id = intval(Request::input("msg_id"));
$force = intval(Request::input("force"));
$language = Base::inputOrHeader('language');
if (empty($language)) {
return Base::retError("参数错误");
}
$targetLanguage = Doo::getLanguages($language);
//
if (empty($targetLanguage)) {
@ -2571,7 +2628,8 @@ class DialogController extends AbstractController
} else {
$userids = is_array($userids) ? $userids : [];
}
return $msg->toggleTodoMsg($user->userid, $userids);
$remindAt = Request::exists('remind_at') ? (trim(Request::input('remind_at', '')) ?: null) : false;
return $msg->toggleTodoMsg($user->userid, $userids, $remindAt);
}
/**
@ -2604,6 +2662,64 @@ class DialogController extends AbstractController
return Base::retSuccess('success', $todo ?: []);
}
/**
* @api {post} api/dialog/msg/todoremind 设置/修改/取消待办提醒时间
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__todoremind
*
* @apiParam {Number} msg_id 消息ID
* @apiParam {Array} userids 目标成员ID组
* @apiParam {String} remind_at 提醒时间(空表示取消提醒)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__todoremind()
{
$user = User::auth();
//
$msg_id = intval(Request::input("msg_id"));
$userids = Request::input('userids');
$userids = is_array($userids) ? array_values(array_filter(array_map('intval', $userids))) : [];
$remindAt = trim(Request::input('remind_at', '')) ?: null;
//
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
if (empty($msg)) {
return Base::retError("消息不存在或已被删除");
}
if (in_array($msg->type, ['tag', 'todo', 'notice'])) {
return Base::retError('此消息不支持设待办');
}
$dialog = WebSocketDialog::checkDialog($msg->dialog_id);
//
if (empty($userids)) {
return Base::retError("请选择成员");
}
// 权限管控(与设/取消待办同一开关与放行规则)
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
$others = array_diff($userids, [$user->userid]);
if ($others && !$dialog->checkTodoOwnerPermission($user->userid)) {
return Base::retError('仅群主、项目/任务负责人或系统管理员可设置或取消他人待办');
}
}
//
$msg->setTodoRemind($userids, $remindAt);
//
$upData = [
'id' => $msg->id,
'todo' => $msg->todo,
'todo_done' => $msg->isTodoDone(true),
'dialog_id' => $msg->dialog_id,
];
$dialog->pushMsg('update', $upData);
//
return Base::retSuccess($remindAt ? '设置成功' : '取消成功', $upData);
}
/**
* @api {get} api/dialog/msg/done 完成待办
*
@ -2848,7 +2964,9 @@ class DialogController extends AbstractController
$data['avatar'] = Base::fillUrl($array['avatar'] = $avatar);
}
$existName = Request::exists('chat_name') || Request::exists('name');
if ($existName && $dialog->group_type === 'user') {
// 个人群组群主可改名;全员群仅系统管理员可改名
$canEditName = $dialog->group_type === 'user' || ($dialog->group_type === 'all' && $admin === 1);
if ($existName && $canEditName) {
$chatName = trim(Request::input('chat_name') ?: Request::input('name'));
if (mb_strlen($chatName) < 2) {
return Base::retError('群名称至少2个字');
@ -2972,7 +3090,7 @@ class DialogController extends AbstractController
*/
public function group__transfer()
{
if (!Base::is_internal_ip(Base::getIp()) || Request::input("key") !== env('APP_KEY')) {
if (!Base::is_internal_ip(Base::getIp()) || Request::input("key") !== config('app.key')) {
$user = User::auth();
}
//
@ -3282,7 +3400,7 @@ class DialogController extends AbstractController
*/
public function okr__push()
{
if (!Base::is_internal_ip(Base::getIp()) || Request::input("key") !== env('APP_KEY')) {
if (!Base::is_internal_ip(Base::getIp()) || Request::input("key") !== config('app.key')) {
User::auth();
}
$text = trim(Request::input('text'));

View File

@ -41,6 +41,7 @@ class FileController extends AbstractController
* @apiName lists
*
* @apiParam {Number} [pid] 父级ID
* @apiParam {String} [scope] 板块范围根目录生效mine=我的文件、shared=共享文件、all=全部(默认)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -51,8 +52,12 @@ class FileController extends AbstractController
$user = User::auth();
//
$pid = intval(Request::input('pid'));
$scope = Request::input('scope', 'all');
if (!in_array($scope, ['mine', 'shared', 'all'])) {
$scope = 'all';
}
//
return Base::retSuccess('success', (new File)->getFileList($user, $pid));
return Base::retSuccess('success', (new File)->getFileList($user, $pid, 'all', true, $scope));
}
/**
@ -737,7 +742,10 @@ class FileController extends AbstractController
File::isNeedInstallApp('office');
//
$config = Request::input('config');
$token = \Firebase\JWT\JWT::encode($config, env('APP_KEY') ,'HS256');
if (!is_array($config)) {
return Base::retError('参数错误');
}
$token = \Firebase\JWT\JWT::encode($config, config('app.key') ,'HS256');
return Base::retSuccess('成功', [
'token' => $token
]);

View File

@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Base;
use App\Module\OnlineLicense;
use Request;
/**
* 在线授权客户端(与 SystemController::license 的离线粘贴并存)。
*
* 动态路由routes/web.php
* api/license/email/send -> email__send()
* api/license/login -> login()
* api/license/login/confirm -> login__confirm()
* api/license/trial -> trial()
* api/license/status -> status()
* api/license/refresh -> refresh()
* api/license/logout -> logout()
*/
class LicenseController extends AbstractController
{
/**
* 发送邮箱验证码(登录与试用共用)
*/
public function email__send()
{
User::auth('admin');
$email = trim(Request::input('email'));
if ($email === '') {
return Base::retError('请输入邮箱');
}
$masked = OnlineLicense::emailSend($email);
return Base::retSuccess('验证码已发送', ['email' => $masked]);
}
/**
* 邮箱 + 验证码登录并签发在线授权
*/
public function login()
{
User::auth('admin');
$email = trim(Request::input('email'));
$code = trim(Request::input('code'));
if ($email === '' || $code === '') {
return Base::retError('请输入邮箱和验证码');
}
$data = OnlineLicense::login($email, $code);
return Base::retSuccess('授权成功', $data);
}
/**
* 多条可用授权时,用户选定后确认签发(复用验证码)
*/
public function login__confirm()
{
User::auth('admin');
$email = trim(Request::input('email'));
$code = trim(Request::input('code'));
$entitlementId = (int)Request::input('entitlement_id');
if ($email === '' || $code === '') {
return Base::retError('请输入邮箱和验证码');
}
if ($entitlementId <= 0) {
return Base::retError('请选择要使用的授权');
}
$data = OnlineLicense::loginConfirm($email, $code, $entitlementId);
return Base::retSuccess('授权成功', $data);
}
/**
* 邮箱 + 验证码申请试用并签发
*/
public function trial()
{
User::auth('admin');
$email = trim(Request::input('email'));
$code = trim(Request::input('code'));
if ($email === '' || $code === '') {
return Base::retError('请输入邮箱和验证码');
}
$data = OnlineLicense::trial($email, $code);
return Base::retSuccess('试用已开通', $data);
}
/**
* 当前在线授权状态
*/
public function status()
{
User::auth('admin');
return Base::retSuccess('success', OnlineLicense::status());
}
/**
* 进入授权页时的静默刷新:服务可达则更新授权数据,网络失败则不更新、不提示。
*/
public function refresh()
{
User::auth('admin');
OnlineLicense::refresh();
return Base::retSuccess('success', OnlineLicense::status());
}
/**
* 退出在线授权(释放座位 + 回落默认)
*/
public function logout()
{
User::auth('admin');
OnlineLicense::logout();
return Base::retSuccess('已退出在线授权');
}
}

View File

@ -5,7 +5,6 @@ namespace App\Http\Controllers\Api;
use Request;
use Redirect;
use Response;
use Madzipper;
use Carbon\Carbon;
use App\Module\Down;
use App\Module\Doo;
@ -1490,6 +1489,214 @@ class ProjectController extends AbstractController
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/project/user/projects 会员参与的项目列表
*
* @apiDescription 需要token身份。用于会员卡片查看「该会员参与的项目」。
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
* @apiVersion 1.0.0
* @apiGroup project
* @apiName user__projects
*
* @apiParam {Number} userid 目标会员ID
* @apiParam {String} [archived] 是否归档all/yes/no默认no
* @apiParam {Object} [keys] 搜索条件keys.name 项目名称)
* @apiParam {Number} [page] 当前页默认1
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function user__projects()
{
$viewer = User::auth();
$targetId = intval(Request::input('userid'));
$context = UserDepartment::userWorksContext($viewer, $targetId);
if (!$context['allowed']) {
return Base::retError('没有查看权限');
}
$readonly = !$context['is_self'] && !$context['is_admin'];
//
$archived = Request::input('archived', 'no');
$keys = Request::input('keys');
//
$builder = Project::select(['projects.*', 'project_users.owner', 'project_users.top_at', 'project_users.sort'])
->join('project_users', function ($join) use ($targetId) {
$join->on('projects.id', '=', 'project_users.project_id')
->where('project_users.userid', '=', $targetId);
});
// 部门负责人视角:限定在允许可见的项目集合内
if ($readonly) {
$builder->whereIn('projects.id', $context['project_ids'] ?: [0]);
}
//
if ($archived == 'yes') {
$builder->whereNotNull('projects.archived_at');
} elseif ($archived == 'no') {
$builder->whereNull('projects.archived_at');
}
if (is_array($keys) && !empty($keys['name'])) {
$builder->where('projects.name', 'like', "%{$keys['name']}%");
}
//
$list = $builder
->orderByDesc('project_users.top_at')
->orderBy('project_users.sort')
->orderByDesc('projects.id')
->paginate(Base::getPaginate(100, 50));
$list->transform(function (Project $project) use ($targetId, $readonly) {
$array = $project->toArray();
$array['department_readonly'] = $readonly;
$array = array_merge($array, $project->getTaskStatistics($targetId));
return $array;
});
//
return Base::retSuccess('success', $list);
}
/**
* @api {get} api/project/user/tasks 会员参与的任务列表
*
* @apiDescription 需要token身份。用于会员卡片查看「该会员参与的任务」负责的 / 协作的)。
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
* @apiVersion 1.0.0
* @apiGroup project
* @apiName user__tasks
*
* @apiParam {Number} userid 目标会员ID
* @apiParam {Number} [owner] 任务身份筛选1=负责的0=协作的,不传=全部
* @apiParam {Number} [project_id] 仅查询指定项目
* @apiParam {Object} [keys] 搜索条件keys.name 任务名称keys.status completed/uncompleted
* @apiParam {Number} [page] 当前页默认1
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function user__tasks()
{
$viewer = User::auth();
$targetId = intval(Request::input('userid'));
$context = UserDepartment::userWorksContext($viewer, $targetId);
if (!$context['allowed']) {
return Base::retError('没有查看权限');
}
$readonly = !$context['is_self'] && !$context['is_admin'];
//
$owner = Request::input('owner');
$owner = is_numeric($owner) ? intval($owner) : null;
$project_id = intval(Request::input('project_id'));
$keys = Request::input('keys');
$keys = is_array($keys) ? $keys : [];
//
$builder = ProjectTask::with(['taskUser', 'taskTag', 'project:id,name'])
->select(['project_tasks.*', 'project_task_users.owner'])
->join('project_task_users', function ($join) use ($targetId) {
$join->on('project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', '=', $targetId);
});
if ($owner !== null) {
$builder->where('project_task_users.owner', $owner);
}
// 部门负责人视角:限定可见项目集合,且仅"全员可见"(visibility=1)的任务(与 findForDepartmentView 一致,避免列出打不开的任务)
if ($readonly) {
$builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]);
$builder->where('project_tasks.visibility', 1);
}
if ($project_id > 0) {
$builder->where('project_tasks.project_id', $project_id);
}
if (!empty($keys['name'])) {
$builder->where(function ($query) use ($keys) {
$query->where('project_tasks.name', 'like', "%{$keys['name']}%")
->orWhere('project_tasks.desc', 'like', "%{$keys['name']}%");
});
}
if (!empty($keys['status'])) {
if ($keys['status'] == 'completed') {
$builder->whereNotNull('project_tasks.complete_at');
} elseif ($keys['status'] == 'uncompleted') {
$builder->whereNull('project_tasks.complete_at');
}
}
$builder->whereNull('project_tasks.archived_at');
//
$list = $builder->orderByDesc('project_tasks.id')->paginate(Base::getPaginate(100, 50));
$list->transform(function (ProjectTask $task) use ($readonly) {
$task->setAppends(['today', 'overdue']);
$array = $task->toArray();
$array['project_name'] = $array['project']['name'] ?? '';
$array['department_readonly'] = $readonly;
unset($array['project']);
return $array;
});
//
return Base::retSuccess('success', $list);
}
/**
* @api {get} api/project/user/counts 会员参与的项目/任务数量
*
* @apiDescription 需要token身份。用于会员卡片「项目与任务」弹窗的 Tab 角标,仅返回数量(轻量)。
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
* @apiVersion 1.0.0
* @apiGroup project
* @apiName user__counts
*
* @apiParam {Number} userid 目标会员ID
* @apiParam {Number} [owner] 任务身份筛选1=负责的0=协作的,不传=全部(仅影响任务数量)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data {project, todo, done}
*/
public function user__counts()
{
$viewer = User::auth();
$targetId = intval(Request::input('userid'));
$context = UserDepartment::userWorksContext($viewer, $targetId);
if (!$context['allowed']) {
return Base::retError('没有查看权限');
}
$readonly = !$context['is_self'] && !$context['is_admin'];
$owner = Request::input('owner');
$owner = is_numeric($owner) ? intval($owner) : null;
//
$projectBuilder = Project::join('project_users', function ($join) use ($targetId) {
$join->on('projects.id', '=', 'project_users.project_id')
->where('project_users.userid', '=', $targetId);
})
->whereNull('projects.archived_at');
if ($readonly) {
$projectBuilder->whereIn('projects.id', $context['project_ids'] ?: [0]);
}
$projectCount = $projectBuilder->distinct()->count('projects.id');
//
$taskBuilder = function () use ($targetId, $owner, $readonly, $context) {
$builder = ProjectTask::join('project_task_users', function ($join) use ($targetId) {
$join->on('project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', '=', $targetId);
})
->whereNull('project_tasks.archived_at');
if ($owner !== null) {
$builder->where('project_task_users.owner', $owner);
}
if ($readonly) {
$builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]);
$builder->where('project_tasks.visibility', 1);
}
return $builder;
};
$todoCount = $taskBuilder()->whereNull('project_tasks.complete_at')->count();
$doneCount = $taskBuilder()->whereNotNull('project_tasks.complete_at')->count();
//
return Base::retSuccess('success', [
'project' => $projectCount,
'todo' => $todoCount,
'done' => $doneCount,
]);
}
/**
* @api {get} api/project/task/easylists 任务列表-简单的
*
@ -1795,7 +2002,7 @@ class ProjectController extends AbstractController
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
Base::zipAddFiles($zipPath, $xlsPath);
} catch (\Throwable) {
}
//
@ -1963,7 +2170,7 @@ class ProjectController extends AbstractController
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
Base::zipAddFiles($zipPath, $xlsPath);
} catch (\Throwable) {
}
//
@ -2064,9 +2271,12 @@ class ProjectController extends AbstractController
}
//
$data = $task->toArray();
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
$data['project_name'] = $task->project?->name;
$taskProject = Project::withTrashed()->find($task->project_id);
$data['project_name'] = $taskProject?->name;
$data['project_deleted'] = (!$taskProject || $taskProject->trashed()) ? 1 : 0;
$data['project_archived'] = $taskProject?->archived_at ? 1 : 0;
$data['column_name'] = $task->projectColumn?->name;
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
$data['visibility_appointor'] = $task->visibility == 1 ? [0] : ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid');
return Base::retSuccess('success', $data);
}
@ -2998,8 +3208,27 @@ class ProjectController extends AbstractController
//
$task = ProjectTask::userTask($task_id, null, $type !== 'recovery');
//
$project = Project::userProject($task->project_id);
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_REMOVE, $task);
try {
$project = Project::userProject($task->project_id);
ProjectPermission::userTaskPermission($project, ProjectPermission::TASK_REMOVE, $task);
} catch (\Throwable $e) {
if ($type == 'recovery') {
throw $e;
}
// 项目已删除/已归档时放行删除操作(限:管理员、原项目负责人、任务负责人/协助人/创建人)
$project = Project::withTrashed()->find($task->project_id);
$projectInvalid = !$project || $project->trashed() || $project->archived_at;
if (!$projectInvalid) {
throw $e;
}
$isProjectOwner = ProjectUser::whereProjectId($task->project_id)
->whereUserid(Doo::userId())
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->exists();
if (!$isProjectOwner && !$task->permission(3)) {
throw new ApiException('仅项目负责人或任务相关成员删除');
}
}
//
if ($type == 'recovery') {
$task->restoreTask();

View File

@ -9,21 +9,22 @@ use App\Module\AI;
use App\Module\Down;
use Request;
use Response;
use Madzipper;
use Carbon\Carbon;
use App\Module\Doo;
use App\Models\User;
use App\Module\Base;
use App\Module\OnlineLicense;
use App\Module\Timer;
use App\Models\Setting;
use LdapRecord\Container;
use App\Module\BillExport;
use Guanguans\Notify\Factory;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
use App\Models\UserCheckinRecord;
use App\Module\Apps;
use App\Module\BillMultipleExport;
use LdapRecord\LdapRecordException;
use Guanguans\Notify\Messages\EmailMessage;
use Swoole\Coroutine;
/**
@ -54,7 +55,7 @@ class SystemController extends AbstractController
{
$type = trim(Request::input('type'));
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
return Base::retError('当前环境禁止修改');
}
Base::checkClientVersion('0.41.11');
@ -69,6 +70,8 @@ class SystemController extends AbstractController
'login_code',
'password_policy',
'project_invite',
'project_add_permission',
'project_add_userids',
'chat_information',
'anon_message',
'convert_video',
@ -95,6 +98,7 @@ class SystemController extends AbstractController
'unclaimed_task_reminder_time',
'task_ai_auto_analyze',
'department_owner_project_view',
'todo_set_permission',
])) {
unset($all[$key]);
}
@ -107,7 +111,7 @@ class SystemController extends AbstractController
return Base::retError('自动归档时间不可大于100天');
}
}
if ($all['system_alias'] == env('APP_NAME')) {
if ($all['system_alias'] == config('app.name')) {
$all['system_alias'] = '';
}
if ($all['system_welcome'] == '欢迎您,{username}') {
@ -142,6 +146,7 @@ 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';
@ -149,9 +154,13 @@ class SystemController extends AbstractController
$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['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'open';
$setting['server_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion();
// 指定人员名单仅管理员可见
if ($type != 'all' && $type != 'save') {
unset($setting['project_add_userids']);
}
//
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
@ -176,7 +185,7 @@ class SystemController extends AbstractController
//
$type = trim(Request::input('type'));
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
return Base::retError('当前环境禁止修改');
}
$user->identity('admin');
@ -246,7 +255,7 @@ class SystemController extends AbstractController
//
$type = trim(Request::input('type'));
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
return Base::retError('当前环境禁止修改');
}
$all = Request::input();
@ -270,7 +279,7 @@ class SystemController extends AbstractController
}
//
$setting['open'] = $setting['open'] ?: 'close';
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
$setting['appid'] = substr($setting['appid'], 0, 4) . str_repeat('*', strlen($setting['appid']) - 8) . substr($setting['appid'], -4);
$setting['app_certificate'] = substr($setting['app_certificate'], 0, 4) . str_repeat('*', strlen($setting['app_certificate']) - 8) . substr($setting['app_certificate'], -4);
$setting['api_key'] = substr($setting['api_key'], 0, 4) . str_repeat('*', strlen($setting['api_key']) - 8) . substr($setting['api_key'], -4);
@ -316,7 +325,7 @@ class SystemController extends AbstractController
$filter = trim(Request::input('filter'));
$setting = Base::setting('aibotSetting');
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
return Base::retError('当前环境禁止修改');
}
Base::checkClientVersion('0.41.11');
@ -334,11 +343,15 @@ class SystemController extends AbstractController
}, ARRAY_FILTER_USE_BOTH);
}
//
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
foreach ($setting as $key => $item) {
if (empty($item)) {
continue;
}
// dooai_key 是官方网关 token需原样返回供鉴权
if ($key === 'dooai_key') {
continue;
}
if (str_ends_with($key, '_key') || str_ends_with($key, '_secret')) {
$setting[$key] = substr($item, 0, 4) . str_repeat('*', strlen($item) - 8) . substr($item, -4);
}
@ -388,7 +401,7 @@ class SystemController extends AbstractController
//
$type = trim(Request::input('type'));
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
return Base::retError('当前环境禁止修改');
}
$all = Request::input();
@ -537,7 +550,7 @@ class SystemController extends AbstractController
//
$type = trim(Request::input('type'));
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
return Base::retError('当前环境禁止修改');
}
$all = Request::input();
@ -602,7 +615,7 @@ class SystemController extends AbstractController
return Base::retError($e->getMessage() ?: "验证失败:未知错误", config("ldap.connections.default"));
}
} elseif ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
return Base::retError('当前环境禁止修改');
}
$all = Base::newTrim(Request::input());
@ -654,7 +667,7 @@ class SystemController extends AbstractController
//
$type = trim(Request::input('type'));
if ($type == 'save') {
if (env("SYSTEM_SETTING") == 'disabled') {
if (config('dootask.system_setting') == 'disabled') {
return Base::retError('当前环境禁止修改');
}
$all = Base::newTrim(Request::input());
@ -687,8 +700,8 @@ class SystemController extends AbstractController
*/
public function demo()
{
$demo_account = env('DEMO_ACCOUNT');
$demo_password = env('DEMO_PASSWORD');
$demo_account = config('dootask.demo_account');
$demo_password = config('dootask.demo_password');
if (empty($demo_account) || empty($demo_password)) {
return Base::retError('No demo account');
}
@ -765,14 +778,14 @@ class SystemController extends AbstractController
}
$apps = Setting::normalizeCustomMicroApps($list);
$setting = Base::setting('microapp_menu', $apps);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
$setting = Setting::formatCustomMicroAppsForResponse($setting, true);
} else {
$setting = Base::setting('microapp_menu');
if (!is_array($setting)) {
$setting = [];
}
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
$setting = Setting::formatCustomMicroAppsForResponse($setting, $user && $user->isAdmin());
}
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}
@ -848,7 +861,17 @@ class SystemController extends AbstractController
$type = trim(Request::input('type'));
if ($type == 'save') {
$license = Request::input('license');
// 解密失败sn 为空)视为无效 license
$decoded = Doo::licenseDecode($license);
if ((string)($decoded['sn'] ?? '') === '') {
return Base::retError('LICENSE 格式错误');
}
if ($err = Doo::licenseBindingError($decoded)) {
return Base::retError($err);
}
Doo::licenseSave($license);
// 离线/在线互斥:保存离线 license 即退出在线模式(尽力释放座位+清在线标志,不删除刚写入的文件)
OnlineLicense::switchToOffline();
}
//
$data = [
@ -860,8 +883,8 @@ class SystemController extends AbstractController
'user_count' => User::whereBot(0)->whereNull('disable_at')->count(),
'error' => []
];
if ($data['info']['people'] > 3) {
// 小于3人的License不检查
if ($data['info']['people'] == 0 || $data['info']['people'] > 3) {
// 付费档才检查 SN/MAC
if ($data['info']['sn'] != $data['doo_sn']) {
$data['error'][] = '终端SN与License不匹配';
}
@ -884,6 +907,11 @@ class SystemController extends AbstractController
if ($data['info']['expired_at'] && strtotime($data['info']['expired_at']) <= Timer::time()) {
$data['error'][] = '终端License已过期';
}
// 在线授权:把状态机提醒并入 error[]dashboard 警告条与本页错误展示自动复用),并附在线状态
foreach (OnlineLicense::stageMessages() as $msg) {
$data['error'][] = $msg;
}
$data['online'] = OnlineLicense::status();
//
if ($type === 'error') {
$data = [
@ -909,7 +937,7 @@ class SystemController extends AbstractController
*/
public function get__info()
{
if (Request::input("key") !== env('APP_KEY')) {
if (Request::input("key") !== config('app.key')) {
return [];
}
return Base::retSuccess('success', [
@ -1225,21 +1253,19 @@ class SystemController extends AbstractController
}
try {
Setting::validateAddr($all['to'], function($to) use ($all) {
Factory::mailer()
->setDsn("smtp://{$all['account']}:{$all['password']}@{$all['smtp_server']}:{$all['port']}?verify_peer=0")
->setMessage(EmailMessage::create()
->from(Base::settingFind('system', 'system_alias', 'Task') . " <{$all['account']}>")
->to($to)
->subject('Mail sending test')
->html('<p>' . Doo::translate('收到此电子邮件意味着您的邮箱配置正确。') . '</p>'))
->send();
$mailer = new Mailer(Transport::fromDsn("smtp://{$all['account']}:{$all['password']}@{$all['smtp_server']}:{$all['port']}?verify_peer=0"));
$mailer->send((new Email())
->from(Base::settingFind('system', 'system_alias', 'Task') . " <{$all['account']}>")
->to($to)
->subject('Mail sending test')
->html('<p>' . Doo::translate('收到此电子邮件意味着您的邮箱配置正确。') . '</p>'));
}, function () {
throw new \Exception("收件人地址错误或已被忽略");
});
return Base::retSuccess('成功发送');
} catch (\Throwable $e) {
// 一般是请求超时
if (str_contains($e->getMessage(), "Timed Out")) {
if (stripos($e->getMessage(), "timed out") !== false) {
return Base::retError("邮件发送超时,请检查邮箱配置是否正确");
} elseif ($e->getCode() === 550) {
return Base::retError('邮件内容被拒绝,请检查邮箱是否开启接收功能');
@ -1437,7 +1463,7 @@ class SystemController extends AbstractController
Base::deleteDirAndFile($zipPath, true);
}
try {
Madzipper::make($zipPath)->add($xlsPath)->close();
Base::zipAddFiles($zipPath, $xlsPath);
} catch (\Throwable) {
}
//

View File

@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Base;
use App\Module\ChunkUpload;
use Request;
/**
* 分片上传统一入口。
*
* 动态路由routes/web.php
* api/upload/init -> init() 启动一个上传会话(含秒传 / 续传命中)
* api/upload/chunk -> chunk() 接收一个分片
* api/upload/merge -> merge() 合并分片并按 scene 入库
*
* 小文件(<10MB不走此接口前端直接调用各 scene 的老接口(透明降级)。
*/
class UploadController extends AbstractController
{
/**
* @api {post} api/upload/init 启动上传会话
*
* @apiDescription 提交文件 hash/size/name/scene/scene_params返回 upload_id 与已收分片列表;
* 若同用户曾上传过同 hash 文件,直接返回 done=true(秒传)。
* @apiGroup upload
* @apiName init
*
* @apiParam {String} hash 文件 md5小写 32 字符)
* @apiParam {Number} size 文件大小(字节)
* @apiParam {String} name 原始文件名(含扩展名)
* @apiParam {String} scene 场景file_cabinet | dialog_file | image | generic_file
* @apiParam {Object} [scene_params] 场景参数(如 file_cabinet pid/cover/webkit_relative_path
*
* @apiSuccess {Number} ret
* @apiSuccess {Object} data done / upload_id / chunk_size / chunk_count / received 或秒传 file
*/
public function init()
{
$user = User::auth();
$result = ChunkUpload::start($user, [
'hash' => Request::input('hash', ''),
'size' => Request::input('size', 0),
'name' => Request::input('name', ''),
'scene' => Request::input('scene', ''),
'scene_params' => Request::input('scene_params', []),
]);
return $result;
}
/**
* @api {post} api/upload/chunk 上传一个分片
*
* @apiDescription multipart 请求blob 字段为分片数据。
* @apiGroup upload
* @apiName chunk
*
* @apiParam {String} upload_id init 返回的 upload_id
* @apiParam {Number} index 分片序号0-based
* @apiParam {File} blob 分片数据
*
* @apiSuccess {Number} ret
* @apiSuccess {Object} data upload_id 与最新 received[]
*/
public function chunk()
{
$user = User::auth();
$uploadId = trim(Request::input('upload_id', ''));
$index = intval(Request::input('index', -1));
$blob = Request::file('blob');
if ($uploadId === '') {
return Base::retError('upload_id 不能为空');
}
return ChunkUpload::receive($user, $uploadId, $index, $blob);
}
/**
* @api {post} api/upload/merge 合并分片并入库
*
* @apiDescription 全部分片到齐后调用;后端按 scene 路由到对应入库逻辑,返回与该 scene 老接口对齐的数据。
* @apiGroup upload
* @apiName merge
*
* @apiParam {String} upload_id init 返回的 upload_id
*
* @apiSuccess {Number} ret
* @apiSuccess {Object} data scene 入库返回数据
*/
public function merge()
{
$user = User::auth();
$uploadId = trim(Request::input('upload_id', ''));
if ($uploadId === '') {
return Base::retError('upload_id 不能为空');
}
try {
return ChunkUpload::merge($user, $uploadId);
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
return Base::retError('合并繁忙,请稍后再试');
}
return Base::retError($e->getMessage());
}
}
/**
* @api {post} api/upload/cancel 取消上传会话
*
* @apiDescription 调用方主动放弃一次分片上传时调用:删除 Redis meta/chunks/hash 索引并清掉分片目录。
* 会话已过期或归属其他用户时静默成功,避免给前端取消按钮回写"取消失败"
* @apiGroup upload
* @apiName cancel
*
* @apiParam {String} upload_id init 返回的 upload_id
*
* @apiSuccess {Number} ret
*/
public function cancel()
{
$user = User::auth();
$uploadId = trim(Request::input('upload_id', ''));
if ($uploadId === '') {
return Base::retError('upload_id 不能为空');
}
ChunkUpload::cancelByUser($user, $uploadId);
return Base::retSuccess('已取消');
}
}

View File

@ -41,6 +41,9 @@ 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
@ -273,6 +276,7 @@ class UsersController extends AbstractController
public function login__codejson()
{
$captcha = Captcha::create('default', true);
$captcha['img'] = (string)$captcha['img'];
return Base::retSuccess('请求成功', $captcha);
}
@ -319,7 +323,7 @@ class UsersController extends AbstractController
$expiredAtCarbon = $expiredAt ? Carbon::parse($expiredAt) : null;
$data = [
'expired_at' => $expiredAtCarbon?->toDateTimeString(),
'remaining_seconds' => $expiredAtCarbon ? Carbon::now()->diffInSeconds($expiredAtCarbon, false) : null,
'remaining_seconds' => $expiredAtCarbon ? (int)Carbon::now()->diffInSeconds($expiredAtCarbon, false) : null,
'expired' => $expired,
'server_time' => Carbon::now()->toDateTimeString(),
];
@ -414,7 +418,7 @@ class UsersController extends AbstractController
public function info__managed_departments()
{
$user = User::auth();
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
return Base::retSuccess('success', []);
}
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
@ -881,7 +885,8 @@ class UsersController extends AbstractController
*/
public function extra()
{
$user = User::auth();
$viewer = User::auth();
$user = $viewer;
//
$userid = intval(Request::input('userid'));
if ($userid <= 0) {
@ -916,6 +921,8 @@ class UsersController extends AbstractController
$tagMeta = UserTag::listWithMeta($userid, $user);
$worksContext = UserDepartment::userWorksContext($viewer, $userid);
$data = [
'userid' => $userid,
'birthday' => $birthday,
@ -923,6 +930,7 @@ class UsersController extends AbstractController
'introduction' => $introduction,
'personal_tags' => $tagMeta['top'],
'personal_tags_total' => $tagMeta['total'],
'works_visible' => $worksContext['allowed'],
];
return Base::retSuccess('success', $data);
@ -1091,6 +1099,8 @@ class UsersController extends AbstractController
* - clearadmin 取消管理员
* - settemp 设为临时帐号
* - cleartemp 取消临时身份(取消临时帐号)
* - setverity 标记邮箱为已认证
* - clearverity 标记邮箱为未认证
* - checkin_macs 修改自动签到mac地址需要参数 checkin_macs
* - checkin_face 修改签到人脸图片(需要参数 checkin_face
* - department 修改部门(需要参数 department
@ -1154,6 +1164,16 @@ class UsersController extends AbstractController
$upArray['identity'] = array_diff($userInfo->identity, ['temp']);
break;
case 'setverity':
$msg = '设置成功';
$upArray['email_verity'] = 1;
break;
case 'clearverity':
$msg = '取消成功';
$upArray['email_verity'] = 0;
break;
case 'checkin_macs':
$list = is_array($data['checkin_macs']) ? $data['checkin_macs'] : [];
$array = [];
@ -1263,7 +1283,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;
}
// 昵称
@ -1340,6 +1360,101 @@ 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 {Number} [email_verity] 是否标记邮箱为已认证1是、0否默认1
* @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;
$emailVerity = intval(Request::input('email_verity', 1)) === 1;
$profession = trim((string)Request::input('profession', ''));
$department = Request::input('department', []);
$user = User::createByAdmin($email, $password, $nickname, [
'changePass' => $changePass,
'emailVerity' => $emailVerity,
'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[]、email_verity(1已认证/0未认证默认0))进行创建
* @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 邮箱验证
*
@ -1521,7 +1636,7 @@ class UsersController extends AbstractController
} elseif ($type === 'create') {
$meetingid = strtoupper(Base::generatePassword(11, 1));
$name = $name ?: Doo::translate("{$user?->nickname} 发起的会议");
$channel = "DooTask:" . substr(md5($meetingid . env("APP_KEY")), 16);
$channel = "DooTask:" . substr(md5($meetingid . config('app.key')), 16);
$meeting = Meeting::createInstance([
'meetingid' => $meetingid,
'name' => $name,

View File

@ -23,9 +23,10 @@ use App\Tasks\CheckinRemindTask;
use App\Tasks\CloseMeetingRoomTask;
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;
use App\Module\PatchedAvatar as Avatar;
/**
@ -220,11 +221,13 @@ class IndexController extends InvokeController
'radius' => 0,
],
]);
return response($avatar->create($name)->save($file))
->header('Pragma', 'public')
->header('Cache-Control', 'max-age=1814400')
->header('Content-type', 'image/png')
->header('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400));
$avatar->create($name)->save($file);
return response()->file($file, [
'Pragma' => 'public',
'Cache-Control' => 'max-age=1814400',
'Content-type' => 'image/png',
'Expires' => gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400),
]);
}
/**
@ -270,6 +273,8 @@ class IndexController extends InvokeController
Task::deliver(new JokeSoupTask());
// 未领取任务通知
Task::deliver(new UnclaimedTaskRemindTask());
// 待办提醒
Task::deliver(new TodoRemindTask());
// 关闭会议室
Task::deliver(new CloseMeetingRoomTask());
// Manticore Search 同步
@ -296,7 +301,7 @@ class IndexController extends InvokeController
if (preg_match("/^\d+\.\d+\.\d+$/", $publishVersion)) {
// 判断密钥
$publishKey = Request::header('publish-key');
if ($publishKey !== env('APP_KEY')) {
if ($publishKey !== config('app.key')) {
return Base::retError("key error");
}
// 判断版本

View File

@ -24,8 +24,8 @@ class InvokeController extends BaseController
if ($action) {
$app .= "__" . $action;
}
// 接口不存在
if (!method_exists($this, $app)) {
// 接口不存在(仅 public 方法可作为端点protected/private 为内部方法,不暴露为路由)
if (!method_exists($this, $app) || !(new \ReflectionMethod($this, $app))->isPublic()) {
$msg = "404 not found (" . str_replace("__", "/", $app) . ").";
return Base::ajaxError($msg);
}

View File

@ -1,68 +0,0 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'webapi' => \App\Http\Middleware\WebApi::class,
];
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function redirectTo($request)
{
if (! $request->expectsJson()) {
return route('login');
}
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
* @return mixed
*/
public function handle(Request $request, Closure $next, ...$guards)
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}

View File

@ -1,20 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array
*/
public function hosts()
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Http\Middleware;
use Fideloper\Proxy\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
// 接口部分
'api/*',
// 发布桌面端
'desktop/publish/',
];
}

View File

@ -25,14 +25,6 @@ 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);

View File

@ -15,10 +15,8 @@ class LdapUser extends Model
{
/**
* The object classes of the LDAP model.
*
* @var array
*/
public static $objectClasses = [
public static array $objectClasses = [
'person',
'top',
];

View File

@ -5,6 +5,7 @@ namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
@ -31,7 +32,10 @@ class AbstractModel extends Model
const ID = 'id';
protected $dates = [
/**
* 全局日期字段Laravel 10 移除 $dates 属性后改经 getCasts 合并,子模型 $casts 同名键优先)
*/
protected $defaultDatetimeCasts = [
'top_at',
'last_at',
@ -51,12 +55,23 @@ class AbstractModel extends Model
'read_at',
'done_at',
'remind_at',
'reminded_at',
'created_at',
'updated_at',
'deleted_at',
];
public function getCasts(): array
{
$casts = parent::getCasts();
foreach ($this->defaultDatetimeCasts as $field) {
$casts[$field] ??= 'datetime';
}
return $casts;
}
protected $appendattrs = [];
/**
@ -187,6 +202,66 @@ class AbstractModel extends Model
return $instance;
}
/**
* 覆写框架 saveOrIgnore 的底层插入逻辑。
*
* 框架默认走 insertOrIgnoreReturningINSERT ... ON CONFLICT ... RETURNING
* MySQL/MariaDB grammar 不支持该变体,会抛
* "This database engine does not support insert or ignore with returning."
* 这里改用 MySQL 支持的 INSERT IGNORE并在成功插入时手动回填自增ID
* 保持与框架一致的返回语义(冲突被忽略时返回 false)。
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array|string|null $uniqueBy
* @return bool
*/
protected function performInsertOrIgnore(Builder $query, array|string|null $uniqueBy)
{
// MySQL INSERT IGNORE 无法按指定列限制冲突范围,所有 unique 冲突一并吞掉。
// 若调用方传了 $uniqueBy 期望精确 scope这里直接抛错避免与框架语义偷偷不一致。
if ($uniqueBy !== null) {
throw new \InvalidArgumentException('saveOrIgnore $uniqueBy is not supported on MySQL driver; pass null.');
}
if ($this->usesUniqueIds()) {
$this->setUniqueIds();
}
if ($this->fireModelEvent('creating') === false) {
return false;
}
if ($this->usesTimestamps()) {
$this->updateTimestamps();
}
$attributes = $this->getAttributesForInsert();
if (empty($attributes)) {
return true;
}
if ($query->toBase()->insertOrIgnore($attributes) === 0) {
return false;
}
if ($this->getIncrementing()) {
$lastId = $query->getConnection()->getPdo()->lastInsertId();
// 无 auto_increment 列的表上 INSERT IGNORE 即使插入成功 lastInsertId 也返回 "0"
// 别用它去覆盖业务设置的主键。
if ($lastId > 0) {
$this->setAttribute($this->getKeyName(), $lastId);
}
}
$this->exists = true;
$this->wasRecentlyCreated = true;
$this->fireModelEvent('created', false);
return true;
}
/**
* 更新数据校验
* @param array $param

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
/**
* AI 助手回复反馈(👍/👎)
*
* @property int $id
* @property int $userid
* @property string $session_key
* @property string $session_id
* @property int $local_id
* @property string $feedback
* @property string|null $prompt
* @property string $answer_digest
* @property string|null $answer
* @property string|null $source_ids
* @property string $model
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereAnswer($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereAnswerDigest($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereFeedback($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereLocalId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereModel($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback wherePrompt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereSessionId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereSessionKey($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereSourceIds($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantFeedback whereUserid($value)
* @mixin \Eloquent
*/
class AiAssistantFeedback extends AbstractModel
{
protected $table = 'ai_assistant_feedbacks';
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
/**
* AI 助手帮助知识库检索日志
*
* @property int $id
* @property int $userid
* @property int $dialog_id
* @property string $context_key
* @property string $source
* @property string $query
* @property string $locale
* @property string|null $source_ids
* @property float $top_score
* @property int $result_count
* @property int $duration_ms
* @property int $empty
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereContextKey($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereDialogId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereDurationMs($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereEmpty($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereLocale($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereQuery($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereResultCount($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereSource($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereSourceIds($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereTopScore($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSearchLog whereUserid($value)
* @mixin \Eloquent
*/
class AiAssistantSearchLog extends AbstractModel
{
protected $table = 'ai_assistant_search_logs';
}

View File

@ -15,6 +15,26 @@ namespace App\Models;
* @property string|null $images
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereImages($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereSceneKey($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereSessionId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereSessionKey($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|AiAssistantSession whereUserid($value)
* @mixin \Eloquent
*/
class AiAssistantSession extends AbstractModel
{

48
app/Models/AppBadge.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
/**
* App\Models\AppBadge
*
* 插件/微应用菜单角标(每个 (app_id, menu_key, userid) 一行,仅存非清除态)
*
* @property int $id
* @property string $app_id 应用ID
* @property string $menu_key 菜单稳定标识(空串=第一个菜单)
* @property int $userid 用户ID
* @property int $count 角标数字
* @property bool $dot 是否显示红点
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge query()
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereAppId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereCount($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereDot($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereMenuKey($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUserid($value)
* @mixin \Eloquent
*/
class AppBadge extends AbstractModel
{
protected $table = 'app_badges';
const CREATED_AT = null;
protected $fillable = [
'app_id',
'menu_key',
'userid',
'count',
'dot',
];
protected $casts = [
'userid' => 'integer',
'count' => 'integer',
'dot' => 'boolean',
];
}

View File

@ -1,99 +0,0 @@
<?php
namespace App\Models;
use Cache;
use Carbon\Carbon;
use DB;
/**
* App\Models\ApproveProcInstHistory
*
* @property int $id
* @property int $proc_def_id 流程定义ID
* @property string|null $proc_def_name 流程定义名
* @property string|null $title 标题
* @property int|null $department_id 用户部门ID
* @property string|null $department 用户部门
* @property string|null $company 用户公司
* @property string|null $node_id 当前节点
* @property string|null $candidate 审批人
* @property int|null $task_id 当前任务
* @property string|null $start_time 开始时间
* @property string|null $end_time 结束时间
* @property int|null $duration 持续时间
* @property string|null $start_user_id 开始用户ID
* @property string|null $start_user_name 开始用户名
* @property int|null $is_finished 是否完成
* @property string|null $var
* @property int $state 当前状态: 0待审批1审批中2通过3拒绝4撤回
* @property string|null $latest_comment
* @property string|null $global_comment
* @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|ApproveProcInstHistory newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereCandidate($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereCompany($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDepartment($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDepartmentId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereDuration($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereEndTime($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereGlobalComment($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereIsFinished($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereLatestComment($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereNodeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereProcDefId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereProcDefName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartTime($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereStartUserName($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereState($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcInstHistory whereVar($value)
* @mixin \Eloquent
*/
class ApproveProcInstHistory extends AbstractModel
{
protected $table = 'approve_proc_inst_history';
/**
* 获取用户审批状态(请假、外出)
* @param $userid
* @return mixed|null
*/
public static function getUserApprovalStatus($userid)
{
if (empty($userid)) {
return null;
}
return Cache::remember('user_is_leave_' . $userid, Carbon::now()->addMinute(), function () use ($userid) {
return self::where([
['start_user_id', '=', $userid],
[DB::raw("JSON_UNQUOTE(JSON_EXTRACT(var, '$.startTime'))"), '<=', Carbon::now()->toDateTimeString()],
[DB::raw("JSON_UNQUOTE(JSON_EXTRACT(var, '$.endTime'))"), '>=', Carbon::now()->toDateTimeString()],
['state', '=', 2]
])->where(function ($query) {
$query->where('proc_def_name', 'like', '%请假%')
->orWhere('proc_def_name', 'like', '%外出%');
})->orderByDesc('id')->value('proc_def_name');
});
}
/**
* 判断用户是否请假(包含:请假、外出)
* @param $userid
* @return bool
*/
public static function userIsLeave($userid)
{
return (bool)self::getUserApprovalStatus($userid);
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Models;
/**
* App\Models\ApproveProcMsg
*
* @property int $id
* @property int|null $proc_inst_id 流程实例ID
* @property int|null $userid 会员ID
* @property int|null $msg_id 消息ID
* @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|ApproveProcMsg newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereProcInstId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApproveProcMsg whereUserid($value)
* @mixin \Eloquent
*/
class ApproveProcMsg extends AbstractModel
{
}

View File

@ -178,28 +178,26 @@ class File extends AbstractModel
* @param int $pid
* @param string $type
* @param bool $isGetparent
* @param string $scope 板块范围根目录生效mine=仅我的私有文件shared=共享文件(别人共享给我的+我共享出去的all=全部(默认,兼容旧调用)
* @return array
*/
public function getFileList($user, int $pid, $type = "all", $isGetparent = true)
public function getFileList($user, int $pid, $type = "all", $isGetparent = true, $scope = "all")
{
$permission = 1000;
$userids = $user->isTemp() ? [$user->userid] : [0, $user->userid];
$builder = File::wherePid($pid)
->when($type == 'dir', function ($q) {
$q->whereType('folder');
});
//
if ($pid > 0) {
// 目录内:按权限返回子级(不区分板块)
File::permissionFind($pid, $userids, 0, $permission);
} else {
$builder->whereUserid($user->userid);
}
//
$array = $builder->take(500)->get()->toArray();
foreach ($array as &$item) {
$item['permission'] = $permission;
}
//
if ($pid > 0) {
$array = File::wherePid($pid)
->when($type == 'dir', function ($q) {
$q->whereType('folder');
})
->take(500)->get()->toArray();
foreach ($array as &$item) {
$item['permission'] = $permission;
}
unset($item);
// 遍历获取父级
if ($isGetparent) {
while ($pid > 0) {
@ -230,24 +228,58 @@ class File extends AbstractModel
$array = array_values($array);
}
} else {
// 获取共享相关
DB::statement("SET SQL_MODE=''");
$pre = DB::connection()->getTablePrefix();
$list = File::select(["files.*", DB::raw("MAX({$pre}file_users.permission) as permission")])
->join('file_users', 'files.id', '=', 'file_users.file_id')
->where('files.userid', '!=', $user->userid)
->whereIn('file_users.userid', $userids)
->groupBy('files.id')
->take(100)
->when($type == 'dir', function ($q) {
$q->where('files.type', 'folder');
})
->get();
if ($list->isNotEmpty()) {
foreach ($list as $file) {
$temp = $file->toArray();
$temp['pid'] = 0;
$array[] = $temp;
// 根目录:按板块拆分
$array = [];
// 我的文件我拥有的全部含已共享出去的mine 与 all 一致
if ($scope === 'mine' || $scope === 'all') {
$mine = File::wherePid(0)
->whereUserid($user->userid)
->when($type == 'dir', function ($q) {
$q->whereType('folder');
})
->take(500)->get()->toArray();
foreach ($mine as &$item) {
$item['permission'] = $permission;
}
unset($item);
$array = array_merge($array, $mine);
}
// 共享文件
if ($scope === 'shared' || $scope === 'all') {
// 别人共享给我的
DB::statement("SET SQL_MODE=''");
$pre = DB::connection()->getTablePrefix();
$list = File::select(["files.*", DB::raw("MAX({$pre}file_users.permission) as permission")])
->join('file_users', 'files.id', '=', 'file_users.file_id')
->where('files.userid', '!=', $user->userid)
->whereIn('file_users.userid', $userids)
->groupBy('files.id')
->take(100)
->when($type == 'dir', function ($q) {
$q->where('files.type', 'folder');
})
->get();
if ($list->isNotEmpty()) {
foreach ($list as $file) {
$temp = $file->toArray();
$temp['pid'] = 0;
$array[] = $temp;
}
}
// 我共享出去的(仅 shared 板块补充all 板块已包含在“我的文件”里)
if ($scope === 'shared') {
$mineShared = File::wherePid(0)
->whereUserid($user->userid)
->where('share', 1)
->when($type == 'dir', function ($q) {
$q->whereType('folder');
})
->take(500)->get()->toArray();
foreach ($mineShared as &$item) {
$item['permission'] = $permission;
}
unset($item);
$array = array_merge($array, $mineShared);
}
}
}
@ -267,20 +299,74 @@ class File extends AbstractModel
* @return array
*/
public function contentUpload($user, int $pid, $webkitRelativePath, $overwrite = false)
{
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
$data = Base::upload([
"file" => Request::file('files'),
"type" => 'more',
"autoThumb" => false,
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
"quality" => true,
]);
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, null, $overwrite);
}
/**
* contentUpload 同一入库链路,但接收已落盘的本地文件而非 Request 上传文件。
* 供分片上传 merge 阶段调用。
*
* @param user $user
* @param int $pid
* @param string $localPath 合并后的完整文件绝对路径
* @param string $originalName 原始文件名(含扩展名)
* @param string $webkitRelativePath
* @param string|null $hash 文件 md5用于秒传索引
* @param bool $overwrite
* @return array
*/
public function contentUploadFromPath($user, int $pid, string $localPath, string $originalName, $webkitRelativePath, $hash = null, $overwrite = false)
{
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
$data = Base::uploadFromPath([
"path_local" => $localPath,
"name" => $originalName,
"type" => 'more',
"autoThumb" => false,
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
"quality" => true,
]);
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, $hash, $overwrite);
}
/**
* 上传前置:权限/计数校验 + webkitRelativePath 拆出来的中间文件夹创建。
* 失败抛 ApiException成功返回 [最终 pid, 拥有者 userid, 已创建的中间文件夹列表]
*
* @param user $user
* @param int $pid
* @param string $webkitRelativePath
* @return array{0:int, 1:int, 2:array}
*/
public function contentUploadPrep($user, int $pid, $webkitRelativePath): array
{
$userid = $user->userid;
if ($pid > 0) {
if (File::wherePid($pid)->count() >= 300) {
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
}
$row = File::permissionFind($pid, $user, 1);
$userid = $row->userid;
} else {
if (File::whereUserid($user->userid)->wherePid(0)->count() >= 300) {
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
}
}
//
$dirs = explode("/", $webkitRelativePath);
$addItem = [];
while (count($dirs) > 1) {
@ -297,12 +383,10 @@ class File extends AbstractModel
'created_id' => $user->userid,
]);
$dirRow->handleDuplicateName();
if ($dirRow->saveBeforePP()) {
$addItem[] = File::find($dirRow->id);
if (!$dirRow->saveBeforePP()) {
throw new ApiException('创建文件夹失败');
}
}
if (empty($dirRow)) {
throw new ApiException('创建文件夹失败');
$addItem[] = File::find($dirRow->id);
}
$pid = $dirRow->id;
});
@ -311,20 +395,24 @@ class File extends AbstractModel
}
}
}
//
$path = 'uploads/tmp/file/' . date("Ym") . '/';
$data = Base::upload([
"file" => Request::file('files'),
"type" => 'more',
"autoThumb" => false,
"path" => $path,
"quality" => true
]);
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
$data = $data['data'];
//
return [$pid, $userid, $addItem];
}
/**
* 上传后置ext type 映射 + File 记录创建 + uploadMove + FileContent 入库。
*
* @param user $user
* @param int $userid 目标文件拥有者
* @param int $pid
* @param array $data Base::upload/uploadFromPath 返回的 data 部分
* @param array $addItem prep 阶段累积的中间文件夹
* @param string $webkitRelativePath
* @param string|null $hash 可选,写入 files.hash秒传索引
* @param bool $overwrite
* @return array{data: array, addItem: array}
*/
private function contentUploadCommit($user, int $userid, int $pid, array $data, array $addItem, $webkitRelativePath, $hash, bool $overwrite): array
{
$type = match ($data['ext']) {
'text', 'md', 'markdown' => 'document',
'drawio' => 'drawio',
@ -356,7 +444,6 @@ class File extends AbstractModel
if ($data['ext'] == 'markdown') {
$data['ext'] = 'md';
}
$file = null;
$params = [
'pid' => $pid,
'name' => Base::rightDelete($data['name'], '.' . $data['ext']),
@ -365,6 +452,7 @@ class File extends AbstractModel
'userid' => $userid,
'created_id' => $user->userid,
];
$file = null;
if ($overwrite) {
$file = self::wherePid($params['pid'])->whereExt($params['ext'])->whereName($params['name'])->first();
}
@ -373,11 +461,12 @@ class File extends AbstractModel
$file = File::createInstance($params);
$file->handleDuplicateName();
}
// 开始创建
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file) {
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file, $hash) {
$file->size = $data['size'] * 1024;
if ($hash) {
$file->hash = $hash;
}
$file->saveBeforePP();
//
$data = Base::uploadMove($data, "uploads/file/" . $file->type . "/" . date("Ym") . "/" . $file->id . "/");
$content = [
'from' => '',
@ -389,25 +478,20 @@ class File extends AbstractModel
$content['width'] = $data['width'];
$content['height'] = $data['height'];
}
$content = FileContent::createInstance([
FileContent::createInstance([
'fid' => $file->id,
'content' => $content,
'text' => '',
'size' => $file->size,
'userid' => $user->userid,
]);
$content->save();
//
])->save();
$tmpRow = File::find($file->id);
$tmpRow->pushMsg('add', $tmpRow);
//
$data = File::handleImageUrl($tmpRow->toArray());
$data['full_name'] = $webkitRelativePath ?: ($data['name'] . '.' . $data['ext']);
$data['overwrite'] = $overwrite ? 1 : 0;
//
$addItem[] = $data;
return ['data' => $data, 'addItem' => $addItem];
$row = File::handleImageUrl($tmpRow->toArray());
$row['full_name'] = $webkitRelativePath ?: ($row['name'] . '.' . $row['ext']);
$row['overwrite'] = $overwrite ? 1 : 0;
$addItem[] = $row;
return ['data' => $row, 'addItem' => $addItem];
});
}

View File

@ -14,6 +14,25 @@ namespace App\Models;
* @property \Carbon\Carbon|null $last_retry_at 最后重试时间
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereAction($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereDataId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereDataType($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereErrorMessage($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereLastRetryAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereRetryCount($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ManticoreSyncFailure whereUpdatedAt($value)
* @mixin \Eloquent
*/
class ManticoreSyncFailure extends AbstractModel
{
@ -28,10 +47,8 @@ class ManticoreSyncFailure extends AbstractModel
'last_retry_at',
];
protected $dates = [
'last_retry_at',
'created_at',
'updated_at',
protected $casts = [
'last_retry_at' => 'datetime',
];
/**
@ -73,6 +90,24 @@ class ManticoreSyncFailure extends AbstractModel
->delete();
}
/**
* 批量清除同步成功记录(供批量写入路径使用,避免逐条删除)
*
* @param string $dataType 数据类型
* @param array $dataIds 数据ID列表
* @param string $action 操作类型
*/
public static function removeSuccessBatch(string $dataType, array $dataIds, string $action): void
{
if (empty($dataIds)) {
return;
}
self::where('data_type', $dataType)
->whereIn('data_id', $dataIds)
->where('action', $action)
->delete();
}
/**
* 获取待重试的记录
* 根据重试次数决定间隔1次=1分钟2次=5分钟3次=15分钟4次+=30分钟

View File

@ -68,6 +68,10 @@ use Request;
* @method static \Illuminate\Database\Eloquent\Builder|Project whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|Project withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|Project withoutTrashed()
* @property-read array $deputy_userids
* @method static \Illuminate\Database\Eloquent\Builder<static>|Project whereAiAutoAnalyze($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Project whereDepartmentOwnerView($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Project whereTaskTemplateShare($value)
* @mixin \Eloquent
*/
class Project extends AbstractModel
@ -605,6 +609,38 @@ class Project extends AbstractModel
});
}
/**
* 判断用户是否有权限创建项目(依据系统设置「项目创建权限」)
* @param int $userid
* @return bool
*/
public static function userCanCreate($userid)
{
// 范围已在 Setting::getSettingAttribute() 归一化(默认 ['all']
$modes = Base::settingFind('system', 'project_add_permission', ['all']);
// 「所有人」:放行(与具体用户无关,避免未携带身份时被误判为无权)
if (in_array('all', $modes)) {
return true;
}
$user = User::find(intval($userid));
if (empty($user)) {
return false;
}
// 系统管理员始终可创建项目(不受开关限制)
if ($user->isAdmin()) {
return true;
}
// 部门负责人/部门管理员
if (in_array('departmentOwner', $modes) && UserDepartment::getManagedDepartments($user->userid)->isNotEmpty()) {
return true;
}
// 指定人员
if (in_array('appoint', $modes)) {
return in_array($user->userid, Base::settingFind('system', 'project_add_userids', []));
}
return false;
}
/**
* 创建项目
* @param $params
@ -621,6 +657,10 @@ class Project extends AbstractModel
$desc = trim(Arr::get($params, 'desc', ''));
$flow = trim(Arr::get($params, 'flow', 'close'));
$isPersonal = intval(Arr::get($params, 'personal'));
// 个人项目为系统自动创建,不受创建权限限制
if (!$isPersonal && !self::userCanCreate($userid)) {
return Base::retError('当前仅指定人员可以创建项目');
}
if (mb_strlen($name) < 2) {
return Base::retError('项目名称不可以少于2个字');
} elseif (mb_strlen($name) > 32) {

View File

@ -482,7 +482,7 @@ class ProjectTask extends AbstractModel
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;
$matched = collect($priorityList)->first(fn($item) => intval($item['priority']) === $p_level);
} else {
$matched = Setting::getDefaultTaskPriorityItem($priorityList);
}
@ -937,7 +937,7 @@ class ProjectTask extends AbstractModel
'cache' => [
'task_at' => $oldStringAt,
'change_at' => $newStringAt,
'over_sec' => $effectiveEndTime->diffInSeconds($oldAt[1]),
'over_sec' => (int)$effectiveEndTime->diffInSeconds($oldAt[1], true),
'owners' => $this->taskUser->where('owner', 1)->pluck('userid')->toArray(),
'assists' => $this->taskUser->where('owner', 0)->pluck('userid')->toArray(),
]
@ -1633,7 +1633,7 @@ class ProjectTask extends AbstractModel
$this->addLog("{任务}超期未完成", [
'cache' => [
'task_at' => $this->start_at . '~' . $this->end_at,
'over_sec' => Carbon::now()->diffInSeconds($this->end_at),
'over_sec' => (int)Carbon::now()->diffInSeconds($this->end_at, true),
'owners' => $this->taskUser->where('owner', 1)->pluck('userid')->toArray(),
'assists' => $this->taskUser->where('owner', 0)->pluck('userid')->toArray(),
]
@ -2246,7 +2246,7 @@ class ProjectTask extends AbstractModel
$project = Project::userProject($task->project_id);
} catch (\Throwable $e) {
if ($task->owner !== null || $task->permission(4)) {
$project = Project::find($task->project_id);
$project = Project::withTrashed()->find($task->project_id);
if (empty($project)) {
throw new ApiException('项目不存在或已被删除', [ 'task_id' => $task_id ], -4002);
}

View File

@ -18,6 +18,28 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property \Illuminate\Support\Carbon|null $executed_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ProjectTask|null $task
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent change($array)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent remove()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereError($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereEventType($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereExecutedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereResult($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereRetryCount($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereTaskId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskAiEvent whereUpdatedAt($value)
* @mixin \Eloquent
*/
class ProjectTaskAiEvent extends AbstractModel
{

View File

@ -38,6 +38,8 @@ namespace App\Models;
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskTemplate whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskTemplate whereLastUsedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProjectTaskTemplate whereUseCount($value)
* @mixin \Eloquent
*/
class ProjectTaskTemplate extends AbstractModel

View File

@ -156,7 +156,7 @@ class Report extends AbstractModel
* @param User|null $user
* @return Builder|Model|\Illuminate\Database\Query\Builder|object
*/
public static function getLastOne(User $user = null)
public static function getLastOne(?User $user = null)
{
$user === null && $user = User::auth();
$one = self::whereUserid($user->userid)->orderByDesc("created_at")->first();

View File

@ -51,20 +51,28 @@ class Setting extends AbstractModel
switch ($this->name) {
// 系统设置
case 'system':
$value['system_alias'] = $value['system_alias'] ?: env('APP_NAME');
$value['image_compress'] = $value['image_compress'] ?: 'open';
$value['image_quality'] = min(100, max(0, intval($value['image_quality']) ?: 90));
$value['image_save_local'] = $value['image_save_local'] ?: 'open';
$value['task_user_limit'] = min(2000, max(1, intval($value['task_user_limit']) ?: 500));
if (!is_array($value['task_default_time']) || count($value['task_default_time']) != 2 || !Timer::isTime($value['task_default_time'][0]) || !Timer::isTime($value['task_default_time'][1])) {
$value['system_alias'] = ($value['system_alias'] ?? null) ?: config('app.name');
$value['image_compress'] = ($value['image_compress'] ?? null) ?: 'open';
$value['image_quality'] = min(100, max(0, intval($value['image_quality'] ?? 0) ?: 90));
$value['image_save_local'] = ($value['image_save_local'] ?? null) ?: 'open';
$value['task_user_limit'] = min(2000, max(1, intval($value['task_user_limit'] ?? 0) ?: 500));
if (!is_array($value['task_default_time'] ?? null) || 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'];
}
// 项目创建权限范围all/departmentOwner/appoint默认 all+ 指定人员
$value['project_add_permission'] = array_values(array_intersect(
is_array($value['project_add_permission'] ?? null) ? $value['project_add_permission'] : [],
['all', 'departmentOwner', 'appoint']
)) ?: ['all'];
$value['project_add_userids'] = is_array($value['project_add_userids'] ?? null)
? array_values(array_unique(array_filter(array_map('intval', $value['project_add_userids']))))
: [];
break;
// 文件设置
case 'fileSetting':
$value['permission_pack_type'] = $value['permission_pack_type'] ?: 'all';
$value['permission_pack_userids'] = is_array($value['permission_pack_userids']) ? $value['permission_pack_userids'] : [];
$value['permission_pack_type'] = ($value['permission_pack_type'] ?? null) ?: 'all';
$value['permission_pack_userids'] = is_array($value['permission_pack_userids'] ?? null) ? $value['permission_pack_userids'] : [];
break;
// AI 机器人设置
@ -73,7 +81,7 @@ class Setting extends AbstractModel
$value['claude_key'] = $value['claude_token'];
}
$array = [];
$aiList = ['openai', 'claude', 'deepseek', 'gemini', 'grok', 'ollama', 'zhipu', 'qianwen', 'wenxin'];
$aiList = ['openai', 'claude', 'deepseek', 'gemini', 'grok', 'ollama', 'zhipu', 'qianwen', 'wenxin', 'dooai'];
$fieldList = ['key', 'secret', 'models', 'model', 'base_url', 'agency', 'temperature', 'system'];
foreach ($aiList as $aiName) {
foreach ($fieldList as $fieldName) {
@ -81,11 +89,13 @@ class Setting extends AbstractModel
$content = !empty($value[$key]) ? trim($value[$key]) : '';
switch ($fieldName) {
case 'models':
if ($content) {
// 新 JSON 数组格式原样保留;仅旧的换行格式按行清洗
if ($content && !str_starts_with($content, '[')) {
$content = explode("\n", $content);
$content = array_filter($content);
$content = implode("\n", $content);
}
$content = is_array($content) ? implode("\n", $content) : '';
$content = is_string($content) ? $content : '';
break;
case 'model':
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
@ -206,20 +216,59 @@ class Setting extends AbstractModel
/**
* AI 机器人模型转数组
* @param $models
* @param bool $retValue
* @param bool $retValue 仅返回模型 value 列表
* @param bool $visibleOnly 仅返回可见模型(跳过标记 hidden 的项,供展示给终端用户的场景)
* @return array
*/
public static function AIBotModels2Array($models, $retValue = false)
public static function AIBotModels2Array($models, $retValue = false, $visibleOnly = false)
{
$list = is_array($models) ? $models : explode("\n", $models);
$list = null;
if (is_array($models)) {
$list = $models;
} else {
$text = trim((string)$models);
if ($text !== '' && str_starts_with($text, '[')) {
$decoded = json_decode($text, true);
if (is_array($decoded)) {
$list = $decoded;
}
}
if ($list === null) {
$list = explode("\n", (string)$models);
}
}
$array = [];
foreach ($list as $item) {
$arr = Base::newTrim(explode('|', $item . '|'));
if ($arr[0]) {
if (is_array($item)) {
// 新 JSON 记录格式:{id,name,thinking}(兼容 {value,label}
$value = trim((string)($item['id'] ?? $item['value'] ?? ''));
if ($value === '') {
continue;
}
// 隐藏模型:仍保存在设置中,但展示给终端用户时跳过($visibleOnly
if ($visibleOnly && !empty($item['hidden'])) {
continue;
}
$label = trim((string)($item['name'] ?? $item['label'] ?? ''));
$thinking = strtolower(trim((string)($item['thinking'] ?? 'off')));
if (!in_array($thinking, ['off', 'low', 'medium', 'high'], true)) {
$thinking = 'off';
}
$array[] = [
'value' => $arr[0],
'label' => $arr[1] ?: $arr[0]
'value' => $value,
'label' => $label !== '' ? $label : $value,
'thinking' => $thinking,
];
} else {
// 兼容旧字符串格式 "id|name"
$arr = Base::newTrim(explode('|', $item . '|'));
if ($arr[0]) {
$array[] = [
'value' => $arr[0],
'label' => $arr[1] ?: $arr[0],
'thinking' => 'off',
];
}
}
}
if ($retValue) {
@ -228,6 +277,26 @@ class Setting extends AbstractModel
return $array;
}
/**
* 获取指定模型的思考档位off|low|medium|high未配置返回 off
* @param string|array $models 模型列表设置JSON 字符串或旧格式)
* @param string $modelName 模型 ID
* @return string
*/
public static function AIBotModelThinking($models, $modelName)
{
$modelName = trim((string)$modelName);
if ($modelName === '') {
return 'off';
}
foreach (self::AIBotModels2Array($models) as $item) {
if ($item['value'] === $modelName) {
return $item['thinking'] ?? 'off';
}
}
return 'off';
}
/**
* 规范自定义微应用配置
* @param array $list
@ -289,18 +358,25 @@ class Setting extends AbstractModel
/**
* 将存储结构转换成 appstore 接口同款格式
* @param array $apps
* @param bool $keepVisible 是否保留可见范围(仅管理员编辑场景需要,用于回填)
* @return array
*/
public static function formatCustomMicroAppsForResponse(array $apps)
public static function formatCustomMicroAppsForResponse(array $apps, bool $keepVisible = false)
{
return array_values(array_map(function ($app) {
unset($app['visible_to']);
return array_values(array_map(function ($app) use ($keepVisible) {
if ($keepVisible) {
$app['visible_to'] = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
} else {
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);
$menu['key'] = isset($menu['key']) ? (string)$menu['key'] : '';
$menu['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
if (isset($menu['visible_to'])) {
unset($menu['visible_to']);
}
@ -390,6 +466,9 @@ class Setting extends AbstractModel
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
$payload['capsule'] = Base::newTrim($menu['capsule']);
}
// 角标:菜单稳定标识 与 打开时是否自动清零
$payload['key'] = Base::newTrim($menu['key'] ?? '');
$payload['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
return $payload;
}
@ -398,7 +477,7 @@ class Setting extends AbstractModel
* @param mixed $value
* @return array
*/
protected static function normalizeCustomMicroVisible($value)
public static function normalizeCustomMicroVisible($value)
{
if (is_array($value)) {
$list = array_filter(array_map('trim', $value));
@ -421,7 +500,7 @@ class Setting extends AbstractModel
* @param int $userId
* @return bool
*/
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
public static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
{
if (in_array('all', $visible)) {
return true;

View File

@ -89,6 +89,8 @@ use Carbon\Carbon;
*/
class User extends AbstractModel
{
const IMPORT_MAX = 500;
protected $primaryKey = 'userid';
protected $hidden = [
@ -303,12 +305,12 @@ class User extends AbstractModel
if ($onlyUserid && $onlyUserid != $this->userid) {
return;
}
if (env("PASSWORD_ADMIN") == 'disabled') {
if (config('dootask.password_admin') == 'disabled') {
if ($this->userid == 1) {
throw new ApiException('当前环境禁止此操作');
}
}
if (env("PASSWORD_OWNER") == 'disabled') {
if (config('dootask.password_owner') == 'disabled') {
throw new ApiException('当前环境禁止此操作');
}
}
@ -392,6 +394,10 @@ class User extends AbstractModel
}
// 密码
self::passwordPolicy($password);
// license
if ($err = Doo::licenseBindingError(Doo::license())) {
throw new ApiException($err);
}
// 开始注册
$user = Doo::userCreate($email, $password);
if ($other) {
@ -425,6 +431,287 @@ class User extends AbstractModel
return $createdUser;
}
/**
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
* @param string $email
* @param string $password
* @param string $nickname
* @param array $options changePass(bool,默认true) / emailVerity(bool,默认false,标记邮箱已认证) / 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;
$emailVerity = ($options['emailVerity'] ?? false) ? 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; // 复用现有首登强制改密机制
$user->email_verity = $emailVerity; // 管理员可在创建时直接标记邮箱认证状态
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,
'emailVerity' => !empty($row['email_verity']),
'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'] ?? '',
'email_verity' => 1, // 默认标记为已认证,前端可在预览中按行调整
'status' => $ok ? 'ok' : 'error',
'reason' => $reason ?? '',
];
}
return [
'total' => count($rows),
'valid' => $valid,
'invalid' => count($rows) - $valid,
'rows' => $list,
];
}
/**
* 获取我的ID
* @return int
@ -678,6 +965,8 @@ class User extends AbstractModel
return url("images/avatar/default_ollama.png");
case 'ai-zhipu@bot.system':
return url("images/avatar/default_zhipu.png");
case 'ai-dooai@bot.system':
return url("images/avatar/default_dooai.png");
case 'bot-manager@bot.system':
return url("images/avatar/default_bot.png");
case 'meeting-alert@bot.system':

View File

@ -151,6 +151,7 @@ class UserBot extends AbstractModel
$name = match ($name) {
'system-msg' => '系统消息',
'task-alert' => '任务提醒',
'todo-alert' => '待办提醒',
'check-in' => '签到打卡',
'anon-msg' => '匿名消息',
'approval-alert' => '审批',
@ -163,6 +164,7 @@ class UserBot extends AbstractModel
'ai-zhipu' => '智谱清言',
'ai-qianwen' => '通义千问',
'ai-wenxin' => '文心一言',
'ai-dooai' => 'Doo AI',
'bot-manager' => '机器人管理',
'meeting-alert' => '会议通知',
'okr-alert' => 'OKR提醒',
@ -271,8 +273,11 @@ class UserBot extends AbstractModel
if ($match[1] === "ai-") {
$aibotSetting = Base::setting('aibotSetting');
$aibotModel = $aibotSetting[$match[2] . '_model'];
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models']);
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models'], false, true);
if ($aibotModels) {
if (!in_array($aibotModel, array_column($aibotModels, 'value'), true)) {
$aibotModel = $aibotModels[0]['value'];
}
$menus = array_merge(
[
[

View File

@ -33,6 +33,7 @@ use Request;
* @method static \Illuminate\Database\Eloquent\Builder|UserDepartment whereOwnerUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDepartment whereParentId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDepartment whereUpdatedAt($value)
* @property-read array $deputy_userids
* @mixin \Eloquent
*/
class UserDepartment extends AbstractModel
@ -560,7 +561,7 @@ class UserDepartment extends AbstractModel
'own_project_ids' => [],
'own_project_id_map' => [],
];
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
return $empty;
}
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
@ -615,4 +616,69 @@ class UserDepartment extends AbstractModel
return $project;
}
/**
* 会员卡片「查看该会员项目/任务」的权限上下文。
* 允许条件:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
* @param User $viewer 当前登录用户
* @param int $targetUserid 目标会员
* @return array ['allowed'=>bool, 'is_self'=>bool, 'is_admin'=>bool, 'project_ids'=>int[]]
* project_ids 仅在部门负责人视角下有意义(限定可见项目集合);本人/管理员为空数组表示不限制
*/
public static function userWorksContext(User $viewer, int $targetUserid): array
{
$result = [
'allowed' => false,
'is_self' => false,
'is_admin' => false,
'project_ids' => [],
];
if ($targetUserid <= 0) {
return $result;
}
// 机器人/系统账号(或不存在)不展示项目与任务
$target = User::select(['userid', 'bot'])->whereUserid($targetUserid)->first();
if (empty($target) || $target->bot) {
return $result;
}
// 本人
if ($viewer->userid === $targetUserid) {
$result['allowed'] = true;
$result['is_self'] = true;
return $result;
}
// 系统管理员
if ($viewer->isAdmin()) {
$result['allowed'] = true;
$result['is_admin'] = true;
return $result;
}
// 部门负责人只读视角
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
return $result;
}
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
if (!in_array($targetUserid, $memberUserids, true)) {
return $result;
}
// 目标会员参与、且未关闭「部门负责人视角可见」的项目
$projectIds = ProjectUser::where('project_users.userid', $targetUserid)
->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();
if (empty($projectIds)) {
return $result;
}
$result['allowed'] = true;
$result['project_ids'] = $projectIds;
return $result;
}
}

View File

@ -7,8 +7,9 @@ use App\Module\Base;
use App\Module\Doo;
use App\Module\Timer;
use Carbon\Carbon;
use Guanguans\Notify\Factory;
use Guanguans\Notify\Messages\EmailMessage;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
/**
* App\Models\UserEmailVerification
@ -97,16 +98,14 @@ class UserEmailVerification extends AbstractModel
);
break;
}
Factory::mailer()
->setDsn("smtp://{$setting['account']}:{$setting['password']}@{$setting['smtp_server']}:{$setting['port']}?verify_peer=0")
->setMessage(EmailMessage::create()
->from($alias . " <{$setting['account']}>")
->to($email)
->subject($subject)
->html($content))
->send();
$mailer = new Mailer(Transport::fromDsn("smtp://{$setting['account']}:{$setting['password']}@{$setting['smtp_server']}:{$setting['port']}?verify_peer=0"));
$mailer->send((new Email())
->from($alias . " <{$setting['account']}>")
->to($email)
->subject($subject)
->html($content));
} catch (\Throwable $e) {
if (str_contains($e->getMessage(), "Timed Out")) {
if (stripos($e->getMessage(), "timed out") !== false) {
throw new ApiException("邮件发送超时,请检查邮箱配置是否正确");
} elseif ($e->getCode() === 550) {
throw new ApiException('邮件内容被拒绝,请检查邮箱是否开启接收功能');

View File

@ -57,8 +57,8 @@ class UserRecentItem extends AbstractModel
'browsed_at',
];
protected $dates = [
'browsed_at',
protected $casts = [
'browsed_at' => 'datetime',
];
public static function record(int $userid, string $targetType, int $targetId, string $sourceType = '', int $sourceId = 0): self

View File

@ -40,8 +40,8 @@ class UserTaskBrowse extends AbstractModel
'browsed_at',
];
protected $dates = [
'browsed_at',
protected $casts = [
'browsed_at' => 'datetime',
];
/**

View File

@ -5,8 +5,6 @@ namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use Carbon\Carbon;
use Guanguans\Notify\Factory;
use Guanguans\Notify\Messages\EmailMessage;
/**
* App\Models\UserTransfer

View File

@ -56,12 +56,16 @@ use Illuminate\Support\Facades\DB;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog withoutTrashed()
* @property-read array $deputy_ids
* @mixin \Eloquent
*/
class WebSocketDialog extends AbstractModel
{
use SoftDeletes;
// 全员群初始化默认名称(双语字面量),用于识别"管理员尚未自定义"的状态
const ALL_GROUP_DEFAULT_NAME = '全体成员 All members';
protected $appends = ['deputy_ids'];
/**
@ -366,7 +370,9 @@ class WebSocketDialog extends AbstractModel
}
break;
case 'all':
$data['name'] = Doo::translate('全体成员');
$data['name'] = ($data['name'] && $data['name'] !== self::ALL_GROUP_DEFAULT_NAME)
? $data['name']
: Doo::translate('全体成员');
$data['dialog_mute'] = Base::settingFind('system', 'all_group_mute');
break;
}
@ -710,6 +716,46 @@ class WebSocketDialog extends AbstractModel
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 (User::find($userid)?->isAdmin()) {
return true;
}
// 群主 / 群管理员
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 列表
*
@ -784,7 +830,9 @@ class WebSocketDialog extends AbstractModel
$name = \DB::table('project_tasks')->where('dialog_id', $this->id)->value('name');
break;
case 'all':
$name = Doo::translate('全体成员');
$name = ($name && $name !== self::ALL_GROUP_DEFAULT_NAME)
? $name
: Doo::translate('全体成员');
break;
}
}
@ -1052,6 +1100,46 @@ class WebSocketDialog extends AbstractModel
});
}
/**
* sendMsgFiles 同链路,但接收已落盘的本地文件(分片合并产物),跳过 Base::upload。
*
* @param User $user
* @param int[] $dialogIds
* @param string $localPath 已落盘绝对路径
* @param string $originalName 原始文件名
* @param int $replyId
* @param bool $imageAttachment 任务群组中图片是否也作为附件保存
* @return array
*/
public static function sendMsgFilesFromPath($user, $dialogIds, string $localPath, string $originalName, int $replyId = 0, bool $imageAttachment = false)
{
$first = null;
$fileName = $originalName;
$resolve = function ($path) use (&$first, &$fileName, $localPath, $originalName) {
if ($first !== null) {
return self::copyFileDataTo($first, $path);
}
$setting = Base::setting("system");
$data = Base::uploadFromPath([
"path_local" => $localPath,
"name" => $originalName,
"type" => 'more',
"path" => $path,
"fileName" => $fileName,
"quality" => true,
"convertVideo" => $setting['convert_video'] === 'open',
"compressVideo" => $setting['compress_video'] === 'open',
]);
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
$first = $data['data'];
$fileName = $first['name'];
return $first;
};
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
}
/**
* 发送消息文件
*
@ -1066,24 +1154,18 @@ class WebSocketDialog extends AbstractModel
*/
public static function sendMsgFiles($user, $dialogIds, $files, $image64, $fileName, $replyId, $imageAttachment)
{
$filePath = '';
$result = [];
$data = [];
foreach ($dialogIds as $dialog_id) {
$dialog = WebSocketDialog::checkDialog($dialog_id);
$action = $replyId > 0 ? "reply-$replyId" : "";
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
$first = null;
$resolve = function ($path) use (&$first, &$fileName, $files, $image64) {
if ($first !== null) {
return self::copyFileDataTo($first, $path);
}
if ($image64) {
$data = Base::image64save([
"image64" => $image64,
"path" => $path,
"fileName" => $fileName,
"quality" => true
"quality" => true,
]);
} else if ($filePath) {
Base::makeDir(public_path($path));
copy($filePath, public_path($path) . basename($filePath));
} else {
$setting = Base::setting("system");
$data = Base::upload([
@ -1099,19 +1181,41 @@ class WebSocketDialog extends AbstractModel
if (Base::isError($data)) {
throw new ApiException($data['msg']);
}
$fileData = $data['data'];
$filePath = $fileData['file'];
$fileName = $fileData['name'];
$fileData['thumb'] = Base::unFillUrl($fileData['thumb']);
$fileData['size'] *= 1024;
$first = $data['data'];
$fileName = $first['name'];
return $first;
};
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
}
// 任务群组保存文件
/**
* 遍历多个 dialog 发送文件消息:每个 dialog 取一份 fileData 任务群组建附件 sendMsg。
* fileData 的策略由 $resolve(path) 决定(首次 upload后续 copy
*
* @param User $user
* @param int[] $dialogIds
* @param int $replyId
* @param bool $imageAttachment
* @param callable $resolve fn(string $path): array 返回该 dialog fileData
* @return array sendMsg 的最终返回(最后一个 dialog 的结果)
*/
private static function dispatchFileMessages($user, array $dialogIds, int $replyId, bool $imageAttachment, callable $resolve): array
{
$result = [];
foreach ($dialogIds as $dialog_id) {
$dialog = WebSocketDialog::checkDialog($dialog_id);
$action = $replyId > 0 ? "reply-$replyId" : "";
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
$fileData = $resolve($path);
$fileData['thumb'] = Base::unFillUrl($fileData['thumb'] ?? '');
$fileData['size'] *= 1024;
$task = null;
if ($dialog->group_type === 'task') {
// 如果是图片不保存
// 图片消息默认不作为任务附件存档,除非显式 $imageAttachment
if ($imageAttachment || !in_array($fileData['ext'], File::imageExt)) {
$task = ProjectTask::whereDialogId($dialog->id)->first();
if ($task) {
$file = ProjectTaskFile::createInstance([
ProjectTaskFile::createInstance([
'project_id' => $task->project_id,
'task_id' => $task->id,
'name' => $fileData['name'],
@ -1120,20 +1224,30 @@ class WebSocketDialog extends AbstractModel
'path' => $fileData['path'],
'thumb' => $fileData['thumb'],
'userid' => $user->userid,
]);
$file->save();
])->save();
}
}
}
// 发送消息
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'file', $fileData, $user->userid);
if (Base::isSuccess($result)) {
if (isset($task)) {
$result['data']['task_id'] = $task->id;
}
if (Base::isSuccess($result) && $task) {
$result['data']['task_id'] = $task->id;
}
}
return $result;
}
/**
* 把首个 dialog 上传得到的物理文件 copy 到后续 dialog 的目录,返回更新后的 fileData。
*/
private static function copyFileDataTo(array $first, string $path): array
{
Base::makeDir(public_path($path));
$target = public_path($path) . basename($first['file']);
copy($first['file'], $target);
$copy = $first;
$copy['file'] = $target;
$copy['path'] = $path . basename($first['file']);
$copy['url'] = Base::fillUrl($copy['path']);
return $copy;
}
}

View File

@ -414,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('此消息不支持设待办');
@ -423,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();
@ -477,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

View File

@ -2,6 +2,8 @@
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\WebSocketDialogMsgTodo
*
@ -25,6 +27,10 @@ namespace App\Models;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTodo whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTodo whereMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgTodo whereUserid($value)
* @property \Illuminate\Support\Carbon|null $remind_at 提醒时间
* @property \Illuminate\Support\Carbon|null $reminded_at 已提醒时间
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebSocketDialogMsgTodo whereRemindAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebSocketDialogMsgTodo whereRemindedAt($value)
* @mixin \Eloquent
*/
class WebSocketDialogMsgTodo extends AbstractModel
@ -50,4 +56,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();
}
}

View File

@ -43,6 +43,8 @@ namespace App\Models;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereTopAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogUser whereUserid($value)
* @property int $role 0=普通成员 1=群主 2=群管理员
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebSocketDialogUser whereRole($value)
* @mixin \Eloquent
*/
class WebSocketDialogUser extends AbstractModel

View File

@ -21,7 +21,8 @@ class AI
'ollama',
'zhipu',
'qianwen',
'wenxin'
'wenxin',
'dooai'
];
protected const OPENAI_DEFAULT_MODEL = 'gpt-5.1-mini';
@ -140,7 +141,31 @@ class AI
* @param mixed $contextInput
* @return array
*/
public static function createStreamKey($modelType, $modelName, $contextInput = [])
/**
* 判定当前用户是否启用 ai-kb RAG灰度判定
*
* 规则(参考 config/ai.php
* - 总开关 rag_enabled=false 关闭所有kill switch
* - rag_canary_userids 为空 全员启用
* - 否则仅白名单 userid 启用
*/
public static function ragEnabledFor(int $userid): bool
{
if (!config('ai.rag_enabled', true)) {
return false;
}
$raw = trim((string) config('ai.rag_canary_userids', ''));
if ($raw === '') {
return true;
}
$allow = array_filter(array_map(
fn($v) => (int) trim($v),
explode(',', $raw)
), fn($v) => $v > 0);
return in_array($userid, $allow, true);
}
public static function createStreamKey($modelType, $modelName, $contextInput = [], $locale = 'zh', $ragEnabled = true, $contextKey = '', $fd = 0)
{
$modelType = trim((string)$modelType);
$modelName = trim((string)$modelName);
@ -221,6 +246,14 @@ class AI
'model_type' => $remoteModelType,
'model_name' => $modelName,
'context' => $contextJson,
'locale' => $locale,
// ai-kb 灰度透传1 启用 RAGhint + search_help_docs tool0 关闭
'rag_enabled' => $ragEnabled ? '1' : '0',
// 前端会话IDAI 服务存为 context_key 用于检索打点关联
'context_key' => mb_substr(trim((string)$contextKey), 0, 100),
// AI 助手路径启用 doo 执行工具fd 为用户当前 WebSocket 连接页面操作用0 表示无)
'doo_enabled' => '1',
'fd' => intval($fd),
];
$baseUrl = trim((string)($setting[$modelType . '_base_url'] ?? ''));
@ -867,7 +900,84 @@ class AI
}
/**
* 通过 OpenAI 兼容接口获取文本的 Embedding 向量
* 调用 ai 插件的 /embeddings 端点批量向量化(免费向量模型,零配置)。
*
* 走主程序 ai 插件的内网调用,共享主程序 APP_KEY 鉴权;
* 向量维度由 ai 插件的模型决定,主程序不再传 dimensions。
*
* @param array $texts 文本数组
* @return array retSuccess(data=向量数组的数组,与输入同序,缺失位置为 []) / retError
*/
protected static function requestPluginEmbeddings(array $texts)
{
$texts = array_values($texts);
$count = count($texts);
if ($count === 0) {
return Base::retSuccess("success", []);
}
$post = json_encode(["input" => $texts]);
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . (string) config('app.key'),
];
$timeout = $count > 1 ? 120 : 30;
$res = Ihttp::ihttp_request(self::embeddingsUrl(), $post, $headers, $timeout);
if (Base::isError($res)) {
return Base::retError("Embedding 接口请求失败", $res);
}
$resData = Base::json2array($res['data']);
// 先识别端点的业务错误Ihttp 对 401/500 等带响应体的状态同样返回成功标志,
// 必须按响应里的 code 字段判错,否则鉴权错配等故障只会报"格式错误"难以定位
$resCode = intval($resData['code'] ?? 0);
if ($resCode !== 0 && $resCode !== 200) {
return Base::retError("Embedding 接口错误 [{$resCode}]: " . ($resData['error'] ?? 'unknown'), $resData);
}
if (empty($resData['data']) || !is_array($resData['data'])) {
return Base::retError("Embedding 接口返回数据格式错误", $resData);
}
// 记录当前实际生效的向量模型(查询缓存键与模型变化检测依赖它)
$model = (string) ($resData['model'] ?? '');
if ($model !== '' && Cache::get('ai:embedding_model') !== $model) {
Cache::forever('ai:embedding_model', $model);
}
// 按 index 回填,保证与输入顺序对齐,缺失位置留 []
$vectors = array_fill(0, $count, []);
foreach ($resData['data'] as $item) {
$idx = $item['index'] ?? null;
if ($idx === null || !isset($vectors[$idx])) {
continue;
}
$embedding = $item['embedding'] ?? [];
if (is_array($embedding) && !empty($embedding)) {
$vectors[$idx] = $embedding;
}
}
return Base::retSuccess("success", $vectors);
}
/**
* ai 插件 /embeddings 端点地址
*
* 查询侧(本类)与 Manticore 表定义ManticoreBase::vectorColumnDDL共用的唯一来源
* 两侧必须指向同一端点,否则查询向量与存量向量来自不同模型导致语义搜索错乱。
*/
public static function embeddingsUrl(): string
{
$host = config('dootask.ai_host', 'ai');
$port = (int) config('dootask.ai_port', 5001);
return "http://{$host}:{$port}/embeddings";
}
/**
* 通过 ai 插件的免费向量模型获取文本的 Embedding 向量
*
* @param string $text 需要转换的文本
* @param bool $noCache 是否禁用缓存
@ -883,50 +993,24 @@ class AI
return Base::retError('文本内容不能为空');
}
// 截断过长的文本(OpenAI 限制 8191 tokens约 32K 字符)
// 截断过长的文本(约 32K 字符)
$text = mb_substr($text, 0, 30000);
$cacheKey = "openAIEmbedding::" . md5($text);
// 缓存键带上当前生效的模型标识:切换向量模型后旧查询缓存自动失效,
// 避免最长 7 天内用旧模型向量去搜新模型索引(模型未知时为空串)
$model = (string) Cache::get('ai:embedding_model', '');
$cacheKey = "embeddingV2::" . md5($model . '|' . $text);
if ($noCache) {
Cache::forget($cacheKey);
}
$provider = self::resolveEmbeddingProvider();
if (!$provider) {
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $provider) {
$payload = [
"model" => $provider['model'],
"input" => $text,
];
// 统一向量维度为 1536与 Manticore 配置一致)
// OpenAI、智谱等支持 dimensions 参数的厂商需要显式指定
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
if ($supportsDimensions) {
$payload['dimensions'] = 1536;
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setUrlPath('/embeddings');
$ai->setTimeout(30);
$res = $ai->request(true);
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text) {
$res = self::requestPluginEmbeddings([$text]);
if (Base::isError($res)) {
return Base::retError("Embedding 请求失败", $res);
return $res;
}
$resData = Base::json2array($res['data']);
if (empty($resData['data'][0]['embedding'])) {
return Base::retError("Embedding 接口返回数据格式错误", $resData);
}
$embedding = $resData['data'][0]['embedding'];
$embedding = $res['data'][0] ?? [];
if (!is_array($embedding) || empty($embedding)) {
return Base::retError("Embedding 向量为空");
}
@ -940,193 +1024,4 @@ class AI
return $result;
}
/**
* 批量获取文本的 Embedding 向量
* OpenAI API 原生支持批量输入,一次请求处理多个文本
*
* @param array $texts 文本数组(最多 100 条)
* @param bool $noCache 是否禁用缓存
* @return array 返回结果,成功时 data 为向量数组的数组(与输入顺序对应)
*/
public static function getBatchEmbeddings(array $texts, $noCache = false)
{
if (!Apps::isInstalled('ai')) {
return Base::retError('应用「AI Assistant」未安装');
}
if (empty($texts)) {
return Base::retSuccess("success", []);
}
// 限制批量大小
// OpenAI 限制:最多 2048 条,单次请求合计最多 300,000 tokens
// 这里限制 500 条,假设平均每条 500 tokens合计 250,000 tokens
$texts = array_slice($texts, 0, 500);
// 准备结果数组,并检查缓存
$results = [];
$uncachedTexts = [];
$uncachedIndices = [];
foreach ($texts as $index => $text) {
if (empty($text)) {
$results[$index] = [];
continue;
}
// 截断过长的文本
$text = mb_substr($text, 0, 30000);
$texts[$index] = $text; // 更新截断后的文本
$cacheKey = "openAIEmbedding::" . md5($text);
if ($noCache) {
Cache::forget($cacheKey);
}
// 检查缓存
if (!$noCache && Cache::has($cacheKey)) {
$cached = Cache::get($cacheKey);
if (Base::isSuccess($cached)) {
$results[$index] = $cached['data'];
continue;
}
}
// 未命中缓存,加入待请求列表
$uncachedTexts[] = $text;
$uncachedIndices[] = $index;
}
// 如果所有文本都在缓存中
if (empty($uncachedTexts)) {
// 按原始顺序返回
ksort($results);
return Base::retSuccess("success", array_values($results));
}
// 获取 provider
$provider = self::resolveEmbeddingProvider();
if (!$provider) {
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
}
// 构建批量请求
$payload = [
"model" => $provider['model'],
"input" => $uncachedTexts,
];
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
if ($supportsDimensions) {
$payload['dimensions'] = 1536;
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setUrlPath('/embeddings');
$ai->setTimeout(120); // 批量请求需要更长超时
$res = $ai->request(true);
if (Base::isError($res)) {
return Base::retError("批量 Embedding 请求失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['data'])) {
return Base::retError("Embedding 接口返回数据格式错误", $resData);
}
// 处理返回的向量并写入缓存
foreach ($resData['data'] as $item) {
$itemIndex = $item['index'] ?? null;
if ($itemIndex === null || !isset($uncachedIndices[$itemIndex])) {
continue;
}
$originalIndex = $uncachedIndices[$itemIndex];
$embedding = $item['embedding'] ?? [];
if (!empty($embedding) && is_array($embedding)) {
$results[$originalIndex] = $embedding;
} else {
$results[$originalIndex] = [];
}
}
// 填充未获取到向量的位置
foreach ($uncachedIndices as $originalIndex) {
if (!isset($results[$originalIndex])) {
$results[$originalIndex] = [];
}
}
// 按原始顺序返回
ksort($results);
return Base::retSuccess("success", array_values($results));
}
/**
* 获取 Embedding 模型配置
*
* @return array|null
*/
protected static function resolveEmbeddingProvider()
{
$setting = Base::setting('aibotSetting');
if (!is_array($setting)) {
$setting = [];
}
// 优先使用 OpenAI支持 embedding 接口)
$key = trim((string)($setting['openai_key'] ?? ''));
if ($key !== '') {
$baseUrl = trim((string)($setting['openai_base_url'] ?? ''));
$baseUrl = $baseUrl ?: 'https://api.openai.com/v1';
$agency = trim((string)($setting['openai_agency'] ?? ''));
return [
'vendor' => 'openai',
'model' => 'text-embedding-3-small',
'api_key' => $key,
'base_url' => rtrim($baseUrl, '/'),
'agency' => $agency,
];
}
$vendorDefaults = [
'deepseek' => [
'base_url' => 'https://api.deepseek.com',
'model' => 'deepseek-embedding',
],
'zhipu' => [
'base_url' => 'https://open.bigmodel.cn/api/paas/v4',
'model' => 'embedding-3',
],
];
// 尝试其他支持 embedding 的服务(如 deepseek、zhipu、qianwen 等)
foreach ($vendorDefaults as $vendor => $defaults) {
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
if ($key !== '') {
$baseUrl = trim((string)($setting[$vendor . '_base_url'] ?? ''));
$baseUrl = $baseUrl ?: $defaults['base_url']; // 使用配置或默认值
$agency = trim((string)($setting[$vendor . '_agency'] ?? ''));
return [
'vendor' => $vendor,
'model' => $defaults['model'],
'api_key' => $key,
'base_url' => rtrim($baseUrl, '/'),
'agency' => $agency,
];
}
}
return null;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\Setting;
use App\Models\User;
use App\Models\UserDepartment;
use App\Services\RequestContext;
@ -29,14 +30,7 @@ class Apps
return (bool) RequestContext::get($key, false);
}
$configFile = base_path('docker/appstore/config/' . $appId . '/config.yml');
$installed = false;
if (file_exists($configFile)) {
$configData = Yaml::parseFile($configFile);
$installed = $configData['status'] === 'installed';
}
return RequestContext::save($key, $installed);
return RequestContext::save($key, self::loadInstalledConfig($appId) !== null);
}
/**
@ -62,6 +56,180 @@ class Apps
}
}
/**
* appstore 目录下的绝对路径(统一 docker/appstore 前缀)。
*
* @param string $relative 相对 docker/appstore 的路径
* @return string
*/
private static function appstorePath(string $relative): string
{
return base_path('docker/appstore/' . ltrim($relative, '/'));
}
/**
* 读取并校验某应用的 appstore config.yml仅当文件存在且 status=installed 时返回解析后的配置数组。
*
* @param string $appId
* @return array|null
*/
private static function loadInstalledConfig(string $appId): ?array
{
$appId = trim($appId);
if ($appId === '' || $appId === 'appstore') {
return null;
}
$configFile = self::appstorePath("config/{$appId}/config.yml");
if (!file_exists($configFile)) {
return null;
}
$config = Yaml::parseFile($configFile);
if (!is_array($config) || ($config['status'] ?? '') !== 'installed') {
return null;
}
return $config;
}
/**
* 将单个 menu_items 项映射为角标用的菜单配置。
*
* @param array $menu
* @param mixed $visibleDefault 应用级默认可见范围
* @return array ['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool]
*/
private static function mapMenuItem(array $menu, $visibleDefault): array
{
return [
'key' => trim((string)($menu['key'] ?? '')),
'visible' => Setting::normalizeCustomMicroVisible($menu['visible_to'] ?? $visibleDefault),
'badge_clear_on_open' => (bool)($menu['badge_clear_on_open'] ?? false),
];
}
/**
* 获取(必要时生成并持久化)应用的独立密钥 APP_SECRET。
*
* 与全局 APP_KEY 不同APP_SECRET 每个已安装应用独立、唯一,持久化在应用自身的
* docker/appstore/config/{appid}/config.yml与其它每应用安装参数同源
* appstore 安装链路按内置 compose 变量 APP_SECRET 注入插件容器。
* 此处主程序侧负责生成/持久化与校验;首次需要时若不存在则惰性生成,保证主程序可独立验证。
*
* @param string $appId 应用ID
* @return string 应用密钥;应用未安装或非插件应用时返回空字符串
*/
public static function appSecret(string $appId): string
{
$appId = trim($appId);
$config = self::loadInstalledConfig($appId);
if ($config === null) {
return '';
}
$secret = trim((string)($config['app_secret'] ?? ''));
if ($secret !== '') {
return $secret;
}
// 首次需要时生成并持久化(按 appid 唯一)
$secret = Base::generatePassword(48);
$config['app_secret'] = $secret;
try {
file_put_contents(self::appstorePath("config/{$appId}/config.yml"), Yaml::dump($config, 4, 2));
} catch (\Throwable $e) {
info('[app_badge] persist app_secret fail', ['appid' => $appId, 'error' => $e->getMessage()]);
return '';
}
return $secret;
}
/**
* 解析应用的菜单角标配置(菜单 key 列表与各自的可见范围)。
*
* 同时覆盖两类应用:
* - 插件应用:读取 docker/appstore/apps/{appid}/{version}/config.yml menu_items
* - 自定义微应用:读取 microapp_menu 设置
*
* @param string $appId 应用ID
* @return array|null ['source'=>'plugin'|'custom', 'menus'=>[['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool], ...]];应用不存在返回 null
*/
public static function appMenuConfig(string $appId): ?array
{
$appId = trim($appId);
if ($appId === '') {
return null;
}
// 插件应用
$config = self::loadInstalledConfig($appId);
if ($config !== null) {
$version = trim((string)($config['install_version'] ?? ''));
return [
'source' => 'plugin',
'menus' => self::readPluginMenus($appId, $version),
];
}
// 自定义微应用
$apps = Base::setting('microapp_menu');
if (is_array($apps)) {
foreach ($apps as $app) {
if (!is_array($app) || trim((string)($app['id'] ?? '')) !== $appId) {
continue;
}
$appVisibleDefault = $app['visible_to'] ?? 'admin';
$menus = [];
foreach (($app['menu_items'] ?? []) as $menu) {
if (!is_array($menu)) {
continue;
}
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
}
return ['source' => 'custom', 'menus' => $menus];
}
}
return null;
}
/**
* 读取插件包 config.yml 的菜单配置。
*
* @param string $appId
* @param string $version 已安装版本
* @return array
*/
private static function readPluginMenus(string $appId, string $version): array
{
$paths = [];
if ($version !== '') {
$paths[] = self::appstorePath("apps/{$appId}/{$version}/config.yml");
}
$paths[] = self::appstorePath("apps/{$appId}/config.yml");
$pkg = null;
foreach ($paths as $p) {
if (file_exists($p) && is_readable($p)) {
try {
$pkg = Yaml::parseFile($p);
} catch (\Throwable $e) {
$pkg = null;
}
if (is_array($pkg)) {
break;
}
}
}
$menus = [];
if (is_array($pkg) && !empty($pkg['menu_items']) && is_array($pkg['menu_items'])) {
$appVisibleDefault = $pkg['visible_to'] ?? 'all';
foreach ($pkg['menu_items'] as $menu) {
if (!is_array($menu)) {
continue;
}
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
}
}
if (empty($menus)) {
// 读不到包配置(权限/缺失)时退化为单一默认菜单,仍可对第一个菜单设角标
$menus[] = ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
}
return $menus;
}
/**
* Dispatch user lifecycle hook to appstore (user_onboard/user_offboard/user_update).
*
@ -72,7 +240,7 @@ class Apps
*/
public static function dispatchUserHook(User $user, string $action, string $eventType = '', array $changedFields = []): void
{
$appKey = env('APP_KEY', '');
$appKey = config('app.key') ?: '';
if (empty($appKey)) {
info('[appstore_hook] APP_KEY is empty, skip dispatchUserHook');
return;

329
app/Module/Badge.php Normal file
View File

@ -0,0 +1,329 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\AppBadge;
use App\Models\Setting;
use App\Models\User;
use App\Tasks\PushTask;
/**
* 插件 / 微应用菜单角标业务编排。
*
* 角标真值归插件(应用密钥写入),主程序仅作为存储与分发:
* - 绝对设置/清除 app_badges 行(仅存非清除态)
* - 通过 WebSocketPushTask向在线用户实时推送 appBadge 消息
* - 提供初始同步所需的用户角标快照
*/
class Badge
{
/**
* 设置角标(应用密钥鉴权场景):校验密钥/菜单/可见性,绝对设置并实时推送。
*
* @param string $appId
* @param string $secret 请求携带的应用密钥
* @param mixed $userid 目标用户ID单个或数组
* @param string $menuKeyInput 请求中的 menu_key可空留空取第一个菜单
* @param mixed $count 角标数字
* @param mixed $dot 是否红点
* @return array 响应数据
* @throws ApiException 参数/密钥/应用/菜单校验失败
*/
public static function set(string $appId, string $secret, $userid, string $menuKeyInput, $count, $dot): array
{
if ($appId === '') {
throw new ApiException('参数错误');
}
if ($secret === '') {
throw new ApiException('密钥无效');
}
$expect = Apps::appSecret($appId);
if ($expect === '' || !hash_equals($expect, $secret)) {
throw new ApiException('密钥无效');
}
$menu = self::resolveAppMenu($appId, $menuKeyInput);
$menuKey = (string)($menu['key'] ?? '');
$userids = self::normalizeUserids($userid);
if (empty($userids)) {
throw new ApiException('参数错误');
}
// 仅保留对该应用菜单有可见权限的用户
$userids = self::filterVisibleUserids($menu, $userids);
if (empty($userids)) {
return ['appid' => $appId, 'menu_key' => $menuKey, 'affected' => 0];
}
$count = max(0, intval($count));
$dot = filter_var($dot, FILTER_VALIDATE_BOOLEAN);
self::applySet($appId, $menuKey, $userids, $count, $dot);
self::push($appId, $menuKey, $userids, $count, $dot);
return [
'appid' => $appId,
'menu_key' => $menuKey,
'count' => $count,
'dot' => $dot,
'affected' => count($userids),
];
}
/**
* 清除指定用户在某应用某菜单的角标(用户 token 鉴权场景),并推送多端一致。
*
* @param int $userid 当前用户ID
* @param string $appId
* @param string $menuKeyInput 请求中的 menu_key可空
* @return array 响应数据
* @throws ApiException 参数/应用/菜单校验失败
*/
public static function clearForUser(int $userid, string $appId, string $menuKeyInput): array
{
if ($userid <= 0) {
throw new ApiException('参数错误');
}
if ($appId === '') {
throw new ApiException('参数错误');
}
$menu = self::resolveAppMenu($appId, $menuKeyInput);
$menuKey = (string)($menu['key'] ?? '');
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereUserid($userid)->delete();
// 推送给该用户的所有在线端,保证多端一致
self::push($appId, $menuKey, [$userid], 0, false);
return [
'appid' => $appId,
'menu_key' => $menuKey,
];
}
/**
* 解析应用菜单配置并定位目标菜单。
*
* @param string $appId
* @param string $menuKeyInput
* @return array 命中的菜单配置
* @throws ApiException 应用未安装或菜单不存在
*/
private static function resolveAppMenu(string $appId, string $menuKeyInput): array
{
$config = Apps::appMenuConfig($appId);
if ($config === null) {
throw new ApiException('应用未安装');
}
$menu = self::resolveMenu($config['menus'], $menuKeyInput);
if ($menu === null) {
throw new ApiException('菜单不存在');
}
return $menu;
}
/**
* 归一化目标用户ID单值/数组 -> 去重去零的整型数组。
*
* @param mixed $userid
* @return int[]
*/
private static function normalizeUserids($userid): array
{
if (is_string($userid) || is_numeric($userid)) {
$userid = [$userid];
}
if (!is_array($userid)) {
return [];
}
return self::intIds($userid);
}
/**
* 数组 -> 去重去零的整型数组。
*
* @param array $ids
* @return int[]
*/
private static function intIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
}
/**
* 解析目标菜单menu_key 为空时取第一个菜单;否则必须命中已声明的菜单 key。
*
* @param array $menus appMenuConfig 返回的 menus
* @param string $menuKey 请求中的 menu_key可空
* @return array|null 命中的菜单配置;非法 menu_key 返回 null
*/
private static function resolveMenu(array $menus, string $menuKey): ?array
{
if ($menuKey === '') {
return $menus[0] ?? ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
}
foreach ($menus as $menu) {
if (($menu['key'] ?? '') === $menuKey) {
return $menu;
}
}
return null;
}
/**
* 按菜单可见范围过滤目标用户,仅保留对该应用菜单有权限的用户。
*
* @param array $menu 命中的菜单配置(含 visible
* @param int[] $userids
* @return int[] 允许的用户ID
*/
private static function filterVisibleUserids(array $menu, array $userids): array
{
if (empty($userids)) {
return [];
}
$visible = $menu['visible'] ?? ['all'];
if (in_array('all', $visible)) {
return $userids;
}
$allowed = [];
$users = User::whereIn('userid', $userids)->get(['userid', 'identity']);
foreach ($users as $user) {
if (Setting::isCustomMicroVisibleTo($visible, $user->isAdmin(), (int)$user->userid)) {
$allowed[] = (int)$user->userid;
}
}
return $allowed;
}
/**
* 绝对设置角标幂等。count=0 dot=false 即清除(删行)。
*
* @param string $appId
* @param string $menuKey
* @param int[] $userids
* @param int $count
* @param bool $dot
* @return void
*/
private static function applySet(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
{
if (empty($userids)) {
return;
}
// 清除态:一条 whereIn 删除
if ($count === 0 && !$dot) {
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereIn('userid', $userids)->delete();
return;
}
// 非清除态:依赖唯一键 (app_id,menu_key,userid) 批量 upsert
$now = date('Y-m-d H:i:s');
$rows = array_map(fn($uid) => [
'app_id' => $appId,
'menu_key' => $menuKey,
'userid' => (int)$uid,
'count' => $count,
'dot' => $dot,
'updated_at' => $now,
], $userids);
AppBadge::upsert($rows, ['app_id', 'menu_key', 'userid'], ['count', 'dot', 'updated_at']);
}
/**
* 清除某应用的全部角标(应用卸载时)。
*
* @param string $appId
* @return void
*/
public static function clearByApp(string $appId): void
{
$appId = trim($appId);
if ($appId === '') {
return;
}
AppBadge::whereAppId($appId)->delete();
}
/**
* 清除某用户的全部角标(用户离职时)。
*
* @param int $userid
* @return void
*/
public static function clearByUser(int $userid): void
{
if ($userid <= 0) {
return;
}
AppBadge::whereUserid($userid)->delete();
}
/**
* 获取用户当前全部角标快照,用于前端初始同步。
* 过滤掉应用已不存在(卸载 / 自定义微应用被删除)的行,避免父级聚合统计残留数据。
*
* @param int $userid
* @return array app_id => menu_key => ['count'=>int,'dot'=>bool]
*/
public static function userBadges(int $userid): array
{
$map = [];
if ($userid <= 0) {
return $map;
}
$rows = AppBadge::whereUserid($userid)->get(['app_id', 'menu_key', 'count', 'dot']);
if ($rows->isEmpty()) {
return $map;
}
// 自定义微应用 id 集合一次性收,避免每行重复 foreach
$customIds = [];
$customApps = Base::setting('microapp_menu');
if (is_array($customApps)) {
foreach ($customApps as $app) {
if (is_array($app) && !empty($app['id'])) {
$customIds[(string)$app['id']] = true;
}
}
}
// 按 app_id 缓存判定结果:插件应用走 Apps::isInstalled单 yaml + 请求级缓存),
// 自定义微应用查 set避免每行都读 yaml / 遍历 setting。
$exists = [];
foreach ($rows as $row) {
$appId = (string)$row->app_id;
if (!isset($exists[$appId])) {
$exists[$appId] = isset($customIds[$appId]) || Apps::isInstalled($appId);
}
if (!$exists[$appId]) {
continue;
}
$map[$appId][$row->menu_key] = [
'count' => (int)$row->count,
'dot' => (bool)$row->dot,
];
}
return $map;
}
/**
* 向在线用户实时推送角标变更(仅投递,不补发离线)。
*
* @param string $appId
* @param string $menuKey
* @param int[] $userids
* @param int $count
* @param bool $dot
* @return void
*/
public static function push(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
{
$userids = self::intIds($userids);
if (empty($userids)) {
return;
}
PushTask::push([
'userid' => $userids,
'msg' => [
'type' => 'appBadge',
'data' => [
'appid' => $appId,
'menu_key' => $menuKey,
'count' => $count,
'dot' => $dot,
],
],
], false);
}
}

View File

@ -14,7 +14,7 @@ use Overtrue\Pinyin\Pinyin;
use Redirect;
use Request;
use Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\File;
use Validator;
@ -848,6 +848,13 @@ class Base
*/
public static function getSchemeAndHost()
{
// 优先用当前请求的协议+主机getScheme() 会经 TrustProxies 采信 X-Forwarded-Proto
// 从而正确识别 httpshost 取自 Host 头(不信 X-Forwarded-Host避免 Host 注入)
$request = request();
if ($request instanceof \Illuminate\Http\Request && $request->getHttpHost()) {
return $request->getSchemeAndHttpHost();
}
// 非请求上下文Task/命令行等)的兜底
$scheme = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == '443' ? 'https://' : 'http://';
return $scheme.($_SERVER['HTTP_HOST'] ?? '');
}
@ -2067,8 +2074,20 @@ class Base
*/
public static function upload($param)
{
// 可选 key 默认值,下游直接访问不会 undefined index
$param += [
'chmod' => 0644,
'saveName' => null,
'scale' => null,
'size' => 0,
'fileName' => null,
'quality' => null,
'autoThumb' => null,
'convertVideo' => null,
'compressVideo' => null,
];
$file = $param['file'];
$chmod = $param['chmod'] ?: 0644;
$chmod = $param['chmod'];
if (empty($file)) {
return Base::retError("您没有选择要上传的文件");
}
@ -2123,6 +2142,9 @@ class Base
$limitSize = intval($param['size']);
if ($limitSize <= 0) {
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
if ($fileUploadLimit <= 0) {
$fileUploadLimit = 1024;
}
$limitSize = $fileUploadLimit * 1024;
}
try {
@ -2310,6 +2332,27 @@ class Base
}
}
/**
* 把本地文件包装成 UploadedFile(test=true) 转给 Base::upload复用全套上传逻辑。
* @param array $param path_local + name 为本方法特有,其余与 Base::upload 一致
*/
public static function uploadFromPath(array $param)
{
$localPath = $param['path_local'] ?? '';
$name = $param['name'] ?? '';
if (!$localPath || !is_file($localPath)) {
return Base::retError('源文件不存在');
}
if (!$name) {
$name = basename($localPath);
}
unset($param['path_local'], $param['name']);
// test=true → UploadedFile::move 走 rename(),绕开 move_uploaded_file 的 is_uploaded_file 校验
$param['file'] = new \Illuminate\Http\UploadedFile($localPath, $name, null, null, true);
$param['fileName'] = $param['fileName'] ?? $name;
return self::upload($param);
}
/**
* 上传文件移动
* @param array $uploadResult
@ -2582,6 +2625,23 @@ class Base
return $array;
}
/**
* 创建 zip 压缩包并添加文件,条目名取文件 basename等价旧 Madzipper::make()->add()->close()
* @param string $zipPath 压缩包路径
* @param string|array $files 要添加的文件路径
*/
public static function zipAddFiles($zipPath, $files)
{
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE) !== true) {
throw new \RuntimeException("Unable to open zip file: " . $zipPath);
}
foreach ((array)$files as $file) {
$zip->addFile($file, basename($file));
}
$zip->close();
}
/**
* 获取中文字符拼音首字母
* @param $str
@ -2597,8 +2657,7 @@ class Base
return '#';
}
if (!preg_match("/^[a-zA-Z]$/", $first)) {
$pinyin = new Pinyin();
$first = $pinyin->abbr($first, '', PINYIN_NAME);
$first = Pinyin::abbr($first, true)->join('');
}
return $first ? strtoupper($first) : '#';
}
@ -2616,8 +2675,7 @@ class Base
}
if (!preg_match("/^[a-zA-Z0-9_.]+$/", $str)) {
$str = Cache::rememberForever("cn2pinyin:" . md5($str . '_' . $delim), function () use ($delim, $str) {
$pinyin = new Pinyin();
return $pinyin->permalink($str, $delim);
return Pinyin::permalink($str, $delim);
});
}
return $str;
@ -2818,14 +2876,17 @@ class Base
/**
* 字节转格式
* @param $bytes
* @param int|float $bytes
* @return string
*/
public static function readableBytes($bytes)
public static function readableBytes(int|float $bytes): string
{
$i = floor(log($bytes) / log(1024));
if ($bytes <= 0) {
return '0 B';
}
$i = (int) floor(log($bytes) / log(1024));
$sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
return sprintf('%.02F', $bytes / pow(1024, $i)) * 1 . ' ' . $sizes[$i];
return (string) ((float) sprintf('%.02F', $bytes / pow(1024, $i))) . ' ' . $sizes[$i];
}
/**
@ -2868,9 +2929,15 @@ class Base
/**
* DownloadFileResponse 下载文件
*
* 返回 Symfony BinaryFileResponse LaravelS/Swoole 环境下由 StaticResponse 走原生
* sendfile() 发送——OS 级零拷贝、不占用 PHP 内存,可支持任意大小文件(如几百 MB 的大文件)。
* 切勿改回 StreamedResponse它会被 LaravelS ob_start()/ob_get_clean() 把整个响应体
* 缓冲进 PHP 内存,大文件会撞 memory_limit 导致下载失败。
*
* @param File|\SplFileInfo|string $file 文件对象或路径
* @param string|null $name 下载文件名
* @return StreamedResponse
* @return BinaryFileResponse
*/
public static function DownloadFileResponse($file, $name = null)
{
@ -2889,12 +2956,6 @@ class Base
throw new FileException('File must be readable and exist.');
}
// 获取文件信息
$size = $file->getSize();
if ($size === false || $size < 0) {
throw new FileException('Unable to determine file size.');
}
// 处理文件名
if (empty($name)) {
$name = basename($file->getPathname());
@ -2912,83 +2973,27 @@ class Base
$mimeType = 'application/octet-stream';
}
// 处理 Range 请求
$start = 0;
$end = $size - 1;
$length = $size;
$isRangeRequest = false;
// BinaryFileResponseautoEtag=false 避免对大文件做 md5/sha1 全文件哈希autoLastModified=true 取 mtime开销极小
$response = new BinaryFileResponse($file, 200, [], true, null, false, true);
$response->headers->set('Content-Type', $mimeType);
$response->headers->set('Cache-Control', 'private, no-transform, no-store, must-revalidate, max-age=0');
// filename 兜底为纯 ASCIIfilename* 用 UTF-8 编码,兼容含中文/特殊字符的文件名
$asciiName = preg_replace('/[^\x20-\x7e]/', '_', $name);
$response->headers->set('Content-Disposition', sprintf(
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
$asciiName,
rawurlencode($name)
));
if (isset($_SERVER['HTTP_RANGE'])) {
$range = str_replace('bytes=', '', $_SERVER['HTTP_RANGE']);
if (preg_match('/^(\d+)-(\d*)$/', $range, $matches)) {
$start = intval($matches[1]);
$end = !empty($matches[2]) ? intval($matches[2]) : $size - 1;
// 验证范围的有效性
if ($start >= 0 && $end < $size && $start <= $end) {
$length = $end - $start + 1;
$isRangeRequest = true;
} else {
$start = 0;
$end = $size - 1;
}
}
// LaravelS/Swoole 下 StaticResponse 用 sendfile() 整文件发送,不支持分段;
// 若放任 Symfony 处理 Range 会返回 206 头却仍发送完整文件,导致内容错位/损坏。
// 故在 Swoole 环境下移除 Range 请求头,始终以 200 返回完整文件。
if (app()->bound('swoole')) {
Request::instance()->headers->remove('Range');
$response->headers->set('Accept-Ranges', 'none');
}
// 设置基本响应头
$headers = [
'Content-Type' => $mimeType,
'Content-Disposition' => sprintf(
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
$name,
rawurlencode($name)
),
'Accept-Ranges' => 'bytes',
'Cache-Control' => 'private, no-transform, no-store, must-revalidate, max-age=0',
'Content-Length' => $length,
'Last-Modified' => gmdate('D, d M Y H:i:s', $file->getMTime()) . ' GMT',
'ETag' => sprintf('"%s"', md5_file($file->getPathname()))
];
if ($isRangeRequest) {
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
$statusCode = 206;
} else {
$statusCode = 200;
}
// 创建流式响应
return new StreamedResponse(
function () use ($file, $start, $length) {
$handle = fopen($file->getPathname(), 'rb');
if ($handle === false) {
throw new FileException('Cannot open file for reading');
}
if (fseek($handle, $start) === -1) {
fclose($handle);
throw new FileException('Cannot seek to position ' . $start);
}
$remaining = $length;
$bufferSize = 8192; // 8KB chunks
while ($remaining > 0 && !feof($handle)) {
$readSize = min($bufferSize, $remaining);
$buffer = fread($handle, $readSize);
if ($buffer === false) {
break;
}
echo $buffer;
flush();
$remaining -= strlen($buffer);
}
fclose($handle);
},
$statusCode,
$headers
);
return $response;
} catch (\Exception $e) {
\Log::error('File download failed', [
'error' => $e->getMessage(),

511
app/Module/ChunkUpload.php Normal file
View File

@ -0,0 +1,511 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\File as FileModel;
use App\Models\FileContent;
use App\Models\User;
use App\Models\WebSocketDialog;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Redis;
/**
* 分片上传核心:状态机与磁盘/Redis 调度。
*
* 流程start receive × N merge (scene dispatcher) cleanup
*
* Redis key:
* upload:{upload_id} JSON 元数据 (TTL 24h)
* upload:{upload_id}:chunks SET 已收分片 index (TTL 24h)
* upload:hash:{userid}:{hash} upload_id 反查(续传 / hash 复用) (TTL 24h)
*
* 磁盘:
* uploads/tmp/chunks/{userid}/{upload_id}/{index}
*/
class ChunkUpload
{
/** 单个分片大小5MB。注意要小于 Swoole package_max_length 1G */
const CHUNK_SIZE = 5 * 1024 * 1024;
/** 状态/反查索引 TTL24h */
const STATE_TTL = 86400;
/** 单文件硬上限KB系统设置之外的兜底保护10G */
const MAX_FILE_KB = 10 * 1024 * 1024;
/** 支持的 scene 枚举 */
const SCENES = ['file_cabinet', 'dialog_file', 'image', 'generic_file'];
/**
* 启动上传。
* - 同用户同 hash 命中 files 秒传
* - 同用户同 hash 命中 upload 反查 续传
* - 否则新建 upload_id
*
* @param User $user
* @param array $param [hash, size(B), name, scene, scene_params(array)]
* @return array
*/
public static function start(User $user, array $param): array
{
$hash = strtolower(trim($param['hash'] ?? ''));
$size = intval($param['size'] ?? 0);
$name = trim($param['name'] ?? '');
$scene = trim($param['scene'] ?? '');
$sceneParams = $param['scene_params'] ?? [];
if (!is_array($sceneParams)) {
$sceneParams = [];
}
if (strlen($hash) !== 32) {
return Base::retError('文件 hash 格式错误');
}
if ($size <= 0) {
return Base::retError('文件大小无效');
}
if (intval(ceil($size / 1024)) > self::MAX_FILE_KB) {
return Base::retError('文件超过系统支持的最大尺寸');
}
// init 时拦截系统配置上限,避免传完分片才在 merge 阶段被 Base::upload 拒绝
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
if ($fileUploadLimit <= 0) {
$fileUploadLimit = 1024;
}
if ($size > $fileUploadLimit * 1024 * 1024) {
return Base::retError('文件大小超限,最大限制:' . $fileUploadLimit . 'MB');
}
if ($name === '') {
return Base::retError('文件名不能为空');
}
if (!in_array($scene, self::SCENES, true)) {
return Base::retError('不支持的上传场景');
}
// 1) 秒传:同用户已上传过同 hash 文件 → 直接复用入库
$hit = self::trySecondPass($user, $scene, $hash, $name, $sceneParams);
if ($hit !== null) {
return Base::retSuccess('success', $hit);
}
// 2) 续传:同用户同 hash 有未完成上传
$reuseKey = self::keyHashIndex($user->userid, $hash);
$existingId = Redis::get($reuseKey);
if ($existingId) {
$meta = self::loadMeta($existingId);
if ($meta && $meta['userid'] === $user->userid && $meta['hash'] === $hash) {
return Base::retSuccess('success', self::sessionView($existingId, $meta));
}
// 反查指向了已失效的 upload_id清掉
Redis::del($reuseKey);
}
// 3) 新建
$uploadId = Base::generatePassword(32);
$chunkCount = intval(ceil($size / self::CHUNK_SIZE));
$meta = [
'hash' => $hash,
'size' => $size,
'name' => $name,
'scene' => $scene,
'scene_params' => $sceneParams,
'userid' => intval($user->userid),
'chunk_size' => self::CHUNK_SIZE,
'chunk_count' => $chunkCount,
'created_at' => time(),
];
Redis::setex(self::keyMeta($uploadId), self::STATE_TTL, json_encode($meta, JSON_UNESCAPED_UNICODE));
Redis::setex($reuseKey, self::STATE_TTL, $uploadId);
Base::makeDir(self::chunkDir($user->userid, $uploadId));
return Base::retSuccess('success', self::sessionView($uploadId, $meta));
}
/**
* 接收一个分片。
*
* @param User $user
* @param string $uploadId
* @param int $index 分片序号0-based
* @param UploadedFile|null $blob
* @return array
*/
public static function receive(User $user, string $uploadId, int $index, $blob): array
{
$meta = self::loadMeta($uploadId);
if (!$meta) {
return Base::retError('上传会话不存在或已过期');
}
if ($meta['userid'] !== intval($user->userid)) {
return Base::retError('上传会话归属错误');
}
if ($index < 0 || $index >= $meta['chunk_count']) {
return Base::retError('分片序号超出范围');
}
if (!$blob || !$blob->isValid()) {
return Base::retError('分片数据无效');
}
// 最后一片可能小于 CHUNK_SIZE其余必须等于
$isLast = $index === $meta['chunk_count'] - 1;
$chunkSize = $blob->getSize();
if (!$isLast && $chunkSize !== self::CHUNK_SIZE) {
return Base::retError('分片大小不符合预期');
}
if ($isLast) {
$expectLast = $meta['size'] - self::CHUNK_SIZE * ($meta['chunk_count'] - 1);
if ($chunkSize !== $expectLast) {
return Base::retError('末尾分片大小不符合预期');
}
}
$dir = self::chunkDir($user->userid, $uploadId);
Base::makeDir($dir);
$blob->move($dir, (string)$index);
// 记录已收 + 续期三个相关 key
Redis::sadd(self::keyChunks($uploadId), $index);
Redis::expire(self::keyChunks($uploadId), self::STATE_TTL);
Redis::expire(self::keyMeta($uploadId), self::STATE_TTL);
Redis::expire(self::keyHashIndex($user->userid, $meta['hash']), self::STATE_TTL);
return Base::retSuccess('success', [
'upload_id' => $uploadId,
'received' => self::receivedList($uploadId),
]);
}
/**
* 合并分片并入库。需要在 Lock 内调用。
*
* @param User $user
* @param string $uploadId
* @return array scene 入库返回结构(与 retSuccess/retError 对齐)
*/
public static function merge(User $user, string $uploadId): array
{
$meta = self::loadMeta($uploadId);
if (!$meta) {
return Base::retError('上传会话不存在或已过期');
}
if ($meta['userid'] !== intval($user->userid)) {
return Base::retError('上传会话归属错误');
}
$received = self::receivedList($uploadId);
if (count($received) !== $meta['chunk_count']) {
return Base::retError('分片不完整,无法合并');
}
return Lock::withLock("upload:merge:{$uploadId}", function () use ($user, $uploadId, $meta) {
$dir = self::chunkDir($user->userid, $uploadId);
$mergedPath = $dir . '/merged.' . substr($meta['hash'], 0, 8);
$writeFp = @fopen($mergedPath, 'wb');
if (!$writeFp) {
return Base::retError('无法创建合并文件');
}
// 拼接与 md5 同步进行:一遍磁盘读完成"写文件 + 算 hash"
$hashCtx = hash_init('md5');
try {
for ($i = 0; $i < $meta['chunk_count']; $i++) {
$partPath = $dir . '/' . $i;
$readFp = @fopen($partPath, 'rb');
if (!$readFp) {
return Base::retError("分片读取失败:{$i}");
}
while (!feof($readFp)) {
$buf = fread($readFp, 1024 * 1024);
if ($buf === false) {
fclose($readFp);
return Base::retError("分片读取失败:{$i}");
}
fwrite($writeFp, $buf);
hash_update($hashCtx, $buf);
}
fclose($readFp);
}
} finally {
fclose($writeFp);
}
$actualHash = hash_final($hashCtx);
if ($actualHash !== $meta['hash']) {
@unlink($mergedPath);
return Base::retError('文件校验失败,请重试');
}
// 调用 scene 入库
$result = self::dispatch($user, $meta, $mergedPath);
// 清理(无论成功失败都清,失败用户重新启 upload
self::cleanup($user->userid, $uploadId, $meta['hash']);
return $result;
}, 60000, 60000);
}
/**
* 用户主动取消:校验归属后清理。会话不存在或归属错误一律静默成功,前端取消按钮不需要分支处理。
*/
public static function cancelByUser(User $user, string $uploadId): void
{
$meta = self::loadMeta($uploadId);
if (!$meta || intval($meta['userid'] ?? 0) !== $user->userid) {
return;
}
self::cleanup($user->userid, $uploadId, $meta['hash'] ?? '');
}
/**
* 清理一个 upload_id 的所有状态。
*/
public static function cleanup(int $userid, string $uploadId, string $hash = ''): void
{
Redis::del(self::keyMeta($uploadId));
Redis::del(self::keyChunks($uploadId));
if ($hash) {
Redis::del(self::keyHashIndex($userid, $hash));
}
$dir = self::chunkDir($userid, $uploadId);
if (is_dir($dir)) {
Base::deleteDirAndFile($dir);
}
}
// ===== scene dispatcher =====
/**
* 把合并后的本地文件交给对应 scene 入库。
* 返回结构对齐各 scene 老接口的 retSuccess。
*/
protected static function dispatch(User $user, array $meta, string $mergedPath): array
{
$scene = $meta['scene'];
$name = $meta['name'];
$hash = $meta['hash'];
$params = $meta['scene_params'] ?? [];
switch ($scene) {
case 'file_cabinet':
$pid = intval($params['pid'] ?? 0);
$webkitRelativePath = strval($params['webkit_relative_path'] ?? $name);
$overwrite = boolval($params['overwrite'] ?? false);
// pid 锁避免与并发上传的 handleDuplicateName / 中间目录创建竞态
try {
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite) {
$result = (new FileModel)->contentUploadFromPath($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite);
$outName = $result['data']['name'] ?? $name;
return Base::retSuccess($outName . ' 上传成功', $result['addItem']);
}, 120000, 120000);
} catch (ApiException $e) {
return Base::retError($e->getMessage());
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
return Base::retError('上传繁忙,请稍后再试');
}
return Base::retError($e->getMessage());
}
case 'image':
// 头像 / 系统图片 / 编辑器粘贴图片,对齐 system/imgupload
$width = intval($params['width'] ?? 0);
$height = intval($params['height'] ?? 0);
$whcut = strval($params['whcut'] ?? 'percentage');
$whcut = match ($whcut) {
'1' => 'cover',
'0' => 'contain',
'cover', 'contain' => $whcut,
default => 'percentage',
};
$scale = [$width ?: 2160, $height ?: 4160, $whcut];
$imagePath = "uploads/user/picture/" . $user->userid . "/" . date("Ym") . "/";
$data = Base::uploadFromPath([
"path_local" => $mergedPath,
"name" => $name,
"type" => 'image',
"path" => $imagePath,
"scale" => $scale,
"quality" => true,
]);
if (Base::isError($data)) {
return $data;
}
return Base::retSuccess('success', $data['data']);
case 'generic_file':
// 编辑器粘贴文件 / 系统通用文件,对齐 system/fileupload
$filePath = "uploads/user/file/" . $user->userid . "/" . date("Ym") . "/";
$data = Base::uploadFromPath([
"path_local" => $mergedPath,
"name" => $name,
"type" => 'file',
"path" => $filePath,
"quality" => true,
]);
return $data;
case 'dialog_file':
// 聊天发文件 + 任务附件共用同一接入(任务附件本质是任务对话流的一条消息)
$dialogIds = $params['dialog_ids'] ?? [];
if (!is_array($dialogIds)) {
$dialogIds = [$dialogIds];
}
$dialogIds = array_values(array_filter(array_map('intval', $dialogIds)));
if (empty($dialogIds)) {
return Base::retError('dialog_ids 不能为空');
}
$replyId = intval($params['reply_id'] ?? 0);
$imageAttachment = boolval($params['image_attachment'] ?? false);
try {
return WebSocketDialog::sendMsgFilesFromPath($user, $dialogIds, $mergedPath, $name, $replyId, $imageAttachment);
} catch (ApiException $e) {
return Base::retError($e->getMessage());
}
default:
return Base::retError("scene 暂未实现:{$scene}");
}
}
/**
* hash 命中则在目标位置复用源 FileContent 指向的物理文件,零字节传输。
* 未命中返回 null 让上层走真上传。
*/
protected static function trySecondPass(User $user, string $scene, string $hash, string $name, array $sceneParams): ?array
{
if ($scene !== 'file_cabinet') {
return null;
}
$hit = FileModel::whereUserid($user->userid)->whereHash($hash)->whereNull('deleted_at')->first();
if (!$hit) {
return null;
}
$srcContent = FileContent::whereFid($hit->id)->orderByDesc('id')->first();
if (!$srcContent) {
return null;
}
$contentArr = is_array($srcContent->content)
? $srcContent->content
: json_decode($srcContent->content, true);
if (empty($contentArr['url'])) {
return null;
}
$rawPid = intval($sceneParams['pid'] ?? 0);
$webkitRelativePath = strval($sceneParams['webkit_relative_path'] ?? $name);
$overwrite = boolval($sceneParams['overwrite'] ?? false);
try {
return Lock::withLock("file:upload:{$user->userid}:{$rawPid}", function () use ($user, $rawPid, $webkitRelativePath, $overwrite, $hit, $hash, $name, $contentArr) {
[$pid, $userid, $addItem] = (new FileModel)->contentUploadPrep($user, $rawPid, $webkitRelativePath);
$ext = $hit->ext;
$bareName = Base::rightDelete($name, '.' . $ext);
$existing = null;
if ($overwrite) {
$existing = FileModel::wherePid($pid)->whereName($bareName)->whereExt($ext)->whereNull('deleted_at')->first();
}
if ($existing) {
$existing->size = $hit->size;
$existing->hash = $hash;
$existing->type = $hit->type;
if (!$existing->saveBeforePP()) {
throw new ApiException('秒传保存失败');
}
FileContent::createInstance([
'fid' => $existing->id,
'content' => $contentArr,
'text' => '',
'size' => $existing->size,
'userid' => $user->userid,
])->save();
$created = FileModel::find($existing->id);
$overwriteFlag = 1;
} else {
$newFile = FileModel::createInstance([
'pid' => $pid,
'name' => $bareName,
'type' => $hit->type,
'ext' => $ext,
'size' => $hit->size,
'hash' => $hash,
'userid' => $userid,
'created_id' => $user->userid,
]);
$newFile->handleDuplicateName();
if (!$newFile->saveBeforePP()) {
throw new ApiException('秒传保存失败');
}
FileContent::createInstance([
'fid' => $newFile->id,
'content' => $contentArr,
'text' => '',
'size' => $newFile->size,
'userid' => $user->userid,
])->save();
$created = FileModel::find($newFile->id);
$overwriteFlag = 0;
}
$created->pushMsg($overwriteFlag ? 'update' : 'add', $created);
$data = FileModel::handleImageUrl($created->toArray());
$data['full_name'] = $name;
$data['overwrite'] = $overwriteFlag;
$addItem[] = $data;
return [
'done' => true,
'instant' => true,
'addItem' => $addItem,
'msg' => $name . ' 秒传成功',
];
}, 120000, 120000);
} catch (\Throwable $_e) {
// 退化到真上传:错误由 dispatch 阶段权威报出,避免两条路径错误码不一致
return null;
}
}
// ===== helpers =====
protected static function keyMeta(string $uploadId): string
{
return "upload:{$uploadId}";
}
protected static function keyChunks(string $uploadId): string
{
return "upload:{$uploadId}:chunks";
}
protected static function keyHashIndex(int $userid, string $hash): string
{
return "upload:hash:{$userid}:{$hash}";
}
protected static function chunkDir(int $userid, string $uploadId): string
{
return public_path("uploads/tmp/chunks/{$userid}/{$uploadId}");
}
protected static function loadMeta(string $uploadId): ?array
{
$raw = Redis::get(self::keyMeta($uploadId));
if (!$raw) {
return null;
}
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
protected static function receivedList(string $uploadId): array
{
$list = Redis::smembers(self::keyChunks($uploadId)) ?: [];
$list = array_map('intval', $list);
sort($list);
return $list;
}
protected static function sessionView(string $uploadId, array $meta): array
{
return [
'done' => false,
'upload_id' => $uploadId,
'chunk_size' => $meta['chunk_size'],
'chunk_count' => $meta['chunk_count'],
'received' => self::receivedList($uploadId),
];
}
}

View File

@ -0,0 +1,538 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\ProjectUser;
use App\Models\Setting;
use App\Models\User;
use App\Models\UserDepartment;
use Carbon\Carbon;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* 仪表盘负责人视角数据聚合。
*
* 口径:所选管理部门(含下级部门)的在职成员所参与、且允许负责人查看的未归档项目;
* 仅统计主任务、全员可见任务,任务需由范围内成员负责或当前没有负责人。
*/
class DashboardTeam
{
public const SOON_DAYS = 3;
public const HIGH_PRIORITY_COUNT = 2;
public const STATS_CACHE_SECONDS = 30;
/**
* 解析并校验负责人视角范围。
*/
public static function context(User $user, $selectedDepartmentIds = null): array
{
if (Base::settingFind('system', 'department_owner_project_view', 'open') !== 'open') {
throw new ApiException('未开启部门负责人视角功能');
}
$managedIds = UserDepartment::getManagedDepartments($user->userid)
->pluck('id')
->map(fn($id) => intval($id))
->values()
->toArray();
if (empty($managedIds)) {
throw new ApiException('没有可查看的部门数据');
}
$selectedIds = self::normalizeSelectedDepartmentIds($selectedDepartmentIds, $managedIds);
if (empty($selectedIds)) {
throw new ApiException('没有可查看的部门数据');
}
$departmentIds = self::expandDepartmentIds($selectedIds);
$members = self::loadMembers($departmentIds);
$memberUserids = $members->pluck('userid')->map(fn($id) => intval($id))->values()->toArray();
$projectIds = self::loadProjectIds($memberUserids);
$ownProjectIds = empty($projectIds) ? [] : ProjectUser::whereUserid($user->userid)
->whereIn('project_id', $projectIds)
->pluck('project_id')
->map(fn($id) => intval($id))
->unique()
->values()
->toArray();
return [
'viewer_userid' => intval($user->userid),
'selected_department_ids' => $selectedIds,
'department_ids' => $departmentIds,
'member_userids' => $memberUserids,
'member_map' => $members->keyBy('userid')->map(fn(User $member) => [
'userid' => intval($member->userid),
'nickname' => $member->nickname,
'userimg' => $member->userimg,
])->toArray(),
'project_ids' => $projectIds,
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
];
}
/**
* 团队统计。
*/
public static function stats(array $context, bool $refresh = false): array
{
$cacheKey = 'dashboard:team:stats:v3:' . $context['viewer_userid'] . ':' . sha1(json_encode([
$context['selected_department_ids'],
$context['department_ids'],
$context['member_userids'],
$context['project_ids'],
]));
if ($refresh) {
Cache::forget($cacheKey);
}
return Cache::remember($cacheKey, now()->addSeconds(self::STATS_CACHE_SECONDS), function () use ($context) {
$now = Carbon::now();
$taskAlias = DB::getTablePrefix() . 't';
$soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay();
$weekStart = $now->clone()->startOfWeek(Carbon::MONDAY)->startOfDay();
$nextWeekStart = $weekStart->clone()->addWeek();
$lastWeekStart = $weekStart->clone()->subWeek();
$base = self::baseTaskBuilder($context);
$core = (clone $base)->selectRaw("
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL THEN 1 ELSE 0 END) AS stat_uncompleted,
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN 1 ELSE 0 END) AS stat_overdue,
SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at >= ? AND {$taskAlias}.end_at <= ? THEN 1 ELSE 0 END) AS stat_due_soon,
SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_week_completed,
SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_last_week_completed
", [
$now->toDateTimeString(),
$now->toDateTimeString(),
$soonEnd->toDateTimeString(),
$weekStart->toDateTimeString(),
$nextWeekStart->toDateTimeString(),
$lastWeekStart->toDateTimeString(),
$weekStart->toDateTimeString(),
])->first();
$noOwner = (clone $base)
->whereNull('t.complete_at')
->whereNotExists(self::ownerExistsQuery('t.id'))
->count();
$members = self::memberDistribution($context, $now);
return [
'generated_at' => $now->toDateTimeString(),
'member_count' => count($context['member_userids']),
'blocks' => [
'uncompleted' => intval($core->stat_uncompleted ?? 0),
'overdue' => intval($core->stat_overdue ?? 0),
'overdue_owner_count' => count(array_filter($members, fn($member) => $member['overdue'] > 0)),
'due_soon' => intval($core->stat_due_soon ?? 0),
'week_completed' => intval($core->stat_week_completed ?? 0),
'last_week_completed' => intval($core->stat_last_week_completed ?? 0),
'no_owner' => intval($noOwner),
],
'priority' => self::priorityDistribution($context),
'members' => $members,
'high_levels' => self::highPriorityLevels(),
];
});
}
/**
* 分页任务列表。
*/
public static function tasks(array $context, array $filters)
{
$now = Carbon::now();
$soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay();
$memberId = intval($filters['member_id'] ?? 0);
$level = $filters['level'] ?? null;
$type = $filters['type'] ?? '';
$builder = self::baseTaskBuilder($context)->whereNull('t.complete_at');
if ($memberId > 0) {
$builder->whereExists(self::ownerExistsQuery('t.id', [$memberId]));
} elseif ($level !== null) {
if (intval($level) === -1) {
$levels = self::priorityLevels();
if (!empty($levels)) {
$builder->whereNotIn('t.p_level', $levels);
}
} else {
$builder->where('t.p_level', intval($level));
}
} else {
switch ($type) {
case 'overdue':
$builder->whereNotNull('t.end_at')->where('t.end_at', '<', $now);
break;
case 'soon':
$builder->whereNotNull('t.end_at')->whereBetween('t.end_at', [$now, $soonEnd]);
break;
case 'hi':
$levels = self::highPriorityLevels();
$builder->whereIn('t.p_level', $levels ?: [-1]);
break;
case 'noowner':
$builder->whereNotExists(self::ownerExistsQuery('t.id'));
break;
}
}
$taskAlias = DB::getTablePrefix() . 't';
$builder->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id')
->select([
't.id',
't.parent_id',
't.project_id',
't.column_id',
't.name',
't.end_at',
't.p_level',
't.p_name',
't.p_color',
't.flow_item_id',
't.flow_item_name',
'p.name as project_name',
'fi.status as flow_item_status',
'fi.color as flow_item_color',
])
->orderByRaw("{$taskAlias}.end_at IS NULL")
->orderBy('t.end_at')
->orderByDesc('t.id');
$list = $builder->paginate(Base::getPaginate(50, 20));
$taskIds = $list->getCollection()->pluck('id')->map(fn($id) => intval($id))->toArray();
$owners = self::taskOwners($taskIds, $context['member_userids'], $memberId);
$ownProjectMap = $context['own_project_id_map'];
$list->setCollection($list->getCollection()->map(function ($task) use ($owners, $ownProjectMap) {
$item = (array)$task;
$item['id'] = intval($item['id']);
$item['parent_id'] = intval($item['parent_id']);
$item['project_id'] = intval($item['project_id']);
$item['column_id'] = intval($item['column_id']);
$item['p_level'] = intval($item['p_level']);
$flowParts = explode('|', $item['flow_item_name'] ?: '');
if (count($flowParts) >= 2) {
$item['flow_item_status'] = $item['flow_item_status'] ?: ($flowParts[0] ?? '');
$item['flow_item_name'] = $flowParts[1] ?? $item['flow_item_name'];
$item['flow_item_color'] = $item['flow_item_color'] ?: ($flowParts[2] ?? '');
}
$item['owners'] = $owners[$item['id']] ?? [];
$item['owner'] = $item['owners'][0] ?? null;
$item['department_readonly'] = !isset($ownProjectMap[$item['project_id']]);
return $item;
}));
return $list;
}
/**
* 当前系统优先级ID列表保持设置顺序
* @return array<int>
*/
public static function priorityLevels(): array
{
return array_map(fn($item) => intval($item['priority']), self::priorityList());
}
/**
* 基础任务范围:主任务、全员可见、未归档项目,且由团队成员负责或没有负责人。
*/
protected static function baseTaskBuilder(array $context): Builder
{
$projectIds = $context['project_ids'] ?: [0];
$memberUserids = $context['member_userids'];
return DB::table('project_tasks as t')
->join('projects as p', 'p.id', '=', 't.project_id')
->whereIn('t.project_id', $projectIds)
->where('t.parent_id', 0)
->where('t.visibility', 1)
->whereNull('t.archived_at')
->whereNull('t.deleted_at')
->whereNull('p.archived_at')
->whereNull('p.deleted_at')
->where(function (Builder $query) {
$query->where('p.department_owner_view', '<>', 'close')
->orWhereNull('p.department_owner_view');
})
->where(function (Builder $query) use ($memberUserids) {
if (empty($memberUserids)) {
$query->whereRaw('1 = 0');
return;
}
$query->whereExists(self::ownerExistsQuery('t.id', $memberUserids))
->orWhereNotExists(self::ownerExistsQuery('t.id'));
});
}
/**
* 负责人存在子查询。
*/
protected static function ownerExistsQuery(string $taskColumn, ?array $userids = null): callable
{
return function (Builder $query) use ($taskColumn, $userids) {
$query->selectRaw('1')
->from('project_task_users as owner_scope')
->whereColumn('owner_scope.task_id', $taskColumn)
->where('owner_scope.owner', 1);
if ($userids !== null) {
$query->whereIn('owner_scope.userid', $userids ?: [0]);
}
};
}
/**
* 优先级分布(包含无负责人任务)。
*/
protected static function priorityDistribution(array $context): array
{
$taskAlias = DB::getTablePrefix() . 't';
$rows = self::baseTaskBuilder($context)
->whereNull('t.complete_at')
->selectRaw("{$taskAlias}.p_level, COUNT(*) AS stat_num")
->groupBy('t.p_level')
->pluck('stat_num', 't.p_level');
$result = [];
$matched = [];
foreach (self::priorityList() as $item) {
$level = intval($item['priority']);
$matched[] = $level;
$result[] = [
'level' => $level,
'name' => $item['name'],
'color' => $item['color'],
'num' => intval($rows->get($level, 0)),
];
}
$unset = 0;
foreach ($rows as $level => $num) {
if (!in_array(intval($level), $matched, true)) {
$unset += intval($num);
}
}
if ($unset > 0) {
$result[] = [
'level' => -1,
'name' => '',
'color' => '#c5c8ce',
'num' => $unset,
];
}
return $result;
}
/**
* 成员任务分配。流程阶段与超期风险独立统计,无工作流的普通未完成任务归入待处理;
* 多负责人任务会分别计入每个负责人的工作量,团队总数仍按任务去重。
*/
protected static function memberDistribution(array $context, Carbon $now): array
{
$memberUserids = $context['member_userids'];
if (empty($memberUserids) || empty($context['project_ids'])) {
return array_values(array_map(fn($member) => array_merge($member, [
'total' => 0,
'overdue' => 0,
'segments' => ['progress' => 0, 'start' => 0, 'test' => 0],
]), $context['member_map']));
}
$nowText = $now->toDateTimeString();
$prefix = DB::getTablePrefix();
$taskAlias = $prefix . 't';
$taskUserAlias = $prefix . 'tu';
$flowAlias = $prefix . 'fi';
$rows = DB::table('project_task_users as tu')
->join('project_tasks as t', 't.id', '=', 'tu.task_id')
->join('projects as p', 'p.id', '=', 't.project_id')
->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id')
->whereIn('tu.userid', $memberUserids)
->where('tu.owner', 1)
->whereIn('t.project_id', $context['project_ids'])
->where('t.parent_id', 0)
->where('t.visibility', 1)
->whereNull('t.complete_at')
->whereNull('t.archived_at')
->whereNull('t.deleted_at')
->whereNull('p.archived_at')
->whereNull('p.deleted_at')
->where(function (Builder $query) {
$query->where('p.department_owner_view', '<>', 'close')
->orWhereNull('p.department_owner_view');
})
->selectRaw("
{$taskUserAlias}.userid,
COUNT(DISTINCT {$taskAlias}.id) AS stat_total,
COUNT(DISTINCT CASE WHEN {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN {$taskAlias}.id END) AS stat_overdue,
COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'progress' THEN {$taskAlias}.id END) AS stat_progress,
COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'test' THEN {$taskAlias}.id END) AS stat_test,
COUNT(DISTINCT CASE WHEN {$flowAlias}.status IS NULL OR {$flowAlias}.status NOT IN ('progress', 'test') THEN {$taskAlias}.id END) AS stat_start
", [$nowText])
->groupBy('tu.userid')
->get()
->keyBy(fn($row) => intval($row->userid));
$members = [];
foreach ($context['member_map'] as $userid => $member) {
$row = $rows->get(intval($userid));
$members[] = array_merge($member, [
'total' => intval($row->stat_total ?? 0),
'overdue' => intval($row->stat_overdue ?? 0),
'segments' => [
'progress' => intval($row->stat_progress ?? 0),
'start' => intval($row->stat_start ?? 0),
'test' => intval($row->stat_test ?? 0),
],
]);
}
usort($members, function ($a, $b) {
return $b['overdue'] <=> $a['overdue']
?: $b['total'] <=> $a['total']
?: $a['userid'] <=> $b['userid'];
});
return $members;
}
/**
* 任务负责人数据,范围内成员优先展示;成员筛选时将该成员置首。
*/
protected static function taskOwners(array $taskIds, array $memberUserids, int $preferredUserid = 0): array
{
if (empty($taskIds)) {
return [];
}
$rows = DB::table('project_task_users')
->whereIn('task_id', $taskIds)
->where('owner', 1)
->orderBy('id')
->get(['task_id', 'userid']);
$userids = $rows->pluck('userid')->map(fn($id) => intval($id))->unique()->values()->toArray();
$users = empty($userids) ? collect() : User::select(User::$basicField)
->whereIn('userid', $userids)
->get()
->keyBy('userid');
$memberMap = array_fill_keys($memberUserids, true);
$owners = [];
foreach ($rows as $row) {
$userid = intval($row->userid);
$user = $users->get($userid);
$owners[intval($row->task_id)][] = [
'userid' => $userid,
'nickname' => $user?->nickname ?? '',
];
}
foreach ($owners as &$list) {
usort($list, function ($a, $b) use ($memberMap, $preferredUserid) {
if ($preferredUserid > 0) {
$preferred = ($b['userid'] === $preferredUserid) <=> ($a['userid'] === $preferredUserid);
if ($preferred !== 0) {
return $preferred;
}
}
$managed = isset($memberMap[$b['userid']]) <=> isset($memberMap[$a['userid']]);
return $managed !== 0 ? $managed : $a['userid'] <=> $b['userid'];
});
}
unset($list);
return $owners;
}
/**
* 在全部部门中一次性展开下级部门,避免递归 N+1
*/
protected static function expandDepartmentIds(array $selectedIds): array
{
$children = UserDepartment::select(['id', 'parent_id'])
->get()
->groupBy(fn(UserDepartment $department) => intval($department->parent_id));
$result = [];
$queue = array_values(array_unique(array_map('intval', $selectedIds)));
while (!empty($queue)) {
$departmentId = array_shift($queue);
if ($departmentId <= 0 || isset($result[$departmentId])) {
continue;
}
$result[$departmentId] = true;
foreach ($children->get($departmentId, collect()) as $child) {
$queue[] = intval($child->id);
}
}
return array_keys($result);
}
/**
* 当前范围内的在职非机器人成员。users.department 为现有权限口径的权威数据源。
*/
protected static function loadMembers(array $departmentIds): Collection
{
if (empty($departmentIds)) {
return collect();
}
return User::select(User::$basicField)
->whereNull('disable_at')
->where('bot', 0)
->where(function ($query) use ($departmentIds) {
foreach ($departmentIds as $departmentId) {
$query->orWhere('department', 'like', "%,{$departmentId},%");
}
})
->orderBy('userid')
->get();
}
/**
* 成员参与且允许负责人查看的活动项目。
*/
protected static function loadProjectIds(array $memberUserids): array
{
if (empty($memberUserids)) {
return [];
}
return DB::table('project_users as pu')
->join('projects as p', 'p.id', '=', 'pu.project_id')
->whereIn('pu.userid', $memberUserids)
->whereNull('p.archived_at')
->whereNull('p.deleted_at')
->where(function (Builder $query) {
$query->where('p.department_owner_view', '<>', 'close')
->orWhereNull('p.department_owner_view');
})
->distinct()
->orderBy('p.id')
->pluck('p.id')
->map(fn($id) => intval($id))
->toArray();
}
protected static function normalizeSelectedDepartmentIds($selectedIds, array $managedIds): array
{
if ($selectedIds === null || $selectedIds === '' || $selectedIds === 'all' || $selectedIds === []) {
return $managedIds;
}
if (!is_array($selectedIds)) {
$selectedIds = explode(',', (string)$selectedIds);
}
return array_values(array_unique(array_intersect(
array_map('intval', $selectedIds),
$managedIds
)));
}
protected static function highPriorityLevels(): array
{
return array_slice(self::priorityLevels(), 0, self::HIGH_PRIORITY_COUNT);
}
protected static function priorityList(): array
{
return Setting::normalizeTaskPriorityList(Base::setting('priority'));
}
}

View File

@ -53,7 +53,7 @@ class Doo
*/
public static function licenseContent(): string
{
if (env("SYSTEM_LICENSE") == 'hidden') {
if (config('dootask.system_license') == 'hidden') {
return '';
}
$paths = [
@ -81,6 +81,39 @@ class Doo
self::load()->licenseSave($license);
}
/**
* 解析License取字段
* @param $license
* @return array
*/
public static function licenseDecode($license): array
{
return self::load()->licenseDecode($license);
}
/**
* 校验 license SN/MAC 是否与本机匹配。通过返回 null,否则返回错误文案。
* @param array $info license 信息,含 people/sn/macmac 兼容数组或逗号串)
* @return string|null
*/
public static function licenseBindingError(array $info): ?string
{
$people = (int)($info['people'] ?? 0);
if (!($people === 0 || $people > 3)) {
return null;
}
if ((string)($info['sn'] ?? '') !== self::dooSN()) {
return '终端SN与License不匹配';
}
$mac = $info['mac'] ?? [];
$licenseMacs = array_filter(array_map('trim', is_array($mac) ? $mac : explode(',', (string)$mac)));
$curMacs = self::macs();
if ($licenseMacs && $curMacs && !array_intersect($licenseMacs, $curMacs)) {
return '终端MAC与License不匹配';
}
return null;
}
/**
* 当前会员ID来自请求的token
* @return int

View File

@ -14,6 +14,8 @@ class Ihttp
}
if(!empty($urlset['query'])) {
$urlset['query'] = "?{$urlset['query']}";
} else {
$urlset['query'] = '';
}
if(empty($urlset['port'])) {
$urlset['port'] = $urlset['scheme'] == 'https' ? '443' : '80';
@ -194,13 +196,14 @@ class Ihttp
$rlt['status'] = $matches[3];
$rlt['responseline'] = $split2[0];
$header = explode("\r\n", $split2[1]);
$rlt['headers'] = [];
$isgzip = false;
$ischunk = false;
foreach ($header as $v) {
$row = explode(':', $v);
$key = trim($row[0]);
$value = trim(substr($v, strlen($row[0]) + 1));
if (is_array($rlt['headers'][$key])) {
if (isset($rlt['headers'][$key]) && is_array($rlt['headers'][$key])) {
$rlt['headers'][$key][] = $value;
} elseif (!empty($rlt['headers'][$key])) {
$temp = $rlt['headers'][$key];

View File

@ -8,6 +8,7 @@ use App\Module\Base;
use App\Module\AI;
use PDO;
use PDOException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
@ -21,6 +22,30 @@ class ManticoreBase
private static ?PDO $pdo = null;
private static bool $initialized = false;
/**
* 向量表结构版本;修改表结构/向量列参数时递增,触发已部署实例自动重建
*/
private const SCHEMA_VERSION = 1;
/**
* Auto Embeddings MODEL_NAME。必须用 Manticore 不认识的名字:
* 已知名字(如 text-embedding-ada-002)会按引擎硬编码维度校验,
* 未知名字才会在建表时向 API_URL 探测真实维度(免费模型为 1024)。
* 实际模型由 ai 插件的 EMBEDDING_MODEL 决定,此处仅为路由标签。
*/
private const EMBEDDING_MODEL_NAME = 'openai/qwen3-embedding';
/**
* 批量写入分块上限行数与字节预算Manticore max_allowed_packet 默认 128MB取保守值
*/
private const BATCH_CHUNK_ROWS = 30;
private const BATCH_CHUNK_BYTES = 8388608;
/**
* 5 张向量表名(键值即 VECTOR_TABLE_CONFIG type
*/
private const VECTOR_TABLES = ['msg', 'file', 'task', 'project', 'user'];
private string $host;
private int $port;
@ -29,8 +54,8 @@ class ManticoreBase
*/
public function __construct()
{
$this->host = env('SEARCH_HOST', 'search');
$this->port = (int) env('SEARCH_PORT', 9306);
$this->host = config('dootask.search_host');
$this->port = (int) config('dootask.search_port');
}
/**
@ -70,13 +95,169 @@ class ManticoreBase
/**
* 初始化表结构
*
* 向量列使用 Manticore Auto EmbeddingsMODEL_NAME/API_URL 指向 ai 插件 /embeddings
* 引擎在写入/更新行时自动按 FROM 字段生成向量,无需 PHP 侧生成。
* key_values 中的 vector:schema 标记记录当前结构指纹(结构版本/模型/端点/APP_KEY 哈希),
* 不匹配首次安装、老版本升级、APP_KEY 轮换)即整体重建并重置同步指针,触发全量重灌。
*/
private function initializeTables(PDO $pdo): void
{
try {
// 创建文件向量表
// charset_table='non_cjk, cjk' 同时支持英文和中日韩文字
// 键值表必须最先建(用于读取/持久化结构标记与同步指针)
$pdo->exec("
CREATE TABLE IF NOT EXISTS key_values (
id BIGINT,
k STRING,
v TEXT
)
");
$expected = self::schemaMarker();
if (self::kvGetPdo($pdo, 'vector:schema') === $expected
&& self::allVectorTablesExist($pdo)
&& !self::embeddingModelChanged($pdo)) {
return;
}
// 并发进程只允许一个执行重建;抢锁失败的进程本轮写入失败会进重试队列,无碍
$lock = Cache::lock('manticore:schema-rebuild', 300);
if (!$lock->get()) {
return;
}
try {
// 拿到锁时可能另一进程刚完成重建marker 写入在锁内),先复检避免重复重建
if (self::kvGetPdo($pdo, 'vector:schema') === $expected
&& self::allVectorTablesExist($pdo)
&& !self::embeddingModelChanged($pdo)) {
return;
}
// 先用一次性探针表验证 ai 端点可用CREATE 会向端点探测维度、未就绪则抛错),
// 通过后才 DROP 现有表——避免 ai 未就绪时销毁旧索引后建不回来
$pdo->exec("DROP TABLE IF EXISTS _schema_probe");
$pdo->exec("CREATE TABLE _schema_probe (t TEXT, " . self::vectorColumnDDL('t') . ")");
$pdo->exec("DROP TABLE IF EXISTS _schema_probe");
foreach (self::vectorTableDDLs() as $table => $ddl) {
$pdo->exec("DROP TABLE IF EXISTS {$table}");
$pdo->exec($ddl);
}
self::resetSyncPointersPdo($pdo);
// 清理旧向量管道遗留键vector:dim 与 vector:*LastId 指针)
$legacy = ["'vector:dim'"];
foreach (self::VECTOR_TABLES as $t) {
$legacy[] = "'vector:manticore" . ucfirst($t) . "LastId'";
}
$pdo->exec("DELETE FROM key_values WHERE k IN (" . implode(',', $legacy) . ")");
self::rememberEmbeddingModel($pdo);
// marker 最后写入且在锁内:写入即代表重建完整成功
self::kvSetPdo($pdo, 'vector:schema', $expected);
Log::info("Manticore vector tables rebuilt for auto-embeddings schema {$expected}");
} catch (\Throwable $e) {
// 重建失败(如 ai 插件未就绪/未升级):不写 marker下个进程重试可自愈
Log::error('Manticore schema rebuild failed: ' . $e->getMessage());
} finally {
$lock->release();
}
} catch (\Throwable $e) {
Log::error('Manticore initializeTables failed: ' . $e->getMessage());
}
}
/**
* ai 插件向量化端点地址(唯一来源在 AI::embeddingsUrl查询侧与表定义共用
*/
private static function embeddingsApiUrl(): string
{
return AI::embeddingsUrl();
}
/**
* 写入表定义的派生密钥sha256(APP_KEY:embeddings),与 ai 插件约定一致。
* 不直接写 APP_KEYLaravel 主密钥),避免其落入搜索引擎元数据/数据卷;
* 派生值不可反推,且仅授予 /embeddings 调用权限。
*/
private static function embeddingsApiKey(): string
{
return hash('sha256', config('app.key') . ':embeddings');
}
/**
* 当前向量表结构指纹:结构版本/模型名/端点/APP_KEY 哈希任一变化都会触发整体重建
*/
private static function schemaMarker(): string
{
return md5(self::SCHEMA_VERSION . '|' . self::EMBEDDING_MODEL_NAME . '|'
. self::embeddingsApiUrl() . '|' . hash('sha256', (string) config('app.key')));
}
/**
* 检测 ai 插件实际生效的向量模型是否与建表时不一致(不一致需重建,否则维度可能不匹配)。
*
* 实际模型EMBEDDING_MODEL env对主程序不可见由查询侧在成功请求后
* 写入缓存 ai:embedding_model建表时的模型记录在 key_values vector:model。
* 任一侧未知时不触发(返回 false),已知且存量缺失时顺手补记。
*/
private static function embeddingModelChanged(PDO $pdo): bool
{
$live = (string) Cache::get('ai:embedding_model', '');
if ($live === '') {
return false;
}
$stored = self::kvGetPdo($pdo, 'vector:model');
if ($stored === null || $stored === '') {
// 旧部署/首次:补记当前模型,不触发重建
self::kvSetPdo($pdo, 'vector:model', $live);
return false;
}
return $stored !== $live;
}
/**
* 重建成功后记录当前生效的向量模型(优先取查询侧维护的缓存值)
*/
private static function rememberEmbeddingModel(PDO $pdo): void
{
$live = (string) Cache::get('ai:embedding_model', '');
if ($live !== '') {
self::kvSetPdo($pdo, 'vector:model', $live);
} else {
// 未知则清掉存量,待查询侧探得后由 embeddingModelChanged 补记
$pdo->exec("DELETE FROM key_values WHERE k = 'vector:model'");
}
}
/**
* 生成 Auto Embeddings 向量列定义(引擎按 FROM 字段自动生成/更新向量)
*
* 注意不写 KNN_DIMS MODEL_NAME 互斥),维度由引擎建表时向端点探测。
*
* @param string $from 参与向量化的字段(镜像旧 PHP 管道的拼接字段,逗号分隔)
*/
private static function vectorColumnDDL(string $from): string
{
return "content_vector float_vector knn_type='hnsw' hnsw_similarity='cosine'"
. " MODEL_NAME='" . self::EMBEDDING_MODEL_NAME . "'"
. " FROM='{$from}'"
. " API_KEY='" . self::embeddingsApiKey() . "'"
. " API_URL='" . self::embeddingsApiUrl() . "'"
. " API_TIMEOUT='60'";
}
/**
* 生成 5 张向量表的建表语句
*
* charset_table='non_cjk, cjk' 同时支持英文和中日韩文字
*
* @return array [table => DDL]
*/
private static function vectorTableDDLs(): array
{
$tail = "\n ) charset_table='non_cjk, cjk' morphology='icu_chinese'";
return [
'file_vectors' => "
CREATE TABLE IF NOT EXISTS file_vectors (
id BIGINT,
file_id BIGINT,
@ -87,21 +268,8 @@ class ManticoreBase
file_ext STRING,
content TEXT,
allowed_users MULTI,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建键值存储表
$pdo->exec("
CREATE TABLE IF NOT EXISTS key_values (
id BIGINT,
k STRING,
v TEXT
)
");
// 创建用户向量表
$pdo->exec("
" . self::vectorColumnDDL('file_name,content') . $tail,
'user_vectors' => "
CREATE TABLE IF NOT EXISTS user_vectors (
id BIGINT,
userid BIGINT,
@ -110,12 +278,8 @@ class ManticoreBase
profession TEXT,
tags TEXT,
introduction TEXT,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建项目向量表
$pdo->exec("
" . self::vectorColumnDDL('nickname,email,profession,tags,introduction') . $tail,
'project_vectors' => "
CREATE TABLE IF NOT EXISTS project_vectors (
id BIGINT,
project_id BIGINT,
@ -124,12 +288,8 @@ class ManticoreBase
project_name TEXT,
project_desc TEXT,
allowed_users MULTI,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建任务向量表
$pdo->exec("
" . self::vectorColumnDDL('project_name,project_desc') . $tail,
'task_vectors' => "
CREATE TABLE IF NOT EXISTS task_vectors (
id BIGINT,
task_id BIGINT,
@ -140,12 +300,8 @@ class ManticoreBase
task_desc TEXT,
task_content TEXT,
allowed_users MULTI,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建消息向量表
$pdo->exec("
" . self::vectorColumnDDL('task_name,task_desc,task_content') . $tail,
'msg_vectors' => "
CREATE TABLE IF NOT EXISTS msg_vectors (
id BIGINT,
msg_id BIGINT,
@ -155,13 +311,69 @@ class ManticoreBase
content TEXT,
allowed_users MULTI,
created_at BIGINT,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
" . self::vectorColumnDDL('content') . $tail,
];
}
// Tables initialized successfully
} catch (PDOException $e) {
// 表可能已存在,忽略初始化错误
/**
* 检查 5 张向量表是否都已存在
*/
private static function allVectorTablesExist(PDO $pdo): bool
{
try {
$stmt = $pdo->query("SHOW TABLES");
$existing = [];
foreach ($stmt->fetchAll(PDO::FETCH_NUM) as $row) {
$existing[$row[0]] = true;
}
foreach (array_keys(self::vectorTableDDLs()) as $table) {
if (!isset($existing[$table])) {
return false;
}
}
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 直接用 PDO 读取 key_values避免初始化期通过 ManticoreKeyValue 造成递归)
*/
private static function kvGetPdo(PDO $pdo, string $key): ?string
{
try {
$stmt = $pdo->prepare("SELECT v FROM key_values WHERE k = ?");
$stmt->execute([$key]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return ($row && isset($row['v'])) ? (string)$row['v'] : null;
} catch (\Throwable $e) {
return null;
}
}
/**
* 直接用 PDO 写入 key_values ManticoreKeyValue::set id 规则)
*/
private static function kvSetPdo(PDO $pdo, string $key, string $value): void
{
try {
$del = $pdo->prepare("DELETE FROM key_values WHERE k = ?");
$del->execute([$key]);
$ins = $pdo->prepare("INSERT INTO key_values (id, k, v) VALUES (?, ?, ?)");
$ins->execute([abs(crc32($key)), $key, $value]);
} catch (\Throwable $e) {
// 忽略
}
}
/**
* 重置全文同步进度指针sync:*),触发全量重灌(向量由引擎随行自动生成)
*/
private static function resetSyncPointersPdo(PDO $pdo): void
{
foreach (self::VECTOR_TABLES as $t) {
self::kvSetPdo($pdo, "sync:manticore" . ucfirst($t) . "LastId", '0');
}
}
@ -256,13 +468,17 @@ class ManticoreBase
*/
public function executeRaw(string $sql): bool
{
// 日志上下文只保留 SQL 前 2KB多行批量语句可达数 MB完整写入会淹没日志
$sqlPreview = strlen($sql) > 2048
? substr($sql, 0, 2048) . ' ...[+' . (strlen($sql) - 2048) . ' bytes]'
: $sql;
return $this->runWithRetry(
function (PDO $pdo) use ($sql) {
$pdo->exec($sql);
return true;
},
false,
['sql' => $sql]
['sql' => $sqlPreview]
);
}
@ -1810,10 +2026,10 @@ class ManticoreBase
];
/**
* 通用向量插入方法
* 通用单行写入方法REPLACE向量由引擎 Auto Embeddings 自动生成)
*
* 使用 executeRaw 直接执行 SQL避免 Manticore prepared statement
* 无法解析 MVA 和向量字段括号语法的问题。
* 无法解析 MVA 字段括号语法的问题。
*
* @param string $type 类型: msg/file/task/project/user
* @param array $data 数据,键名对应字段名
@ -1828,8 +2044,6 @@ class ManticoreBase
$config = self::VECTOR_TABLE_CONFIG[$type];
$table = $config['table'];
$pk = $config['pk'];
$fields = $config['fields'];
$mvaFields = $config['mva_fields'];
// 检查主键
$pkValue = $data[$pk] ?? 0;
@ -1838,44 +2052,11 @@ class ManticoreBase
}
$instance = new self();
[$fieldList, $valueList] = $instance->buildRowValues($config, $data);
// 先删除已存在的记录
$instance->execute("DELETE FROM {$table} WHERE {$pk} = ?", [$pkValue]);
// 构建字段列表和值
$fieldList = [];
$valueList = [];
// 处理普通字段
foreach ($fields as $field) {
$fieldList[] = $field;
$value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : ''));
if (in_array($field, self::NUMERIC_FIELDS)) {
$valueList[] = (int)$value;
} else {
$valueList[] = $instance->quoteValue((string)$value);
}
}
// 处理 MVA 字段
foreach ($mvaFields as $mvaField) {
$fieldList[] = $mvaField;
$mvaData = $data[$mvaField] ?? [];
$valueList[] = !empty($mvaData)
? '(' . implode(',', array_map('intval', $mvaData)) . ')'
: '()';
}
// 处理向量字段
$vectorValue = $data['content_vector'] ?? null;
if ($vectorValue) {
$fieldList[] = 'content_vector';
$valueList[] = str_replace(['[', ']'], ['(', ')'], $vectorValue);
}
// 构建并执行 SQL
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")";
// REPLACE 按 id 原子替换整行,向量列由引擎按 FROM 字段自动重新生成。
// 前提:所有 upsertXxxVector 均强制 id = 主键值,故 REPLACE(按 id) 与按主键去重等价
$sql = "REPLACE INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")";
$result = $instance->executeRaw($sql);
@ -1885,12 +2066,46 @@ class ManticoreBase
ManticoreSyncFailure::removeSuccess($type, $pkValue, 'sync');
} else {
// 失败则记录
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "INSERT failed for {$table}");
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "REPLACE failed for {$table}");
}
return $result;
}
/**
* 构建一行数据的字段列表与内联值(普通字段 + MVA 字段,向量列由引擎自动生成,不在此列)
*
* @param array $config VECTOR_TABLE_CONFIG 中的类型配置
* @param array $data 行数据
* @return array [fieldList, valueList]
*/
private function buildRowValues(array $config, array $data): array
{
$fieldList = [];
$valueList = [];
foreach ($config['fields'] as $field) {
$fieldList[] = $field;
$value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : ''));
if (in_array($field, self::NUMERIC_FIELDS)) {
$valueList[] = (int)$value;
} else {
$valueList[] = $this->quoteValue((string)$value);
}
}
foreach ($config['mva_fields'] as $mvaField) {
$fieldList[] = $mvaField;
$mvaData = $data[$mvaField] ?? [];
$valueList[] = !empty($mvaData)
? '(' . implode(',', array_map('intval', $mvaData)) . ')'
: '()';
}
return [$fieldList, $valueList];
}
/**
* 通用向量删除方法
*
@ -1924,179 +2139,92 @@ class ManticoreBase
}
/**
* 通用批量更新向量方法(高性能版本)
* 通用批量写入方法:每块一条多行 REPLACE向量由引擎 Auto Embeddings 自动生成
*
* 优化:将 N 条记录的 3N 次操作减少为 N+2 次操作
* 1. 批量 SELECT 获取现有记录 (1)
* 2. 预构建所有 INSERT SQL验证数据完整性
* 3. 批量 DELETE 删除旧记录 (1)
* 4. 逐条 INSERT 新记录带向量 (N次因向量字段无法批量绑定)
* 引擎对一条多行语句只调用一次向量化接口(实测 30 0.9s
* 多行语句失败是原子的;整块失败时回退逐行 upsertVector
* 使单条坏行不毒化整批、且失败按真实主键记入重试表。
*
* @param string $type 类型: msg/file/task/project/user
* @param array $vectorData 向量数据 [pk_value => vectorStr, ...]
* @return int 成功更新的数量
* @param array $rows 行数据数组(与 upsertVector $data 同构,需含 id 与主键)
* @return int 成功写入的数量
*/
public static function batchUpdateVectors(string $type, array $vectorData): int
public static function batchUpsertVectors(string $type, array $rows): int
{
if (empty($vectorData) || !isset(self::VECTOR_TABLE_CONFIG[$type])) {
if (empty($rows) || !isset(self::VECTOR_TABLE_CONFIG[$type])) {
return 0;
}
$config = self::VECTOR_TABLE_CONFIG[$type];
$table = $config['table'];
$pk = $config['pk'];
$fields = $config['fields'];
$mvaFields = $config['mva_fields'];
$instance = new self();
$ids = array_keys($vectorData);
// 1. 批量查询现有记录
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$existingRows = $instance->query(
"SELECT * FROM {$table} WHERE {$pk} IN ({$placeholders})",
$ids
);
if (empty($existingRows)) {
return 0;
}
// 建立 pk => row 的映射
$existingMap = [];
foreach ($existingRows as $row) {
$existingMap[$row[$pk]] = $row;
}
$idsToUpdate = array_keys($existingMap);
if (empty($idsToUpdate)) {
return 0;
}
// 2. 预构建所有 INSERT 语句(在删除前验证数据完整性)
$insertStatements = [];
foreach ($idsToUpdate as $pkValue) {
$existing = $existingMap[$pkValue];
$vectorStr = $vectorData[$pkValue] ?? null;
if (empty($vectorStr)) {
// 剔除无主键行;按内容长度估算分块(不在此渲染 SQL块内惰性渲染以压低内存峰值
$pending = [];
foreach ($rows as $row) {
if (($row[$pk] ?? 0) <= 0) {
continue;
}
// Manticore 向量使用 () 格式
$vectorStr = str_replace(['[', ']'], ['(', ')'], $vectorStr);
// 构建字段列表和值(直接内联值,不使用参数绑定)
$fieldList = $fields;
$quotedValues = [];
foreach ($fields as $field) {
$value = $existing[$field] ?? null;
// 处理默认值:数值字段用 0时间戳字段用当前时间其他用空字符串
if ($value === null) {
if ($field === 'created_at') {
$value = time();
} elseif (in_array($field, self::NUMERIC_FIELDS)) {
$value = 0;
} else {
$value = '';
}
}
// 根据字段类型处理值
if (in_array($field, self::NUMERIC_FIELDS)) {
$quotedValues[] = (int)$value;
} else {
$quotedValues[] = $instance->quoteValue((string)$value);
$bytes = 64;
foreach ($row as $value) {
if (is_string($value)) {
$bytes += strlen($value);
}
}
// 构建 MVA 字段
$mvaValuesStr = [];
foreach ($mvaFields as $mvaField) {
$fieldList[] = $mvaField;
$mvaValuesStr[] = !empty($existing[$mvaField])
? '(' . $existing[$mvaField] . ')'
: '()';
}
// 添加向量字段
$fieldList[] = 'content_vector';
// 构建 SQL所有值直接内联使用 executeRaw 避免 prepared statement 解析问题)
$allValues = implode(', ', array_merge($quotedValues, $mvaValuesStr, [$vectorStr]));
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES ({$allValues})";
$insertStatements[] = ['sql' => $sql, 'pk' => $pkValue];
$pending[] = ['pk' => $row[$pk], 'bytes' => $bytes, 'row' => $row];
}
// 如果没有有效的插入语句,直接返回
if (empty($insertStatements)) {
if (empty($pending)) {
return 0;
}
// 3. 批量删除旧记录(只删除有有效向量的记录)
$validPks = array_column($insertStatements, 'pk');
$deletePlaceholders = implode(',', array_fill(0, count($validPks), '?'));
$instance->execute(
"DELETE FROM {$table} WHERE {$pk} IN ({$deletePlaceholders})",
$validPks
);
// 分块:行数上限 + 字节预算(文件内容可达 10 万字符/行)
$chunks = [];
$current = [];
$currentBytes = 0;
foreach ($pending as $item) {
if (!empty($current)
&& (count($current) >= self::BATCH_CHUNK_ROWS || $currentBytes + $item['bytes'] > self::BATCH_CHUNK_BYTES)) {
$chunks[] = $current;
$current = [];
$currentBytes = 0;
}
$current[] = $item;
$currentBytes += $item['bytes'];
}
$chunks[] = $current;
// 4. 逐条插入新记录(使用 executeRaw 避免 prepared statement 解析问题)
$successCount = 0;
foreach ($insertStatements as $stmt) {
if ($instance->executeRaw($stmt['sql'])) {
$successCount++;
// 成功则删除失败记录(如果有)
ManticoreSyncFailure::removeSuccess($type, $stmt['pk'], 'sync');
foreach ($chunks as $chunk) {
// 块内渲染,执行后即释放,内存峰值 = 原始行 + 单块 SQL
$fieldListRef = null;
$valuesSql = [];
foreach ($chunk as $item) {
[$fieldList, $valueList] = $instance->buildRowValues($config, $item['row']);
$fieldListRef = $fieldList;
$valuesSql[] = '(' . implode(', ', $valueList) . ')';
}
$sql = "REPLACE INTO {$table} (" . implode(', ', $fieldListRef) . ") VALUES "
. implode(', ', $valuesSql);
unset($valuesSql);
$ok = $instance->executeRaw($sql);
unset($sql);
if ($ok) {
$successCount += count($chunk);
ManticoreSyncFailure::removeSuccessBatch($type, array_column($chunk, 'pk'), 'sync');
} else {
// 失败则记录
ManticoreSyncFailure::recordFailure($type, $stmt['pk'], 'sync', "Batch INSERT failed for {$table}");
// 整块失败回退逐行REPLACE 幂等,重复写已生效行无害)
foreach ($chunk as $item) {
if (self::upsertVector($type, $item['row'])) {
$successCount++;
}
}
}
}
return $successCount;
}
/**
* 批量更新消息向量(兼容方法)
*/
public static function batchUpdateMsgVectors(array $vectorData): int
{
return self::batchUpdateVectors('msg', $vectorData);
}
/**
* 批量更新文件向量
*/
public static function batchUpdateFileVectors(array $vectorData): int
{
return self::batchUpdateVectors('file', $vectorData);
}
/**
* 批量更新任务向量
*/
public static function batchUpdateTaskVectors(array $vectorData): int
{
return self::batchUpdateVectors('task', $vectorData);
}
/**
* 批量更新项目向量
*/
public static function batchUpdateProjectVectors(array $vectorData): int
{
return self::batchUpdateVectors('project', $vectorData);
}
/**
* 批量更新用户向量
*/
public static function batchUpdateUserVectors(array $vectorData): int
{
return self::batchUpdateVectors('user', $vectorData);
}
// ==============================
// 通用工具方法
// ==============================

View File

@ -8,7 +8,6 @@ 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;
/**
@ -38,7 +37,9 @@ class ManticoreFile
public const SEARCHABLE_TYPES = ['document', 'word', 'excel', 'ppt', 'txt', 'md', 'text', 'code'];
/**
* 最大内容长度(字符)- 提取后的文本内容限制
* 最大内容长度(字符)- 提取后的文本内容限制(服务全文检索范围)。
* 注意:向量化输入在 ai 插件侧另有 30000 字符上限main.py _EMBEDDING_INPUT_MAX_CHARS
* 超出部分只参与全文检索、不参与语义向量。
*/
public const MAX_CONTENT_LENGTH = 100000; // 100K 字符
@ -214,13 +215,12 @@ class ManticoreFile
// ==============================
/**
* 同步单个文件到 Manticore allowed_users
* 同步单个文件到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param File $file 文件模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(File $file, bool $withVector = false): bool
public static function sync(File $file): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -240,42 +240,7 @@ class ManticoreFile
}
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;
return ManticoreBase::upsertFileVector(self::buildRow($file));
} catch (\Exception $e) {
Log::error('Manticore sync error: ' . $e->getMessage(), [
'file_id' => $file->id,
@ -285,6 +250,30 @@ class ManticoreFile
}
}
/**
* 构建文件索引行数据(含提取的文件内容与 allowed_users
*
* @param File $file 文件模型
* @return array 行数据
*/
private static function buildRow(File $file): array
{
// 提取文件内容并限制长度
$content = mb_substr(self::extractFileContent($file), 0, self::MAX_CONTENT_LENGTH);
return [
'id' => $file->id,
'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,
'allowed_users' => self::getAllowedUsers($file),
];
}
/**
* 根据文件扩展名获取最大文件大小限制
*
@ -317,25 +306,41 @@ class ManticoreFile
}
/**
* 批量同步文件
* 批量同步文件(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $files 文件列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $files, bool $withVector = false): int
public static function batchSync(iterable $files): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($files as $file) {
if (self::sync($file, $withVector)) {
// 文件夹不索引
if ($file->type === 'folder') {
$count++;
continue;
}
// 超限文件删除旧索引
if ($file->size > self::getMaxFileSizeByExt($file->ext)) {
self::delete($file->id);
$count++;
continue;
}
try {
$rows[] = self::buildRow($file);
} catch (\Exception $e) {
Log::error('Manticore file batchSync build error: ' . $e->getMessage(), [
'file_id' => $file->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('file', $rows);
}
/**
@ -497,27 +502,6 @@ class ManticoreFile
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);
}
/**
* 清空所有索引
@ -577,94 +561,4 @@ class ManticoreFile
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

@ -5,8 +5,6 @@ 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;
@ -39,7 +37,8 @@ class ManticoreMsg
public const INDEXABLE_TYPES = ['text', 'file', 'record', 'meeting', 'vote'];
/**
* 最大内容长度(字符)
* 最大内容长度(字符)。向量化输入在 ai 插件侧另有 30000 字符上限
* main.py _EMBEDDING_INPUT_MAX_CHARS超出部分只参与全文检索
*/
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
@ -328,13 +327,12 @@ class ManticoreMsg
// ==============================
/**
* 同步单个消息到 Manticore allowed_users
* 同步单个消息到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param WebSocketDialogMsg $msg 消息模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(WebSocketDialogMsg $msg, bool $withVector = false): bool
public static function sync(WebSocketDialogMsg $msg): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -347,37 +345,7 @@ class ManticoreMsg
}
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;
return ManticoreBase::upsertMsgVector(self::buildRow($msg));
} catch (\Exception $e) {
Log::error('Manticore msg sync error: ' . $e->getMessage(), [
'msg_id' => $msg->id,
@ -388,106 +356,59 @@ class ManticoreMsg
}
/**
* 批量同步消息
* 构建消息索引行数据(含 allowed_users
*
* @param WebSocketDialogMsg $msg 消息模型
* @return array 行数据
*/
private static function buildRow(WebSocketDialogMsg $msg): array
{
// 提取消息内容(使用 key 字段)并限制长度
$content = mb_substr($msg->key ?? '', 0, self::MAX_CONTENT_LENGTH);
return [
'id' => $msg->id,
'msg_id' => $msg->id,
'dialog_id' => $msg->dialog_id,
'userid' => $msg->userid,
'msg_type' => $msg->type,
'content' => $content,
'allowed_users' => self::getAllowedUsers($msg),
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
];
}
/**
* 批量同步消息(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $msgs 消息列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $msgs, bool $withVector = false): int
public static function batchSync(iterable $msgs): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
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()) {
if (!self::shouldIndex($msg)) {
if (ManticoreBase::deleteMsgVector($msg->id)) {
$count++;
}
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;
try {
$rows[] = self::buildRow($msg);
} catch (\Exception $e) {
Log::error('Manticore msg batchSync build error: ' . $e->getMessage(), [
'msg_id' => $msg->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('msg', $rows);
}
/**

View File

@ -5,8 +5,6 @@ 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;
/**
@ -122,13 +120,12 @@ class ManticoreProject
}
/**
* 同步单个项目到 Manticore allowed_users
* 同步单个项目到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param Project $project 项目模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(Project $project, bool $withVector = false): bool
public static function sync(Project $project): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -140,33 +137,7 @@ class ManticoreProject
}
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;
return ManticoreBase::upsertProjectVector(self::buildRow($project));
} catch (\Exception $e) {
Log::error('Manticore project sync error: ' . $e->getMessage(), [
'project_id' => $project->id,
@ -177,45 +148,55 @@ class ManticoreProject
}
/**
* 构建可搜索的文本内容
* 构建项目索引行数据(含 allowed_users
*
* @param Project $project 项目模型
* @return string 可搜索的文本
* @return array 行数据
*/
private static function buildSearchableContent(Project $project): string
private static function buildRow(Project $project): array
{
$parts = [];
if (!empty($project->name)) {
$parts[] = $project->name;
}
if (!empty($project->desc)) {
$parts[] = $project->desc;
}
return implode(' ', $parts);
return [
'id' => $project->id,
'project_id' => $project->id,
'userid' => $project->userid ?? 0,
'personal' => $project->personal ?? 0,
'project_name' => $project->name ?? '',
'project_desc' => $project->desc ?? '',
'allowed_users' => self::getAllowedUsers($project->id),
];
}
/**
* 批量同步项目
* 批量同步项目(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $projects 项目列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $projects, bool $withVector = false): int
public static function batchSync(iterable $projects): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($projects as $project) {
if (self::sync($project, $withVector)) {
$count++;
if ($project->archived_at) {
if (self::delete($project->id)) {
$count++;
}
continue;
}
try {
$rows[] = self::buildRow($project);
} catch (\Exception $e) {
Log::error('Manticore project batchSync build error: ' . $e->getMessage(), [
'project_id' => $project->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('project', $rows);
}
/**
@ -286,84 +267,4 @@ class ManticoreProject
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

@ -9,7 +9,6 @@ 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;
/**
@ -42,7 +41,8 @@ use Illuminate\Support\Facades\Log;
class ManticoreTask
{
/**
* 最大内容长度(字符)
* 最大内容长度(字符)。向量化输入在 ai 插件侧另有 30000 字符上限
* main.py _EMBEDDING_INPUT_MAX_CHARS超出部分只参与全文检索
*/
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
@ -189,13 +189,12 @@ class ManticoreTask
// ==============================
/**
* 同步单个任务到 Manticore allowed_users
* 同步单个任务到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param ProjectTask $task 任务模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(ProjectTask $task, bool $withVector = false): bool
public static function sync(ProjectTask $task): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -207,38 +206,7 @@ class ManticoreTask
}
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;
return ManticoreBase::upsertTaskVector(self::buildRow($task));
} catch (\Exception $e) {
Log::error('Manticore task sync error: ' . $e->getMessage(), [
'task_id' => $task->id,
@ -248,6 +216,27 @@ class ManticoreTask
}
}
/**
* 构建任务索引行数据(含详细内容与 allowed_users
*
* @param ProjectTask $task 任务模型
* @return array 行数据
*/
private static function buildRow(ProjectTask $task): array
{
return [
'id' => $task->id,
'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' => self::getTaskContent($task),
'allowed_users' => self::getAllowedUsers($task),
];
}
/**
* 获取任务详细内容
*
@ -311,49 +300,36 @@ class ManticoreTask
}
/**
* 构建可搜索的文本内容
*
* @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);
}
/**
* 批量同步任务
* 批量同步任务(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $tasks 任务列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $tasks, bool $withVector = false): int
public static function batchSync(iterable $tasks): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($tasks as $task) {
if (self::sync($task, $withVector)) {
$count++;
if ($task->archived_at || $task->deleted_at) {
if (self::delete($task->id)) {
$count++;
}
continue;
}
try {
$rows[] = self::buildRow($task);
} catch (\Exception $e) {
Log::error('Manticore task batchSync build error: ' . $e->getMessage(), [
'task_id' => $task->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('task', $rows);
}
/**
@ -506,88 +482,4 @@ class ManticoreTask
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

@ -5,8 +5,6 @@ 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;
/**
@ -123,13 +121,12 @@ class ManticoreUser
}
/**
* 同步单个用户到 Manticore
* 同步单个用户到 Manticore(向量由引擎 Auto Embeddings 按行内文本自动生成)
*
* @param User $user 用户模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(User $user, bool $withVector = false): bool
public static function sync(User $user): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -146,33 +143,13 @@ class ManticoreUser
}
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) . ']';
}
$row = self::buildRow($user);
// 脏检查:与已索引行完全一致则跳过。标签点赞/识别等高频事件经常不改变
// Top-10 标签文本,跳过可省一次真实的向量化调用与整行重写
if (self::rowUnchanged($row)) {
return true;
}
// 写入 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;
return ManticoreBase::upsertUserVector($row);
} catch (\Exception $e) {
Log::error('Manticore user sync error: ' . $e->getMessage(), [
'userid' => $user->userid,
@ -183,55 +160,81 @@ class ManticoreUser
}
/**
* 构建可搜索的文本内容
*
* @param User $user 用户模型
* @param string $tags 用户标签(空格分隔)
* @return string 可搜索的文本
* 判断待写入行与当前已索引行是否完全一致(文本字段逐一比较)
*/
private static function buildSearchableContent(User $user, string $tags = ''): string
private static function rowUnchanged(array $row): bool
{
$parts = [];
if (!empty($user->nickname)) {
$parts[] = $user->nickname;
$existing = (new ManticoreBase())->queryOne(
"SELECT nickname, email, profession, tags, introduction FROM user_vectors WHERE userid = ?",
[$row['userid']]
);
if (!$existing) {
return false;
}
if (!empty($user->email)) {
$parts[] = $user->email;
foreach (['nickname', 'email', 'profession', 'tags', 'introduction'] as $field) {
if ((string) ($existing[$field] ?? '') !== (string) $row[$field]) {
return false;
}
}
if (!empty($user->profession)) {
$parts[] = $user->profession;
}
if (!empty($tags)) {
$parts[] = $tags;
}
if (!empty($user->introduction)) {
$parts[] = $user->introduction;
}
return implode(' ', $parts);
return true;
}
/**
* 批量同步用户
* 构建用户索引行数据(含标签 Top 10
*
* @param User $user 用户模型
* @return array 行数据
*/
private static function buildRow(User $user): array
{
$tags = self::getUserTags($user->userid);
return [
'id' => $user->userid,
'userid' => $user->userid,
'nickname' => $user->nickname ?? '',
'email' => $user->email ?? '',
'profession' => $user->profession ?? '',
'tags' => $tags,
'introduction' => $user->introduction ?? '',
];
}
/**
* 批量同步用户(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $users 用户列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $users, bool $withVector = false): int
public static function batchSync(iterable $users): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($users as $user) {
if (self::sync($user, $withVector)) {
if ($user->bot) {
$count++;
continue;
}
if ($user->disable_at) {
if (self::delete($user->userid)) {
$count++;
}
continue;
}
try {
$rows[] = self::buildRow($user);
} catch (\Exception $e) {
Log::error('Manticore user batchSync build error: ' . $e->getMessage(), [
'userid' => $user->userid,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('user', $rows);
}
/**
@ -276,87 +279,4 @@ class ManticoreUser
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,485 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use App\Services\RequestContext;
use Carbon\Carbon;
use Illuminate\Support\Facades\Crypt;
/**
* 在线授权客户端编排。
*
* 在线授权产出的仍是现有格式的离线 license blob只是「获取方式」变成用 appstore 账号登录
* 自助签发、并由本类定时续期。doo.so 本地校验、license 文件存储全部复用。绑定状态以单例
* 形式存于 settings name=onlineLicenseinstance_token Crypt 加密。
*
* 四级状态机(基于租约内嵌到期 lease_expired_at 与本地宽限,全程不依赖 appstore 可达):
* active 续期正常
* reminder 续期失败/租约剩余不足 warn_days仅管理员可见提醒
* frozen 租约已过期doo.so 既有行为:限制新增用户)
* revoked 冻结超过 grace_days appstore 明确吊销 回落默认 3 人版
*/
class OnlineLicense
{
const KEY = 'onlineLicense';
// ---- 配置 ----
protected static function appstoreUrl(): string
{
return rtrim((string)config('dootask.online_license_appstore_url'), '/');
}
protected static function renewWithinDays(): int
{
return (int)config('dootask.online_license_renew_within_days', 20);
}
protected static function warnDays(): int
{
return (int)config('dootask.online_license_warn_days', 7);
}
protected static function graceDays(): int
{
return (int)config('dootask.online_license_grace_days', 14);
}
// ---- 状态读写(单例 settings----
public static function get(): array
{
return Base::setting(self::KEY) ?: [];
}
protected static function set(array $patch): array
{
$next = array_merge(self::get(), $patch);
Base::setting(self::KEY, $next);
return $next;
}
public static function enabled(): bool
{
$s = self::get();
return !empty($s['enabled']) && ($s['mode'] ?? '') === 'online';
}
protected static function token(): string
{
$enc = self::get()['instance_token'] ?? '';
if (empty($enc)) {
return '';
}
try {
return Crypt::decryptString($enc);
} catch (\Throwable) {
return '';
}
}
/**
* 当前请求语言,透传给 appstore 用于邮件按语言渲染(中文/繁体→中文,其余→英文)。
* 非请求上下文(如定时续期)返回空串,由 appstore 回落默认语言。
*/
protected static function lang(): string
{
return (string)Base::headerOrInput('language');
}
protected static function fingerprint(): array
{
return [
'sn' => Doo::dooSN(),
'macs' => implode(',', Doo::macs()),
// 优先真实外网地址config('app.url') 若为 localhost 由 replaceBaseUrl 替换为缓存的访问地址
'url' => RequestContext::replaceBaseUrl((string)config('app.url')),
// DooTask 应用版本(非 doo.so 库版本)
'version' => Base::getVersion(),
];
}
// ---- appstore 调用 ----
/**
* appstore license 接口。返回 ['ok'=>bool, 'data'=>array, 'message'=>string]
* $bearer 非空时带实例令牌(续期/释放)。
*/
protected static function call(string $path, array $payload, string $bearer = ''): array
{
$url = self::appstoreUrl() . '/api/v1/license/' . ltrim($path, '/');
$headers = ['Content-Type' => 'application/json'];
if ($bearer !== '') {
$headers['Authorization'] = 'Bearer ' . $bearer;
}
$resp = Ihttp::ihttp_request($url, json_encode($payload, JSON_UNESCAPED_UNICODE), $headers, 15);
if (Base::isError($resp)) {
return ['ok' => false, 'data' => [], 'message' => $resp['msg'] ?: '无法连接授权服务'];
}
$body = Base::json2array($resp['data'] ?? '');
if (($body['code'] ?? 0) !== 200) {
return ['ok' => false, 'data' => [], 'message' => $body['message'] ?: '授权服务返回错误'];
}
return ['ok' => true, 'data' => $body['data'] ?? [], 'message' => ''];
}
/**
* 处理签发结果issued/renewed 则落地 license + 更新绑定状态;其它状态原样返回供上层决策。
*/
protected static function applyIssue(string $account, array $d): string
{
$status = $d['status'] ?? '';
if (in_array($status, ['issued', 'renewed'], true)) {
$blob = $d['license'] ?? '';
if ($blob === '') {
throw new ApiException('授权服务未返回 license');
}
Doo::licenseSave($blob); // 复用离线落地与 doo.so 校验
$snap = $d['snapshot'] ?? [];
$patch = [
'enabled' => true,
'mode' => 'online',
'account' => $account,
'plan' => $snap['plan'] ?? '',
'people' => $snap['people'] ?? 0,
'valid_until' => $snap['valid_until'] ?? null,
'lease_expired_at' => $snap['lease_expired_at'] ?? null,
'server_status' => $status,
'error_count' => 0,
'last_error' => '',
'frozen_since' => null,
'last_renewed_at' => Carbon::now()->toDateTimeString(),
];
if (!empty($d['instance_token'])) {
$patch['instance_token'] = Crypt::encryptString($d['instance_token']);
}
self::set($patch);
self::computeStage();
}
return $status;
}
// ---- 对外动作 ----
/**
* 发送邮箱验证码(登录与试用共用),返回脱敏邮箱。
*/
public static function emailSend(string $email): string
{
$r = self::call('email/send', ['email' => $email, 'lang' => self::lang()]);
if (!$r['ok']) {
throw new ApiException($r['message']);
}
return $r['data']['email'] ?? '';
}
/**
* 邮箱 + 验证码登录并签发。失败抛 ApiException。
* 本机有多条可用授权时appstore 返回 select_required + candidates
* 此处不签发、原样返回候选,由前端选定后走 loginConfirm()
*/
public static function login(string $email, string $code): array
{
$r = self::call('login', array_merge(['email' => $email, 'code' => $code, 'lang' => self::lang()], self::fingerprint()));
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$d = $r['data'];
if (($d['status'] ?? '') === 'select_required') {
return [
'select_required' => true,
'candidates' => $d['candidates'] ?? [],
];
}
$status = self::applyIssue($email, $d);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));
}
return self::status();
}
/**
* 多条可用授权时,用户选定 $entitlementId 后确认签发(复用同一验证码)。失败抛 ApiException。
*/
public static function loginConfirm(string $email, string $code, int $entitlementId): array
{
$payload = array_merge([
'email' => $email,
'code' => $code,
'entitlement_id' => $entitlementId,
'lang' => self::lang(),
], self::fingerprint());
$r = self::call('login/confirm', $payload);
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$status = self::applyIssue($email, $r['data']);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));
}
return self::status();
}
/**
* 邮箱 + 验证码申请试用并签发。
*/
public static function trial(string $email, string $code): array
{
$payload = array_merge(['email' => $email, 'code' => $code, 'lang' => self::lang()], self::fingerprint());
$r = self::call('trial', $payload);
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$status = self::applyIssue($email, $r['data']);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));
}
return self::status();
}
/**
* 续期(定时任务调用)。不抛异常:网络/服务错误只累加计数,最终由状态机本地降级。
*/
public static function renew(): void
{
if (!self::enabled()) {
return;
}
$token = self::token();
if ($token === '') {
return;
}
$r = self::call('renew', self::fingerprint(), $token);
if (!$r['ok']) {
$s = self::get();
self::set([
'error_count' => (int)($s['error_count'] ?? 0) + 1,
'last_error' => $r['message'],
]);
self::computeStage();
return;
}
$status = $r['data']['status'] ?? '';
if (in_array($status, ['issued', 'renewed'], true)) {
self::applyIssue(self::get()['account'] ?? '', $r['data']);
return;
}
// 服务侧明确状态revoked/suspended/no_entitlement不延长租约记录后交状态机
self::set(['server_status' => $status, 'last_error' => self::statusHint($status)]);
self::computeStage();
}
/**
* 是否到了该续期的时间(租约剩余不足 renew_within_days
*/
public static function dueForRenew(): bool
{
$lease = self::get()['lease_expired_at'] ?? null;
if (!$lease) {
return true;
}
return Carbon::parse($lease)->lte(Carbon::now()->addDays(self::renewWithinDays()));
}
/**
* 定时续期入口:由容器内独立进程的 artisan 命令online-license:renew按小时调用。
* 先本地状态机推进(断网也能降级 frozen→revoked再在租约将尽时续期。
*/
public static function cron(): void
{
if (!self::enabled()) {
return;
}
self::computeStage();
if (self::enabled() && self::dueForRenew()) {
self::renew();
}
}
/**
* 进入授权页时的静默刷新:服务可达则按服务结果更新(成功续签 / 反映吊销冻结),
* 网络失败则什么都不做、不提示、不降级(避免一次页面刷新失败就误报)。
*/
public static function refresh(): void
{
if (!self::enabled()) {
return;
}
$token = self::token();
if ($token === '') {
return;
}
try {
$r = self::call('renew', self::fingerprint(), $token);
if (!$r['ok']) {
return; // 刷新失败:不更新、不提示
}
$status = $r['data']['status'] ?? '';
if (in_array($status, ['issued', 'renewed'], true)) {
self::applyIssue(self::get()['account'] ?? '', $r['data']);
} elseif (in_array($status, ['revoked', 'suspended', 'no_entitlement'], true)) {
// 服务侧明确结果(非网络失败):如实反映
self::set(['server_status' => $status, 'last_error' => self::statusHint($status)]);
self::computeStage();
}
} catch (\Throwable) {
// 忽略,保持现状
}
}
/**
* 退出在线授权:释放座位 + 回落默认。
*/
public static function logout(): void
{
$token = self::token();
if ($token !== '') {
self::call('deactivate', [], $token);
}
self::fallbackToDefault();
Base::setting(self::KEY, ['enabled' => false, 'mode' => 'offline']);
}
/**
* 切换到离线授权(互斥):保存离线 license 后调用。
* 尽力释放在线座位 + 清在线标志,但「不」删除 license 文件(刚保存的离线 license 要保留)。
*/
public static function switchToOffline(): void
{
if (!self::enabled()) {
return;
}
$token = self::token();
if ($token !== '') {
self::call('deactivate', [], $token); // 尽力释放座位,失败忽略
}
Base::setting(self::KEY, ['enabled' => false, 'mode' => 'offline']);
}
// ---- 状态机 ----
/**
* 据租约到期 + 宽限重新计算 status并在 revoked 时执行降级。
*/
public static function computeStage(): string
{
$s = self::get();
if (($s['mode'] ?? '') !== 'online' || empty($s['enabled'])) {
return 'offline';
}
$now = Carbon::now();
$server = $s['server_status'] ?? '';
$lease = $s['lease_expired_at'] ?? null;
if ($server === 'revoked') {
return self::transitionRevoked();
}
if ($lease && Carbon::parse($lease)->lte($now)) {
// 租约已过期 → 冻结;超过宽限 → 吊销
$frozenSince = $s['frozen_since'] ?? null;
if (!$frozenSince) {
$frozenSince = $now->toDateTimeString();
self::set(['frozen_since' => $frozenSince]);
}
if (Carbon::parse($frozenSince)->addDays(self::graceDays())->lte($now)) {
return self::transitionRevoked();
}
self::set(['status' => 'frozen']);
return 'frozen';
}
// 租约有效
$remindByLease = $lease && Carbon::parse($lease)->lte($now->copy()->addDays(self::warnDays()));
$remindByError = (int)($s['error_count'] ?? 0) > 0 || $server === 'suspended' || $server === 'no_entitlement';
$status = ($remindByLease || $remindByError) ? 'reminder' : 'active';
self::set(['status' => $status, 'frozen_since' => null]);
return $status;
}
protected static function transitionRevoked(): string
{
self::fallbackToDefault();
self::set(['status' => 'revoked', 'enabled' => false]);
return 'revoked';
}
/**
* 删除在线 license 文件,让 dooso 回落默认 3 人版(触发既有超员禁用)。
*/
protected static function fallbackToDefault(): void
{
foreach (['LICENSE', 'license'] as $name) {
$path = config_path($name);
if (is_file($path)) {
@unlink($path);
}
}
}
// ---- 提醒文案(注入 system/license 的 error[],复用 dashboard 警告条与 license 页)----
public static function stageMessages(): array
{
if (!self::enabled() && (self::get()['status'] ?? '') !== 'revoked') {
return [];
}
$s = self::get();
$status = $s['status'] ?? self::computeStage();
$msgs = [];
switch ($status) {
case 'reminder':
if (($s['server_status'] ?? '') === 'suspended') {
$msgs[] = '在线授权已被冻结,请联系服务商';
} elseif ((int)($s['error_count'] ?? 0) > 0) {
$msgs[] = '在线授权续期失败,请检查网络';
} else {
$msgs[] = '在线授权即将到期,请保持联网续期';
}
break;
case 'frozen':
$msgs[] = '在线授权已过期,新增用户受限,请尽快续期';
break;
case 'revoked':
$msgs[] = '在线授权已失效,已回落到基础版';
break;
}
return $msgs;
}
protected static function statusHint(string $status): string
{
return match ($status) {
'no_entitlement' => '该账号暂无可用授权,请先申请试用或购买',
'revoked' => '该授权已被吊销',
'suspended' => '该授权已被冻结',
'seat_taken' => '该授权已在另一台实例使用,请先在原实例释放(换机)',
'entitlement_expired' => '该授权已到期',
default => '签发失败(' . $status . '',
};
}
/**
* 对外状态(前端在线 Tab / status 接口用,不含敏感 token
*/
public static function status(): array
{
$s = self::get();
if (($s['mode'] ?? '') !== 'online' || empty($s['enabled'])) {
return ['mode' => 'offline'];
}
return [
'mode' => 'online',
'account' => $s['account'] ?? '',
'plan' => $s['plan'] ?? '',
'people' => $s['people'] ?? 0,
'valid_until' => $s['valid_until'] ?? null,
'lease_expired_at' => $s['lease_expired_at'] ?? null,
'last_renewed_at' => $s['last_renewed_at'] ?? null,
'status' => $s['status'] ?? self::computeStage(),
'error_count' => (int)($s['error_count'] ?? 0),
'last_error' => $s['last_error'] ?? '',
];
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Module;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\ImageManager;
use Intervention\Image\Typography\FontFactory;
use Laravolt\Avatar\Avatar;
/**
* laravolt/avatar 6.5.0 buildAvatar 给纵向对齐传 'middle'
* intervention/image 4.1.3 Alignment 枚举仅接受 'center',会抛
* InvalidArgumentException(Invalid value for alignment)。上游修复前以子类覆写修正。
*/
class PatchedAvatar extends Avatar
{
public function buildAvatar(): static
{
$this->buildInitial();
$x = $this->width / 2;
$y = $this->height / 2;
$driver = $this->driver === 'gd' ? new GdDriver : new ImagickDriver;
$this->image = ImageManager::usingDriver($driver)->createImage($this->width, $this->height);
$this->createShape();
if (empty($this->initials)) {
return $this;
}
$this->image->text(
$this->initials,
(int) $x,
(int) $y,
function (FontFactory $font) {
$font->filepath($this->font);
$font->size($this->fontSize);
$font->color($this->foreground);
$font->align('center', 'center');
}
);
return $this;
}
}

View File

@ -24,7 +24,8 @@ abstract class AbstractData
protected function __construct()
{
$this->table = app('swoole')->{$this->getTableName()};
// 非 Swoole 运行时artisan/测试)无 swoole 绑定table 为 null各方法返回默认值
$this->table = app()->bound('swoole') ? app('swoole')->{$this->getTableName()} : null;
}
public function getTable()
@ -42,22 +43,34 @@ abstract class AbstractData
public static function set($key, $value)
{
if (!self::instance()->table) {
return false;
}
return self::instance()->table->set($key, ['value' => $value]);
}
public static function get($key, $default = null)
{
if (!self::instance()->table) {
return $default;
}
$data = self::instance()->table->get($key);
return $data ? $data['value'] : $default;
}
public static function del($key)
{
if (!self::instance()->table) {
return false;
}
return self::instance()->table->del($key);
}
public static function exist($key)
{
if (!self::instance()->table) {
return false;
}
return self::instance()->table->exist($key);
}
@ -70,6 +83,9 @@ abstract class AbstractData
public static function clear()
{
if (!self::instance()->table) {
return;
}
foreach (self::instance()->table as $key => $row) {
self::del($key);
}
@ -77,6 +93,9 @@ abstract class AbstractData
public static function getAll()
{
if (!self::instance()->table) {
return [];
}
$result = [];
foreach (self::instance()->table as $key => $row) {
$result[$key] = $row['value'];

View File

@ -17,6 +17,9 @@ class OnlineData extends AbstractData
*/
public static function online($userid)
{
if (!self::instance()->getTable()) {
return 0;
}
$key = "online::" . $userid;
$value = self::instance()->getTable()->incr($key, 'value');
if ($value === 1) {
@ -35,6 +38,9 @@ class OnlineData extends AbstractData
*/
public static function offline($userid)
{
if (!self::instance()->getTable()) {
return 0;
}
$key = "online::" . $userid;
$value = self::instance()->getTable()->decr($key, 'value');
if ($value === 0) {
@ -57,6 +63,9 @@ class OnlineData extends AbstractData
*/
public static function live($userid)
{
if (!self::instance()->getTable()) {
return 0;
}
$key = "online::" . $userid;
return intval(self::instance()->getTable()->get($key));
}

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

@ -4,6 +4,7 @@ namespace App\Observers;
use App\Models\User;
use App\Module\Apps;
use App\Module\Badge;
use App\Tasks\ManticoreSyncTask;
class UserObserver extends AbstractObserver
@ -80,6 +81,8 @@ class UserObserver extends AbstractObserver
} elseif (!$originalDisableAt && $currentDisableAt) {
// disable_at 从 null 变为有值 → 离职 (offboarded)
Apps::dispatchUserHook($user, 'user_offboard', 'offboarded');
// 离职清除该用户全部应用角标
Badge::clearByUser((int)$user->userid);
}
return;
}
@ -122,6 +125,9 @@ class UserObserver extends AbstractObserver
if (!$user->bot) {
Apps::dispatchUserHook($user, 'user_offboard', 'delete');
}
// 清除该用户全部应用角标
Badge::clearByUser((int)$user->userid);
}
}

View File

@ -2,6 +2,40 @@
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;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -32,5 +66,48 @@ class AppServiceProvider extends ServiceProvider
\Illuminate\Database\Eloquent\Builder::macro('rawSql', function(){
return ($this->getQuery()->rawSql());
});
$this->configureRateLimiting();
$this->registerEvents();
$this->registerObservers();
}
/**
* api 组限流(原 RouteServiceProvider::configureRateLimiting
*/
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
});
}
/**
* 事件监听(原 EventServiceProvider::$listen
*/
protected function registerEvents()
{
Event::listen(Registered::class, SendEmailVerificationNotification::class);
}
/**
* 模型观察者(原 EventServiceProvider::boot
*/
protected function registerObservers()
{
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

@ -1,30 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
/**
* The policy mappings for the application.
*
* @var array
*/
protected $policies = [
// 'App\Models\Model' => 'App\Policies\ModelPolicy',
];
/**
* Register any authentication / authorization services.
*
* @return void
*/
public function boot()
{
$this->registerPolicies();
//
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}

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