diff --git a/.gitignore b/.gitignore index 5f328c71..948a3967 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ backend/**/target/ backend/**/.flattened-pom.xml /node_modules/ +/.pnpm-store/ frontend/node_modules/ frontend/dist/ diff --git a/AGENTS.md b/AGENTS.md index 403cbbe7..9021ada0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## 项目定位 -这是“物业AI人力资源系统”工程,2026-07-07 立项会后交付物升级为**银城员工端 APP**(H5 套壳,分三阶段:陪练 → 大喇叭+知识学习平台 → 北森对接,见 `docs/银城员工端APP分阶段实施总纲.md`)。2026-07 MVP 演示资料已归档;当前开发按 `docs/DEMO_ACCEPTANCE.md` 守住演示验收口径,并按 `docs/AI陪练二期开发推进计划.md`(总纲阶段一)推进管家(生活顾问)岗位试点可用——“管家”与“生活顾问”是同一岗位,`position` 标签沿用现有值。不要把归档作战清单当当前待办,也不要把演示闭环写成一期生产验收完成,更不要把大喇叭/品检翻转/北森对接拉进阶段一。 +本项目已升级为**银城员工端 APP**,按“陪练 → 大喇叭+知识学习平台 → 北森对接”三阶段推进,见 `docs/银城员工端APP分阶段实施总纲.md`。当前只按 `docs/DEMO_ACCEPTANCE.md` 和 `docs/AI陪练二期开发推进计划.md` 推进阶段一管家(生活顾问)试点;`position` 沿用现有值。2026-07 MVP 资料已归档,不要把演示闭环写成生产验收完成,也不要把阶段二/三能力拉进当前主线。 ## 工程边界 @@ -39,6 +39,7 @@ ## 发布口径 +- 线上管理端:`https://peilian.njzhmj.top/`,Caddy 服务 `/opt/wygj/www`;该目录包含移动端 `/h5`,发布管理端时先备份整个目录,再 `rsync -az frontend/dist/ YCWY:/opt/wygj/www/`,**不得**对根目录使用 `--delete`。纯管理端静态修复不需要重启后端。 - 线上移动端 H5:`https://peilian.njzhmj.top/h5/`,Caddy 服务 `/opt/wygj/www/h5`。 - 只发布移动端静态资源时,用 `npm --prefix mobile-uni run build:h5` 后备份远端 `/opt/wygj/www/h5`,再 `rsync -az --delete mobile-uni/dist/build/h5/ YCWY:/opt/wygj/www/h5/`;前端静态修复不需要重启后端。 @@ -54,10 +55,11 @@ - 三角色对练:`frontend/src/views/train/practice.vue`,路由 `/train/practice`。 - 案例沉淀:`frontend/src/views/knowledge/cases.vue`,路由 `/knowledge/cases`。 - SOP 知识库:`frontend/src/views/knowledge/sop.vue`,路由 `/knowledge/sop`。 -- 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`。 +- 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`;解析任务按当前筛选结果前端分页,默认 10 条/页,可选 20/50 条,切换筛选或刷新时回到第一页。 - 模型配置:`frontend/src/views/system/model/index.vue`,路由 `/system/model`。 - MVP 阶段侧栏只保留“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”。过滤逻辑在 `frontend/src/layout/components/Sidebar/index.vue`,不要在未明确要求时恢复若依默认全量菜单。 -- 移动端三端首页:`mobile-uni/src/pages/*`,员工端 `/h5/#/pages/user/today/index`、候选人端 `/h5/#/pages/candidate/index/index`、主管端 `/h5/#/pages/supervisor/index/index`;`/h5/user`、`/h5/candidate`、`/h5/supervisor`、`/h5/employee`、`/h5/admin` 仅作为旧链接兼容重定向,移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);问答卡两侧图片容器统一 `64×64`,横向内容垂直居中;员工端训练页是 `/h5/#/pages/user/practice/index`,首屏必须先给「场景模拟/开始练习/最近练过」,每日三题后置;点击「开始练习」进入训练对话区域,不能在旧 `mobile/src/App.vue` 上继续堆锚点。师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换,**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 页面调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。 +- 当前用户侧只改 `mobile-uni/src/pages/*`:员工、候选人、主管首页分别为 `/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index`;旧 `/h5/user|candidate|supervisor|employee|admin` 只做兼容重定向,主管端不等于后台管理员。员工首页按「师傅区→今日安排→我的地图→成长摘要」,视觉以 `docs/feedback/2026070423/design/` 和 `REVIEW.md` 为准(修正项优先,问答图片 `64×64`);训练首屏先放场景、开始练习、最近练过,每日三题后置。`pages.json` 固定底部「今日/练/问/我」,未实现入口进 `/pages/common/building/index`。SOP 搜索/总结卡分别复用 `/api/knowledge/search`(`category='sop'`)和 `/api/knowledge/summary-card`,失败不兜假数据。 +- 不要把三端概括成全模态:员工对练/每日题支持文字和语音,案例只传音频,问师傅仅文字;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管只写文字复盘/指派并回听录音。员工图像/视频情境、方言验收和阶段二个人知识空间仍未完成。首页“语音提问”与问师傅仅文字不一致;优先改文案,若补语音则复用 `services/speech.ts` 和 `/api/ai/asr`,不要新建接口。 - 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。 - 移动端手机号登录:验证码发送走 `GET /resource/sms/code?phonenumber=...`,登录走 `POST /auth/mobile/sms-login`;短信发送复用 `sms.blends.config1` 阿里云配置。手机号不存在时自动注册 `app_user`,备注为“移动端短信自动注册”。模板 ID 用 `AIHR_SMS_LOGIN_TEMPLATE_ID`,AccessKey/Secret/签名用 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`,本地放 `.env.local`。`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod 默认关闭,试点期必须同时显式设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true`,停用时删除固定码并关闭生产开关。 - 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;`mode=mobile` 完成后写入 `aihr_practice_session`,并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。 @@ -71,7 +73,7 @@ - SOP 上传接口是 `POST /api/knowledge/doc/upload`,表单字段为 `file` 和 `category`;当前限制 100MB 内,支持 txt/md/PDF/Word/Excel/PPT 及图片,先写 MinIO/`sys_oss` 再绑定 `aihr_knowledge_attach.oss_id`,同一知识库同名文件会替换旧 fragment。若数据库启用 `category=vector` 的模型配置,会同步调用 OpenAI-compatible `/embeddings`,写入 `embedding_json` 并尽力 upsert 到 Qdrant;配置缺失或外部 embedding 失败才用 `local-hash-v1` 兜底,Qdrant 不可用时保留 MySQL Fulltext/seed fallback。管理端 `uploadKnowledgeDoc` 单接口 timeout 是 180s;遇到约 50s `Broken pipe` 先查浏览器客户端超时,不要改全局 axios。 - 图片视觉 OCR:上传图片时经 `visionRuntime()` 找启用模型(优先 `aihr_model_config.category='vision'`,无则回退 `category='chat'`),把图片编码成 base64 data URL 调 OpenAI-compatible `/chat/completions` 提取文字再切分入库;无可用视觉模型或 OCR 无结果时按「待处理」落库(0 片段),不算失败。视频关键帧用带画面描述的 FRAME_PROMPT(文字+一句场景描述),改 prompt 时别退回纯 OCR。不依赖 Tesseract。 - 向量库状态接口是 `GET /api/knowledge/doc/vector-index-status`,重建接口是 `POST /api/knowledge/doc/rebuild-vector-index`;外部 embedding 成功但 Qdrant collection 维度不一致时不要静默降级,走状态提示和重建。 -- 资料处理接口是 `GET /api/knowledge/processing/overview`,直接聚合 `aihr_knowledge_attach`、`aihr_knowledge_fragment` 和 `sys_oss.ext1`;`POST /api/knowledge/doc/import-local` 保留同步导入,`POST /api/knowledge/doc/import-local-task` + `GET /api/knowledge/doc/import-tasks` 用于页面启动后台目录导入和轮询进度,`POST /api/knowledge/doc/import-tasks/{id}/cancel` 取消运行中任务。目录只能是 `aihr.import.root` 下的相对路径,逐文件复用上传解析链路;大批量资料优先走服务端导入任务,不走浏览器单次上传。 +- 资料处理接口是 `GET /api/knowledge/processing/overview`,直接聚合 `aihr_knowledge_attach`、`aihr_knowledge_fragment` 和 `sys_oss.ext1`;页面只保留“批量导入”,走 `POST /api/knowledge/doc/upload-async`,不要恢复“选择目录”“服务端导入”或目录导入任务面板。`POST /api/knowledge/doc/import-local`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks` 和 `POST /api/knowledge/doc/import-tasks/{id}/cancel` 仅保留为后端运维/调试接口;目录只能是 `aihr.import.root` 下的相对路径。 - seed service 阶段已完成;后续按 `docs/RUOYI_AI_INCREMENTAL_MIGRATION.md` 分片迁移 `ruoyi-ai` 的知识库、文档解析、RAG、chat 能力。 - 迁移时不要整包搬 `ruoyi-chat`;优先在 `ruoyi-aihr` 内吸收必要表、service、loader 和检索接口,并保留 seed fallback。 - 已落地的演示流不要退回静态壳:AI 面试、三角色对练、案例沉淀已是真模型优先(未配置/失败回退本地兜底),SOP 知识库、移动端三端首页保留 fallback——改造时降级链必须保留。 @@ -82,6 +84,7 @@ - 总入口:`README.md` - 文档索引:`docs/README.md` - Figma 设计资产清单:`docs/FIGMA_DESIGN_INVENTORY.md`;页面实现应复用既有变量与组件,以清单中的正式画板为视觉基准,不按单页自由发挥。 +- Figma 视觉验收必须逐张正式画板生成并人工查看当次截图,按 `docs/FIGMA_DESIGN_INVENTORY.md` 的“正式画板逐页视觉验收标准”检查信息架构、栅格、字号/换行、固定底栏/安全区、溢出和 active/disabled 状态;仅枚举节点或图层不算视觉通过。结构、视觉、功能、发布和生产验证必须分别记录。 - MVP 演示与验收:`docs/DEMO_ACCEPTANCE.md` - 阶段交付归档:`docs/archive/2026-07-mvp-delivery/` - 分阶段总纲:`docs/银城员工端APP分阶段实施总纲.md` diff --git a/README.md b/README.md index 562e36ff..4587cbc7 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ - 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。 - 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。 - 管理端 MVP:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。 -- 用户侧 Alpha:三端已在 `mobile-uni/` 按真实页面栈重建,portless 本地入口为 `https://wygj-mobile-uni.localhost/h5/`;员工端路由为 `/h5/#/pages/user/today/index`、`/practice/index`、`/sop/index`、`/profile/index`,候选人端为 `/h5/#/pages/candidate/index/index`,主管端为 `/h5/#/pages/supervisor/index/index`。已接手机号短信登录、问师傅检索/反馈/总结卡、员工训练闭环、案例素材入库、候选人面试/资料、主管复盘/派专项;旧 `mobile/` 不再作为新增功能承载面。 +- 用户侧 Alpha:三端已在 `mobile-uni/` 按真实页面栈重建,portless 本地入口为 `https://wygj-mobile-uni.localhost/h5/`;员工端路由为 `/h5/#/pages/user/today/index`、`/practice/index`、`/sop/index`、`/profile/index`,候选人端为 `/h5/#/pages/candidate/index/index`,主管端为 `/h5/#/pages/supervisor/index/index`。已接手机号短信登录、问师傅检索/反馈/总结卡、员工训练闭环、案例素材入库、候选人面试/资料、主管复盘/派专项;旧 `mobile/` 不再作为新增功能承载面。三端输入不是统一全模态:员工对练/每日题支持文字和语音、问师傅仅文字,候选面试支持文字和语音且补充资料可传图片,主管端以文字复盘和录音回听为主;详见 [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md)。 - 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。 -- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。 -- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。 -- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。 +- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。后端目录导入接口仅保留为运维/调试能力。 +- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。2026-07-15 已在线上完成外部同步配置和覆盖同步,生产快照为 3417 名员工,其中 3392 人可通过手机号映射,25 人为上游手机号数据不可用。 +- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。组织身份同步和至少 20 名手机号映射门槛已具备;下一步仍需选定真实试点窗口,产生员工训练、人工校准和 SOP 评审样本后重跑严格门禁,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。 - 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。 - 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。 - 生产发布:先执行 `scripts/release-preflight.sh`,再按 [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) 处理 schema、内置内容、备份、回滚和发布后回归;本地构建或 strict gate 通过,都不能单独证明线上已发布。 @@ -52,7 +52,7 @@ | [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 | | [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 | | [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 | -| [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md) | BRD 功能、权限、迁移、线上资源和未完成项的证据记录 | +| [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md) | 现阶段结论、三端输入匹配矩阵、权限、迁移、线上资源和未完成项证据 | | [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | 生产 schema/内置内容迁移、只读预检、发布和回滚边界 | | [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 | | [项目规划方案与 AI 接口说明](docs/AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位和 AI 接口规划 | @@ -66,4 +66,4 @@ ## 下一步 -现役验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。阶段一的工程闭环已经就绪,当前唯一主线是补齐正式组织身份数据,完成 1–2 个项目、至少 20 名员工的真实训练、校准和 SOP 业务评审后重跑 M5 严格门禁;详见 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)。大喇叭信息平台、知识学习平台和北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。 +现役验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。阶段一的工程闭环和正式组织同步已就绪,当前主线是选定 1–2 个项目、至少 20 名员工的真实试点窗口,产生训练、校准和 SOP 业务评审样本后重跑 M5 严格门禁;详见 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)。大喇叭信息平台、知识学习平台和北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。 diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityController.java new file mode 100644 index 00000000..891de78b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityController.java @@ -0,0 +1,76 @@ +package org.dromara.aihr.community; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.BestAnswerRequest; +import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest; +import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest; +import org.dromara.aihr.community.AihrCommunityDto.IncentiveOverviewResponse; +import org.dromara.aihr.community.AihrCommunityDto.PointsResponse; +import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@SaCheckLogin +@RequestMapping("/api/aihr/community") +public class AihrCommunityController { + + private final AihrCommunityService communityService; + private final AihrIncentiveService incentiveService; + + @GetMapping("/questions") + public R> questions(@RequestParam(value = "status", required = false) String status, + @RequestParam(value = "mine", defaultValue = "false") boolean mine) { + return R.ok(communityService.publicQuestions(status, mine)); + } + + @PostMapping("/questions") + public R createQuestion(@RequestBody CreateQuestionRequest request) { + return R.ok(communityService.createQuestion(request, false)); + } + + @GetMapping("/questions/{questionId}") + public R question(@PathVariable Long questionId) { + return R.ok(communityService.publicQuestion(questionId)); + } + + @PostMapping("/questions/{questionId}/answers") + public R answer(@PathVariable Long questionId, @RequestBody CreateAnswerRequest request) { + return R.ok(communityService.createAnswer(questionId, request)); + } + + @PostMapping("/questions/{questionId}/best-answer") + public R chooseBest(@PathVariable Long questionId, + @RequestBody BestAnswerRequest request) { + if (request == null || request.answerId() == null) { + return R.fail("请选择最佳回答"); + } + return R.ok(communityService.chooseBest(questionId, request.answerId(), false)); + } + + @GetMapping("/points/me") + public R myPoints() { + return R.ok(communityService.myPoints()); + } + + @GetMapping("/incentives") + public R incentives() { + AihrKnowledgePrincipal principal = communityService.currentPrincipal(); + PointsResponse points = communityService.myPoints(); + return R.ok(new IncentiveOverviewResponse( + points.pointsBalance(), points.studyCreditBalance(), incentiveService.list(principal, false))); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java new file mode 100644 index 00000000..ead6d4e1 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java @@ -0,0 +1,87 @@ +package org.dromara.aihr.community; + +import java.util.List; + +public final class AihrCommunityDto { + + private AihrCommunityDto() { + } + + public record CreateQuestionRequest(String title, + String content, + String category, + Integer rewardPoints, + String deadline, + String ruleCode) { + } + + public record CreateAnswerRequest(String content) { + } + + public record ReviewRequest(String decision, String note) { + } + + public record BestAnswerRequest(Long answerId) { + } + + public record AnswerResponse(Long id, + Long questionId, + String authorName, + String content, + String status, + boolean best, + boolean mine, + String createdAt) { + } + + public record QuestionResponse(Long id, + String type, + String category, + String title, + String content, + String authorName, + int rewardPoints, + String rewardAccountType, + String ruleCode, + String deadline, + String status, + Long bestAnswerId, + int answerCount, + boolean mine, + String createdAt, + List answers) { + } + + public record BestAnswerResponse(Long questionId, + Long answerId, + int awardedPoints, + boolean newlyAwarded, + int pointsBalance, + String accountType, + int awardedValue, + int studyCreditBalance) { + } + + /** + * {@code balance} is kept as a compatibility alias for the POINTS balance. + */ + public record PointsResponse(int balance, + int pointsBalance, + int studyCreditBalance, + List entries) { + } + + public record PointsEntryResponse(Long id, + String type, + Long businessId, + String accountType, + int points, + String reason, + String createdAt) { + } + + public record IncentiveOverviewResponse(int pointsBalance, + int studyCreditBalance, + List rules) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityPolicy.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityPolicy.java new file mode 100644 index 00000000..55d5f052 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityPolicy.java @@ -0,0 +1,43 @@ +package org.dromara.aihr.community; + +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; + +import java.util.Locale; +import java.util.Set; + +public final class AihrCommunityPolicy { + + private static final Set MODERATOR_ROLES = Set.of( + "supervisor", "manager", "project_manager", "admin", "superadmin", "hr_operator" + ); + + private AihrCommunityPolicy() { + } + + public static int rewardPoints(Integer requested, AihrCommunityProperties properties) { + int value = requested == null ? properties.getDefaultRewardPoints() : requested; + if (value < 0) { + throw new IllegalArgumentException("奖励积分不能小于0"); + } + int maximum = Math.max(0, properties.getMaxRewardPoints()); + return Math.min(value, maximum); + } + + public static boolean canModerate(AihrKnowledgePrincipal principal) { + if (principal == null) { + return false; + } + return principal.roles().stream() + .map(role -> role == null ? "" : role.trim().toLowerCase(Locale.ROOT)) + .anyMatch(MODERATOR_ROLES::contains); + } + + public static String moderatedStatus(String decision) { + String normalized = decision == null ? "" : decision.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "approve", "approved", "publish", "published", "通过", "发布" -> "PUBLISHED"; + case "reject", "rejected", "驳回", "拒绝" -> "REJECTED"; + default -> throw new IllegalArgumentException("审核结论只能为 approve 或 reject"); + }; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityProperties.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityProperties.java new file mode 100644 index 00000000..2664552e --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityProperties.java @@ -0,0 +1,28 @@ +package org.dromara.aihr.community; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "aihr.community") +public class AihrCommunityProperties { + + private int defaultRewardPoints = 10; + private int maxRewardPoints = 500; + + public int getDefaultRewardPoints() { + return defaultRewardPoints; + } + + public void setDefaultRewardPoints(int defaultRewardPoints) { + this.defaultRewardPoints = defaultRewardPoints; + } + + public int getMaxRewardPoints() { + return maxRewardPoints; + } + + public void setMaxRewardPoints(int maxRewardPoints) { + this.maxRewardPoints = maxRewardPoints; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java new file mode 100644 index 00000000..0a45a3ce --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java @@ -0,0 +1,591 @@ +package org.dromara.aihr.community; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest; +import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest; +import org.dromara.aihr.community.AihrCommunityDto.PointsEntryResponse; +import org.dromara.aihr.community.AihrCommunityDto.PointsResponse; +import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse; +import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +@Service +@RequiredArgsConstructor +public class AihrCommunityService { + + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private final JdbcTemplate jdbcTemplate; + private final AihrKnowledgePrincipalResolver principalResolver; + private final AihrCommunityProperties properties; + private final AihrIncentiveService incentiveService; + + private volatile boolean tablesReady; + + public List publicQuestions(String status, boolean mine) { + AihrKnowledgePrincipal principal = principalResolver.current(); + ensureTables(); + String normalized = publicStatus(status); + List rows = jdbcTemplate.query(""" + select q.id, q.author_user_id, q.author_name, q.question_type, q.category, q.title, q.content, + q.reward_points, q.reward_rule_code, + coalesce((select r.account_type from aihr_incentive_rule r + where r.tenant_id = q.tenant_id and r.rule_code = q.reward_rule_code + order by r.enabled desc, r.id desc limit 1), 'POINTS') reward_account_type, + q.deadline, q.question_status, + q.best_answer_id, q.create_time, + (select count(*) from aihr_community_answer a + where a.tenant_id = q.tenant_id and a.question_id = q.id and a.answer_status = 'PUBLISHED') answer_count + from aihr_community_question q + where q.tenant_id = ? + and ((? = 1 and q.author_user_id = ?) or (? = 0 and q.question_status in ('PUBLISHED', 'CLOSED'))) + and (? = '' or q.question_status = ?) + order by q.id desc + limit 100 + """, this::questionRow, principal.tenantId(), mine ? 1 : 0, principal.userId(), mine ? 1 : 0, + normalized, normalized); + return rows.stream().map(row -> response(principal, row, false)).toList(); + } + + public List adminQuestions(String status) { + AihrKnowledgePrincipal principal = requireModerator(); + ensureTables(); + String normalized = adminStatus(status); + List rows = jdbcTemplate.query(""" + select q.id, q.author_user_id, q.author_name, q.question_type, q.category, q.title, q.content, + q.reward_points, q.reward_rule_code, + coalesce((select r.account_type from aihr_incentive_rule r + where r.tenant_id = q.tenant_id and r.rule_code = q.reward_rule_code + order by r.enabled desc, r.id desc limit 1), 'POINTS') reward_account_type, + q.deadline, q.question_status, + q.best_answer_id, q.create_time, + (select count(*) from aihr_community_answer a + where a.tenant_id = q.tenant_id and a.question_id = q.id and a.answer_status = 'PUBLISHED') answer_count + from aihr_community_question q + where q.tenant_id = ? and (? = '' or q.question_status = ?) + order by q.id desc + limit 100 + """, this::questionRow, principal.tenantId(), normalized, normalized); + return rows.stream().map(row -> response(principal, row, true)).toList(); + } + + public QuestionResponse createQuestion(CreateQuestionRequest request, boolean adminBounty) { + AihrKnowledgePrincipal principal = adminBounty ? requireModerator() : principalResolver.current(); + ensureTables(); + if (request == null) { + throw new IllegalArgumentException("问题参数不能为空"); + } + String title = required(request.title(), "问题标题", 160); + String content = required(request.content(), "问题内容", 4000); + String category = normalizeCategory(request.category()); + String ruleCode = normalizeRuleCode(request.ruleCode()); + AihrIncentiveService.IncentiveRuleResponse rule = incentiveService.activeRule(principal, ruleCode); + Integer requestedPoints = request.rewardPoints() == null && rule != null + ? rule.value() : request.rewardPoints(); + int points = AihrCommunityPolicy.rewardPoints(requestedPoints, properties); + LocalDateTime deadline = parseDeadline(request.deadline(), adminBounty); + String type = adminBounty ? "BOUNTY" : "OPEN"; + String status = adminBounty ? "PUBLISHED" : "PENDING"; + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_community_question + (tenant_id, author_user_id, author_ext_party_id, author_name, title, content, + question_type, category, reward_rule_code, reward_points, deadline, question_status, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, principal.tenantId()); + statement.setLong(2, principal.userId()); + statement.setString(3, clean(principal.extPartyId())); + statement.setString(4, displayName(principal)); + statement.setString(5, title); + statement.setString(6, content); + statement.setString(7, type); + statement.setString(8, category); + statement.setString(9, ruleCode); + statement.setInt(10, points); + statement.setTimestamp(11, deadline == null ? null : Timestamp.valueOf(deadline)); + statement.setString(12, status); + return statement; + }, keyHolder); + Number key = keyHolder.getKey(); + return response(principal, findQuestion(principal, key == null ? 0L : key.longValue()), adminBounty); + } + + public AnswerResponse createAnswer(Long questionId, CreateAnswerRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + ensureTables(); + QuestionRow question = findQuestion(principal, questionId); + if (!"PUBLISHED".equals(question.status())) { + throw new IllegalArgumentException("当前问题暂不可回答"); + } + if (question.deadline() != null && question.deadline().isBefore(LocalDateTime.now())) { + throw new IllegalArgumentException("该问题已过截止时间"); + } + if (question.authorUserId().equals(principal.userId())) { + throw new IllegalArgumentException("不能回答自己发布的问题"); + } + String content = required(request == null ? null : request.content(), "回答内容", 5000); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_community_answer + (tenant_id, question_id, author_user_id, author_ext_party_id, author_name, content, + answer_status, is_best, create_time, update_time) + values (?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, principal.tenantId()); + statement.setLong(2, questionId); + statement.setLong(3, principal.userId()); + statement.setString(4, clean(principal.extPartyId())); + statement.setString(5, displayName(principal)); + statement.setString(6, content); + return statement; + }, keyHolder); + Number key = keyHolder.getKey(); + return findAnswer(principal, key == null ? 0L : key.longValue()); + } + + public QuestionResponse publicQuestion(Long questionId) { + AihrKnowledgePrincipal principal = principalResolver.current(); + ensureTables(); + QuestionRow row = findQuestion(principal, questionId); + if (!row.authorUserId().equals(principal.userId()) + && !("PUBLISHED".equals(row.status()) || "CLOSED".equals(row.status()))) { + throw new ServiceException("问题尚未发布", 403); + } + return response(principal, row, false); + } + + public QuestionResponse reviewQuestion(Long questionId, ReviewRequest request) { + AihrKnowledgePrincipal principal = requireModerator(); + ensureTables(); + String status = AihrCommunityPolicy.moderatedStatus(request == null ? null : request.decision()); + int updated = jdbcTemplate.update(""" + update aihr_community_question + set question_status = ?, moderator_user_id = ?, moderation_note = ?, moderated_time = now(), update_time = now() + where tenant_id = ? and id = ? and question_status = 'PENDING' + """, status, principal.userId(), truncate(clean(request.note()), 300), principal.tenantId(), questionId); + if (updated == 0) { + throw new IllegalArgumentException("待审核问题不存在或已处理"); + } + return response(principal, findQuestion(principal, questionId), true); + } + + public AnswerResponse reviewAnswer(Long answerId, ReviewRequest request) { + AihrKnowledgePrincipal principal = requireModerator(); + ensureTables(); + String status = AihrCommunityPolicy.moderatedStatus(request == null ? null : request.decision()); + int updated = jdbcTemplate.update(""" + update aihr_community_answer + set answer_status = ?, moderator_user_id = ?, moderation_note = ?, moderated_time = now(), update_time = now() + where tenant_id = ? and id = ? and answer_status = 'PENDING' + """, status, principal.userId(), truncate(clean(request.note()), 300), principal.tenantId(), answerId); + if (updated == 0) { + throw new IllegalArgumentException("待审核回答不存在或已处理"); + } + return findAnswer(principal, answerId); + } + + @Transactional(rollbackFor = Exception.class) + public BestAnswerResponse chooseBest(Long questionId, Long answerId, boolean admin) { + AihrKnowledgePrincipal principal = admin ? requireModerator() : principalResolver.current(); + ensureTables(); + QuestionRow question = findQuestionForUpdate(principal, questionId); + if (!admin && !question.authorUserId().equals(principal.userId())) { + throw new ServiceException("只有提问人或管理员可以选择最佳回答", 403); + } + if (!("PUBLISHED".equals(question.status()) || "CLOSED".equals(question.status()))) { + throw new IllegalArgumentException("当前问题不能选择最佳回答"); + } + AnswerRow answer = findAnswerRow(principal, answerId); + if (!questionId.equals(answer.questionId()) || !"PUBLISHED".equals(answer.status())) { + throw new IllegalArgumentException("回答不存在或尚未通过审核"); + } + if (question.authorUserId().equals(answer.authorUserId())) { + throw new IllegalArgumentException("不能把自己的回答设为最佳回答"); + } + if (question.bestAnswerId() != null && !question.bestAnswerId().equals(answerId)) { + throw new IllegalArgumentException("该问题已经选择了其他最佳回答"); + } + if (question.bestAnswerId() == null) { + int updated = jdbcTemplate.update(""" + update aihr_community_question + set best_answer_id = ?, question_status = 'CLOSED', update_time = now() + where tenant_id = ? and id = ? and best_answer_id is null + """, answerId, principal.tenantId(), questionId); + if (updated == 0) { + throw new IllegalStateException("最佳回答已被其他操作更新,请刷新后重试"); + } + jdbcTemplate.update(""" + update aihr_community_answer set is_best = 1, update_time = now() + where tenant_id = ? and id = ? + """, principal.tenantId(), answerId); + } + Award award = awardableReward(principal, question, answer); + int inserted = award.value() <= 0 ? 0 : jdbcTemplate.update(""" + insert ignore into aihr_points_ledger + (tenant_id, user_id, ext_party_id, business_type, business_id, account_type, points, reason, create_time) + values (?, ?, ?, 'BEST_ANSWER', ?, ?, ?, ?, now()) + """, principal.tenantId(), answer.authorUserId(), answer.authorExtPartyId(), answerId, + award.accountType(), award.value(), + "开放问题榜最佳回答"); + String awardedAccount = inserted > 0 ? award.accountType() + : existingAwardAccount(principal.tenantId(), answer.authorUserId(), answerId, award.accountType()); + int awardedValue = inserted > 0 ? award.value() : 0; + return new BestAnswerResponse(questionId, answerId, + "POINTS".equals(awardedAccount) ? awardedValue : 0, + inserted > 0, + accountBalance(principal.tenantId(), answer.authorUserId(), "POINTS"), + awardedAccount, + awardedValue, + accountBalance(principal.tenantId(), answer.authorUserId(), "STUDY_CREDIT")); + } + + public PointsResponse myPoints() { + AihrKnowledgePrincipal principal = principalResolver.current(); + ensureTables(); + List entries = jdbcTemplate.query(""" + select id, business_type, business_id, account_type, points, reason, create_time + from aihr_points_ledger + where tenant_id = ? and user_id = ? + order by id desc + limit 50 + """, (rs, rowNum) -> new PointsEntryResponse( + rs.getLong("id"), rs.getString("business_type"), rs.getLong("business_id"), + rs.getString("account_type"), rs.getInt("points"), rs.getString("reason"), + time(rs.getTimestamp("create_time")) + ), principal.tenantId(), principal.userId()); + int pointsBalance = accountBalance(principal.tenantId(), principal.userId(), "POINTS"); + int studyCreditBalance = accountBalance(principal.tenantId(), principal.userId(), "STUDY_CREDIT"); + return new PointsResponse(pointsBalance, pointsBalance, studyCreditBalance, entries); + } + + public AihrKnowledgePrincipal currentPrincipal() { + return principalResolver.current(); + } + + private QuestionResponse response(AihrKnowledgePrincipal principal, QuestionRow row, boolean includeModeration) { + List answers = answers(principal, row.id(), includeModeration); + return new QuestionResponse(row.id(), row.type(), row.category(), row.title(), row.content(), row.authorName(), + row.rewardPoints(), row.rewardAccountType(), row.ruleCode(), + row.deadline() == null ? "" : row.deadline().format(TIME_FORMAT), + row.status(), row.bestAnswerId(), row.answerCount(), row.authorUserId().equals(principal.userId()), + row.createdAt().format(TIME_FORMAT), answers); + } + + private List answers(AihrKnowledgePrincipal principal, Long questionId, boolean includeModeration) { + return jdbcTemplate.query(""" + select id, question_id, author_user_id, author_ext_party_id, author_name, content, + answer_status, is_best, create_time + from aihr_community_answer + where tenant_id = ? and question_id = ? + and (? = 1 or answer_status = 'PUBLISHED' or author_user_id = ?) + order by is_best desc, id desc + """, (rs, rowNum) -> new AnswerResponse( + rs.getLong("id"), rs.getLong("question_id"), rs.getString("author_name"), + rs.getString("content"), rs.getString("answer_status"), rs.getBoolean("is_best"), + rs.getLong("author_user_id") == principal.userId(), + time(rs.getTimestamp("create_time")) + ), principal.tenantId(), questionId, includeModeration ? 1 : 0, principal.userId()); + } + + private QuestionRow findQuestion(AihrKnowledgePrincipal principal, Long id) { + List rows = jdbcTemplate.query(""" + select q.id, q.author_user_id, q.author_name, q.question_type, q.category, q.title, q.content, + q.reward_points, q.reward_rule_code, + coalesce((select r.account_type from aihr_incentive_rule r + where r.tenant_id = q.tenant_id and r.rule_code = q.reward_rule_code + order by r.enabled desc, r.id desc limit 1), 'POINTS') reward_account_type, + q.deadline, q.question_status, + q.best_answer_id, q.create_time, + (select count(*) from aihr_community_answer a + where a.tenant_id = q.tenant_id and a.question_id = q.id and a.answer_status = 'PUBLISHED') answer_count + from aihr_community_question q where q.tenant_id = ? and q.id = ? + """, this::questionRow, principal.tenantId(), id); + if (rows.isEmpty()) { + throw new IllegalArgumentException("问题不存在"); + } + return rows.get(0); + } + + private QuestionRow findQuestionForUpdate(AihrKnowledgePrincipal principal, Long id) { + List rows = jdbcTemplate.query(""" + select q.id, q.author_user_id, q.author_name, q.question_type, q.category, q.title, q.content, + q.reward_points, q.reward_rule_code, + coalesce((select r.account_type from aihr_incentive_rule r + where r.tenant_id = q.tenant_id and r.rule_code = q.reward_rule_code + order by r.enabled desc, r.id desc limit 1), 'POINTS') reward_account_type, + q.deadline, q.question_status, + q.best_answer_id, q.create_time, 0 answer_count + from aihr_community_question q where q.tenant_id = ? and q.id = ? for update + """, this::questionRow, principal.tenantId(), id); + if (rows.isEmpty()) { + throw new IllegalArgumentException("问题不存在"); + } + return rows.get(0); + } + + private AnswerResponse findAnswer(AihrKnowledgePrincipal principal, Long id) { + AnswerRow row = findAnswerRow(principal, id); + return new AnswerResponse(row.id(), row.questionId(), row.authorName(), row.content(), row.status(), + row.best(), row.authorUserId().equals(principal.userId()), row.createdAt().format(TIME_FORMAT)); + } + + private AnswerRow findAnswerRow(AihrKnowledgePrincipal principal, Long id) { + List rows = jdbcTemplate.query(""" + select id, question_id, author_user_id, author_ext_party_id, author_name, content, + answer_status, is_best, create_time + from aihr_community_answer where tenant_id = ? and id = ? + """, (rs, rowNum) -> new AnswerRow( + rs.getLong("id"), rs.getLong("question_id"), rs.getLong("author_user_id"), + rs.getString("author_ext_party_id"), rs.getString("author_name"), rs.getString("content"), + rs.getString("answer_status"), rs.getBoolean("is_best"), + rs.getTimestamp("create_time").toLocalDateTime() + ), principal.tenantId(), id); + if (rows.isEmpty()) { + throw new IllegalArgumentException("回答不存在"); + } + return rows.get(0); + } + + private QuestionRow questionRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + Timestamp deadline = rs.getTimestamp("deadline"); + Number bestId = (Number) rs.getObject("best_answer_id"); + return new QuestionRow( + rs.getLong("id"), rs.getLong("author_user_id"), rs.getString("author_name"), + rs.getString("question_type"), rs.getString("category"), rs.getString("title"), rs.getString("content"), + rs.getInt("reward_points"), rs.getString("reward_rule_code"), rs.getString("reward_account_type"), + deadline == null ? null : deadline.toLocalDateTime(), rs.getString("question_status"), + bestId == null ? null : bestId.longValue(), rs.getInt("answer_count"), + rs.getTimestamp("create_time").toLocalDateTime() + ); + } + + private Award awardableReward(AihrKnowledgePrincipal principal, QuestionRow question, AnswerRow answer) { + AihrIncentiveService.IncentiveRuleResponse rule = incentiveService.activeRule(principal, question.ruleCode()); + if (rule == null) { + return new Award("POINTS", 0); + } + String accountType = AihrIncentiveService.normalizeAccountType(rule.accountType()); + int desired = Math.max(0, question.rewardPoints()); + if (rule.dailyCap() <= 0) { + return new Award(accountType, desired); + } + Integer awardedToday = jdbcTemplate.queryForObject(""" + select coalesce(sum(points), 0) from aihr_points_ledger + where tenant_id = ? and user_id = ? and account_type = ? and create_time >= current_date() + """, Integer.class, principal.tenantId(), answer.authorUserId(), accountType); + return new Award(accountType, + Math.min(desired, Math.max(0, rule.dailyCap() - (awardedToday == null ? 0 : awardedToday)))); + } + + private int accountBalance(String tenantId, Long userId, String accountType) { + Integer balance = jdbcTemplate.queryForObject(""" + select coalesce(sum(points), 0) from aihr_points_ledger + where tenant_id = ? and user_id = ? and account_type = ? + """, Integer.class, tenantId, userId, accountType); + return balance == null ? 0 : balance; + } + + private String existingAwardAccount(String tenantId, Long userId, Long answerId, String fallback) { + List rows = jdbcTemplate.query(""" + select account_type from aihr_points_ledger + where tenant_id = ? and user_id = ? and business_type = 'BEST_ANSWER' and business_id = ? + limit 1 + """, (rs, rowNum) -> rs.getString("account_type"), tenantId, userId, answerId); + return rows.isEmpty() ? fallback : rows.get(0); + } + + private AihrKnowledgePrincipal requireModerator() { + AihrKnowledgePrincipal principal = principalResolver.current(); + if (!AihrCommunityPolicy.canModerate(principal)) { + throw new ServiceException("仅主管或管理员可执行该操作", 403); + } + return principal; + } + + private void ensureTables() { + if (tablesReady) { + return; + } + synchronized (this) { + if (tablesReady) { + return; + } + for (String ddl : tableDdls()) { + jdbcTemplate.execute(ddl); + } + Integer accountTypeColumnCount = jdbcTemplate.queryForObject(""" + select count(*) from information_schema.columns + where table_schema = database() + and table_name = 'aihr_points_ledger' + and column_name = 'account_type' + """, Integer.class); + if (accountTypeColumnCount == null || accountTypeColumnCount == 0) { + jdbcTemplate.execute(""" + ALTER TABLE `aihr_points_ledger` + ADD COLUMN `account_type` varchar(20) NOT NULL DEFAULT 'POINTS' + COMMENT 'POINTS/STUDY_CREDIT' AFTER `business_id` + """); + } + tablesReady = true; + } + } + + private static List tableDdls() { + List ddls = new ArrayList<>(); + ddls.add(""" + CREATE TABLE IF NOT EXISTS `aihr_community_question` ( + `id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL, + `author_user_id` bigint NOT NULL, `author_ext_party_id` varchar(80) DEFAULT NULL, + `author_name` varchar(100) NOT NULL, `title` varchar(160) NOT NULL, `content` text NOT NULL, + `question_type` varchar(24) NOT NULL DEFAULT 'OPEN', + `category` varchar(40) NOT NULL DEFAULT '工作求助', + `reward_rule_code` varchar(60) NOT NULL DEFAULT 'BEST_ANSWER', + `reward_points` int NOT NULL DEFAULT 10, `deadline` datetime DEFAULT NULL, + `question_status` varchar(24) NOT NULL DEFAULT 'PENDING', `best_answer_id` bigint DEFAULT NULL, + `moderator_user_id` bigint DEFAULT NULL, `moderation_note` varchar(300) DEFAULT NULL, + `moderated_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), KEY `idx_aihr_community_question_board` (`tenant_id`, `question_status`, `create_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + ddls.add(""" + CREATE TABLE IF NOT EXISTS `aihr_community_answer` ( + `id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL, + `question_id` bigint NOT NULL, `author_user_id` bigint NOT NULL, + `author_ext_party_id` varchar(80) DEFAULT NULL, `author_name` varchar(100) NOT NULL, + `content` text NOT NULL, `answer_status` varchar(24) NOT NULL DEFAULT 'PENDING', + `is_best` tinyint NOT NULL DEFAULT 0, `moderator_user_id` bigint DEFAULT NULL, + `moderation_note` varchar(300) DEFAULT NULL, `moderated_time` datetime DEFAULT NULL, + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), KEY `idx_aihr_community_answer_question` (`tenant_id`, `question_id`, `answer_status`, `create_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + ddls.add(""" + CREATE TABLE IF NOT EXISTS `aihr_points_ledger` ( + `id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL, + `user_id` bigint NOT NULL, `ext_party_id` varchar(80) DEFAULT NULL, + `business_type` varchar(40) NOT NULL, `business_id` bigint NOT NULL, + `account_type` varchar(20) NOT NULL DEFAULT 'POINTS', + `points` int NOT NULL, `reason` varchar(200) NOT NULL, + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_points_business` (`tenant_id`, `user_id`, `business_type`, `business_id`), + KEY `idx_aihr_points_user` (`tenant_id`, `user_id`, `create_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + return ddls; + } + + private static String publicStatus(String status) { + String value = clean(status).toUpperCase(Locale.ROOT); + return ("PUBLISHED".equals(value) || "CLOSED".equals(value)) ? value : ""; + } + + private static String adminStatus(String status) { + String value = clean(status).toUpperCase(Locale.ROOT); + return List.of("PENDING", "PUBLISHED", "REJECTED", "CLOSED").contains(value) ? value : ""; + } + + private static String normalizeRuleCode(String ruleCode) { + String value = clean(ruleCode).toUpperCase(Locale.ROOT); + return value.isBlank() ? "BEST_ANSWER" : value; + } + + private static String normalizeCategory(String category) { + String value = clean(category); + return value.isBlank() ? "工作求助" : truncate(value, 40); + } + + private static LocalDateTime parseDeadline(String value, boolean required) { + String text = clean(value); + if (text.isBlank()) { + if (required) { + throw new IllegalArgumentException("悬赏问题必须设置截止时间"); + } + return null; + } + try { + LocalDateTime parsed; + if (text.matches("\\d{4}-\\d{2}-\\d{2}")) { + parsed = LocalDate.parse(text).atTime(23, 59, 59); + } else if (text.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}")) { + parsed = LocalDateTime.parse(text, TIME_FORMAT); + } else { + parsed = LocalDateTime.parse(text); + } + if (parsed.isBefore(LocalDateTime.now())) { + throw new IllegalArgumentException("截止时间不能早于当前时间"); + } + return parsed; + } catch (DateTimeParseException error) { + throw new IllegalArgumentException("截止时间格式不正确"); + } + } + + private static String required(String value, String label, int max) { + String cleaned = clean(value); + if (cleaned.isBlank()) { + throw new IllegalArgumentException(label + "不能为空"); + } + if (cleaned.length() > max) { + throw new IllegalArgumentException(label + "不能超过" + max + "字"); + } + return cleaned; + } + + private static String displayName(AihrKnowledgePrincipal principal) { + String identity = clean(principal.extPartyId()); + if (identity.isBlank()) { + return "管理员" + principal.userId(); + } + return identity.length() <= 4 ? identity : "员工" + identity.substring(identity.length() - 4); + } + + private static String time(Timestamp value) { + return value == null ? "" : value.toLocalDateTime().format(TIME_FORMAT); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String truncate(String value, int max) { + return value.length() <= max ? value : value.substring(0, max); + } + + private record QuestionRow(Long id, Long authorUserId, String authorName, String type, String category, String title, + String content, int rewardPoints, String ruleCode, String rewardAccountType, LocalDateTime deadline, + String status, Long bestAnswerId, int answerCount, LocalDateTime createdAt) { + } + + private record AnswerRow(Long id, Long questionId, Long authorUserId, String authorExtPartyId, + String authorName, String content, String status, boolean best, + LocalDateTime createdAt) { + } + + private record Award(String accountType, int value) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrIncentiveAdminController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrIncentiveAdminController.java new file mode 100644 index 00000000..80a4962e --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrIncentiveAdminController.java @@ -0,0 +1,52 @@ +package org.dromara.aihr.community; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.community.AihrIncentiveService.IncentiveRuleRequest; +import org.dromara.aihr.community.AihrIncentiveService.IncentiveRuleResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@SaCheckLogin +@RequestMapping("/api/aihr/incentive-admin") +public class AihrIncentiveAdminController { + + private final AihrIncentiveService incentiveService; + private final AihrKnowledgePrincipalResolver principalResolver; + + @GetMapping("/rules") + public R> rules() { + AihrKnowledgePrincipal principal = requireModerator(); + return R.ok(incentiveService.list(principal, true)); + } + + @PostMapping("/rules") + public R create(@RequestBody IncentiveRuleRequest request) { + return R.ok(incentiveService.create(requireModerator(), request)); + } + + @PutMapping("/rules") + public R update(@RequestBody IncentiveRuleRequest request) { + return R.ok(incentiveService.update(requireModerator(), request)); + } + + private AihrKnowledgePrincipal requireModerator() { + AihrKnowledgePrincipal principal = principalResolver.current(); + if (!AihrCommunityPolicy.canModerate(principal)) { + throw new ServiceException("仅主管或管理员可管理激励规则", 403); + } + return principal; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrIncentiveService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrIncentiveService.java new file mode 100644 index 00000000..0a76658b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrIncentiveService.java @@ -0,0 +1,205 @@ +package org.dromara.aihr.community; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Locale; + +@Service +@RequiredArgsConstructor +public class AihrIncentiveService { + + private final JdbcTemplate jdbcTemplate; + private volatile boolean tableReady; + + public List list(AihrKnowledgePrincipal principal, boolean includeDisabled) { + ensureTable(); + ensureDefaultRule(principal.tenantId()); + return jdbcTemplate.query(""" + select id, rule_code, display_name, account_type, rule_value, daily_cap, enabled + from aihr_incentive_rule + where tenant_id = ? and (? = 1 or enabled = 1) + order by id + """, (rs, rowNum) -> new IncentiveRuleResponse( + rs.getLong("id"), rs.getString("rule_code"), rs.getString("display_name"), + rs.getString("account_type"), rs.getInt("rule_value"), rs.getInt("daily_cap"), + rs.getBoolean("enabled") + ), principal.tenantId(), includeDisabled ? 1 : 0); + } + + public IncentiveRuleResponse create(AihrKnowledgePrincipal principal, IncentiveRuleRequest request) { + ensureTable(); + NormalizedRule rule = normalize(request, false); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_incentive_rule + (tenant_id, rule_code, display_name, account_type, rule_value, daily_cap, enabled, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, principal.tenantId()); + statement.setString(2, rule.ruleCode()); + statement.setString(3, rule.displayName()); + statement.setString(4, rule.accountType()); + statement.setInt(5, rule.value()); + statement.setInt(6, rule.dailyCap()); + statement.setBoolean(7, rule.enabled()); + return statement; + }, keyHolder); + Number id = keyHolder.getKey(); + return find(principal, id == null ? 0L : id.longValue()); + } + + public IncentiveRuleResponse update(AihrKnowledgePrincipal principal, IncentiveRuleRequest request) { + ensureTable(); + NormalizedRule rule = normalize(request, true); + int updated = jdbcTemplate.update(""" + update aihr_incentive_rule + set rule_code = ?, display_name = ?, account_type = ?, rule_value = ?, daily_cap = ?, enabled = ?, update_time = now() + where tenant_id = ? and id = ? + """, rule.ruleCode(), rule.displayName(), rule.accountType(), rule.value(), rule.dailyCap(), + rule.enabled(), principal.tenantId(), request.id()); + if (updated == 0) { + throw new IllegalArgumentException("激励规则不存在"); + } + return find(principal, request.id()); + } + + IncentiveRuleResponse activeRule(AihrKnowledgePrincipal principal, String ruleCode) { + ensureTable(); + ensureDefaultRule(principal.tenantId()); + List rules = jdbcTemplate.query(""" + select id, rule_code, display_name, account_type, rule_value, daily_cap, enabled + from aihr_incentive_rule + where tenant_id = ? and rule_code = ? and enabled = 1 + limit 1 + """, (rs, rowNum) -> new IncentiveRuleResponse( + rs.getLong("id"), rs.getString("rule_code"), rs.getString("display_name"), + rs.getString("account_type"), rs.getInt("rule_value"), rs.getInt("daily_cap"), true + ), principal.tenantId(), normalizeCode(ruleCode)); + return rules.isEmpty() ? null : rules.get(0); + } + + private IncentiveRuleResponse find(AihrKnowledgePrincipal principal, Long id) { + List rows = jdbcTemplate.query(""" + select id, rule_code, display_name, account_type, rule_value, daily_cap, enabled + from aihr_incentive_rule + where tenant_id = ? and id = ? + """, (rs, rowNum) -> new IncentiveRuleResponse( + rs.getLong("id"), rs.getString("rule_code"), rs.getString("display_name"), + rs.getString("account_type"), rs.getInt("rule_value"), rs.getInt("daily_cap"), + rs.getBoolean("enabled") + ), principal.tenantId(), id); + if (rows.isEmpty()) { + throw new IllegalArgumentException("激励规则不存在"); + } + return rows.get(0); + } + + private static NormalizedRule normalize(IncentiveRuleRequest request, boolean idRequired) { + if (request == null || (idRequired && request.id() == null)) { + throw new IllegalArgumentException("激励规则参数不完整"); + } + String code = normalizeCode(request.ruleCode()); + String name = clean(request.displayName()); + if (!code.matches("[A-Z0-9_]{2,60}")) { + throw new IllegalArgumentException("规则编码仅支持大写字母、数字和下划线"); + } + if (name.isBlank() || name.length() > 100) { + throw new IllegalArgumentException("规则名称不能为空且不能超过100字"); + } + String accountType = normalizeAccountType(request.accountType()); + int value = request.value() == null ? 0 : request.value(); + int dailyCap = request.dailyCap() == null ? 0 : request.dailyCap(); + if (value < 0 || value > 100000 || dailyCap < 0 || dailyCap > 1000000) { + throw new IllegalArgumentException("激励数值超出允许范围"); + } + return new NormalizedRule(code, name, accountType, value, dailyCap, + request.enabled() == null || request.enabled()); + } + + private void ensureTable() { + if (tableReady) { + return; + } + synchronized (this) { + if (tableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_incentive_rule` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) NOT NULL, + `rule_code` varchar(60) NOT NULL, + `display_name` varchar(100) NOT NULL, + `account_type` varchar(20) NOT NULL DEFAULT 'POINTS', + `rule_value` int NOT NULL DEFAULT 0, + `daily_cap` int NOT NULL DEFAULT 0, + `enabled` tinyint NOT NULL DEFAULT 1, + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_incentive_rule` (`tenant_id`, `rule_code`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + tableReady = true; + } + } + + private void ensureDefaultRule(String tenantId) { + jdbcTemplate.update(""" + insert ignore into aihr_incentive_rule + (tenant_id, rule_code, display_name, account_type, rule_value, daily_cap, enabled) + values (?, 'BEST_ANSWER', '最佳回答积分', 'POINTS', 10, 100, 1) + """, tenantId); + } + + private static String normalizeCode(String value) { + return clean(value).toUpperCase(Locale.ROOT); + } + + static String normalizeAccountType(String value) { + String accountType = clean(value).toUpperCase(Locale.ROOT); + if (!("POINTS".equals(accountType) || "STUDY_CREDIT".equals(accountType))) { + throw new IllegalArgumentException("账户类型仅支持 POINTS 或 STUDY_CREDIT"); + } + return accountType; + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + public record IncentiveRuleRequest(Long id, + String ruleCode, + String displayName, + String accountType, + Integer value, + Integer dailyCap, + Boolean enabled) { + } + + public record IncentiveRuleResponse(Long id, + String ruleCode, + String displayName, + String accountType, + int value, + int dailyCap, + boolean enabled) { + } + + private record NormalizedRule(String ruleCode, + String displayName, + String accountType, + int value, + int dailyCap, + boolean enabled) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrQuestionAdminController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrQuestionAdminController.java new file mode 100644 index 00000000..2c97270f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrQuestionAdminController.java @@ -0,0 +1,57 @@ +package org.dromara.aihr.community; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.BestAnswerRequest; +import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest; +import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse; +import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@SaCheckLogin +@RequestMapping("/api/aihr/question-admin") +public class AihrQuestionAdminController { + + private final AihrCommunityService communityService; + + @GetMapping("/questions") + public R> questions(@RequestParam(value = "status", required = false) String status) { + return R.ok(communityService.adminQuestions(status)); + } + + @PostMapping("/questions") + public R createBounty(@RequestBody CreateQuestionRequest request) { + return R.ok(communityService.createQuestion(request, true)); + } + + @PostMapping("/questions/{id}/review") + public R reviewQuestion(@PathVariable Long id, @RequestBody ReviewRequest request) { + return R.ok(communityService.reviewQuestion(id, request)); + } + + @PostMapping("/answers/{id}/review") + public R reviewAnswer(@PathVariable Long id, @RequestBody ReviewRequest request) { + return R.ok(communityService.reviewAnswer(id, request)); + } + + @PostMapping("/questions/{id}/best-answer") + public R chooseBest(@PathVariable Long id, @RequestBody BestAnswerRequest request) { + if (request == null || request.answerId() == null) { + return R.fail("请选择最佳回答"); + } + return R.ok(communityService.chooseBest(id, request.answerId(), true)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java index 34847a40..f557183e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -11,6 +11,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse; import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest; import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; @@ -27,6 +29,20 @@ import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionResponse; import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse; import org.dromara.aihr.domain.vo.AihrMobileDto.IdentityResponse; +import org.dromara.aihr.learning.AihrExamService; +import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackRequest; +import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackResponse; +import org.dromara.aihr.learning.AihrLearningDto.DifficultyAssignRequest; +import org.dromara.aihr.learning.AihrLearningDto.DifficultyResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamSummaryResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest; +import org.dromara.aihr.learning.AihrLearningDto.LearningMatrixResponse; +import org.dromara.aihr.learning.AihrLearningService; import org.dromara.aihr.service.AihrOrgSyncService; import org.dromara.aihr.service.AihrMobileSeedService; import org.dromara.aihr.service.AihrSensitiveText; @@ -44,6 +60,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PutMapping; import java.io.IOException; import java.util.LinkedHashMap; @@ -63,6 +80,8 @@ public class AihrMobileController { private final AihrMobileSeedService mobileSeedService; private final AihrOrgSyncService orgSyncService; private final ISysOssService ossService; + private final AihrLearningService learningService; + private final AihrExamService examService; @SaIgnore @GetMapping("/home/{role}") @@ -224,12 +243,92 @@ public class AihrMobileController { return R.ok(mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId())); } + @PostMapping("/practice/assignments/batch") + public R createPracticeAssignments(@RequestBody PracticeAssignmentBatchRequest request) { + return R.ok(mobileSeedService.createPracticeAssignments(request, supervisorScopeExtPartyId())); + } + @PostMapping("/practice/assignments/{id}/answer") public R answerDailyDrill(@PathVariable Long id, @RequestBody(required = false) DailyDrillAnswerRequest request) { DailyDrillAnswerResponse response = mobileSeedService.answerDailyDrill(id, request, ownMobileExtPartyId(null), currentAppUserId()); return response == null ? R.fail("每日三题不存在或已完成") : R.ok(response); } + @PostMapping("/practice/assignments/{id}/feedback") + public R assignmentFeedback(@PathVariable Long id, + @RequestBody AssignmentFeedbackRequest request) { + return R.ok(learningService.submitAssignmentFeedback(id, request, ownMobileExtPartyId(null))); + } + + @GetMapping("/practice/difficulties") + public R> practiceDifficulties( + @RequestParam(required = false, defaultValue = "20") Integer limit, + @RequestParam(required = false, defaultValue = "30") Integer windowDays, + @RequestParam(required = false) String position) { + return R.ok(learningService.difficulties( + supervisorScopeExtPartyId(), limit == null ? 20 : limit, + windowDays == null ? 30 : windowDays, position)); + } + + @PostMapping("/practice/difficulties/assign") + public R assignPracticeDifficulty(@RequestBody DifficultyAssignRequest request) { + return R.ok(learningService.assignDifficulty(request, supervisorScopeExtPartyId())); + } + + @GetMapping("/learning/matrix") + public R learningMatrix(@RequestParam(required = false) String positionCode) { + supervisorScopeExtPartyId(); + return R.ok(learningService.matrix(positionCode)); + } + + @GetMapping("/exams") + public R> exams() { + return R.ok(examService.employeeExams(ownMobileExtPartyId(null))); + } + + @GetMapping("/exams/{id}") + public R exam(@PathVariable Long id) { + return R.ok(examService.employeeExam(id, ownMobileExtPartyId(null))); + } + + @PostMapping("/exams/{id}/submit") + public R submitExam(@PathVariable Long id, @RequestBody ExamSubmitRequest request) { + return R.ok(examService.submit(id, request, ownMobileExtPartyId(null))); + } + + @GetMapping("/exams/supervisor") + public R> supervisorExams() { + return R.ok(examService.supervisorExams(supervisorScopeExtPartyId())); + } + + @PostMapping("/exams/supervisor") + public R createSupervisorExam(@RequestBody ExamUpsertRequest request) { + String supervisor = supervisorScopeExtPartyId(); + return R.ok(examService.create(request, supervisor)); + } + + @GetMapping("/exams/supervisor/{id}") + public R supervisorExam(@PathVariable Long id) { + return R.ok(examService.supervisorExam(id, supervisorScopeExtPartyId())); + } + + @PutMapping("/exams/supervisor/{id}") + public R updateSupervisorExam(@PathVariable Long id, @RequestBody ExamUpsertRequest request) { + String supervisor = supervisorScopeExtPartyId(); + return R.ok(examService.update(id, request, supervisor)); + } + + @PostMapping("/exams/supervisor/{id}/publish") + public R publishSupervisorExam(@PathVariable Long id, + @RequestBody(required = false) ExamPublishRequest request) { + return R.ok(examService.publish(id, request, supervisorScopeExtPartyId())); + } + + @GetMapping("/exams/supervisor/{id}/results") + public R> supervisorExamResults(@PathVariable Long id) { + return R.ok(examService.results(id, supervisorScopeExtPartyId())); + } + @PostMapping("/practice/satisfaction") public R submitSatisfaction(@RequestBody SatisfactionRequest request) { SatisfactionResponse response = mobileSeedService.submitSatisfaction(request, ownMobileExtPartyId(null)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java index b39905e1..b1de1e61 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java @@ -29,6 +29,13 @@ import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; import org.dromara.aihr.service.AihrSopSeedService; import org.dromara.aihr.service.AihrOrgSyncService; import org.dromara.aihr.service.AihrUploadQueueService; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.common.core.constant.TenantConstants; import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.model.LoginUser; @@ -62,13 +69,22 @@ public class AihrSopController { private final AihrSopSeedService sopSeedService; private final AihrOrgSyncService orgSyncService; private final AihrUploadQueueService uploadQueueService; + private final AihrKnowledgeAccessService knowledgeAccessService; + private final AihrKnowledgeAppService knowledgeAppService; + private final AihrKnowledgePrincipalResolver knowledgePrincipalResolver; + private final AihrKnowledgeQueryService knowledgeQueryService; @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @PostMapping("/doc/upload-async") public R uploadAsync(@RequestPart("file") MultipartFile file, @RequestParam(value = "category", required = false) String category, + @RequestParam(value = "spaceCodes", required = false) List spaceCodes, @RequestParam(value = "batchId", required = false) String batchId) { - return R.ok(uploadQueueService.enqueue(file, category, batchId)); + if (spaceCodes == null || spaceCodes.isEmpty()) { + return R.ok(uploadQueueService.enqueue(file, category, batchId)); + } + requireManageSpaces(spaceCodes); + return R.ok(uploadQueueService.enqueue(file, spaceCodes, category, batchId)); } @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @@ -87,7 +103,12 @@ public class AihrSopController { @SaCheckLogin @PostMapping("/search") public R search(@RequestBody SearchRequest request) { - return R.ok(sopSeedService.search(restrictDemoVerificationSource(request), currentRequesterExtPartyId())); + SearchRequest compatible = restrictDemoVerificationSource(request); + return R.ok(knowledgeQueryService.queryInternal(new QueryRequest( + compatible == null ? null : compatible.queryText(), List.of(), + compatible == null ? null : compatible.category(), compatible == null ? null : compatible.position(), + compatible == null ? null : compatible.source(), compatible == null ? null : compatible.limit(), null + )).legacy()); } private String currentRequesterExtPartyId() { @@ -166,13 +187,29 @@ public class AihrSopController { @SaCheckLogin @PostMapping("/summary-card") public R summaryCard(@RequestBody SummaryCardRequest request) { - return R.ok(sopSeedService.summaryCard(request)); + AihrKnowledgePrincipal principal = knowledgePrincipalResolver.current(); + AuthenticatedApp app = knowledgeAppService.requireSessionApp(principal.tenantId(), principal.clientKey()); + Set allowedKnowledgeIds = knowledgeAccessService.resolveInternalSpaceIds( + principal, app, List.of(), "READ"); + return R.ok(sopSeedService.summaryCardAuthorized(request, allowedKnowledgeIds)); } @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @PostMapping("/doc/upload") - public R uploadDoc(@RequestPart("file") MultipartFile file, @RequestParam("category") String category) { - return R.ok(sopSeedService.uploadDoc(file, category)); + public R uploadDoc(@RequestPart("file") MultipartFile file, + @RequestParam(value = "category", required = false) String category, + @RequestParam(value = "spaceCodes", required = false) List spaceCodes) { + if (spaceCodes == null || spaceCodes.isEmpty()) { + return R.ok(sopSeedService.uploadDoc(file, category)); + } + requireManageSpaces(spaceCodes); + return R.ok(sopSeedService.uploadDoc(file, spaceCodes)); + } + + private void requireManageSpaces(List spaceCodes) { + AihrKnowledgePrincipal principal = knowledgePrincipalResolver.current(); + AuthenticatedApp app = knowledgeAppService.requireSessionApp(principal.tenantId(), principal.clientKey()); + knowledgeAccessService.resolveInternalSpaceIds(principal, app, spaceCodes, "MANAGE"); } @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index d3053d61..397535cd 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -252,7 +252,51 @@ public final class AihrPracticeDto { public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) { } - public record PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId, String scenarioName, String source, String reason) { + public record PracticeAssignmentRequest( + String extPartyId, + String traineeName, + String scenarioId, + String scenarioName, + String source, + String reason, + String dueDate, + String requestId + ) { + public PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId, + String scenarioName, String source, String reason) { + this(extPartyId, traineeName, scenarioId, scenarioName, source, reason, null, null); + } + } + + public record PracticeAssignmentTarget(String extPartyId, String traineeName) { + } + + public record PracticeAssignmentBatchRequest( + String requestId, + List targets, + String scenarioId, + String reason, + String dueDate + ) { + } + + public record PracticeAssignmentBatchItemResponse( + String extPartyId, + String traineeName, + String status, + String message, + PracticeAssignmentResponse assignment + ) { + } + + public record PracticeAssignmentBatchResponse( + String requestId, + Integer total, + Integer created, + Integer duplicate, + Integer failed, + List results + ) { } public record PracticeAssignmentResponse( @@ -273,8 +317,62 @@ public final class AihrPracticeDto { Integer score, String scoreMode, String audioUrl, - Long audioOssId + Long audioOssId, + String dueDate, + String contentVersion, + String contentHash, + String feedbackVerdict ) { + public PracticeAssignmentResponse( + Long id, + String extPartyId, + String traineeName, + String scenarioId, + String scenarioName, + String source, + String reason, + String status, + String createTime, + String question, + String referenceAnswer, + String userAnswer, + String feedback, + Boolean correct, + Integer score, + String scoreMode, + String audioUrl, + Long audioOssId + ) { + this(id, extPartyId, traineeName, scenarioId, scenarioName, source, reason, status, createTime, + question, referenceAnswer, userAnswer, feedback, correct, score, scoreMode, audioUrl, audioOssId, + null, null, null, null); + } + + public PracticeAssignmentResponse( + Long id, + String extPartyId, + String traineeName, + String scenarioId, + String scenarioName, + String source, + String reason, + String status, + String createTime, + String question, + String referenceAnswer, + String userAnswer, + String feedback, + Boolean correct, + Integer score, + String scoreMode, + String audioUrl, + Long audioOssId, + String dueDate + ) { + this(id, extPartyId, traineeName, scenarioId, scenarioName, source, reason, status, createTime, + question, referenceAnswer, userAnswer, feedback, correct, score, scoreMode, audioUrl, audioOssId, + dueDate, null, null, null); + } } public record OnboardTaskResponse( diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java index f9d70a4a..f664335c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java @@ -194,6 +194,7 @@ public final class AihrSopDto { String batchId, String fileName, String category, + List spaceCodes, Integer status, String statusLabel, String error, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java new file mode 100644 index 00000000..a59d6548 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java @@ -0,0 +1,46 @@ +package org.dromara.aihr.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.common.core.domain.R; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/knowledge") +public class AihrKnowledgeQueryController { + + private final AihrKnowledgeQueryService queryService; + + @SaCheckLogin + @PostMapping("/query") + public R query(@RequestBody QueryRequest request) { + return R.ok(queryService.queryInternal(request)); + } + + @SaCheckLogin + @PostMapping(value = "/query-media", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R queryMedia(@RequestPart("file") MultipartFile file, + @RequestParam(value = "queryText", required = false) String queryText, + @RequestParam(value = "position", required = false) String position) { + String question = queryText == null || queryText.isBlank() + ? "请根据附件现场情况,告诉我应该按什么 SOP 处理" + : queryText.trim(); + QueryRequest request = new QueryRequest( + question, List.of(), "sop", position, "mobile_uni_media", 5, null + ); + return R.ok(queryService.queryInternalMedia(request, file)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeSpaceController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeSpaceController.java new file mode 100644 index 00000000..075ac7c3 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeSpaceController.java @@ -0,0 +1,112 @@ +package org.dromara.aihr.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckRole; +import cn.dev33.satoken.annotation.SaMode; +import cn.dev33.satoken.stp.StpUtil; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.TokenIssuedResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AdminContext; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AppView; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateAppRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateAppResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateSpaceRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.DocumentView; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.GrantItem; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.ReplaceAppSpacesRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.ReplaceGrantsRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.RotateTokenRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.SpaceView; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UnbindDocumentResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateAppRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateSpaceRequest; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.knowledge.service.AihrKnowledgeSpaceAdminService; +import org.dromara.common.core.constant.TenantConstants; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/knowledge/admin") +@RequiredArgsConstructor +@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator"}, mode = SaMode.OR) +public class AihrKnowledgeSpaceController { + + private final AihrKnowledgeSpaceAdminService adminService; + private final AihrKnowledgePrincipalResolver principalResolver; + + @GetMapping("/spaces") + public R> spaces() { + return R.ok(adminService.spaces(context())); + } + + @PostMapping("/spaces") + public R createSpace(@RequestBody CreateSpaceRequest request) { + return R.ok(adminService.createSpace(context(), request)); + } + + @PutMapping("/spaces/{id}") + public R updateSpace(@PathVariable Long id, @RequestBody UpdateSpaceRequest request) { + return R.ok(adminService.updateSpace(context(), id, request)); + } + + @GetMapping("/spaces/{id}/documents") + public R> documents(@PathVariable Long id) { + return R.ok(adminService.documents(context(), id)); + } + + @DeleteMapping("/spaces/{id}/documents/{attachId}") + public R unbindDocument(@PathVariable Long id, @PathVariable Long attachId) { + return R.ok(adminService.unbindDocument(context(), id, attachId)); + } + + @GetMapping("/spaces/{id}/grants") + public R> grants(@PathVariable Long id) { + return R.ok(adminService.grants(context(), id)); + } + + @PutMapping("/spaces/{id}/grants") + public R> replaceGrants(@PathVariable Long id, @RequestBody ReplaceGrantsRequest request) { + return R.ok(adminService.replaceGrants(context(), id, request == null ? List.of() : request.grants())); + } + + @GetMapping("/apps") + public R> apps() { + return R.ok(adminService.apps(context())); + } + + @PostMapping("/apps") + public R createApp(@RequestBody CreateAppRequest request) { + return R.ok(adminService.createApp(context(), request)); + } + + @PutMapping("/apps/{id}") + public R updateApp(@PathVariable Long id, @RequestBody UpdateAppRequest request) { + return R.ok(adminService.updateApp(context(), id, request)); + } + + @PutMapping("/apps/{id}/spaces") + public R replaceAppSpaces(@PathVariable Long id, @RequestBody ReplaceAppSpacesRequest request) { + return R.ok(adminService.replaceAppSpaces(context(), id, request == null ? List.of() : request.spaceCodes())); + } + + @PostMapping("/apps/{id}/rotate-token") + public R rotateToken(@PathVariable Long id, @RequestBody(required = false) RotateTokenRequest request) { + return R.ok(adminService.rotateToken(context(), id, request == null ? null : request.expiresTime())); + } + + private AdminContext context() { + AihrKnowledgePrincipal principal = principalResolver.current(); + return new AdminContext(principal.tenantId(), principal.userId(), principal.roles(), + StpUtil.hasRole(TenantConstants.SUPER_ADMIN_ROLE_KEY)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrOpenKnowledgeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrOpenKnowledgeController.java new file mode 100644 index 00000000..31c572f1 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrOpenKnowledgeController.java @@ -0,0 +1,30 @@ +package org.dromara.aihr.knowledge.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/open/knowledge") +public class AihrOpenKnowledgeController { + + private final AihrKnowledgeAppService appService; + private final AihrKnowledgeQueryService queryService; + + @SaIgnore + @PostMapping("/query") + public R query(@RequestHeader(value = "Authorization", required = false) String authorization, + @RequestBody QueryRequest request) { + return R.ok(queryService.queryExternal(appService.authenticateBearer(authorization), request)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java new file mode 100644 index 00000000..5f473390 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java @@ -0,0 +1,45 @@ +package org.dromara.aihr.knowledge.domain; + +import org.dromara.aihr.domain.AihrSopDto.SearchResponse; + +import java.util.List; + +public final class AihrKnowledgeQueryDto { + + private AihrKnowledgeQueryDto() { + } + + public record QueryRequest( + String queryText, + List spaceCodes, + String category, + String position, + String source, + Integer limit, + String toolCode + ) { + } + + public record Citation( + String spaceCode, + String sourceType, + String docId, + String title, + String snippet, + Long fragmentId + ) { + } + + public record QueryResponse( + String requestId, + String queryText, + String answer, + List citations, + List usedSpaceCodes, + boolean noEvidence, + String promptVersion, + SearchResponse legacy, + Object data + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeSpaceDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeSpaceDto.java new file mode 100644 index 00000000..b9119302 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeSpaceDto.java @@ -0,0 +1,62 @@ +package org.dromara.aihr.knowledge.domain; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +public final class AihrKnowledgeSpaceDto { + + private AihrKnowledgeSpaceDto() { + } + + public record AdminContext(String tenantId, Long operatorId, Set roles, boolean superAdmin) { + } + + public record SpaceView(Long id, String code, String name, String description, String spaceType, + String sensitivityLevel, String status, int documentCount, int fragmentCount) { + } + + public record DocumentView(Long attachId, String docId, String name, String type, String status, + Long ossId, int fragmentCount, LocalDateTime updateTime) { + } + + public record UnbindDocumentResponse(Long attachId, String spaceCode, Long ossId, boolean ossDeleted) { + } + + public record CreateSpaceRequest(String code, String name, String description, String spaceType, + String sensitivityLevel, String status) { + } + + public record UpdateSpaceRequest(String code, String name, String description, String spaceType, + String sensitivityLevel, String status) { + } + + public record GrantItem(String principalType, String principalValue, String permission, String status) { + } + + public record ReplaceGrantsRequest(List grants) { + } + + public record AppView(Long id, String appCode, String appName, String authType, String internalClientKey, + String status, Integer rateLimitPerMinute, LocalDateTime expiresTime, + LocalDateTime lastUsedTime, List spaceCodes) { + } + + public record CreateAppRequest(String appCode, String appName, String authType, String internalClientKey, + String status, Integer rateLimitPerMinute, LocalDateTime expiresTime, + List spaceCodes) { + } + + public record CreateAppResponse(AppView app, String token) { + } + + public record UpdateAppRequest(String appName, String status, Integer rateLimitPerMinute, + LocalDateTime expiresTime) { + } + + public record ReplaceAppSpacesRequest(List spaceCodes) { + } + + public record RotateTokenRequest(LocalDateTime expiresTime) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java new file mode 100644 index 00000000..341e02ce --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java @@ -0,0 +1,88 @@ +package org.dromara.aihr.knowledge.service; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.service.AihrMobileSeedService; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AihrKnowledgeDataToolService { + + public static final String MY_PRACTICE_SUMMARY = "MY_PRACTICE_SUMMARY"; + public static final String TEAM_PRACTICE_SUMMARY = "TEAM_PRACTICE_SUMMARY"; + + private final AihrMobileSeedService mobileSeedService; + + public ToolResult execute(AihrKnowledgePrincipal principal, String rawToolCode) { + if (principal == null || !hasText(principal.extPartyId())) { + throw new ServiceException("当前登录身份不能使用训练数据工具", HttpStatus.FORBIDDEN); + } + String toolCode = rawToolCode == null ? "" : rawToolCode.trim().toUpperCase(java.util.Locale.ROOT); + return switch (toolCode) { + case MY_PRACTICE_SUMMARY -> myPractice(principal.extPartyId()); + case TEAM_PRACTICE_SUMMARY -> teamPractice(principal); + default -> throw new ServiceException("不支持的数据工具", HttpStatus.BAD_REQUEST); + }; + } + + private ToolResult myPractice(String extPartyId) { + List records = mobileSeedService.practiceHistory(extPartyId); + List recent = records.stream().map(row -> new PracticeRecord( + row.time(), row.scene(), row.score(), row.status(), row.summary())).toList(); + Integer average = averageScore(records); + MyPracticeSummary data = new MyPracticeSummary(records.size(), average, + records.isEmpty() ? "" : records.get(0).time(), recent); + String answer = records.isEmpty() + ? "当前没有可查询的本人训练记录。" + : "最近训练 " + records.size() + " 次,平均分 " + average + " 分,最近一次为 " + data.latestAt() + "。"; + return new ToolResult(MY_PRACTICE_SUMMARY, "我的训练概况", "RECENT_5", answer, data); + } + + private ToolResult teamPractice(AihrKnowledgePrincipal principal) { + if (!principal.roles().contains("supervisor")) { + throw new ServiceException("只有主管可以查询团队训练概况", HttpStatus.FORBIDDEN); + } + String supervisorIdentity = mobileSeedService.requireSupervisorIdentity(principal.extPartyId()); + PracticeTeamResponse snapshot = mobileSeedService.practiceTeam(supervisorIdentity); + List records = snapshot.records() == null ? List.of() : snapshot.records(); + int memberCount = snapshot.members() == null ? 0 : snapshot.members().size(); + int pendingAssignments = snapshot.assignments() == null ? 0 : snapshot.assignments().size(); + int lowScoreCount = snapshot.mistakes() == null ? 0 : snapshot.mistakes().size(); + long pendingReviews = records.stream().filter(row -> "待复盘".equals(row.status())).count(); + TeamPracticeSummary data = new TeamPracticeSummary( + memberCount, records.size(), (int) pendingReviews, pendingAssignments, lowScoreCount, averageScore(records)); + String answer = "当前管理范围共 " + memberCount + " 人,训练记录 " + records.size() + + " 条,待复盘 " + pendingReviews + " 条,平均分 " + data.averageScore() + " 分。"; + return new ToolResult(TEAM_PRACTICE_SUMMARY, "团队训练概况", "CURRENT_SUPERVISOR_SCOPE", answer, data); + } + + private static int averageScore(List records) { + return (int) Math.round(records.stream().filter(row -> row.score() != null) + .mapToInt(RecordResponse::score).average().orElse(0)); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + public record ToolResult(String toolCode, String title, String window, String answer, Object data) { + } + + public record PracticeRecord(String time, String scene, Integer score, String status, String summary) { + } + + public record MyPracticeSummary(int practiceCount, int averageScore, String latestAt, + List recentRecords) { + } + + public record TeamPracticeSummary(int memberCount, int practiceCount, int pendingReviewCount, + int pendingAssignmentCount, int lowScoreCount, int averageScore) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java new file mode 100644 index 00000000..44586423 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java @@ -0,0 +1,47 @@ +package org.dromara.aihr.knowledge.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AihrKnowledgeQueryAuditService { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + public void record(String requestId, AihrKnowledgePrincipal principal, AuthenticatedApp app, String question, + List spaceCodes, List sourceTypes, String status, long latencyMs, + String promptVersion) { + try { + jdbcTemplate.update(""" + insert into aihr_knowledge_query_log + (request_id, tenant_id, app_id, user_id, question_hash, used_space_codes_json, + source_types_json, status, latency_ms, prompt_version, create_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) + """, requestId, app.tenantId(), app.id(), principal == null ? null : principal.userId(), + AihrKnowledgeAppService.sha256(question == null ? "" : question), json(spaceCodes), + json(sourceTypes), status, latencyMs, promptVersion); + } catch (DataAccessException ex) { + log.warn("knowledge query audit write failed for request {}", requestId); + } + } + + private String json(List values) { + try { + return objectMapper.writeValueAsString(values == null ? List.of() : values); + } catch (JsonProcessingException ex) { + return "[]"; + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java new file mode 100644 index 00000000..8023d489 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -0,0 +1,215 @@ +package org.dromara.aihr.knowledge.service; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrSopDto.SearchRequest; +import org.dromara.aihr.domain.AihrSopDto.SearchResponse; +import org.dromara.aihr.domain.AihrSopDto.SnippetResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult; +import org.dromara.aihr.service.AihrSopSeedService; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.tenant.helper.TenantHelper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AihrKnowledgeQueryService { + + private final AihrKnowledgePrincipalResolver principalResolver; + private final AihrKnowledgeAppService appService; + private final AihrKnowledgeAccessService accessService; + private final AihrSopSeedService sopService; + private final AihrKnowledgeQueryAuditService auditService; + private final JdbcTemplate jdbcTemplate; + private final AihrKnowledgeDataToolService dataToolService; + + public QueryResponse queryInternal(QueryRequest rawRequest) { + QueryRequest request = validate(rawRequest); + AihrKnowledgePrincipal principal = principalResolver.current(); + AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); + if (hasText(request.toolCode())) { + return queryDataTool(principal, app, request); + } + Set spaceIds = accessService.resolveInternalSpaceIds( + principal, app, request.spaceCodes(), "READ"); + return queryDocuments(principal, app, spaceIds, request); + } + + public QueryResponse queryInternalMedia(QueryRequest rawRequest, MultipartFile file) { + QueryRequest request = validate(rawRequest); + if (hasText(request.toolCode())) { + throw new ServiceException("媒体提问不能调用内部数据工具", HttpStatus.BAD_REQUEST); + } + AihrKnowledgePrincipal principal = principalResolver.current(); + AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); + Set spaceIds = accessService.resolveInternalSpaceIds( + principal, app, request.spaceCodes(), "READ"); + String extracted = sopService.extractQuestionMedia(file, request.queryText()); + String context = extracted == null ? "" : extracted.trim(); + if (context.isBlank()) { + throw new ServiceException("附件中没有可用于查询的现场信息", HttpStatus.BAD_REQUEST); + } + String combinedQuery = truncate(request.queryText(), 400) + + "\n附件现场信息:" + truncate(context, 520); + QueryResponse response = queryDocuments(principal, app, spaceIds, new QueryRequest( + combinedQuery, request.spaceCodes(), request.category(), request.position(), + request.source(), request.limit(), null + )); + return new QueryResponse( + response.requestId(), request.queryText(), response.answer(), response.citations(), + response.usedSpaceCodes(), response.noEvidence(), response.promptVersion(), response.legacy(), response.data() + ); + } + + public QueryResponse queryExternal(AuthenticatedApp app, QueryRequest rawRequest) { + QueryRequest request = validate(rawRequest); + if (hasText(request.toolCode())) { + throw new ServiceException("外部调用应用不能使用内部数据工具", HttpStatus.FORBIDDEN); + } + return TenantHelper.dynamic(app.tenantId(), () -> { + Set spaceIds = accessService.resolveExternalSpaceIds(app, request.spaceCodes()); + return queryDocuments(null, app, spaceIds, request); + }); + } + + private QueryResponse queryDocuments(AihrKnowledgePrincipal principal, AuthenticatedApp app, + Set spaceIds, QueryRequest request) { + String requestId = UUID.randomUUID().toString().replace("-", ""); + long started = System.nanoTime(); + List scopeCodes = spaceCodes(app.tenantId(), spaceIds); + try { + SearchResponse legacy = sopService.searchAuthorized(new SearchRequest( + request.queryText(), request.category(), request.position(), request.source(), request.limit()), + principal == null ? "" : principal.extPartyId(), spaceIds); + List citations = citations(app.tenantId(), spaceIds, legacy.snippets()); + boolean noEvidence = citations.isEmpty(); + String status = noEvidence ? "NO_EVIDENCE" : "SUCCESS"; + long latency = elapsedMillis(started); + auditService.record(requestId, principal, app, request.queryText(), scopeCodes, + citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, legacy.promptVersion()); + return new QueryResponse(requestId, request.queryText(), legacy.answer(), citations, scopeCodes, + noEvidence, legacy.promptVersion(), legacy, null); + } catch (RuntimeException ex) { + auditService.record(requestId, principal, app, request.queryText(), scopeCodes, List.of(), + ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500 + ? "REJECTED" : "FAILED", + elapsedMillis(started), null); + throw ex; + } + } + + private QueryResponse queryDataTool(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request) { + String requestId = UUID.randomUUID().toString().replace("-", ""); + long started = System.nanoTime(); + try { + ToolResult result = dataToolService.execute(principal, request.toolCode()); + Citation citation = new Citation(null, "DATA_TOOL", result.toolCode(), result.title(), result.window(), null); + auditService.record(requestId, principal, app, request.queryText(), List.of(), + List.of("DATA_TOOL:" + result.toolCode()), "SUCCESS", elapsedMillis(started), "data-tool-v1"); + return new QueryResponse(requestId, request.queryText(), result.answer(), List.of(citation), List.of(), + false, "data-tool-v1", null, result.data()); + } catch (RuntimeException ex) { + auditService.record(requestId, principal, app, request.queryText(), List.of(), + List.of("DATA_TOOL:" + request.toolCode()), + ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500 + ? "REJECTED" : "FAILED", + elapsedMillis(started), "data-tool-v1"); + throw ex; + } + } + + private List citations(String tenantId, Set allowedSpaceIds, List snippets) { + if (snippets == null || snippets.isEmpty()) { + return List.of(); + } + List fragmentIds = snippets.stream().map(SnippetResponse::fragmentId) + .filter(id -> id != null && id > 0).distinct().toList(); + if (fragmentIds.isEmpty()) { + return List.of(); + } + List args = new ArrayList<>(); + args.add(tenantId); + args.addAll(allowedSpaceIds); + args.addAll(fragmentIds); + List rows = jdbcTemplate.query(""" + select k.code as space_code, f.doc_id, coalesce(a.name, k.name) as title, + f.content, f.id as fragment_id + from aihr_knowledge_fragment f + join aihr_knowledge_info k on k.id = f.knowledge_id and k.tenant_id = f.tenant_id + left join aihr_knowledge_attach a + on a.tenant_id = f.tenant_id and a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id + where f.tenant_id = ? + and f.knowledge_id in (%s) + and f.id in (%s) + """.formatted(placeholders(allowedSpaceIds.size()), placeholders(fragmentIds.size())), + (rs, rowNum) -> new Citation( + rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"), + truncate(rs.getString("content"), 500), rs.getLong("fragment_id") + ), args.toArray()); + Map byId = new LinkedHashMap<>(); + rows.forEach(row -> byId.put(row.fragmentId(), row)); + return fragmentIds.stream().map(byId::get).filter(java.util.Objects::nonNull).toList(); + } + + private List spaceCodes(String tenantId, Set spaceIds) { + if (spaceIds.isEmpty()) { + return List.of(); + } + List args = new ArrayList<>(); + args.add(tenantId); + args.addAll(spaceIds); + return jdbcTemplate.queryForList(""" + select code from aihr_knowledge_info + where tenant_id = ? and id in (%s) and status = 'ACTIVE' + order by id + """.formatted(placeholders(spaceIds.size())), String.class, args.toArray()); + } + + private static QueryRequest validate(QueryRequest request) { + String query = request == null || request.queryText() == null ? "" : request.queryText().trim(); + if (query.isBlank() || query.length() > 1000) { + throw new ServiceException("问题长度必须为 1-1000 字", HttpStatus.BAD_REQUEST); + } + int limit = request.limit() == null ? 5 : request.limit(); + if (limit < 1 || limit > 10) { + throw new ServiceException("limit 仅支持 1-10", HttpStatus.BAD_REQUEST); + } + List codes = request.spaceCodes() == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(request.spaceCodes())); + if (codes.size() > 20) { + throw new ServiceException("单次最多选择 20 个知识空间", HttpStatus.BAD_REQUEST); + } + return new QueryRequest(query, codes, request.category(), request.position(), request.source(), limit, request.toolCode()); + } + + private static long elapsedMillis(long started) { + return Math.max(0, (System.nanoTime() - started) / 1_000_000); + } + + private static String placeholders(int size) { + return String.join(",", java.util.Collections.nCopies(size, "?")); + } + + private static String truncate(String value, int max) { + String text = value == null ? "" : value; + return text.length() <= max ? text : text.substring(0, max); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java new file mode 100644 index 00000000..5b387d0e --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java @@ -0,0 +1,490 @@ +package org.dromara.aihr.knowledge.service; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.TokenIssuedResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AdminContext; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AppView; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateAppRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateAppResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateSpaceRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.DocumentView; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.GrantItem; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.SpaceView; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UnbindDocumentResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateAppRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateSpaceRequest; +import org.dromara.aihr.service.AihrSopSeedService; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class AihrKnowledgeSpaceAdminService { + + private final JdbcTemplate jdbcTemplate; + private final AihrKnowledgeAppService appService; + private final AihrSopSeedService sopService; + + public List spaces(AdminContext context) { + requireContext(context); + List args = new ArrayList<>(); + args.add(context.tenantId()); + String scope = ""; + if (!context.superAdmin()) { + scope = """ + and exists (select 1 from aihr_knowledge_space_grant g + where g.tenant_id = k.tenant_id and g.knowledge_id = k.id + and g.permission = 'MANAGE' and g.status = 'ACTIVE' + and ((g.principal_type = 'USER' and g.principal_value = ?)%s)) + """.formatted(roleScope(context.roles(), args)); + args.add(1, String.valueOf(context.operatorId())); + } + return jdbcTemplate.query(""" + select k.id, k.code, k.name, coalesce(k.description, '') description, k.space_type, + k.sensitivity_level, k.status, + count(distinct a.id) document_count, count(distinct f.id) fragment_count + from aihr_knowledge_info k + left join aihr_knowledge_attach a on a.tenant_id = k.tenant_id and a.knowledge_id = k.id + left join aihr_knowledge_fragment f on f.tenant_id = k.tenant_id and f.knowledge_id = k.id + where k.tenant_id = ? %s + group by k.id, k.code, k.name, k.description, k.space_type, k.sensitivity_level, k.status + order by k.id + """.formatted(scope), (rs, rowNum) -> new SpaceView( + rs.getLong("id"), rs.getString("code"), rs.getString("name"), rs.getString("description"), + rs.getString("space_type"), rs.getString("sensitivity_level"), rs.getString("status"), + rs.getInt("document_count"), rs.getInt("fragment_count")), args.toArray()); + } + + public List documents(AdminContext context, Long spaceId) { + requireSpace(context, spaceId, true); + return jdbcTemplate.query(""" + select a.id, a.doc_id, a.name, a.type, + case a.status when 0 then '等待解析' when 1 then '解析中' when 2 then '已解析' else '解析失败' end as status, + a.oss_id, count(f.id) fragment_count, a.update_time + from aihr_knowledge_attach a + left join aihr_knowledge_fragment f + on f.tenant_id = a.tenant_id and f.knowledge_id = a.knowledge_id and f.doc_id = a.doc_id + where a.tenant_id = ? and a.knowledge_id = ? + group by a.id, a.doc_id, a.name, a.type, a.status, a.oss_id, a.update_time + order by a.id desc + """, (rs, rowNum) -> new DocumentView( + rs.getLong("id"), rs.getString("doc_id"), rs.getString("name"), rs.getString("type"), + rs.getString("status"), rs.getObject("oss_id", Long.class), rs.getInt("fragment_count"), + rs.getTimestamp("update_time") == null ? null : rs.getTimestamp("update_time").toLocalDateTime()), + context.tenantId(), spaceId); + } + + @Transactional + public UnbindDocumentResponse unbindDocument(AdminContext context, Long spaceId, Long attachId) { + SpaceView space = requireSpace(context, spaceId, true); + UnbindDocumentResponse response = sopService.unbindDocumentMembership(space.code(), attachId); + audit(context, "UNBIND", "DOCUMENT", attachId, + "space=" + space.code() + ",ossId=" + response.ossId(), + "membership=removed,ossDeleted=" + response.ossDeleted()); + return response; + } + + @Transactional + public SpaceView createSpace(AdminContext context, CreateSpaceRequest request) { + requireContext(context); + String code = code(request == null ? null : request.code()); + String name = required(request == null ? null : request.name(), "空间名称"); + String type = option(request.spaceType(), Set.of("PUBLIC", "BUSINESS", "MANAGEMENT", "EXTERNAL"), "BUSINESS"); + String sensitivity = option(request.sensitivityLevel(), Set.of("PUBLIC", "INTERNAL", "CONFIDENTIAL"), "INTERNAL"); + String status = option(request.status(), Set.of("DRAFT", "ACTIVE", "DISABLED"), "DRAFT"); + KeyHolder keys = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_knowledge_info + (tenant_id, name, code, description, space_type, sensitivity_level, status, `separator`, overlap_char, + retrieve_limit, similarity_threshold, text_block_size, enable_hybrid, hybrid_alpha, system_prompt, + create_by, create_time, update_by, update_time, remark) + values (?, ?, ?, ?, ?, ?, ?, '\n\n', 120, 5, 0.5, 800, 1, 1.0, ?, ?, now(), ?, now(), 'knowledge-space-admin') + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, context.tenantId()); + statement.setString(2, name); + statement.setString(3, code); + statement.setString(4, text(request.description(), 500)); + statement.setString(5, type); + statement.setString(6, sensitivity); + statement.setString(7, status); + statement.setString(8, "只能基于当前知识空间已授权片段回答。"); + statement.setObject(9, context.operatorId()); + statement.setObject(10, context.operatorId()); + return statement; + }, keys); + long id = keys.getKey().longValue(); + jdbcTemplate.update(""" + insert into aihr_knowledge_space_grant + (tenant_id, knowledge_id, principal_type, principal_value, permission, status, create_by, create_time, update_by, update_time) + values (?, ?, 'USER', ?, 'MANAGE', 'ACTIVE', ?, now(), ?, now()) + """, context.tenantId(), id, String.valueOf(context.operatorId()), context.operatorId(), context.operatorId()); + audit(context, "CREATE", "SPACE", id, "", summary(code, name, type, sensitivity, status)); + return requireSpace(context, id, false); + } + + @Transactional + public SpaceView updateSpace(AdminContext context, Long id, UpdateSpaceRequest request) { + SpaceView before = requireSpace(context, id, true); + if (request != null && hasText(request.code()) && !before.code().equals(request.code().trim())) { + throw new ServiceException("空间编码创建后不可修改", HttpStatus.BAD_REQUEST); + } + String name = required(request == null ? null : request.name(), "空间名称"); + String type = option(request.spaceType(), Set.of("PUBLIC", "BUSINESS", "MANAGEMENT", "EXTERNAL"), before.spaceType()); + String sensitivity = option(request.sensitivityLevel(), Set.of("PUBLIC", "INTERNAL", "CONFIDENTIAL"), before.sensitivityLevel()); + String status = option(request.status(), Set.of("DRAFT", "ACTIVE", "DISABLED"), before.status()); + jdbcTemplate.update(""" + update aihr_knowledge_info set name = ?, description = ?, space_type = ?, sensitivity_level = ?, status = ?, + update_by = ?, update_time = now() + where tenant_id = ? and id = ? + """, name, text(request.description(), 500), type, sensitivity, status, + context.operatorId(), context.tenantId(), id); + SpaceView after = requireSpace(context, id, false); + audit(context, "UPDATE", "SPACE", id, spaceSummary(before), spaceSummary(after)); + return after; + } + + public List grants(AdminContext context, Long spaceId) { + requireSpace(context, spaceId, true); + return jdbcTemplate.query(""" + select principal_type, principal_value, permission, status + from aihr_knowledge_space_grant + where tenant_id = ? and knowledge_id = ? order by id + """, (rs, rowNum) -> new GrantItem(rs.getString(1), rs.getString(2), rs.getString(3), rs.getString(4)), + context.tenantId(), spaceId); + } + + @Transactional + public List replaceGrants(AdminContext context, Long spaceId, List rawGrants) { + requireSpace(context, spaceId, true); + List before = grants(context, spaceId); + List grants = rawGrants == null ? List.of() : rawGrants.stream() + .map(AihrKnowledgeSpaceAdminService::validateGrant).distinct().toList(); + jdbcTemplate.update("delete from aihr_knowledge_space_grant where tenant_id = ? and knowledge_id = ?", + context.tenantId(), spaceId); + for (GrantItem grant : grants) { + jdbcTemplate.update(""" + insert into aihr_knowledge_space_grant + (tenant_id, knowledge_id, principal_type, principal_value, permission, status, create_by, create_time, update_by, update_time) + values (?, ?, ?, ?, ?, ?, ?, now(), ?, now()) + """, context.tenantId(), spaceId, grant.principalType(), grant.principalValue(), grant.permission(), + grant.status(), context.operatorId(), context.operatorId()); + } + audit(context, "REPLACE", "GRANT", spaceId, before.toString(), grants.toString()); + return grants; + } + + public List apps(AdminContext context) { + requireTenantAdmin(context); + return jdbcTemplate.query(""" + select a.id, a.app_code, a.app_name, a.auth_type, a.internal_client_key, a.status, + a.rate_limit_per_minute, a.expires_time, a.last_used_time, + group_concat(k.code order by k.id separator ',') as space_codes + from aihr_knowledge_app a + left join aihr_knowledge_app_space s on s.tenant_id = a.tenant_id and s.app_id = a.id + left join aihr_knowledge_info k on k.tenant_id = s.tenant_id and k.id = s.knowledge_id + where a.tenant_id = ? + group by a.id, a.app_code, a.app_name, a.auth_type, a.internal_client_key, a.status, + a.rate_limit_per_minute, a.expires_time, a.last_used_time + order by a.id + """, (rs, rowNum) -> new AppView( + rs.getLong("id"), rs.getString("app_code"), rs.getString("app_name"), rs.getString("auth_type"), + rs.getString("internal_client_key"), rs.getString("status"), rs.getInt("rate_limit_per_minute"), + rs.getTimestamp("expires_time") == null ? null : rs.getTimestamp("expires_time").toLocalDateTime(), + rs.getTimestamp("last_used_time") == null ? null : rs.getTimestamp("last_used_time").toLocalDateTime(), + csv(rs.getString("space_codes"))), context.tenantId()); + } + + @Transactional + public CreateAppResponse createApp(AdminContext context, CreateAppRequest request) { + requireTenantAdmin(context); + String appCode = appCode(request == null ? null : request.appCode()); + String appName = required(request == null ? null : request.appName(), "应用名称"); + String authType = option(request.authType(), Set.of("SESSION", "API_TOKEN"), "SESSION"); + String clientKey = "SESSION".equals(authType) ? required(request.internalClientKey(), "内部客户端标识") : null; + String status = option(request.status(), Set.of("ACTIVE", "DISABLED"), "ACTIVE"); + int rate = rate(request.rateLimitPerMinute()); + String token = "API_TOKEN".equals(authType) ? AihrKnowledgeAppService.generateToken(appCode) : null; + KeyHolder keys = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_knowledge_app + (tenant_id, app_code, app_name, auth_type, internal_client_key, token_hash, status, + rate_limit_per_minute, expires_time, create_by, create_time, update_by, update_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), ?, now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, context.tenantId()); + statement.setString(2, appCode); + statement.setString(3, appName); + statement.setString(4, authType); + statement.setString(5, clientKey); + statement.setString(6, token == null ? null : AihrKnowledgeAppService.sha256(token)); + statement.setString(7, status); + statement.setInt(8, rate); + statement.setObject(9, request.expiresTime()); + statement.setObject(10, context.operatorId()); + statement.setObject(11, context.operatorId()); + return statement; + }, keys); + long id = keys.getKey().longValue(); + replaceAppSpacesInternal(context, id, request.spaceCodes()); + audit(context, "CREATE", "APP", id, "", "code=" + appCode + ",authType=" + authType + ",status=" + status); + return new CreateAppResponse(app(context, id), token); + } + + @Transactional + public AppView updateApp(AdminContext context, Long id, UpdateAppRequest request) { + AppView before = app(context, id); + jdbcTemplate.update(""" + update aihr_knowledge_app set app_name = ?, status = ?, rate_limit_per_minute = ?, expires_time = ?, + update_by = ?, update_time = now() + where tenant_id = ? and id = ? + """, required(request.appName(), "应用名称"), option(request.status(), Set.of("ACTIVE", "DISABLED"), before.status()), + rate(request.rateLimitPerMinute()), request.expiresTime(), context.operatorId(), context.tenantId(), id); + AppView after = app(context, id); + audit(context, "UPDATE", "APP", id, appSummary(before), appSummary(after)); + return after; + } + + @Transactional + public AppView replaceAppSpaces(AdminContext context, Long appId, List spaceCodes) { + AppView before = app(context, appId); + replaceAppSpacesInternal(context, appId, spaceCodes); + AppView after = app(context, appId); + audit(context, "REPLACE", "APP_SPACE", appId, before.spaceCodes().toString(), after.spaceCodes().toString()); + return after; + } + + public TokenIssuedResponse rotateToken(AdminContext context, Long appId, LocalDateTime expiresTime) { + AppView app = app(context, appId); + if (!"API_TOKEN".equals(app.authType())) { + throw new ServiceException("内部会话应用不使用访问令牌", HttpStatus.BAD_REQUEST); + } + TokenIssuedResponse response = appService.rotateToken(context.tenantId(), appId, expiresTime); + audit(context, "ROTATE", "TOKEN", appId, "token=hidden", "token=rotated,expires=" + expiresTime); + return response; + } + + private void replaceAppSpacesInternal(AdminContext context, Long appId, List rawCodes) { + requireAppTenant(context, appId); + List codes = cleanCodes(rawCodes); + List ids = spaceIds(context, codes, true); + jdbcTemplate.update("delete from aihr_knowledge_app_space where tenant_id = ? and app_id = ?", context.tenantId(), appId); + for (Long spaceId : ids) { + jdbcTemplate.update(""" + insert into aihr_knowledge_app_space (tenant_id, app_id, knowledge_id, create_by, create_time) + values (?, ?, ?, ?, now()) + """, context.tenantId(), appId, spaceId, context.operatorId()); + } + } + + private SpaceView requireSpace(AdminContext context, Long id, boolean requireManage) { + requireContext(context); + List rows = jdbcTemplate.query(""" + select k.id, k.code, k.name, coalesce(k.description, ''), k.space_type, k.sensitivity_level, k.status, + (select count(*) from aihr_knowledge_attach a where a.tenant_id = k.tenant_id and a.knowledge_id = k.id), + (select count(*) from aihr_knowledge_fragment f where f.tenant_id = k.tenant_id and f.knowledge_id = k.id) + from aihr_knowledge_info k where k.tenant_id = ? and k.id = ? + """, (rs, rowNum) -> new SpaceView(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4), + rs.getString(5), rs.getString(6), rs.getString(7), rs.getInt(8), rs.getInt(9)), context.tenantId(), id); + if (rows.isEmpty()) { + throw new ServiceException("知识空间不存在", HttpStatus.NOT_FOUND); + } + if (requireManage && !context.superAdmin() && !canManage(context, id)) { + throw new ServiceException("没有该知识空间的管理权限", HttpStatus.FORBIDDEN); + } + return rows.get(0); + } + + private AppView app(AdminContext context, Long id) { + requireTenantAdmin(context); + return apps(context).stream().filter(row -> row.id().equals(id)).findFirst() + .orElseThrow(() -> new ServiceException("调用应用不存在", HttpStatus.NOT_FOUND)); + } + + private void requireAppTenant(AdminContext context, Long appId) { + Integer count = jdbcTemplate.queryForObject("select count(*) from aihr_knowledge_app where tenant_id = ? and id = ?", + Integer.class, context.tenantId(), appId); + if (count == null || count == 0) { + throw new ServiceException("调用应用不存在", HttpStatus.NOT_FOUND); + } + } + + private List spaceIds(AdminContext context, List codes, boolean requireManage) { + if (codes.isEmpty()) { + return List.of(); + } + List args = new ArrayList<>(); + args.add(context.tenantId()); + args.addAll(codes); + List ids = jdbcTemplate.queryForList("select id from aihr_knowledge_info where tenant_id = ? and code in (%s)" + .formatted(placeholders(codes.size())), Long.class, args.toArray()); + if (ids.size() != codes.size()) { + throw new ServiceException("应用绑定包含其他租户或不存在的空间", HttpStatus.BAD_REQUEST); + } + if (requireManage && !context.superAdmin() && ids.stream().anyMatch(id -> !canManage(context, id))) { + throw new ServiceException("应用绑定包含无管理权限的空间", HttpStatus.FORBIDDEN); + } + return ids; + } + + private boolean canManage(AdminContext context, Long spaceId) { + List args = new ArrayList<>(); + args.add(context.tenantId()); + args.add(spaceId); + args.add(String.valueOf(context.operatorId())); + String roles = roleScope(context.roles(), args); + Integer count = jdbcTemplate.queryForObject(""" + select count(*) from aihr_knowledge_space_grant g + where g.tenant_id = ? and g.knowledge_id = ? and g.permission = 'MANAGE' and g.status = 'ACTIVE' + and ((g.principal_type = 'USER' and g.principal_value = ?)%s) + """.formatted(roles), Integer.class, args.toArray()); + return count != null && count > 0; + } + + private void requireTenantAdmin(AdminContext context) { + requireContext(context); + if (context.superAdmin()) { + return; + } + List args = new ArrayList<>(); + args.add(context.tenantId()); + args.add(String.valueOf(context.operatorId())); + String roles = roleScope(context.roles(), args); + Integer count = jdbcTemplate.queryForObject(""" + select count(*) from aihr_knowledge_space_grant g + where g.tenant_id = ? and g.permission = 'MANAGE' and g.status = 'ACTIVE' + and ((g.principal_type = 'USER' and g.principal_value = ?)%s) + """.formatted(roles), Integer.class, args.toArray()); + if (count == null || count == 0) { + throw new ServiceException("没有知识平台管理权限", HttpStatus.FORBIDDEN); + } + } + + private void audit(AdminContext context, String action, String type, Long objectId, String before, String after) { + jdbcTemplate.update(""" + insert into aihr_knowledge_admin_audit + (tenant_id, operator_id, action, object_type, object_id, before_summary, after_summary, create_time) + values (?, ?, ?, ?, ?, ?, ?, now()) + """, context.tenantId(), context.operatorId(), action, type, objectId, text(before, 1000), text(after, 1000)); + } + + private static String roleScope(Set roles, List args) { + if (roles == null || roles.isEmpty()) { + return ""; + } + args.addAll(roles); + return " or (g.principal_type = 'ROLE' and g.principal_value in (" + placeholders(roles.size()) + "))"; + } + + private static GrantItem validateGrant(GrantItem grant) { + if (grant == null) { + throw new ServiceException("授权项不能为空", HttpStatus.BAD_REQUEST); + } + String type = option(grant.principalType(), Set.of("ROLE", "USER"), null); + String value = required(grant.principalValue(), "授权主体"); + String permission = option(grant.permission(), Set.of("READ", "MANAGE"), null); + String status = option(grant.status(), Set.of("ACTIVE", "DISABLED"), "ACTIVE"); + return new GrantItem(type, value, permission, status); + } + + private static String code(String value) { + String result = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + if (!result.matches("[a-z0-9][a-z0-9_]{2,99}")) { + throw new ServiceException("空间编码仅支持 3-100 位小写字母、数字和下划线", HttpStatus.BAD_REQUEST); + } + return result; + } + + private static String appCode(String value) { + String result = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + if (!result.matches("[a-z0-9_]{3,64}")) { + throw new ServiceException("应用编码仅支持 3-64 位小写字母、数字和下划线", HttpStatus.BAD_REQUEST); + } + return result; + } + + private static String option(String value, Set allowed, String fallback) { + String result = hasText(value) ? value.trim().toUpperCase(Locale.ROOT) : fallback; + if (result == null || !allowed.contains(result)) { + throw new ServiceException("参数取值不正确", HttpStatus.BAD_REQUEST); + } + return result; + } + + private static int rate(Integer value) { + int rate = value == null ? 60 : value; + if (rate < 1 || rate > 10000) { + throw new ServiceException("每分钟限流必须为 1-10000", HttpStatus.BAD_REQUEST); + } + return rate; + } + + private static List cleanCodes(List values) { + LinkedHashSet result = new LinkedHashSet<>(); + if (values != null) { + values.stream().filter(AihrKnowledgeSpaceAdminService::hasText).map(String::trim).forEach(result::add); + } + if (result.size() > 20) { + throw new ServiceException("单个应用最多绑定 20 个知识空间", HttpStatus.BAD_REQUEST); + } + return List.copyOf(result); + } + + private static List csv(String value) { + return hasText(value) ? List.of(value.split(",")) : List.of(); + } + + private static String required(String value, String label) { + if (!hasText(value)) { + throw new ServiceException(label + "不能为空", HttpStatus.BAD_REQUEST); + } + return text(value.trim(), 100); + } + + private static void requireContext(AdminContext context) { + if (context == null || !hasText(context.tenantId()) || context.operatorId() == null) { + throw new ServiceException("管理登录身份缺失", HttpStatus.UNAUTHORIZED); + } + } + + private static String placeholders(int count) { + return String.join(",", java.util.Collections.nCopies(count, "?")); + } + + private static String spaceSummary(SpaceView value) { + return summary(value.code(), value.name(), value.spaceType(), value.sensitivityLevel(), value.status()); + } + + private static String summary(String code, String name, String type, String sensitivity, String status) { + return "code=" + code + ",name=" + name + ",type=" + type + ",sensitivity=" + sensitivity + ",status=" + status; + } + + private static String appSummary(AppView value) { + return "code=" + value.appCode() + ",name=" + value.appName() + ",status=" + value.status() + + ",rate=" + value.rateLimitPerMinute() + ",expires=" + value.expiresTime(); + } + + private static String text(String value, int max) { + String result = value == null ? "" : value; + return result.length() <= max ? result : result.substring(0, max); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamScoring.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamScoring.java new file mode 100644 index 00000000..5a38de93 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamScoring.java @@ -0,0 +1,138 @@ +package org.dromara.aihr.learning; + +import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult; +import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDraft; +import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest; +import org.dromara.common.core.exception.ServiceException; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** Pure validation and scoring rules so exam submissions stay deterministic and testable. */ +final class AihrExamScoring { + + private static final Set QUESTION_TYPES = Set.of("single", "multiple", "judge", "short"); + + private AihrExamScoring() { + } + + static void validate(ExamUpsertRequest request) { + if (request == null || clean(request.title()).isEmpty()) { + throw new ServiceException("请填写考试名称"); + } + int passScore = request.passScore() == null ? 80 : request.passScore(); + if (passScore < 1 || passScore > 100) { + throw new ServiceException("及格分必须在1-100之间"); + } + List questions = request.questions() == null ? List.of() : request.questions(); + if (questions.isEmpty()) { + throw new ServiceException("请至少添加一道考试题"); + } + if (questions.size() > 100) { + throw new ServiceException("单份试卷最多100题"); + } + validateTargets(request.targets()); + int total = 0; + for (ExamQuestionDraft question : questions) { + validateQuestion(question); + total += question.score(); + } + if (total != 100) { + throw new ServiceException("试卷题目分值合计必须为100分"); + } + } + + static void validateTargets(List targets) { + if (targets == null || targets.isEmpty()) { + return; + } + if (targets.size() > 100) { + throw new ServiceException("单次考试最多选择100名员工"); + } + Set identities = new LinkedHashSet<>(); + for (AihrLearningDto.ExamTargetDraft target : targets) { + String identity = target == null ? "" : clean(target.extPartyId()); + if (identity.isEmpty()) { + throw new ServiceException("考试人员身份不能为空"); + } + if (!identities.add(identity)) { + throw new ServiceException("考试人员不能重复选择"); + } + } + } + + private static void validateQuestion(ExamQuestionDraft question) { + if (question == null || clean(question.stem()).isEmpty()) { + throw new ServiceException("考试题干不能为空"); + } + String type = clean(question.questionType()).toLowerCase(Locale.ROOT); + if (!QUESTION_TYPES.contains(type)) { + throw new ServiceException("不支持的考试题型"); + } + if (question.score() == null || question.score() < 1 || question.score() > 100) { + throw new ServiceException("单题分值必须在1-100之间"); + } + List correct = normalized(question.correctAnswers()); + if (correct.isEmpty()) { + throw new ServiceException("请设置考试题参考答案"); + } + if (!"short".equals(type)) { + List options = normalized(question.options()); + if (options.size() < 2 || !options.containsAll(correct)) { + throw new ServiceException("客观题选项或答案配置不正确"); + } + } + } + + static ScoreResult score(List questions, List answers) { + Map> answerMap = new LinkedHashMap<>(); + for (ExamAnswerRequest answer : answers == null ? List.of() : answers) { + if (answer != null && answer.questionId() != null) { + answerMap.put(answer.questionId(), normalized(answer.answers())); + } + } + int earned = 0; + List results = new ArrayList<>(); + for (ExamQuestionDraft question : questions == null ? List.of() : questions) { + List actual = answerMap.getOrDefault(question.id(), List.of()); + boolean correct = isCorrect(question.questionType(), normalized(question.correctAnswers()), actual); + int itemScore = correct ? question.score() : 0; + earned += itemScore; + results.add(new ExamAnswerResult(question.id(), correct, itemScore, question.score(), + correct ? "回答正确" : clean(question.explanation()))); + } + return new ScoreResult(Math.max(0, Math.min(100, earned)), List.copyOf(results)); + } + + private static boolean isCorrect(String type, List expected, List actual) { + if ("short".equalsIgnoreCase(clean(type))) { + String text = String.join(" ", actual).toLowerCase(Locale.ROOT); + return !expected.isEmpty() && expected.stream().allMatch(text::contains); + } + return new LinkedHashSet<>(expected).equals(new LinkedHashSet<>(actual)); + } + + static List normalized(List values) { + LinkedHashSet result = new LinkedHashSet<>(); + for (String value : values == null ? List.of() : values) { + String normalized = clean(value).toLowerCase(Locale.ROOT); + if (!normalized.isEmpty()) { + result.add(normalized); + } + } + return List.copyOf(result); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + record ScoreResult(int score, List results) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java new file mode 100644 index 00000000..6ea6f242 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java @@ -0,0 +1,763 @@ +package org.dromara.aihr.learning; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult; +import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDraft; +import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamSummaryResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamTargetDraft; +import org.dromara.aihr.learning.AihrLearningDto.ExamTargetResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.tenant.helper.TenantHelper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Supervisor-authored onboarding exams and employee submission flow. */ +@Service +@RequiredArgsConstructor +public class AihrExamService { + + private static final String DEFAULT_TENANT = "000000"; + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final TypeReference> STRING_LIST = new TypeReference<>() { }; + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + private volatile boolean schemaReady; + + @PostConstruct + void initializeSchema() { + ensureSchema(); + } + + public List employeeExams(String extPartyId) { + ensureSchema(); + String identity = canonicalIdentity(requireIdentity(extPartyId)); + return jdbcTemplate.query(""" + SELECT e.id, e.title, e.project_code, e.position_code, e.status, e.total_score, e.pass_score, + e.due_date, COUNT(DISTINCT q.id) AS question_count, + COUNT(DISTINCT all_target.ext_party_id) AS target_count, + COUNT(DISTINCT a.id) AS submission_count, MAX(a.score) AS best_score, + MAX(CASE WHEN a.passed = 1 THEN 1 ELSE 0 END) AS passed + FROM aihr_onboard_exam e + JOIN aihr_onboard_exam_target own_target + ON own_target.tenant_id = e.tenant_id AND own_target.exam_id = e.id AND own_target.ext_party_id = ? + LEFT JOIN aihr_onboard_exam_target all_target + ON all_target.tenant_id = e.tenant_id AND all_target.exam_id = e.id + LEFT JOIN aihr_onboard_exam_question q ON q.tenant_id = e.tenant_id AND q.exam_id = e.id + LEFT JOIN aihr_onboard_exam_attempt a ON a.tenant_id = e.tenant_id AND a.exam_id = e.id AND a.ext_party_id = ? + WHERE e.tenant_id = ? AND e.status = 'PUBLISHED' + AND (e.due_date IS NULL OR e.due_date >= CURRENT_DATE()) + GROUP BY e.id, e.title, e.project_code, e.position_code, e.status, e.total_score, e.pass_score, e.due_date + ORDER BY CASE WHEN submission_count = 0 THEN 0 ELSE 1 END, COALESCE(e.due_date, '9999-12-31'), e.id DESC + """, (rs, rowNum) -> mapSummary(rs), identity, identity, tenantId()); + } + + public ExamDetailResponse employeeExam(Long examId, String extPartyId) { + ensureSchema(); + String identity = canonicalIdentity(requireIdentity(extPartyId)); + ExamHeader header = requireEmployeeExam(examId, identity); + return detail(header, false, identity); + } + + @Transactional(rollbackFor = Exception.class) + public ExamSubmitResponse submit(Long examId, ExamSubmitRequest request, String extPartyId) { + ensureSchema(); + String identity = canonicalIdentity(requireIdentity(extPartyId)); + ExamHeader header = requireEmployeeExam(examId, identity); + String requestId = normalizeRequestId(request == null ? null : request.requestId()); + ExamSubmitResponse existing = findAttempt(examId, identity, requestId); + if (existing != null) { + return existing; + } + List questions = loadQuestionDrafts(examId); + AihrExamScoring.ScoreResult score = AihrExamScoring.score(questions, + request == null ? List.of() : request.answers()); + boolean passed = score.score() >= header.passScore(); + // MySQL DATETIME has second precision here. Normalize before insert so the first + // response and an idempotent replay cannot differ because JDBC rounds nanos. + LocalDateTime submittedAt = databaseSecondPrecision(LocalDateTime.now()); + long attemptId; + try { + attemptId = insertAttempt(examId, identity, requestId, score.score(), passed, submittedAt); + } catch (DuplicateKeyException duplicate) { + ExamSubmitResponse raced = findAttempt(examId, identity, requestId); + if (raced != null) { + return raced; + } + throw duplicate; + } + List answers = request == null || request.answers() == null ? List.of() : request.answers(); + for (var result : score.results()) { + List answer = answers.stream() + .filter(item -> item != null && result.questionId().equals(item.questionId())) + .findFirst().map(ExamAnswerRequest::answers).orElse(List.of()); + jdbcTemplate.update(""" + INSERT INTO aihr_onboard_exam_answer + (tenant_id, attempt_id, question_id, answer_json, correct, earned_score, create_time) + VALUES (?, ?, ?, ?, ?, ?, now()) + """, tenantId(), attemptId, result.questionId(), json(answer), result.correct(), result.earnedScore()); + } + return new ExamSubmitResponse(attemptId, "SUBMITTED", score.score(), passed, + submittedAt.format(TIME_FORMAT), score.results()); + } + + public List supervisorExams(String supervisorExtPartyId) { + ensureSchema(); + String owner = clean(supervisorExtPartyId); + return jdbcTemplate.query(""" + SELECT e.id, e.title, e.project_code, e.position_code, e.status, e.total_score, e.pass_score, + e.due_date, COUNT(DISTINCT q.id) AS question_count, + COUNT(DISTINCT t.ext_party_id) AS target_count, + COUNT(DISTINCT a.id) AS submission_count, MAX(a.score) AS best_score, + MAX(CASE WHEN a.passed = 1 THEN 1 ELSE 0 END) AS passed + FROM aihr_onboard_exam e + LEFT JOIN aihr_onboard_exam_question q ON q.tenant_id = e.tenant_id AND q.exam_id = e.id + LEFT JOIN aihr_onboard_exam_target t ON t.tenant_id = e.tenant_id AND t.exam_id = e.id + LEFT JOIN aihr_onboard_exam_attempt a ON a.tenant_id = e.tenant_id AND a.exam_id = e.id + WHERE e.tenant_id = ? AND (? = '' OR e.created_by = ?) + GROUP BY e.id, e.title, e.project_code, e.position_code, e.status, e.total_score, e.pass_score, e.due_date + ORDER BY e.id DESC + """, (rs, rowNum) -> mapSummary(rs), tenantId(), owner, owner); + } + + @Transactional(rollbackFor = Exception.class) + public ExamDetailResponse create(ExamUpsertRequest request, String supervisorExtPartyId) { + ensureSchema(); + AihrExamScoring.validate(request); + String owner = fallback(supervisorExtPartyId, "system"); + LocalDate dueDate = parseDueDate(request.dueDate()); + List targets = resolveTargets(request.targets(), supervisorExtPartyId); + long examId = insertExam(request, owner, dueDate); + saveQuestions(examId, request.questions()); + saveTargets(examId, targets); + return supervisorExam(examId, supervisorExtPartyId); + } + + public ExamDetailResponse supervisorExam(Long examId, String supervisorExtPartyId) { + ensureSchema(); + return detail(requireSupervisorExam(examId, supervisorExtPartyId), true, null); + } + + @Transactional(rollbackFor = Exception.class) + public ExamDetailResponse update(Long examId, ExamUpsertRequest request, String supervisorExtPartyId) { + ensureSchema(); + AihrExamScoring.validate(request); + ExamHeader existing = requireSupervisorExam(examId, supervisorExtPartyId); + if (!"DRAFT".equals(existing.status())) { + throw new ServiceException("仅草稿考试可以修改"); + } + LocalDate dueDate = parseDueDate(request.dueDate()); + List targets = resolveTargets(request.targets(), supervisorExtPartyId); + int updated = jdbcTemplate.update(""" + UPDATE aihr_onboard_exam + SET title = ?, project_code = ?, position_code = ?, pass_score = ?, due_date = ?, update_time = now() + WHERE tenant_id = ? AND id = ? AND status = 'DRAFT' + """, clean(request.title()), clean(request.projectCode()), clean(request.positionCode()), + request.passScore() == null ? 80 : request.passScore(), dueDate == null ? null : Date.valueOf(dueDate), + tenantId(), examId); + if (updated == 0) { + throw new ServiceException("考试不存在或状态已变化"); + } + jdbcTemplate.update("DELETE FROM aihr_onboard_exam_question WHERE tenant_id = ? AND exam_id = ?", tenantId(), examId); + saveQuestions(examId, request.questions()); + jdbcTemplate.update("DELETE FROM aihr_onboard_exam_target WHERE tenant_id = ? AND exam_id = ?", tenantId(), examId); + saveTargets(examId, targets); + return supervisorExam(examId, supervisorExtPartyId); + } + + @Transactional(rollbackFor = Exception.class) + public ExamDetailResponse publish(Long examId, ExamPublishRequest request, String supervisorExtPartyId) { + ensureSchema(); + ExamHeader header = requireSupervisorExam(examId, supervisorExtPartyId); + String requestId = normalizeOptionalRequestId(request == null ? null : request.requestId()); + assertPublishRequestAvailable(examId, header.createdBy(), requestId); + if (!"DRAFT".equals(header.status())) { + return detail(header, true, null); + } + Integer score = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(score), 0) FROM aihr_onboard_exam_question WHERE tenant_id = ? AND exam_id = ? + """, Integer.class, tenantId(), examId); + if (score == null || score != 100) { + throw new ServiceException("试卷题目分值合计必须为100分"); + } + Integer targetCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM aihr_onboard_exam_target WHERE tenant_id = ? AND exam_id = ? + """, Integer.class, tenantId(), examId); + if (targetCount == null || targetCount < 1 || targetCount > 100) { + throw new ServiceException("发布考试前请选择1-100名真实员工"); + } + List storedTargets = jdbcTemplate.query(""" + SELECT ext_party_id, trainee_name FROM aihr_onboard_exam_target + WHERE tenant_id = ? AND exam_id = ? ORDER BY id + """, (rs, rowNum) -> new ExamTargetDraft(rs.getString("ext_party_id"), rs.getString("trainee_name")), + tenantId(), examId); + List currentTargets = resolveTargets(storedTargets, supervisorExtPartyId); + jdbcTemplate.update("DELETE FROM aihr_onboard_exam_target WHERE tenant_id = ? AND exam_id = ?", + tenantId(), examId); + saveTargets(examId, currentTargets); + try { + jdbcTemplate.update(""" + UPDATE aihr_onboard_exam + SET status = 'PUBLISHED', publish_request_key = ?, published_time = now(), update_time = now() + WHERE tenant_id = ? AND id = ? AND status = 'DRAFT' + """, requestId.isEmpty() ? null : requestId, tenantId(), examId); + } catch (DuplicateKeyException duplicate) { + throw new ServiceException("发布请求ID已用于其他考试"); + } + return supervisorExam(examId, supervisorExtPartyId); + } + + public ExamDetailResponse publish(Long examId, String supervisorExtPartyId) { + return publish(examId, null, supervisorExtPartyId); + } + + public List results(Long examId, String supervisorExtPartyId) { + ensureSchema(); + requireSupervisorExam(examId, supervisorExtPartyId); + return jdbcTemplate.query(""" + SELECT a.id, t.ext_party_id, t.trainee_name, + a.score, a.passed, a.submitted_time + FROM aihr_onboard_exam_target t + LEFT JOIN aihr_onboard_exam_attempt a + ON a.tenant_id = t.tenant_id AND a.exam_id = t.exam_id AND a.ext_party_id = t.ext_party_id + AND a.id = (SELECT a2.id FROM aihr_onboard_exam_attempt a2 + WHERE a2.tenant_id = t.tenant_id AND a2.exam_id = t.exam_id + AND a2.ext_party_id = t.ext_party_id + ORDER BY a2.score DESC, a2.submitted_time DESC, a2.id DESC LIMIT 1) + WHERE t.tenant_id = ? AND t.exam_id = ? + ORDER BY CASE WHEN a.id IS NULL THEN 1 ELSE 0 END, a.score DESC, t.trainee_name, t.ext_party_id + """, (rs, rowNum) -> new ExamResultResponse( + (Long) rs.getObject("id"), rs.getString("ext_party_id"), rs.getString("trainee_name"), + (Integer) rs.getObject("score"), rs.getObject("passed") == null ? null : rs.getBoolean("passed"), + format(rs.getTimestamp("submitted_time")), rs.getObject("id") == null ? "PENDING" : "SUBMITTED" + ), tenantId(), examId); + } + + private ExamDetailResponse detail(ExamHeader header, boolean includeAnswers, String employeeIdentity) { + List questions = loadQuestionDrafts(header.id()).stream().map(question -> + new ExamQuestionResponse(question.id(), question.questionType(), question.stem(), question.options(), + includeAnswers ? question.correctAnswers() : List.of(), question.score(), + includeAnswers ? question.explanation() : "", question.sortOrder())).toList(); + List targets = loadTargets(header.id(), includeAnswers ? null : employeeIdentity); + return new ExamDetailResponse(header.id(), header.title(), header.projectCode(), header.positionCode(), + header.status(), 100, header.passScore(), header.dueDate(), questions, targets); + } + + private ExamHeader requireEmployeeExam(Long examId, String extPartyId) { + String identity = canonicalIdentity(requireIdentity(extPartyId)); + List rows = jdbcTemplate.query(""" + SELECT e.id, e.title, e.project_code, e.position_code, e.status, e.pass_score, e.due_date, + e.created_by, e.publish_request_key + FROM aihr_onboard_exam e + JOIN aihr_onboard_exam_target t + ON t.tenant_id = e.tenant_id AND t.exam_id = e.id AND t.ext_party_id = ? + WHERE e.tenant_id = ? AND e.id = ? AND e.status = 'PUBLISHED' + AND (e.due_date IS NULL OR e.due_date >= CURRENT_DATE()) + """, (rs, rowNum) -> mapHeader(rs), identity, tenantId(), examId); + if (rows.isEmpty()) { + throw new ServiceException("考试不存在、已截止或不适用于当前岗位"); + } + return rows.get(0); + } + + private ExamHeader requireSupervisorExam(Long examId, String supervisorExtPartyId) { + if (examId == null || examId <= 0) { + throw new ServiceException("考试不存在"); + } + String owner = clean(supervisorExtPartyId); + List rows = jdbcTemplate.query(""" + SELECT id, title, project_code, position_code, status, pass_score, due_date, created_by, + publish_request_key + FROM aihr_onboard_exam WHERE tenant_id = ? AND id = ? AND (? = '' OR created_by = ?) + """, (rs, rowNum) -> mapHeader(rs), tenantId(), examId, owner, owner); + if (rows.isEmpty()) { + throw new ServiceException("考试不存在或无权操作"); + } + return rows.get(0); + } + + private List loadQuestionDrafts(Long examId) { + return jdbcTemplate.query(""" + SELECT id, question_type, stem, options_json, correct_answers_json, score, explanation, sort_order + FROM aihr_onboard_exam_question WHERE tenant_id = ? AND exam_id = ? ORDER BY sort_order, id + """, (rs, rowNum) -> new ExamQuestionDraft( + rs.getLong("id"), rs.getString("question_type"), rs.getString("stem"), + stringList(rs.getString("options_json")), stringList(rs.getString("correct_answers_json")), + rs.getInt("score"), rs.getString("explanation"), rs.getInt("sort_order") + ), tenantId(), examId); + } + + private long insertExam(ExamUpsertRequest request, String owner, LocalDate dueDate) { + KeyHolder key = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO aihr_onboard_exam + (tenant_id, title, project_code, position_code, status, total_score, pass_score, due_date, + created_by, create_time, update_time) + VALUES (?, ?, ?, ?, 'DRAFT', 100, ?, ?, ?, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, tenantId()); + statement.setString(2, clean(request.title())); + statement.setString(3, clean(request.projectCode())); + statement.setString(4, clean(request.positionCode())); + statement.setInt(5, request.passScore() == null ? 80 : request.passScore()); + statement.setDate(6, dueDate == null ? null : Date.valueOf(dueDate)); + statement.setString(7, owner); + return statement; + }, key); + Number id = key.getKey(); + if (id == null) { + throw new ServiceException("创建考试失败"); + } + return id.longValue(); + } + + private long insertAttempt(Long examId, String identity, String requestId, int score, boolean passed, + LocalDateTime submittedAt) { + KeyHolder key = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO aihr_onboard_exam_attempt + (tenant_id, exam_id, ext_party_id, request_key, status, score, passed, submitted_time, create_time, update_time) + VALUES (?, ?, ?, ?, 'SUBMITTED', ?, ?, ?, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, tenantId()); + statement.setLong(2, examId); + statement.setString(3, identity); + statement.setString(4, requestId); + statement.setInt(5, score); + statement.setBoolean(6, passed); + statement.setTimestamp(7, Timestamp.valueOf(submittedAt)); + return statement; + }, key); + Number id = key.getKey(); + if (id == null) { + throw new ServiceException("提交考试失败"); + } + return id.longValue(); + } + + private void saveQuestions(Long examId, List questions) { + int index = 0; + for (ExamQuestionDraft question : questions) { + index++; + jdbcTemplate.update(""" + INSERT INTO aihr_onboard_exam_question + (tenant_id, exam_id, question_type, stem, options_json, correct_answers_json, score, + explanation, sort_order, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + """, tenantId(), examId, clean(question.questionType()).toLowerCase(Locale.ROOT), + clean(question.stem()), json(question.options()), json(question.correctAnswers()), question.score(), + truncate(question.explanation(), 1000), question.sortOrder() == null ? index : question.sortOrder()); + } + } + + private void saveTargets(Long examId, List targets) { + for (ResolvedTarget target : targets) { + jdbcTemplate.update(""" + INSERT INTO aihr_onboard_exam_target + (tenant_id, exam_id, ext_party_id, trainee_name, project_code, position_name, create_time) + VALUES (?, ?, ?, ?, ?, ?, now()) + """, tenantId(), examId, target.extPartyId(), target.traineeName(), target.projectCode(), + target.positionName()); + } + } + + private List loadTargets(Long examId, String employeeIdentity) { + List args = new ArrayList<>(); + args.add(tenantId()); + args.add(examId); + String employeeSql = ""; + if (!clean(employeeIdentity).isEmpty()) { + employeeSql = " AND ext_party_id = ?"; + args.add(clean(employeeIdentity)); + } + return jdbcTemplate.query(""" + SELECT ext_party_id, trainee_name, project_code, position_name + FROM aihr_onboard_exam_target + WHERE tenant_id = ? AND exam_id = ?%s + ORDER BY trainee_name, ext_party_id + """.formatted(employeeSql), (rs, rowNum) -> new ExamTargetResponse( + rs.getString("ext_party_id"), rs.getString("trainee_name"), rs.getString("project_code"), + rs.getString("position_name") + ), args.toArray()); + } + + private List resolveTargets(List requested, String supervisorExtPartyId) { + AihrExamScoring.validateTargets(requested); + if (requested == null || requested.isEmpty()) { + return List.of(); + } + Map requestedById = new LinkedHashMap<>(); + for (ExamTargetDraft target : requested) { + requestedById.put(clean(target.extPartyId()), target); + } + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(requestedById.keySet()); + String scopeSql = ""; + String supervisor = clean(supervisorExtPartyId); + if (!supervisor.isEmpty()) { + List projects = supervisorProjects(supervisor); + if (projects.isEmpty()) { + throw new ServiceException("当前账号没有可指派的在职员工范围"); + } + scopeSql = " AND project_code IN (" + placeholders(projects.size()) + ")"; + args.addAll(projects); + } + List rows = jdbcTemplate.query(""" + SELECT ext_party_id, person_name, project_code, position_name + FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' + AND ext_party_id IN (%s)%s + """.formatted(placeholders(requestedById.size()), scopeSql), (rs, rowNum) -> new ResolvedTarget( + rs.getString("ext_party_id"), fallback(rs.getString("person_name"), "员工"), + clean(rs.getString("project_code")), clean(rs.getString("position_name")) + ), args.toArray()); + Map resolved = new LinkedHashMap<>(); + rows.forEach(row -> resolved.put(row.extPartyId(), row)); + List invalid = requestedById.keySet().stream().filter(id -> !resolved.containsKey(id)).toList(); + if (!invalid.isEmpty()) { + throw new ServiceException("所选人员不存在、已离职或超出主管范围: " + String.join(",", invalid)); + } + return requestedById.keySet().stream().map(resolved::get).toList(); + } + + private List supervisorProjects(String supervisorIdentity) { + return jdbcTemplate.query(""" + SELECT DISTINCT project_code + FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' + AND (ext_party_id = ? OR person_phone = ?) + AND position_level IN ('主管', '项目经理') + ORDER BY project_code + """, (rs, rowNum) -> rs.getString("project_code"), tenantId(), supervisorIdentity, supervisorIdentity); + } + + private void assertPublishRequestAvailable(Long examId, String owner, String requestId) { + if (requestId.isEmpty()) { + return; + } + List rows = jdbcTemplate.query(""" + SELECT id FROM aihr_onboard_exam + WHERE tenant_id = ? AND created_by = ? AND publish_request_key = ? LIMIT 1 + """, (rs, rowNum) -> rs.getLong("id"), tenantId(), owner, requestId); + if (!rows.isEmpty() && !rows.get(0).equals(examId)) { + throw new ServiceException("发布请求ID已用于其他考试"); + } + } + + private ExamSubmitResponse findAttempt(Long examId, String identity, String requestId) { + List rows = jdbcTemplate.query(""" + SELECT id, status, score, passed, submitted_time FROM aihr_onboard_exam_attempt + WHERE tenant_id = ? AND exam_id = ? AND ext_party_id = ? AND request_key = ? LIMIT 1 + """, (rs, rowNum) -> new AttemptRow(rs.getLong("id"), rs.getString("status"), rs.getInt("score"), + rs.getBoolean("passed"), format(rs.getTimestamp("submitted_time"))), + tenantId(), examId, identity, requestId); + if (rows.isEmpty()) { + return null; + } + AttemptRow row = rows.get(0); + List results = jdbcTemplate.query(""" + SELECT answer.question_id, answer.correct, answer.earned_score, + question.score AS max_score, + CASE WHEN answer.correct = 1 THEN '回答正确' + ELSE COALESCE(question.explanation, '') END AS explanation + FROM aihr_onboard_exam_answer answer + JOIN aihr_onboard_exam_question question + ON question.tenant_id = answer.tenant_id + AND question.id = answer.question_id + AND question.exam_id = ? + WHERE answer.tenant_id = ? AND answer.attempt_id = ? + ORDER BY question.sort_order, question.id + """, (rs, rowNum) -> new ExamAnswerResult( + rs.getLong("question_id"), rs.getBoolean("correct"), rs.getInt("earned_score"), + rs.getInt("max_score"), clean(rs.getString("explanation")) + ), examId, tenantId(), row.id()); + return new ExamSubmitResponse(row.id(), row.status(), row.score(), row.passed(), row.submittedTime(), results); + } + + private String canonicalIdentity(String identity) { + List rows = jdbcTemplate.query(""" + SELECT ext_party_id FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' AND (ext_party_id = ? OR person_phone = ?) + ORDER BY snapshot_date DESC, id DESC LIMIT 1 + """, (rs, rowNum) -> rs.getString("ext_party_id"), tenantId(), identity, identity); + return rows.isEmpty() ? identity : rows.get(0); + } + + private static ExamHeader mapHeader(java.sql.ResultSet rs) throws java.sql.SQLException { + return new ExamHeader(rs.getLong("id"), rs.getString("title"), rs.getString("project_code"), + rs.getString("position_code"), rs.getString("status"), rs.getInt("pass_score"), + rs.getDate("due_date") == null ? null : rs.getDate("due_date").toLocalDate().toString(), + rs.getString("created_by"), rs.getString("publish_request_key")); + } + + private static ExamSummaryResponse mapSummary(java.sql.ResultSet rs) throws java.sql.SQLException { + Object passed = rs.getObject("passed"); + Object bestScore = rs.getObject("best_score"); + return new ExamSummaryResponse(rs.getLong("id"), rs.getString("title"), rs.getString("project_code"), + rs.getString("position_code"), rs.getString("status"), rs.getInt("total_score"), rs.getInt("pass_score"), + rs.getDate("due_date") == null ? null : rs.getDate("due_date").toLocalDate().toString(), + rs.getInt("question_count"), rs.getInt("target_count"), rs.getInt("submission_count"), + bestScore == null ? null : rs.getInt("best_score"), + passed == null ? null : rs.getBoolean("passed")); + } + + private LocalDate parseDueDate(String value) { + if (clean(value).isEmpty()) { + return null; + } + try { + LocalDate date = LocalDate.parse(clean(value)); + if (date.isBefore(LocalDate.now())) { + throw new ServiceException("考试截止日期不能早于今天"); + } + return date; + } catch (DateTimeParseException error) { + throw new ServiceException("考试截止日期格式应为YYYY-MM-DD"); + } + } + + private String normalizeRequestId(String value) { + String requestId = clean(value); + if (requestId.isEmpty() || requestId.length() > 100 || !requestId.matches("[A-Za-z0-9._:-]+")) { + throw new ServiceException("缺少有效的考试提交请求ID"); + } + return requestId; + } + + private String normalizeOptionalRequestId(String value) { + String requestId = clean(value); + if (!requestId.isEmpty() && (requestId.length() > 100 || !requestId.matches("[A-Za-z0-9._:-]+"))) { + throw new ServiceException("考试发布请求ID格式不正确"); + } + return requestId; + } + + private String json(List values) { + try { + return objectMapper.writeValueAsString(values == null ? List.of() : values); + } catch (JsonProcessingException error) { + throw new ServiceException("考试答案序列化失败"); + } + } + + private List stringList(String json) { + if (clean(json).isEmpty()) { + return List.of(); + } + try { + return objectMapper.readValue(json, STRING_LIST); + } catch (JsonProcessingException error) { + throw new ServiceException("考试题数据损坏"); + } + } + + private void ensureSchema() { + if (schemaReady) { + return; + } + synchronized (this) { + if (schemaReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS aihr_onboard_exam ( + id bigint NOT NULL AUTO_INCREMENT, + tenant_id varchar(20) NOT NULL DEFAULT '000000', + title varchar(200) NOT NULL, + project_code varchar(100) NOT NULL DEFAULT '', + position_code varchar(80) NOT NULL DEFAULT '', + status varchar(20) NOT NULL DEFAULT 'DRAFT', + total_score int NOT NULL DEFAULT 100, + pass_score int NOT NULL DEFAULT 80, + due_date date DEFAULT NULL, + created_by varchar(100) NOT NULL, + publish_request_key varchar(100) DEFAULT NULL, + published_time datetime DEFAULT NULL, + create_time datetime DEFAULT NULL, + update_time datetime DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_onboard_exam_publish_request (tenant_id, created_by, publish_request_key), + KEY idx_aihr_onboard_exam_scope (tenant_id, status, project_code, position_code, due_date), + KEY idx_aihr_onboard_exam_owner (tenant_id, created_by, id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + ensureColumn("aihr_onboard_exam", "publish_request_key", """ + ALTER TABLE aihr_onboard_exam + ADD COLUMN publish_request_key varchar(100) DEFAULT NULL AFTER created_by + """); + ensureIndex("aihr_onboard_exam", "uk_aihr_onboard_exam_publish_request", """ + ALTER TABLE aihr_onboard_exam + ADD UNIQUE KEY uk_aihr_onboard_exam_publish_request (tenant_id, created_by, publish_request_key) + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS aihr_onboard_exam_question ( + id bigint NOT NULL AUTO_INCREMENT, + tenant_id varchar(20) NOT NULL DEFAULT '000000', + exam_id bigint NOT NULL, + question_type varchar(20) NOT NULL, + stem varchar(1000) NOT NULL, + options_json text, + correct_answers_json text NOT NULL, + score int NOT NULL, + explanation varchar(1000) DEFAULT NULL, + sort_order int NOT NULL DEFAULT 0, + create_time datetime DEFAULT NULL, + update_time datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_aihr_onboard_exam_question (tenant_id, exam_id, sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS aihr_onboard_exam_target ( + id bigint NOT NULL AUTO_INCREMENT, + tenant_id varchar(20) NOT NULL DEFAULT '000000', + exam_id bigint NOT NULL, + ext_party_id varchar(100) NOT NULL, + trainee_name varchar(100) NOT NULL, + project_code varchar(100) NOT NULL DEFAULT '', + position_name varchar(100) NOT NULL DEFAULT '', + create_time datetime DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_onboard_exam_target (tenant_id, exam_id, ext_party_id), + KEY idx_aihr_onboard_exam_target_party (tenant_id, ext_party_id, exam_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS aihr_onboard_exam_attempt ( + id bigint NOT NULL AUTO_INCREMENT, + tenant_id varchar(20) NOT NULL DEFAULT '000000', + exam_id bigint NOT NULL, + ext_party_id varchar(100) NOT NULL, + request_key varchar(100) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'SUBMITTED', + score int NOT NULL DEFAULT 0, + passed tinyint NOT NULL DEFAULT 0, + submitted_time datetime NOT NULL, + create_time datetime DEFAULT NULL, + update_time datetime DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_onboard_exam_attempt_request (tenant_id, exam_id, ext_party_id, request_key), + KEY idx_aihr_onboard_exam_attempt_result (tenant_id, exam_id, score, submitted_time), + KEY idx_aihr_onboard_exam_attempt_party (tenant_id, ext_party_id, submitted_time) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS aihr_onboard_exam_answer ( + id bigint NOT NULL AUTO_INCREMENT, + tenant_id varchar(20) NOT NULL DEFAULT '000000', + attempt_id bigint NOT NULL, + question_id bigint NOT NULL, + answer_json text, + correct tinyint NOT NULL DEFAULT 0, + earned_score int NOT NULL DEFAULT 0, + create_time datetime DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_onboard_exam_answer (tenant_id, attempt_id, question_id), + KEY idx_aihr_onboard_exam_answer_question (tenant_id, question_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + schemaReady = true; + } + } + + private void ensureColumn(String table, String column, String ddl) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? + """, Integer.class, table, column); + if (count == null || count == 0) { + jdbcTemplate.execute(ddl); + } + } + + private void ensureIndex(String table, String index, String ddl) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? + """, Integer.class, table, index); + if (count == null || count == 0) { + jdbcTemplate.execute(ddl); + } + } + + private String tenantId() { + return fallback(TenantHelper.getTenantId(), DEFAULT_TENANT); + } + + private static String requireIdentity(String value) { + String identity = clean(value); + if (identity.isEmpty()) { + throw new ServiceException("缺少员工身份"); + } + return identity; + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String fallback(String value, String fallback) { + return clean(value).isEmpty() ? fallback : clean(value); + } + + private static String truncate(String value, int maxLength) { + String text = clean(value); + return text.length() <= maxLength ? text : text.substring(0, maxLength); + } + + private static String placeholders(int count) { + return String.join(",", java.util.Collections.nCopies(Math.max(1, count), "?")); + } + + private static String format(Timestamp timestamp) { + return timestamp == null ? "" : timestamp.toLocalDateTime().format(TIME_FORMAT); + } + + static LocalDateTime databaseSecondPrecision(LocalDateTime value) { + return value.withNano(0); + } + + private record ExamHeader(Long id, String title, String projectCode, String positionCode, String status, + Integer passScore, String dueDate, String createdBy, String publishRequestKey) { + } + + private record AttemptRow(Long id, String status, Integer score, Boolean passed, String submittedTime) { + } + + private record ResolvedTarget(String extPartyId, String traineeName, String projectCode, String positionName) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java new file mode 100644 index 00000000..b8d1efd4 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java @@ -0,0 +1,178 @@ +package org.dromara.aihr.learning; + +import java.util.List; + +/** + * Contracts for the mobile learning, exam, feedback, and content-matrix APIs. + */ +public final class AihrLearningDto { + + private AihrLearningDto() { + } + + public record AssignmentFeedbackRequest(String verdict, String comment) { + } + + public record AssignmentFeedbackResponse(Long assignmentId, String verdict, String comment, String updateTime) { + } + + public record DifficultyResponse( + String scenarioId, + String scenarioName, + String position, + Integer attempts, + Integer mistakeCount, + Integer affectedPeople, + Integer averageScore, + Integer difficultyScore, + String latestTime + ) { + } + + public record DifficultyAssignRequest( + String scenarioId, + String cause, + String dueDate, + String requestId, + Integer windowDays, + String position, + List targets + ) { + } + + public record LearningQuestionResponse( + String questionCode, + String questionType, + String question, + String referenceAnswer, + String contentVersion, + String contentHash + ) { + } + + public record ScenarioMatrixResponse( + String scenarioCode, + String scenarioName, + String scenarioType, + Integer difficulty, + String sopRefs, + List questions + ) { + } + + public record PositionMatrixResponse( + String positionCode, + String positionName, + Integer scenarioCount, + Integer questionCount, + List scenarios + ) { + } + + public record LearningMatrixResponse(Integer positionCount, Integer scenarioCount, Integer questionCount, + List positions) { + } + + public record ExamQuestionDraft( + Long id, + String questionType, + String stem, + List options, + List correctAnswers, + Integer score, + String explanation, + Integer sortOrder + ) { + } + + /** A selected employee. traineeName is display-only; the server resolves the authoritative org name. */ + public record ExamTargetDraft(String extPartyId, String traineeName) { + } + + public record ExamTargetResponse( + String extPartyId, + String traineeName, + String projectCode, + String positionName + ) { + } + + public record ExamUpsertRequest( + String title, + String projectCode, + String positionCode, + Integer passScore, + String dueDate, + List questions, + List targets + ) { + public ExamUpsertRequest(String title, String projectCode, String positionCode, Integer passScore, + String dueDate, List questions) { + this(title, projectCode, positionCode, passScore, dueDate, questions, List.of()); + } + } + + public record ExamPublishRequest(String requestId) { + } + + public record ExamSummaryResponse( + Long id, + String title, + String projectCode, + String positionCode, + String status, + Integer totalScore, + Integer passScore, + String dueDate, + Integer questionCount, + Integer targetCount, + Integer submissionCount, + Integer bestScore, + Boolean passed + ) { + } + + public record ExamQuestionResponse( + Long id, + String questionType, + String stem, + List options, + List correctAnswers, + Integer score, + String explanation, + Integer sortOrder + ) { + } + + public record ExamDetailResponse( + Long id, + String title, + String projectCode, + String positionCode, + String status, + Integer totalScore, + Integer passScore, + String dueDate, + List questions, + List targets + ) { + } + + public record ExamAnswerRequest(Long questionId, List answers) { + } + + public record ExamSubmitRequest(String requestId, List answers) { + } + + public record ExamAnswerResult(Long questionId, Boolean correct, Integer earnedScore, Integer maxScore, + String explanation) { + } + + public record ExamSubmitResponse(Long attemptId, String status, Integer score, Boolean passed, + String submittedTime, List results) { + } + + public record ExamResultResponse(Long attemptId, String extPartyId, String traineeName, Integer score, + Boolean passed, String submittedTime, String status) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningService.java new file mode 100644 index 00000000..e6ac7d11 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningService.java @@ -0,0 +1,485 @@ +package org.dromara.aihr.learning; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse; +import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackRequest; +import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackResponse; +import org.dromara.aihr.learning.AihrLearningDto.DifficultyAssignRequest; +import org.dromara.aihr.learning.AihrLearningDto.DifficultyResponse; +import org.dromara.aihr.learning.AihrLearningDto.LearningMatrixResponse; +import org.dromara.aihr.learning.AihrLearningDto.LearningQuestionResponse; +import org.dromara.aihr.learning.AihrLearningDto.PositionMatrixResponse; +import org.dromara.aihr.learning.AihrLearningDto.ScenarioMatrixResponse; +import org.dromara.aihr.service.AihrPracticeSeedService; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.tenant.helper.TenantHelper; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Learning content matrix, assignment usefulness feedback and team difficulty aggregation. + */ +@Service +@RequiredArgsConstructor +public class AihrLearningService { + + private static final String DEFAULT_TENANT = "000000"; + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Set VERDICTS = Set.of("useful", "not_useful"); + + private final JdbcTemplate jdbcTemplate; + private final AihrPracticeSeedService practiceSeedService; + + private volatile boolean schemaReady; + + @PostConstruct + void initializeSchema() { + ensureSchema(); + } + + public AssignmentFeedbackResponse submitAssignmentFeedback(Long assignmentId, AssignmentFeedbackRequest request, + String extPartyId) { + ensureSchema(); + if (assignmentId == null || assignmentId <= 0 || clean(extPartyId).isEmpty()) { + throw new ServiceException("训练题不存在或无权反馈"); + } + String verdict = normalizeVerdict(request == null ? null : request.verdict()); + String comment = truncate(request == null ? null : request.comment(), 500); + if (!ownsAssignment(assignmentId, extPartyId)) { + throw new ServiceException("训练题不存在或无权反馈"); + } + jdbcTemplate.update(""" + INSERT INTO aihr_practice_question_feedback + (tenant_id, assignment_id, ext_party_id, verdict, comment, create_time, update_time) + VALUES (?, ?, ?, ?, ?, now(), now()) + ON DUPLICATE KEY UPDATE verdict = VALUES(verdict), comment = VALUES(comment), update_time = now() + """, tenantId(), assignmentId, clean(extPartyId), verdict, emptyToNull(comment)); + return new AssignmentFeedbackResponse(assignmentId, verdict, comment, LocalDateTime.now().format(TIME_FORMAT)); + } + + public List difficulties(String supervisorExtPartyId, int limit, int windowDays, + String positionCode) { + ensureSchema(); + int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100)); + int safeWindow = validateDifficultyWindow(windowDays); + String position = clean(positionCode); + List team = teamIdentities(supervisorExtPartyId); + if (supervisorExtPartyId != null && !supervisorExtPartyId.isBlank() && team.isEmpty()) { + return List.of(); + } + List args = new ArrayList<>(); + args.add(tenantId()); + args.add(safeWindow); + String assignmentScope = scopeSql("a.ext_party_id", team, args); + args.add(tenantId()); + args.add(safeWindow); + String sessionScope = scopeSql("s.ext_party_id", team, args); + args.add(tenantId()); + args.add(position); + args.add(position); + args.add(safeLimit); + return jdbcTemplate.query(""" + SELECT evidence.scenario_id, + MAX(evidence.scenario_name) AS scenario_name, + COALESCE(MAX(scenario.position), '') AS position, + COUNT(*) AS attempts, + SUM(is_mistake) AS mistake_count, + COUNT(DISTINCT CASE WHEN is_mistake = 1 THEN ext_party_id END) AS affected_people, + ROUND(AVG(score)) AS average_score, + ROUND((SUM(is_mistake) / COUNT(*)) * 70 + (100 - AVG(score)) * 0.30) AS difficulty_score, + MAX(event_time) AS latest_time + FROM ( + SELECT a.scenario_id, a.scenario_name, a.ext_party_id, COALESCE(a.score, 0) AS score, + CASE WHEN a.correct = 0 OR COALESCE(a.score, 0) < 70 THEN 1 ELSE 0 END AS is_mistake, + COALESCE(a.finished_time, a.update_time, a.create_time) AS event_time + FROM aihr_practice_assignment a + WHERE a.tenant_id = ? AND a.status = '已完成' + AND COALESCE(a.finished_time, a.update_time, a.create_time) >= TIMESTAMPADD(DAY, -?, NOW())%s + UNION ALL + SELECT s.scenario_id, s.scenario_name, s.ext_party_id, COALESCE(s.total_score, 0) AS score, + CASE WHEN COALESCE(s.total_score, 0) < 80 OR s.dialogue_json LIKE '%%\"redFlag\":true%%' THEN 1 ELSE 0 END, + COALESCE(s.finished_time, s.update_time, s.create_time) + FROM aihr_practice_session s + WHERE s.tenant_id = ? AND s.mode = 'mobile' AND s.finished_time IS NOT NULL + AND COALESCE(s.finished_time, s.update_time, s.create_time) >= TIMESTAMPADD(DAY, -?, NOW())%s + ) evidence + JOIN aihr_practice_scenario scenario + ON scenario.tenant_id = ? AND scenario.scenario_code = evidence.scenario_id + WHERE (? = '' OR scenario.position = ?) + GROUP BY evidence.scenario_id + HAVING mistake_count > 0 + ORDER BY difficulty_score DESC, mistake_count DESC, latest_time DESC + LIMIT ? + """.formatted(assignmentScope, sessionScope), (rs, rowNum) -> new DifficultyResponse( + rs.getString("scenario_id"), + fallback(rs.getString("scenario_name"), "场景训练"), + rs.getString("position"), + rs.getInt("attempts"), + rs.getInt("mistake_count"), + rs.getInt("affected_people"), + rs.getInt("average_score"), + rs.getInt("difficulty_score"), + format(rs.getTimestamp("latest_time")) + ), args.toArray()); + } + + public PracticeAssignmentBatchResponse assignDifficulty(DifficultyAssignRequest request, String supervisorExtPartyId) { + if (request == null) { + throw new ServiceException("缺少专项派发参数"); + } + int windowDays = validateDifficultyWindow(request.windowDays()); + List eligibleTargets = + difficultyTargets(request.scenarioId(), windowDays, request.position(), supervisorExtPartyId); + List targets = eligibleTargets; + if (request.targets() != null && !request.targets().isEmpty()) { + Set requestedIdentities = request.targets().stream() + .map(org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget::extPartyId) + .map(AihrLearningService::clean) + .filter(value -> !value.isEmpty()) + .collect(java.util.stream.Collectors.toSet()); + targets = eligibleTargets.stream() + .filter(target -> requestedIdentities.contains(clean(target.extPartyId()))) + .toList(); + } + if (targets.isEmpty()) { + throw new ServiceException("当前项目暂无该场景的低分员工"); + } + PracticeAssignmentBatchRequest batch = new PracticeAssignmentBatchRequest( + request.requestId(), targets, request.scenarioId(), + fallback(request.cause(), "常见难题一键专项"), request.dueDate()); + return practiceSeedService.createAssignments(batch, supervisorExtPartyId); + } + + private List difficultyTargets( + String scenarioId, int windowDays, String positionCode, String supervisorExtPartyId) { + String scenario = clean(scenarioId); + if (scenario.isEmpty()) { + throw new ServiceException("缺少难题场景ID"); + } + String position = clean(positionCode); + List team = teamIdentities(supervisorExtPartyId); + if (team != null && team.isEmpty()) { + return List.of(); + } + List args = new ArrayList<>(); + args.add(tenantId()); + args.add(scenario); + args.add(windowDays); + String assignmentScope = scopeSql("a.ext_party_id", team, args); + args.add(tenantId()); + args.add(scenario); + args.add(windowDays); + String sessionScope = scopeSql("s.ext_party_id", team, args); + args.add(tenantId()); + args.add(scenario); + args.add(position); + args.add(position); + return jdbcTemplate.query(""" + SELECT evidence.ext_party_id, + COALESCE((SELECT NULLIF(o.person_name, '') FROM aihr_org_snapshot o + WHERE o.tenant_id = ? AND o.employment_status = 'active' + AND (o.ext_party_id = evidence.ext_party_id OR o.person_phone = evidence.ext_party_id) + ORDER BY o.snapshot_date DESC, o.id DESC LIMIT 1), '员工') AS trainee_name + FROM ( + SELECT a.ext_party_id FROM aihr_practice_assignment a + WHERE a.tenant_id = ? AND a.scenario_id = ? AND a.status = '已完成' + AND COALESCE(a.finished_time, a.update_time, a.create_time) >= TIMESTAMPADD(DAY, -?, NOW()) + AND (a.correct = 0 OR COALESCE(a.score, 0) < 70)%s + UNION + SELECT s.ext_party_id FROM aihr_practice_session s + WHERE s.tenant_id = ? AND s.scenario_id = ? AND s.mode = 'mobile' AND s.finished_time IS NOT NULL + AND COALESCE(s.finished_time, s.update_time, s.create_time) >= TIMESTAMPADD(DAY, -?, NOW()) + AND (COALESCE(s.total_score, 0) < 80 OR s.dialogue_json LIKE '%%\"redFlag\":true%%')%s + ) evidence + JOIN aihr_practice_scenario scenario + ON scenario.tenant_id = ? AND scenario.scenario_code = ? + AND (? = '' OR scenario.position = ?) + WHERE evidence.ext_party_id IS NOT NULL AND evidence.ext_party_id <> '' + ORDER BY evidence.ext_party_id + LIMIT 100 + """.formatted(assignmentScope, sessionScope), (rs, rowNum) -> + new org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget( + rs.getString("ext_party_id"), rs.getString("trainee_name")), + prependTenant(args)); + } + + static int validateDifficultyWindow(Integer windowDays) { + int value = windowDays == null ? 30 : windowDays; + if (value < 1 || value > 365) { + throw new ServiceException("统计窗口须为1至365天"); + } + return value; + } + + private Object[] prependTenant(List args) { + List all = new ArrayList<>(); + all.add(tenantId()); + all.addAll(args); + return all.toArray(); + } + + public LearningMatrixResponse matrix(String positionCode) { + ensureSchema(); + String position = clean(positionCode); + List rows = jdbcTemplate.query(""" + SELECT s.position, s.scenario_code, s.scenario_name, s.scenario_type, s.difficulty, s.sop_refs, + q.question_code, q.question_type, q.question_text, q.reference_answer, + q.content_version, q.content_hash + FROM aihr_practice_scenario s + LEFT JOIN aihr_learning_question q + ON q.tenant_id = s.tenant_id AND q.scenario_code = s.scenario_code AND q.enabled = 1 + WHERE s.tenant_id = ? AND s.enabled = 1 AND (? = '' OR s.position = ?) + ORDER BY s.position, s.sort_order, s.scenario_code, q.sort_order, q.id + """, (rs, rowNum) -> new MatrixRow( + rs.getString("position"), rs.getString("scenario_code"), rs.getString("scenario_name"), + rs.getString("scenario_type"), rs.getInt("difficulty"), rs.getString("sop_refs"), + rs.getString("question_code"), rs.getString("question_type"), rs.getString("question_text"), + rs.getString("reference_answer"), rs.getString("content_version"), rs.getString("content_hash") + ), tenantId(), position, position); + + Map positions = new LinkedHashMap<>(); + for (MatrixRow row : rows) { + PositionAccumulator positionItem = positions.computeIfAbsent(row.position(), PositionAccumulator::new); + ScenarioAccumulator scenario = positionItem.scenarios.computeIfAbsent(row.scenarioCode(), + ignored -> new ScenarioAccumulator(row)); + if (row.questionCode() != null) { + scenario.questions.add(new LearningQuestionResponse( + row.questionCode(), fallback(row.questionType(), "daily"), row.questionText(), row.referenceAnswer(), + fallback(row.contentVersion(), "v1"), row.contentHash())); + } + } + List result = positions.values().stream().map(PositionAccumulator::response).toList(); + int scenarios = result.stream().mapToInt(PositionMatrixResponse::scenarioCount).sum(); + int questions = result.stream().mapToInt(PositionMatrixResponse::questionCount).sum(); + return new LearningMatrixResponse(result.size(), scenarios, questions, result); + } + + static String normalizeVerdict(String value) { + String verdict = clean(value).toLowerCase(java.util.Locale.ROOT); + if (!VERDICTS.contains(verdict)) { + throw new ServiceException("反馈类型只能是useful或not_useful"); + } + return verdict; + } + + public static String contentHash(String contentVersion, String question, String referenceAnswer) { + String canonical = clean(contentVersion) + "\n" + clean(question) + "\n" + clean(referenceAnswer); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 unavailable", error); + } + } + + private boolean ownsAssignment(Long assignmentId, String identity) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM aihr_practice_assignment a + WHERE a.tenant_id = ? AND a.id = ? + AND (a.ext_party_id = ? OR a.ext_party_id IN ( + SELECT o.ext_party_id FROM aihr_org_snapshot o + WHERE o.tenant_id = ? AND o.employment_status = 'active' + AND (o.ext_party_id = ? OR o.person_phone = ?) + )) + """, Integer.class, tenantId(), assignmentId, clean(identity), tenantId(), clean(identity), clean(identity)); + return count != null && count > 0; + } + + /** null means unscoped system operator; empty means fail-closed scoped operator. */ + private List teamIdentities(String supervisorExtPartyId) { + String owner = clean(supervisorExtPartyId); + if (owner.isEmpty()) { + return null; + } + try { + List projects = jdbcTemplate.query(""" + SELECT DISTINCT project_code + FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' + AND (ext_party_id = ? OR person_phone = ?) + AND position_level IN ('主管', '项目经理') + """, (rs, rowNum) -> rs.getString("project_code"), tenantId(), owner, owner); + if (projects.isEmpty()) { + return List.of(); + } + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(projects); + return jdbcTemplate.query(""" + SELECT identity FROM ( + SELECT ext_party_id AS identity FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' AND project_code IN (%s) + UNION + SELECT person_phone AS identity FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' AND project_code IN (%s) + AND person_phone IS NOT NULL AND person_phone <> '' + ) team ORDER BY identity + """.formatted(placeholders(projects.size()), placeholders(projects.size())), (rs, rowNum) -> rs.getString("identity"), + combinedArgs(args, tenantId(), projects)); + } catch (DataAccessException error) { + return List.of(); + } + } + + private static Object[] combinedArgs(List first, String tenantId, List projects) { + List args = new ArrayList<>(first); + args.add(tenantId); + args.addAll(projects); + return args.toArray(); + } + + private static String scopeSql(String column, List team, List args) { + if (team == null) { + return ""; + } + if (team.isEmpty()) { + return " AND 1 = 0"; + } + args.addAll(team); + return " AND " + column + " IN (" + placeholders(team.size()) + ")"; + } + + private void ensureSchema() { + if (schemaReady) { + return; + } + synchronized (this) { + if (schemaReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS aihr_learning_question ( + id bigint NOT NULL AUTO_INCREMENT, + tenant_id varchar(20) NOT NULL DEFAULT '000000', + question_code varchar(120) NOT NULL, + scenario_code varchar(80) NOT NULL, + position_code varchar(80) NOT NULL, + question_type varchar(20) NOT NULL DEFAULT 'daily', + question_text varchar(1000) NOT NULL, + reference_answer varchar(2000) NOT NULL, + content_version varchar(30) NOT NULL DEFAULT 'v1', + content_hash char(64) NOT NULL, + enabled tinyint NOT NULL DEFAULT 1, + sort_order int NOT NULL DEFAULT 0, + create_time datetime DEFAULT NULL, + update_time datetime DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_learning_question (tenant_id, question_code), + KEY idx_aihr_learning_question_matrix (tenant_id, position_code, question_type, enabled, sort_order), + KEY idx_aihr_learning_question_hash (tenant_id, content_hash) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS aihr_practice_question_feedback ( + id bigint NOT NULL AUTO_INCREMENT, + tenant_id varchar(20) NOT NULL DEFAULT '000000', + assignment_id bigint NOT NULL, + ext_party_id varchar(100) NOT NULL, + verdict varchar(20) NOT NULL, + comment varchar(500) DEFAULT NULL, + create_time datetime DEFAULT NULL, + update_time datetime DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_question_feedback (tenant_id, assignment_id, ext_party_id), + KEY idx_aihr_question_feedback_verdict (tenant_id, verdict, update_time) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + ensureAssignmentColumn("content_version", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT 'v1' AFTER request_key"); + ensureAssignmentColumn("question_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN question_snapshot varchar(1000) DEFAULT NULL AFTER content_version"); + ensureAssignmentColumn("reference_answer_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN reference_answer_snapshot varchar(2000) DEFAULT NULL AFTER question_snapshot"); + ensureAssignmentColumn("content_hash", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_hash char(64) DEFAULT NULL AFTER reference_answer_snapshot"); + schemaReady = true; + } + } + + private void ensureAssignmentColumn(String column, String ddl) { + Integer exists = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_practice_assignment' AND COLUMN_NAME = ? + """, Integer.class, column); + if (exists == null || exists == 0) { + jdbcTemplate.execute(ddl); + } + } + + private String tenantId() { + return fallback(TenantHelper.getTenantId(), DEFAULT_TENANT); + } + + private static String placeholders(int size) { + return String.join(",", java.util.Collections.nCopies(Math.max(1, size), "?")); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String fallback(String value, String fallback) { + return clean(value).isEmpty() ? fallback : clean(value); + } + + private static String truncate(String value, int max) { + String text = clean(value); + return text.length() <= max ? text : text.substring(0, max); + } + + private static String emptyToNull(String value) { + return clean(value).isEmpty() ? null : clean(value); + } + + private static String format(Timestamp timestamp) { + return timestamp == null ? "" : timestamp.toLocalDateTime().format(TIME_FORMAT); + } + + private record MatrixRow(String position, String scenarioCode, String scenarioName, String scenarioType, + Integer difficulty, String sopRefs, String questionCode, String questionType, + String questionText, String referenceAnswer, String contentVersion, String contentHash) { + } + + private static final class ScenarioAccumulator { + private final MatrixRow row; + private final List questions = new ArrayList<>(); + + private ScenarioAccumulator(MatrixRow row) { + this.row = row; + } + + private ScenarioMatrixResponse response() { + return new ScenarioMatrixResponse(row.scenarioCode(), row.scenarioName(), row.scenarioType(), + row.difficulty(), row.sopRefs(), List.copyOf(questions)); + } + } + + private static final class PositionAccumulator { + private final String position; + private final Map scenarios = new LinkedHashMap<>(); + + private PositionAccumulator(String position) { + this.position = position; + } + + private PositionMatrixResponse response() { + List items = scenarios.values().stream().map(ScenarioAccumulator::response).toList(); + int questionCount = items.stream().mapToInt(item -> item.questions().size()).sum(); + return new PositionMatrixResponse(position, position, items.size(), questionCount, items); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java index cfa57bec..49ff808e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java @@ -4,6 +4,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse; import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest; import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; @@ -188,6 +190,10 @@ public class AihrMobileSeedService { return practiceSeedService.createAssignment(request, supervisorExtPartyId); } + public PracticeAssignmentBatchResponse createPracticeAssignments(PracticeAssignmentBatchRequest request, String supervisorExtPartyId) { + return practiceSeedService.createAssignments(request, supervisorExtPartyId); + } + public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) { return answerDailyDrill(id, request, ""); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java index bb7ea317..cc515046 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java @@ -95,15 +95,19 @@ public class AihrOrgSyncService { List companyItems = fetchOptional(baseUrl, token, req, "company", pageSize, maxPages, warnings); List departmentItems = fetchOptional(baseUrl, token, req, "department", pageSize, maxPages, warnings); + List projectItems = fetchOptional(baseUrl, token, req, "project", pageSize, maxPages, warnings); + List assignmentItems = fetchOptional(baseUrl, token, req, "employee_project_assignment", pageSize, maxPages, warnings); List employeeItems = fetchSnapshot(baseUrl, token, req, "employee", pageSize, maxPages); Map companies = companyMap(companyItems); Map departments = departmentMap(departmentItems); + Map projects = projectMap(projectItems); + Map assignments = assignmentMap(assignmentItems); List rows = new ArrayList<>(); int skipped = 0; int maskedPhone = 0; for (JsonNode employee : employeeItems) { - OrgRow row = orgRow(employee, companies, departments); + OrgRow row = orgRow(employee, companies, departments, projects, assignments); if (row == null) { skipped++; } else { @@ -375,33 +379,69 @@ public class AihrOrgSyncService { }); } - private OrgRow orgRow(JsonNode employee, Map companies, Map departments) { - String extPartyId = firstNonBlank(text(employee, "employee_number", "employeeNo", "employee_id", "employeeId", "id", "user_id")); + private OrgRow orgRow(JsonNode employee, Map companies, Map departments, + Map projects, Map assignments) { + String employeeNumber = firstNonBlank(text(employee, "employee_number", "employeeNumber", "employeeNo")); + String employeeId = firstNonBlank(text(employee, "employee_id", "employeeId", "id", "user_id", "userId")); + String extPartyId = firstNonBlank(employeeNumber, employeeId); if (extPartyId.isBlank()) { return null; } - String departmentId = firstNonBlank(text(employee, "department_id", "departmentId", "dept_id", "deptId")); + AssignmentInfo assignment = assignment(assignments, employeeId, employeeNumber, extPartyId); + String departmentId = firstNonBlank( + text(employee, "department_id", "departmentId", "dept_id", "deptId"), + assignment == null ? "" : assignment.employeeDepartmentId() + ); DepartmentInfo dept = departments.get(departmentId); - String companyId = firstNonBlank(text(employee, "company_id", "companyId"), dept == null ? "" : dept.companyId()); + String projectId = firstNonBlank( + assignment == null ? "" : assignment.projectId(), + text(employee, "project_id", "projectId"), + dept == null ? "" : dept.projectId() + ); + ProjectInfo project = projects.get(projectId); + String companyId = firstNonBlank( + text(employee, "company_id", "companyId"), + project == null ? "" : project.companyId(), + dept == null ? "" : dept.companyId() + ); CompanyInfo company = companies.get(companyId); - String deptName = firstNonBlank(text(employee, "department_name", "departmentName", "dept_name", "deptName"), dept == null ? "" : dept.name()); + String deptName = firstNonBlank( + text(employee, "department_name", "departmentName", "dept_name", "deptName"), + assignment == null ? "" : assignment.employeeDepartmentName(), + dept == null ? "" : dept.name(), + assignment == null ? "" : assignment.projectDepartmentName() + ); String projectCode = firstNonBlank( - text(employee, "project_code", "projectCode", "company_code", "companyCode"), + assignment == null ? "" : assignment.projectCode(), + text(employee, "project_code", "projectCode"), + project == null ? "" : project.code(), + dept == null ? "" : dept.projectCode(), + text(employee, "company_code", "companyCode"), company == null ? "" : company.code(), companyId, dept == null ? "" : dept.code(), "ORG" ); String projectName = firstNonBlank( - text(employee, "project_name", "projectName", "company_name", "companyName"), + assignment == null ? "" : assignment.projectName(), + text(employee, "project_name", "projectName"), + project == null ? "" : project.name(), + dept == null ? "" : dept.projectName(), + text(employee, "company_name", "companyName"), company == null ? "" : company.name(), storeDisplayFields ? deptName : "", "组织架构" ); String personName = firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-"); - String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle", + String positionName = firstNonBlank( + assignment == null ? "" : assignment.positionName(), + text(employee, "project_position_name", "projectPositionName", "position_name", "positionName", "job_title", "jobTitle", "post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工"); - String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName)); + String positionLevel = firstNonBlank( + assignment == null ? "" : assignment.positionLevel(), + text(employee, "project_position_level", "projectPositionLevel", "position_level", "positionLevel", "job_level", "jobLevel"), + level(positionName) + ); LocalDate hireDate = parseDate(firstNonBlank( text(employee, "hire_date", "hireDate", "entry_date", "entryDate", "employment_date", "employmentDate"))); boolean suspectText = hasQuestionMark(projectName) @@ -443,16 +483,98 @@ public class AihrOrgSyncService { for (JsonNode item : items) { String id = firstNonBlank(text(item, "id", "department_id", "departmentId", "dept_id", "deptId")); if (!id.isBlank()) { - map.put(id, new DepartmentInfo( + DepartmentInfo info = new DepartmentInfo( firstNonBlank(text(item, "code", "department_code", "departmentCode", "dept_code", "deptCode"), id), firstNonBlank(text(item, "name", "department_name", "departmentName", "dept_name", "deptName"), id), - firstNonBlank(text(item, "company_id", "companyId"), "") - )); + firstNonBlank(text(item, "company_id", "companyId"), ""), + firstNonBlank(text(item, "project_id", "projectId"), ""), + firstNonBlank(text(item, "project_code", "projectCode"), ""), + firstNonBlank(text(item, "project_name", "projectName"), "") + ); + map.put(id, info); + putIfNotBlank(map, info.code(), info); } } return map; } + private Map projectMap(List items) { + Map map = new TreeMap<>(); + for (JsonNode item : items) { + String id = firstNonBlank(text(item, "id", "project_id", "projectId")); + if (!id.isBlank()) { + ProjectInfo info = new ProjectInfo( + firstNonBlank(text(item, "code", "project_code", "projectCode"), id), + firstNonBlank(text(item, "project_name", "projectName", "name"), id), + firstNonBlank(text(item, "company_id", "companyId"), ""), + firstNonBlank(text(item, "project_department_id", "projectDepartmentId"), "") + ); + map.put(id, info); + putIfNotBlank(map, info.code(), info); + } + } + return map; + } + + private Map assignmentMap(List items) { + Map map = new TreeMap<>(); + for (JsonNode item : items) { + if ("departed".equals(status(text(item, "status")))) { + continue; + } + String employeeId = firstNonBlank(text(item, "employee_id", "employeeId")); + String employeeNumber = firstNonBlank(text(item, "employee_number", "employeeNumber")); + AssignmentInfo info = new AssignmentInfo( + employeeId, + employeeNumber, + firstNonBlank(text(item, "employee_department_id", "employeeDepartmentId"), ""), + firstNonBlank(text(item, "employee_department_name", "employeeDepartmentName"), ""), + firstNonBlank(text(item, "project_id", "projectId"), ""), + firstNonBlank(text(item, "project_code", "projectCode"), ""), + firstNonBlank(text(item, "project_name", "projectName"), ""), + firstNonBlank(text(item, "project_department_id", "projectDepartmentId"), ""), + firstNonBlank(text(item, "project_department_name", "projectDepartmentName"), ""), + firstNonBlank(text(item, "project_position_name", "projectPositionName"), ""), + firstNonBlank(text(item, "project_position_level", "projectPositionLevel"), ""), + bool(item, "is_primary", "isPrimary") || "primary".equalsIgnoreCase(text(item, "assignment_type", "assignmentType")) + ); + putAssignment(map, employeeId, info); + putAssignment(map, employeeNumber, info); + } + return map; + } + + private static AssignmentInfo assignment(Map assignments, String... keys) { + for (String key : keys) { + AssignmentInfo info = assignments.get(clean(key)); + if (info != null) { + return info; + } + } + return null; + } + + private static void putAssignment(Map map, String key, AssignmentInfo info) { + String cleanKey = clean(key); + if (cleanKey.isBlank()) { + return; + } + AssignmentInfo current = map.get(cleanKey); + if (current == null || preferAssignment(info, current)) { + map.put(cleanKey, info); + } + } + + private static boolean preferAssignment(AssignmentInfo candidate, AssignmentInfo current) { + if (candidate.primary() != current.primary()) { + return candidate.primary(); + } + if (!candidate.positionLevel().isBlank() && current.positionLevel().isBlank()) { + return true; + } + return !candidate.projectCode().isBlank() && current.projectCode().isBlank(); + } + private String orgSnapshotWhere(String keyword, String projectCode, String positionLevel, String status, List args) { StringBuilder where = new StringBuilder(" where tenant_id = ?"); args.add(tenantId()); @@ -596,9 +718,25 @@ public class AihrOrgSyncService { private static String positionLevel(String rawLevel, String positionName) { String inferred = level(positionName); String level = clean(rawLevel); + String normalized = level.toLowerCase(); + if (normalized.equals("manager") || normalized.equals("project_manager") || normalized.equals("project-manager") + || normalized.equals("pm")) { + return "项目经理"; + } + if (normalized.equals("foreman") || normalized.equals("supervisor") || normalized.equals("leader") + || normalized.equals("team_leader") || normalized.equals("team-leader")) { + return "主管"; + } + if (normalized.equals("worker") || normalized.equals("staff") || normalized.equals("employee") + || normalized.equals("frontline") || normalized.equals("front-line")) { + return "一线".equals(inferred) ? "一线" : inferred; + } if (level.isBlank() || (("一线".equals(level) || "员工".equals(level)) && !"一线".equals(inferred))) { return inferred; } + if (level.matches("\\d+")) { + return inferred; + } return level; } @@ -654,6 +792,29 @@ public class AihrOrgSyncService { } } + private static void putIfNotBlank(Map map, String key, T value) { + String clean = clean(key); + if (!clean.isBlank()) { + map.put(clean, value); + } + } + + private static boolean bool(JsonNode node, String... fields) { + for (String field : fields) { + JsonNode child = node.path(field); + if (child.isBoolean()) { + return child.asBoolean(); + } + if (child.isValueNode() && !child.isNull()) { + String value = clean(child.asText()).toLowerCase(); + if (value.equals("true") || value.equals("1") || value.equals("yes")) { + return true; + } + } + } + return false; + } + private static int clamp(Integer value, int fallback, int min, int max) { int result = value == null ? fallback : value; return Math.max(min, Math.min(max, result)); @@ -737,7 +898,27 @@ public class AihrOrgSyncService { private record CompanyInfo(String code, String name) { } - private record DepartmentInfo(String code, String name, String companyId) { + private record DepartmentInfo(String code, String name, String companyId, String projectId, String projectCode, + String projectName) { + } + + private record ProjectInfo(String code, String name, String companyId, String departmentId) { + } + + private record AssignmentInfo( + String employeeId, + String employeeNumber, + String employeeDepartmentId, + String employeeDepartmentName, + String projectId, + String projectCode, + String projectName, + String projectDepartmentId, + String projectDepartmentName, + String positionName, + String positionLevel, + boolean primary + ) { } private record OrgRow( diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 555549e5..823919a3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -20,8 +20,12 @@ import org.dromara.aihr.domain.AihrPracticeDto.GrowthStageResponse; import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse; import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchItemResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; @@ -48,6 +52,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.StartRequest; import org.dromara.aihr.domain.AihrPracticeDto.StartResponse; import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest; import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse; +import org.dromara.aihr.learning.AihrLearningService; import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse; import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn; import org.dromara.aihr.service.AihrPracticeLlmService.DailyDrillAssessment; @@ -57,6 +62,7 @@ import org.dromara.system.service.ISysOssService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataAccessException; +import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -69,6 +75,7 @@ import java.sql.SQLException; import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -81,6 +88,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; /** * 三角色对练编排器。seed 剧本承担剧情锚点与兜底;配置了 chat 模型时, @@ -100,6 +108,7 @@ public class AihrPracticeSeedService { "professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。" ); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm"); + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); // 阶段一只统计“员工收到业主话术后到提交下一轮”的陪练响应时长;正式首响/工单处理时效仍需业务系统数据。 private static final long RESPONSE_FAST_MS = 5_000L; private static final long RESPONSE_ACCEPTABLE_MS = 10_000L; @@ -248,6 +257,7 @@ public class AihrPracticeSeedService { private volatile boolean promptTemplateTableReady; private volatile boolean annotationTableReady; private volatile boolean audioTableReady; + private final TransactionTemplate assignmentTransaction; @Autowired public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService, @@ -273,6 +283,7 @@ public class AihrPracticeSeedService { this.objectMapper = objectMapper; this.jdbcTemplate = jdbcTemplate; this.practiceLlmService = practiceLlmService; + this.assignmentTransaction = configureAssignmentTransaction(pilotExportTransaction); this.sopSeedService = sopSeedService; this.pilotExportTransaction = configurePilotExportTransaction(pilotExportTransaction); this.ossService = ossService; @@ -1106,10 +1117,14 @@ public class AihrPracticeSeedService { String identityExpression = hasOrgSnapshot ? teamCanonicalIdentityExpression("a", hasPhone) : "a.ext_party_id"; - String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name"; + String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name"; String sql = """ SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time, - answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode + answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode, due_date, + content_version, question_snapshot, reference_answer_snapshot, content_hash, + (SELECT f.verdict FROM aihr_practice_question_feedback f + WHERE f.tenant_id = a.tenant_id AND f.assignment_id = a.id AND f.ext_party_id = a.ext_party_id + LIMIT 1) AS feedback_verdict FROM aihr_practice_assignment a %s ORDER BY a.create_time DESC, a.id DESC @@ -1386,6 +1401,18 @@ public class AihrPracticeSeedService { return transactionTemplate; } + private static TransactionTemplate configureAssignmentTransaction(TransactionTemplate transactionTemplate) { + if (transactionTemplate == null || transactionTemplate.getTransactionManager() == null) { + return null; + } + TransactionTemplate result = new TransactionTemplate(transactionTemplate.getTransactionManager()); + // A batch may legitimately contain one invalid/stale employee. Each employee is + // therefore committed independently so one failure never rolls back another's task. + result.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + result.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + return result; + } + private int countCompletedPilotPeople() { ensurePracticeTable(); return count(""" @@ -1464,7 +1491,7 @@ public class AihrPracticeSeedService { if (id == null) { return null; } - String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name"; + String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name"; List rows = jdbcTemplate.query(""" SELECT id, session_id, finished_time, %s AS trainee_name, scenario_id, scenario_name, total_score, status, summary, mentor_rewrite, ai_comment, review_advice, incentive_point, @@ -1607,14 +1634,20 @@ public class AihrPracticeSeedService { if (isBlank(ownerIdentity) || ownerUserId == null || ownerUserId <= 0) { throw new ServiceException("录音归属校验失败,请重新录音后提交"); } - Integer count = jdbcTemplate.queryForObject(""" - SELECT COUNT(*) - FROM sys_oss - WHERE tenant_id = ? AND oss_id = ? AND create_by = ? - """, Integer.class, tenantId(), ossId, ownerUserId); - if (count == null || count == 0) { - throw new ServiceException("录音不属于当前账号,请重新录音后提交"); + try { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM aihr_practice_audio_upload + WHERE tenant_id = ? AND oss_id = ? AND owner_user_id = ? AND owner_identity = ? + AND status IN ('staged', 'bound') + """, Integer.class, tenantId(), ossId, ownerUserId, ownerIdentity.trim()); + if (count != null && count > 0) { + return; + } + } catch (DataAccessException ignored) { + // Keep the ownership failure generic; do not leak database details. } + throw new ServiceException("录音不属于当前账号,请重新录音后提交"); } @Transactional(rollbackFor = Exception.class) @@ -2023,20 +2056,29 @@ public class AihrPracticeSeedService { String party = isBlank(extPartyId) ? "" : extPartyId.trim(); ensureDailyDrills(party); String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name"; - return jdbcTemplate.query(""" + int resultLimit = normalizeLimit(limit); + List rows = jdbcTemplate.query(""" SELECT id, ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time, - answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode + answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode, due_date, + content_version, question_snapshot, reference_answer_snapshot, content_hash, + (SELECT f.verdict FROM aihr_practice_question_feedback f + WHERE f.tenant_id = aihr_practice_assignment.tenant_id + AND f.assignment_id = aihr_practice_assignment.id + AND f.ext_party_id = aihr_practice_assignment.ext_party_id + LIMIT 1) AS feedback_verdict FROM aihr_practice_assignment WHERE tenant_id = ? AND (? = '' OR ext_party_id = ?) AND ((source <> 'daily' AND status = '待训练') OR (source = 'daily' AND DATE(create_time) = CURRENT_DATE())) ORDER BY CASE WHEN source = 'daily' THEN 1 ELSE 0 END, + CASE WHEN source = 'daily' AND request_key LIKE 'daily:%%' THEN 0 ELSE 1 END, CASE WHEN source = 'daily' THEN id ELSE NULL END ASC, create_time DESC, id DESC LIMIT ? - """.formatted(nameExpression), this::mapAssignment, tenantId(), party, party, normalizeLimit(limit)); + """.formatted(nameExpression), this::mapAssignment, tenantId(), party, party, assignmentQueryLimit(resultLimit)); + return visibleAssignments(rows, resultLimit); } /** @@ -2182,68 +2224,145 @@ public class AihrPracticeSeedService { List args = new ArrayList<>(); args.add(tenantId()); args.addAll(scope.extPartyIds()); - args.add(normalizeLimit(limit)); + int resultLimit = normalizeLimit(limit); + args.add(assignmentQueryLimit(resultLimit)); boolean hasOrgSnapshot = orgSnapshotTableExists(); boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone"); String identityExpression = hasOrgSnapshot ? teamCanonicalIdentityExpression("a", hasPhone) : "a.ext_party_id"; String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name"; - return jdbcTemplate.query(""" + List rows = jdbcTemplate.query(""" SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time, - answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode + answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode, due_date, + content_version, question_snapshot, reference_answer_snapshot, content_hash, + (SELECT f.verdict FROM aihr_practice_question_feedback f + WHERE f.tenant_id = a.tenant_id AND f.assignment_id = a.id AND f.ext_party_id = a.ext_party_id + LIMIT 1) AS feedback_verdict FROM aihr_practice_assignment a WHERE a.tenant_id = ? AND a.ext_party_id IN (%s) AND ((a.source <> 'daily' AND a.status = '待训练') OR (a.source = 'daily' AND DATE(a.create_time) = CURRENT_DATE())) ORDER BY CASE WHEN a.source = 'daily' THEN 1 ELSE 0 END, + CASE WHEN a.source = 'daily' AND a.request_key LIKE 'daily:%%' THEN 0 ELSE 1 END, CASE WHEN a.source = 'daily' THEN a.id ELSE NULL END ASC, a.create_time DESC, a.id DESC LIMIT ? """.formatted(identityExpression, nameExpression, inClause(scope.extPartyIds().size())), this::mapAssignment, args.toArray()); + return visibleAssignments(rows, resultLimit); } private void ensureDailyDrills(String extPartyId) { if (isBlank(extPartyId) || !dailyDrillEligible(extPartyId)) { return; } - int existingToday = count(""" - SELECT COUNT(*) + Set existingQuestions = new LinkedHashSet<>(jdbcTemplate.query(""" + SELECT scenario_id, reason FROM aihr_practice_assignment WHERE tenant_id = ? AND ext_party_id = ? AND source = 'daily' AND DATE(create_time) = CURRENT_DATE() - """, tenantId(), extPartyId); - if (existingToday >= 3) { - return; - } - List drills = dailyDrills(); - int start = Math.max(0, existingToday); - for (int i = start; i < drills.size(); i++) { + ORDER BY id + """, (rs, rowNum) -> dailyDrillParts(rs.getString("reason"), rs.getString("scenario_id")).question(), + tenantId(), extPartyId)); + LocalDate today = businessToday(); + List drills = dailyDrills(today, dailyDrillPosition(extPartyId), extPartyId); + for (int i : missingDailyDrillSlots(drills, existingQuestions)) { DailyDrillSeed drill = drills.get(i); ScenarioSeed scenario = resolveScenario(drill.scenarioId(), null); - LocalDateTime now = LocalDateTime.now(); - jdbcTemplate.update(""" - INSERT INTO aihr_practice_assignment - (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time) - VALUES (?, ?, ?, ?, ?, 'daily', ?, '待训练', ?, ?) - """, - tenantId(), - extPartyId, - persistedTraineeName(extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId), - scenario.id(), - scenario.name(), - drill.question(), - Timestamp.valueOf(now), - Timestamp.valueOf(now) - ); + String contentVersion = "daily-v1"; + String contentHash = AihrLearningService.contentHash(contentVersion, drill.question(), drill.referenceAnswer()); + if (dailyQuestionRecentlyUsed(extPartyId, contentHash)) { + continue; + } + LocalDateTime now = businessNow(); + try { + jdbcTemplate.update(""" + INSERT INTO aihr_practice_assignment + (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, + request_key, content_version, question_snapshot, reference_answer_snapshot, content_hash, + create_time, update_time) + VALUES (?, ?, ?, ?, ?, 'daily', ?, '待训练', ?, ?, ?, ?, ?, ?, ?) + """, + tenantId(), + extPartyId, + persistedTraineeName(extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId), + scenario.id(), + scenario.name(), + dailyDrillReason(drill), + dailyRequestKey(today, i), + contentVersion, + drill.question(), + drill.referenceAnswer(), + contentHash, + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ); + } catch (DuplicateKeyException ignored) { + // Concurrent page loads may race after the initial count. The daily + // request key makes each employee/date/slot exactly-once. + } + existingQuestions.add(drill.question()); + } + } + + private boolean dailyQuestionRecentlyUsed(String extPartyId, String contentHash) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM aihr_practice_assignment + WHERE tenant_id = ? AND ext_party_id = ? AND source = 'daily' AND content_hash = ? + AND create_time >= DATE_SUB(CURRENT_DATE(), INTERVAL 6 DAY) + """, Integer.class, tenantId(), extPartyId, contentHash); + return count != null && count > 0; + } + + static List missingDailyDrillSlots(List drills, Set existingQuestions) { + Set existing = Optional.ofNullable(existingQuestions).orElse(Set.of()); + List missing = new ArrayList<>(); + List safeDrills = Optional.ofNullable(drills).orElse(List.of()); + for (int index = 0; index < safeDrills.size(); index++) { + if (!existing.contains(safeDrills.get(index).question())) { + missing.add(index); + } + } + return List.copyOf(missing); + } + + private String dailyDrillPosition(String extPartyId) { + if (isBlank(extPartyId) || !orgSnapshotTableExists() || !orgSnapshotColumnExists("position_name")) { + return "生活顾问"; + } + try { + List positions = jdbcTemplate.query(""" + SELECT position_name + FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' + AND (ext_party_id = ? OR person_phone = ?) + AND position_name IS NOT NULL AND position_name <> '' + ORDER BY snapshot_date DESC, id DESC + LIMIT 1 + """, (rs, rowNum) -> rs.getString("position_name"), tenantId(), extPartyId, extPartyId); + return positions.stream().filter(position -> !isBlank(position)).findFirst().orElse("生活顾问"); + } catch (DataAccessException ignored) { + return "生活顾问"; + } + } + + private String scenarioContentVersion(String scenarioId) { + try { + List versions = jdbcTemplate.query(""" + SELECT content_version FROM aihr_practice_scenario + WHERE tenant_id = ? AND scenario_code = ? AND enabled = 1 LIMIT 1 + """, (rs, rowNum) -> rs.getString("content_version"), tenantId(), scenarioId); + return versions.stream().filter(version -> !isBlank(version)).findFirst().orElse("v1"); + } catch (DataAccessException ignored) { + return "v1"; } } private boolean dailyDrillEligible(String extPartyId) { if (orgSnapshotTableExists() && orgSnapshotColumnExists("hire_date")) { return findHireDate(extPartyId) - .map(hireDate -> isWithinNewHireWindow(hireDate, LocalDate.now())) + .map(hireDate -> isWithinNewHireWindow(hireDate, businessToday())) .orElseGet(() -> isLegacyDailyDrillEligible( isLegacyDailyDrillFallbackEnabled(allowLegacyDailyDrillFallback, activeProfiles), extPartyId, @@ -2298,44 +2417,184 @@ public class AihrPracticeSeedService { } public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request) { + return createAssignmentInternal(request).assignment(); + } + + public PracticeAssignmentBatchResponse createAssignments(PracticeAssignmentBatchRequest request, String supervisorExtPartyId) { + String requestId = normalizeBatchRequestId(request == null ? null : request.requestId()); + List targets = request == null || request.targets() == null + ? List.of() + : request.targets(); + if (targets.isEmpty()) { + throw new ServiceException("请至少选择一名员工"); + } + if (targets.size() > 100) { + throw new ServiceException("单次最多派发100名员工"); + } + String scenarioId = firstNonBlank(request.scenarioId(), "fee-parking"); + ensureScenarioEnabledForStart(scenarioId); + ScenarioSeed batchScenario = resolveScenario(scenarioId, null); + LocalDate dueDate = normalizeAssignmentDueDate(request.dueDate(), businessToday()); + String reason = firstNonBlank(request.reason(), "主管派发专项训练"); + // Runtime schema fallback may execute DDL (and MySQL DDL commits implicitly), so + // prepare it once before the per-employee REQUIRES_NEW transactions begin. + ensureAssignmentTable(); + TeamScope scope = teamScope(supervisorExtPartyId); + Set scopedExtPartyIds = new LinkedHashSet<>(scope.extPartyIds()); + List requestedExtPartyIds = targets.stream() + .map(target -> firstNonBlank(target == null ? null : target.extPartyId(), "")) + .filter(extPartyId -> !isBlank(extPartyId)) + .distinct() + .toList(); + AssignmentStorageIdentityResolution storageResolution = assignmentStorageIdentities(requestedExtPartyIds); + Map storageIdentities = storageResolution.identities(); + Set seen = new LinkedHashSet<>(); + List results = new ArrayList<>(); + int created = 0; + int duplicate = 0; + int failed = 0; + for (PracticeAssignmentTarget target : targets) { + String extPartyId = firstNonBlank(target == null ? null : target.extPartyId(), ""); + String traineeName = firstNonBlank(target == null ? null : target.traineeName(), extPartyId); + if (isBlank(extPartyId)) { + results.add(new PracticeAssignmentBatchItemResponse("", traineeName, "FAILED", "缺少员工ID", null)); + failed++; + continue; + } + if (!seen.add(extPartyId)) { + results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "DUPLICATE", "本次名单中已包含该员工", null)); + duplicate++; + continue; + } + if (scope.scoped() && !scopedExtPartyIds.contains(extPartyId.trim())) { + results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED", "不在当前项目的在职名单中", null)); + failed++; + continue; + } + if (storageResolution.ambiguousExtPartyIds().contains(extPartyId.trim())) { + results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED", + "该手机号对应多名在职员工,请先修正组织通讯录后再派发", null)); + failed++; + continue; + } + PracticeAssignmentRequest itemRequest = new PracticeAssignmentRequest( + extPartyId, + traineeName, + scenarioId, + null, + "manual", + reason, + dueDate.toString(), + requestId + ); + try { + String storedExtPartyId = storageIdentities.getOrDefault(extPartyId, extPartyId); + AssignmentCreation outcome = inAssignmentTransaction( + () -> createAssignmentInternal(itemRequest, batchScenario, storedExtPartyId)); + String status = outcome.duplicate() ? "DUPLICATE" : "CREATED"; + String message = outcome.duplicate() ? "该批次已派发,无需重复创建" : "派发成功"; + results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, status, message, outcome.assignment())); + if (outcome.duplicate()) { + duplicate++; + } else { + created++; + } + } catch (ServiceException ex) { + results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED", ex.getMessage(), null)); + failed++; + } catch (RuntimeException ex) { + log.warn("batch practice assignment failed for request {} target {}", requestId, extPartyId, ex); + results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED", "派发失败,请稍后重试", null)); + failed++; + } + } + return new PracticeAssignmentBatchResponse(requestId, results.size(), created, duplicate, failed, results); + } + + private AssignmentCreation createAssignmentInternal(PracticeAssignmentRequest request) { + return createAssignmentInternal(request, null, null); + } + + private AssignmentCreation createAssignmentInternal(PracticeAssignmentRequest request, ScenarioSeed preResolvedScenario, + String preResolvedStorageIdentity) { String extPartyId = request == null ? "" : firstNonBlank(request.extPartyId(), ""); if (isBlank(extPartyId)) { throw new ServiceException("缺少员工ID,无法派发专项训练"); } - String storedExtPartyId = assignmentStorageIdentity(extPartyId); + String storedExtPartyId = isBlank(preResolvedStorageIdentity) + ? assignmentStorageIdentity(extPartyId) + : preResolvedStorageIdentity; ensureAssignmentTable(); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = businessNow(); String scenarioId = firstNonBlank(request == null ? null : request.scenarioId(), "fee-parking"); - ensureScenarioEnabledForStart(scenarioId); - ScenarioSeed scenario = resolveScenario(scenarioId, null); + ScenarioSeed scenario = preResolvedScenario; + if (scenario == null || !scenarioId.equals(scenario.id())) { + ensureScenarioEnabledForStart(scenarioId); + scenario = resolveScenario(scenarioId, null); + } String traineeName = firstNonBlank(request == null ? null : request.traineeName(), extPartyId); String storedTraineeName = storeDisplayFields ? traineeName : ""; // Assignment source is a server-owned classification. Client supplied values // must not turn a manual supervisor assignment into a daily/retry/camp record. String source = "manual"; String reason = firstNonBlank(request == null ? null : request.reason(), "主管派发专项训练"); - Timestamp createdAt = Timestamp.valueOf(now); - jdbcTemplate.update(""" - INSERT INTO aihr_practice_assignment - (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time) - VALUES (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?) - """, tenantId(), storedExtPartyId, storedTraineeName, scenario.id(), scenario.name(), source, reason, createdAt, createdAt); - List rows = jdbcTemplate.query(""" - SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, - answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode - FROM aihr_practice_assignment - WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ? - ORDER BY id DESC - LIMIT 1 - """, this::mapAssignment, tenantId(), storedExtPartyId, scenario.id(), source, reason); - if (rows.isEmpty()) { - return new PracticeAssignmentResponse(null, extPartyId, storeDisplayFields ? traineeName : "员工", - scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null, null, null); + LocalDate dueDate = normalizeAssignmentDueDate(request == null ? null : request.dueDate(), businessToday()); + String requestId = normalizeOptionalRequestId(request == null ? null : request.requestId()); + String contentVersion = scenarioContentVersion(scenario.id()); + String questionSnapshot = scenario.rounds().isEmpty() ? scenario.goal() : scenario.rounds().get(0).customer(); + String referenceAnswerSnapshot = scenario.rewrite(); + String contentHash = AihrLearningService.contentHash(contentVersion, questionSnapshot, referenceAnswerSnapshot); + if (requestId != null) { + PracticeAssignmentResponse existing = findAssignmentByRequest(requestId, storedExtPartyId, extPartyId); + if (existing != null) { + return new AssignmentCreation(existing, true); } - PracticeAssignmentResponse row = rows.get(0); - return new PracticeAssignmentResponse(row.id(), extPartyId, storeDisplayFields ? row.traineeName() : "员工", - row.scenarioId(), row.scenarioName(), row.source(), row.reason(), row.status(), row.createTime(), row.question(), - row.referenceAnswer(), row.userAnswer(), row.feedback(), row.correct(), row.score(), row.scoreMode(), row.audioUrl(), row.audioOssId()); + } + Timestamp createdAt = Timestamp.valueOf(now); + try { + jdbcTemplate.update(""" + INSERT INTO aihr_practice_assignment + (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, + due_date, request_key, content_version, question_snapshot, reference_answer_snapshot, content_hash, + create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?, ?, ?, ?, ?, ?, ?) + """, tenantId(), storedExtPartyId, storedTraineeName, scenario.id(), scenario.name(), source, reason, + java.sql.Date.valueOf(dueDate), requestId, contentVersion, questionSnapshot, referenceAnswerSnapshot, + contentHash, createdAt, createdAt); + } catch (DuplicateKeyException ex) { + PracticeAssignmentResponse existing = findAssignmentByRequest(requestId, storedExtPartyId, extPartyId); + if (existing != null) { + return new AssignmentCreation(existing, true); + } + throw ex; + } + List rows; + if (requestId == null) { + rows = jdbcTemplate.query(""" + SELECT a.id, a.ext_party_id, a.trainee_name, a.scenario_id, a.scenario_name, a.source, a.reason, a.status, a.create_time, + a.answer_text, a.answer_audio_url, a.answer_audio_oss_id, a.feedback, a.correct, a.score, a.score_mode, a.due_date, + a.content_version, a.question_snapshot, a.reference_answer_snapshot, a.content_hash, + (SELECT f.verdict FROM aihr_practice_question_feedback f + WHERE f.tenant_id = a.tenant_id AND f.assignment_id = a.id AND f.ext_party_id = a.ext_party_id + LIMIT 1) AS feedback_verdict + FROM aihr_practice_assignment a + WHERE a.tenant_id = ? AND a.ext_party_id = ? AND a.scenario_id = ? AND a.source = ? AND a.reason = ? + ORDER BY a.id DESC + LIMIT 1 + """, this::mapAssignment, tenantId(), storedExtPartyId, scenario.id(), source, reason); + } else { + PracticeAssignmentResponse requested = findAssignmentByRequest(requestId, storedExtPartyId, extPartyId); + rows = requested == null ? List.of() : List.of(requested); + } + PracticeAssignmentResponse row = rows.stream().filter(java.util.Objects::nonNull).findFirst().orElse(null); + if (row == null) { + row = new PracticeAssignmentResponse(null, extPartyId, storeDisplayFields ? traineeName : "员工", + scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, + null, null, null, null, null, null, dueDate.toString()); + } else { + row = assignmentWithRequestedIdentity(row, extPartyId); + } + return new AssignmentCreation(row, false); } public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) { @@ -2346,20 +2605,138 @@ public class AihrPracticeSeedService { return createAssignment(request); } - private String assignmentStorageIdentity(String extPartyId) { - if (isBlank(extPartyId) || jdbcTemplate == null || !orgSnapshotTableExists() - || !orgSnapshotColumnExists("person_phone")) { - return extPartyId; + private PracticeAssignmentResponse findAssignmentByRequest(String requestId, String storedExtPartyId, + String requestedExtPartyId) { + if (isBlank(requestId)) { + return null; } - List phones = jdbcTemplate.query(""" - SELECT person_phone - FROM aihr_org_snapshot - WHERE tenant_id = ? AND ext_party_id = ? AND employment_status = 'active' - AND person_phone IS NOT NULL AND person_phone <> '' - ORDER BY id + List rows = jdbcTemplate.query(""" + SELECT a.id, a.ext_party_id, a.trainee_name, a.scenario_id, a.scenario_name, a.source, a.reason, a.status, a.create_time, + a.answer_text, a.answer_audio_url, a.answer_audio_oss_id, a.feedback, a.correct, a.score, a.score_mode, a.due_date, + a.content_version, a.question_snapshot, a.reference_answer_snapshot, a.content_hash, + (SELECT f.verdict FROM aihr_practice_question_feedback f + WHERE f.tenant_id = a.tenant_id AND f.assignment_id = a.id AND f.ext_party_id = a.ext_party_id + LIMIT 1) AS feedback_verdict + FROM aihr_practice_assignment a + WHERE a.tenant_id = ? AND a.request_key = ? AND a.ext_party_id = ? + ORDER BY a.id DESC LIMIT 1 - """, (rs, rowNum) -> rs.getString("person_phone"), tenantId(), extPartyId.trim()); - return phones.isEmpty() ? extPartyId.trim() : phones.get(0).trim(); + """, this::mapAssignment, tenantId(), requestId, storedExtPartyId); + return rows.isEmpty() ? null : assignmentWithRequestedIdentity(rows.get(0), requestedExtPartyId); + } + + private PracticeAssignmentResponse assignmentWithRequestedIdentity(PracticeAssignmentResponse row, + String extPartyId) { + return new PracticeAssignmentResponse(row.id(), extPartyId, storeDisplayFields ? row.traineeName() : "员工", + row.scenarioId(), row.scenarioName(), row.source(), row.reason(), row.status(), row.createTime(), row.question(), + row.referenceAnswer(), row.userAnswer(), row.feedback(), row.correct(), row.score(), row.scoreMode(), row.audioUrl(), + row.audioOssId(), row.dueDate(), row.contentVersion(), row.contentHash(), row.feedbackVerdict()); + } + + private T inAssignmentTransaction(Supplier work) { + if (assignmentTransaction == null) { + return work.get(); + } + return assignmentTransaction.execute(status -> work.get()); + } + + static LocalDate normalizeAssignmentDueDate(String value, LocalDate today) { + LocalDate base = today == null ? businessToday() : today; + if (value == null || value.isBlank()) { + return base.plusDays(1); + } + try { + LocalDate dueDate = LocalDate.parse(value.trim()); + if (dueDate.isBefore(base)) { + throw new ServiceException("截止日期不能早于今天"); + } + return dueDate; + } catch (java.time.format.DateTimeParseException ex) { + throw new ServiceException("截止日期格式应为YYYY-MM-DD"); + } + } + + private static String normalizeBatchRequestId(String value) { + String requestId = normalizeOptionalRequestId(value); + if (requestId == null) { + throw new ServiceException("缺少批次请求ID"); + } + return requestId; + } + + private static String normalizeOptionalRequestId(String value) { + if (value == null || value.isBlank()) { + return null; + } + String requestId = value.trim(); + if (requestId.length() > 100 || !requestId.matches("[A-Za-z0-9._:-]+")) { + throw new ServiceException("批次请求ID格式不正确"); + } + if (requestId.regionMatches(true, 0, "daily:", 0, "daily:".length())) { + throw new ServiceException("批次请求ID不能使用系统保留前缀"); + } + return requestId; + } + + private String assignmentStorageIdentity(String extPartyId) { + AssignmentStorageIdentityResolution resolution = assignmentStorageIdentities(List.of(extPartyId)); + String normalized = isBlank(extPartyId) ? "" : extPartyId.trim(); + if (resolution.ambiguousExtPartyIds().contains(normalized)) { + throw new ServiceException("该手机号对应多名在职员工,请先修正组织通讯录后再派发"); + } + return resolution.identities().getOrDefault(normalized, normalized); + } + + private AssignmentStorageIdentityResolution assignmentStorageIdentities(List extPartyIds) { + Map identities = new LinkedHashMap<>(); + extPartyIds.stream() + .filter(extPartyId -> !isBlank(extPartyId)) + .map(String::trim) + .distinct() + .forEach(extPartyId -> identities.put(extPartyId, extPartyId)); + if (identities.isEmpty() || jdbcTemplate == null || !orgSnapshotTableExists() + || !orgSnapshotColumnExists("person_phone")) { + return new AssignmentStorageIdentityResolution(Map.copyOf(identities), Set.of()); + } + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(identities.keySet()); + args.addAll(identities.keySet()); + List rows = jdbcTemplate.query(""" + SELECT o.ext_party_id, o.person_phone, + (SELECT COUNT(DISTINCT x.ext_party_id) + FROM aihr_org_snapshot x + WHERE x.tenant_id = o.tenant_id AND x.employment_status = 'active' + AND x.person_phone = o.person_phone) AS phone_owner_count + FROM aihr_org_snapshot o + WHERE o.tenant_id = ? + AND (o.ext_party_id IN (%s) OR o.person_phone IN (%s)) + AND o.employment_status = 'active' + AND o.person_phone IS NOT NULL AND o.person_phone <> '' + ORDER BY o.id + """.formatted(inClause(identities.size()), inClause(identities.size())), + (rs, rowNum) -> new OrgIdentityRow(rs.getString("ext_party_id"), rs.getString("person_phone"), + rs.getInt("phone_owner_count")), + args.toArray()); + Set ambiguousExtPartyIds = new LinkedHashSet<>(); + for (OrgIdentityRow row : rows) { + if (!isBlank(row.extPartyId()) && !isBlank(row.personPhone())) { + String normalizedExtPartyId = row.extPartyId().trim(); + if (row.phoneOwnerCount() > 1) { + if (identities.containsKey(normalizedExtPartyId)) { + ambiguousExtPartyIds.add(normalizedExtPartyId); + } + String normalizedPhone = row.personPhone().trim(); + if (identities.containsKey(normalizedPhone)) { + ambiguousExtPartyIds.add(normalizedPhone); + } + } else { + identities.computeIfPresent(normalizedExtPartyId, (ignored, current) -> + current.equals(normalizedExtPartyId) ? row.personPhone().trim() : current); + } + } + } + return new AssignmentStorageIdentityResolution(Map.copyOf(identities), Set.copyOf(ambiguousExtPartyIds)); } private void markAssignmentCompleted(Long requestAssignmentId, ActiveSession activeSession) { @@ -2402,7 +2779,7 @@ public class AihrPracticeSeedService { } String party = isBlank(extPartyId) ? "" : extPartyId.trim(); List> rows = jdbcTemplate.queryForList(""" - SELECT id, reason, source, status, scenario_id + SELECT id, reason, source, status, scenario_id, question_snapshot, reference_answer_snapshot FROM aihr_practice_assignment WHERE tenant_id = ? AND id = ? AND source = 'daily' AND DATE(create_time) = CURRENT_DATE() @@ -2413,7 +2790,13 @@ public class AihrPracticeSeedService { if (rows.isEmpty()) { return null; } - DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id")); + DailyDrillParts legacyParts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id")); + String questionSnapshot = (String) rows.get(0).get("question_snapshot"); + String referenceSnapshot = (String) rows.get(0).get("reference_answer_snapshot"); + DailyDrillParts parts = new DailyDrillParts( + firstNonBlank(questionSnapshot, legacyParts.question()), + firstNonBlank(referenceSnapshot, legacyParts.referenceAnswer()) + ); String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), "")); String audioUrl = exposedAudioUrl(request == null ? null : request.audioUrl()); Long audioOssId = request == null ? null : request.audioOssId(); @@ -2713,10 +3096,15 @@ public class AihrPracticeSeedService { return; } LocalDateTime now = LocalDateTime.now(); + String contentVersion = scenarioContentVersion(scenario.id()); + String questionSnapshot = scenario.rounds().isEmpty() ? scenario.goal() : scenario.rounds().get(0).customer(); + String referenceAnswerSnapshot = scenario.rewrite(); + String contentHash = AihrLearningService.contentHash(contentVersion, questionSnapshot, referenceAnswerSnapshot); jdbcTemplate.update(""" INSERT INTO aihr_practice_assignment - (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time) - VALUES (?, ?, ?, ?, ?, 'retry', ?, '待训练', ?, ?) + (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, + content_version, question_snapshot, reference_answer_snapshot, content_hash, create_time, update_time) + VALUES (?, ?, ?, ?, ?, 'retry', ?, '待训练', ?, ?, ?, ?, ?, ?) """, tenantId(), extPartyId, @@ -2724,6 +3112,10 @@ public class AihrPracticeSeedService { scenario.id(), scenario.name(), "本场得分 " + result.total() + ",自动生成错题重练", + contentVersion, + questionSnapshot, + referenceAnswerSnapshot, + contentHash, Timestamp.valueOf(now), Timestamp.valueOf(now) ); @@ -3040,6 +3432,8 @@ public class AihrPracticeSeedService { String reason = rs.getString("reason"); String status = rs.getString("status"); DailyDrillParts daily = dailyDrillParts(reason, rs.getString("scenario_id")); + String questionSnapshot = firstNonBlank(rs.getString("question_snapshot"), daily.question()); + String referenceSnapshot = firstNonBlank(rs.getString("reference_answer_snapshot"), daily.referenceAnswer()); Object correctValue = rs.getObject("correct"); return new PracticeAssignmentResponse( rs.getLong("id"), @@ -3051,33 +3445,141 @@ public class AihrPracticeSeedService { "daily".equals(source) ? daily.question() : reason, status, createTime == null ? "" : createTime.toLocalDateTime().format(TIME_FORMATTER), - "daily".equals(source) ? daily.question() : null, - "daily".equals(source) && "已完成".equals(status) ? daily.referenceAnswer() : null, + "daily".equals(source) ? questionSnapshot : rs.getString("question_snapshot"), + "已完成".equals(status) ? referenceSnapshot : null, rs.getString("answer_text"), "daily".equals(source) ? stripReferenceAnswer(rs.getString("feedback")) : rs.getString("feedback"), correctValue == null ? null : rs.getBoolean("correct"), rs.getObject("score") == null ? null : rs.getInt("score"), firstNonBlank(rs.getString("score_mode"), "RULE_FALLBACK"), exposedAudioUrl(rs.getString("answer_audio_url")), - rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id") + rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id"), + rs.getDate("due_date") == null ? null : rs.getDate("due_date").toLocalDate().toString(), + firstNonBlank(rs.getString("content_version"), "v1"), + rs.getString("content_hash"), + rs.getString("feedback_verdict") ); } - private List dailyDrills() { + static String dailyRequestKey(LocalDate date, int slotIndex) { + LocalDate safeDate = date == null ? LocalDate.ofEpochDay(0) : date; + return "daily:" + safeDate + ":" + (Math.max(0, slotIndex) + 1); + } + + static List visibleAssignments(List rows, int limit) { + int resultLimit = normalizeLimit(limit); + Map> dailyQuestionsByParty = new LinkedHashMap<>(); + List visible = new ArrayList<>(); + for (PracticeAssignmentResponse row : Optional.ofNullable(rows).orElse(List.of())) { + if (row == null) { + continue; + } + if ("daily".equals(row.source())) { + String party = firstNonBlankStatic(row.extPartyId(), ""); + Set questions = dailyQuestionsByParty.computeIfAbsent(party, ignored -> new LinkedHashSet<>()); + String question = firstNonBlankStatic(row.question(), row.reason()); + if (questions.size() >= 3 || !questions.add(question)) { + continue; + } + } + visible.add(row); + if (visible.size() >= resultLimit) { + break; + } + } + return List.copyOf(visible); + } + + private static int assignmentQueryLimit(int resultLimit) { + return Math.min(300, Math.max(3, normalizeLimit(resultLimit) * 3)); + } + + private static LocalDate businessToday() { + return LocalDate.now(BUSINESS_ZONE); + } + + private static LocalDateTime businessNow() { + return LocalDateTime.now(BUSINESS_ZONE); + } + + static List dailyDrills(LocalDate date, String position, String extPartyId) { + LocalDate safeDate = date == null ? LocalDate.ofEpochDay(0) : date; + String scope = firstNonBlankStatic(position, "生活顾问") + "|" + firstNonBlankStatic(extPartyId, "anonymous"); + List> packs = dailyDrillPacks(); + int packIndex = (int) Math.floorMod(safeDate.toEpochDay() + scope.hashCode(), packs.size()); + List pack = packs.get(packIndex); + List numbered = new ArrayList<>(pack.size()); + for (int i = 0; i < pack.size(); i++) { + DailyDrillSeed drill = pack.get(i); + numbered.add(new DailyDrillSeed( + drill.scenarioId(), + "每日三题 " + (i + 1) + ":" + drill.question(), + drill.referenceAnswer() + )); + } + return List.copyOf(numbered); + } + + private static List> dailyDrillPacks() { return List.of( - new DailyDrillSeed("complaint-water", "每日三题 1:业主投诉车库积水,第一句话先说什么?", "先承接情绪,再确认点位、责任人和首次反馈时间。"), - new DailyDrillSeed("fee-collection", "每日三题 2:业主拒缴物业费并质疑服务,如何拆分问题?", "把服务整改和费用沟通分开,说明复核、整改跟进和缴费节点。"), - new DailyDrillSeed("repair-leak", "每日三题 3:报修跟进超时,如何给下一步承诺?", "说明已核实的信息、责任人、到场或反馈时间,并主动同步变化。") + List.of( + new DailyDrillSeed("complaint-water", "业主投诉车库积水,第一句话先说什么?", "先承接情绪,再确认点位、责任人和首次反馈时间。"), + new DailyDrillSeed("fee-collection", "业主拒缴物业费并质疑服务,如何拆分问题?", "把服务整改和费用沟通分开,说明复核、整改跟进和缴费节点。"), + new DailyDrillSeed("repair-leak", "报修跟进超时,如何给下一步承诺?", "说明已核实的信息、责任人、到场或反馈时间,并主动同步变化。") + ), + List.of( + new DailyDrillSeed("complaint-water", "电梯故障时业主连续追问进度,你先确认哪三项信息?", "确认受影响楼栋和人员、维保责任人、预计恢复或下一次反馈时间。"), + new DailyDrillSeed("fee-collection", "业主认为收费项目看不懂,如何解释又不激化争议?", "先确认具体收费项,提供依据和明细,并约定复核责任人与反馈时间。"), + new DailyDrillSeed("complaint-water", "夜间噪声投诉暂时无法定位来源,如何回复?", "承接休息受影响的情绪,说明核查范围、现场责任人和阶段反馈时间。") + ), + List.of( + new DailyDrillSeed("complaint-water", "同一报修问题第二次复发,怎样避免只说“再看看”?", "说明历史处理情况,升级责任人,给出复查动作、完成节点和回访安排。"), + new DailyDrillSeed("fee-collection", "业主同意补缴但要求先看整改结果,如何推进?", "分别确认整改验收和缴费节点,记录双方约定并按时同步进展。"), + new DailyDrillSeed("complaint-water", "公共区域异味投诉多人同时反馈,如何组织首次回应?", "统一承接口径,确认点位和影响范围,明确排查责任人及首次通报时间。") + ), + List.of( + new DailyDrillSeed("complaint-water", "临时停水通知晚了,业主质疑物业失职,如何回应?", "先道歉并承接影响,说明已采取的供水保障、责任人和恢复或更新节点。"), + new DailyDrillSeed("fee-collection", "业主拿其他小区价格质疑物业费,如何避免争辩?", "确认对比口径,解释本项目收费依据和服务范围,并提供复核渠道与时间。"), + new DailyDrillSeed("complaint-water", "装修施工影响邻居休息,双方情绪都很高,先做什么?", "分别安抚并核实事实,说明管理规则,明确现场协调人和后续反馈节点。") + ), + List.of( + new DailyDrillSeed("complaint-water", "访客登记引发业主不满,怎样兼顾安全和体验?", "先解释安全目的,确认业主实际困难,提供合规替代方式并协助完成登记。"), + new DailyDrillSeed("fee-collection", "业主只愿意口头承诺缴费,如何形成可跟进的节点?", "确认金额和时间,复述双方约定,记录跟进责任人并提前提醒。"), + new DailyDrillSeed("complaint-water", "园区车辆被剐蹭但监控存在盲区,如何避免过度承诺?", "说明已核实范围和证据边界,协助报案或调取记录,明确后续联系人和反馈时间。") + ), + List.of( + new DailyDrillSeed("complaint-water", "独居老人多次未应门,邻居担心安全,如何处理?", "先核实紧急程度和可用联系人,按应急流程联动物业人员,并持续记录反馈。"), + new DailyDrillSeed("fee-collection", "业主投诉服务后要求立即减免物业费,如何回应?", "先登记和整改服务问题,再说明减免需按制度复核,明确受理人与答复时间。"), + new DailyDrillSeed("complaint-water", "儿童游乐设施被反映松动,现场还没有维修人员,怎么回复?", "立即隔离风险区域,通知维修责任人,说明到场时间并主动同步处理结果。") + ), + List.of( + new DailyDrillSeed("complaint-water", "暴雨预警后业主询问地下车库是否安全,怎样回答?", "说明巡检和防汛措施、重点点位责任人以及异常情况的通知和升级方式。"), + new DailyDrillSeed("fee-collection", "业主对历史欠费金额有异议,当前无法现场核清,怎么办?", "记录争议明细,暂停武断结论,约定账务复核责任人和书面反馈时间。"), + new DailyDrillSeed("complaint-water", "投诉处理完成后业主仍不满意,回访时要确认什么?", "确认问题是否真正解决、剩余影响和期望,说明补救动作、负责人及再次回访时间。") + ) ); } + private static String firstNonBlankStatic(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + + private static String dailyDrillReason(DailyDrillSeed drill) { + return drill.question() + " 参考答案:" + drill.referenceAnswer(); + } + + private static List dailyDrillBank() { + return dailyDrillPacks().stream().flatMap(List::stream).toList(); + } + private DailyDrillParts dailyDrillParts(String reason, String scenarioId) { String safeReason = firstNonBlank(reason, ""); String marker = "参考答案:"; int markerIndex = safeReason.indexOf(marker); if (markerIndex < 0) { - String reference = dailyDrills().stream() - .filter(drill -> drill.question().equals(safeReason) || drill.scenarioId().equals(scenarioId)) + String unnumberedReason = safeReason.replaceFirst("^每日三题\\s+\\d+:", ""); + String reference = dailyDrillBank().stream() + .filter(drill -> drill.question().equals(unnumberedReason)) .map(DailyDrillSeed::referenceAnswer) .findFirst() .orElse(""); @@ -3890,7 +4392,7 @@ public class AihrPracticeSeedService { return value == null ? fallback : value; } - private int normalizeLimit(int limit) { + private static int normalizeLimit(int limit) { if (limit <= 0) { return 5; } @@ -4021,6 +4523,12 @@ public class AihrPracticeSeedService { `source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual', `reason` varchar(500) DEFAULT NULL COMMENT '派发原因', `status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态', + `due_date` date DEFAULT NULL COMMENT '训练截止日期', + `request_key` varchar(100) DEFAULT NULL COMMENT '客户端幂等请求ID', + `content_version` varchar(30) DEFAULT 'v1' COMMENT '派发内容版本', + `question_snapshot` varchar(1000) DEFAULT NULL COMMENT '派发时题目快照', + `reference_answer_snapshot` varchar(2000) DEFAULT NULL COMMENT '派发时参考答案快照', + `content_hash` char(64) DEFAULT NULL COMMENT '版本化内容哈希', `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答', `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址', `answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号', @@ -4032,6 +4540,7 @@ public class AihrPracticeSeedService { `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_assignment_request` (`tenant_id`, `request_key`, `ext_party_id`), KEY `idx_aihr_assignment_party` (`tenant_id`, `ext_party_id`, `status`, `create_time`), KEY `idx_aihr_assignment_source` (`tenant_id`, `source`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务'; @@ -4044,6 +4553,16 @@ public class AihrPracticeSeedService { ensureColumn("aihr_practice_assignment", "score", "ALTER TABLE aihr_practice_assignment ADD COLUMN `score` int DEFAULT NULL COMMENT '每日三题得分' AFTER `correct`"); ensureColumn("aihr_practice_assignment", "score_mode", "ALTER TABLE aihr_practice_assignment ADD COLUMN `score_mode` varchar(30) DEFAULT NULL COMMENT '评分模式 LLM/RULE_FALLBACK' AFTER `score`"); ensureColumn("aihr_practice_assignment", "finished_time", "ALTER TABLE aihr_practice_assignment ADD COLUMN `finished_time` datetime DEFAULT NULL COMMENT '完成时间' AFTER `score_mode`"); + ensureColumn("aihr_practice_assignment", "due_date", "ALTER TABLE aihr_practice_assignment ADD COLUMN `due_date` date DEFAULT NULL COMMENT '训练截止日期' AFTER `status`"); + ensureColumn("aihr_practice_assignment", "request_key", "ALTER TABLE aihr_practice_assignment ADD COLUMN `request_key` varchar(100) DEFAULT NULL COMMENT '客户端幂等请求ID' AFTER `due_date`"); + ensureColumn("aihr_practice_assignment", "content_version", "ALTER TABLE aihr_practice_assignment ADD COLUMN `content_version` varchar(30) DEFAULT 'v1' COMMENT '派发内容版本' AFTER `request_key`"); + ensureColumn("aihr_practice_assignment", "question_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN `question_snapshot` varchar(1000) DEFAULT NULL COMMENT '派发时题目快照' AFTER `content_version`"); + ensureColumn("aihr_practice_assignment", "reference_answer_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN `reference_answer_snapshot` varchar(2000) DEFAULT NULL COMMENT '派发时参考答案快照' AFTER `question_snapshot`"); + ensureColumn("aihr_practice_assignment", "content_hash", "ALTER TABLE aihr_practice_assignment ADD COLUMN `content_hash` char(64) DEFAULT NULL COMMENT '版本化内容哈希' AFTER `reference_answer_snapshot`"); + ensureIndex("aihr_practice_assignment", "uk_aihr_assignment_request", + "ALTER TABLE aihr_practice_assignment ADD UNIQUE KEY `uk_aihr_assignment_request` (`tenant_id`, `request_key`, `ext_party_id`)"); + ensureIndex("aihr_practice_assignment", "idx_aihr_assignment_recent_content", + "ALTER TABLE aihr_practice_assignment ADD KEY `idx_aihr_assignment_recent_content` (`tenant_id`, `ext_party_id`, `source`, `content_hash`, `create_time`)"); assignmentTableReady = true; } } @@ -4282,6 +4801,17 @@ public class AihrPracticeSeedService { } } + private void ensureIndex(String table, String index, String ddl) { + Integer exists = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + """, Integer.class, table, index); + if (exists == null || exists == 0) { + jdbcTemplate.execute(ddl); + } + } + private boolean isBlank(String value) { return value == null || value.isBlank(); } @@ -4585,7 +5115,7 @@ public class AihrPracticeSeedService { private record ResolvedTurn(String customerText, Integer emotion, Integer trust, String coachHint, boolean redFlag) { } - private record DailyDrillSeed(String scenarioId, String question, String referenceAnswer) { + record DailyDrillSeed(String scenarioId, String question, String referenceAnswer) { } private record DailyDrillParts(String question, String referenceAnswer) { @@ -4594,12 +5124,22 @@ public class AihrPracticeSeedService { private record DailyDrillScore(boolean correct, int score, String feedback, String mode) { } + private record AssignmentCreation(PracticeAssignmentResponse assignment, boolean duplicate) { + } + private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) { } private record OrgScopeRow(String projectCode, String positionLevel) { } + private record OrgIdentityRow(String extPartyId, String personPhone, int phoneOwnerCount) { + } + + private record AssignmentStorageIdentityResolution(Map identities, + Set ambiguousExtPartyIds) { + } + private record TeamScope(boolean scoped, List extPartyIds) { static TeamScope unscoped() { return new TeamScope(false, List.of()); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 1c24bb0b..5d797731 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -36,6 +36,7 @@ import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse; import org.dromara.aihr.domain.AihrSopDto.UploadResponse; import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse; import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UnbindDocumentResponse; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.tenant.helper.TenantHelper; import org.apache.tika.metadata.Metadata; @@ -44,12 +45,19 @@ import org.apache.tika.parser.AutoDetectParser; import org.apache.tika.parser.ParseContext; import org.apache.tika.sax.BodyContentHandler; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.PreparedStatementCreator; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.multipart.MultipartFile; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.service.ISysOssService; @@ -124,14 +132,31 @@ public class AihrSopSeedService { private final ScheduledExecutorService scheduledExecutorService; private final AihrVideoService videoService; private final AihrModelSeedService modelService; + private final TransactionTemplate requiresNewTransaction; private volatile boolean knowledgeGapTableReady; private volatile boolean sopReviewTableReady; private volatile boolean answerFeedbackTableReady; + @Autowired public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService, @Value("${aihr.import.root:}") String importRootConfig, ScheduledExecutorService scheduledExecutorService, AihrVideoService videoService, - AihrModelSeedService modelService) { + AihrModelSeedService modelService, PlatformTransactionManager transactionManager) { + this(objectMapper, jdbcTemplate, ossService, importRootConfig, scheduledExecutorService, videoService, + modelService, new TransactionTemplate(transactionManager)); + } + + public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService, + String importRootConfig, ScheduledExecutorService scheduledExecutorService, + AihrVideoService videoService, AihrModelSeedService modelService) { + this(objectMapper, jdbcTemplate, ossService, importRootConfig, scheduledExecutorService, videoService, + modelService, (TransactionTemplate) null); + } + + AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService, + String importRootConfig, ScheduledExecutorService scheduledExecutorService, + AihrVideoService videoService, AihrModelSeedService modelService, + TransactionTemplate requiresNewTransaction) { this.objectMapper = objectMapper; this.jdbcTemplate = jdbcTemplate; this.ossService = ossService; @@ -139,6 +164,7 @@ public class AihrSopSeedService { this.scheduledExecutorService = scheduledExecutorService; this.videoService = videoService; this.modelService = modelService; + this.requiresNewTransaction = configureRequiresNewTransaction(requiresNewTransaction); } public SearchResponse search(SearchRequest request) { @@ -167,6 +193,25 @@ public class AihrSopSeedService { return withReviewId(noEvidenceResponse(queryText, category), source, requester); } + public SearchResponse searchAuthorized(SearchRequest request, String requesterExtPartyId, Set allowedKnowledgeIds) { + if (allowedKnowledgeIds == null || allowedKnowledgeIds.isEmpty()) { + throw new ServiceException("当前请求没有可访问的知识空间", 403); + } + String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim(); + String position = firstNonBlank(request == null ? null : request.position(), "生活顾问"); + String source = normalizeSearchSource(request == null ? null : request.source()); + String requester = isBlank(requesterExtPartyId) ? "" : requesterExtPartyId.trim(); + if (isBlank(queryText)) { + return withReviewId(noEvidenceResponse("", "sop"), source, requester); + } + SearchResponse response = dbSearch("", queryText, request == null ? null : request.limit(), allowedKnowledgeIds); + if (response == null || isNoEvidenceAnswer(response.answer())) { + recordKnowledgeGap(queryText, "sop", position, source); + response = noEvidenceResponse(queryText, "sop"); + } + return withReviewId(response, source, requester); + } + /** * 提供岗位学习导航摘要,不把它冒充成正式的上岗资格判断。 * 有正式岗位/SOP映射时按项目范围读取;没有映射或旧库尚未迁移时回退一期 seed。 @@ -403,7 +448,10 @@ public class AihrSopSeedService { return updated == 0 ? null : new AnswerFeedbackReviewResponse(id, "已复核", firstNonBlank(operator, "人工复核"), formatTimestamp(reviewedTime)); } - public SummaryCardResponse summaryCard(SummaryCardRequest request) { + public SummaryCardResponse summaryCardAuthorized(SummaryCardRequest request, Set allowedKnowledgeIds) { + if (allowedKnowledgeIds == null || allowedKnowledgeIds.isEmpty()) { + throw new ServiceException("当前请求没有可访问的知识空间", 403); + } String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim(); if (isBlank(queryText)) { throw new ServiceException("请先输入问题"); @@ -411,7 +459,7 @@ public class AihrSopSeedService { String category = request == null ? "sop" : firstNonBlank(request.category(), "sop"); List hits; try { - hits = dbHits(dbCategory(category), queryText, 5); + hits = dbHits(dbCategory(category), queryText, 5, allowedKnowledgeIds); } catch (DataAccessException e) { hits = List.of(); } @@ -420,7 +468,7 @@ public class AihrSopSeedService { } Optional runtime = chatRuntime(); if (runtime.isEmpty()) { - throw new ServiceException("未配置对话模型,无法生成总结卡"); + return evidenceSummaryCard(queryText, hits); } StringBuilder context = new StringBuilder(); @@ -444,15 +492,184 @@ public class AihrSopSeedService { jsonStrings(root.path("scripts"), 3, 90), jsonStrings(root.path("reminders"), 5, 20) ); - } catch (ServiceException e) { - throw e; } catch (Exception e) { - log.warn("summary card failed(处理错误已隐藏)"); - throw new ServiceException("总结卡生成失败,请稍后重试"); + log.warn("summary card model unavailable, falling back to retrieved evidence: {}", e.getClass().getSimpleName()); + return evidenceSummaryCard(queryText, hits); } } + /** + * 模型未配置或暂时不可用时,直接把已命中的 SOP 原文整理成卡片。 + * 该降级路径只摘录检索证据,不生成话术或异议回应,避免把推测内容冒充制度要求。 + */ + static SummaryCardResponse evidenceSummaryCard(String queryText, List hits) { + List steps = new ArrayList<>(); + Set seenEvidence = new LinkedHashSet<>(); + Set sourceTitles = new LinkedHashSet<>(); + List relevantHits = summaryEvidenceHits(queryText, hits); + if (relevantHits.isEmpty()) { + throw new ServiceException("检索片段与问题相关度不足,暂不能生成总结卡"); + } + for (KnowledgeHit hit : relevantHits) { + if (!isBlank(hit.title())) { + sourceTitles.add(hit.title().strip()); + } + String content = Optional.ofNullable(hit.content()).orElse(""); + for (String candidate : content.split("[。!?!?;;\\r\\n]+")) { + String evidence = candidate + .replaceFirst("^[\\s\\-—–•·*\\d一二三四五六七八九十、.()()]+", "") + .replaceAll("\\s+", " ") + .strip(); + if (evidence.length() < 4 || !seenEvidence.add(evidence)) { + continue; + } + steps.add(new CardStep("原文" + (steps.size() + 1), truncate(evidence, 40))); + if (steps.size() >= 5) { + break; + } + } + if (steps.size() >= 5) { + break; + } + } + if (steps.isEmpty()) { + throw new ServiceException("检索片段中没有可生成总结卡的文字内容"); + } + + String normalizedQuery = Optional.ofNullable(queryText).orElse("") + .replaceAll("[\\s??!!。]+", "") + .strip(); + String title = isBlank(normalizedQuery) ? "SOP原文总结卡" : truncate(normalizedQuery, 14); + List reminders = sourceTitles.stream() + .limit(3) + .map(source -> "来源:" + truncate(source, 15)) + .toList(); + return new SummaryCardResponse(title, List.copyOf(steps), List.of(), List.of(), reminders); + } + + private static List summaryEvidenceHits(String queryText, List hits) { + List candidates = Optional.ofNullable(hits).orElse(List.of()).stream().limit(5).toList(); + if (candidates.isEmpty()) { + return List.of(); + } + Set queryBigrams = evidenceBigrams(queryText); + if (queryBigrams.isEmpty()) { + return List.of(candidates.get(0)); + } + Map scores = new LinkedHashMap<>(); + int bestScore = 0; + for (KnowledgeHit hit : candidates) { + String evidence = evidenceText(firstNonBlank(hit.title(), "") + " " + firstNonBlank(hit.content(), "")); + int score = (int) queryBigrams.stream().filter(evidence::contains).count(); + scores.put(hit, score); + bestScore = Math.max(bestScore, score); + } + if (bestScore == 0) { + return List.of(); + } + int threshold = bestScore >= 3 ? Math.max(2, (bestScore + 1) / 2) : bestScore; + return scores.entrySet().stream() + .filter(entry -> entry.getValue() >= threshold) + .limit(MAX_EVIDENCE_SNIPPETS) + .map(Map.Entry::getKey) + .toList(); + } + + private static Set evidenceBigrams(String value) { + String normalized = evidenceText(value); + if (normalized.length() < 2) { + return Set.of(); + } + Set bigrams = new LinkedHashSet<>(); + for (int index = 0; index < normalized.length() - 1; index++) { + bigrams.add(normalized.substring(index, index + 2)); + } + return Set.copyOf(bigrams); + } + + @Transactional(rollbackFor = Exception.class) public UploadResponse uploadDoc(MultipartFile file, String category) { + validateUploadFile(file); + String fileName = AihrMultipartFiles.sanitizeFileName(file.getOriginalFilename(), "knowledge.txt"); + MultipartFile sanitizedFile = AihrMultipartFiles.withOriginalFilename(file, fileName); + return saveDocument(fileName, category, () -> ossService.upload(sanitizedFile), () -> readContent(sanitizedFile, fileName), () -> fileFingerprint(sanitizedFile)); + } + + @Transactional(rollbackFor = Exception.class) + public UploadResponse uploadDoc(MultipartFile file, List spaceCodes) { + validateUploadFile(file); + String fileName = AihrMultipartFiles.sanitizeFileName(file.getOriginalFilename(), "knowledge.txt"); + MultipartFile sanitizedFile = AihrMultipartFiles.withOriginalFilename(file, fileName); + return saveDocumentToSpaces(fileName, spaceCodes, + () -> ossService.upload(sanitizedFile), + () -> readContent(sanitizedFile, fileName), + () -> fileFingerprint(sanitizedFile)); + } + + /** + * 临时提取员工提问附件中的现场信息。附件只在本次请求中使用,不写 OSS 或共享知识库。 + */ + public String extractQuestionMedia(MultipartFile file, String question) { + if (file == null || file.isEmpty()) { + throw new ServiceException("请选择图片或视频"); + } + String fileName = AihrMultipartFiles.sanitizeFileName(file.getOriginalFilename(), "question-media.bin"); + boolean image = imageFile(fileName); + boolean video = AihrVideoService.videoFile(fileName); + if (!image && !video) { + throw new ServiceException("提问附件仅支持 jpg/jpeg/png/gif/webp/bmp 图片和 mp4/mov/avi/mkv/webm/m4v 视频"); + } + long maxSize = image ? 10L * 1024 * 1024 : 100L * 1024 * 1024; + if (file.getSize() > maxSize) { + throw new ServiceException(image ? "图片不能超过 10MB" : "视频不能超过 100MB"); + } + if (image) { + ChatRuntime runtime = visionRuntime() + .orElseThrow(() -> new ServiceException("未配置可用视觉模型,暂时无法分析现场图片")); + try { + String prompt = QUESTION_IMAGE_PROMPT + truncate(question, 300); + String extracted = normalizeExtractedText(callVisionOcr(runtime, file.getBytes(), guessMimeType(fileName), prompt)); + if (isBlank(extracted)) { + throw new ServiceException("图片中未识别到可用于查询的现场信息"); + } + return extracted; + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.warn("question image analysis failed for {}(处理错误已隐藏)", AihrSensitiveText.forModel(fileName)); + throw new ServiceException("图片分析失败,请稍后重试"); + } + } + + Path tempFile = null; + try { + String lower = fileName.toLowerCase(Locale.ROOT); + int dot = lower.lastIndexOf('.'); + String suffix = dot >= 0 ? lower.substring(dot) : ".video"; + tempFile = Files.createTempFile("aihr-question-video-", suffix); + file.transferTo(tempFile.toFile()); + String extracted = readContent(tempFile, fileName); + if (isBlank(extracted)) { + throw new ServiceException("视频中未提取到语音、文字或场景信息,请确认已启用 ASR/视觉模型"); + } + return extracted; + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.warn("question video analysis failed for {}(处理错误已隐藏)", AihrSensitiveText.forModel(fileName)); + throw new ServiceException("视频分析失败,请稍后重试"); + } finally { + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + log.warn("delete question video temp file failed(处理错误已隐藏)"); + } + } + } + } + + private static void validateUploadFile(MultipartFile file) { if (file == null || file.isEmpty()) { throw new ServiceException("上传文件不能为空"); } @@ -463,15 +680,13 @@ public class AihrSopSeedService { if (file.getSize() > 100 * 1024 * 1024) { throw new ServiceException("文件不能超过 100MB"); } - - MultipartFile sanitizedFile = AihrMultipartFiles.withOriginalFilename(file, fileName); - return saveDocument(fileName, category, () -> ossService.upload(sanitizedFile), () -> readContent(sanitizedFile, fileName), () -> fileFingerprint(sanitizedFile)); } /** * 供后台上传队列(AihrUploadQueueService)复用的完整加工链路:OSS 上传、解析、归类、切片、向量化。 * 与目录导入同源,入参为暂存文件路径。 */ + @Transactional(rollbackFor = Exception.class) public UploadResponse processStagedDocument(String fileName, String category, Path stagedFile) { return saveDocument(fileName, category, () -> ossService.upload(stagedFile.toFile()), @@ -479,6 +694,91 @@ public class AihrSopSeedService { () -> fileFingerprint(stagedFile)); } + @Transactional(rollbackFor = Exception.class) + public UploadResponse processStagedDocument(String fileName, List spaceCodes, Path stagedFile) { + return saveDocumentToSpaces(fileName, spaceCodes, + () -> ossService.upload(stagedFile.toFile()), + () -> readContent(stagedFile, fileName), + () -> fileFingerprint(stagedFile)); + } + + /** + * Removes only one document-to-space membership. The shared OSS object is deleted only after a + * cross-tenant reference count confirms that no other knowledge attachment still uses it. + */ + @Transactional + public UnbindDocumentResponse unbindDocumentMembership(String spaceCode, Long attachId) { + String code = spaceCode == null ? "" : spaceCode.trim(); + if (code.isEmpty() || attachId == null || attachId <= 0) { + throw new ServiceException("知识空间和文档成员不能为空", 400); + } + String currentTenantId = tenantId(); + List memberships = jdbcTemplate.query(""" + select a.id, a.knowledge_id, k.code, a.oss_id, a.doc_id + from aihr_knowledge_attach a + join aihr_knowledge_info k on k.tenant_id = a.tenant_id and k.id = a.knowledge_id + where a.tenant_id = ? and k.code = ? and a.id = ? + for update + """, (rs, rowNum) -> new DocumentMembership( + rs.getLong("id"), rs.getLong("knowledge_id"), rs.getString("code"), + rs.getObject("oss_id", Long.class), rs.getString("doc_id")), currentTenantId, code, attachId); + if (memberships.isEmpty()) { + throw new ServiceException("当前知识空间不存在该文档成员", 404); + } + + DocumentMembership membership = memberships.get(0); + jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", + currentTenantId, membership.knowledgeId(), membership.docId()); + deleteQdrantDoc(membership.knowledgeId(), membership.docId()); + int deleted = jdbcTemplate.update( + "delete from aihr_knowledge_attach where tenant_id = ? and knowledge_id = ? and id = ?", + currentTenantId, membership.knowledgeId(), membership.attachId()); + if (deleted != 1) { + throw new ServiceException("文档成员解绑失败", 409); + } + + if (membership.ossId() != null) { + afterCommitOrNow(() -> deleteOrphanedOss(currentTenantId, membership.ossId())); + } + // Physical cleanup is deliberately post-commit; the response never claims deletion before it is durable. + return new UnbindDocumentResponse(membership.attachId(), membership.spaceCode(), membership.ossId(), false); + } + + private void deleteOrphanedOss(String tenantId, Long ossId) { + Runnable cleanup = () -> { + List lockedOss = TenantHelper.ignore(() -> jdbcTemplate.queryForList( + "select oss_id from sys_oss where oss_id = ? for update", Long.class, ossId)); + if (lockedOss.isEmpty()) { + return; + } + Integer references = TenantHelper.ignore(() -> jdbcTemplate.queryForObject( + "select count(*) from aihr_knowledge_attach where oss_id = ?", Integer.class, ossId)); + if (references == null || references != 0) { + return; + } + try { + TenantHelper.ignore(() -> ossService.deleteWithValidByIds(List.of(ossId), false)); + } catch (Exception cleanupError) { + log.warn("knowledge unbind leaves orphan OSS for later cleanup tenantId={} ossId={}", + tenantId, ossId, cleanupError); + } + }; + if (requiresNewTransaction == null) { + cleanup.run(); + return; + } + requiresNewTransaction.executeWithoutResult(status -> cleanup.run()); + } + + private static TransactionTemplate configureRequiresNewTransaction(TransactionTemplate source) { + if (source == null) { + return null; + } + TransactionTemplate configured = new TransactionTemplate(source.getTransactionManager()); + configured.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return configured; + } + public VectorizeResponse vectorizeMissing() { List targets = jdbcTemplate.query(""" select f.knowledge_id, @@ -796,6 +1096,16 @@ public class AihrSopSeedService { } private UploadResponse saveDocument(String fileName, String category, Supplier ossUploader, Supplier contentReader, Supplier fileFingerprintReader) { + if (!TransactionSynchronizationManager.isActualTransactionActive() && requiresNewTransaction != null) { + return requiresNewTransaction.execute(status -> + saveDocumentInTransaction(fileName, category, ossUploader, contentReader, fileFingerprintReader)); + } + return saveDocumentInTransaction(fileName, category, ossUploader, contentReader, fileFingerprintReader); + } + + private UploadResponse saveDocumentInTransaction(String fileName, String category, Supplier ossUploader, + Supplier contentReader, + Supplier fileFingerprintReader) { FileFingerprint fileFingerprint = fileFingerprintReader.get(); String content; List fragments; @@ -826,7 +1136,6 @@ public class AihrSopSeedService { if (duplicate.isPresent()) { DuplicateHit hit = duplicate.get(); ossId = hit.ossId(); - removeStaleDuplicates(hit.attachId(), fileName, fingerprint); docId = reuseDuplicateAttach(hit, config.knowledgeId(), fileName); remark = "duplicate:" + hit.matchType() + ":" + insight.classifiedBy(); } else { @@ -867,6 +1176,135 @@ public class AihrSopSeedService { return new UploadResponse(docId, ossId, fileName, knowledgeName, fragments.size(), insight.summary(), insight.tags(), snippets); } + private UploadResponse saveDocumentToSpaces(String fileName, List spaceCodes, Supplier ossUploader, + Supplier contentReader, Supplier fileFingerprintReader) { + List spaces = resolveSpaces(spaceCodes); + FileFingerprint fileFingerprint = fileFingerprintReader.get(); + String content = contentReader.get(); + if (content.isBlank()) { + if (imageFile(fileName) || AihrVideoService.videoFile(fileName)) { + return savePendingImageToSpaces(fileName, spaces, ossUploader); + } + throw new ServiceException("文件内容不能为空"); + } + + SpaceConfig primary = spaces.get(0); + DocumentFingerprint fingerprint = new DocumentFingerprint( + fileFingerprint.sizeBytes(), fileFingerprint.fileSha256(), fileFingerprint.fileMd5(), textSha256(content)); + Optional duplicate = duplicateHit(fileName, fingerprint); + DocumentInsight insight = duplicate + .flatMap(hit -> duplicateInsight(hit, primary.name())) + .filter(this::hasStoredInsight) + .orElseGet(() -> documentInsight(fileName, content, primary.name())); + List fragments = split(content, primary.blockSize(), primary.overlap()); + EmbeddingData embeddingData = generateEmbeddings(fragments); + + Long ossId; + SysOssVo uploadedOss = null; + if (duplicate.isPresent()) { + ossId = duplicate.get().ossId(); + } else { + uploadedOss = ossUploader.get(); + ossId = uploadedOss.getOssId(); + } + + List docIds = new ArrayList<>(); + try { + for (SpaceConfig space : spaces) { + String docId = duplicate.isPresent() + ? reuseDuplicateAttach(duplicate.get(), space.knowledgeId(), fileName) + : upsertAttach(space.knowledgeId(), ossId, fileName); + docIds.add(docId); + deleteQdrantDoc(space.knowledgeId(), docId); + jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", + tenantId(), space.knowledgeId(), docId); + for (int i = 0; i < fragments.size(); i++) { + jdbcTemplate.update(""" + insert into aihr_knowledge_fragment + (tenant_id, knowledge_id, idx, doc_id, content, create_time, update_time, remark) + values (?, ?, ?, ?, ?, now(), now(), ?) + """, tenantId(), space.knowledgeId(), i + 1, docId, fragments.get(i), "upload:" + fileName); + } + persistEmbeddings(space.knowledgeId(), space.name(), docId, fragments, embeddingData); + String remark = duplicate.isPresent() + ? "duplicate:" + duplicate.get().matchType() + ":multi-space" + : "oss-upload:multi-space"; + markAttachStatus(space.knowledgeId(), docId, 2, remark); + } + } catch (RuntimeException error) { + if (uploadedOss != null) { + deleteUploadedOssQuietly(uploadedOss); + } + throw error; + } + + updateOssInsight(ossId, insight, fingerprint); + List snippets = fragments.stream() + .limit(3) + .map(fragment -> new SnippetResponse(fileName, fragment)) + .toList(); + return new UploadResponse(docIds.get(0), ossId, fileName, + String.join("、", spaces.stream().map(SpaceConfig::name).toList()), + fragments.size(), insight.summary(), insight.tags(), snippets); + } + + private UploadResponse savePendingImageToSpaces(String fileName, List spaces, Supplier ossUploader) { + boolean video = AihrVideoService.videoFile(fileName); + SysOssVo uploadedOss = ossUploader.get(); + List docIds = new ArrayList<>(); + try { + for (SpaceConfig space : spaces) { + String docId = upsertAttach(space.knowledgeId(), uploadedOss.getOssId(), fileName); + docIds.add(docId); + markAttachStatus(space.knowledgeId(), docId, 0, video ? "pending-transcribe" : "pending-ocr"); + } + } catch (RuntimeException error) { + deleteUploadedOssQuietly(uploadedOss); + throw error; + } + String hint = video + ? "视频未提取到语音转写或画面文字,已在所选知识空间标记待处理" + : "图片未提取到文字,已在所选知识空间标记待处理"; + List tags = video ? List.of("视频", "待转写") : List.of("图片", "待OCR"); + return new UploadResponse(docIds.get(0), uploadedOss.getOssId(), fileName, + String.join("、", spaces.stream().map(SpaceConfig::name).toList()), 0, hint, tags, List.of()); + } + + private List resolveSpaces(List requestedCodes) { + LinkedHashSet uniqueCodes = new LinkedHashSet<>(); + if (requestedCodes != null) { + requestedCodes.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(code -> !code.isBlank()) + .forEach(uniqueCodes::add); + } + if (uniqueCodes.isEmpty() || uniqueCodes.size() > 20) { + throw new ServiceException("请选择 1 到 20 个知识空间"); + } + if (uniqueCodes.stream().anyMatch(code -> !code.matches("[A-Za-z0-9][A-Za-z0-9_-]{1,63}"))) { + throw new ServiceException("知识空间编码格式不正确"); + } + String placeholders = String.join(",", java.util.Collections.nCopies(uniqueCodes.size(), "?")); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(uniqueCodes); + List rows = jdbcTemplate.query(""" + select id, code, name, coalesce(text_block_size, 800) as block_size, + coalesce(overlap_char, 120) as overlap_char + from aihr_knowledge_info + where tenant_id = ? and status = 'ACTIVE' and code in (%s) + """.formatted(placeholders), (rs, rowNum) -> new SpaceConfig( + rs.getLong("id"), rs.getString("code"), rs.getString("name"), + Math.max(200, rs.getInt("block_size")), Math.max(0, Math.min(rs.getInt("overlap_char"), 200)) + ), args.toArray()); + Map byCode = rows.stream().collect(java.util.stream.Collectors.toMap(SpaceConfig::code, value -> value)); + if (byCode.size() != uniqueCodes.size()) { + throw new ServiceException("知识空间不存在、未启用或不属于当前租户", 404); + } + return uniqueCodes.stream().map(byCode::get).toList(); + } + /** * OCR/转写拿不到文字的图片或视频:只落 OSS + attach 标「等待解析」(status 0),0 片段返回; * 配置视觉/ASR 模型后重新上传同名文件即可替换加工。 @@ -1080,6 +1518,8 @@ public class AihrSopSeedService { /** 视频关键帧:文字 + 一句画面描述,让纯视觉信息(动作/场景演示)也能进入文本向量检索。 */ private static final String FRAME_PROMPT = "这是一段物业培训/工作视频的画面截图。请输出:1)画面中的可见文字(保持原文);2)另起一行,以「(画面:…)」格式用一句话描述画面正在发生的内容(场景、人物动作、演示要点)。不要添加其他解释。"; + private static final String QUESTION_IMAGE_PROMPT = + "这是物业一线员工提问时附带的现场图片。请只输出可供检索SOP的客观信息:1)图片中的可见文字;2)现场场景、设施、人物动作或异常现象;3)与员工问题直接相关的线索。不要给处理结论,不要补充图片中不存在的信息。员工问题:"; private String callVisionOcr(ChatRuntime runtime, byte[] imageBytes, String mimeType) throws Exception { return callVisionOcr(runtime, imageBytes, mimeType, OCR_PROMPT); @@ -1138,7 +1578,7 @@ public class AihrSopSeedService { return Optional.ofNullable(jdbcTemplate.queryForObject(""" select o.ext1, coalesce(k.name, '') as category from aihr_knowledge_attach a - join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id + join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id left join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id where a.tenant_id = ? and a.id = ? limit 1 @@ -1169,7 +1609,7 @@ public class AihrSopSeedService { else 'name-size' end as match_type from aihr_knowledge_attach a - join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id + join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id where a.tenant_id = ? and ( json_unquote(json_extract(o.ext1, '$.aihrFileSha256')) = ? @@ -1196,30 +1636,17 @@ public class AihrSopSeedService { } private String reuseDuplicateAttach(DuplicateHit hit, long targetKnowledgeId, String fileName) { - String docId = isBlank(hit.docId()) ? UUID.randomUUID().toString().replace("-", "") : hit.docId(); - if (hit.knowledgeId() != targetKnowledgeId && !isBlank(hit.docId())) { - deleteQdrantDoc(hit.knowledgeId(), hit.docId()); - jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", tenantId(), hit.knowledgeId(), hit.docId()); - } - jdbcTemplate.update(""" - update aihr_knowledge_attach - set knowledge_id = ?, oss_id = ?, doc_id = ?, type = ?, status = 1, update_time = now(), remark = 'duplicate:reusing' - where tenant_id = ? and id = ? - """, targetKnowledgeId, hit.ossId(), docId, fileType(fileName), tenantId(), hit.attachId()); - return docId; - } - - private void removeStaleDuplicates(long keepAttachId, String fileName, DocumentFingerprint fingerprint) { - for (DuplicateHit hit : duplicateHits(fileName, fingerprint)) { - if (hit.attachId() == keepAttachId) { - continue; - } - if (!isBlank(hit.docId())) { - deleteQdrantDoc(hit.knowledgeId(), hit.docId()); - jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", tenantId(), hit.knowledgeId(), hit.docId()); - } - jdbcTemplate.update("delete from aihr_knowledge_attach where tenant_id = ? and id = ?", tenantId(), hit.attachId()); + if (hit.knowledgeId() == targetKnowledgeId) { + lockOssForReference(hit.ossId()); + String docId = isBlank(hit.docId()) ? UUID.randomUUID().toString().replace("-", "") : hit.docId(); + jdbcTemplate.update(""" + update aihr_knowledge_attach + set oss_id = ?, doc_id = ?, type = ?, status = 1, update_time = now(), remark = 'duplicate:reusing' + where tenant_id = ? and id = ? + """, hit.ossId(), docId, fileType(fileName), tenantId(), hit.attachId()); + return docId; } + return upsertAttach(targetKnowledgeId, hit.ossId(), fileName); } private void updateOssInsight(Long ossId, DocumentInsight insight, DocumentFingerprint fingerprint) { @@ -1291,8 +1718,7 @@ public class AihrSopSeedService { } private List processingRows() { - try { - return jdbcTemplate.query(""" + return jdbcTemplate.query(""" select a.id, a.oss_id, i.name as category, @@ -1307,7 +1733,7 @@ public class AihrSopSeedService { from aihr_knowledge_attach a join aihr_knowledge_info i on i.id = a.knowledge_id and i.tenant_id = a.tenant_id left join aihr_knowledge_fragment f on f.tenant_id = a.tenant_id and f.knowledge_id = a.knowledge_id and f.doc_id = a.doc_id - left join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id + left join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id where a.tenant_id = ? group by a.id, a.oss_id, i.name, a.name, a.type, a.status, a.update_time, a.remark, o.ext1 order by a.update_time desc, a.id desc @@ -1332,9 +1758,6 @@ public class AihrSopSeedService { insight.reason() ); }, tenantId()); - } catch (DataAccessException e) { - return List.of(); - } } private ProcessingTaskResponse processingTask(ProcessingTaskRow row) { @@ -1687,8 +2110,14 @@ public class AihrSopSeedService { } private EmbeddingBatch embedFragments(long knowledgeId, String category, String docId, List fragments) { + EmbeddingData data = generateEmbeddings(fragments); + persistEmbeddings(knowledgeId, category, docId, fragments, data); + return new EmbeddingBatch(data.modelName(), data.embeddings().size()); + } + + private EmbeddingData generateEmbeddings(List fragments) { if (fragments.isEmpty()) { - return new EmbeddingBatch("", 0); + return new EmbeddingData("", List.of()); } for (EmbeddingRuntime runtime : embeddingRuntimes()) { List embeddings; @@ -1701,21 +2130,29 @@ public class AihrSopSeedService { if (embeddings.size() != fragments.size()) { continue; } - for (int i = 0; i < embeddings.size(); i++) { - jdbcTemplate.update(""" - update aihr_knowledge_fragment - set embedding_json = ?, embedding_model = ?, embedding_time = now(), update_time = now() - where tenant_id = ? and knowledge_id = ? and doc_id = ? and idx = ? - """, embeddings.get(i), runtime.modelName(), tenantId(), knowledgeId, docId, i + 1); - } + return new EmbeddingData(runtime.modelName(), embeddings); + } + return new EmbeddingData("", List.of()); + } + + private void persistEmbeddings(long knowledgeId, String category, String docId, List fragments, EmbeddingData data) { + if (data.embeddings().isEmpty()) { + return; + } + for (int i = 0; i < data.embeddings().size(); i++) { + jdbcTemplate.update(""" + update aihr_knowledge_fragment + set embedding_json = ?, embedding_model = ?, embedding_time = now(), update_time = now() + where tenant_id = ? and knowledge_id = ? and doc_id = ? and idx = ? + """, data.embeddings().get(i), data.modelName(), tenantId(), knowledgeId, docId, i + 1); + } + afterCommitOrNow(() -> { try { - upsertQdrant(knowledgeId, category, docId, fragments, embeddings, runtime.modelName()); + upsertQdrant(knowledgeId, category, docId, fragments, data.embeddings(), data.modelName()); } catch (Exception ignored) { // ponytail: keep MySQL embeddings; the status panel tells users when Qdrant needs rebuild. } - return new EmbeddingBatch(runtime.modelName(), embeddings.size()); - } - return new EmbeddingBatch("", 0); + }); } private List embeddingRuntimes() { @@ -1919,8 +2356,12 @@ public class AihrSopSeedService { } private SearchResponse dbSearch(String category, String queryText, Integer limit) { + return dbSearch(category, queryText, limit, null); + } + + private SearchResponse dbSearch(String category, String queryText, Integer limit, Set allowedKnowledgeIds) { try { - List hits = dbHits(category, queryText, limit); + List hits = dbHits(category, queryText, limit, allowedKnowledgeIds); if (hits.isEmpty()) { return null; } @@ -2261,10 +2702,31 @@ public class AihrSopSeedService { } private List dbHits(String category, String queryText, Integer limit) { + return dbHits(category, queryText, limit, null); + } + + private List dbHits(String category, String queryText, Integer limit, Set allowedKnowledgeIds) { int limitValue = limitValue(limit); - List keywordHits = likeHits(category, queryText, limitValue); - List textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue); - return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue)); + List keywordHits = likeHits(category, queryText, limitValue, allowedKnowledgeIds); + List textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue); + return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue)); + } + + private static String knowledgeScopeSql(String alias, Set allowedKnowledgeIds) { + if (allowedKnowledgeIds == null) { + return ""; + } + if (allowedKnowledgeIds.isEmpty()) { + return "and 1 = 0"; + } + return "and " + alias + ".knowledge_id in (" + + String.join(",", java.util.Collections.nCopies(allowedKnowledgeIds.size(), "?")) + ")"; + } + + private static void addKnowledgeScopeArgs(List args, Set allowedKnowledgeIds) { + if (allowedKnowledgeIds != null) { + args.addAll(allowedKnowledgeIds); + } } private FeedbackFilterResult filterDownFeedback(String queryText, List hits) { @@ -2306,16 +2768,20 @@ public class AihrSopSeedService { } private List vectorHits(String category, String queryText, int limit) { + return vectorHits(category, queryText, limit, null); + } + + private List vectorHits(String category, String queryText, int limit, Set allowedKnowledgeIds) { for (EmbeddingRuntime runtime : embeddingRuntimes()) { try { List embeddings = callEmbeddings(runtime, List.of(queryText)); if (embeddings.isEmpty()) { continue; } - List matches = queryQdrant(category, embeddings.get(0), limit); + List matches = queryQdrant(category, embeddings.get(0), limit, allowedKnowledgeIds); List hits = new ArrayList<>(); for (VectorMatch match : matches) { - hydrateQdrantHit(match).ifPresent(hits::add); + hydrateQdrantHit(match, allowedKnowledgeIds).ifPresent(hits::add); } if (!hits.isEmpty()) { return hits; @@ -2328,7 +2794,11 @@ public class AihrSopSeedService { } private List fulltextHits(String category, String queryText, int limit) { - return jdbcTemplate.query(""" + return fulltextHits(category, queryText, limit, null); + } + + private List fulltextHits(String category, String queryText, int limit, Set allowedKnowledgeIds) { + String sql = """ select f.id as fragment_id, coalesce(a.name, i.name) as title, i.name as category, @@ -2341,11 +2811,21 @@ public class AihrSopSeedService { join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id where f.tenant_id = ? + %s and (? = '' or i.name = ?) and match(f.content) against (? in natural language mode) order by score desc, f.idx asc limit ? - """, (rs, rowNum) -> new KnowledgeHit( + """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds)); + List args = new ArrayList<>(); + args.add(queryText); + args.add(tenantId()); + addKnowledgeScopeArgs(args, allowedKnowledgeIds); + args.add(category); + args.add(category); + args.add(queryText); + args.add(limit); + return jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit( rs.getLong("fragment_id"), rs.getString("title"), rs.getString("category"), @@ -2354,10 +2834,14 @@ public class AihrSopSeedService { rs.getString("content"), rs.getInt("idx"), rs.getDouble("score") - ), queryText, tenantId(), category, category, queryText, limit); + ), args.toArray()); } private List likeHits(String category, String queryText, int limit) { + return likeHits(category, queryText, limit, null); + } + + private List likeHits(String category, String queryText, int limit, Set allowedKnowledgeIds) { List terms = searchTerms(queryText); if (terms.isEmpty()) { return List.of(); @@ -2381,14 +2865,16 @@ public class AihrSopSeedService { join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id where f.tenant_id = ? + %s and (? = '' or i.name = ?) and (%s) order by score desc, f.idx asc limit ? - """.formatted(String.join(" + ", scoreParts), String.join(" or ", whereParts)); + """.formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds), String.join(" or ", whereParts)); List args = new ArrayList<>(); terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add); args.add(tenantId()); + addKnowledgeScopeArgs(args, allowedKnowledgeIds); args.add(category); args.add(category); terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add); @@ -2406,7 +2892,11 @@ public class AihrSopSeedService { } private Optional hydrateQdrantHit(VectorMatch match) { - List rows = jdbcTemplate.query(""" + return hydrateQdrantHit(match, null); + } + + private Optional hydrateQdrantHit(VectorMatch match, Set allowedKnowledgeIds) { + String sql = """ select f.id as fragment_id, coalesce(a.name, i.name) as title, i.name as category, @@ -2418,10 +2908,17 @@ public class AihrSopSeedService { join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id where f.tenant_id = ? + %s and f.doc_id = ? and f.idx = ? limit 1 - """, (rs, rowNum) -> new KnowledgeHit( + """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds)); + List args = new ArrayList<>(); + args.add(tenantId()); + addKnowledgeScopeArgs(args, allowedKnowledgeIds); + args.add(match.docId()); + args.add(match.idx()); + List rows = jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit( rs.getLong("fragment_id"), rs.getString("title"), rs.getString("category"), @@ -2430,7 +2927,7 @@ public class AihrSopSeedService { rs.getString("content"), rs.getInt("idx"), match.score() - ), tenantId(), match.docId(), match.idx()); + ), args.toArray()); return rows.stream().findFirst(); } @@ -2885,15 +3382,17 @@ public class AihrSopSeedService { PreparedStatementCreator creator = connection -> { PreparedStatement ps = connection.prepareStatement(""" insert into aihr_knowledge_info - (tenant_id, name, description, `separator`, overlap_char, retrieve_limit, similarity_threshold, - text_block_size, enable_hybrid, hybrid_alpha, system_prompt, create_time, update_time, remark) - values (?, ?, ?, ?, 120, 5, 0.5, 800, 1, 1.0, ?, now(), now(), 'upload') + (tenant_id, code, name, description, space_type, sensitivity_level, status, `separator`, overlap_char, + retrieve_limit, similarity_threshold, text_block_size, enable_hybrid, hybrid_alpha, system_prompt, + create_time, update_time, remark) + values (?, ?, ?, ?, 'INTERNAL', 'INTERNAL', 'ACTIVE', ?, 120, 5, 0.5, 800, 1, 1.0, ?, now(), now(), 'upload') """, Statement.RETURN_GENERATED_KEYS); ps.setString(1, tenantId()); - ps.setString(2, category); - ps.setString(3, category + " 上传知识库"); - ps.setString(4, "\n\n"); - ps.setString(5, "回答必须引用 " + category + " 的已入库片段。"); + ps.setString(2, "legacy_" + UUID.randomUUID().toString().replace("-", "").substring(0, 24)); + ps.setString(3, category); + ps.setString(4, category + " 上传知识库"); + ps.setString(5, "\n\n"); + ps.setString(6, "回答必须引用 " + category + " 的已入库片段。"); return ps; }; jdbcTemplate.update(creator, keyHolder); @@ -2901,6 +3400,7 @@ public class AihrSopSeedService { } private String upsertAttach(long knowledgeId, Long ossId, String fileName) { + lockOssForReference(ossId); List rows = jdbcTemplate.queryForList(""" select doc_id from aihr_knowledge_attach @@ -2924,6 +3424,18 @@ public class AihrSopSeedService { return docId; } + private void lockOssForReference(Long ossId) { + if (ossId == null) { + return; + } + List rows = jdbcTemplate.queryForList( + "select oss_id from sys_oss where tenant_id = ? and oss_id = ? for update", + Long.class, tenantId(), ossId); + if (rows.isEmpty()) { + throw new ServiceException("文档对象已被清理,请重新上传", 409); + } + } + private void markAttachStatus(long knowledgeId, String docId, int status, String remark) { jdbcTemplate.update(""" update aihr_knowledge_attach @@ -2976,6 +3488,10 @@ public class AihrSopSeedService { } private void deleteQdrantDoc(long knowledgeId, String docId) { + afterCommitOrNow(() -> deleteQdrantDocNow(knowledgeId, docId)); + } + + private void deleteQdrantDocNow(long knowledgeId, String docId) { try { ObjectNode body = objectMapper.createObjectNode(); body.set("filter", qdrantFilter(knowledgeId, docId, null)); @@ -2991,10 +3507,28 @@ public class AihrSopSeedService { } } + private static void afterCommitOrNow(Runnable action) { + if (TransactionSynchronizationManager.isActualTransactionActive() + && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + return; + } + action.run(); + } + private List queryQdrant(String category, String embeddingJson, int limit) throws Exception { + return queryQdrant(category, embeddingJson, limit, null); + } + + private List queryQdrant(String category, String embeddingJson, int limit, Set allowedKnowledgeIds) throws Exception { ObjectNode body = objectMapper.createObjectNode(); body.set("query", objectMapper.readTree(embeddingJson)); - body.set("filter", qdrantFilter(null, null, category)); + body.set("filter", qdrantFilterForSpaces(allowedKnowledgeIds, null, category)); body.put("limit", limit); body.put("with_payload", true); body.put("with_vector", false); @@ -3132,11 +3666,15 @@ public class AihrSopSeedService { } private ObjectNode qdrantFilter(Long knowledgeId, String docId, String category) { + return qdrantFilterForSpaces(knowledgeId == null ? null : Set.of(knowledgeId), docId, category); + } + + private ObjectNode qdrantFilterForSpaces(Set knowledgeIds, String docId, String category) { ObjectNode filter = objectMapper.createObjectNode(); ArrayNode must = filter.putArray("must"); must.add(qdrantMatch("tenant_id", tenantId())); - if (knowledgeId != null) { - must.add(qdrantMatch("knowledge_id", knowledgeId)); + if (knowledgeIds != null && !knowledgeIds.isEmpty()) { + must.add(qdrantMatchAny("knowledge_id", knowledgeIds)); } if (!isBlank(docId)) { must.add(qdrantMatch("doc_id", docId)); @@ -3165,6 +3703,16 @@ public class AihrSopSeedService { return condition; } + private ObjectNode qdrantMatchAny(String key, Set values) { + ObjectNode condition = objectMapper.createObjectNode(); + ObjectNode match = objectMapper.createObjectNode(); + ArrayNode any = match.putArray("any"); + values.forEach(any::add); + condition.put("key", key); + condition.set("match", match); + return condition; + } + private HttpResponse qdrantRequest(String method, String path, JsonNode body) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(qdrantBaseUrl() + path)) @@ -3617,12 +4165,18 @@ public class AihrSopSeedService { private record KnowledgeConfig(long knowledgeId, int blockSize, int overlap) { } + private record SpaceConfig(long knowledgeId, String code, String name, int blockSize, int overlap) { + } + private record EmbeddingRuntime(String modelName, String baseUrl, String apiKey, int dimension) { } private record EmbeddingBatch(String modelName, int count) { } + private record EmbeddingData(String modelName, List embeddings) { + } + private record VectorDbStats(int fragments, int embeddedFragments, String embeddingModel, Integer embeddingDimension) { } @@ -3650,6 +4204,9 @@ public class AihrSopSeedService { private record DuplicateHit(long attachId, long knowledgeId, String docId, long ossId, String matchType) { } + private record DocumentMembership(long attachId, long knowledgeId, String spaceCode, Long ossId, String docId) { + } + private record VectorMatch(String docId, int idx, double score) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java index c43939a3..28fc7ced 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java @@ -1,5 +1,7 @@ package org.dromara.aihr.service; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse; @@ -14,6 +16,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -21,7 +25,10 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; @@ -47,6 +54,8 @@ public class AihrUploadQueueService { private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024; private static final int MAX_ARCHIVE_ENTRIES = 1000; private static final int MAX_ERROR_CHARS = 480; + // ponytail: UTF-8 + GBK covers the current Chinese sources; add an explicit charset option only if another legacy encoding appears. + private static final Charset ZIP_FALLBACK_CHARSET = Charset.forName("GBK"); /** 加工并发上限:链路含 LLM 归类与 embedding 外呼,并发过高会互相争抢配额并拖慢单文件耗时。 */ private static final int WORKER_PERMITS = 2; /** 处理中超过该时长视为孤儿(服务重启/线程异常丢失),由清扫任务重置回待处理。需大于最慢单文件加工时长。 */ @@ -55,6 +64,7 @@ public class AihrUploadQueueService { private static final int VIDEO_STUCK_MINUTES = 120; /** 失败条目的暂存文件保留时长,超时由清扫回收磁盘(之后重试会提示重新上传)。 */ private static final int STAGING_RETENTION_HOURS = 72; + private static final ObjectMapper JSON = new ObjectMapper(); private final AihrSopSeedService sopSeedService; private final JdbcTemplate jdbcTemplate; @@ -79,6 +89,18 @@ public class AihrUploadQueueService { } public UploadEnqueueResponse enqueue(MultipartFile file, String category, String batchId) { + return enqueueInternal(file, List.of(), category, batchId); + } + + public UploadEnqueueResponse enqueue(MultipartFile file, List spaceCodes, String category, String batchId) { + List normalized = normalizeSpaceCodes(spaceCodes); + if (normalized.isEmpty()) { + throw new ServiceException("请至少选择一个知识空间"); + } + return enqueueInternal(file, normalized, category, batchId); + } + + private UploadEnqueueResponse enqueueInternal(MultipartFile file, List spaceCodes, String category, String batchId) { if (file == null || file.isEmpty()) { throw new ServiceException("上传文件不能为空"); } @@ -107,9 +129,10 @@ public class AihrUploadQueueService { } jdbcTemplate.update(""" insert into aihr_knowledge_upload_item - (tenant_id, batch_id, file_name, category, staging_path, status, create_time, update_time) - values (?, ?, ?, ?, ?, 0, now(), now()) - """, currentTenantId(), batch, fileName, category == null ? "" : category.trim(), staged.toString()); + (tenant_id, batch_id, file_name, category, space_codes_json, staging_path, status, create_time, update_time) + values (?, ?, ?, ?, ?, ?, 0, now(), now()) + """, currentTenantId(), batch, fileName, category == null ? "" : category.trim(), + writeSpaceCodes(spaceCodes), staged.toString()); // staging_path 含 UUID 全局唯一,用它反查主键,避免 last_insert_id 的跨连接问题 Long itemId = jdbcTemplate.query(""" select id from aihr_knowledge_upload_item where tenant_id = ? and staging_path = ? @@ -124,7 +147,7 @@ public class AihrUploadQueueService { String batch = sanitizeBatchId(batchId); if (batchId == null || batchId.isBlank()) { return jdbcTemplate.query(""" - select id, batch_id, file_name, category, status, error, doc_id, fragment_count, update_time + select id, batch_id, file_name, category, space_codes_json, status, error, doc_id, fragment_count, update_time from aihr_knowledge_upload_item where tenant_id = ? order by id desc @@ -132,7 +155,7 @@ public class AihrUploadQueueService { """.formatted(safeLimit), this::mapItem, currentTenantId()); } return jdbcTemplate.query(""" - select id, batch_id, file_name, category, status, error, doc_id, fragment_count, update_time + select id, batch_id, file_name, category, space_codes_json, status, error, doc_id, fragment_count, update_time from aihr_knowledge_upload_item where tenant_id = ? and batch_id = ? order by id asc @@ -147,11 +170,12 @@ public class AihrUploadQueueService { } String tenantId = currentTenantId(); ItemRow row = jdbcTemplate.query(""" - select tenant_id, id, batch_id, file_name, category, staging_path, status + select tenant_id, id, batch_id, file_name, category, space_codes_json, staging_path, status from aihr_knowledge_upload_item where tenant_id = ? and id = ? """, rs -> rs.next() - ? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status")) + ? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), + rs.getString("category"), readSpaceCodes(rs.getString("space_codes_json")), rs.getString("staging_path"), rs.getInt("status")) : null, tenantId, id); if (row == null) { throw new ServiceException("重试条目不存在"); @@ -242,11 +266,12 @@ public class AihrUploadQueueService { String tenantId = claim.tenantId(); Long id = claim.id(); ItemRow row = jdbcTemplate.query(""" - select tenant_id, id, batch_id, file_name, category, staging_path, status + select tenant_id, id, batch_id, file_name, category, space_codes_json, staging_path, status from aihr_knowledge_upload_item where tenant_id = ? and id = ? """, rs -> rs.next() - ? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status")) + ? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), + rs.getString("category"), readSpaceCodes(rs.getString("space_codes_json")), rs.getString("staging_path"), rs.getInt("status")) : null, tenantId, id); if (row == null) { return; @@ -269,8 +294,9 @@ public class AihrUploadQueueService { } return; } - UploadResponse result = TenantHelper.dynamic(tenantId, - () -> sopSeedService.processStagedDocument(row.fileName(), row.category(), staged)); + UploadResponse result = TenantHelper.dynamic(tenantId, () -> row.spaceCodes().isEmpty() + ? sopSeedService.processStagedDocument(row.fileName(), row.category(), staged) + : sopSeedService.processStagedDocument(row.fileName(), row.spaceCodes(), staged)); // 0 片段的完成态(如图片待 OCR)把说明写进 error 列,面板可见原因 String note = result.fragments() != null && result.fragments() == 0 ? truncateError(result.summary()) : null; // CAS:仅当仍是本工人持有的「处理中」才写完成,防止清扫重置后被后来的工人覆盖状态 @@ -284,22 +310,71 @@ public class AihrUploadQueueService { } else { log.info("upload item {} finished but row was re-claimed, skip status write", id); } + } catch (ArchiveException e) { + log.warn("upload item {} archive rejected: {}", id, e.getMessage(), e); + markFailed(tenantId, id, e.getMessage()); } catch (Exception e) { - log.warn("upload item {} process failed(处理错误已隐藏)", id); + log.warn("upload item {} process failed", id, e); markFailed(tenantId, id, "资料加工失败,请重试或联系管理员"); } } private int expandArchive(ItemRow archive) throws IOException { + List files; + try { + files = extractArchiveWithEncodingFallback(archive.stagingPath()); + } catch (ArchiveException e) { + throw e; + } catch (IOException | IllegalArgumentException e) { + throw new ArchiveException("ZIP解压失败,请使用未加密的 UTF-8 或 GBK ZIP 文件后重试", e); + } + try { + enqueueExtractedFiles(archive, files); + } catch (RuntimeException e) { + deleteExtractedFiles(files); + throw new ArchiveException("ZIP文件入队失败,请重试或联系管理员", e); + } + return files.size(); + } + + private List extractArchiveWithEncodingFallback(String stagingPath) throws IOException { + try { + return extractArchive(stagingPath, StandardCharsets.UTF_8); + } catch (IOException | IllegalArgumentException e) { + if (!zipNameDecodeFailure(e)) { + throw e; + } + log.info("ZIP filenames are not UTF-8, retrying with GBK"); + return extractArchive(stagingPath, ZIP_FALLBACK_CHARSET); + } + } + + private static boolean zipNameDecodeFailure(Throwable error) { + Throwable current = error; + while (current != null) { + if (current instanceof IllegalArgumentException) { + return true; + } + String message = current.getMessage(); + if (message != null && (message.contains("bad entry name") || message.contains("malformed input"))) { + return true; + } + current = current.getCause(); + } + return false; + } + + private List extractArchive(String stagingPath, Charset charset) throws IOException { List files = new ArrayList<>(); long unpackedBytes = 0; int entries = 0; - try (var input = Files.newInputStream(Path.of(archive.stagingPath())); - var zip = new ZipInputStream(input)) { + Set usedFileNames = new HashSet<>(); + try (var input = Files.newInputStream(Path.of(stagingPath)); + var zip = new ZipInputStream(input, charset)) { ZipEntry entry; while ((entry = zip.getNextEntry()) != null) { if (++entries > MAX_ARCHIVE_ENTRIES) { - throw new IOException("ZIP文件数量不能超过 1000 个"); + throw new ArchiveException("ZIP文件数量不能超过 1000 个"); } if (entry.isDirectory()) { continue; @@ -307,11 +382,11 @@ public class AihrUploadQueueService { String entryName = entry.getName() == null ? "" : entry.getName().replace('\\', '/'); Path entryPath = Path.of(entryName).normalize(); if (entryName.isBlank() || entryPath.isAbsolute() || entryPath.startsWith("..")) { - throw new IOException("ZIP包含不安全的文件路径"); + throw new ArchiveException("ZIP包含不安全的文件路径"); } - String fileName = sanitizeFileName(entryPath.getFileName().toString()); + String fileName = uniqueFileName(sanitizeFileName(entryPath.getFileName().toString()), usedFileNames); if (isZip(fileName)) { - throw new IOException("不支持嵌套 ZIP 文件"); + throw new ArchiveException("不支持嵌套 ZIP 文件"); } if (!AihrSopSeedService.supportedFile(fileName)) { continue; @@ -326,37 +401,69 @@ public class AihrUploadQueueService { entryBytes += read; unpackedBytes += read; if (entryBytes > maxBytes || unpackedBytes > MAX_ARCHIVE_UNPACKED_BYTES) { - throw new IOException("ZIP解压后文件超过大小限制"); + throw new ArchiveException("ZIP解压后文件超过大小限制"); } output.write(buffer, 0, read); } + } catch (IOException | RuntimeException e) { + deleteQuietly(extracted); + throw e; } files.add(new ExtractedFile(fileName, extracted)); } - } catch (IOException e) { - files.forEach(file -> deleteQuietly(file.stagedPath())); + } catch (IOException | RuntimeException e) { + deleteExtractedFiles(files); throw e; } if (files.isEmpty()) { - throw new IOException("ZIP内没有支持的资料文件"); + throw new ArchiveException("ZIP内没有支持的资料文件"); } - try { - for (ExtractedFile file : files) { - jdbcTemplate.update(""" - insert into aihr_knowledge_upload_item - (tenant_id, batch_id, file_name, category, staging_path, status, create_time, update_time) - values (?, ?, ?, ?, ?, 0, now(), now()) - """, archive.tenantId(), archive.batchId(), file.fileName(), archive.category(), file.stagedPath().toString()); + return files; + } + + private void enqueueExtractedFiles(ItemRow archive, List files) { + StringBuilder sql = new StringBuilder(""" + insert into aihr_knowledge_upload_item + (tenant_id, batch_id, file_name, category, space_codes_json, staging_path, status, create_time, update_time) + values + """); + List parameters = new ArrayList<>(files.size() * 6); + for (int index = 0; index < files.size(); index++) { + if (index > 0) { + sql.append(','); } - } catch (RuntimeException e) { - for (ExtractedFile file : files) { - jdbcTemplate.update("delete from aihr_knowledge_upload_item where tenant_id = ? and staging_path = ?", - archive.tenantId(), file.stagedPath().toString()); - deleteQuietly(file.stagedPath()); - } - throw new IOException("ZIP文件入队失败", e); + sql.append(" (?, ?, ?, ?, ?, ?, 0, now(), now())"); + ExtractedFile file = files.get(index); + parameters.add(archive.tenantId()); + parameters.add(archive.batchId()); + parameters.add(file.fileName()); + parameters.add(archive.category()); + parameters.add(writeSpaceCodes(archive.spaceCodes())); + parameters.add(file.stagedPath().toString()); } - return files.size(); + jdbcTemplate.update(sql.toString(), parameters.toArray()); + } + + private static void deleteExtractedFiles(List files) { + files.forEach(file -> deleteQuietly(file.stagedPath())); + } + + private static String uniqueFileName(String fileName, Set usedFileNames) { + String candidate = fileName; + int suffix = 2; + while (!usedFileNames.add(candidate.toLowerCase(Locale.ROOT))) { + candidate = fileNameWithSuffix(fileName, suffix++); + } + return candidate; + } + + private static String fileNameWithSuffix(String fileName, int suffix) { + int extensionStart = fileName.lastIndexOf('.'); + String extension = extensionStart > 0 ? fileName.substring(extensionStart) : ""; + String stem = extensionStart > 0 ? fileName.substring(0, extensionStart) : fileName; + String marker = "(" + suffix + ")"; + int maxStemLength = Math.max(1, 200 - extension.length() - marker.length()); + return stem.substring(0, Math.min(stem.length(), maxStemLength)) + marker + extension; } private static boolean isZip(String fileName) { @@ -366,6 +473,16 @@ public class AihrUploadQueueService { private record ExtractedFile(String fileName, Path stagedPath) { } + private static final class ArchiveException extends IOException { + private ArchiveException(String message) { + super(message); + } + + private ArchiveException(String message, Throwable cause) { + super(message, cause); + } + } + /** 清扫卡在「处理中」的孤儿条目(重启/异常导致),重置回待处理后再触发一轮加工;顺带回收过期暂存文件。 */ private void sweepAndTrigger() { try { @@ -430,7 +547,7 @@ public class AihrUploadQueueService { private UploadItemResponse item(Long id) { List rows = jdbcTemplate.query(""" - select id, batch_id, file_name, category, status, error, doc_id, fragment_count, update_time + select id, batch_id, file_name, category, space_codes_json, status, error, doc_id, fragment_count, update_time from aihr_knowledge_upload_item where tenant_id = ? and id = ? """, this::mapItem, currentTenantId(), id); @@ -445,6 +562,7 @@ public class AihrUploadQueueService { rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), + readSpaceCodes(rs.getString("space_codes_json")), status, statusLabel(status), rs.getString("error"), @@ -488,6 +606,39 @@ public class AihrUploadQueueService { return cleaned.isEmpty() || cleaned.length() > 64 ? "b-" + UUID.randomUUID() : cleaned; } + private static List normalizeSpaceCodes(List values) { + Set codes = new java.util.LinkedHashSet<>(); + if (values != null) { + values.stream().filter(java.util.Objects::nonNull).map(String::trim).filter(value -> !value.isBlank()).forEach(codes::add); + } + if (codes.size() > 20) { + throw new ServiceException("单次最多选择 20 个知识空间"); + } + if (codes.stream().anyMatch(code -> !code.matches("[A-Za-z0-9][A-Za-z0-9_-]{1,63}"))) { + throw new ServiceException("知识空间编码格式不正确"); + } + return List.copyOf(codes); + } + + private static String writeSpaceCodes(List values) { + try { + return JSON.writeValueAsString(values == null ? List.of() : values); + } catch (Exception ex) { + throw new ServiceException("知识空间参数无法保存"); + } + } + + private static List readSpaceCodes(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return normalizeSpaceCodes(JSON.readValue(json, new TypeReference>() { })); + } catch (Exception ex) { + throw new ServiceException("上传任务的知识空间参数无效"); + } + } + private static void deleteQuietly(Path path) { try { Files.deleteIfExists(path); @@ -516,6 +667,7 @@ public class AihrUploadQueueService { `batch_id` varchar(64) NOT NULL COMMENT '上传批次', `file_name` varchar(255) NOT NULL COMMENT '文件名', `category` varchar(100) DEFAULT '' COMMENT '目标分类', + `space_codes_json` json DEFAULT NULL COMMENT '目标知识空间编码', `staging_path` varchar(500) NOT NULL COMMENT '暂存文件路径', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1处理中 2完成 3失败', `error` varchar(500) DEFAULT NULL COMMENT '失败原因', @@ -532,6 +684,7 @@ public class AihrUploadQueueService { } } - private record ItemRow(String tenantId, Long id, String batchId, String fileName, String category, String stagingPath, int status) { + private record ItemRow(String tenantId, Long id, String batchId, String fileName, String category, + List spaceCodes, String stagingPath, int status) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java index 0f755400..f369b1a2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java @@ -148,17 +148,22 @@ public class AihrVideoService { } private long probeDurationSeconds(Path video) { + Process process = null; try { - Process process = new ProcessBuilder( + process = new ProcessBuilder( "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", video.toString() ).redirectErrorStream(true).start(); - String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(); if (!process.waitFor(30, TimeUnit.SECONDS)) { - process.destroyForcibly(); + terminateProcess(process); throw new ServiceException("视频时长探测超时"); } + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(); return (long) Double.parseDouble(output.lines().findFirst().orElse("0")); + } catch (InterruptedException e) { + terminateProcess(process); + Thread.currentThread().interrupt(); + throw new ServiceException("视频时长探测已中断"); } catch (ServiceException e) { throw e; } catch (Exception e) { @@ -167,12 +172,19 @@ public class AihrVideoService { } private int runFfmpeg(List command) throws IOException, InterruptedException { - Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); - // 消费输出防止缓冲区塞满导致进程挂起 - byte[] discarded = process.getInputStream().readAllBytes(); - if (!process.waitFor(FFMPEG_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - process.destroyForcibly(); - throw new ServiceException("ffmpeg 处理超时"); + Process process = new ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + try { + if (!process.waitFor(FFMPEG_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + terminateProcess(process); + throw new ServiceException("ffmpeg 处理超时"); + } + } catch (InterruptedException e) { + terminateProcess(process); + Thread.currentThread().interrupt(); + throw e; } int exit = process.exitValue(); if (exit != 0) { @@ -182,12 +194,39 @@ public class AihrVideoService { } private static boolean commandAvailable(String command) { + Process process = null; try { - Process process = new ProcessBuilder(command, "-version").redirectErrorStream(true).start(); - process.getInputStream().readAllBytes(); - return process.waitFor(10, TimeUnit.SECONDS) && process.exitValue() == 0; - } catch (Exception e) { + process = new ProcessBuilder(command, "-version") + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + if (!process.waitFor(10, TimeUnit.SECONDS)) { + terminateProcess(process); + return false; + } + return process.exitValue() == 0; + } catch (InterruptedException e) { + terminateProcess(process); + Thread.currentThread().interrupt(); return false; + } catch (IOException e) { + return false; + } + } + + private static void terminateProcess(Process process) { + if (process == null || !process.isAlive()) { + return; + } + process.destroy(); + try { + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiController.java new file mode 100644 index 00000000..3d077570 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiController.java @@ -0,0 +1,32 @@ +package org.dromara.aihr.webai; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest; +import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse; +import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@SaCheckLogin +@RequestMapping("/api/aihr/web-ai") +public class AihrWebAiController { + + private final AihrWebAiService webAiService; + + @GetMapping("/capabilities") + public R capabilities() { + return R.ok(webAiService.capabilities()); + } + + @PostMapping("/query") + public R query(@RequestBody QueryRequest request) { + return R.ok(webAiService.query(request)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiDto.java new file mode 100644 index 00000000..9eb98be9 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiDto.java @@ -0,0 +1,29 @@ +package org.dromara.aihr.webai; + +import java.util.List; + +public final class AihrWebAiDto { + + private AihrWebAiDto() { + } + + public record QueryRequest(String question, Boolean consent) { + } + + public record QueryResponse(String mode, + String status, + String answer, + List sources, + String provider, + String notice, + boolean redacted, + boolean requiresConsent) { + } + + public record CapabilitiesResponse(boolean enabled, + String provider, + boolean requiresConsent, + int maxQuestionLength, + String notice) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiProperties.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiProperties.java new file mode 100644 index 00000000..a3fde096 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiProperties.java @@ -0,0 +1,68 @@ +package org.dromara.aihr.webai; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "aihr.web-ai") +public class AihrWebAiProperties { + + private boolean enabled; + private String provider = "tavily"; + private String endpoint = "https://api.tavily.com/search"; + private String apiKey = ""; + private String secretKey = ""; + private int maxResults = 5; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public int getMaxResults() { + return maxResults; + } + + public void setMaxResults(int maxResults) { + this.maxResults = maxResults; + } + + int boundedMaxResults() { + return Math.max(1, Math.min(maxResults, 8)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiService.java new file mode 100644 index 00000000..a23d77d2 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiService.java @@ -0,0 +1,137 @@ +package org.dromara.aihr.webai; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest; +import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse; +import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AihrWebAiService { + + private final AihrWebAiProperties properties; + private final AihrWebSearchClient searchClient; + private final AihrKnowledgePrincipalResolver principalResolver; + private final JdbcTemplate jdbcTemplate; + + private volatile boolean auditTableReady; + + public QueryResponse query(QueryRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String rawQuestion = normalizeQuestion(request == null ? null : request.question()); + String question = AihrWebPrivacy.forExternalSearch(rawQuestion); + boolean redacted = !question.equals(rawQuestion); + AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal); + String provider = runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name(); + if (request == null || !Boolean.TRUE.equals(request.consent())) { + audit(principal, question, provider, "CONSENT_REQUIRED", 0); + return new QueryResponse("web", "CONSENT_REQUIRED", "", List.of(), provider, + "请确认同意将脱敏后的问题发送到外部检索服务", redacted, true); + } + if (runtime == null) { + audit(principal, question, provider, "DISABLED", 0); + return new QueryResponse("web", "DISABLED", "", List.of(), provider, + "全网检索默认关闭,请由管理员配置合规的外部检索服务后启用", redacted, true); + } + try { + AihrWebSearchClient.SearchResult result = searchClient.search(runtime, question); + if (result.sources().isEmpty()) { + audit(principal, question, provider, "NO_SOURCES", 0); + return new QueryResponse("web", "NO_SOURCES", "", List.of(), provider, + "没有找到可公开核验的来源,本次不生成答案", redacted, true); + } + if (result.answer() == null || result.answer().isBlank()) { + audit(principal, question, provider, "NO_ANSWER", result.sources().size()); + return new QueryResponse("web", "NO_ANSWER", "", result.sources(), provider, + "已找到公开来源,但外部服务未返回可核验答案;请直接查看来源", redacted, true); + } + audit(principal, question, provider, "OK", result.sources().size()); + return new QueryResponse("web", "OK", result.answer(), result.sources(), provider, + "回答来自全网公开资料,不代表企业内部 SOP;请核对下方来源", redacted, true); + } catch (RuntimeException error) { + log.warn("web AI query failed provider={} (details hidden)", provider); + audit(principal, question, provider, "FAILED", 0); + return new QueryResponse("web", "FAILED", "", List.of(), provider, + "全网检索暂时不可用,本次未生成答案", redacted, true); + } + } + + public CapabilitiesResponse capabilities() { + AihrKnowledgePrincipal principal = principalResolver.current(); + AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal); + return new CapabilitiesResponse(runtime != null, + runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name(), true, 500, + runtime == null + ? "全网检索未配置,企业内部问师傅不受影响" + : "问题会先脱敏,再发送到外部公开检索服务;答案必须带可核验来源"); + } + + static String normalizeQuestion(String value) { + String question = value == null ? "" : value.trim(); + if (question.length() < 2) { + throw new IllegalArgumentException("请输入至少2个字的问题"); + } + if (question.length() > 500) { + throw new IllegalArgumentException("问题不能超过500字"); + } + return question; + } + + private void audit(AihrKnowledgePrincipal principal, String question, String provider, String status, int sourceCount) { + try { + ensureAuditTable(); + jdbcTemplate.update(""" + insert into aihr_web_ai_query_audit + (tenant_id, user_id, ext_party_id, question, provider, query_status, source_count, create_time) + values (?, ?, ?, ?, ?, ?, ?, now()) + """, principal.tenantId(), principal.userId(), principal.extPartyId(), question, + auditProvider(provider), status, sourceCount); + } catch (RuntimeException error) { + log.warn("web AI audit write failed (details hidden)"); + } + } + + private void ensureAuditTable() { + if (auditTableReady) { + return; + } + synchronized (this) { + if (auditTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_web_ai_query_audit` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) NOT NULL, + `user_id` bigint NOT NULL, + `ext_party_id` varchar(80) DEFAULT NULL, + `question` varchar(600) NOT NULL, + `provider` varchar(40) NOT NULL, + `query_status` varchar(24) NOT NULL, + `source_count` int NOT NULL DEFAULT 0, + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_aihr_web_ai_user` (`tenant_id`, `user_id`, `create_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + auditTableReady = true; + } + } + + private static String clean(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + + static String auditProvider(String value) { + String provider = clean(value, "unknown"); + return provider.length() <= 40 ? provider : provider.substring(0, 40); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebPrivacy.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebPrivacy.java new file mode 100644 index 00000000..69e59013 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebPrivacy.java @@ -0,0 +1,22 @@ +package org.dromara.aihr.webai; + +import org.dromara.aihr.service.AihrSensitiveText; + +import java.util.regex.Pattern; + +final class AihrWebPrivacy { + + private static final Pattern ID_CARD = Pattern.compile("(? response = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + .send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new ServiceException("全网检索服务暂时不可用"); + } + return parseTavilyResponse(objectMapper, response.body(), properties.boundedMaxResults()); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new ServiceException("全网检索已中断"); + } catch (ServiceException error) { + throw error; + } catch (Exception error) { + throw new ServiceException("全网检索服务暂时不可用"); + } + } + + RuntimeProvider runtime(AihrKnowledgePrincipal principal) { + RuntimeProvider configured = providerService.active(principal); + if (configured != null) { + return configured; + } + if (!properties.isEnabled()) { + return null; + } + if (!isPublicHttpsUrl(properties.getEndpoint()) || clean(properties.getApiKey()).isBlank()) { + return null; + } + return new RuntimeProvider(-1L, clean(properties.getProvider()), clean(properties.getProvider()), + clean(properties.getEndpoint()), clean(properties.getApiKey()), true, true, null); + } + + static SearchResult parseTavilyResponse(ObjectMapper mapper, String body, int limit) throws Exception { + JsonNode root = mapper.readTree(body == null ? "{}" : body); + List sources = new ArrayList<>(); + JsonNode results = root.path("results"); + if (results.isArray()) { + for (JsonNode item : results) { + if (sources.size() >= Math.max(1, Math.min(limit, 8))) { + break; + } + String url = clean(item.path("url").asText("")); + if (!isPublicHttpUrl(url)) { + continue; + } + sources.add(new WebSource( + clean(item.path("title").asText("公开来源")), + url, + truncate(clean(item.path("content").asText("")), 600), + item.path("score").asDouble(0D) + )); + } + } + String answer = sources.isEmpty() ? "" : truncate(clean(root.path("answer").asText("")), 4000); + return new SearchResult(answer, List.copyOf(sources)); + } + + static boolean isPublicHttpUrl(String value) { + try { + URI uri = URI.create(clean(value)); + String scheme = clean(uri.getScheme()).toLowerCase(Locale.ROOT); + if (!("http".equals(scheme) || "https".equals(scheme)) || uri.getUserInfo() != null) { + return false; + } + String host = clean(uri.getHost()).toLowerCase(Locale.ROOT); + if (host.isBlank() || host.equals("localhost") || host.endsWith(".localhost") + || host.endsWith(".local") || host.endsWith(".internal")) { + return false; + } + InetAddress[] addresses = InetAddress.getAllByName(host); + if (addresses.length == 0) { + return false; + } + for (InetAddress address : addresses) { + if (!isPublicAddress(address)) { + return false; + } + } + return true; + } catch (Exception error) { + return false; + } + } + + static boolean isPublicHttpsUrl(String value) { + try { + URI uri = URI.create(clean(value)); + return "https".equalsIgnoreCase(clean(uri.getScheme())) && isPublicHttpUrl(value); + } catch (RuntimeException error) { + return false; + } + } + + private static boolean isPublicAddress(InetAddress address) { + if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isLinkLocalAddress() + || address.isSiteLocalAddress() || address.isMulticastAddress()) { + return false; + } + byte[] bytes = address.getAddress(); + if (address instanceof Inet4Address && bytes.length == 4) { + int first = Byte.toUnsignedInt(bytes[0]); + int second = Byte.toUnsignedInt(bytes[1]); + return first != 0 && first != 127 && first < 224 + && !(first == 100 && second >= 64 && second <= 127); + } + if (address instanceof Inet6Address && bytes.length == 16) { + int first = Byte.toUnsignedInt(bytes[0]); + return (first & 0xfe) != 0xfc; + } + return false; + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String truncate(String value, int max) { + return value.length() <= max ? value : value.substring(0, max); + } + + public record WebSource(String title, String url, String snippet, double score) { + } + + public record SearchResult(String answer, List sources) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderAdminController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderAdminController.java new file mode 100644 index 00000000..aedc9c5b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderAdminController.java @@ -0,0 +1,84 @@ +package org.dromara.aihr.webai; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.community.AihrCommunityPolicy; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderRequest; +import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderResponse; +import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@SaCheckLogin +@RequestMapping("/api/aihr/web-search") +public class AihrWebSearchProviderAdminController { + + private final AihrWebSearchProviderService providerService; + private final AihrWebSearchClient searchClient; + private final AihrKnowledgePrincipalResolver principalResolver; + + @GetMapping("/providers") + public R> providers() { + AihrKnowledgePrincipal principal = requireModerator(); + return R.ok(providerService.list(principal)); + } + + @PostMapping("/providers") + public R create(@RequestBody ProviderRequest request) { + return R.ok(providerService.create(requireModerator(), request)); + } + + @PutMapping("/providers") + public R update(@RequestBody ProviderRequest request) { + return R.ok(providerService.update(requireModerator(), request)); + } + + @PatchMapping("/providers/{id}/enabled") + public R enabled(@PathVariable Long id, @RequestBody EnabledRequest request) { + return R.ok(providerService.setEnabled(requireModerator(), id, request != null && request.enabled())); + } + + @PostMapping("/providers/{id}/test") + public R test(@PathVariable Long id) { + AihrKnowledgePrincipal principal = requireModerator(); + RuntimeProvider provider = providerService.provider(principal, id); + try { + AihrWebSearchClient.SearchResult result = searchClient.search(provider, "物业服务行业最新公开信息"); + boolean ok = !result.sources().isEmpty(); + providerService.recordTest(principal, id, ok); + return R.ok(new TestResponse(ok, result.sources().size(), + ok ? "连接正常" : "连接成功,但未检索到可公开核验的来源")); + } catch (RuntimeException error) { + providerService.recordTest(principal, id, false); + return R.ok(new TestResponse(false, 0, "连接测试失败,请检查 HTTPS 地址和访问密钥")); + } + } + + private AihrKnowledgePrincipal requireModerator() { + AihrKnowledgePrincipal principal = principalResolver.current(); + if (!AihrCommunityPolicy.canModerate(principal)) { + throw new ServiceException("仅主管或管理员可管理全网检索提供方", 403); + } + return principal; + } + + public record EnabledRequest(boolean enabled) { + } + + public record TestResponse(boolean ok, int sourceCount, String message) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderService.java new file mode 100644 index 00000000..6e30c4f1 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSearchProviderService.java @@ -0,0 +1,233 @@ +package org.dromara.aihr.webai; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; + +@Service +@RequiredArgsConstructor +public class AihrWebSearchProviderService { + + private final JdbcTemplate jdbcTemplate; + private final AihrWebSecretCodec secretCodec; + private volatile boolean tableReady; + + public List list(AihrKnowledgePrincipal principal) { + ensureTable(); + return jdbcTemplate.query(""" + select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time + from aihr_web_search_provider where tenant_id = ? order by id + """, (rs, rowNum) -> new ProviderResponse( + rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"), + rs.getString("endpoint"), hasText(rs.getString("api_key")), rs.getBoolean("enabled"), + rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class) + ), principal.tenantId()); + } + + public ProviderResponse create(AihrKnowledgePrincipal principal, ProviderRequest request) { + ensureTable(); + ProviderData data = normalize(request, false); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_web_search_provider + (tenant_id, provider_name, provider_code, endpoint, api_key, enabled, create_time, update_time) + values (?, ?, ?, ?, ?, 0, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, principal.tenantId()); + statement.setString(2, data.name()); + statement.setString(3, data.code()); + statement.setString(4, data.endpoint()); + statement.setString(5, secretCodec.encrypt(data.apiKey())); + return statement; + }, keyHolder); + Number key = keyHolder.getKey(); + return responseById(principal, key == null ? 0L : key.longValue()); + } + + public ProviderResponse update(AihrKnowledgePrincipal principal, ProviderRequest request) { + ensureTable(); + ProviderData data = normalize(request, true); + String apiKey = hasText(request.apiKey()) ? secretCodec.encrypt(request.apiKey().trim()) : null; + int updated = jdbcTemplate.update(""" + update aihr_web_search_provider + set provider_name = ?, provider_code = ?, endpoint = ?, + api_key = case when ? is null then api_key else ? end, + enabled = 0, last_test_ok = 0, last_test_time = null, + update_time = now() + where tenant_id = ? and id = ? + """, data.name(), data.code(), data.endpoint(), apiKey, apiKey, principal.tenantId(), request.id()); + if (updated == 0) { + throw new IllegalArgumentException("全网检索提供方不存在"); + } + return responseById(principal, request.id()); + } + + @Transactional(rollbackFor = Exception.class) + public ProviderResponse setEnabled(AihrKnowledgePrincipal principal, Long id, boolean enabled) { + ensureTable(); + if (enabled) { + RuntimeProvider selected = provider(principal, id); + if (!AihrWebSearchClient.isPublicHttpsUrl(selected.endpoint()) || !hasText(selected.apiKey())) { + throw new IllegalArgumentException("请先配置安全的公开接口和访问密钥"); + } + if (!selected.lastTestOk() || selected.lastTestTime() == null) { + throw new IllegalArgumentException("当前配置尚未通过连接测试,请先测试后再启用"); + } + jdbcTemplate.update(""" + update aihr_web_search_provider set enabled = 0, update_time = now() where tenant_id = ? + """, principal.tenantId()); + } + jdbcTemplate.update(""" + update aihr_web_search_provider set enabled = ?, update_time = now() where tenant_id = ? and id = ? + """, enabled ? 1 : 0, principal.tenantId(), id); + return responseById(principal, id); + } + + public ProviderResponse recordTest(AihrKnowledgePrincipal principal, Long id, boolean ok) { + ensureTable(); + int updated = jdbcTemplate.update(""" + update aihr_web_search_provider + set last_test_ok = ?, last_test_time = now(), update_time = now() + where tenant_id = ? and id = ? + """, ok ? 1 : 0, principal.tenantId(), id); + if (updated == 0) { + throw new IllegalArgumentException("全网检索提供方不存在"); + } + return responseById(principal, id); + } + + RuntimeProvider active(AihrKnowledgePrincipal principal) { + ensureTable(); + try { + List rows = jdbcTemplate.query(""" + select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time + from aihr_web_search_provider + where tenant_id = ? and enabled = 1 + order by id desc limit 1 + """, (rs, rowNum) -> providerRow(rs), principal.tenantId()); + return rows.isEmpty() ? null : rows.get(0); + } catch (ServiceException error) { + return null; + } + } + + RuntimeProvider provider(AihrKnowledgePrincipal principal, Long id) { + ensureTable(); + List rows = jdbcTemplate.query(""" + select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time + from aihr_web_search_provider where tenant_id = ? and id = ? + """, (rs, rowNum) -> providerRow(rs), principal.tenantId(), id); + if (rows.isEmpty()) { + throw new IllegalArgumentException("全网检索提供方不存在"); + } + return rows.get(0); + } + + private RuntimeProvider providerRow(java.sql.ResultSet rs) throws java.sql.SQLException { + return new RuntimeProvider(rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"), + rs.getString("endpoint"), secretCodec.decrypt(rs.getString("api_key")), rs.getBoolean("enabled"), + rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class)); + } + + private ProviderResponse responseById(AihrKnowledgePrincipal principal, Long id) { + List rows = jdbcTemplate.query(""" + select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time + from aihr_web_search_provider where tenant_id = ? and id = ? + """, (rs, rowNum) -> new ProviderResponse( + rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"), + rs.getString("endpoint"), hasText(rs.getString("api_key")), rs.getBoolean("enabled"), + rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class) + ), principal.tenantId(), id); + if (rows.isEmpty()) { + throw new IllegalArgumentException("全网检索提供方不存在"); + } + return rows.get(0); + } + + private static ProviderData normalize(ProviderRequest request, boolean idRequired) { + if (request == null || (idRequired && request.id() == null)) { + throw new IllegalArgumentException("提供方参数不完整"); + } + String name = clean(request.name()); + String code = clean(request.providerCode()).toLowerCase(Locale.ROOT); + String endpoint = clean(request.endpoint()); + if (name.isBlank() || name.length() > 100) { + throw new IllegalArgumentException("提供方名称不能为空且不能超过100字"); + } + if (!"tavily".equals(code)) { + throw new IllegalArgumentException("当前仅支持 tavily"); + } + if (!AihrWebSearchClient.isPublicHttpsUrl(endpoint)) { + throw new IllegalArgumentException("检索地址必须是可公开访问的 HTTPS 地址"); + } + String apiKey = clean(request.apiKey()); + if (!idRequired && apiKey.isBlank()) { + throw new IllegalArgumentException("访问密钥不能为空"); + } + if (apiKey.length() > 1000) { + throw new IllegalArgumentException("访问密钥长度超限"); + } + return new ProviderData(name, code, endpoint, apiKey); + } + + private void ensureTable() { + if (tableReady) { + return; + } + synchronized (this) { + if (tableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_web_search_provider` ( + `id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL, + `provider_name` varchar(100) NOT NULL, `provider_code` varchar(40) NOT NULL DEFAULT 'tavily', + `endpoint` varchar(500) NOT NULL, `api_key` varchar(1000) DEFAULT NULL, + `enabled` tinyint NOT NULL DEFAULT 0, + `last_test_ok` tinyint NOT NULL DEFAULT 0, + `last_test_time` datetime DEFAULT NULL, + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), UNIQUE KEY `uk_aihr_web_search_provider` (`tenant_id`, `provider_name`), + KEY `idx_aihr_web_search_enabled` (`tenant_id`, `enabled`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + tableReady = true; + } + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + public record ProviderRequest(Long id, String name, String providerCode, String endpoint, String apiKey) { + } + + public record ProviderResponse(Long id, String name, String providerCode, String endpoint, + boolean configured, boolean enabled, boolean lastTestOk, + LocalDateTime lastTestTime) { + } + + record RuntimeProvider(Long id, String name, String code, String endpoint, String apiKey, boolean enabled, + boolean lastTestOk, LocalDateTime lastTestTime) { + } + + private record ProviderData(String name, String code, String endpoint, String apiKey) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSecretCodec.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSecretCodec.java new file mode 100644 index 00000000..21e2cf06 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSecretCodec.java @@ -0,0 +1,79 @@ +package org.dromara.aihr.webai; + +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +@Component +@RequiredArgsConstructor +class AihrWebSecretCodec { + + private static final String PREFIX = "enc:v1:"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final AihrWebAiProperties properties; + + String encrypt(String plaintext) { + if (plaintext == null || plaintext.isBlank()) { + return ""; + } + try { + byte[] iv = new byte[12]; + RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key(), new GCMParameterSpec(128, iv)); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] payload = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, payload, 0, iv.length); + System.arraycopy(encrypted, 0, payload, iv.length, encrypted.length); + return PREFIX + Base64.getEncoder().encodeToString(payload); + } catch (ServiceException error) { + throw error; + } catch (Exception error) { + throw new ServiceException("全网检索密钥加密失败"); + } + } + + String decrypt(String encoded) { + if (encoded == null || encoded.isBlank()) { + return ""; + } + if (!encoded.startsWith(PREFIX)) { + throw new ServiceException("全网检索密钥格式不安全,请重新保存"); + } + try { + byte[] payload = Base64.getDecoder().decode(encoded.substring(PREFIX.length())); + if (payload.length <= 28) { + throw new IllegalArgumentException("ciphertext too short"); + } + byte[] iv = new byte[12]; + byte[] ciphertext = new byte[payload.length - iv.length]; + System.arraycopy(payload, 0, iv, 0, iv.length); + System.arraycopy(payload, iv.length, ciphertext, 0, ciphertext.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (ServiceException error) { + throw error; + } catch (Exception error) { + throw new ServiceException("全网检索密钥解密失败,请重新保存"); + } + } + + private SecretKeySpec key() throws Exception { + String secret = properties.getSecretKey() == null ? "" : properties.getSecretKey().trim(); + if (secret.length() < 16) { + throw new ServiceException("请先配置至少16位的 AIHR_WEB_AI_SECRET_KEY"); + } + byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8)); + return new SecretKeySpec(digest, "AES"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrCommunityPolicyTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrCommunityPolicyTest.java new file mode 100644 index 00000000..517f2d41 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrCommunityPolicyTest.java @@ -0,0 +1,99 @@ +package org.dromara.aihr.community; + +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.common.core.enums.UserType; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AihrCommunityPolicyTest { + + @Test + @Tag("dev") + void rewardUsesConfigurablePointsAndNeverRepresentsCash() { + AihrCommunityProperties properties = new AihrCommunityProperties(); + properties.setDefaultRewardPoints(12); + properties.setMaxRewardPoints(100); + + assertEquals(12, AihrCommunityPolicy.rewardPoints(null, properties)); + assertEquals(0, AihrCommunityPolicy.rewardPoints(0, properties)); + assertEquals(100, AihrCommunityPolicy.rewardPoints(999, properties)); + assertThrows(IllegalArgumentException.class, () -> AihrCommunityPolicy.rewardPoints(-1, properties)); + } + + @Test + @Tag("dev") + void moderationAuthorityComesFromServerPrincipal() { + AihrKnowledgePrincipal employee = principal(UserType.APP_USER.getUserType(), Set.of("employee")); + AihrKnowledgePrincipal supervisor = principal(UserType.APP_USER.getUserType(), Set.of("employee", "supervisor")); + AihrKnowledgePrincipal hr = principal(UserType.SYS_USER.getUserType(), Set.of("hr_operator")); + + assertFalse(AihrCommunityPolicy.canModerate(employee)); + assertTrue(AihrCommunityPolicy.canModerate(supervisor)); + assertTrue(AihrCommunityPolicy.canModerate(hr)); + } + + @Test + @Tag("dev") + void moderationDecisionIsExplicit() { + assertEquals("PUBLISHED", AihrCommunityPolicy.moderatedStatus("approve")); + assertEquals("REJECTED", AihrCommunityPolicy.moderatedStatus("reject")); + assertThrows(IllegalArgumentException.class, () -> AihrCommunityPolicy.moderatedStatus("pending")); + } + + @Test + @Tag("dev") + void rewardLedgerMigrationHasAnIdempotencyConstraintAndNoCashColumn() throws Exception { + Path migration = Path.of("../../script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql"); + if (!Files.exists(migration)) { + migration = Path.of("script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql"); + } + String sql = Files.readString(migration).toLowerCase(); + + assertTrue(sql.contains("uk_aihr_points_business")); + assertTrue(sql.contains("account_type")); + assertTrue(sql.contains("study_credit")); + assertTrue(sql.contains("reward_points")); + assertFalse(sql.contains("cash_amount")); + assertFalse(sql.contains("payment")); + } + + @Test + @Tag("dev") + void incentiveAccountsAreRestrictedToRealPointsAndStudyCreditLedgers() { + assertEquals("POINTS", AihrIncentiveService.normalizeAccountType("points")); + assertEquals("STUDY_CREDIT", AihrIncentiveService.normalizeAccountType(" study_credit ")); + assertThrows(IllegalArgumentException.class, + () -> AihrIncentiveService.normalizeAccountType("PRIZE")); + assertThrows(IllegalArgumentException.class, + () -> AihrIncentiveService.normalizeAccountType("CASH")); + } + + @Test + @Tag("dev") + void bestAnswerWriteIsTransactionalAndUsesTheUniqueLedgerInsert() throws Exception { + Path source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java"); + if (!Files.exists(source)) { + source = Path.of("src/main/java/org/dromara/aihr/community/AihrCommunityService.java"); + } + String code = Files.readString(source); + + assertTrue(code.contains("@Transactional(rollbackFor = Exception.class)")); + assertTrue(code.contains("insert ignore into aihr_points_ledger")); + assertTrue(code.contains("account_type = ? and create_time >= current_date()")); + assertTrue(code.contains("accountBalance(principal.tenantId(), answer.authorUserId(), \"STUDY_CREDIT\")")); + assertTrue(code.contains("best_answer_id is null")); + } + + private static AihrKnowledgePrincipal principal(String userType, Set roles) { + return new AihrKnowledgePrincipal("000000", 7L, userType, "staff-7", roles, Set.of("P1"), "mobile"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java new file mode 100644 index 00000000..ef7ace6b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java @@ -0,0 +1,79 @@ +package org.dromara.aihr.knowledge; + +import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService; +import org.dromara.aihr.service.AihrMobileSeedService; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrKnowledgeDataToolServiceTest { + + @Test + void myPracticeSummaryAlwaysUsesCurrentAppUserIdentity() { + AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); + when(mobile.practiceHistory("employee-1")).thenReturn(List.of( + new RecordResponse(9L, "session", "07-16 09:00", "张三", "投诉沟通", 88, "已完成", "表达清晰", "employee-1"))); + var service = new AihrKnowledgeDataToolService(mobile); + + var result = service.execute(employee(), AihrKnowledgeDataToolService.MY_PRACTICE_SUMMARY); + + verify(mobile).practiceHistory("employee-1"); + var data = (AihrKnowledgeDataToolService.MyPracticeSummary) result.data(); + assertEquals(1, data.practiceCount()); + assertEquals("投诉沟通", data.recentRecords().get(0).scene()); + } + + @Test + void teamPracticeSummaryUsesResolvedSupervisorScope() { + AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); + when(mobile.requireSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor"); + when(mobile.practiceTeam("canonical-supervisor")) + .thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of())); + var service = new AihrKnowledgeDataToolService(mobile); + var principal = new AihrKnowledgePrincipal("000000", 2L, "app_user", "supervisor-1", + Set.of("employee", "supervisor"), Set.of("P1"), "app"); + + service.execute(principal, AihrKnowledgeDataToolService.TEAM_PRACTICE_SUMMARY); + + verify(mobile).requireSupervisorIdentity("supervisor-1"); + verify(mobile).practiceTeam("canonical-supervisor"); + } + + @Test + void employeeCannotCallTeamPracticeSummary() { + AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); + var service = new AihrKnowledgeDataToolService(mobile); + + assertThrows(ServiceException.class, + () -> service.execute(employee(), AihrKnowledgeDataToolService.TEAM_PRACTICE_SUMMARY)); + verify(mobile, never()).practiceTeam(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void unknownToolIsRejectedWithoutReadingTrainingData() { + AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); + var service = new AihrKnowledgeDataToolService(mobile); + + assertThrows(ServiceException.class, () -> service.execute(employee(), "RAW_SQL")); + verify(mobile, never()).practiceHistory(org.mockito.ArgumentMatchers.anyString()); + } + + private static AihrKnowledgePrincipal employee() { + return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", + Set.of("employee"), Set.of("P1"), "app"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java new file mode 100644 index 00000000..7f6c7643 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -0,0 +1,173 @@ +package org.dromara.aihr.knowledge; + +import org.dromara.aihr.domain.AihrSopDto.SearchRequest; +import org.dromara.aihr.domain.AihrSopDto.SearchResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryAuditService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService; +import org.dromara.aihr.service.AihrSopSeedService; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrKnowledgeQueryServiceTest { + + @Test + void emptyEffectiveScopeStopsBeforeRag() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var sop = mock(AihrSopSeedService.class); + var principal = new AihrKnowledgePrincipal("000000", 1L, "sys_user", "", Set.of("employee"), Set.of(), "pc"); + var app = new AuthenticatedApp(1L, "000000", "yc_admin", "管理端", "SESSION", 60, null); + when(resolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "pc")).thenReturn(app); + when(access.resolveInternalSpaceIds(any(), any(), any(), any())) + .thenThrow(new ServiceException("no scope", 403)); + var service = service(resolver, appService, access, sop); + + assertThrows(ServiceException.class, () -> service.queryInternal( + new QueryRequest("制度是什么", List.of(), "sop", null, "test", 5, null))); + + verify(sop, never()).searchAuthorized(any(), any(), any()); + } + + @Test + void externalApplicationCannotCallDataTools() { + var sop = mock(AihrSopSeedService.class); + var service = service(mock(AihrKnowledgePrincipalResolver.class), mock(AihrKnowledgeAppService.class), + mock(AihrKnowledgeAccessService.class), sop); + var app = new AuthenticatedApp(2L, "100001", "mt_card_miniapp", "名片小程序", "API_TOKEN", 60, null); + + assertThrows(ServiceException.class, () -> service.queryExternal(app, + new QueryRequest("我的训练", List.of(), null, null, "miniapp", 5, "MY_PRACTICE_SUMMARY"))); + verify(sop, never()).searchAuthorized(any(), any(), any()); + } + + @Test + void mediaAnalysisStartsOnlyAfterKnowledgeScopeAuthorization() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var sop = mock(AihrSopSeedService.class); + var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", Set.of("employee"), Set.of(), "app"); + var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + when(resolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")) + .thenThrow(new ServiceException("no scope", 403)); + var service = service(resolver, appService, access, sop); + var media = new MockMultipartFile("file", "lift.jpg", "image/jpeg", new byte[]{1}); + + assertThrows(ServiceException.class, () -> service.queryInternalMedia( + new QueryRequest("电梯怎么处理", List.of(), "sop", "保安", "mobile_uni_media", 5, null), media)); + + verify(sop, never()).extractQuestionMedia(any(), any()); + verify(sop, never()).searchAuthorized(any(), any(), any()); + } + + @Test + void externalDocumentQueryRunsInsideAuthenticatedApplicationTenant() { + var access = mock(AihrKnowledgeAccessService.class); + var sop = mock(AihrSopSeedService.class); + var jdbc = mock(JdbcTemplate.class); + var app = new AuthenticatedApp(2L, "100001", "mt_card_miniapp", "名片小程序", "API_TOKEN", 60, null); + when(access.resolveExternalSpaceIds(app, List.of("mt_customer_service"))).thenReturn(Set.of(9L)); + when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))) + .thenReturn(List.of("mt_customer_service")); + when(sop.searchAuthorized(any(), any(), any())).thenReturn( + new SearchResponse("公开服务", "sop", "仅返回公开内容", "", List.of(), List.of(), + List.of(), List.of(), List.of(), List.of(), "test-v1", null)); + var service = new AihrKnowledgeQueryService(mock(AihrKnowledgePrincipalResolver.class), + mock(AihrKnowledgeAppService.class), access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc, + mock(AihrKnowledgeDataToolService.class)); + + var result = service.queryExternal(app, + new QueryRequest("公开服务", List.of("mt_customer_service"), null, null, "miniapp", 5, null)); + + assertEquals(List.of("mt_customer_service"), result.usedSpaceCodes()); + } + + @Test + void internalToolFailureNeverFallsBackToRag() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var sop = mock(AihrSopSeedService.class); + var tools = mock(AihrKnowledgeDataToolService.class); + var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", Set.of("employee"), Set.of(), "app"); + var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + when(resolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(app); + when(tools.execute(principal, "MY_PRACTICE_SUMMARY")).thenThrow(new ServiceException("tool failed", 500)); + var service = new AihrKnowledgeQueryService(resolver, appService, mock(AihrKnowledgeAccessService.class), sop, + mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), tools); + + assertThrows(ServiceException.class, () -> service.queryInternal( + new QueryRequest("我的训练", List.of(), null, null, "mobile", 5, "MY_PRACTICE_SUMMARY"))); + verify(sop, never()).searchAuthorized(any(), any(), any()); + } + + @Test + void mediaContextParticipatesInRetrievalWithoutReplacingDisplayedQuestion() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var sop = mock(AihrSopSeedService.class); + var jdbc = mock(JdbcTemplate.class); + var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", Set.of("employee"), Set.of(), "app"); + var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + when(resolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L)); + when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop")); + var media = new MockMultipartFile("file", "lift.jpg", "image/jpeg", new byte[]{1}); + when(sop.extractQuestionMedia(media, "电梯这里该怎么办")) + .thenReturn("画面显示电梯门无法打开,楼层指示灯闪烁"); + when(sop.searchAuthorized(any(), any(), any())).thenReturn( + new SearchResponse("现场问题", "sop", "按电梯困人流程处理", "", List.of(), List.of(), + List.of(), List.of(), List.of(), List.of(), "test-v1", null)); + var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, + mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class)); + + var result = service.queryInternalMedia( + new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null), + media); + + var requestCaptor = ArgumentCaptor.forClass(SearchRequest.class); + verify(sop).searchAuthorized(requestCaptor.capture(), any(), any()); + assertEquals("电梯这里该怎么办", result.queryText()); + assertEquals(true, requestCaptor.getValue().queryText().contains("附件现场信息")); + assertEquals(true, requestCaptor.getValue().queryText().contains("电梯门无法打开")); + } + + private static AihrKnowledgeQueryService service( + AihrKnowledgePrincipalResolver resolver, + AihrKnowledgeAppService appService, + AihrKnowledgeAccessService access, + AihrSopSeedService sop + ) { + return new AihrKnowledgeQueryService(resolver, appService, access, sop, + mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java new file mode 100644 index 00000000..068867fd --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java @@ -0,0 +1,95 @@ +package org.dromara.aihr.knowledge; + +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AdminContext; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.SpaceView; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateSpaceRequest; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeSpaceAdminService; +import org.dromara.aihr.service.AihrSopSeedService; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrKnowledgeSpaceAdminServiceTest { + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void listReturnsOnlyCurrentTenantSpaces() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of()); + var service = new AihrKnowledgeSpaceAdminService(jdbc, mock(AihrKnowledgeAppService.class), mock(AihrSopSeedService.class)); + + service.spaces(admin()); + + ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).query(sql.capture(), any(RowMapper.class), args.capture()); + assertTrue(sql.getValue().contains("where k.tenant_id = ?")); + assertTrue(java.util.Arrays.asList(args.getValue()).contains("tenant-a")); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void codeCannotChangeAfterSpaceCreation() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of( + new SpaceView(1L, "fixed_code", "旧名称", "", "BUSINESS", "INTERNAL", "ACTIVE", 0, 0))); + var service = new AihrKnowledgeSpaceAdminService(jdbc, mock(AihrKnowledgeAppService.class), mock(AihrSopSeedService.class)); + + assertThrows(ServiceException.class, () -> service.updateSpace(admin(), 1L, + new UpdateSpaceRequest("changed_code", "新名称", "", "BUSINESS", "INTERNAL", "ACTIVE"))); + } + + @Test + void tokenHashNeverAppearsInAdminDtoOrSelect() throws Exception { + Path dto = Path.of("src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeSpaceDto.java"); + Path service = Path.of("src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java"); + if (!Files.exists(dto)) { + dto = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeSpaceDto.java"); + service = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java"); + } + assertFalse(Files.readString(dto).contains("tokenHash")); + String code = Files.readString(service); + String listMethod = code.substring(code.indexOf("public List apps"), code.indexOf("public CreateAppResponse createApp")); + assertFalse(listMethod.contains("token_hash")); + assertTrue(code.contains("token=hidden")); + } + + @Test + void adminServiceKeepsTenantChecksOnBindingsAndManageChecks() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java"); + } + String code = Files.readString(source); + assertTrue(code.contains("where tenant_id = ? and code in")); + assertTrue(code.contains("应用绑定包含其他租户或不存在的空间")); + assertTrue(code.contains("g.permission = 'MANAGE'")); + assertTrue(code.contains("String roles = roleScope(context.roles(), args)")); + assertTrue(code.contains("aihr_knowledge_admin_audit")); + assertTrue(code.contains("where a.tenant_id = ? and a.knowledge_id = ?")); + assertTrue(code.contains("sopService.unbindDocumentMembership(space.code(), attachId)")); + } + + private static AdminContext admin() { + return new AdminContext("tenant-a", 7L, Set.of("admin"), true); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrExamIdempotencyTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrExamIdempotencyTest.java new file mode 100644 index 00000000..6e502274 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrExamIdempotencyTest.java @@ -0,0 +1,97 @@ +package org.dromara.aihr.learning; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult; +import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitResponse; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrExamIdempotencyTest { + + @Test + void submissionTimestampUsesTheSamePrecisionAsTheDatabase() { + LocalDateTime raw = LocalDateTime.of(2026, 7, 18, 0, 5, 27, 900_000_000); + + assertEquals(LocalDateTime.of(2026, 7, 18, 0, 5, 27), + AihrExamService.databaseSecondPrecision(raw)); + } + + @Test + void retryWithSameRequestIdRestoresTheCompleteOriginalPerQuestionResult() throws Exception { + AihrExamService service = new AihrExamService(new ReplayJdbcTemplate(), new ObjectMapper()); + ExamSubmitResponse expected = new ExamSubmitResponse( + 42L, "SUBMITTED", 40, false, "2026-07-17 10:11:12", + List.of( + new ExamAnswerResult(101L, true, 40, 40, "回答正确"), + new ExamAnswerResult(102L, false, 0, 60, "应先确认现场风险并约定反馈时间") + ) + ); + + ExamSubmitResponse firstRetry = findAttempt(service); + ExamSubmitResponse secondRetry = findAttempt(service); + + assertEquals(expected, firstRetry); + assertEquals(firstRetry, secondRetry); + assertEquals(expected.results(), secondRetry.results()); + } + + private static ExamSubmitResponse findAttempt(AihrExamService service) throws Exception { + var method = AihrExamService.class.getDeclaredMethod( + "findAttempt", Long.class, String.class, String.class); + method.setAccessible(true); + return (ExamSubmitResponse) method.invoke(service, 7L, "EMP-1", "submit-001"); + } + + private static final class ReplayJdbcTemplate extends JdbcTemplate { + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + try { + if (sql.contains("FROM aihr_onboard_exam_attempt")) { + ResultSet attempt = mock(ResultSet.class); + when(attempt.getLong("id")).thenReturn(42L); + when(attempt.getString("status")).thenReturn("SUBMITTED"); + when(attempt.getInt("score")).thenReturn(40); + when(attempt.getBoolean("passed")).thenReturn(false); + when(attempt.getTimestamp("submitted_time")) + .thenReturn(Timestamp.valueOf("2026-07-17 10:11:12")); + return List.of(rowMapper.mapRow(attempt, 0)); + } + if (sql.contains("FROM aihr_onboard_exam_answer")) { + List rows = new ArrayList<>(); + rows.add(rowMapper.mapRow(answerRow(101L, true, 40, 40, "回答正确"), 0)); + rows.add(rowMapper.mapRow(answerRow(102L, false, 0, 60, + "应先确认现场风险并约定反馈时间"), 1)); + return rows; + } + return List.of(); + } catch (java.sql.SQLException error) { + throw new IllegalStateException(error); + } + } + + private static ResultSet answerRow(long questionId, boolean correct, int earnedScore, int maxScore, + String explanation) throws java.sql.SQLException { + ResultSet row = mock(ResultSet.class); + when(row.getLong("question_id")).thenReturn(questionId); + when(row.getBoolean("correct")).thenReturn(correct); + when(row.getInt("earned_score")).thenReturn(earnedScore); + when(row.getInt("max_score")).thenReturn(maxScore); + when(row.getString("explanation")).thenReturn(explanation); + return row; + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrExamScoringTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrExamScoringTest.java new file mode 100644 index 00000000..ea0837d3 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrExamScoringTest.java @@ -0,0 +1,84 @@ +package org.dromara.aihr.learning; + +import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDraft; +import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamTargetDraft; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrExamScoringTest { + + @Test + void objectiveAnswersUseExactSetComparisonAndShortAnswersRequireAllKeywords() { + var questions = List.of( + new ExamQuestionDraft(11L, "single", "第一步?", List.of("A", "B"), List.of("A"), 30, "", 1), + new ExamQuestionDraft(12L, "multiple", "哪些需要记录?", List.of("A", "B", "C"), List.of("A", "C"), 40, "", 2), + new ExamQuestionDraft(13L, "short", "闭环要素", List.of(), List.of("责任人", "反馈时间"), 30, "", 3) + ); + + var score = AihrExamScoring.score(questions, List.of( + new ExamAnswerRequest(11L, List.of("A")), + new ExamAnswerRequest(12L, List.of("C", "A")), + new ExamAnswerRequest(13L, List.of("先明确责任人,再约定反馈时间")) + )); + + assertEquals(100, score.score()); + assertTrue(score.results().stream().allMatch(result -> result.correct())); + + var partial = AihrExamScoring.score(questions, List.of( + new ExamAnswerRequest(11L, List.of("B")), + new ExamAnswerRequest(12L, List.of("A")), + new ExamAnswerRequest(13L, List.of("只说明责任人")) + )); + assertEquals(0, partial.score()); + assertFalse(partial.results().stream().anyMatch(result -> result.correct())); + } + + @Test + void examDraftRequiresQuestionsWhoseScoresTotalOneHundred() { + ExamUpsertRequest invalid = new ExamUpsertRequest( + "岗前测验", "P100", "生活顾问", 80, null, + List.of(new ExamQuestionDraft(null, "single", "第一步?", List.of("A", "B"), List.of("A"), 60, "", 1)) + ); + + ServiceException error = assertThrows(ServiceException.class, () -> AihrExamScoring.validate(invalid)); + assertEquals("试卷题目分值合计必须为100分", error.getMessage()); + } + + @Test + void examTargetsAreUniqueAndLimitedToOneHundred() { + assertThrows(ServiceException.class, () -> AihrExamScoring.validateTargets(List.of( + new ExamTargetDraft("EMP-1", "张三"), new ExamTargetDraft("EMP-1", "张三") + ))); + + List tooMany = java.util.stream.IntStream.rangeClosed(1, 101) + .mapToObj(index -> new ExamTargetDraft("EMP-" + index, "员工" + index)).toList(); + ServiceException error = assertThrows(ServiceException.class, + () -> AihrExamScoring.validateTargets(tooMany)); + assertEquals("单次考试最多选择100名员工", error.getMessage()); + } + + @Test + void feedbackAndContentHashAreCanonicalAndStrict() { + assertEquals("useful", AihrLearningService.normalizeVerdict(" useful ")); + assertEquals("not_useful", AihrLearningService.normalizeVerdict("not_useful")); + assertThrows(ServiceException.class, () -> AihrLearningService.normalizeVerdict("like")); + + String first = AihrLearningService.contentHash("v1", " 如何处理? ", "先承接情绪"); + String same = AihrLearningService.contentHash("v1", "如何处理?", "先承接情绪"); + String changed = AihrLearningService.contentHash("v2", "如何处理?", "先承接情绪"); + assertEquals(64, first.length()); + assertEquals(first, same); + assertFalse(first.equals(changed)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java new file mode 100644 index 00000000..926b9b03 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java @@ -0,0 +1,177 @@ +package org.dromara.aihr.learning; + +import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackRequest; +import org.dromara.aihr.learning.AihrLearningDto.DifficultyAssignRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.lang.reflect.ParameterizedType; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrLearningContractTest { + + @Test + void assignmentFeedbackIsAnOwnedIdempotentUpsert() { + FeedbackJdbcTemplate jdbcTemplate = new FeedbackJdbcTemplate(); + AihrLearningService service = new AihrLearningService(jdbcTemplate, null); + + var response = service.submitAssignmentFeedback(9L, + new AssignmentFeedbackRequest("not_useful", "题目与现场不符"), "EMP-1"); + + assertEquals(9L, response.assignmentId()); + assertEquals("not_useful", response.verdict()); + assertTrue(jdbcTemplate.feedbackSql.contains("ON DUPLICATE KEY UPDATE")); + assertEquals("EMP-1", jdbcTemplate.feedbackArgs[2]); + } + + @Test + void mobileControllerPublishesTheAgreedLearningAndExamRoutes() throws Exception { + String controller = Files.readString(projectPath( + "src/main/java/org/dromara/aihr/controller/AihrMobileController.java")); + + assertTrue(controller.contains("@PostMapping(\"/practice/assignments/{id}/feedback\")")); + assertTrue(controller.contains("@GetMapping(\"/practice/difficulties\")")); + assertTrue(controller.contains("@PostMapping(\"/practice/difficulties/assign\")")); + assertTrue(controller.contains("@GetMapping(\"/learning/matrix\")")); + assertTrue(controller.contains("@GetMapping(\"/exams\")")); + assertTrue(controller.contains("@PostMapping(\"/exams/{id}/submit\")")); + assertTrue(controller.contains("@PostMapping(\"/exams/supervisor/{id}/publish\")")); + assertTrue(controller.contains("@GetMapping(\"/exams/supervisor/{id}/results\")")); + } + + @Test + void migrationKeepsVersionedSnapshotsFeedbackAndExamAttempts() throws Exception { + String migration = Files.readString(projectPath( + "../../script/sql/update/aihr_20260717_learning_closure_mysql8.sql")); + + assertTrue(migration.contains("question_snapshot")); + assertTrue(migration.contains("reference_answer_snapshot")); + assertTrue(migration.contains("idx_aihr_assignment_recent_content")); + assertTrue(migration.contains("aihr_practice_question_feedback")); + assertTrue(migration.contains("aihr_onboard_exam_attempt")); + assertTrue(migration.contains("uk_aihr_onboard_exam_attempt_request")); + assertTrue(migration.contains("aihr_onboard_exam_target")); + assertTrue(migration.contains("publish_request_key")); + assertTrue(migration.contains("uk_aihr_onboard_exam_target")); + assertTrue(migration.contains("uk_aihr_onboard_exam_publish_request")); + } + + @Test + void employeeExamVisibilityIsTargetBasedAndPublishRequiresOneToOneHundredTargets() throws Exception { + String service = Files.readString(projectPath( + "src/main/java/org/dromara/aihr/learning/AihrExamService.java")); + + assertTrue(service.contains("JOIN aihr_onboard_exam_target own_target")); + assertTrue(service.contains("own_target.ext_party_id = ?")); + assertTrue(service.contains("JOIN aihr_onboard_exam_target t")); + assertTrue(service.contains("t.ext_party_id = ?")); + assertTrue(service.contains("targetCount < 1 || targetCount > 100")); + assertTrue(service.contains("assertPublishRequestAvailable")); + assertTrue(service.contains("publish_request_key = ?")); + } + + @Test + void submitContractUsesAnExplicitAnswerListRatherThanAnAmbiguousRecordMap() { + var answers = java.util.Arrays.stream(ExamSubmitRequest.class.getRecordComponents()) + .filter(component -> "answers".equals(component.getName())) + .findFirst().orElseThrow(); + + assertEquals(List.class, answers.getType()); + ParameterizedType generic = (ParameterizedType) answers.getGenericType(); + assertEquals(AihrLearningDto.ExamAnswerRequest.class, generic.getActualTypeArguments()[0]); + } + + @Test + void commonDifficultyAssignmentUsesTheDisplayedWindowAndPositionScope() throws Exception { + assertEquals(30, AihrLearningService.validateDifficultyWindow(null)); + assertEquals(7, AihrLearningService.validateDifficultyWindow(7)); + assertThrows(org.dromara.common.core.exception.ServiceException.class, + () -> AihrLearningService.validateDifficultyWindow(0)); + assertThrows(org.dromara.common.core.exception.ServiceException.class, + () -> AihrLearningService.validateDifficultyWindow(366)); + + String service = Files.readString(projectPath( + "src/main/java/org/dromara/aihr/learning/AihrLearningService.java")); + assertTrue(service.contains("difficultyTargets(request.scenarioId(), windowDays, request.position()")); + assertTrue(service.contains("TIMESTAMPADD(DAY, -?, NOW())")); + assertTrue(service.contains("scenario.position = ?")); + assertTrue(service.contains("requestedIdentities.contains")); + + DifficultyJdbcTemplate jdbcTemplate = new DifficultyJdbcTemplate(); + AihrLearningService learningService = new AihrLearningService(jdbcTemplate, null); + assertThrows(org.dromara.common.core.exception.ServiceException.class, () -> + learningService.assignDifficulty(new DifficultyAssignRequest( + "SCENE-1", "近期低分", "2026-07-18", "difficulty:1", 7, "保洁", List.of()), "")); + assertTrue(jdbcTemplate.difficultySql.contains("TIMESTAMPADD(DAY, -?, NOW())")); + assertTrue(jdbcTemplate.difficultySql.contains("scenario.position = ?")); + assertEquals(7, jdbcTemplate.difficultyArgs[3]); + assertEquals(7, jdbcTemplate.difficultyArgs[6]); + assertEquals("保洁", jdbcTemplate.difficultyArgs[9]); + assertEquals("保洁", jdbcTemplate.difficultyArgs[10]); + } + + private static Path projectPath(String relative) { + Path direct = Path.of(relative); + if (Files.exists(direct)) { + return direct; + } + return Path.of("ruoyi-modules/ruoyi-aihr").resolve(relative).normalize(); + } + + private static final class FeedbackJdbcTemplate extends JdbcTemplate { + private String feedbackSql = ""; + private Object[] feedbackArgs = new Object[0]; + + @Override + public void execute(String sql) { + // DDL is covered by the migration contract test. + } + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + return requiredType.cast(1); + } + + @Override + public int update(String sql, Object... args) { + if (sql.contains("aihr_practice_question_feedback")) { + feedbackSql = sql; + feedbackArgs = args; + } + return 1; + } + } + + private static final class DifficultyJdbcTemplate extends JdbcTemplate { + private String difficultySql = ""; + private Object[] difficultyArgs = new Object[0]; + + @Override + public void execute(String sql) { + // DDL is covered by the migration contract test. + } + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + return requiredType.cast(1); + } + + @Override + public List query(String sql, org.springframework.jdbc.core.RowMapper rowMapper, Object... args) { + if (sql.contains("FROM aihr_practice_assignment a") && sql.contains("TIMESTAMPADD")) { + difficultySql = sql; + difficultyArgs = args; + } + return List.of(); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrFivePositionCatalogMigrationTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrFivePositionCatalogMigrationTest.java new file mode 100644 index 00000000..42596bb1 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrFivePositionCatalogMigrationTest.java @@ -0,0 +1,92 @@ +package org.dromara.aihr.service; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrFivePositionCatalogMigrationTest { + + private static final Pattern CATALOG_ROW = Pattern.compile( + "(?m)^\\('(?:生活顾问|保安|保洁|保修|客服)', '([A-Za-z0-9-]+)', '([^']+)', '[^']+', '(SOP-[A-Z]{2}-\\d{3})',"); + + @Test + void migrationSeedsTwentyScenariosForEachPositionWithUniqueSopCodes() throws Exception { + String sql = migrationSql(); + + Matcher matcher = CATALOG_ROW.matcher(sql); + Set scenarioCodes = new HashSet<>(); + Set sopCodes = new HashSet<>(); + int rows = 0; + while (matcher.find()) { + rows++; + scenarioCodes.add(matcher.group(1)); + sopCodes.add(matcher.group(3)); + } + + assertEquals(100, rows); + assertEquals(100, scenarioCodes.size()); + assertEquals(100, sopCodes.size()); + for (String position : Set.of("生活顾问", "保安", "保洁", "保修", "客服")) { + assertEquals(20, countRows(sql, position)); + } + } + + @Test + void migrationBuildsDailyAndSpecialVersionedQuestionsForEveryScenario() throws Exception { + String sql = migrationSql(); + + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS `aihr_learning_question`")); + assertTrue(sql.contains("`question_type` varchar(20) NOT NULL DEFAULT 'daily'")); + assertTrue(sql.contains("SELECT 'daily' AS `question_type`")); + assertTrue(sql.contains("UNION ALL SELECT 'special'")); + assertTrue(sql.contains("CONCAT(c.`scenario_code`, '-', q.`question_type`, '-v1')")); + assertTrue(sql.contains("SHA2(CONCAT_WS('|', c.`scenario_code`, q.`question_type`")); + assertTrue(sql.contains("`content_version` = VALUES(`content_version`)")); + assertTrue(sql.contains("`content_hash` = VALUES(`content_hash`)")); + } + + @Test + void migrationIsRepeatableAndKeepsUnreviewedHighRiskContentDisabled() throws Exception { + String sql = migrationSql(); + + assertTrue(sql.contains("ON DUPLICATE KEY UPDATE")); + assertTrue(sql.contains("information_schema.columns")); + assertTrue(sql.contains("COALESCE(MAX(`id`), 0) + 1")); + assertTrue(sql.contains("ALTER TABLE aihr_practice_scenario AUTO_INCREMENT =")); + assertTrue(sql.contains("'BA-FIRE-ALARM', '消防报警首响与升级'")); + assertTrue(sql.contains("'BA-ELEVATOR-TRAPPED', '电梯困人信息收集与升级'")); + assertTrue(sql.contains("'BX-LIVE-ELECTRICAL', '带电故障处置边界'")); + assertTrue(sql.contains("'BJ-HIGH-RISE-EXTERIOR', '高空外墙保洁边界'")); + assertTrue(sql.contains("c.`enabled`,\n CASE q.`question_type`")); + } + + private static long countRows(String sql, String position) { + return Pattern.compile("(?m)^\\('" + Pattern.quote(position) + "', '").matcher(sql).results().count(); + } + + private static String migrationSql() throws Exception { + return Files.readString(repoRoot().resolve( + "backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql")); + } + + private static Path repoRoot() { + Path current = Path.of("").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("backend/script/sql"))) { + current = current.getParent(); + } + if (current == null) { + throw new IllegalStateException("repository root not found"); + } + return current; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java index c7b12fa6..50512ac5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java @@ -79,18 +79,61 @@ public class AihrOrgSyncServiceTest { """); ReflectionTestUtils.setField(service, "storeDisplayFields", false); - Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of()); + Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of()); assertEquals("", ReflectionTestUtils.getField(masked, "personName")); assertEquals("", ReflectionTestUtils.getField(masked, "deptName")); assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(masked, "hireDate")); ReflectionTestUtils.setField(service, "storeDisplayFields", true); - Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of()); + Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of()); assertEquals("测试员工", ReflectionTestUtils.getField(demo, "personName")); assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName")); assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(demo, "hireDate")); } + @Test + public void projectAssignmentDeterminesProjectScopeAndPermissionLevel() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class)); + ReflectionTestUtils.setField(service, "storeDisplayFields", true); + var employee = mapper.readTree(""" + {"id":"EMP-1","employee_number":"YC001","name":"测试主管","department_id":"D-1","position_name":"员工","position_level":3,"phone":"13900001111"} + """); + List departments = List.of(mapper.readTree(""" + {"id":"D-1","name":"生活服务部","code":"D001","company_id":"C-1"} + """)); + List projects = List.of(mapper.readTree(""" + {"id":"P-1","project_name":"翡翠湾","code":"FW001","company_id":"C-1"} + """)); + List assignments = List.of(mapper.readTree(""" + {"employee_id":"EMP-1","employee_number":"YC001","project_id":"P-1","project_code":"FW001","project_name":"翡翠湾","employee_department_id":"D-1","employee_department_name":"生活服务部","project_position_name":"管家主管","project_position_level":"foreman","is_primary":true,"status":"active"} + """)); + + Object row = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), + ReflectionTestUtils.invokeMethod(service, "departmentMap", departments), + ReflectionTestUtils.invokeMethod(service, "projectMap", projects), + ReflectionTestUtils.invokeMethod(service, "assignmentMap", assignments)); + + assertEquals("FW001", ReflectionTestUtils.getField(row, "projectCode")); + assertEquals("翡翠湾", ReflectionTestUtils.getField(row, "projectName")); + assertEquals("生活服务部", ReflectionTestUtils.getField(row, "deptName")); + assertEquals("管家主管", ReflectionTestUtils.getField(row, "positionName")); + assertEquals("主管", ReflectionTestUtils.getField(row, "positionLevel")); + } + + @Test + public void numericRawPositionLevelFallsBackToPositionName() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class)); + var employee = mapper.readTree(""" + {"id":"EMP-1","project_name":"翡翠湾","position_name":"项目经理","position_level":2,"phone":"13900001111"} + """); + + Object row = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of()); + + assertEquals("项目经理", ReflectionTestUtils.getField(row, "positionLevel")); + } + @Test public void suspectTextIsCheckedBeforeDisplayFieldsAreMasked() throws Exception { ObjectMapper mapper = new ObjectMapper(); @@ -100,7 +143,7 @@ public class AihrOrgSyncServiceTest { """); ReflectionTestUtils.setField(service, "storeDisplayFields", false); - Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of()); + Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of()); assertEquals("", ReflectionTestUtils.getField(masked, "personName")); assertEquals("", ReflectionTestUtils.getField(masked, "deptName")); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index a49906c1..53622058 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -5,6 +5,9 @@ import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse; import org.dromara.aihr.domain.AihrPracticeDto.DimensionResponse; import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; import org.dromara.aihr.domain.AihrPracticeDto.StartRequest; @@ -33,8 +36,10 @@ import java.sql.SQLException; import java.sql.Timestamp; import java.time.LocalDate; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -113,6 +118,101 @@ public class AihrPracticeSeedServiceTest { assertEquals("缺少员工ID,无法派发专项训练", error.getMessage()); } + @Test + public void batchAssignmentRequiresStableRequestIdAndAtLeastOneTarget() { + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null); + + ServiceException missingRequest = assertThrows(ServiceException.class, () -> service.createAssignments( + new PracticeAssignmentBatchRequest("", List.of(new PracticeAssignmentTarget("EMP-1", "员工甲")), + "fee-parking", null, "2026-07-18"), "SUP-1")); + assertEquals("缺少批次请求ID", missingRequest.getMessage()); + + ServiceException missingTarget = assertThrows(ServiceException.class, () -> service.createAssignments( + new PracticeAssignmentBatchRequest("assign-20260717-001", List.of(), + "fee-parking", null, "2026-07-18"), "SUP-1")); + assertEquals("请至少选择一名员工", missingTarget.getMessage()); + } + + @Test + public void batchAssignmentDueDateDefaultsToTomorrowAndRejectsPastDate() { + LocalDate today = LocalDate.of(2026, 7, 17); + + assertEquals(LocalDate.of(2026, 7, 18), AihrPracticeSeedService.normalizeAssignmentDueDate(null, today)); + assertEquals(today, AihrPracticeSeedService.normalizeAssignmentDueDate("2026-07-17", today)); + ServiceException past = assertThrows(ServiceException.class, + () -> AihrPracticeSeedService.normalizeAssignmentDueDate("2026-07-16", today)); + assertEquals("截止日期不能早于今天", past.getMessage()); + ServiceException malformed = assertThrows(ServiceException.class, + () -> AihrPracticeSeedService.normalizeAssignmentDueDate("2026/07/18", today)); + assertEquals("截止日期格式应为YYYY-MM-DD", malformed.getMessage()); + } + + @Test + public void batchAssignmentUsesPerEmployeeTransactionsAndDatabaseIdempotency() throws Exception { + String serviceSource = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")); + String controllerSource = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrMobileController.java")); + + assertTrue(serviceSource.contains("TransactionDefinition.PROPAGATION_REQUIRES_NEW")); + assertTrue(serviceSource.contains("() -> createAssignmentInternal(itemRequest, batchScenario, storedExtPartyId)")); + assertTrue(serviceSource.contains("TeamScope scope = teamScope(supervisorExtPartyId)")); + assertTrue(serviceSource.contains("AssignmentStorageIdentityResolution storageResolution = assignmentStorageIdentities(requestedExtPartyIds)")); + assertTrue(serviceSource.contains("phone_owner_count")); + assertTrue(serviceSource.contains("ambiguousExtPartyIds().contains")); + assertTrue(serviceSource.contains("uk_aihr_assignment_request")); + assertTrue(serviceSource.contains("new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, \"FAILED\"")); + assertTrue(controllerSource.contains("@PostMapping(\"/practice/assignments/batch\")")); + } + + @Test + public void batchAssignmentCreatesIndependentRowsAndRetryIsIdempotent() { + BatchAssignmentJdbcTemplate jdbcTemplate = new BatchAssignmentJdbcTemplate(); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + PracticeAssignmentBatchRequest request = new PracticeAssignmentBatchRequest( + "assign-20260717-002", + List.of( + new PracticeAssignmentTarget("EMP-1", "员工甲"), + new PracticeAssignmentTarget("EMP-2", "员工乙") + ), + "complaint-water", + "投诉沟通巩固", + "2099-07-20" + ); + + var first = service.createAssignments(request, ""); + var retry = service.createAssignments(request, ""); + + assertEquals(2, first.created()); + assertEquals(0, first.failed()); + assertEquals(List.of("CREATED", "CREATED"), first.results().stream().map(item -> item.status()).toList()); + assertEquals(0, retry.created()); + assertEquals(2, retry.duplicate()); + assertEquals(List.of("DUPLICATE", "DUPLICATE"), retry.results().stream().map(item -> item.status()).toList()); + assertEquals(2, jdbcTemplate.insertCount); + } + + @Test + public void batchAssignmentKeepsSuccessfulEmployeesWhenAnotherInsertFails() { + BatchAssignmentJdbcTemplate jdbcTemplate = new BatchAssignmentJdbcTemplate("EMP-FAIL"); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + var result = service.createAssignments(new PracticeAssignmentBatchRequest( + "assign-20260717-003", + List.of( + new PracticeAssignmentTarget("EMP-1", "员工甲"), + new PracticeAssignmentTarget("EMP-FAIL", "员工失败") + ), + "complaint-water", + null, + "2099-07-20" + ), ""); + + assertEquals(1, result.created()); + assertEquals(1, result.failed()); + assertEquals("CREATED", result.results().get(0).status()); + assertEquals("FAILED", result.results().get(1).status()); + assertEquals(1, jdbcTemplate.insertCount); + } + @Test public void historyAndReviewDetailUseDisplayFieldPrivacyContract() throws Exception { Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"); @@ -227,6 +327,99 @@ public class AihrPracticeSeedServiceTest { assertFalse(AihrPracticeSeedService.isLegacyDailyDrillFallbackEnabled(false, "dev")); } + @Test + public void dailyDrillsRotateByDatePositionAndEmployeeWithoutConsecutiveRepeats() { + LocalDate date = LocalDate.of(2026, 7, 17); + + List today = + AihrPracticeSeedService.dailyDrills(date, "物业管家", "EMP-1003"); + List sameScope = + AihrPracticeSeedService.dailyDrills(date, "物业管家", "EMP-1003"); + List tomorrow = + AihrPracticeSeedService.dailyDrills(date.plusDays(1), "物业管家", "EMP-1003"); + List anotherEmployee = + AihrPracticeSeedService.dailyDrills(date, "物业管家", "EMP-1004"); + List anotherPosition = + AihrPracticeSeedService.dailyDrills(date, "客服专员", "EMP-1003"); + + assertEquals(today, sameScope); + assertEquals(3, today.size()); + assertEquals(List.of("每日三题 1:", "每日三题 2:", "每日三题 3:"), + today.stream().map(drill -> drill.question().substring(0, 7)).toList()); + assertTrue(java.util.Collections.disjoint( + today.stream().map(AihrPracticeSeedService.DailyDrillSeed::question).toList(), + tomorrow.stream().map(AihrPracticeSeedService.DailyDrillSeed::question).toList() + )); + assertFalse(today.equals(anotherEmployee)); + assertFalse(today.equals(anotherPosition)); + } + + @Test + public void dailyDrillQuestionCarriesItsReferenceSnapshot() throws Exception { + String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")); + + assertTrue(source.contains("dailyDrillReason(drill)")); + assertTrue(source.contains("return drill.question() + \" 参考答案:\" + drill.referenceAnswer();")); + assertTrue(source.contains("dailyDrills(today, dailyDrillPosition(extPartyId), extPartyId)")); + assertTrue(source.contains("dailyRequestKey(today, i)")); + assertTrue(source.contains("catch (DuplicateKeyException ignored)")); + } + + @Test + public void dailyRequestKeysAreStablePerDateAndDistinctPerSlot() { + LocalDate date = LocalDate.of(2026, 7, 17); + + assertEquals("daily:2026-07-17:1", AihrPracticeSeedService.dailyRequestKey(date, 0)); + assertEquals("daily:2026-07-17:2", AihrPracticeSeedService.dailyRequestKey(date, 1)); + assertEquals("daily:2026-07-18:1", AihrPracticeSeedService.dailyRequestKey(date.plusDays(1), 0)); + } + + @Test + public void assignmentVisibilityDeduplicatesLegacyDailyRowsAndCapsThreePerEmployee() { + List rows = new ArrayList<>(); + rows.add(assignment(1L, "EMP-1", "daily", "问题一")); + rows.add(assignment(2L, "EMP-1", "daily", "问题一")); + rows.add(assignment(3L, "EMP-1", "daily", "问题二")); + rows.add(assignment(4L, "EMP-1", "daily", "问题二")); + rows.add(assignment(5L, "EMP-1", "daily", "问题三")); + rows.add(assignment(6L, "EMP-1", "daily", "问题三")); + rows.add(assignment(7L, "EMP-1", "daily", "问题四")); + + List visible = AihrPracticeSeedService.visibleAssignments(rows, 10); + + assertEquals(List.of("问题一", "问题二", "问题三"), + visible.stream().map(PracticeAssignmentResponse::question).toList()); + } + + @Test + public void legacyDuplicateDailyRowsStillLeaveMissingSlotsToGenerate() { + List drills = List.of( + new AihrPracticeSeedService.DailyDrillSeed("scene-1", "问题一", "答案一"), + new AihrPracticeSeedService.DailyDrillSeed("scene-2", "问题二", "答案二"), + new AihrPracticeSeedService.DailyDrillSeed("scene-3", "问题三", "答案三") + ); + + assertEquals(List.of(1, 2), AihrPracticeSeedService.missingDailyDrillSlots(drills, Set.of("问题一"))); + assertEquals(List.of(), AihrPracticeSeedService.missingDailyDrillSlots(drills, Set.of("问题一", "问题二", "问题三"))); + } + + @Test + public void batchRequestIdRejectsReservedDailyNamespace() { + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null); + + ServiceException error = assertThrows(ServiceException.class, () -> service.createAssignments( + new PracticeAssignmentBatchRequest("daily:2026-07-17:1", List.of( + new PracticeAssignmentTarget("EMP-1", "员工甲") + ), "complaint-water", null, "2099-07-20"), "")); + + assertEquals("批次请求ID不能使用系统保留前缀", error.getMessage()); + } + + private static PracticeAssignmentResponse assignment(Long id, String extPartyId, String source, String question) { + return new PracticeAssignmentResponse(id, extPartyId, "员工", "scene", "场景", source, question, + "待训练", "", question, null, null, null, null, null, null, null, null, null); + } + @Test public void dailyDrillAnswerDoesNotOverwriteCompletedAttempt() throws Exception { String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")); @@ -311,7 +504,10 @@ public class AihrPracticeSeedServiceTest { String controller = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrPracticeController.java")); String mobileController = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrMobileController.java")); - assertTrue(service.contains("FROM sys_oss\n WHERE tenant_id = ? AND oss_id = ? AND create_by = ?")); + assertTrue(service.contains("FROM aihr_practice_audio_upload")); + assertTrue(service.contains("owner_user_id = ? AND owner_identity = ?")); + assertTrue(service.contains("status IN ('staged', 'bound')")); + assertFalse(service.contains("FROM sys_oss\n WHERE tenant_id = ? AND oss_id = ? AND create_by = ?")); assertTrue(service.contains("validateAudioOwnership(request == null ? null : request.traineeAudioOssId(), ownerIdentity, ownerUserId)")); assertTrue(service.contains("validateAudioOwnership(audioOssId, extPartyId, ownerUserId)")); assertTrue(service.contains("d.answer_audio_oss_id = ?")); @@ -1044,6 +1240,11 @@ public class AihrPracticeSeedServiceTest { // Table creation is irrelevant for query assembly tests. } + @Override + public int update(String sql, Object... args) { + return 1; + } + @Override public List query(String sql, RowMapper rowMapper, Object... args) { try { @@ -1070,6 +1271,76 @@ public class AihrPracticeSeedServiceTest { } } + private static final class BatchAssignmentJdbcTemplate extends JdbcTemplate { + private final Map> rows = new LinkedHashMap<>(); + private final String failingExtPartyId; + private int insertCount; + + private BatchAssignmentJdbcTemplate() { + this(""); + } + + private BatchAssignmentJdbcTemplate(String failingExtPartyId) { + this.failingExtPartyId = failingExtPartyId; + } + + @Override + public T queryForObject(String sql, Class requiredType) { + return requiredType.cast(sql.contains("aihr_org_snapshot") ? 0 : 1); + } + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + return requiredType.cast(1); + } + + @Override + public void execute(String sql) { + // Schema DDL is outside this behavior test. + } + + @Override + public int update(String sql, Object... args) { + if (!sql.contains("INSERT INTO aihr_practice_assignment")) { + return 0; + } + String extPartyId = String.valueOf(args[1]); + if (extPartyId.equals(failingExtPartyId)) { + throw new org.springframework.dao.DataAccessResourceFailureException("simulated target insert failure"); + } + String requestId = String.valueOf(args[8]); + insertCount++; + rows.put(requestId + "|" + extPartyId, Map.ofEntries( + Map.entry("id", String.valueOf(insertCount)), + Map.entry("ext_party_id", extPartyId), + Map.entry("trainee_name", ""), + Map.entry("scenario_id", String.valueOf(args[3])), + Map.entry("scenario_name", String.valueOf(args[4])), + Map.entry("source", "manual"), + Map.entry("reason", String.valueOf(args[6])), + Map.entry("status", "待训练") + )); + return 1; + } + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + if (sql.contains("request_key = ?")) { + Map row = rows.get(String.valueOf(args[1]) + "|" + String.valueOf(args[2])); + if (row == null) { + return List.of(); + } + try { + return mapRows(rowMapper, List.of(row)); + } catch (SQLException ex) { + throw new IllegalStateException(ex); + } + } + // No database scenario override: keep the built-in phase-one seed. + return List.of(); + } + } + private static final class PracticeReviewJdbcTemplate extends JdbcTemplate { private final String ownerLevel; private final List scopeIds; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java index f041dddc..1dae9a78 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java @@ -2,9 +2,11 @@ package org.dromara.aihr.service; import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.domain.AihrSopDto; +import org.dromara.common.core.exception.ServiceException; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -18,6 +20,7 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -175,6 +178,32 @@ public class AihrSopSeedServiceTest { assertTrue(code.contains("private void deleteUploadedOssQuietly(SysOssVo oss)")); } + @Test + @Tag("dev") + public void unbindingOneSpaceKeepsSharedOssUntilEveryMembershipIsGone() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + } + String code = Files.readString(source); + String method = code.substring(code.indexOf("public UnbindDocumentResponse unbindDocumentMembership"), + code.indexOf("public VectorizeResponse vectorizeMissing")); + + assertTrue(method.contains("where a.tenant_id = ? and k.code = ? and a.id = ?")); + assertTrue(method.contains("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?")); + assertTrue(method.contains("delete from aihr_knowledge_attach where tenant_id = ? and knowledge_id = ? and id = ?")); + assertTrue(method.contains("afterCommitOrNow(() -> deleteOrphanedOss")); + assertTrue(code.contains("select oss_id from sys_oss where oss_id = ? for update")); + assertTrue(code.contains("select count(*) from aihr_knowledge_attach where oss_id = ?")); + assertTrue(code.indexOf("references != 0") < code.indexOf("ossService.deleteWithValidByIds")); + assertTrue(code.contains("PROPAGATION_REQUIRES_NEW")); + assertTrue(code.contains("lockOssForReference(ossId)")); + assertTrue(code.contains("@Transactional(rollbackFor = Exception.class)\n public UploadResponse uploadDoc(MultipartFile file, String category)")); + assertTrue(code.contains("@Transactional(rollbackFor = Exception.class)\n public UploadResponse processStagedDocument(String fileName, String category, Path stagedFile)")); + assertTrue(code.contains("!TransactionSynchronizationManager.isActualTransactionActive() && requiresNewTransaction != null")); + assertTrue(code.contains("TenantHelper.ignore")); + } + @Test @Tag("dev") public void disabledVisionGateStopsBeforeDatabaseLookup() throws Exception { @@ -349,13 +378,27 @@ public class AihrSopSeedServiceTest { } String code = Files.readString(source); - assertTrue(code.contains("join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id")); - assertTrue(code.contains("left join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id")); + assertTrue(code.contains("join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id")); + assertTrue(code.contains("left join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id")); assertTrue(code.contains("left join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id")); assertTrue(code.contains("where tenant_id = ? and oss_id = ? limit 1")); assertTrue(code.contains("where tenant_id = ? and oss_id = ?\", objectMapper.writeValueAsString(node), tenantId(), ossId")); } + @Test + @Tag("dev") + @SuppressWarnings({"rawtypes", "unchecked"}) + public void processingOverviewPropagatesDatabaseFailures() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenThrow(new DataAccessResourceFailureException("collation mismatch")); + AihrModelSeedService modelService = mock(AihrModelSeedService.class); + when(modelService.visionAllowed()).thenReturn(false); + AihrSopSeedService service = new AihrSopSeedService(new ObjectMapper(), jdbcTemplate, null, "", null, null, modelService); + + assertThrows(DataAccessResourceFailureException.class, service::processingOverview); + } + @Test @Tag("dev") public void qdrantRebuildKeepsOtherTenantsVectors() throws Exception { @@ -372,6 +415,155 @@ public class AihrSopSeedServiceTest { assertFalse(code.contains("qdrantRequest(\"DELETE\", \"/collections/\" + qdrantCollection()")); } + @Test + @Tag("dev") + public void authorizedSearchScopesMysqlAndQdrantToKnowledgeIds() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + } + String code = Files.readString(source); + assertTrue(code.contains("searchAuthorized(SearchRequest request, String requesterExtPartyId, Set allowedKnowledgeIds)")); + assertTrue(code.contains(".knowledge_id in (")); + assertTrue(code.contains("qdrantMatchAny(\"knowledge_id\", knowledgeIds)")); + + AihrSopSeedService service = new AihrSopSeedService( + new ObjectMapper(), mock(JdbcTemplate.class), null, "", null, null, mock(AihrModelSeedService.class)); + assertThrows(org.dromara.common.core.exception.ServiceException.class, + () -> service.searchAuthorized(new AihrSopDto.SearchRequest("问题", "sop", null, null, 5), "", Set.of())); + } + + @Test + @Tag("dev") + public void legacySearchControllerDelegatesToAuthorizedQueryService() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/controller/AihrSopController.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java"); + } + String code = Files.readString(source); + assertTrue(code.contains("knowledgeQueryService.queryInternal(new QueryRequest")); + assertFalse(code.contains("return R.ok(sopSeedService.search(restrictDemoVerificationSource")); + } + + @Test + @Tag("dev") + public void duplicateDocumentsNeverMoveOrDeleteOtherSpaceMemberships() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + } + String code = Files.readString(source); + String method = code.substring(code.indexOf("private String reuseDuplicateAttach"), + code.indexOf("private void updateOssInsight")); + assertFalse(method.contains("set knowledge_id = ?")); + assertFalse(method.contains("delete from aihr_knowledge_fragment")); + assertTrue(code.contains("saveDocumentToSpaces")); + assertTrue(code.contains("generateEmbeddings(fragments)")); + } + + @Test + @Tag("dev") + public void multiSpaceUploadsRollbackDatabaseMembershipsAsOneUnit() throws Exception { + assertTrue(AihrSopSeedService.class + .getMethod("uploadDoc", org.springframework.web.multipart.MultipartFile.class, List.class) + .getAnnotation(org.springframework.transaction.annotation.Transactional.class) != null); + assertTrue(AihrSopSeedService.class + .getMethod("processStagedDocument", String.class, List.class, Path.class) + .getAnnotation(org.springframework.transaction.annotation.Transactional.class) != null); + + Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + String code = Files.readString(source); + assertTrue(code.contains("TransactionSynchronizationManager.registerSynchronization")); + assertTrue(code.contains("public void afterCommit()")); + assertTrue(code.contains("afterCommitOrNow(() -> deleteQdrantDocNow")); + assertTrue(code.contains("afterCommitOrNow(() -> {\n try {\n upsertQdrant")); + } + + @Test + @Tag("dev") + public void videoProcessTimeoutsWaitBeforeReadingProcessOutput() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrVideoService.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java"); + } + String code = Files.readString(source); + int probe = code.indexOf("private long probeDurationSeconds"); + int run = code.indexOf("private int runFfmpeg", probe); + String probeMethod = code.substring(probe, run); + assertTrue(probeMethod.indexOf("waitFor(30") < probeMethod.indexOf("readAllBytes()")); + assertTrue(code.contains("redirectOutput(ProcessBuilder.Redirect.DISCARD)")); + assertTrue(code.contains("terminateProcess(process)")); + assertTrue(code.contains("process.destroyForcibly();\n process.waitFor(5")); + } + + @Test + @Tag("dev") + public void summaryCardFallsBackToRetrievedEvidenceWithoutInventingScripts() { + List hits = List.of( + new AihrSopSeedService.KnowledgeHit( + 1L, + "投诉处理 SOP.pdf", + "sop", + "", + "doc-1", + "先记录业主诉求、房号和联系方式。30分钟内反馈首个处理节点,并保留工单记录。", + 1, + 1.0 + ), + new AihrSopSeedService.KnowledgeHit( + 2L, + "报修跟进 SOP.pdf", + "sop", + "", + "doc-2", + "派单后确认责任人和完成时限;复查通过后再关闭工单。", + 1, + 0.9 + ) + ); + + AihrSopDto.SummaryCardResponse response = AihrSopSeedService.evidenceSummaryCard( + "业主投诉漏水,第一步怎么处理?", + hits + ); + + assertFalse(response.title().isBlank()); + assertTrue(response.steps().size() >= 2); + assertTrue(response.steps().stream().anyMatch(step -> step.desc().contains("记录业主诉求"))); + assertFalse(response.steps().stream().anyMatch(step -> step.desc().contains("确认责任人"))); + assertTrue(response.scripts().isEmpty()); + assertTrue(response.objections().isEmpty()); + assertEquals(List.of("来源:投诉处理 SOP.pdf"), response.reminders()); + } + + @Test + @Tag("dev") + public void summaryCardRejectsUnrelatedEvidenceInsteadOfPackagingFirstHit() { + List hits = List.of( + new AihrSopSeedService.KnowledgeHit(1L, "信息安全制度.pdf", "sop", "", "doc-1", + "员工电脑必须启用屏幕锁定并定期更新密码。", 1, 1.0) + ); + + ServiceException error = assertThrows(ServiceException.class, + () -> AihrSopSeedService.evidenceSummaryCard("停车费催缴怎么沟通?", hits)); + + assertTrue(error.getMessage().contains("相关度不足")); + } + + @Test + @Tag("dev") + public void summaryCardControllerCarriesTheSameKnowledgeSpaceReadScope() throws Exception { + Path controller = Path.of("src/main/java/org/dromara/aihr/controller/AihrSopController.java"); + if (!Files.exists(controller)) { + controller = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java"); + } + String code = Files.readString(controller); + + assertTrue(code.contains("resolveInternalSpaceIds")); + assertTrue(code.contains("principal, app, List.of(), \"READ\"")); + assertTrue(code.contains("summaryCardAuthorized(request, allowedKnowledgeIds)")); + } + private static AihrSopSeedService.KnowledgeHit hit(Long fragmentId, String title) { return new AihrSopSeedService.KnowledgeHit(fragmentId, title, "sop", "", "doc-" + fragmentId, "片段内容", 1, 1.0); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java index 9bc67760..20f825c6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java @@ -2,16 +2,67 @@ package org.dromara.aihr.service; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.jdbc.core.JdbcTemplate; +import org.mockito.ArgumentCaptor; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @Tag("dev") class AihrUploadQueueServiceTest { + @TempDir + Path tempDir; + + @Test + void expandsLegacyGbkZipAtomicallyAndKeepsDuplicateEntries() throws Exception { + Path staging = Files.createDirectories(tempDir.resolve("staging")); + Path archive = tempDir.resolve("legacy.zip"); + writeZip(archive, Charset.forName("GBK"), "目录一/附件.docx", "目录二/附件.docx"); + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.update(anyString(), any(Object[].class))).thenReturn(1); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + try { + AihrUploadQueueService service = new AihrUploadQueueService(null, jdbcTemplate, executor, staging.toString()); + + int extracted = assertDoesNotThrow(() -> expandArchive(service, itemRow(archive))); + + assertEquals(2, extracted); + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbcTemplate, times(1)).update(anyString(), parameters.capture()); + Object[] values = parameters.getValue(); + assertEquals(12, values.length); + assertEquals("附件.docx", values[2]); + assertEquals("[]", values[4]); + assertEquals("附件(2).docx", values[8]); + assertTrue(Files.isRegularFile(Path.of(values[5].toString()))); + assertTrue(Files.isRegularFile(Path.of(values[11].toString()))); + } finally { + executor.shutdownNow(); + } + } + @Test void queueKeepsTenantWhenWorkerLeavesLoginThread() throws Exception { Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java"); @@ -31,4 +82,34 @@ class AihrUploadQueueServiceTest { assertFalse(code.contains("\"暂存文件写入失败:\" + e.getMessage()")); assertFalse(code.contains("where tenant_id = ? and status = 1\n and (")); } + + private static void writeZip(Path archive, Charset charset, String... entries) throws IOException { + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(archive), charset)) { + for (String entry : entries) { + output.putNextEntry(new ZipEntry(entry)); + output.write("fixture".getBytes()); + output.closeEntry(); + } + } + } + + private static Object itemRow(Path archive) throws ReflectiveOperationException { + Class rowType = Class.forName("org.dromara.aihr.service.AihrUploadQueueService$ItemRow"); + Constructor constructor = rowType.getDeclaredConstructors()[0]; + constructor.setAccessible(true); + return constructor.newInstance("000000", 1L, "batch", "legacy.zip", "__auto__", java.util.List.of(), archive.toString(), 1); + } + + private static int expandArchive(AihrUploadQueueService service, Object itemRow) throws Exception { + Method method = service.getClass().getDeclaredMethod("expandArchive", itemRow.getClass()); + method.setAccessible(true); + try { + return (int) method.invoke(service, itemRow); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof Exception cause) { + throw cause; + } + throw e; + } + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java new file mode 100644 index 00000000..ba1cece4 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java @@ -0,0 +1,185 @@ +package org.dromara.aihr.webai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest; +import org.dromara.aihr.webai.AihrWebSearchClient.SearchResult; +import org.dromara.aihr.webai.AihrWebSearchClient.WebSource; +import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AihrWebAiSecurityTest { + + @Test + @Tag("dev") + void webSearchIsDisabledByDefault() { + AihrWebAiProperties properties = new AihrWebAiProperties(); + + assertFalse(properties.isEnabled()); + assertEquals("tavily", properties.getProvider()); + } + + @Test + @Tag("dev") + void searchEndpointRejectsLocalAndPrivateNetworks() { + assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://localhost/search")); + assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://127.0.0.1/search")); + assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://10.0.0.8/search")); + assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://169.254.169.254/latest/meta-data")); + assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://192.168.1.8/search")); + assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://[::1]/search")); + assertFalse(AihrWebSearchClient.isPublicHttpUrl("file:///etc/passwd")); + assertFalse(AihrWebSearchClient.isPublicHttpUrl("https://user:pass@8.8.8.8/search")); + assertTrue(AihrWebSearchClient.isPublicHttpUrl("https://8.8.8.8/search")); + assertFalse(AihrWebSearchClient.isPublicHttpsUrl("http://8.8.8.8/search")); + assertTrue(AihrWebSearchClient.isPublicHttpsUrl("https://8.8.8.8/search")); + } + + @Test + @Tag("dev") + void providerAnswerIsDiscardedWhenThereAreNoPublicSources() throws Exception { + String body = """ + { + "answer": "不能在没有可核验来源时返回这段答案", + "results": [ + {"title":"内网","url":"http://127.0.0.1/admin","content":"secret","score":0.99} + ] + } + """; + + AihrWebSearchClient.SearchResult result = AihrWebSearchClient.parseTavilyResponse( + new ObjectMapper(), body, 5 + ); + + assertTrue(result.sources().isEmpty()); + assertEquals("", result.answer()); + } + + @Test + @Tag("dev") + void providerAnswerKeepsOnlyPublicCitedSources() throws Exception { + String body = """ + { + "answer": "这是基于公开来源生成的答案", + "results": [ + {"title":"公开资料","url":"https://8.8.8.8/article","content":"公开摘要","score":0.91}, + {"title":"内网","url":"http://10.0.0.1/secret","content":"secret","score":0.99} + ] + } + """; + + AihrWebSearchClient.SearchResult result = AihrWebSearchClient.parseTavilyResponse( + new ObjectMapper(), body, 5 + ); + + assertEquals("这是基于公开来源生成的答案", result.answer()); + assertEquals(1, result.sources().size()); + assertEquals("https://8.8.8.8/article", result.sources().get(0).url()); + } + + @Test + @Tag("dev") + void providerRequestsRequireHttpsEvenForPublicHosts() { + AihrWebSearchClient client = new AihrWebSearchClient( + new AihrWebAiProperties(), mock(AihrWebSearchProviderService.class), new ObjectMapper() + ); + RuntimeProvider provider = new RuntimeProvider(1L, "test", "tavily", + "http://8.8.8.8/search", "secret", true, true, LocalDateTime.now()); + + assertThrows(ServiceException.class, () -> client.search(provider, "test question")); + } + + @Test + @Tag("dev") + void sourcedButEmptyProviderAnswerIsNotReportedAsOk() { + AihrWebAiProperties properties = new AihrWebAiProperties(); + AihrWebSearchClient searchClient = mock(AihrWebSearchClient.class); + AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class); + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + AihrKnowledgePrincipal principal = new AihrKnowledgePrincipal( + "000000", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile" + ); + RuntimeProvider provider = new RuntimeProvider(1L, "Tavily", "tavily", + "https://8.8.8.8/search", "secret", true, true, LocalDateTime.now()); + when(resolver.current()).thenReturn(principal); + when(searchClient.runtime(principal)).thenReturn(provider); + when(searchClient.search(provider, "物业行业新规")) + .thenReturn(new SearchResult("", List.of( + new WebSource("公开来源", "https://8.8.8.8/article", "公开摘要", 0.9) + ))); + AihrWebAiService service = new AihrWebAiService(properties, searchClient, resolver, jdbcTemplate); + + var response = service.query(new QueryRequest("物业行业新规", true)); + + assertEquals("NO_ANSWER", response.status()); + assertEquals("", response.answer()); + assertEquals(1, response.sources().size()); + } + + @Test + @Tag("dev") + void providerAuditNameIsBoundedToDatabaseColumn() { + assertEquals(40, AihrWebAiService.auditProvider("x".repeat(100)).length()); + assertEquals("unknown", AihrWebAiService.auditProvider(" ")); + } + + @Test + @Tag("dev") + void providerTestStateMigrationIsIdempotentAndDefaultsToUntested() throws Exception { + Path migration = Path.of("../../script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql"); + if (!Files.exists(migration)) { + migration = Path.of("script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql"); + } + String sql = Files.readString(migration).toLowerCase(); + + assertTrue(sql.contains("last_test_ok")); + assertTrue(sql.contains("last_test_time")); + assertTrue(sql.contains("information_schema.columns")); + assertTrue(sql.contains("default 0")); + } + + @Test + @Tag("dev") + void outboundQuestionRedactsKnownPersonalAndAccessData() { + String raw = "张女士住3栋1201,手机13812345678,身份证320311199001011234,门禁码 667788"; + + String safe = AihrWebPrivacy.forExternalSearch(raw); + + assertFalse(safe.contains("13812345678")); + assertFalse(safe.contains("320311199001011234")); + assertFalse(safe.contains("1201")); + assertFalse(safe.contains("667788")); + assertTrue(safe.contains("已脱敏")); + } + + @Test + @Tag("dev") + void databaseProviderSecretUsesAuthenticatedEncryption() { + AihrWebAiProperties properties = new AihrWebAiProperties(); + properties.setSecretKey("unit-test-secret-key-32-bytes-long"); + AihrWebSecretCodec codec = new AihrWebSecretCodec(properties); + + String encrypted = codec.encrypt("tvly-test-secret"); + + assertTrue(encrypted.startsWith("enc:v1:")); + assertFalse(encrypted.contains("tvly-test-secret")); + assertEquals("tvly-test-secret", codec.decrypt(encrypted)); + } +} diff --git a/backend/script/sql/aihr_knowledge_mysql8.sql b/backend/script/sql/aihr_knowledge_mysql8.sql index 4634bfc0..3eb57793 100644 --- a/backend/script/sql/aihr_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_knowledge_mysql8.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_info` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', `name` varchar(100) NOT NULL COMMENT '知识库名称', - `code` varchar(100) NOT NULL COMMENT '租户内稳定空间编码', + `code` varchar(100) DEFAULT NULL COMMENT '租户内稳定空间编码;平台迁移会回填 legacy 编码并改为必填', `space_type` varchar(30) NOT NULL DEFAULT 'BUSINESS' COMMENT 'PUBLIC/BUSINESS/MANAGEMENT/EXTERNAL', `sensitivity_level` varchar(30) NOT NULL DEFAULT 'INTERNAL' COMMENT 'PUBLIC/INTERNAL/CONFIDENTIAL', `status` varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'DRAFT/ACTIVE/DISABLED', @@ -230,3 +230,17 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_query_log` ( KEY `idx_aihr_query_tenant_app` (`tenant_id`, `app_id`, `create_time`), KEY `idx_aihr_query_status` (`tenant_id`, `status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识问答最小化审计日志'; + +CREATE TABLE IF NOT EXISTS `aihr_knowledge_admin_audit` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `operator_id` bigint DEFAULT NULL COMMENT '操作人', + `action` varchar(50) NOT NULL COMMENT '操作动作', + `object_type` varchar(30) NOT NULL COMMENT 'SPACE/GRANT/APP/APP_SPACE/TOKEN', + `object_id` bigint DEFAULT NULL COMMENT '对象ID', + `before_summary` varchar(1000) DEFAULT NULL COMMENT '变更前摘要', + `after_summary` varchar(1000) DEFAULT NULL COMMENT '变更后摘要', + `create_time` datetime NOT NULL COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_admin_audit_tenant` (`tenant_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识平台管理变更审计'; diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index 11c240a4..cad3ca68 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -119,6 +119,8 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` ( `source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual', `reason` varchar(500) DEFAULT NULL COMMENT '派发原因', `status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态', + `due_date` date DEFAULT NULL COMMENT '训练截止日期', + `request_key` varchar(100) DEFAULT NULL COMMENT '客户端幂等请求ID', `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答', `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址', `answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号', @@ -130,6 +132,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` ( `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_assignment_request` (`tenant_id`, `request_key`, `ext_party_id`), KEY `idx_aihr_assignment_party` (`tenant_id`, `ext_party_id`, `status`, `create_time`), KEY `idx_aihr_assignment_source` (`tenant_id`, `source`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务'; diff --git a/backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql b/backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql index fe910757..09c26960 100644 --- a/backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql +++ b/backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql @@ -1,15 +1,50 @@ -- AIHR BRD 住宅 SOP 补充迁移(MySQL 8.x) -- 补齐服务推介与日常服务两类可检索 SOP;不写入正式评审数据,可重复执行。 -INSERT INTO `aihr_knowledge_info` - (`id`, `tenant_id`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark`) +CREATE TEMPORARY TABLE IF NOT EXISTS `_aihr_brd_sop_space_seed` ( + `id` bigint NOT NULL, + `tenant_id` varchar(20) NOT NULL, + `code` varchar(100) NOT NULL, + `name` varchar(100) NOT NULL, + `description` varchar(500) DEFAULT NULL, + `separator` varchar(20) DEFAULT NULL, + `overlap_char` int DEFAULT NULL, + `retrieve_limit` int DEFAULT NULL, + `similarity_threshold` decimal(6,4) DEFAULT NULL, + `text_block_size` int DEFAULT NULL, + `vector_model` varchar(100) DEFAULT NULL, + `embedding_model` varchar(100) DEFAULT NULL, + `enable_hybrid` tinyint DEFAULT NULL, + `hybrid_alpha` decimal(6,4) DEFAULT NULL, + `system_prompt` text, + `create_time` datetime DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +DELETE FROM `_aihr_brd_sop_space_seed`; + +INSERT INTO `_aihr_brd_sop_space_seed` + (`id`, `tenant_id`, `code`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark`) VALUES - (1201, '000000', '增值服务需求识别 SOP', '基于业主真实需求介绍增值服务,明确适用范围、价格和取消规则。', '\n\n', 120, 5, 0.5, 800, NULL, NULL, 1, 1.0, '只能基于已确认的服务目录和价目口径回答,不得编造优惠或效果承诺。', NOW(), NOW(), 'seed:sop:brd-supplement'), - (1202, '000000', '日常服务回访 SOP', '服务完成后的满意度确认、遗留问题记录、责任人和回访节点。', '\n\n', 120, 5, 0.5, 800, NULL, NULL, 1, 1.0, '已完成不等于已闭环,回答必须包含满意度确认、责任人和下一次回访节点。', NOW(), NOW(), 'seed:sop:brd-supplement') -ON DUPLICATE KEY UPDATE - `description` = VALUES(`description`), - `system_prompt` = VALUES(`system_prompt`), - `update_time` = NOW(); + (1201, '000000', 'sop_service_promotion', '增值服务需求识别 SOP', '基于业主真实需求介绍增值服务,明确适用范围、价格和取消规则。', '\n\n', 120, 5, 0.5, 800, NULL, NULL, 1, 1.0, '只能基于已确认的服务目录和价目口径回答,不得编造优惠或效果承诺。', NOW(), NOW(), 'seed:sop:brd-supplement'), + (1202, '000000', 'sop_daily_service', '日常服务回访 SOP', '服务完成后的满意度确认、遗留问题记录、责任人和回访节点。', '\n\n', 120, 5, 0.5, 800, NULL, NULL, 1, 1.0, '已完成不等于已闭环,回答必须包含满意度确认、责任人和下一次回访节点。', NOW(), NOW(), 'seed:sop:brd-supplement'); + +SET @aihr_space_code_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_knowledge_info' AND COLUMN_NAME = 'code' +); +SET @aihr_brd_sop_space_upsert := IF( + @aihr_space_code_exists = 1, + 'INSERT INTO `aihr_knowledge_info` (`id`, `tenant_id`, `code`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark`) SELECT `id`, `tenant_id`, `code`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark` FROM `_aihr_brd_sop_space_seed` ON DUPLICATE KEY UPDATE `code` = VALUES(`code`), `description` = VALUES(`description`), `system_prompt` = VALUES(`system_prompt`), `update_time` = NOW()', + 'INSERT INTO `aihr_knowledge_info` (`id`, `tenant_id`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark`) SELECT `id`, `tenant_id`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark` FROM `_aihr_brd_sop_space_seed` ON DUPLICATE KEY UPDATE `description` = VALUES(`description`), `system_prompt` = VALUES(`system_prompt`), `update_time` = NOW()' +); +PREPARE aihr_brd_sop_space_stmt FROM @aihr_brd_sop_space_upsert; +EXECUTE aihr_brd_sop_space_stmt; +DEALLOCATE PREPARE aihr_brd_sop_space_stmt; + +DROP TEMPORARY TABLE IF EXISTS `_aihr_brd_sop_space_seed`; INSERT INTO `aihr_knowledge_attach` (`id`, `tenant_id`, `knowledge_id`, `doc_id`, `name`, `type`, `status`, `create_time`, `update_time`, `remark`) diff --git a/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql b/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql index 1e9e944c..5f3e3e86 100644 --- a/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql +++ b/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql @@ -68,7 +68,10 @@ INSERT INTO aihr_brd_rubric_codes (tenant_id, rubric_code) SELECT r.tenant_id, r.rubric_code FROM aihr_practice_rubric r JOIN aihr_practice_scenario s - ON s.tenant_id = r.tenant_id AND s.scenario_code = r.scenario_code + ON CONVERT(s.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci + = CONVERT(r.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci + AND CONVERT(s.scenario_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci + = CONVERT(r.scenario_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci JOIN ( SELECT tenant_id, rubric_code FROM aihr_practice_rubric_dimension @@ -76,14 +79,20 @@ JOIN ( GROUP BY tenant_id, rubric_code HAVING COUNT(*) = 4 ) old_dimensions - ON old_dimensions.tenant_id = r.tenant_id AND old_dimensions.rubric_code = r.rubric_code + ON CONVERT(old_dimensions.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci + = CONVERT(r.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci + AND CONVERT(old_dimensions.rubric_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci + = CONVERT(r.rubric_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci WHERE r.tenant_id = '000000' AND s.position = '生活顾问'; DELETE d FROM aihr_practice_rubric_dimension d JOIN aihr_brd_rubric_codes r - ON r.tenant_id = d.tenant_id AND r.rubric_code = d.rubric_code; + ON CONVERT(r.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci + = CONVERT(d.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci + AND CONVERT(r.rubric_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci + = CONVERT(d.rubric_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci; INSERT INTO aihr_practice_rubric_dimension (tenant_id, rubric_code, dimension_code, dimension_name, weight, description, sort_order, create_time, update_time) diff --git a/backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql b/backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql index a2a4590a..aa640c0f 100644 --- a/backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql +++ b/backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql @@ -152,3 +152,17 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_query_log` ( KEY `idx_aihr_query_tenant_app` (`tenant_id`, `app_id`, `create_time`), KEY `idx_aihr_query_status` (`tenant_id`, `status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识问答最小化审计日志'; + +CREATE TABLE IF NOT EXISTS `aihr_knowledge_admin_audit` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) NOT NULL, + `operator_id` bigint DEFAULT NULL, + `action` varchar(50) NOT NULL, + `object_type` varchar(30) NOT NULL, + `object_id` bigint DEFAULT NULL, + `before_summary` varchar(1000) DEFAULT NULL, + `after_summary` varchar(1000) DEFAULT NULL, + `create_time` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_aihr_admin_audit_tenant` (`tenant_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识平台管理变更审计'; diff --git a/backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql b/backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql new file mode 100644 index 00000000..3c8aa8d3 --- /dev/null +++ b/backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql @@ -0,0 +1,201 @@ +-- 学习闭环:训练题快照/反馈、岗位题库、入职考试(MySQL 8.x)。 +-- 可重复执行;先于 aihr_20260717_practice_five_position_catalog_mysql8.sql 执行。 + +SET @aihr_schema = DATABASE(); + +SET @content_version_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND column_name = 'content_version' +); +SET @content_version_ddl = IF(@content_version_exists = 0, + 'ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT ''v1'' COMMENT ''派发内容版本'' AFTER request_key', + 'SELECT 1'); +PREPARE aihr_content_version_stmt FROM @content_version_ddl; +EXECUTE aihr_content_version_stmt; +DEALLOCATE PREPARE aihr_content_version_stmt; + +SET @question_snapshot_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND column_name = 'question_snapshot' +); +SET @question_snapshot_ddl = IF(@question_snapshot_exists = 0, + 'ALTER TABLE aihr_practice_assignment ADD COLUMN question_snapshot varchar(1000) DEFAULT NULL COMMENT ''派发时题目快照'' AFTER content_version', + 'SELECT 1'); +PREPARE aihr_question_snapshot_stmt FROM @question_snapshot_ddl; +EXECUTE aihr_question_snapshot_stmt; +DEALLOCATE PREPARE aihr_question_snapshot_stmt; + +SET @reference_snapshot_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND column_name = 'reference_answer_snapshot' +); +SET @reference_snapshot_ddl = IF(@reference_snapshot_exists = 0, + 'ALTER TABLE aihr_practice_assignment ADD COLUMN reference_answer_snapshot varchar(2000) DEFAULT NULL COMMENT ''派发时参考答案快照'' AFTER question_snapshot', + 'SELECT 1'); +PREPARE aihr_reference_snapshot_stmt FROM @reference_snapshot_ddl; +EXECUTE aihr_reference_snapshot_stmt; +DEALLOCATE PREPARE aihr_reference_snapshot_stmt; + +SET @content_hash_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND column_name = 'content_hash' +); +SET @content_hash_ddl = IF(@content_hash_exists = 0, + 'ALTER TABLE aihr_practice_assignment ADD COLUMN content_hash char(64) DEFAULT NULL COMMENT ''版本化内容哈希'' AFTER reference_answer_snapshot', + 'SELECT 1'); +PREPARE aihr_content_hash_stmt FROM @content_hash_ddl; +EXECUTE aihr_content_hash_stmt; +DEALLOCATE PREPARE aihr_content_hash_stmt; + +SET @content_hash_index_exists = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND index_name = 'idx_aihr_assignment_recent_content' +); +SET @content_hash_index_ddl = IF(@content_hash_index_exists = 0, + 'ALTER TABLE aihr_practice_assignment ADD KEY idx_aihr_assignment_recent_content (tenant_id, ext_party_id, source, content_hash, create_time)', + 'SELECT 1'); +PREPARE aihr_content_hash_index_stmt FROM @content_hash_index_ddl; +EXECUTE aihr_content_hash_index_stmt; +DEALLOCATE PREPARE aihr_content_hash_index_stmt; + +CREATE TABLE IF NOT EXISTS `aihr_learning_question` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `question_code` varchar(120) NOT NULL COMMENT '题目编码', + `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', + `position_code` varchar(80) NOT NULL COMMENT '岗位编码', + `question_type` varchar(20) NOT NULL DEFAULT 'daily' COMMENT 'daily/special', + `question_text` varchar(1000) NOT NULL COMMENT '题干', + `reference_answer` varchar(2000) NOT NULL COMMENT '参考答案', + `content_version` varchar(30) NOT NULL DEFAULT 'v1' COMMENT '内容版本', + `content_hash` char(64) NOT NULL COMMENT '题目内容哈希', + `enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用', + `sort_order` int NOT NULL DEFAULT 0 COMMENT '排序', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_learning_question` (`tenant_id`, `question_code`), + KEY `idx_aihr_learning_question_matrix` (`tenant_id`, `position_code`, `question_type`, `enabled`, `sort_order`), + KEY `idx_aihr_learning_question_hash` (`tenant_id`, `content_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='岗位场景学习题库'; + +CREATE TABLE IF NOT EXISTS `aihr_practice_question_feedback` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `assignment_id` bigint NOT NULL COMMENT '训练题派发ID', + `ext_party_id` varchar(100) NOT NULL COMMENT '员工身份', + `verdict` varchar(20) NOT NULL COMMENT 'useful/not_useful', + `comment` varchar(500) DEFAULT NULL COMMENT '反馈说明', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_question_feedback` (`tenant_id`, `assignment_id`, `ext_party_id`), + KEY `idx_aihr_question_feedback_verdict` (`tenant_id`, `verdict`, `update_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='每日题和专项题有用性反馈'; + +CREATE TABLE IF NOT EXISTS `aihr_onboard_exam` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `title` varchar(200) NOT NULL COMMENT '考试名称', + `project_code` varchar(100) NOT NULL DEFAULT '' COMMENT '适用项目,空为全部', + `position_code` varchar(80) NOT NULL DEFAULT '' COMMENT '适用岗位,空为全部', + `status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/PUBLISHED', + `total_score` int NOT NULL DEFAULT 100 COMMENT '总分', + `pass_score` int NOT NULL DEFAULT 80 COMMENT '及格分', + `due_date` date DEFAULT NULL COMMENT '截止日期', + `created_by` varchar(100) NOT NULL COMMENT '创建人身份', + `publish_request_key` varchar(100) DEFAULT NULL COMMENT '发布幂等键', + `published_time` datetime DEFAULT NULL COMMENT '发布时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_onboard_exam_publish_request` (`tenant_id`, `created_by`, `publish_request_key`), + KEY `idx_aihr_onboard_exam_scope` (`tenant_id`, `status`, `project_code`, `position_code`, `due_date`), + KEY `idx_aihr_onboard_exam_owner` (`tenant_id`, `created_by`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主管创建的岗前/入职考试'; + +SET @publish_request_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_onboard_exam' AND column_name = 'publish_request_key' +); +SET @publish_request_ddl = IF(@publish_request_exists = 0, + 'ALTER TABLE aihr_onboard_exam ADD COLUMN publish_request_key varchar(100) DEFAULT NULL COMMENT ''发布幂等键'' AFTER created_by', + 'SELECT 1'); +PREPARE aihr_publish_request_stmt FROM @publish_request_ddl; +EXECUTE aihr_publish_request_stmt; +DEALLOCATE PREPARE aihr_publish_request_stmt; + +SET @publish_request_index_exists = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @aihr_schema AND table_name = 'aihr_onboard_exam' + AND index_name = 'uk_aihr_onboard_exam_publish_request' +); +SET @publish_request_index_ddl = IF(@publish_request_index_exists = 0, + 'ALTER TABLE aihr_onboard_exam ADD UNIQUE KEY uk_aihr_onboard_exam_publish_request (tenant_id, created_by, publish_request_key)', + 'SELECT 1'); +PREPARE aihr_publish_request_index_stmt FROM @publish_request_index_ddl; +EXECUTE aihr_publish_request_index_stmt; +DEALLOCATE PREPARE aihr_publish_request_index_stmt; + +CREATE TABLE IF NOT EXISTS `aihr_onboard_exam_target` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `exam_id` bigint NOT NULL COMMENT '考试ID', + `ext_party_id` varchar(100) NOT NULL COMMENT '真实员工外部身份', + `trainee_name` varchar(100) NOT NULL COMMENT '派发时员工姓名快照', + `project_code` varchar(100) NOT NULL DEFAULT '' COMMENT '派发时项目快照', + `position_name` varchar(100) NOT NULL DEFAULT '' COMMENT '派发时岗位快照', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_onboard_exam_target` (`tenant_id`, `exam_id`, `ext_party_id`), + KEY `idx_aihr_onboard_exam_target_party` (`tenant_id`, `ext_party_id`, `exam_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入职考试定向人员(最多100人)'; + +CREATE TABLE IF NOT EXISTS `aihr_onboard_exam_question` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `exam_id` bigint NOT NULL COMMENT '考试ID', + `question_type` varchar(20) NOT NULL COMMENT 'single/multiple/judge/short', + `stem` varchar(1000) NOT NULL COMMENT '题干', + `options_json` text COMMENT '选项JSON', + `correct_answers_json` text NOT NULL COMMENT '正确答案或关键词JSON', + `score` int NOT NULL COMMENT '分值', + `explanation` varchar(1000) DEFAULT NULL COMMENT '解析', + `sort_order` int NOT NULL DEFAULT 0 COMMENT '排序', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_onboard_exam_question` (`tenant_id`, `exam_id`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入职考试题目'; + +CREATE TABLE IF NOT EXISTS `aihr_onboard_exam_attempt` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `exam_id` bigint NOT NULL COMMENT '考试ID', + `ext_party_id` varchar(100) NOT NULL COMMENT '员工身份', + `request_key` varchar(100) NOT NULL COMMENT '提交幂等键', + `status` varchar(20) NOT NULL DEFAULT 'SUBMITTED' COMMENT '提交状态', + `score` int NOT NULL DEFAULT 0 COMMENT '得分', + `passed` tinyint NOT NULL DEFAULT 0 COMMENT '是否通过', + `submitted_time` datetime NOT NULL COMMENT '提交时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_onboard_exam_attempt_request` (`tenant_id`, `exam_id`, `ext_party_id`, `request_key`), + KEY `idx_aihr_onboard_exam_attempt_result` (`tenant_id`, `exam_id`, `score`, `submitted_time`), + KEY `idx_aihr_onboard_exam_attempt_party` (`tenant_id`, `ext_party_id`, `submitted_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入职考试作答记录'; + +CREATE TABLE IF NOT EXISTS `aihr_onboard_exam_answer` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `attempt_id` bigint NOT NULL COMMENT '作答记录ID', + `question_id` bigint NOT NULL COMMENT '题目ID', + `answer_json` text COMMENT '员工答案JSON', + `correct` tinyint NOT NULL DEFAULT 0 COMMENT '是否正确', + `earned_score` int NOT NULL DEFAULT 0 COMMENT '本题得分', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_onboard_exam_answer` (`tenant_id`, `attempt_id`, `question_id`), + KEY `idx_aihr_onboard_exam_answer_question` (`tenant_id`, `question_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入职考试逐题答案'; diff --git a/backend/script/sql/update/aihr_20260717_practice_assignment_batch_mysql8.sql b/backend/script/sql/update/aihr_20260717_practice_assignment_batch_mysql8.sql new file mode 100644 index 00000000..0c198060 --- /dev/null +++ b/backend/script/sql/update/aihr_20260717_practice_assignment_batch_mysql8.sql @@ -0,0 +1,42 @@ +-- 主管专项批量派发:截止日期与客户端幂等键。 +-- request_key 对历史/单人旧调用保持 NULL;MySQL 唯一索引允许多行 NULL。 + +SET @has_due_date := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_assignment' AND column_name = 'due_date' +); +SET @ddl_due_date := IF( + @has_due_date = 0, + 'ALTER TABLE aihr_practice_assignment ADD COLUMN due_date date DEFAULT NULL COMMENT ''训练截止日期'' AFTER status', + 'SELECT 1' +); +PREPARE stmt_due_date FROM @ddl_due_date; +EXECUTE stmt_due_date; +DEALLOCATE PREPARE stmt_due_date; + +SET @has_request_key := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_assignment' AND column_name = 'request_key' +); +SET @ddl_request_key := IF( + @has_request_key = 0, + 'ALTER TABLE aihr_practice_assignment ADD COLUMN request_key varchar(100) DEFAULT NULL COMMENT ''客户端幂等请求ID'' AFTER due_date', + 'SELECT 1' +); +PREPARE stmt_request_key FROM @ddl_request_key; +EXECUTE stmt_request_key; +DEALLOCATE PREPARE stmt_request_key; + +SET @has_request_index := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_assignment' + AND index_name = 'uk_aihr_assignment_request' +); +SET @ddl_request_index := IF( + @has_request_index = 0, + 'ALTER TABLE aihr_practice_assignment ADD UNIQUE KEY uk_aihr_assignment_request (tenant_id, request_key, ext_party_id)', + 'SELECT 1' +); +PREPARE stmt_request_index FROM @ddl_request_index; +EXECUTE stmt_request_index; +DEALLOCATE PREPARE stmt_request_index; diff --git a/backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql b/backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql new file mode 100644 index 00000000..803ef9a9 --- /dev/null +++ b/backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql @@ -0,0 +1,277 @@ +-- 五岗位训练内容目录(MySQL 8.x)。 +-- 生活顾问/保安/保洁/保修/客服各 20 个场景,并为每个场景生成 daily、special 两类版本化题目。 +-- 本脚本是内置内容,不代表高风险作业已获得业务、安全或法务审核;未完成正式审核的高风险行保持 enabled=0。 +-- 依赖:先执行 aihr_20260714_practice_catalog_schema_mysql8.sql。脚本可重复执行。 + +SET @db_name = DATABASE(); + +SET @scenario_version_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @db_name AND table_name = 'aihr_practice_scenario' AND column_name = 'content_version' +); +SET @scenario_version_ddl = IF( + @scenario_version_exists = 0, + 'ALTER TABLE aihr_practice_scenario ADD COLUMN content_version varchar(30) NOT NULL DEFAULT ''v1.0'' COMMENT ''内容版本'' AFTER rounds_json', + 'SELECT 1' +); +PREPARE scenario_version_stmt FROM @scenario_version_ddl; +EXECUTE scenario_version_stmt; +DEALLOCATE PREPARE scenario_version_stmt; + +SET @scenario_hash_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @db_name AND table_name = 'aihr_practice_scenario' AND column_name = 'content_hash' +); +SET @scenario_hash_ddl = IF( + @scenario_hash_exists = 0, + 'ALTER TABLE aihr_practice_scenario ADD COLUMN content_hash char(64) DEFAULT NULL COMMENT ''内容快照哈希'' AFTER content_version', + 'SELECT 1' +); +PREPARE scenario_hash_stmt FROM @scenario_hash_ddl; +EXECUTE scenario_hash_stmt; +DEALLOCATE PREPARE scenario_hash_stmt; + +CREATE TABLE IF NOT EXISTS `aihr_learning_question` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `question_code` varchar(120) NOT NULL COMMENT '题目编码', + `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', + `position_code` varchar(80) NOT NULL COMMENT '岗位编码', + `question_type` varchar(20) NOT NULL DEFAULT 'daily' COMMENT 'daily/special', + `question_text` varchar(1000) NOT NULL COMMENT '题干', + `reference_answer` varchar(2000) NOT NULL COMMENT '参考答案', + `content_version` varchar(30) NOT NULL DEFAULT 'v1.0' COMMENT '内容版本', + `content_hash` char(64) NOT NULL COMMENT '题目内容哈希', + `enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用', + `sort_order` int NOT NULL DEFAULT 0 COMMENT '排序', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_learning_question` (`tenant_id`, `question_code`), + KEY `idx_aihr_learning_question_hash` (`tenant_id`, `content_hash`), + KEY `idx_aihr_learning_question_pick` (`tenant_id`, `position_code`, `question_type`, `enabled`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='岗位学习题库'; + +SET @question_type_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @db_name AND table_name = 'aihr_learning_question' AND column_name = 'question_type' +); +SET @question_type_ddl = IF( + @question_type_exists = 0, + 'ALTER TABLE aihr_learning_question ADD COLUMN question_type varchar(20) NOT NULL DEFAULT ''daily'' COMMENT ''daily/special'' AFTER position_code', + 'SELECT 1' +); +PREPARE question_type_stmt FROM @question_type_ddl; +EXECUTE question_type_stmt; +DEALLOCATE PREPARE question_type_stmt; + +DROP TEMPORARY TABLE IF EXISTS `_aihr_five_position_catalog`; +CREATE TEMPORARY TABLE `_aihr_five_position_catalog` ( + `position` varchar(80) NOT NULL, + `scenario_code` varchar(80) NOT NULL, + `scenario_name` varchar(100) NOT NULL, + `scenario_type` varchar(50) NOT NULL, + `sop_code` varchar(80) NOT NULL, + `opening_text` varchar(500) NOT NULL, + `difficulty` tinyint NOT NULL, + `enabled` tinyint NOT NULL, + PRIMARY KEY (`scenario_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO `_aihr_five_position_catalog` + (`position`, `scenario_code`, `scenario_name`, `scenario_type`, `sop_code`, `opening_text`, `difficulty`, `enabled`) +VALUES +('生活顾问', 'complaint-water', '客服应对投诉', '投诉处理', 'SOP-LG-001', '你们说今天处理,到现在都没人联系我,这个车库还怎么停?', 2, 1), +('生活顾问', 'complaint-noise', '楼上噪音投诉', '投诉处理', 'SOP-LG-002', '楼上每天晚上拖椅子,你们物业到底管不管?', 2, 1), +('生活顾问', 'complaint-attitude', '服务态度投诉', '投诉处理', 'SOP-LG-003', '你们前台刚才那是什么态度?我要投诉。', 1, 1), +('生活顾问', 'fee-collection', '催缴沟通技巧', '催费/停车费', 'SOP-LG-004', '服务做成这样还让我缴费?你先把问题解决了再说。', 2, 1), +('生活顾问', 'fee-parking', '停车费催缴', '催费/停车费', 'SOP-LG-005', '停车费又催,我车位入口问题一直没解决,凭什么交?', 2, 1), +('生活顾问', 'fee-prepay-discount', '预交折扣沟通', '催费/停车费', 'SOP-LG-006', '你们说预交有折扣,那我以前欠的怎么算?', 2, 1), +('生活顾问', 'repair-leak-followup', '漏水报修跟进', '报修跟进', 'SOP-LG-007', '我家天花还在滴水,报修两天了怎么没人来?', 2, 1), +('生活顾问', 'repair-elevator-delay', '电梯故障跟进', '报修跟进', 'SOP-LG-008', '电梯坏了一上午,老人怎么下楼?你们给个准话。', 3, 1), +('生活顾问', 'repair-public-light', '公区照明维修', '报修跟进', 'SOP-LG-009', '楼道灯坏了三天,晚上黑漆漆的。', 1, 1), +('生活顾问', 'emergency-water-outage', '突发停水求助', '突发求助', 'SOP-LG-010', '家里突然没水了,孩子还要洗澡,怎么回事?', 2, 1), +('生活顾问', 'emergency-elder-fall', '老人摔倒求助', '突发求助', 'SOP-LG-011', '我爸在楼下摔倒了,你们快来帮忙!', 3, 1), +('生活顾问', 'emergency-security', '陌生人尾随求助', '突发求助', 'SOP-LG-012', '我刚进小区感觉有人跟着我,你们保安在哪里?', 3, 1), +('生活顾问', 'service-promotion-needs', '增值服务需求识别与推介', '服务推介/增值', 'SOP-LG-013', '家里老人要长期出门,社区有没有适合的生活服务?', 2, 1), +('生活顾问', 'daily-service-followup', '日常服务主动回访', '日常服务', 'SOP-LG-014', '上次问题处理完了,后续还会有人联系我吗?', 1, 1), +('生活顾问', 'LG-RENOVATION-REGISTER', '装修登记引导', '装修服务', 'SOP-LG-015', '我家准备装修,需要先办哪些手续?', 2, 1), +('生活顾问', 'LG-MOVE-IN', '入住服务办理', '入住服务', 'SOP-LG-016', '我明天搬进来,钥匙和门禁怎么办理?', 1, 1), +('生活顾问', 'LG-ACTIVITY-CONSULT', '社区活动咨询', '社区运营', 'SOP-LG-017', '本周社区活动怎么报名,老人可以参加吗?', 1, 1), +('生活顾问', 'LG-PARKING-CONSULT', '车位使用咨询', '停车服务', 'SOP-LG-018', '临时车辆怎么收费,车位可以共享吗?', 1, 1), +('生活顾问', 'LG-INFO-CHANGE', '业主信息变更', '资料维护', 'SOP-LG-019', '我的联系电话换了,在哪里更新业主资料?', 1, 1), +('生活顾问', 'LG-VISITOR-REGISTER', '访客登记协同', '客户接待', 'SOP-LG-020', '家里今天有客人来,门岗说还需要登记,我该怎么办?', 1, 1), + +('保安', 'BA-VEHICLE-ENTRY', '临时车辆入场核验', '门岗管理', 'SOP-BA-001', '我是来访车辆,业主已经同意了,为什么还不能进?', 1, 1), +('保安', 'BA-VISITOR-ENTRY', '访客入场核验', '门岗管理', 'SOP-BA-002', '我来找朋友但联系不上他,可以先让我进去吗?', 2, 1), +('保安', 'BA-DELIVERY-ENTRY', '配送人员入场引导', '门岗管理', 'SOP-BA-003', '我要给业主送货,东西很多,需要从哪里进入?', 1, 1), +('保安', 'BA-NIGHT-PATROL', '夜间巡逻异常记录', '巡逻管理', 'SOP-BA-004', '巡逻时发现单元门长时间敞开且附近无人。', 2, 1), +('保安', 'BA-SUSPICIOUS-PERSON', '可疑人员信息核查', '异常处置', 'SOP-BA-005', '有陌生人在楼栋附近反复徘徊,我有点担心。', 2, 1), +('保安', 'BA-LOST-CHILD', '走失儿童协查', '人员协查', 'SOP-BA-006', '孩子在小区里走丢了,已经十分钟没找到。', 3, 1), +('保安', 'BA-DRUNK-PERSON', '醉酒人员现场协同', '异常处置', 'SOP-BA-007', '有人醉倒在单元门口,还在大声吵闹。', 3, 1), +('保安', 'BA-PARKING-CONFLICT', '停车纠纷降级沟通', '停车秩序', 'SOP-BA-008', '对方占了我的车位,我们已经吵起来了。', 3, 1), +('保安', 'BA-ILLEGAL-PARKING', '消防通道违停劝离', '停车秩序', 'SOP-BA-009', '有车辆堵住通道,怎么联系车主处理?', 2, 1), +('保安', 'BA-GATE-FAILURE', '门岗设备故障登记', '设备异常', 'SOP-BA-010', '闸机无法识别车辆,后面已经排起长队。', 2, 1), +('保安', 'BA-CCTV-REQUEST', '监控查看申请引导', '信息合规', 'SOP-BA-011', '我的东西丢了,能不能直接把监控录像给我?', 2, 1), +('保安', 'BA-KEYS-FOUND', '拾获钥匙登记', '失物招领', 'SOP-BA-012', '我在地库捡到一串钥匙,应该交给谁?', 1, 1), +('保安', 'BA-LARGE-ITEM-EXIT', '大件物品出门核验', '门岗管理', 'SOP-BA-013', '搬家公司要把家具运出去,需要什么凭证?', 2, 1), +('保安', 'BA-EVENT-CROWD', '活动人流秩序引导', '活动保障', 'SOP-BA-014', '活动结束后门口人员集中,请安排疏导。', 2, 1), +('保安', 'BA-EMERGENCY-ACCESS', '救援车辆通道保障', '应急协同', 'SOP-BA-015', '救护车马上到,请确认最快的进入路线。', 3, 1), +('保安', 'BA-FIRE-ALARM', '消防报警首响与升级', '高风险应急', 'SOP-BA-016', '消防报警器持续响起并闻到焦糊味。', 3, 0), +('保安', 'BA-ELEVATOR-TRAPPED', '电梯困人信息收集与升级', '高风险应急', 'SOP-BA-017', '有人被困在电梯里,情绪很紧张。', 3, 0), +('保安', 'BA-VIOLENT-CONFLICT', '暴力冲突报警与隔离边界', '高风险应急', 'SOP-BA-018', '两个人正在发生肢体冲突,现场有人围观。', 3, 0), +('保安', 'BA-FLOOD-CONTROL', '地库积水应急边界', '高风险应急', 'SOP-BA-019', '地库入口积水上涨,车辆仍在进入。', 3, 0), +('保安', 'BA-HAZARDOUS-ITEM', '疑似危险物品隔离与上报', '高风险应急', 'SOP-BA-020', '公共区域发现来源不明且有刺激气味的容器。', 3, 0), + +('保洁', 'BJ-LOBBY-CLEANING', '大堂日常保洁', '日常保洁', 'SOP-BJ-001', '大堂地面有脚印和灰尘,请尽快恢复整洁。', 1, 1), +('保洁', 'BJ-CORRIDOR-CLEANING', '楼道日常保洁', '日常保洁', 'SOP-BJ-002', '楼道角落积灰比较明显,今天能处理吗?', 1, 1), +('保洁', 'BJ-ELEVATOR-CAR', '电梯轿厢日常保洁', '日常保洁', 'SOP-BJ-003', '电梯轿厢有污渍和异味,请安排清洁。', 1, 1), +('保洁', 'BJ-WASTE-CLASSIFY', '生活垃圾分类引导', '垃圾管理', 'SOP-BJ-004', '这个垃圾应该投放在哪一类垃圾桶?', 1, 1), +('保洁', 'BJ-BIN-OVERFLOW', '垃圾桶满溢处理', '垃圾管理', 'SOP-BJ-005', '垃圾桶已经满了,周围也有散落垃圾。', 2, 1), +('保洁', 'BJ-PET-WASTE', '宠物排泄物清理', '专项清洁', 'SOP-BJ-006', '草坪边有宠物排泄物,影响大家通行。', 1, 1), +('保洁', 'BJ-SPILL-SLIP', '地面泼洒防滑处置', '专项清洁', 'SOP-BJ-007', '走廊地面有水,已经有人差点滑倒。', 2, 1), +('保洁', 'BJ-ODOR-COMPLAINT', '公共区域异味排查', '专项清洁', 'SOP-BJ-008', '楼道一直有异味,但找不到来源。', 2, 1), +('保洁', 'BJ-WASHROOM', '公共卫生间保洁', '日常保洁', 'SOP-BJ-009', '公共卫生间需要补充耗材并清洁。', 1, 1), +('保洁', 'BJ-GLASS-DOOR', '低位玻璃门清洁', '日常保洁', 'SOP-BJ-010', '一楼玻璃门有很多手印,影响观感。', 1, 1), +('保洁', 'BJ-PUBLIC-DISINFECTION', '公共接触面消洁', '消洁作业', 'SOP-BJ-011', '请按计划完成门把手和按钮的消洁记录。', 2, 1), +('保洁', 'BJ-EVENT-CLEANUP', '社区活动后保洁', '活动保障', 'SOP-BJ-012', '活动刚结束,场地有纸屑和桌椅需要恢复。', 2, 1), +('保洁', 'BJ-RENOVATION-DUST', '装修扬尘清洁协同', '专项清洁', 'SOP-BJ-013', '装修搬运后楼道都是灰,请协调清理。', 2, 1), +('保洁', 'BJ-RAINY-ENTRANCE', '雨天入口防滑保障', '天气保障', 'SOP-BJ-014', '外面下大雨,大堂入口地面很湿。', 2, 1), +('保洁', 'BJ-LEAF-CLEANUP', '落叶集中清理', '室外保洁', 'SOP-BJ-015', '园区步道落叶较多,影响通行和观感。', 1, 1), +('保洁', 'BJ-PEST-SIGHTING', '虫害线索登记上报', '虫害协同', 'SOP-BJ-016', '垃圾房附近发现较多虫害,请记录并报处理。', 2, 1), +('保洁', 'BJ-MEDICAL-WASTE', '疑似医疗废弃物隔离边界', '高风险作业', 'SOP-BJ-017', '垃圾桶旁发现疑似使用过的医疗针具。', 3, 0), +('保洁', 'BJ-CHEMICAL-SPILL', '不明化学品泼洒处置边界', '高风险作业', 'SOP-BJ-018', '储物间有不明液体泄漏并伴随刺激气味。', 3, 0), +('保洁', 'BJ-HIGH-RISE-EXTERIOR', '高空外墙保洁边界', '高风险作业', 'SOP-BJ-019', '需要安排高空外墙玻璃清洗,请确认作业条件。', 3, 0), +('保洁', 'BJ-SHARP-OBJECT', '锐器垃圾隔离处置边界', '高风险作业', 'SOP-BJ-020', '垃圾袋内有裸露碎玻璃,存在割伤风险。', 3, 0), + +('保修', 'BX-WATER-LEAK', '室内漏水报修初判', '给排水维修', 'SOP-BX-001', '天花板一直滴水,不知道是不是楼上漏下来的。', 2, 1), +('保修', 'BX-DRAIN-BLOCK', '排水堵塞报修', '给排水维修', 'SOP-BX-002', '厨房下水很慢,还有返水现象。', 2, 1), +('保修', 'BX-DOOR-LOCK', '公共门锁故障处理', '门窗维修', 'SOP-BX-003', '单元门锁不上,晚上让人不放心。', 2, 1), +('保修', 'BX-LAMP-REPLACE', '公共照明更换', '电气维修', 'SOP-BX-004', '楼道灯坏了,晚上比较暗。', 1, 1), +('保修', 'BX-PUBLIC-LIGHT', '园区照明异常排查', '电气维修', 'SOP-BX-005', '园区有一排路灯都不亮,请安排检查。', 2, 1), +('保修', 'BX-ACCESS-CONTROL', '门禁设备故障报修', '弱电维修', 'SOP-BX-006', '门禁刷卡没有反应,居民进出不方便。', 2, 1), +('保修', 'BX-INTERCOM', '楼宇对讲故障报修', '弱电维修', 'SOP-BX-007', '家里的可视对讲听不到声音。', 2, 1), +('保修', 'BX-PARKING-GATE', '停车道闸故障协同', '设备维修', 'SOP-BX-008', '出口道闸不抬杆,车辆已经排队。', 2, 1), +('保修', 'BX-PUMP-NOISE', '水泵异响工单处理', '设备维修', 'SOP-BX-009', '设备房附近晚上持续有明显异响。', 2, 1), +('保修', 'BX-COMMON-AC', '公共空调异常报修', '暖通维修', 'SOP-BX-010', '会所公共区域空调不制冷。', 2, 1), +('保修', 'BX-WALL-CRACK', '墙面裂缝登记勘查', '土建维修', 'SOP-BX-011', '公共走廊墙面出现裂缝,是否有安全问题?', 2, 1), +('保修', 'BX-WATERPROOF', '渗水返修跟进', '土建维修', 'SOP-BX-012', '上次做过防水,现在又开始渗水了。', 2, 1), +('保修', 'BX-APPLIANCE-BOUNDARY', '业主家电维修边界说明', '服务边界', 'SOP-BX-013', '家里的冰箱坏了,物业能不能上门维修?', 1, 1), +('保修', 'BX-METER-QUERY', '水电表读数疑问协查', '计量协查', 'SOP-BX-014', '这个月用量异常,能否帮我核对表计?', 2, 1), +('保修', 'BX-SAFE-SHUTDOWN', '设备异常停用与围挡', '故障隔离', 'SOP-BX-015', '公共设施出现异响,是否需要先停用并围挡?', 3, 1), +('保修', 'BX-LIVE-ELECTRICAL', '带电故障处置边界', '高风险作业', 'SOP-BX-016', '配电箱出现火花,现场是否可以直接检查?', 3, 0), +('保修', 'BX-GAS-LEAK', '疑似燃气泄漏升级边界', '高风险作业', 'SOP-BX-017', '家中闻到明显燃气味,请问应该怎么处理?', 3, 0), +('保修', 'BX-ELEVATOR-REPAIR', '电梯故障维修边界', '高风险作业', 'SOP-BX-018', '电梯运行时抖动,能否马上打开设备检查?', 3, 0), +('保修', 'BX-FIRE-SYSTEM', '消防设施故障维修边界', '高风险作业', 'SOP-BX-019', '消防泵控制柜报故障,需要立即检修。', 3, 0), +('保修', 'BX-CONFINED-SPACE', '有限空间作业边界', '高风险作业', 'SOP-BX-020', '污水井需要下井检查,请确认作业准备。', 3, 0), + +('客服', 'KF-PHONE-FIRST', '电话咨询首次响应', '电话服务', 'SOP-KF-001', '我第一次打电话咨询,请告诉我该找哪个部门。', 1, 1), +('客服', 'KF-ONLINE-FIRST', '在线咨询首次响应', '在线服务', 'SOP-KF-002', '我在小程序留言了,能否现在帮我查询?', 1, 1), +('客服', 'KF-COMPLAINT-REGISTER', '投诉信息完整登记', '投诉受理', 'SOP-KF-003', '我要正式投诉,请把我的诉求完整记录下来。', 2, 1), +('客服', 'KF-REPAIR-DISPATCH', '报修信息登记派单', '工单受理', 'SOP-KF-004', '家里漏水,请尽快安排师傅上门。', 2, 1), +('客服', 'KF-PROGRESS-QUERY', '工单进度查询', '工单受理', 'SOP-KF-005', '我的工单已经两天了,现在处理到哪一步?', 2, 1), +('客服', 'KF-RETURN-CALL', '未接来电回呼', '电话服务', 'SOP-KF-006', '刚才你们打电话我没接到,请问是什么事情?', 1, 1), +('客服', 'KF-SATISFACTION', '服务满意度回访', '回访服务', 'SOP-KF-007', '问题已经处理了,你们还要了解哪些情况?', 1, 1), +('客服', 'KF-FEE-QUERY', '费用明细查询受理', '费用咨询', 'SOP-KF-008', '我对账单有疑问,请帮我核对收费项目。', 2, 1), +('客服', 'KF-INVOICE', '发票申请引导', '费用服务', 'SOP-KF-009', '物业费发票在哪里申请,需要提供什么信息?', 1, 1), +('客服', 'KF-ACCESS-CARD', '门禁卡业务受理', '便民服务', 'SOP-KF-010', '门禁卡丢了,怎么挂失和补办?', 1, 1), +('客服', 'KF-PARKING-RENEW', '停车服务续费咨询', '停车服务', 'SOP-KF-011', '停车服务快到期了,如何续费?', 1, 1), +('客服', 'KF-INFO-CHANGE', '客户资料变更受理', '资料维护', 'SOP-KF-012', '我需要修改联系电话,请告诉我验证流程。', 1, 1), +('客服', 'KF-OUTAGE-INQUIRY', '停水停电信息查询', '突发咨询', 'SOP-KF-013', '现在停水了,预计什么时候恢复?', 2, 1), +('客服', 'KF-NOISE-COMPLAINT', '噪音投诉受理', '投诉受理', 'SOP-KF-014', '隔壁夜间噪音很大,请帮我登记处理。', 2, 1), +('客服', 'KF-LOST-FOUND', '失物招领查询', '便民服务', 'SOP-KF-015', '我可能把证件落在园区里了,怎么查询?', 1, 1), +('客服', 'KF-ACTIVITY-REGISTER', '社区活动报名受理', '社区运营', 'SOP-KF-016', '我想报名周末活动,还有名额吗?', 1, 1), +('客服', 'KF-SERVICE-BOOKING', '增值服务预约受理', '服务预约', 'SOP-KF-017', '我想预约上门服务,请说明时间和费用。', 2, 1), +('客服', 'KF-PRIVACY-REQUEST', '个人信息查询申请引导', '信息合规', 'SOP-KF-018', '我想查看物业保存了我的哪些个人信息。', 2, 1), +('客服', 'KF-ABUSIVE-CALLER', '辱骂来电情绪降级', '困难沟通', 'SOP-KF-019', '客户持续辱骂并拒绝提供工单信息。', 3, 1), +('客服', 'KF-EMERGENCY-TRANSFER', '紧急来电识别与转接', '紧急协同', 'SOP-KF-020', '现场有人受伤,请马上告诉我应该联系谁。', 3, 1); + +-- 兼容历史库以显式 id 导入 seed、但 AUTO_INCREMENT 仍停留在 1 的情况。 +-- 不先推进游标时,INSERT ... SELECT 的前若干行可能撞主键并错误进入 ON DUPLICATE 分支。 +SET @next_scenario_id = (SELECT COALESCE(MAX(`id`), 0) + 1 FROM `aihr_practice_scenario`); +SET @scenario_auto_increment_ddl = CONCAT( + 'ALTER TABLE aihr_practice_scenario AUTO_INCREMENT = ', @next_scenario_id +); +PREPARE scenario_auto_increment_stmt FROM @scenario_auto_increment_ddl; +EXECUTE scenario_auto_increment_stmt; +DEALLOCATE PREPARE scenario_auto_increment_stmt; + +INSERT INTO `aihr_practice_scenario` + (`tenant_id`, `scenario_code`, `scenario_name`, `position`, `scenario_type`, `project_type`, `difficulty`, + `customer_persona`, `opening_text`, `explicit_need`, `hidden_need`, `irritants`, `delights`, `sop_refs`, + `success_criteria`, `strategy`, `goal`, `trainee_name`, `trust_start`, `trust_end`, `rounds_json`, + `content_version`, `content_hash`, `enabled`, `sort_order`, `create_time`, `update_time`) +SELECT + '000000', c.`scenario_code`, c.`scenario_name`, c.`position`, c.`scenario_type`, '住宅', c.`difficulty`, + '关注响应速度、事实准确和处理闭环的业主或现场相关人员', c.`opening_text`, + CONCAT('依据 ', c.`sop_code`, ' 完成信息确认、边界说明和后续动作'), + '希望得到明确责任人、处理节点和可追溯记录', + '未经核实直接承诺结果、遗漏记录、越权处置或使用刺激性表达', + '先确认事实和风险,再说明权限边界、责任人、时限和回访方式', + c.`sop_code`, + CONCAT('正确引用 ', c.`sop_code`, ',形成受理、处理、升级、回访闭环'), + '保持承接情绪,优先识别安全风险;不确定时停止越权操作并升级', + CONCAT('训练', c.`position`, '在“', c.`scenario_name`, '”场景中按SOP完成服务闭环。'), + '试点学员', 60, 82, + JSON_ARRAY( + JSON_OBJECT('customer', c.`opening_text`, 'coachHint', CONCAT('先确认事实,再按 ', c.`sop_code`, ' 说明下一步。'), 'emotion', 68, 'trust', 68), + JSON_OBJECT('customer', '如果现在还不能解决,请明确谁负责、什么时候反馈。', 'coachHint', '说明权限边界、升级对象、时间节点和记录方式。', 'emotion', 58, 'trust', 82) + ), + 'v1.0', + SHA2(CONCAT_WS('|', c.`position`, c.`scenario_code`, c.`scenario_name`, c.`scenario_type`, c.`sop_code`, c.`opening_text`, 'v1.0'), 256), + c.`enabled`, + CASE c.`position` WHEN '生活顾问' THEN 100 WHEN '保安' THEN 200 WHEN '保洁' THEN 300 WHEN '保修' THEN 400 ELSE 500 END + + CAST(RIGHT(c.`sop_code`, 3) AS UNSIGNED), + NOW(), NOW() +FROM `_aihr_five_position_catalog` c +ON DUPLICATE KEY UPDATE + `sop_refs` = VALUES(`sop_refs`), + `content_version` = VALUES(`content_version`), + `content_hash` = VALUES(`content_hash`), + `enabled` = VALUES(`enabled`), + `sort_order` = VALUES(`sort_order`), + `update_time` = VALUES(`update_time`); + +INSERT INTO `aihr_learning_question` + (`tenant_id`, `question_code`, `scenario_code`, `position_code`, `question_type`, `question_text`, + `reference_answer`, `content_version`, `content_hash`, `enabled`, `sort_order`, `create_time`, `update_time`) +SELECT + '000000', + CONCAT(c.`scenario_code`, '-', q.`question_type`, '-v1'), + c.`scenario_code`, + c.`position`, + q.`question_type`, + CASE q.`question_type` + WHEN 'daily' THEN CONCAT('面对“', c.`scenario_name`, '”场景,收到“', c.`opening_text`, '”后,你的第一轮回应和后续闭环动作是什么?') + ELSE CONCAT('专项题:在“', c.`scenario_name`, '”场景中,如果对方情绪升级且你无法当场解决,如何依据 ', c.`sop_code`, ' 控制风险、升级并留痕?') + END, + CASE q.`question_type` + WHEN 'daily' THEN CONCAT('先复述并确认事实与诉求,检查是否存在安全风险;依据 ', c.`sop_code`, ' 说明权限边界、责任人和处理时限,记录工单并约定回访。不得编造承诺或越权操作。') + ELSE CONCAT('保持承接情绪,明确当前已知与未知信息;按 ', c.`sop_code`, ' 停止越权处置,隔离或提醒风险,升级到授权责任人,记录时间、对象、证据和反馈节点,并持续回访直至闭环。') + END, + 'v1.0', + SHA2(CONCAT_WS('|', c.`scenario_code`, q.`question_type`, c.`scenario_name`, c.`opening_text`, c.`sop_code`, 'v1.0'), 256), + c.`enabled`, + CASE q.`question_type` WHEN 'daily' THEN 1000 ELSE 2000 END + + CASE c.`position` WHEN '生活顾问' THEN 100 WHEN '保安' THEN 200 WHEN '保洁' THEN 300 WHEN '保修' THEN 400 ELSE 500 END + + CAST(RIGHT(c.`sop_code`, 3) AS UNSIGNED), + NOW(), NOW() +FROM `_aihr_five_position_catalog` c +CROSS JOIN ( + SELECT 'daily' AS `question_type` + UNION ALL SELECT 'special' +) q +WHERE 1 = 1 +ON DUPLICATE KEY UPDATE + `scenario_code` = VALUES(`scenario_code`), + `position_code` = VALUES(`position_code`), + `question_type` = VALUES(`question_type`), + `question_text` = VALUES(`question_text`), + `reference_answer` = VALUES(`reference_answer`), + `content_version` = VALUES(`content_version`), + `content_hash` = VALUES(`content_hash`), + `enabled` = VALUES(`enabled`), + `sort_order` = VALUES(`sort_order`), + `update_time` = VALUES(`update_time`); + +DROP TEMPORARY TABLE IF EXISTS `_aihr_five_position_catalog`; diff --git a/backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql b/backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql new file mode 100644 index 00000000..c7deb3ed --- /dev/null +++ b/backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql @@ -0,0 +1,150 @@ +-- 问 AI(全网)查询审计:只记录来源数量和状态,不写入密钥。 +CREATE TABLE IF NOT EXISTS `aihr_web_search_provider` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `provider_name` varchar(100) NOT NULL COMMENT '提供方名称', + `provider_code` varchar(40) NOT NULL DEFAULT 'tavily' COMMENT '提供方编码', + `endpoint` varchar(500) NOT NULL COMMENT '公开检索接口', + `api_key` varchar(1000) DEFAULT NULL COMMENT '加密访问密钥,接口永不回传', + `enabled` tinyint NOT NULL DEFAULT 0 COMMENT '是否启用', + `last_test_ok` tinyint NOT NULL DEFAULT 0 COMMENT '当前配置最近一次连接测试是否成功', + `last_test_time` datetime DEFAULT NULL COMMENT '当前配置最近一次连接测试时间', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_web_search_provider` (`tenant_id`, `provider_name`), + KEY `idx_aihr_web_search_enabled` (`tenant_id`, `enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='全网检索提供方'; + +-- 兼容已执行过旧版脚本的环境;新增字段默认未测试,避免未经测试的配置被启用。 +SET @aihr_schema = DATABASE(); +SET @web_last_test_ok_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_web_search_provider' AND column_name = 'last_test_ok' +); +SET @web_last_test_ok_ddl = IF(@web_last_test_ok_exists = 0, + 'ALTER TABLE aihr_web_search_provider ADD COLUMN last_test_ok tinyint NOT NULL DEFAULT 0 COMMENT ''当前配置最近一次连接测试是否成功'' AFTER enabled', + 'SELECT 1'); +PREPARE aihr_web_last_test_ok_stmt FROM @web_last_test_ok_ddl; +EXECUTE aihr_web_last_test_ok_stmt; +DEALLOCATE PREPARE aihr_web_last_test_ok_stmt; + +SET @web_last_test_time_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_web_search_provider' AND column_name = 'last_test_time' +); +SET @web_last_test_time_ddl = IF(@web_last_test_time_exists = 0, + 'ALTER TABLE aihr_web_search_provider ADD COLUMN last_test_time datetime DEFAULT NULL COMMENT ''当前配置最近一次连接测试时间'' AFTER last_test_ok', + 'SELECT 1'); +PREPARE aihr_web_last_test_time_stmt FROM @web_last_test_time_ddl; +EXECUTE aihr_web_last_test_time_stmt; +DEALLOCATE PREPARE aihr_web_last_test_time_stmt; + +CREATE TABLE IF NOT EXISTS `aihr_web_ai_query_audit` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `user_id` bigint NOT NULL COMMENT '服务端登录用户ID', + `ext_party_id` varchar(80) DEFAULT NULL COMMENT '组织人员外部ID', + `question` varchar(600) NOT NULL COMMENT '查询内容', + `provider` varchar(40) NOT NULL COMMENT '外部检索提供方', + `query_status` varchar(24) NOT NULL COMMENT 'DISABLED/OK/NO_SOURCES/NO_ANSWER/FAILED', + `source_count` int NOT NULL DEFAULT 0 COMMENT '可公开访问来源数', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_web_ai_user` (`tenant_id`, `user_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='全网AI查询审计'; + +-- 开放问题榜。奖励只进入系统积分或学习学分账户,最终值由服务端配置约束。 +CREATE TABLE IF NOT EXISTS `aihr_community_question` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `author_user_id` bigint NOT NULL COMMENT '提问用户ID', + `author_ext_party_id` varchar(80) DEFAULT NULL COMMENT '组织人员外部ID', + `author_name` varchar(100) NOT NULL COMMENT '展示名', + `title` varchar(160) NOT NULL COMMENT '标题', + `content` text NOT NULL COMMENT '问题内容', + `question_type` varchar(24) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/BOUNTY', + `category` varchar(40) NOT NULL DEFAULT '工作求助' COMMENT '问题分类', + `reward_rule_code` varchar(60) NOT NULL DEFAULT 'BEST_ANSWER' COMMENT '奖励规则编码', + `reward_points` int NOT NULL DEFAULT 10 COMMENT '最佳回答积分', + `deadline` datetime DEFAULT NULL COMMENT '截止时间', + `question_status` varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PUBLISHED/REJECTED/CLOSED', + `best_answer_id` bigint DEFAULT NULL COMMENT '最佳回答ID', + `moderator_user_id` bigint DEFAULT NULL COMMENT '审核人ID', + `moderation_note` varchar(300) DEFAULT NULL COMMENT '审核说明', + `moderated_time` datetime DEFAULT NULL COMMENT '审核时间', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_community_question_board` (`tenant_id`, `question_status`, `create_time`), + KEY `idx_aihr_community_question_author` (`tenant_id`, `author_user_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开放问题榜'; + +CREATE TABLE IF NOT EXISTS `aihr_community_answer` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `question_id` bigint NOT NULL COMMENT '问题ID', + `author_user_id` bigint NOT NULL COMMENT '回答用户ID', + `author_ext_party_id` varchar(80) DEFAULT NULL COMMENT '组织人员外部ID', + `author_name` varchar(100) NOT NULL COMMENT '展示名', + `content` text NOT NULL COMMENT '回答内容', + `answer_status` varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PUBLISHED/REJECTED', + `is_best` tinyint NOT NULL DEFAULT 0 COMMENT '是否最佳回答', + `moderator_user_id` bigint DEFAULT NULL COMMENT '审核人ID', + `moderation_note` varchar(300) DEFAULT NULL COMMENT '审核说明', + `moderated_time` datetime DEFAULT NULL COMMENT '审核时间', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_community_answer_question` (`tenant_id`, `question_id`, `answer_status`, `create_time`), + KEY `idx_aihr_community_answer_author` (`tenant_id`, `author_user_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开放问题回答'; + +CREATE TABLE IF NOT EXISTS `aihr_incentive_rule` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `rule_code` varchar(60) NOT NULL COMMENT '规则编码', + `display_name` varchar(100) NOT NULL COMMENT '规则名称', + `account_type` varchar(20) NOT NULL DEFAULT 'POINTS' COMMENT 'POINTS/STUDY_CREDIT', + `rule_value` int NOT NULL DEFAULT 0 COMMENT '奖励数值', + `daily_cap` int NOT NULL DEFAULT 0 COMMENT '单人每日上限,0为不限制', + `enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_incentive_rule` (`tenant_id`, `rule_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='积分和奖品激励规则'; + +INSERT IGNORE INTO `aihr_incentive_rule` + (`tenant_id`, `rule_code`, `display_name`, `account_type`, `rule_value`, `daily_cap`, `enabled`) +VALUES + ('000000', 'BEST_ANSWER', '最佳回答积分', 'POINTS', 10, 100, 1); + +CREATE TABLE IF NOT EXISTS `aihr_points_ledger` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `user_id` bigint NOT NULL COMMENT '积分用户ID', + `ext_party_id` varchar(80) DEFAULT NULL COMMENT '组织人员外部ID', + `business_type` varchar(40) NOT NULL COMMENT '业务类型', + `business_id` bigint NOT NULL COMMENT '业务记录ID', + `account_type` varchar(20) NOT NULL DEFAULT 'POINTS' COMMENT 'POINTS/STUDY_CREDIT', + `points` int NOT NULL COMMENT '账户数值变动', + `reason` varchar(200) NOT NULL COMMENT '变动原因', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_points_business` (`tenant_id`, `user_id`, `business_type`, `business_id`), + KEY `idx_aihr_points_user` (`tenant_id`, `user_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='员工奖励账户流水'; + +-- 兼容已经执行过旧版脚本的环境:旧流水全部归入 POINTS,保持原积分余额不变。 +SET @aihr_schema = DATABASE(); +SET @account_type_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @aihr_schema AND table_name = 'aihr_points_ledger' AND column_name = 'account_type' +); +SET @account_type_ddl = IF(@account_type_exists = 0, + 'ALTER TABLE aihr_points_ledger ADD COLUMN account_type varchar(20) NOT NULL DEFAULT ''POINTS'' COMMENT ''POINTS/STUDY_CREDIT'' AFTER business_id', + 'SELECT 1'); +PREPARE aihr_account_type_stmt FROM @account_type_ddl; +EXECUTE aihr_account_type_stmt; +DEALLOCATE PREPARE aihr_account_type_stmt; diff --git a/docs/20260708/uni-app用户侧前端重建计划.md b/docs/20260708/uni-app用户侧前端重建计划.md index f78717b2..876008b8 100644 --- a/docs/20260708/uni-app用户侧前端重建计划.md +++ b/docs/20260708/uni-app用户侧前端重建计划.md @@ -121,7 +121,7 @@ mobile-uni/ 截至 2026-07-10,`mobile-uni/` 已是用户、主管、候选人三端的现役页面栈,旧 `mobile/` 仅作 MVP 兜底。三端均以登录身份和真实 API 驱动:员工完成训练、问师傅和成长回读;主管按后端组织范围进行团队、复盘和专项指派;候选人按真实面试/资料状态进入独立线性页面。H5 构建、真实浏览器主链路和接口回读均已完成验证,不使用前端 mock 掩盖业务状态。 -正式试点尚未通过:组织同步 dry-run 的 3474 名上游员工中仅 1 名具备可用于移动端身份映射的手机号,另有 8 条疑似乱码;严格门禁在窗口 `2026-07-07..2026-07-10` 只识别到 1 名正式人员且无人完成 10 次训练。下一阶段优先补齐上游正式组织数据并执行联合试点验收,不继续把功能堆回首页或旧单页。 +2026-07-15 更新:组织同步和手机号映射门槛已从上游数据问题中恢复,线上 `aihr_org_snapshot` 已覆盖为 3417 名员工,其中 3392 人可手机号映射,25 人手机号仍不可用,疑似乱码为 0。严格试点尚未通过的剩余原因不再是组织同步能力,而是需要在真实试点窗口产生训练、人工校准和 SOP 评审样本;旧窗口 `2026-07-07..2026-07-10` 仍不能作为业务验收证据。下一阶段优先组织 1–2 个项目、至少 20 名员工的联合试点验收,不继续把功能堆回首页或旧单页。 - 第一批用户侧主链路已落地到 `mobile-uni/`: 登录、今日页、问师傅、答案反馈、总结卡、对练页、个人页。 - 本机开发入口已切到 portless: `https://wygj-mobile-uni.localhost/h5/`。 @@ -179,8 +179,8 @@ mobile-uni/ - 员工今日页师傅气泡已移除固定姓名 fallback: 未登录显示“同事”,登录后按手机号尾号提示,不再把 seed 姓名暴露给真实用户。 - 员工今日页“查看全部”和小贴士箭头已接通动作: 分别进入练习页和带问题进入问师傅,不再保留看起来可点但无反馈的入口。 - 员工今日页未登录视图已收敛: 公开 home 的 `primary/review` 不再渲染成私人“今日安排”,改为登录提示;工具区未登录时显示为“常用入口”。 -- 员工今日页问答入口文案已收敛: 当前 uni-app 首页只负责进入问师傅页,主按钮改为“问老师傅”,不再用“长按说话”暗示已实现录音交互。 -- 员工今日页训练文案已收敛: 首页主任务从“3分钟语音练习”改为“3分钟情景练习”,不再承诺当前 uni-app 尚未提供的语音对练入口。 +- 员工今日页问答入口仍有待收口:主按钮虽然改为“问老师傅”,副文案仍写“语音提问,快速解答”,但两个入口都只跳到当前仅有文字框的问师傅页;短期应改为文字问答口径,若补语音则复用现有 `services/speech.ts` 与 `/api/ai/asr`。 +- 员工训练页当前已支持文字回答、浏览器录音转写和选择音频转写;首页主任务继续使用“3分钟情景练习”,避免把训练方式限制成只支持语音。 - 问师傅页已补登录守卫: 未登录检索会保留当前问题并跳登录,不再直接暴露“认证失败”。 - 移动端认证清理已收敛到共享 `clearAuth()`: 主动退出和 401/403 会同时清掉 token、clientId、手机号和登录回跳,避免旧手机号残留。 - 员工“我”页已补过期登录态反馈: 401/403 清理后会同步刷新当前页面为“未登录/去登录”,不再继续显示旧手机号和退出按钮。 @@ -254,7 +254,7 @@ mobile-uni/ - 主管权限已以后端组织快照为准: 只有在职“主管/项目经理”可访问团队、预警、复盘和指派接口;普通员工真实浏览器验证只显示“无主管权限”及返回员工端/切换账号,不泄露团队指标和表单草稿。 - 主管复盘写入已补事务和幂等: 仅 `待复盘 -> 已复盘` 的首次状态转换创建后续专项;并发重复请求通过行锁读取最终状态,不重复派发。 - 主管端已用真实项目经理账号验证组织范围: 本地正式快照返回 19 名团队成员、1 条待复盘、2 项待训练和 1 条低分预警;团队页、复盘页和指派页均读取真实接口,不补前端 mock。 -- 正式组织快照中的不可读问号姓名在用户侧回退为手机号尾号或外部 ID 末尾标识,真实 ID 保持不变;开放组织 dry-run 已确认源接口自身仍返回 8 条疑似乱码,需由上游修复后再同步。 +- 正式组织快照中的不可读问号姓名在用户侧回退为手机号尾号或外部 ID 末尾标识,真实 ID 保持不变;2026-07-15 开放组织 dry-run 已确认疑似乱码为 0,线上同步后剩余问题收敛为 25 名员工手机号不可用。 - 候选人端已完成线性页面栈拆分: 原 750 行长页收敛为候选首页、面试任务、补充资料、进度通知和岗前预习 5 个路由;首页只展示真实状态和办理入口,不再原地展开所有表单和结果。 - 候选人流程状态已抽成共享纯函数: 只根据当前候选人的正式面试记录和最新资料状态派生“待面试/待补资料/审核中/已通过”,不读取公开 home seed,不补前端 mock。 - 候选面试页继续复用真实 `/api/recruit/interview/start|answer|finish|records`: 浏览器以当前移动账号验证 `/start` 返回 3 道岗位题,未提交答案和评分,避免产生额外正式面试记录。 @@ -263,10 +263,10 @@ mobile-uni/ - 候选岗前预习页改为自然业务问题检索正式 SOP: 浏览器验证投诉预习返回正式答案、关键要点、注意事项,引用命中 `投诉处理 SOP v1.0`、`生活顾问的一天.mp4` 和正式课件,无前端示例答案。 - 演示预检已跟随页面栈更新: `scripts/demo-check.sh` 改为检查主管复盘详情和候选 4 个子路由,不再在旧主管首页查复盘表单标记;本地数据库连接不可用时非严格模式明确跳过样本、严格模式继续失败。 - 本地连接耗尽已处置: 清理未被 portless 使用的旧后端实例并重启本地 MySQL 后,连接稳定在约 `11/151`,当前 API HTTP 200;`dev.sh stop/restart` 已改为安全终止 portless 进程树,避免旧 Java/Vite 子进程继续占用连接池。 -- 试点严格预检已改为正式批次口径: 必须指定起止日期,训练、校准和 SOP 评审只统计该时间窗;人员通过唯一手机号映射到在职组织,排除重复手机号和身份碰撞,完训定义为每人至少 10 次。`2026-07-07..2026-07-10` 实测开发活动身份 50 人、10 次完训 0 人,但正式组织仍为 `org_login=1/3001`、`org_pilot=1/20`、`org_ten_sessions=0/1`,所以严格模式正确失败。开放组织 dry-run 返回 `employees=3474 phone_linked=1 masked_phone=3473 suspect_text=8 synced=0`,且 dry-run 不访问本地表结构或写库,证明手机号和 8 条问号数据来自上游且本轮未覆盖快照。 -- 管理端试点 CSV 已与严格预检统一口径: 必须选择起止日期,只导出窗口内唯一匹配正式组织的训练明细,完训按每人至少 10 次,校准必须关联正式试点会话。真实 API 返回正式试点 1 人/1 项目、10 次完训 `0/1`、校准 `0/20`、窗口内 SOP `2/2`,总闸门为 `false`;此前开发会话中的 20 条校准不再计入正式验收。 +- 试点严格预检已改为正式批次口径: 必须指定起止日期,训练、校准和 SOP 评审只统计该时间窗;人员通过唯一手机号映射到在职组织,排除重复手机号和身份碰撞,完训定义为每人至少 10 次。2026-07-15 线上组织快照已具备 `employees=3417 phone_linked=3392 masked_phone=25 suspect_text=0`;旧窗口 `2026-07-07..2026-07-10` 仍因无人达到 10 次训练、缺少正式校准样本而失败,所以后续需用新试点窗口重跑,不能沿用开发活动数据。 +- 管理端试点 CSV 已与严格预检统一口径: 必须选择起止日期,只导出窗口内唯一匹配正式组织的训练明细,完训按每人至少 10 次,校准必须关联正式试点会话。组织快照覆盖后 CSV 的人员映射能力已具备;下一步需在正式试点窗口产生真实训练、校准和 SOP 评审记录后重新导出,此前开发会话中的 20 条校准不再计入正式验收。 - 发布会正式候选题已重新走查: Q01-Q12 `12/12` 返回有效答案和依据,来源告警 `0`,向量状态 `450/450`;仍有 119 条 SOP 问答待业务评审,不把开发方走查替代业务签字。 -- 下一阶段进入三端联合发布门槛: 复跑员工-主管-候选人角色切换、清理本地验证数据、核对正式组织姓名编码并完成 H5 发布前验收;不把本次页面拆分等同一期生产验收完成。 +- 下一阶段进入三端联合试点门槛: 复跑员工-主管-候选人角色切换、清理本地验证数据、选择真实项目与员工样本并完成 H5 发布后验收;不把本次页面拆分等同一期生产验收完成。 ## 7. 风险 @@ -274,4 +274,4 @@ mobile-uni/ - H5 路由和将来小程序路径不完全一致,页面命名要早期克制。 - 旧 H5 在 Alpha 前仍承担演示兜底,新工程未完成替换前不能下线旧入口。 - 如果后端某些接口仍要求管理端 token,需要后端补移动端认证口径,不能在新前端绕过认证。 -- 当前本地组织快照存在部分不可读姓名,前端已做可识别展示兜底,但正式试点前仍需从开放组织同步源修复编码并重跑快照同步。 +- 线上组织快照已完成开放组织覆盖同步并消除疑似乱码;剩余 25 名员工手机号不可用属于上游数据质量问题,不阻塞功能发布,但这些人员在修复前无法通过手机号自动映射身份。 diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index c87726cb..2980966a 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -14,8 +14,8 @@ | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;员工学习页按当前 APP 身份读取正式岗前/入职任务,员工只能将本人处于“待完成/进行中”的任务确认完成,服务端按组织快照/手机号归属更新 `status/completed_time`,不接受前端身份参数;资格证据无正式数据时明确返回 `NOT_CONFIGURED`,不以 AI 分数代替上岗资格;`position-sop` 仍保留一期生活顾问学习导航语义 | -| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | -| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 | +| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 | +| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-15 线上已用 `/api/open/v1` 前缀完成配置、dry-run 和覆盖同步:开放平台返回公司 17、部门 963、员工 3417;生产 `aihr_org_snapshot` 为 3417 行,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。当前剩余 25 人手机号不可用属于上游数据质量问题;功能发布不再被组织同步能力阻塞 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 未登录请求只返回不读取租户业务统计的公开首屏 seed;已登录移动端请求自动携带 `Authorization/clientid`,才返回员工/主管真实统计;移动端本地 fallback 保演示 | | 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | @@ -68,9 +68,9 @@ SOP 文档上传第三片已经落最小后端边界: | 混合检索 | `POST /api/knowledge/search` | 先跑中文关键词 `LIKE` 打分和 MySQL Fulltext,再生成 query embedding 走 Qdrant,RRF 融合后交给 `category='rerank'` 模型(如硅基流动 bge-reranker-v2-m3)按语义相关性重排;rerank 未配置或失败保持 RRF 顺序,Qdrant 或外部向量接口不可用时保留关键词/全文检索。命中后若 chat 模型可用,自动生成结构化轻概要写入响应 `answer/keyPoints/cautions`(≤60字直答+3-6要点+0-3注意;temperature=0,15s 超时,硬约束不得编造、答不了明示无依据);概要失败回退片段原文拼接,检索不报错 | | 检索总结卡 | `POST /api/knowledge/summary-card`(JSON `{queryText, category}`) | 按需生成信息图式总结卡 `{title, steps[], objections[], scripts[], reminders[]}`:复用检索命中片段(最多5条)调 chat 模型,temperature=0、30s 超时;无命中/无 chat 模型/生成失败均抛明确错误供前端重试,不兜假数据。移动端查SOP结果页「生成总结卡」按钮触发,底部弹层展示并可 TTS 朗读 | | 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 | -| 服务端目录导入 | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,保留给小批量/调试 | -| 服务端导入任务 | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,页面轮询查看总数、成功数、失败数、当前文件和进度;运行中任务可取消;重试当前按同目录重新启动一轮 | -| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;支持 ZIP ≤500MB,worker 限制最多 1000 个子文件、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;支持的子文件写回同一 batch 继续复用解析链路;完成/失败状态 CAS 写入,卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h 供重试;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览 | +| 服务端目录导入(运维) | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,仅保留给运维/调试,不在资料处理页暴露 | +| 服务端导入任务(运维) | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,调用方可查询总数、成功数、失败数、当前文件和进度,运行中任务可取消;仅保留给运维/调试,不在资料处理页暴露 | +| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;支持 ZIP ≤500MB,worker 限制最多 1000 个子文件、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;ZIP 文件名优先按 UTF-8 解码,旧版中文 Windows ZIP 自动回退 GBK,仍无法解压时返回安全提示;同名子文件自动追加编号并以单条批量写入入队,避免目录扁平化覆盖和半批次入队;完成/失败状态 CAS 写入,卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h 供重试;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览 | | 视频解析 | 走批量异步上传,支持 `.mp4/.mov/.avi/.mkv/.webm/.m4v`,单文件 ≤500MB、时长 ≤60 分钟 | 依赖服务器 ffmpeg/ffprobe;抽音轨按 5 分钟分段调 asr 模型转写(`[mm:ss]` 时间戳),按 30s 间隔(≤40 帧)抽关键帧调 vision 模型提取画面文字(相邻重复画面去重);两类文本合并后走归类/切片/向量化;无 asr 且无 vision 结果时按「待处理」落库不报错;视频同时只加工 1 个 | 模型能力第二片已经落最小后端边界: @@ -179,3 +179,40 @@ curl -fsS -X POST "$API_BASE/api/knowledge/doc/rebuild-vector-index" -H "Authori ``` 浏览器验收仍按 [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) 的四条关键路径执行。 + +## 多租户知识空间统一问答 + +内部员工端、主管端和管理端统一调用: + +```http +POST /api/knowledge/query +Authorization: Bearer +Content-Type: application/json + +{ + "queryText": "漏水投诉第一步怎么处理?", + "spaceCodes": ["yc_property_sop"], + "limit": 5 +} +``` + +内部有效空间由服务端按当前租户、SESSION 应用绑定和用户/角色授权求交集;请求体中的 `tenantId/userId/extPartyId` 不参与身份判断。员工本人和主管团队概况只允许使用固定数据工具: + +```json +{"queryText":"我的训练概况","toolCode":"MY_PRACTICE_SUMMARY"} +{"queryText":"团队训练概况","toolCode":"TEAM_PRACTICE_SUMMARY"} +``` + +外部应用使用独立入口,不能调用数据工具: + +```http +POST /api/open/knowledge/query +Authorization: Bearer +Content-Type: application/json + +{"queryText":"你们提供哪些服务?","spaceCodes":["mt_customer_service"],"limit":5} +``` + +成功响应包含 `requestId`、`answer`、`citations`、`usedSpaceCodes`、`noEvidence` 和兼容 `legacy` 数据。引用携带 `spaceCode/sourceType/docId/title/snippet/fragmentId`;客户端不得把无引用回答包装成有知识依据的正式答案。 + +空间、授权和应用管理接口统一位于 `/api/knowledge/admin`:`spaces`、`spaces/{id}/grants`、`spaces/{id}/documents`、`apps`、`apps/{id}/spaces`、`apps/{id}/rotate-token`。`DELETE /spaces/{id}/documents/{attachId}` 只解绑当前空间成员;其他空间仍引用同一 OSS 时原文件不会删除。仅知识平台管理角色可调用,API_TOKEN 创建或轮换后只返回一次明文。完整初始化、令牌保管、legacy 迁移与回滚步骤见 [KNOWLEDGE_PLATFORM_RUNBOOK.md](KNOWLEDGE_PLATFORM_RUNBOOK.md)。 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 9d730bb2..81ff99a4 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,5 +1,23 @@ # BRD 功能审查与未完成项记录 +## 现阶段总览(2026-07-15) + +本节只回答当前 `mobile-uni` 三端交互输入与 BRD 的匹配程度,不替代 L0/L1、生产发布或正式试点验收。按 BRD v1.2 的最终多模态目标评估,当前约为 **60%–70%(部分匹配)**;按总纲阶段一“陪练”执行边界评估,约为 **80%–90%(核心链路基本匹配)**。区间是工程审查判断,不是 BRD 官方验收指标。 + +| 角色端 | 文字输入 | 语音输入 | 图片/视频输入 | BRD 判断 | +|---|---|---|---|---| +| 员工端 | 对练、每日三题、问师傅和复盘意见支持文字 | 对练、每日三题和问师傅均支持浏览器录音或选择音频后 ASR;案例素材支持音频上传;关键题目、回答、点评和总结卡支持播报 | 问师傅本地已支持图片/视频仅用于本次现场分析;对练仍无图像/视频情境输入 | 满足阶段一语音/文本主链路;问师傅多媒体已有本地最小实现,对练仍未满足 BRD 4.3.3 图像/视频情境输入;均未发布生产 | +| 候选人端 | 面试支持文字作答 | 面试支持录音转文字和选择音频 | 补充资料支持 PDF/Word/常见图片,但不是图片面试或多模态评分 | 满足 BRD 4.1 语音/文本作答;资料图片上传属于附加能力,不能冲抵 4.3.3 缺口 | +| 主管端 | 专项指派、复盘建议和后续训练原因支持文字 | 可回听员工训练录音,不支持主管录音输入 | 无图片/视频提交入口 | 符合当前“查看回放→写复盘→派训练”职责;BRD 未要求主管端同时具备三种输入 | + +已匹配的输入链路:候选人语音/文本面试(BRD 4.1)、员工语音/文本对练与每日一练(4.3/4.4)、主管文字复盘与录音回听(4.4)、案例音频上传(4.6)。尚未闭合的终态要求:员工图像/视频情境输入和服务录像回放(4.3.3)、四川话/粤语等方言的真实样本与 `<=5s` 响应证据(6.5/L1-C)、员工个人文字/文件/图片/链接收藏与混合问答(4.8,明确属于阶段二)。 + +此前 `pages/user/today/index.vue` 显示“语音提问,快速解答”,但问师傅只有文字输入,形成前端承诺不一致。2026-07-17 本地已复用 `mobile-uni/src/services/speech.ts` 和现有 `/api/ai/asr` 恢复录音、转写与选择音频兜底,没有新增移动端专用 ASR 接口;正式 ASR 配置和真机麦克风权限仍待发布前验证。 + +审查证据来自当前源码与本地渲染控件;本轮未重新执行真实方言 ASR、图片上传、语音响应时延或生产环境端到端测试,因此这些能力继续标记为未验证。图片/视频情境输入已在 `AI陪练二期开发推进计划.md` 中明确为文本场景稳定后的独立评估项;个人知识空间按 `银城员工端APP分阶段实施总纲.md` 留在阶段二,不应拉入当前阶段一主线。 + +- 2026-07-15 管理端资料处理分页静态发布:已备份生产根目录至 `/opt/wygj/backups/www-20260715134936`,再同步 `frontend/dist/`;根目录同步未使用 `--delete`,因此未覆盖同目录的 `/h5`。线上 `processing-yUVzof10.js` SHA-256 与本地构建一致。真实管理员会话确认任务分页共 97 页、第二页仍展示 10 条,状态筛选后回到第一页;根站、H5 和租户接口均返回 `200`。本轮未发布后端、迁移数据库或写入业务数据。 + - 2026-07-15 当前发布与资料导入复核:提交 `eacf1a0a` 已发布到线上,`release-preflight.sh` 通过资源/后端哈希、`16/16` schema、根站/H5/API 健康检查,`wygj-aihr.service` 为 `active`。使用真实管理员认证在生产验证 ZIP 异步资料链路:ZIP 入队后拆为 `2` 条队列记录,`2/2` 完成,生成 `1` 个文档和 `2` 个片段;测试队列、附件、片段和 OSS 对象已清理。当前生产 BRD 证据仍未达标:评分校准 `0` 条,SOP 评审 `0/14`,候选资料 `0` 条;组织快照为在职 `22` 条、`2` 个项目、主管/经理岗位 `7` 条,但手机号映射为 `0`。该结果证明“版本已发布、ZIP 功能可用”,不等于正式试点验收完成。 - 2026-07-15 发布 schema gate 回归:`RELEASE_VERIFY_REMOTE_SCHEMA=true` 的只读预检在保留两份 Figma 文档脏工作区的临时索引下正常运行,根站、租户和员工首页均为 `200`,随后准确拦截线上缺失的 8 张表:`aihr_candidate_employee_link`、`aihr_onboard_task`、`aihr_position_responsibility`、`aihr_practice_rubric`、`aihr_practice_rubric_dimension`、`aihr_practice_scenario`、`aihr_qualification_gate`、`aihr_sop_applicability`;未执行迁移或发布。 @@ -276,7 +294,7 @@ - 2026-07-14 测试租户 HTTP 回归:在本地临时建立 `codex-a`/`codex-b` 两个租户,用同一手机号分别登录并授予各自 `superadmin` 测试角色;两租户分别调用异步上传和目录导入接口。A 查询不到 B 的上传批次/导入任务,B 也查询不到 A;两个目录导入任务均完成 `1/1`。后台 worker 的队列加工、目录导入、任务进度和查询均保持租户隔离。测试产生的租户、用户、角色、知识库行和临时文件已清理为 0。该证据只覆盖本地测试环境,不替代正式组织同步和生产多租户验收。 - 2026-07-14 上传队列上下文收口:`AihrUploadQueueService` 的同步请求租户读取改为 `TenantHelper.getTenantId()`,动态租户上下文优先于登录态,避免重试/嵌套任务回退到错误租户;`AihrUploadQueueServiceTest` 已补对应源码契约断言。AIHR 定向测试与模块编译通过。 - 2026-07-14 发布前检查补齐:新增只读 `scripts/release-preflight.sh`,统一核验当前 commit、管理端/H5/后端产物存在性、入口 JS 和 SHA-256;传入 `RELEASE_REMOTE_URL` 时只做公开根站点与租户列表 HTTP 检查,不执行 SSH、rsync、服务重启或生产写入。`docs/DEV_SETUP.md` 已同步发布证据与回滚留痕要求。 -- 2026-07-14 当前状态复核:本地 `ry-vue` 组织快照有 `3001` 条 active 记录,但手机号可映射仅 `1` 条、项目 `41` 个,不能作为正式 1–2 项目试点身份数据;线上根站、`/h5/` 和租户接口均返回 `200`,但线上管理端仍加载 `index-CJZ3Ax3Z.js`,本地现有构建产物为 `index-Cz1rtpOj.js`,且发布预检被两份已有未提交 Figma 文档阻断,因此未把最新本地提交宣称为已发布。 +- 2026-07-14 当前状态复核:本地 `ry-vue` 组织快照有 `3001` 条 active 记录,但手机号可映射仅 `1` 条、项目 `41` 个,不能作为正式 1–2 项目试点身份数据;线上根站、`/h5/` 和租户接口均返回 `200`,但线上管理端仍加载 `index-CJZ3Ax3Z.js`,本地现有构建产物为 `index-Cz1rtpOj.js`,且发布预检被两份已有未提交 Figma 文档阻断,因此未把最新本地提交宣称为已发布。该组织身份结论已由 2026-07-15 线上组织同步闭环条目替代。 - 2026-07-14 BRD 公开导航回归:`GET /api/knowledge/position-sop` 明确是一期生活顾问学习导航摘要,不返回个人或项目私有数据;控制器补 `@SaIgnore` 后匿名请求返回 `200`,而同一控制器的 `POST /api/knowledge/search` 仍返回业务 `401`,保持“公开只读导航、登录后查询与反馈”的边界。同步完成跨租户异步上传成功路径回归:codex-a/codex-b 各上传独立文件并加工为 `status=2`,互查批次均为空;临时租户、用户、队列、知识库、OSS 与暂存文件已清理。 - 2026-07-14 BRD G3 仪表盘权限收口:`/api/aihr/dashboard/overview` 返回组织、训练、知识库、案例和候选资料统计,控制器新增显式 `@SaCheckLogin`;匿名请求业务返回 `401`,同时保留服务层按当前租户过滤。后续正式主管可见范围仍需组织快照和项目权限数据支撑。 - 2026-07-14 BRD G3 主管接口权限复核:移动端主管/团队/预警/派题/复盘接口对后台系统用户现在要求 `superadmin` 或 `hr_operator` 角色;APP 用户仍要求组织快照中的主管/项目经理身份。新增源码契约测试与 `demo-check` marker,避免普通后台账号越权读取团队训练数据或创建派题。 @@ -435,14 +453,14 @@ - 2026-07-14 BRD 门禁夹具同步:严格预检已将 SOP 正式样本限定为带 `requester_ext_party_id` 且能命中在职组织身份的提问记录;原 `demo-check-pilot-gates` 临时表仍是旧 6 列,导致 SOP 指标为空并无法真实覆盖新口径。现补齐主体与 `prompt_version` 字段,使用安全员工主体 `E` 验证 `sop_usable=1/1`、待评审数和窗口过滤,未改变业务数据。 - 2026-07-14 BRD 生产迁移回归固化:新增 `scripts/tests/aihr-schema-migrations.test.sh`,在临时 MySQL 库验证 SOP 主体/版本字段和组织入职日期迁移面对旧表、缺前置表时可安全执行且重复执行幂等;不写入开发库业务数据。 - 2026-07-14 BRD 生产迁移回归扩展:同一临时 MySQL 回归测试同时连续执行 `aihr_20260714_practice_evidence_mysql8.sql`,确认 `aihr_practice_audio` 与 `aihr_practice_calibration` 两张证据表可重复创建;不写入开发库业务数据。 -- 2026-07-14 BRD 当前开发库审查快照:生活顾问启用场景 `12`、校准记录 `20`、已评审 SOP `2`,但住宅 SOP 文档仅 `3/5`、已入库案例 `2/20`,在职组织快照 `3001` 条中手机号可映射仅 `1` 条;这些数据不能作为正式试点通过,下一步优先补内容负责人确认的核心流程素材和正式组织身份映射,不用烟测记录替代。 +- 2026-07-14 BRD 当前开发库审查快照:生活顾问启用场景 `12`、校准记录 `20`、已评审 SOP `2`,但住宅 SOP 文档仅 `3/5`、已入库案例 `2/20`,在职组织快照 `3001` 条中手机号可映射仅 `1` 条;这些数据不能作为正式试点通过,下一步优先补内容负责人确认的核心流程素材和正式组织身份映射,不用烟测记录替代。该组织身份映射结论已由 2026-07-15 线上组织同步闭环条目替代,内容/案例/正式试点样本风险仍需继续跟进。 - 2026-07-14 BRD 5.4 训练隐私边界补强:组织快照存在且默认关闭显示字段时,员工历史、主管复盘详情、任务列表和正式试点 CSV 明细不再直接返回历史 `trainee_name`,统一回退为“员工”;新产生的训练、每日题和错题再练记录也不再默认写入姓名,只有显式开启 `aihr.org-sync.store-display-fields` 才展示/保留名称。新增服务回归断言与 `demo-check` marker,保留稳定 `formal_ext_party_id`、项目和训练证据字段;该修复不替代历史显示字段清理、保留期和法务确认。 - 2026-07-14 BRD G3 组织快照缺失 fail-closed:主管团队接口此前在 `aihr_org_snapshot` 不存在时回退读取训练/派发表,生产迁移遗漏可能把历史姓名和非正式范围暴露给后台主管视图;现仅 `dev/local` profile 允许演示回退,其他 profile 在组织快照不可用时返回空团队并停止继续查询活动数据。新增“非 Demo 无快照直接为空”回归测试与 `demo-check` marker,不改变本地演示路径。 - 2026-07-14 BRD 5.4 隐私开关一致性修复:部分兼容查询此前只有在检测到组织快照表时才隐藏姓名,组织表暂缺时可能绕过 `store-display-fields=false`;现员工历史、任务/主管记录、复盘详情及无快照演示成员读取均统一由显示开关控制,默认关闭即返回“员工”。dev 配置仍显式开启用于演示,生产默认关闭;AIHR 全量真测试通过,未修改业务数据。 - 2026-07-14 BRD 严格门禁绕过修复:`AIHR_DEMO_CHECK_LIBRARY_ONLY=true` 仅允许测试加载脚本函数,和 `AIHR_PILOT_STRICT=true` 同时使用时现在直接失败,避免调试开关把正式试点检查静默跳过;新增回归断言,未修改业务数据。 - 2026-07-14 线上浏览器身份复核:使用数据库已有手机号 `13900001111` 和当前 dev 验证码规则登录 `https://peilian.njzhmj.top/h5/` 成功,但随后进入“确认岗位”页并提示“未从组织数据匹配到岗位”,说明线上手机号登录链路可用而正式组织岗位映射仍未闭合;本轮未点击岗位确认,不写入线上岗位数据。 -- 2026-07-14 BRD 本轮线上只读复核:生产根站、`/h5/`、`/prod-api/auth/tenant/list` 和 `/prod-api/api/aihr/mobile/home/user` 均返回 `200`;线上仍加载管理端 `assets/index-CJZ3Ax3Z.js` 与 H5 `assets/index-D4-NrEpb.js`。当前本地 `HEAD=8d5cb3d6` 的隐私/组织快照修复尚未重新构建或发布,本轮未执行生产静态同步、后端重启或业务数据写入;线上手机号登录仍会进入“确认岗位”但无法匹配正式组织岗位,P0 组织同步缺口保持未完成。 -- 2026-07-14 BRD 5.4 组织快照响应字段复核:`OrgPersonRow` 不包含 `person_phone`,手机号只用于服务端身份映射、过滤和统计;`/api/aihr/org/snapshot` 仍要求 `superadmin/hr_operator`,`store-display-fields=false` 时姓名和部门返回空值。当前未发现新的手机号响应泄露,因此不新增重复脱敏代码;正式组织同步、手机号覆盖率和历史字段治理仍保持 P0 未完成。 +- 2026-07-14 BRD 本轮线上只读复核:生产根站、`/h5/`、`/prod-api/auth/tenant/list` 和 `/prod-api/api/aihr/mobile/home/user` 均返回 `200`;线上仍加载管理端 `assets/index-CJZ3Ax3Z.js` 与 H5 `assets/index-D4-NrEpb.js`。当前本地 `HEAD=8d5cb3d6` 的隐私/组织快照修复尚未重新构建或发布,本轮未执行生产静态同步、后端重启或业务数据写入;线上手机号登录仍会进入“确认岗位”但无法匹配正式组织岗位。该 P0 组织同步缺口已由 2026-07-15 线上组织同步闭环条目替代,本条只保留当时线上资源状态。 +- 2026-07-14 BRD 5.4 组织快照响应字段复核:`OrgPersonRow` 不包含 `person_phone`,手机号只用于服务端身份映射、过滤和统计;`/api/aihr/org/snapshot` 仍要求 `superadmin/hr_operator`,`store-display-fields=false` 时姓名和部门返回空值。当前未发现新的手机号响应泄露,因此不新增重复脱敏代码;正式组织同步和手机号覆盖率已由 2026-07-15 线上同步闭环补齐,历史字段治理仍需独立跟进。 - 2026-07-14 BRD 发布预检复核:在隔离干净工作树按当前 `HEAD=8f2ace4a` 重新构建管理端、`mobile-uni` H5 和后端 jar,`release-preflight.sh` 的产物新鲜度、根站和租户业务码检查通过;本地管理端主资源 `index-CX4fynEb.js`(SHA-256 `c800022a22e82e81782da590cfec81145852014a6521f7521775720e0f1087fe`)与 H5 `index-C1WoTt0t.js`(SHA-256 `bfb07609acecde3678ed18bf48ce022f36596c6b946e076436fe242cf3e15675`)均与线上 `index-CJZ3Ax3Z.js` / `index-D4-NrEpb.js` 不一致,预检按预期停止;本轮未执行生产静态同步、后端重启或业务数据写入。 - 2026-07-14 BRD G3 OSS 元数据租户隔离修复:SOP 文档去重、处理概览和文件解析元数据此前通过 `JdbcTemplate` 仅按 `sys_oss.oss_id` 读取/更新,未显式约束 `sys_oss.tenant_id`;现统一要求 `sys_oss.tenant_id = aihr_knowledge_attach.tenant_id` 或当前租户,避免跨租户 OSS 元数据被误读/覆盖。新增 `AihrSopSeedServiceTest` 契约断言与 `demo-check` marker;未修改业务数据或生产环境。 - 2026-07-14 BRD G3 Qdrant 共享 collection 隔离修复:重建单租户向量索引此前会删除整个共享 Qdrant collection,并用全局点数与当前租户 MySQL 数量比较;现改为按当前租户 payload filter 删除和统计点数,保留其他租户向量。新增回归契约与 `demo-check` marker;未修改业务数据或生产环境。 @@ -456,4 +474,61 @@ - 2026-07-15 BRD G3 候选人面试操作角色边界修复:`/api/recruit/interview/start`、`answer`、`finish` 此前只要求登录,普通后台系统用户可绕过 HR/管理员权限创建或完成面试;现统一限制为 APP 候选人本人或 `superadmin/hr_operator`,候选人会话仍按本人手机号绑定。新增源码契约测试和 `demo-check` marker;尚未发布生产。 - 2026-07-15 BRD G3 面试未知会话状态边界修复:系统用户调用 `answer` 携带不存在或已失效的 `sessionId` 此前会返回“已暂存”而不暴露会话无效;现统一要求会话存在且属于当前租户,`finish` 保留 HR 管理端既有的兼容性创建逻辑。新增服务回归测试和 `demo-check` marker;尚未发布生产。 - 2026-07-15 BRD 发布状态只读复核:生产根站、`/h5/`、`/prod-api/auth/tenant/list` 和 `/prod-api/api/aihr/mobile/home/user` 均返回 HTTP `200`,`wygj-aihr.service` 为 `active`;生产 H5 仍加载 `index-CDn6TrX8.js`,本地最新候选人面试权限/未知会话修复提交 `217edb09` 尚未证明已发布。本轮仅执行 HTTP、服务状态和 H5 资源只读检查,未同步静态资源、重启后端、执行迁移或写入生产业务数据。 -# 2026-07-15 BRD G3/G6 训练语音原始地址暴露收紧:ASR/TTS 成功响应此前可直接返回 `sys_oss.url`,训练历史、主管复盘和管理端复盘在缺少 `audioOssId` 时也会回退播放原始 `audioUrl`;现统一只回传受保护 OSS 编号,服务端持久化/响应仅保留 `data:audio/*` 内联降级,客户端也拒绝 HTTP 音频地址直接播放。TTS 仍写入 `sys_oss`,旧移动端通过内联 data URL 保持可播放;已补源码契约测试和两端回放边界,未修改生产资源或生产数据。 +- 2026-07-15 BRD G3/G6 训练语音原始地址暴露收紧:ASR/TTS 成功响应此前可直接返回 `sys_oss.url`,训练历史、主管复盘和管理端复盘在缺少 `audioOssId` 时也会回退播放原始 `audioUrl`;现统一只回传受保护 OSS 编号,服务端持久化/响应仅保留 `data:audio/*` 内联降级,客户端也拒绝 HTTP 音频地址直接播放。TTS 仍写入 `sys_oss`,旧移动端通过内联 data URL 保持可播放;已补源码契约测试和两端回放边界,未修改生产资源或生产数据。 +- 2026-07-15 BRD 4.6/P0 组织同步线上闭环:生产 `/opt/wygj/config/wygj.env` 已配置开放组织同步变量并备份为 `/opt/wygj/config/wygj.env.bak-orgsync-20260715141150`,`wygj-aihr.service` 重启后租户接口和员工首页 API 均返回业务成功;线上执行 `POST /prod-api/api/aihr/org/sync` dry-run 与覆盖同步成功,开放平台返回公司 17、部门 963、员工 3417,生产 `aihr_org_snapshot` 为 3417 行、手机号可映射 3392、手机号不可用 25、疑似乱码 0、在职 2943、离职 474。本轮未替换 jar 或静态资源;25 名员工手机号不可用是上游数据问题,不阻塞组织同步功能发布,但正式试点仍需真实训练、校准和 SOP 评审样本。 + +## 2026-07-16 多租户知识平台第一版实施结论 + +需求与实施依据: + +- 需求方案:`docs/superpowers/specs/2026-07-16-multi-tenant-knowledge-platform-design.md` +- 实施计划:`docs/superpowers/plans/2026-07-16-multi-tenant-knowledge-platform.md` +- 运维与验收:`docs/KNOWLEDGE_PLATFORM_RUNBOOK.md` +- 第一版边界:银城、美途作为独立租户;租户内支持多个知识空间;调用应用与知识空间建立多对多授权;登录用户实际可访问空间取“应用授权空间”和“用户/角色授权空间”的交集;第一版不做跨租户共享、集团账号天然穿透、跨空间去重和图谱化编排。 + +### 完成度分层 + +| 层级 | 结论 | 证据与边界 | +|---|---|---| +| 开发完成 | 是 | 已完成知识空间、主体授权、应用、令牌、统一问答、查询审计、数据工具、文档成员关系、单空间解绑、管理端、员工端调用迁移、初始化与验收脚本;后端全量 AIHR 测试 `166/166`,管理端测试 `7/7`,移动端测试 `57/57`,相关构建与类型检查通过。 | +| 本地联调可用 | 是 | 本地银城/美途双租户真实 HTTP 验证 `18/18`;验证租户隔离、授权交集、未授权 `403`、停用/过期令牌、限流、来源引用、无证据语义、员工与团队数据工具、同一 OSS 多空间成员关系及仅解绑当前空间。数据库复核确认美途应用已停用且令牌清空、银城公共空间成员已解绑、物业空间成员和 OSS 对象仍保留、解绑审计已记录。 | +| 线上生效 | 否 | 本轮未执行生产数据库迁移、应用初始化、后端发布、静态资源同步或生产密钥写入;不能用本地通过替代线上发布证据。 | +| 正式试点验收 | 否 | 尚缺知识空间业务负责人、正式角色矩阵、各空间首批内容清单和标准问题、可信调用端清单、生产密钥托管、日志保留期、容量与告警阈值以及业务签字;因此不能宣称正式试点完成。 | + +### 功能需求逐项审计 + +| # | 需求 | 工程结论 | 证据或剩余条件 | +|---:|---|---|---| +| 1 | 银城、美途空间和应用按租户隔离 | 通过 | 双租户 HTTP 正反例、数据库 tenant 归属和跨租户 `403` 通过。 | +| 2 | 银城三个首批空间、美途一个空间具备稳定编码、负责人、授权和验证题单 | 部分通过 | 四个稳定编码和工程授权已初始化;业务负责人、正式内容清单和每空间标准题单待提交,因此不满足正式试点。 | +| 3 | 应用与空间多对多 | 通过 | `aihr_knowledge_app_space`、管理接口、初始化脚本和合同测试通过。 | +| 4 | 内部有效范围为应用授权与主体授权交集 | 通过 | 授权服务单测和真实未授权请求验证通过,空交集在检索前拒绝。 | +| 5 | 美途名片应用只能访问美途客户咨询空间 | 通过(本地) | 测试令牌只绑定 `mt_customer_service`;访问银城空间返回 `403`,联调结束后应用已停用并清空令牌。 | +| 6 | 显式越权空间返回 `403`,省略空间只查询有效范围 | 通过 | 真实 HTTP 与服务测试均覆盖。 | +| 7 | 回答包含空间和文档引用,无依据返回 `noEvidence=true` | 通过 | 银城、美途正例及无依据用例通过。 | +| 8 | 同文件多空间独立检索,单空间解绑不影响另一空间和 OSS | 通过 | 非生产专用文件完成破坏性解绑验证;公共空间成员移除,物业空间成员和原 OSS 仍各保留 `1` 个引用,管理审计有 `2` 条 `UNBIND/DOCUMENT` 记录。 | +| 9 | 本人/团队数据工具按服务端身份和管理范围执行,外部应用禁用工具 | 通过 | 员工、主管正例及员工越权、外部应用调用反例通过。 | +| 10 | 旧 `/api/knowledge/search` 保持兼容且不再搜索租户全库 | 通过 | 旧入口已收口到统一授权查询服务,客户端合同测试和服务测试通过。 | + +安全矩阵中的错误/停用/过期令牌、限流 `429`、伪造 `tenantId/userId/extPartyId`、员工团队工具越权、外部应用数据工具越权和无有效空间零召回均已由自动化或本地真实 HTTP 覆盖。Qdrant `aihr_knowledge` 当前健康,`points_count=450`;查询过滤源码与服务测试同时要求当前租户和有效 `knowledgeIds`。 + +### 第一批空间初始化建议 + +- 银城:`yc_public_policy`(公共制度与通用政策)、`yc_property_sop`(物业服务 SOP)、`yc_management_ops`(经营分析与管理制度)。前三个空间已完成工程初始化;具体负责人、受众、首批文件和标准问题仍须业务确认。 +- 美途:`mt_customer_service`(客户咨询知识)。已完成独立租户与独立空间的工程初始化;不得绑定银城空间或复用银城调用应用。 +- `yc_finance` 暂不作为第一批工程默认空间,待财务负责人、数据库字段白名单、聚合口径和审计要求明确后再启用,避免知识文档权限被误当成结构化数据权限。 + +### 安全结论 + +- 多个空间的主要价值是内容治理和最小授权,不是仅靠“分库”自动获得安全;所有表、检索、向量 payload、查询日志和 OSS 元数据都以租户为硬边界,空间授权作为租户内第二层边界。 +- 外部应用仅保存令牌哈希,明文只在创建/轮换时返回一次;停用、过期、越权和超限均拒绝请求。 +- 同一文档可在同租户多个空间形成独立成员关系,解析和向量加工复用;解绑只删除当前空间的附件/片段/向量成员,最后一个引用解除后才清理 OSS,第一版不做跨空间内容去重。 +- 结构化数据工具不继承文档空间权限,按工具白名单、调用主体、当前租户和本人/团队范围重新校验;第一版不开放任意 SQL 或任意数据库直连。 + +### 上线前硬门槛 + +1. 业务确认四个首批空间的负责人、受众、内容清单、有效期和每空间至少 10 个标准问题。 +2. 银城、美途分别确认应用清单、部署位置、回调来源、负责人和生产令牌托管方式;生产令牌不得写入仓库、聊天记录或日志。 +3. 在测试环境执行版本化迁移、初始化、双租户验收、令牌轮换/吊销、限流与审计查询,并留存完整结果。 +4. 确认查询日志保留期、问题哈希策略、Qdrant 容量、失败率与延迟告警阈值、备份恢复和回滚窗口。 +5. 生产发布须单独授权,按运维手册执行备份、迁移、发布、初始化、验收和回滚演练;完成线上 HTTP/数据库/Qdrant 证据链后,才能把“线上生效”改为“是”。 diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index a6c1d515..2959505e 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -38,8 +38,13 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_daily_drill_score_mode_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_sop_requester_identity_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_org_hire_date_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_practice_assignment_batch_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql ``` 顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。 @@ -53,9 +58,17 @@ SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name IN ( - 'aihr_practice_scenario', 'aihr_practice_rubric', 'aihr_practice_rubric_dimension', + 'aihr_practice_scenario', 'aihr_practice_rubric', 'aihr_practice_rubric_dimension', 'aihr_learning_question', 'aihr_practice_audio', 'aihr_practice_audio_upload', 'aihr_practice_calibration', 'aihr_knowledge_gap', 'aihr_knowledge_answer_feedback', + 'aihr_knowledge_space_grant', 'aihr_knowledge_app', 'aihr_knowledge_app_space', + 'aihr_knowledge_query_log', 'aihr_knowledge_admin_audit', + 'aihr_practice_question_feedback', + 'aihr_onboard_exam', 'aihr_onboard_exam_target', 'aihr_onboard_exam_question', + 'aihr_onboard_exam_attempt', 'aihr_onboard_exam_answer', + 'aihr_web_search_provider', 'aihr_web_ai_query_audit', + 'aihr_community_question', 'aihr_community_answer', + 'aihr_incentive_rule', 'aihr_points_ledger', 'aihr_candidate_material', 'aihr_interview_result', 'aihr_candidate_employee_link', 'aihr_position_responsibility', 'aihr_sop_applicability', 'aihr_onboard_task', 'aihr_qualification_gate' @@ -68,8 +81,11 @@ WHERE table_schema = DATABASE() AND ( (table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url', 'learning_points', 'supervisor_comment')) OR (table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion', 'dim_response_timeliness', 'response_latency_ms', 'annotations_json', 'review_advice', 'incentive_point', 'satisfaction_score', 'satisfaction_comment')) - OR (table_name = 'aihr_practice_assignment' AND column_name IN ('score', 'score_mode')) + OR (table_name = 'aihr_practice_scenario' AND column_name IN ('content_version', 'content_hash')) + OR (table_name = 'aihr_practice_assignment' AND column_name IN ('score', 'score_mode', 'due_date', 'request_key', 'content_version', 'question_snapshot', 'reference_answer_snapshot', 'content_hash')) OR (table_name = 'aihr_sop_answer_review' AND column_name IN ('prompt_version', 'requester_ext_party_id')) + OR (table_name = 'aihr_knowledge_info' AND column_name IN ('code', 'space_type', 'sensitivity_level', 'status')) + OR (table_name = 'aihr_knowledge_upload_item' AND column_name = 'space_codes_json') OR (table_name = 'aihr_org_snapshot' AND column_name = 'hire_date') OR (table_name = 'aihr_candidate_material' AND column_name IN ('reviewer', 'reviewed_time')) OR (table_name = 'aihr_interview_result' AND column_name IN ('questions_json', 'reviewed_score', 'review_status', 'reviewer', 'review_note', 'reviewed_time')) @@ -84,9 +100,21 @@ SELECT COUNT(*) AS built_in_prompt_templates FROM aihr_prompt_template WHERE tenant_id = '000000' AND code IN ('summary_card', 'sop_answer', 'mentor_rewrite', 'review_annotation', 'prep_card', 'supervision_image_analysis_maintenance'); + +SELECT position, COUNT(*) AS scenario_count, SUM(enabled = 0) AS disabled_pending_review +FROM aihr_practice_scenario +WHERE tenant_id = '000000' +GROUP BY position +ORDER BY position; + +SELECT question_type, COUNT(*) AS question_count, SUM(enabled = 0) AS disabled_pending_review +FROM aihr_learning_question +WHERE tenant_id = '000000' +GROUP BY question_type +ORDER BY question_type; ``` -预期是三张场景/Rubric 表、三张证据表、两张知识治理表、三张候选人/面试表和四张岗位/SOP/任务/资格契约表均存在,会话证据字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。岗位/SOP/任务/资格表为空是允许的;这只证明 schema/seed 迁移完成,不证明正式岗位规则、资格 gating 或试点完成。 +预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、两张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表和四张岗位/SOP/任务/资格契约表均存在;会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表为空是允许的;这只证明 schema/seed 迁移完成,不证明正式岗位规则、资格 gating 或试点完成。 ## 迁移后应用回归 diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index 92421319..e43b07e1 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -20,7 +20,7 @@ | 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 | | 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看案例详情/回放原始音频 | 已完成语音转写、原始音频 OSS 留痕、整理、入库、学习点、主管点评和按项目权限查看持久化转写/摘要详情;预渲染视频样片仍未接入,不把音频回放当作“查看样片”完成证据 | | 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 | -| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 | +| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 点“批量导入”选择少量文件或 ZIP | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态、批次进度和事件列表;页面不出现“选择目录”“服务端导入” | ## AI陪练二期增量脚本 @@ -34,7 +34,7 @@ 试点正式验收前,用 `AIHR_PILOT_TENANT_ID=000000 AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门,租户和日期替换为本批试点的真实值。闸门只统计目标租户、该时间窗内的数据;正式试点人员必须通过唯一手机号映射到在职组织快照,达到 10 次已完成对练的人数需覆盖试点人员的 80% 以上。校准样本、AI/人工分档一致率和 SOP 可用率未达标时同样失败,不用静态演示 marker 或历史 seed 数据代替真实试点数据。 -2026-07-10 本地快照(时间窗 `2026-07-07..2026-07-10`):场景 `12/12`、开发活动身份 `50` 人,但达到 10 次对练者 `0` 人;窗口内能关联正式试点会话的校准样本为 `0/20`,因此分档一致率暂不可验收;已评审 SOP `2/2` 可用,另有 `119` 条待评审。严格预检当前**失败**于正式组织人员门槛:3001 名本地在职人员只有 1 名具备唯一可用手机号,活动数据也只有 1 人能唯一匹配正式组织,且该人员达到 10 次对练的数量为 `0/1`。开发数据和非正式会话校准不能替代正式试点人员;补齐上游手机号并重新同步前,不得宣称一期业务验收完成。 +2026-07-15 线上组织同步快照:开放平台返回 17 个公司、963 个部门、3417 名员工;生产 `aihr_org_snapshot` 已覆盖为 3417 行,其中 3392 人具备可用于手机号登录映射的号码,25 人手机号仍不可用,疑似乱码为 0,在职 2943 人、离职 474 人。组织同步能力和“至少 20 名正式人员可映射”的发布门槛已具备;25 人属于上游数据质量问题,不阻塞功能发布。严格试点验收仍必须用真实批次窗口产生训练、人工校准和 SOP 评审样本;旧窗口 `2026-07-07..2026-07-10` 没有人达到 10 次对练,不能作为一期业务验收完成证据。 ## 录屏兜底 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 84d92a31..47a88620 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -50,7 +50,7 @@ portless Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。组织人员同步可用 `AIHR_ORG_SYNC_BASE_URL` 指向外部开放平台 `/api/open/v1` 前缀,并配置 `AIHR_ORG_SYNC_ACCESS_TOKEN` 或 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`;业务请求会用 client secret 生成 HMAC-SHA256 hex 签名。移动端手机号登录的短信模板 ID、阿里云 AccessKey、Secret 和短信签名都通过环境变量注入;本地放根目录 `.env.local`,`scripts/dev-backend.sh` 会自动加载。若开放平台凭证放在 `backend/.env`,启动脚本也会加载该文件,并把 `client_id`/`client_secret` 映射为组织同步实际读取的 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`。 -组织同步写入前先运行只读预检:`node scripts/verify-demo-questions.mjs --org-dry-run`。dry-run 不检查或变更本地快照表结构,也不写数据库;输出只包含人数、手机号覆盖、脱敏数、疑似乱码数和警告,不输出员工姓名。只有 `phone_linked` 能覆盖至少 20 名正式试点人员且上游修复疑似乱码后,才切换管理端组织同步为写入模式。 +组织同步写入前先运行只读预检:`node scripts/verify-demo-questions.mjs --org-dry-run`。dry-run 不检查或变更本地快照表结构,也不写数据库;输出只包含人数、手机号覆盖、脱敏数、疑似乱码数和警告,不输出员工姓名。2026-07-15 当前开放平台数据已满足写入条件:3417 名员工中 3392 人可手机号映射,疑似乱码为 0;仍有 25 人手机号不可用,确认后可用 `allowPartialReplace=true` 覆盖写入。 正式试点预检必须指定当前批次租户和时间窗,例如:`AIHR_PILOT_TENANT_ID=000000 AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`。脚本只接受安全租户编号和 `YYYY-MM-DD` 日期,只统计目标租户窗口内完成的训练、校准和 SOP 评审;人员先按唯一手机号映射到在职组织快照,完训口径为每人至少 10 次已完成对练。 @@ -79,8 +79,11 @@ AIHR_ORG_SYNC_SIGNING_SECRET= AIHR_AI_RUNTIME_ENABLED=true AIHR_AI_CHAT_ENABLED=true AIHR_AI_SPEECH_ENABLED=true +AIHR_WEB_AI_SECRET_KEY=replace-with-at-least-16-random-characters ``` +`AIHR_WEB_AI_SECRET_KEY` 用于加密数据库中的全网检索访问密钥,必须配置为至少 16 位的独立随机值;缺失或长度不足时不能保存或读取提供方密钥。全网检索提供方只接受公网 HTTPS 地址,且每次修改地址或密钥后都必须重新连接测试,测试成功后才能启用。 + `application-dev.yml` 只保留占位和默认值,不提交真实短信密钥。dev 环境不配阿里云短信也能登录移动端:验证码固定 `123456`。 ## 启动步骤 @@ -121,12 +124,12 @@ AIHR_AI_SPEECH_ENABLED=true - `aihr_practice_mysql8.sql` 已导入;移动端员工训练记录落 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像聚合 - `aihr_interview_result_mysql8.sql` 已纳入 reset 脚本;AI 面试评分完成后结果落 `aihr_interview_result` - `aihr_candidate_material_mysql8.sql` 已纳入;候选人端补充资料文件写 `sys_oss`/MinIO,关系落 `aihr_candidate_material` -- `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员本地 seed(2 个住宅项目 22 人,项目经理/主管/一线三层)支撑演示,外部开放组织系统配置完成后用 `POST /api/aihr/org/sync` 拉取 `company/department/employee` 快照并覆盖本地 `aihr_org_snapshot` +- `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员本地 seed(2 个住宅项目 22 人,项目经理/主管/一线三层)支撑演示,外部开放组织系统配置完成后用 `POST /api/aihr/org/sync` 拉取 `company/department/employee` 快照并覆盖本地 `aihr_org_snapshot`。2026-07-15 生产已完成该配置和覆盖同步,线上快照为 3417 名员工。 - 组织同步生产默认关闭 `aihr.org-sync.store-display-fields`,不把外部姓名/部门写入或返回组织人员展示快照;开发环境显式打开该开关仅用于 Demo。项目范围、岗位和外部主体 ID仍用于权限与身份映射。 - 本地组织 seed 带演示手机号,可用 `13900000103` 验证员工端自动识别物业管家岗位,用 `13900000202` 验证主管端项目范围 - SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时。资料处理中心异步接口另支持 `.zip`(≤500MB),后台安全解压后逐文件入队;最多 1000 个子文件、解压总量 ≤2GB,不支持嵌套 ZIP - 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`) -- 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。 +- 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。这组接口仅供运维/调试,不在资料处理页提供入口。 - 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。 - 后端 `ruoyi-admin` 已完成 Maven 打包,并通过 portless 暴露为 `https://wygj-api.localhost/` - AIHR 真跑测试必须显式关闭父 POM 的默认跳测:`mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am -DskipTests=false test`;普通 `mvn ... test` 只适合编译/打包检查。 @@ -151,6 +154,19 @@ TOKEN=<登录后 access_token> curl -fsS https://wygj-api.localhost/api/knowledge/doc/vector-index-status -H "Authorization: Bearer $TOKEN" ``` +## 线上管理端静态发布 + +线上根站由 Caddy 从服务器 `/opt/wygj/www` 提供;同一目录下的 `/h5` 是移动端静态资源。只改管理端静态资源时无需重启后端,但必须先备份整个目录,并且同步根目录时不能使用 `--delete`,以免删除移动端资源。 + +```bash +npm --prefix frontend run build:prod +ssh YCWY 'ts=$(date +%Y%m%d%H%M%S); mkdir -p /opt/wygj/backups; cp -a /opt/wygj/www /opt/wygj/backups/www-$ts; echo /opt/wygj/backups/www-$ts' +rsync -az frontend/dist/ YCWY:/opt/wygj/www/ +curl -k -s https://peilian.njzhmj.top/ | sed -n '1,20p' +``` + +发布后以管理员真实会话回归受影响页面;如需核对指定资源,可比较本地构建文件和远端对应文件的 SHA-256。该流程不发布后端、不迁移数据库,也不覆盖 `/opt/wygj/www/h5`。 + ## 线上移动端静态发布 线上 `peilian.njzhmj.top` 由 Caddy 服务,`/h5*` 映射到服务器 `/opt/wygj/www/h5`,并 fallback 到 `/h5/index.html`。只改移动端静态资源时无需重启后端。 @@ -201,7 +217,7 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true R - 三角色对练:进入 `/train/practice`,点击“开始对练” → 完成两轮真实或参考回复 → “结束并评分”,应看到“已完成闭环”“导师改写”和新增对练记录。 - 案例沉淀:进入 `/knowledge/cases`,上传真实语音 → “AI 整理” → “送审” → “入库”,应看到“已完成闭环”和新增案例记录;上传必须先完成 ASR 转写。 - SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。 -- 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;可用少量文件验证“批量导入/选择目录/服务端导入”。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。服务端导入读取 `./.data/import` 下的相对目录,启动后台任务并在页面显示进度,运行中任务可点“取消”;目录导入的重试粒度是同目录重新导入,批量上传的重试粒度是单文件。 +- 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;页面只保留“批量导入”和“刷新”,不应出现“选择目录”“服务端导入”或目录导入任务面板。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;支持多文件和 ZIP,暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。 - uni-app 员工端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`,手机号登录(dev 验证码固定 `123456`)。今日页进入问师傅、练习、案例素材和个人页;练习页应能完成开始练习、提交回应、结束评分、每日三题提交,个人页同步训练历史和成长证据包。每日三题正式按组织快照 `hire_date` 判断入职三个月窗口;本地 Demo 若快照尚无该字段,仅由 `dev/local` profile 且 `application-dev.yml` 的 `aihr.practice.allow-legacy-daily-drill-fallback=true` 启用训练次数回退,生产 profile 即使误传环境变量也强制关闭。 - 移动端登录后若组织快照接口返回 `401/403`,只降级为手动岗位确认,不应清除手机号登录态;岗位确认页仍需允许员工选择“生活顾问”后继续进入业务页。 - uni-app 主管端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/supervisor/index/index`,应看到团队概览、团队画像、团队预警、指派专项、待复盘和复盘详情;从团队画像派专项后,最近专项应立即回读新记录。 diff --git a/docs/FIGMA_DESIGN_INVENTORY.md b/docs/FIGMA_DESIGN_INVENTORY.md index c569995c..bf82a1b9 100644 --- a/docs/FIGMA_DESIGN_INVENTORY.md +++ b/docs/FIGMA_DESIGN_INVENTORY.md @@ -148,15 +148,31 @@ ## 全文件设计一致性审计(2026-07-14) +### 正式画板逐页视觉验收标准(强制) + +以下项目必须基于该画板当次生成并人工查看的截图逐项确认;只枚举图层、节点或组件不算视觉验收: + +1. **信息架构一致**:同一页面的状态稿、字号稿和角色变体必须保持相同的已批准模块、字段语义和主顺序;状态变化不能删除功能。 +2. **版面与栅格一致**:检查左右留白、卡片宽度、对齐线、模块间距、卡片内边距和同类组件尺寸,不能只看画板总宽高。 +3. **文字真实可读**:检查字体、字号、行高、换行、截断和长文案;员工端可见正文不得低于 12px,大字/超大字必须逐稿截图,不允许只改变量后推断通过。 +4. **滚动与固定层安全**:检查首屏、内容末尾、空状态和长内容;固定底栏、吸顶区和安全区不得遮住内容,末尾必须有可滚出的底部占位。 +5. **交互状态唯一**:Tab、筛选、按钮、单选和步骤状态的图标、文字、背景与描边必须表达同一个 active/disabled/loading/error 状态。 +6. **内容与组件可信**:示例数据应能区分列表行和指标含义;重复占位文案、错位头像、未命名组件和失真的组件覆盖都不能按高保真通过。 +7. **可访问性风险可见检查**:检查对比度风险、最小触控区、焦点可见性设计、缩放回流和状态提示;读屏顺序、键盘操作和动态播报仍须在真实实现中验证。 +8. **证据分开记录**:结构检查、截图视觉检查、功能实现、浏览器验证、发布和生产验证分别记录,任一项缺失不得用一个 `[x]` 代替。 + - [x] 建立 Figma 首屏“00|设计索引与审计”(Figma `101:3`) -- [x] 79 个页面、71 张正式画板均有内容,无重复页面名和空业务页 -- [x] 默认图层名清零,文字溢出清零,画板内容越界清零 +- [x] 当前计数已重新发布:81 个顶层页面、78 张顶层 `Screen/` 正式画板;“我”页删除 1 张冗余冲突稿后,78 张画板均已重新导出当次截图并逐张人工检查 +- [x] 默认图层名和根级越界已清零:当前 78 张画板中名为 `Frame` 的图层为 0、不可滚动的根级内容越界为 0;`21:3` 已增加 108px 底部安全占位,`79:674`、`79:706`、`115:210` 已补足画板高度并重新截图复核 - [x] 字体统一为 `LXGW WenKai TC` Regular/Bold - [x] 管理端画板统一为 1440px 宽、最低 960px 高 - [x] 896 个精确匹配颜色补充变量绑定,整体填充变量绑定率约 50% - [x] 品牌主红统一为 `#E60012`,员工端与管理端变量已对齐 -- [x] 正式画板和设计规范中的可见文字最低 12px,字号调整后无裁切和越界 +- [x] 当前 78 张画板可见文字最低字号复扫无小于 12px 的文本;`178:960`、`178:2` 的 6 处 11px 指标文字及排行榜头像缩放产生的小字号均已修正 +- [x] `页面|我`(Figma `4:16`)已修为标准/大字/超大字三张同构画板:节点 `7:2`、`253:131`、`253:188` 使用相同模块、字段和顺序,统一 20px 左右留白、360px 卡片和唯一“我”选中态;三稿均启用纵向滚动、固定底栏并保留 108px 内容末尾安全占位,截图复核未见首屏重叠或文字越界 +- [x] 长文案与数据组件已做截图驱动修复:个人 AI 助理、晋升、开放问题榜、积分荣誉等页面的横向裁切已改为真实换行;排行榜头像不再压住姓名,6 行姓名已改为可区分示例;荣誉徽章不再重复“徽章名称”占位文案;AI 教练周节奏行高由 100px 收紧为 56px,去除异常大段留白 +- [x] 管理端 `90:2`、`91:2`、`93:940`、`95:858` 的指标、状态和表格元数据已消除字符级换行,并重新生成截图检查 - [x] 员工端「页面|今日」已完成首页高保真复核:按「参考稿|今日」收口为紧凑纵向结构,保留品牌头部、师傅 Hero、问答卡、今日安排、我的地图和底部 Tab - [x] 问答卡横向内容垂直居中,左右图片容器统一为 `64×64`,右侧聊天气泡保持居中且文案不溢出 - [x] 师傅 Hero 的提示气泡复用组件库 `Communication/Chat Bubble`;品牌标记复用现有品牌资源,不为页面重复绘制图标 -- [ ] 代码实现阶段验证真实对比度、键盘焦点、状态播报和读屏顺序 +- [ ] 代码实现阶段仍须对照同尺寸 Figma 截图验证真实对比度、键盘焦点、状态播报、读屏顺序、滚动末尾、键盘弹起和长数据回流;Figma 截图通过不等于实现或生产通过 diff --git a/docs/FIGMA_IMPLEMENTATION_MATRIX.md b/docs/FIGMA_IMPLEMENTATION_MATRIX.md index e86c9b33..a17e276a 100644 --- a/docs/FIGMA_IMPLEMENTATION_MATRIX.md +++ b/docs/FIGMA_IMPLEMENTATION_MATRIX.md @@ -1,13 +1,12 @@ # Figma 页面实现矩阵 -更新时间:2026-07-13 +更新时间:2026-07-17 ## 统计口径 -- Figma 当前索引:78 个页面、70 张正式画板。 -- `docs/FIGMA_DESIGN_INVENTORY.md` 的历史清单记录为 79 个页面、71 张正式画板,属于设计索引更新前的数字。 -- Figma 文件 API 当前实际暴露 1 个顶层页面 `101:2`(设计索引与审计);“78 个页面”是索引中的审计统计值,不是 78 个可单独定位的 Figma 顶层页面。 -- 因此不能凭空补出 8 个页面名称或 node id;本表按可定位、可验收的 70 张正式业务画板建立。 +- Figma 索引 `101:3` 仍声明 78 个页面、70 张正式画板;该数字已经落后于当前文件。 +- 2026-07-17 通过 Figma 插件逐页切换并重新导出当次截图,实际确认 **81 个顶层页面、78 张顶层 `Screen/` 正式画板**;“我”页修复时已删除 1 张冗余冲突稿。78 张截图均已人工检查,不再沿用此前 63 页/59 张的限流中间结果。 +- 索引没有说明如何从 78 张 `Screen/` 画板排除为 70 张“正式画板”,因此本表继续保留产品页面映射,但不把 70 当作可复算的当前总数;完整设计质量结论见 `FIGMA需求覆盖与版本偏差审计-20260717.md`。 - `[x]` 只表示 Figma 画板已完成,不表示代码或线上已完成。 - `已实现`:仓库存在对应页面,并有真实业务接口或明确的降级链。 - `部分实现`:有入口或局部状态,但没有完成 Figma 对应的完整业务闭环。 @@ -20,7 +19,7 @@ 这两份文档不是同一张验收表: - 本矩阵回答“Figma 设计的页面在代码/线上完成到什么程度”,覆盖 MVP、二期、阶段二/三和已取消设计。 -- 二期计划回答“生活顾问 AI 陪练阶段一的工程闭环和 M5 试点条件是否满足”,不要求实现全部 70 张 Figma 正式画板。 +- 二期计划回答“生活顾问 AI 陪练阶段一的工程闭环和 M5 试点条件是否满足”,不要求实现索引原口径下的全部 70 张 Figma 正式画板。 - 因此,矩阵中的“未实现”不等于二期延期;个人 AI 助理、开放问题榜、积分/荣誉、完整偏好体系等属于计划明确暂缓的阶段二/三范围。 - 矩阵中的“已实现”也不等于 M5 业务验收通过;评分校准、SOP 评审、正式组织身份和真实试点数据仍以严格预检为准。 @@ -40,20 +39,20 @@ | 优先级 | 项目 | 需要什么才能继续 | |---|---|---| -| P0 | 线上包与角色路由 | 生产服务、根站、H5 和公开业务接口正常,但线上仍加载上一发布批次资源;当前 `HEAD=0bfaee7d` 的管理端/H5/后端 hash 尚未与线上对齐,本轮未执行发布。认证态员工/主管/管理端回归仍需正式 OTP 或测试账号,主管正式角色仍需正式组织数据验证。 | +| P0 | 线上包与角色路由 | 生产服务、根站、H5 和公开业务接口正常;2026-07-15 已单独发布管理端资料处理分页并以真实管理员会话回归。该静态发布不代表当前分支 H5 或后端已对齐线上;认证态员工/主管回归仍需正式 OTP 或测试账号,主管正式角色仍需正式组织数据验证。 | | P1 | 案例学习/视频详情 | 原始音频 `mediaUrl/mediaOssId` 已形成真实留痕和回放;学习点已从真实摘要提取并持久化,主管点评已有受保护接口和两端展示;仍需业务提供一期预渲染视频样片,并确定相关训练场景字段后再完成视频详情页面。 | | P1 | 正式组织数据 | 用正式员工、项目和试点时间窗口验证团队量化、成长证据和 M5 导出;不使用本地 smoke 样本替代。 | | P2 | 三期页面 | 个人 AI 助理、开放问题榜、积分/荣誉、完整七维偏好、独立 AI 教练编排按阶段二/三单独立项。 | -## 70 张正式业务画板矩阵 +## 业务画板实现矩阵(原索引 70 张口径) | # | 页面/状态 | Figma 位置 | 代码路径 | 当前实现 | 是否修正 | |---:|---|---|---|---|---| -| 1 | 员工端:今日 | — | `mobile-uni/src/pages/user/today/index.vue` | [x] 已实现 | 本地源码已对齐;历史线上包已用员工账号完成浏览器回归,当前分支最新修复尚未重新发布 | -| 2 | 员工端:练入口 | — | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证场景首屏、最近练过、每日三题和独立练习入口;蓝白视觉已复核 | -| 3 | 员工端:问 | — | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已验证搜索、引用、反馈状态和总结卡 | -| 4 | 员工端:我 | — | `mobile-uni/src/pages/user/profile/index.vue` | [x] 已实现 | 已验证登录、岗位、成长摘要、训练历史和详情入口 | +| 1 | 员工端:今日 | `23:3` | `mobile-uni/src/pages/user/today/index.vue` | [x] 已实现 | 本地源码已对齐;历史线上包已用员工账号完成浏览器回归,当前分支最新修复尚未重新发布 | +| 2 | 员工端:练入口 | `22:3` | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证场景首屏、最近练过、每日三题和独立练习入口;蓝白视觉已复核 | +| 3 | 员工端:问 | `21:3` | `mobile-uni/src/pages/user/sop/index.vue` | [x] 本地已实现并浏览器验证;Figma 已修 | 已实际验证文字查询、引用、反馈、语音输入/选音频入口、回答和总结卡播报、无模型原文总结卡、PNG 下载成功提示;Figma 已增加 108px 末尾安全占位,真实手机键盘、麦克风与文件权限仍待发布前复验 | +| 4 | 员工端:我 | `4:16`(标准 `7:2` / 大字 `253:131` / 超大字 `253:188`) | `mobile-uni/src/pages/user/profile/index.vue` | [x] 本地已实现并逐档视觉验证;Figma 视觉已修 | 三档 Figma 与代码均保持同一功能结构;全局标准/大字/超大字已持久化,400×900 的三档个人页及关键页超大字截图已复核,无横向溢出、底栏遮挡或卡片重叠;真实设备触控和读屏仍待验收 | | 5 | 训练前预习卡 | `53:2` | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证预习卡、开始练习与真实对话状态;视觉已复核 | | 6 | 正式语音对练 | `54:2` | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证文字提交、情绪/信任、求助和录音入口;失败保留文本降级 | | 7 | 训练完成结果 | `55:2` | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证两轮真实提交、评分结果和主管待复盘更新;视觉已复核 | @@ -62,7 +61,7 @@ | 10 | 主管待复盘列表 | `58:2` | `mobile-uni/src/pages/supervisor/reviews/index.vue` | [x] 已实现 | 已验证真实待复盘列表、低分优先和详情跳转 | | 11 | 主管复盘详情 | `59:2` | `mobile-uni/src/pages/supervisor/review/index.vue` | [x] 已实现 | 已验证评分明细、对话、标注、建议、贡献分和标记已复盘 | | 12 | 员工能力详情 | `61:2` | `mobile-uni/src/pages/supervisor/team/index.vue` | [x] 已实现 | 已验证点击成员展示最新评分、训练次数、待复盘数量、真实训练趋势和成长证据;400px 主管画布、蓝白卡片、层级和间距已复核;未虚构 Figma 未提供的数据 | -| 13 | 训练任务派发 | `61:530` | `mobile-uni/src/pages/supervisor/assign/index.vue` | [x] 已实现 | 已验证按项目范围选择员工、场景、原因并真实创建专项任务 | +| 13 | 训练任务派发 | `61:530` | `mobile-uni/src/pages/supervisor/assign/index.vue` | [x] 本地已实现并真实提交验证 | 已按项目团队姓名多选、最多 100 人、当天/明天/3天/7天、逐人结果和幂等批量接口对齐;浏览器向 2 名成员提交返回 2/2 成功,仍未发布生产 | | 14 | 派发进度与预警 | `62:2` | `mobile-uni/src/pages/supervisor/assign/index.vue` | [x] 已实现 | 已验证真实派发进度计数(待训练/已完成/低分再练)和低分训练预警;400px 主管画布、表单/列表/进度卡层级已复核;截止时间与下滑趋势暂无后端字段,未伪造 | | 15 | 候选人面试首页 | `67:2` | `mobile-uni/src/pages/candidate/index/index.vue` | [x] 已实现 | 已修正 | | 16 | 候选人面试答题 | `68:2` | `mobile-uni/src/pages/candidate/interview/index.vue` | [x] 已实现 | 已修正 | @@ -77,11 +76,11 @@ | 25 | 提示词模板管理 | `73:2` | `frontend/src/views/system/prompts.vue` | [x] 已实现 | 已按 Figma 独立工作台实现模板列表、名称/代码/输出文体/系统提示词/模板/变量编辑、保存草稿、保存并启用;真实浏览器已验证编辑保存、新增启用、刷新持久化,临时样本已清理 | | 26 | 人工评分校准/指标导出 | `74:2` | `frontend/src/views/train/reviews.vue`、`frontend/src/views/train/practice.vue` | [x] 已实现 | 已按 Figma 补齐日期窗口、岗位/组织范围、校准样本指标卡和独立 CSV 导出;真实浏览器已验证页面加载、导出下载和复盘校准表单,正式试点数据仍需真实样本 | | 27 | SOP 回答结果 | `79:11` | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已按 Figma 补齐「师傅回答/我的问题/答案卡/引用依据/反馈」层级,保留真实检索、引用和反馈接口;浏览器已验证登录、真实答案展示和“有用”反馈状态 | -| 28 | SOP 好评/差评反馈 | `79:674` | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已按 Figma 语义对齐为「有帮助/没解决」双按钮,保留真实反馈接口、提交后状态提示与按钮锁定;浏览器已验证真实搜索、点击反馈、结果提示和两个按钮 disabled。Figma 中的「收藏」当前无后端持久化接口,未添加假按钮 | +| 28 | SOP 好评/差评反馈 | `79:674` | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现;Figma 已修 | 已按 Figma 语义对齐为「有帮助/没解决」双按钮,保留真实反馈接口、提交后状态提示与按钮锁定;浏览器已验证真实搜索、点击反馈、结果提示和两个按钮 disabled。Figma 已补足画板高度并重新截图复核;其中「收藏」当前仍无后端持久化接口,未添加假按钮 | | 29 | SOP 后台待复核 | `80:655` | `frontend/src/views/knowledge/sop.vue` | [x] 已实现 | 已补真实答案反馈待复核队列、待复核/已复核筛选、状态展示与「标记已复核」动作;浏览器已验证刷新加载临时待复核样本、点击复核后从待办移除并清理测试数据 | | 30 | SOP 知识库管理 | `81:2` | `frontend/src/views/knowledge/sop.vue` | [x] 已实现 | 已修正 | -| 31 | 异步资料处理队列 | `81:750` | `frontend/src/views/knowledge/processing.vue` | [x] 已实现 | 已修正;ZIP 支持已随当前批次发布,仍需真实资料和权限账号回归 | -| 32 | 案例语音上传 | `79:706` | `mobile-uni/src/pages/user/cases/index.vue`、`frontend/src/views/knowledge/cases.vue` | [x] 已实现 | 已修正 | +| 31 | 异步资料处理队列 | `81:750` | `frontend/src/views/knowledge/processing.vue` | [x] 已实现 | ZIP 异步入队已在生产复核;解析任务列表已于 2026-07-15 发布前端分页(10/20/50 条),真实管理员会话已验证翻页与筛选复位。 | +| 32 | 案例语音上传 | `79:706` | `mobile-uni/src/pages/user/cases/index.vue`、`frontend/src/views/knowledge/cases.vue` | [x] 已实现;Figma 已修 | 功能已实现;Figma 已补足上传表单所需高度并重新截图复核,真实实现仍需验证录音、文件选择和长文件名状态 | | 33 | 案例 AI 整理结果 | `79:734` | `frontend/src/views/knowledge/cases.vue` | [x] 已实现 | 已修正 | | 34 | 案例审核/筛选/发布 | `82:2` | `frontend/src/views/knowledge/cases.vue` | [x] 已实现 | 已修正 | | 35 | 案例学习/视频详情 | `80:2` | `mobile-uni/src/pages/user/learning/index.vue`、`cases/index.vue` | 部分实现 | 已补 `caseId`、按项目权限校验的案例详情 API、原始音频 OSS 留痕/回放、员工端转写/摘要详情、学习点和主管点评查看;仍缺预渲染视频样片与相关训练场景契约,暂不把音频回放标成完整视频详情 | @@ -98,10 +97,10 @@ | 46 | 员工工作量化看板 | `89:9` | `frontend/src/views/competency/growth.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有训练时长、贡献度、测评分、等级、证据包、预警和派发;完整三期量化指标暂不补 | | 47 | 主管团队量化看板 | `89:776` | `mobile-uni/src/pages/supervisor/team/index.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有团队成员、风险、训练记录、成长证据和按项目范围聚合的团队错题本;完整团队经营指标仍需正式组织数据 | | 48 | HR/运营绩效看板 | `90:2` | — | 未实现 | 未修正,三期 | -| 49 | 工作积分排行榜 | `90:91` | — | 未实现 | 未修正,三期 | +| 49 | 工作积分排行榜 | `90:91` | — | 未实现;Figma 已修 | 三期;排行榜行组件已按真实尺寸缩放头像,6 行改为可区分姓名并重新截图复核;代码仍未实现,不进入当前阶段主线 | | 50 | 等级/晋升条件/进度 | `90:867` | `frontend/src/views/competency/growth.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有初/中/高级成长路径和评级证据包;正式晋升申请/审核属于后续范围 | | 51 | 破格晋升申请/审核 | `91:2` | — | 未实现 | 未修正,三期 | -| 52 | 荣誉墙/成长档案导出 | `91:765` | — | 未实现 | 未修正,三期 | +| 52 | 荣誉墙/成长档案导出 | `91:765` | — | 未实现;Figma 已修 | 徽章示例已去除重复占位文案,等级文案字符级换行已修;仍属三期,代码未实现 | | 53 | 七维回复风格调节 | `93:6` | `mobile-uni/src/pages/user/practice/index.vue` | 按二期计划已实现首期形态;按 Figma 部分对齐 | 已有训练内“再严肃/更温柔/更细致/更专业”重生成;偏好持久化和七维设置属于后续范围 | | 54 | 优化前后结果对比 | `93:824` | — | 未实现 | 未修正,三期 | | 55 | 个人 AI 偏好设置 | `93:857` | — | 未实现 | 未修正,三期 | @@ -112,14 +111,14 @@ | 60 | 问题审核/激励规则 | `95:858` | — | 未实现 | 未修正,阶段二 | | 61 | AI 教练训练计划 | `97:5` | `mobile-uni/src/pages/supervisor/assign/index.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已用主管派发、低分再练、每日三题和成长证据承载;独立 AI 教练编排属于后续范围 | | 62 | 自动场景任务/自动复盘 | `97:778` | `mobile-uni/src/pages/user/practice/index.vue`、`supervisor/review/index.vue` | 按二期计划已实现基础闭环;按 Figma 部分对齐 | 已有训练、评分、标注、复盘和再练;自动编排/自动复盘报告不属于当前二期必做 | -| 63 | 积分/徽章/荣誉中心 | `97:823` | — | 未实现 | 未修正,三期 | -| 64 | 企业发布申请 | — | — | 未实现 | 未修正,阶段二 | -| 65 | 个人 AI 助理发布记录 | — | — | 未实现 | 未修正,阶段二 | -| 66 | 个人 AI 助理报告大纲 | — | — | 未实现 | 未修正,阶段二 | -| 67 | 个人 AI 助理 PPT 生成 | — | — | 未实现 | 未修正,阶段二 | -| 68 | 企业知识发布审核列表 | — | — | 未实现 | 未修正,阶段二 | -| 69 | 企业知识发布审核详情 | — | — | 未实现 | 未修正,阶段二 | -| 70 | 个人 AI 助理运营/紧急审计 | — | — | 未实现 | 未修正,阶段二 | +| 63 | 积分/徽章/荣誉中心 | `97:823` | — | 未实现;Figma 已修 | 徽章示例、积分标签和规则入口的字符级换行已修并重新截图复核;仍属三期,代码未实现 | +| 64 | 企业发布申请 | `42:2` | — | 未实现 | 未修正,阶段二 | +| 65 | 个人 AI 助理发布记录 | `44:2` | — | 未实现 | 未修正,阶段二 | +| 66 | 个人 AI 助理报告大纲 | `44:229` | — | 未实现 | 未修正,阶段二 | +| 67 | 个人 AI 助理 PPT 生成 | `45:2` | — | 未实现 | 未修正,阶段二 | +| 68 | 企业知识发布审核列表 | `46:2` | — | 未实现 | 未修正,阶段二 | +| 69 | 企业知识发布审核详情 | `47:2` | — | 未实现 | 未修正,阶段二 | +| 70 | 个人 AI 助理运营/紧急审计 | `48:2` | — | 未实现 | 未修正,阶段二 | ## 已取消而不应补做 diff --git a/docs/FIGMA需求覆盖与版本偏差审计-20260717.md b/docs/FIGMA需求覆盖与版本偏差审计-20260717.md new file mode 100644 index 00000000..c55fa61e --- /dev/null +++ b/docs/FIGMA需求覆盖与版本偏差审计-20260717.md @@ -0,0 +1,259 @@ +# Figma 需求覆盖与版本偏差审计 + +> 日期:2026-07-17 +> Figma 文件:[银城员工端与个人 AI 助理设计](https://www.figma.com/design/30hFiKtieGYraOZvJPJcPd) +> 对照范围:历史可运行移动端 `mobile/`、已确认业务文档、Figma 实际画板、当前权威工程 `mobile-uni/`。 +> 验证边界:本轮通过 Figma 插件只读检查实际节点,没有修改设计稿;代码结论来自当前工作树,不代表已经发布到线上。 + +## 1. 审计结论 + +客户对问题原因的判断**基本成立,但根因不只是“Figma 少画了功能”**。真正的问题是此前没有规定功能事实源、视觉事实源和实现事实源的优先级,导致“对标 Figma”被错误理解成“Figma 画了什么就只做什么”。 + +Figma 当前存在五类风险: + +1. **需求遗漏导致版本回退**:历史可运行版已有的问师傅语音输入、回答播报、总结卡播报,没有在对应 Figma 闭环中完整表达。若只照画板开发,旧能力会自然消失。 +2. **视觉完成被误当成功能完成**:Figma 索引写着“设计冻结完成”,但该冻结只覆盖页面、图层、字体、越界和颜色一致性,不代表历史需求全部进入画板,也不代表接口和业务闭环完成。 +3. **阶段混放导致范围误判**:阶段一陪练、阶段二个人 AI 助理/开放问题榜、三期量化和游戏化共存于同一文件和同一索引,缺少醒目的阶段、批准状态和本期状态标记。Codex 既可能漏做旧功能,也可能把后续页面提前拉入当前主线。 +4. **同页变体已经发生结构漂移**:同一个页面的“可编辑/标准/大字/超大字”并非相同内容的字号变化,而是模块、数据、顺序和导航状态不同,无法判断哪张才是开发基线。 +5. **设计审计结论自身已经失真**:索引页数和画板数无法复算,默认图层名、小字号、越界与遮挡并未清零,连“页面对照审计”画板本身也发生裁切,因此“设计冻结完成”不能继续作为研发放行结论。 + +所以,今后不能再把 Figma 单独作为功能需求真值。正确口径是:**业务文档决定做什么,Figma 决定怎么呈现,代码和运行证据决定实际做到了什么。** + +## 2. Figma 实际证据 + +本轮直接读取了以下正式节点: + +| Figma 节点 | 实际内容 | 审计判断 | +|---|---|---| +| `4:16` 页面|我 | 包含 `7:2` 可编辑版和标准/大字/超大字三张字号版;可编辑版与字号版信息架构不同,超大字版与底栏重叠 | 字号需求已经进入 Figma,但四张画板不能视为同一页面的有效变体;详见第 9 节 | +| `101:3` 设计索引与一致性审计 | 标注“78 原页面、70 正式画板、0 空业务页、设计冻结完成”;冻结说明集中在页面、图层、字体、溢出和颜色 | 这是**视觉与文件卫生审计**,不是需求覆盖审计;没有需求 ID、来源版本、阶段和实现状态 | +| `23:3` 员工端今日 | 明确同时展示“问老师傅·语音提问,快速解答”和“打字问”;还展示学习中心、工作上报 | Figma 明确承诺了语音入口;同时把阶段二/三入口放进阶段一首页,容易造成范围混淆 | +| `54:2` 正式语音对练 | 有“按住说话”和切换文字输入,包含情绪/信任、SOP 求助、红线和风格重生成 | 训练语音设计较完整,但不能代替问师傅、题目、点评等其他页面的语音覆盖 | +| `61:530` 主管训练任务派发 | 明确显示 3 名员工、多选删除、训练类型、专项包、截止时间、任务目标和“确认派发给 3 人” | 多人派发和截止时间已经是明确 Figma 要求;当前实现只有单人、场景和原因 | +| `79:11` SOP 回答结果 | 有问题、结论、三步操作、可用话术、引用、视频、有帮助/没解决/收藏 | 对应画板没有回答播报、总结卡播报和下载图片控件;只照此画板会遗漏历史语音优势和新增下载要求 | +| `95:6` 开放问题榜 | 有“今天遇到什么难题了”“有奖征问题”、热门/待回答/有悬赏/我参与的和奖励规则 | 设计本身完整,但业务总纲明确属于阶段二;不能因为 Figma 已画就算当前阶段缺陷 | + +还有两处文件内部口径冲突: + +- `FIGMA_DESIGN_INVENTORY.md` 顶部仍把员工端“问”和“我”标记为 `[-]`,说明业务状态不完整;同一文件后部却写“设计冻结完成”。 +- `FIGMA_IMPLEMENTATION_MATRIX.md` 曾把训练任务派发行标为“已实现”,当时 `mobile-uni/src/pages/supervisor/assign/index.vue` 只有单员工选择、没有截止时间。2026-07-17 晚间已补多人、截止快捷项和批量接口,矩阵已改为记录本地验证事实,仍不等同于生产发布。 + +## 3. 四方功能对照 + +| 功能 | 历史可运行版/文档要求 | Figma 表达 | 当前实现 | 客观结论与动作 | +|---|---|---|---|---| +| 问师傅语音输入 | 旧 `mobile/src/App.vue` 有语音录制/选择音频并转写;产品方案也明确“语音提问” | 首页 `23:3` 明确写“语音提问”,但未形成完整提问、转写、失败和重试状态画板 | 本地已恢复录音、转写、选择音频兜底,继续复用 `speech.ts` 与 `/api/ai/asr` | **确认回退已在代码恢复,未发布**:Figma 仍需补录音、识别、失败和文件兜底状态 | +| SOP 回答播报 | 旧版有“听一遍” | `79:11` 无播报控件 | 本地已补回答播报,未证明线上已发布 | **Figma 遗漏、代码本地恢复**:先保留功能,再补设计,不得为了贴图删掉 | +| 总结卡播报 | 旧版有总结卡 TTS;总纲写“语音播报(能力已有)” | 对应清单/画板未体现完整播报状态 | 本地已补总结卡播报 | **Figma 遗漏、代码本地恢复**:补播放/停止/生成失败状态 | +| 总结卡下载图片 | 旧版无充分证据;本轮客户新增 | Figma 未体现下载控件 | 本地已补 PNG 下载,无聊天模型时按真实 SOP 原文降级生成;浏览器已触发下载成功提示 | **新增需求已本地实现,未发布**:仍需补 iOS/Android H5 下载状态设计与真机验收 | +| 全局字号调节 | 未找到旧版开关;客户为老年员工新增明确要求 | `4:16` 已修为标准/大字/超大字三张同构画板 | 本地已实现全局三档、持久化与响应式自增高;400×900 已逐档截图复核 | **新增无障碍需求已本地实现并视觉验证,未发布**:继续做真机触控、键盘和读屏验收 | +| 主管多人派发 | 未证明旧版可运行;客户本轮再次明确 | `61:530` 明确选择 3 人并批量确认 | 本地已支持姓名多选、最多 100 人、逐人结果、幂等请求与失败隔离;真实提交 2/2 成功 | **Figma 需求已本地实现,未发布**:生产继续验证大团队权限与超时重试 | +| 截止时间/快捷日期 | 历史计划要求训练任务有截止时间;客户细化为今天/明天/3天/7天 | `61:530` 有精确截止时间,没有快捷日期控件 | 本地已增加 `dueDate` 及当天/明天/3天/7天快捷项,并在逐人结果显示到期日 | **部分被 Figma 覆盖的需求已本地实现,未发布**:补设计状态并复验时区边界 | +| 每日题每日变化/专项不重复 | 文档要求覆盖岗位常见场景和动态训练 | Figma 主要画页面结果,没有内容版本、日期派发、去重和降级规则 | 本地每日题已扩为 7 组 21 题,按日期、岗位和员工身份轮换,同日稳定、相邻日不重复 | **非视觉型缺口已完成第一阶段修复,未发布**:继续扩正式岗位题库和内容版本审计 | +| 生成题目、AI 回复、点评播报 | 语音优先原则支持;不能证明旧版全部覆盖 | 对应画板没有形成全链路播报覆盖表 | 本地已覆盖每日题、AI 回复、点评、参考答案、训练结果与候选面试;无 TTS 模型时 H5 已验证设备语音降级 | **需求覆盖已在代码补齐,未发布**:Figma 仍需形成内容类型/播放状态矩阵 | +| 主管切换学习端 | 客户明确主管也要学习;历史目标支持角色兼任 | Figma 分别画员工端和主管端,没有双模式切换入口及权限说明 | 本地已实现“工作台/学习端”双向切换,显示模式变化但组织角色保持主管;已用主管登录态验证 | **代码已本地实现,Figma 仍未覆盖,未发布**:补入口、返回路径与权限说明画板 | +| 岗位差异化内容 | 总纲要求岗位→SOP→场景→学习内容映射 | Figma 有多个岗位/角色页面,但没有展示内容分发规则和岗位覆盖表 | 当前主要仍围绕生活顾问 | **功能规则缺口,P1**:用岗位内容矩阵验收,不用“页面不同”代替“内容不同” | +| 问 AI(全网) | 旧版无;会议基线是企业内部知识 | 本轮检查的设计索引和相关问答画板未见独立外部 AI 入口、来源和免责声明 | 当前无全网检索 | **新增范围**:单独立项,与问师傅分入口、分数据外发、分引用和免责声明 | +| 图片/视频提问 | 历史文档列为新增/后置情境能力 | `79:11` 有回答侧视频内容,但没有提问附件上传流程 | 本地已补本次查询的图片/视频附件分析 | **设计与实现顺序倒置**:先补权限、PII 外发、大小/失败状态设计,再决定是否发布 | +| 开放问题榜/有奖问答 | 总纲明确阶段二,阶段一只积累 `knowledge_gap` | `95:6` 等画板已完整设计 | 当前没有业务闭环 | **不是当前版本误删**:保留为阶段二,不得因“Figma 已完成”提前宣称上线或拉入 P0 | +| 个人 AI 助理/个人资料库 | 2026-07-11 新增,明确阶段二 | Figma 已有完整画板组 | 当前未实现 | **阶段混放风险**:从当前阶段一研发指令中排除,另建阶段二验收表 | + +## 4. 根因判断 + +问题链路更准确地描述为: + +```text +历史需求和旧版能力没有先形成可追踪清单 +→ Figma 主要完成视觉与页面状态,遗漏非视觉规则和部分旧交互 +→ Figma 又把多个阶段放在同一索引,且标记“设计冻结完成” +→ 给 Codex 的指令只写“对标 Figma” +→ Codex 把 Figma 当成功能全集 +→ 已画但后置的能力被误判为当前范围,没画的旧能力被误判为可以删除 +``` + +因此,本次问题属于**需求追踪和指令约束失败**,不是单一前端页面实现失误。 + +## 5. 今后的事实源优先级 + +| 决策问题 | 第一事实源 | 第二事实源 | 禁止做法 | +|---|---|---|---| +| 本期做什么、哪些不能做 | 已确认 BRD、分阶段总纲、版本功能对比清单 | 客户确认的变更单 | 看到 Figma 画板就自动纳入当前阶段 | +| 功能怎么交互、页面怎么呈现 | 已标注需求 ID 和阶段的 Figma 正式画板 | 现有设计变量与组件 | 用 Figma 缺失作为删除旧功能的依据 | +| 当前代码有什么 | `mobile-uni/`、后端接口和测试 | 历史 `mobile/` 仅作版本回溯 | 用旧版截图代替当前实现判断 | +| 线上是否生效 | 生产包、真实账号、真实接口和浏览器回归 | 发布记录与构建 hash | 把“本地已写”表述成“客户已可用” | + +发生冲突时: + +- 功能范围:已确认业务文档和变更单高于 Figma。 +- 视觉交互:本期正式 Figma 高于开发者自由发挥,但不能覆盖已确认功能。 +- 线上状态:真实运行证据高于源码、文档和设计稿。 +- 无法判断时:标记冲突并请求产品确认,不允许自行删功能或跨阶段扩张。 + +## 6. Figma 必须补的需求追踪层 + +不要求重画全部页面,先在索引和关键画板增加以下字段: + +| 字段 | 示例 | 用途 | +|---|---|---| +| 需求 ID | `ASK-VOICE-001` | 与版本功能清单和验收用例一一对应 | +| 来源 | 旧版可运行 / BRD §4.3 / 2026-07-17 客户新增 | 防止把客户记忆、规划和已交付混为一谈 | +| 阶段 | 阶段一 P0 / 阶段二 / 三期 | 防止 Codex 把后续画板拉入当前主线 | +| 状态 | 已批准 / 待确认 / 已取消 | 区分正式需求和概念设计 | +| 功能覆盖 | 完整 / 部分 / 未覆盖 | “画板完成”不能代替“需求完整” | +| 实现状态 | 未开发 / 本地已实现 / 已发布 / 生产已验证 | 避免设计状态与工程状态串线 | +| 依赖 | ASR、TTS、组织身份、正式内容 | 让页面背后的接口和业务数据可验收 | + +需要优先补画或补状态的页面: + +1. 问师傅语音录制、识别中、失败、选择音频兜底。 +2. SOP 回答和总结卡的播放、停止、失败、下载状态。 +3. 主管多人选择、截止快捷项、逐人派发结果。 +4. 修正已有标准/大字/超大字画板:保持模块、数据语义、顺序和 Tab 状态一致,只允许字号、行高和容器高度变化。 +5. 主管“工作台/学习端”双模式。 +6. “问师傅(企业知识)”与“问 AI(外部信息)”的来源、权限和风险区分。 + +## 7. 给 Codex 的新指令模板 + +后续每轮 UI 开发至少带上以下约束: + +```text +功能事实源: +视觉事实源: +本次阶段:<阶段一/P0 等> +本次必须保留的历史能力:<语音输入、播报、降级链等> +本次明确不做:<阶段二/三页面> +冲突规则:Figma 未画不等于可以删除;Figma 已画不等于本期必须开发。 +验收:功能、视觉、接口、真实账号、发布状态分别记录。 +``` + +仅写“按照 Figma 还原”是不合格指令,因为它无法约束版本连续性、接口规则、阶段边界和线上状态。 + +## 8. 立即执行顺序 + +1. 先让客户确认《员工端版本功能对比与功能对齐清单》中的“确认回退/历史规划/新增需求”分类。 +2. 给确认项分配需求 ID,并把阶段、来源和功能覆盖状态回写到 Figma 索引。 +3. 先做 P0:问师傅语音输入、关键内容播报全覆盖、每日题/专项版本和去重。 +4. 再做 P1:多人派发、截止时间、主管学习端、岗位内容映射、字号三档。 +5. 开放问题榜、个人 AI 助理和全网 AI 分别按阶段二或新增专项推进,不与阶段一修复混包。 +6. 每一批完成后分别记录“本地实现、功能验证、视觉验证、已发布、生产验证”,不再用一个“完成”覆盖所有状态。 + +## 9. 补充抽查:`页面|我` 不是有效的字号变体组 + +客户抽查的 Figma 页面为 `4:16`。插件读取后确认以下问题: + +| 问题 | 可编辑版 `7:2` | 字号版 `178:959` / `196:45` / `196:104` | 严重性 | +|---|---|---|---| +| 信息架构不同 | 显示与辅助 → 成长摘要 → 成长路径 → 成长证据包 → 最近练过 | 成长摘要 → 成长记录 → 显示与辅助 → 账号与安全 | **阻塞**:不是同一页面的字号变体 | +| 指标语义不同 | 4 个指标实例全部显示“综合能力”,截图值均为 86 | 成长分 81、训练次数 3、主管建议 1 | **阻塞**:组件属性未正确覆盖,示例数据也不一致 | +| 功能数量不同 | 有成长路径、证据包、最近训练 | 三项全部消失,新增成长记录和账号安全 | **阻塞**:切换字号不能改变功能集合 | +| 超大字布局 | 无对应等价结构 | 账号卡 `y=728, h=104`,底栏从 `y=802` 开始,垂直重叠 30px | **阻塞**:与“字号调整后无裁切和越界”的审计结论冲突 | +| 底栏状态 | “我”高亮 | 字号版截图出现“今日”图标红色、“我”文字红色 | **高**:两个 Tab 同时呈现 active 状态 | +| 水平网格 | 主要卡片 `x=20, w=368`,左右留白为 20/12 | 主要卡片 `x=20, w=360`,左右留白为 20/20 | **中**:同页网格不一致 | +| 长内容承载 | 画板已设为纵向滚动且固定 2 个底栏节点,但最后一张卡片到底部没有预留 92px 安全区,最大滚动位置仍会被底栏覆盖 | 字号版同样是纵向滚动;超大字内容本身未超过画板高度,因而无法滚动消除账号卡与底栏的 30px 覆盖 | **高**:滚动属性存在,但底部安全区和超大字初始布局仍错误 | + +当前 `mobile-uni/src/pages/user/profile/index.vue` 又是第三套结构:账号、岗位身份、成长摘要、成长证据包和最近练过均来自真实接口,但没有字号选择。这意味着现在不能任选 Figma 四张画板中的一张强行还原,否则必然删除现有功能或产生新的布局回退。 + +该页应立即标记为 **设计阻塞/不可开发**,整改只需要一个最小方案: + +1. 先确认唯一功能结构,保留所有已批准且当前真实可用的模块。 +2. 只维护一套 Auto Layout/组件结构,三档字号通过文本样式或变量模式切换,不再复制三张独立页面。 +3. 三档字号使用相同字段、相同示例数据、相同模块顺序和唯一 active Tab。 +4. 内容区允许滚动,固定底栏前预留底栏高度和安全区;以超大字版无重叠作为验收下限。 +5. 修正后再让 Codex实现;修正前不得把 `4:16` 写进“正式视觉基线”。 + +### 9.1 `页面|我` 修复结果(2026-07-17) + +已按当前真实功能和新增字号需求修复 Figma 正式画板,不再保留四张互相冲突的开发基线: + +- 标准 `7:2`、大字 `253:131`、超大字 `253:188` 均包含账号、岗位身份、显示与辅助、成长摘要、训练画像、成长路径、成长证据包和最近训练,模块名称、字段语义和顺序一致。 +- 三张画板统一为 400×886、左右 20px、卡片宽 360px;摘要示例值统一为训练综合分 78、已训练 3、待复盘 2、分钟 0。 +- 三档分别应用“员工端字号”变量的标准/大字/超大字模式;字号选择卡的当前值和选中项同步切换,不再局部保留标准字号。 +- 三张画板均为纵向滚动、固定 2 个底部节点,并在内容末尾增加 108px 安全占位;超大字不再与底栏重叠,底栏只高亮“我”。 +- 修复前四张画板已复制为隐藏备份,根节点为 `251:119`、`251:299`、`251:358`、`251:417`。 +- 修复后逐张重新生成 400×886 截图并人工查看,同时复核直接子节点网格、文字越界、字体族、最小字号和固定层;三稿首屏均未发现重叠或裁切。 + +因此 `4:16` 的状态先由“设计阻塞”调整为 **Figma 视觉已修**;随后代码恢复批次已实现全局字号和持久化并完成本地视觉复核,详见 10.5。两者均不代表已经发布。 + +## 10. 全文件批量质量审计(2026-07-17) + +本轮不是继续抽样,而是先枚举根级页面,再逐页切换以绕过 Figma 跨页懒加载,最终确认当前文件包含 **81 个顶层页面、78 张以 `Screen/` 命名的顶层正式画板**。修复“我”页时已删除 1 张冗余冲突稿;随后为当前 78 张画板重新生成当次截图并逐张做人工视觉复核。检查项包括信息架构、栅格、字号与换行、固定底栏与安全区、长文案裁切、组件缩放、示例数据可信度、active/disabled 状态和管理端字符级换行。 + +### 10.1 文件级数字不一致 + +| 项目 | 索引 `101:3` 声明 | 插件逐页实测 | 判断 | +|---|---:|---:|---| +| 顶层页面 | 78 | 81 | 索引已过期,少计 3 页 | +| 正式画板 | 70 | 78 张当前 `Screen/` 画板 | 索引排除规则仍未写明,但当前总数可复算 | +| 默认图层名 | 0 | 修复前当前全量复扫为 10 个;重命名后为 0 | 当前已清零,早期“25 个/5 页”来自过期中间快照 | +| 可见文字最低字号 | 12px | 修复后 78 张画板中小于 12px 的可见文本为 0 | 当前达成;同时修复了头像缩放引入的 11.8px 文本 | +| 根级内容越界 | 0 | 修复后不可滚动根画板的直接内容越界为 0 | 当前达成;仍需在真实实现中验证滚动、键盘和动态数据 | + +已修正的 6 处 11px 文字分别位于: + +- `178:960` 主管端“我的工作台”:团队成员、待复盘、风险预警。 +- `178:2` 员工端“学习首页”:待完成、今日完成、知识引用。 + +### 10.2 已确认的真实视觉缺陷 + +| 页面 / 节点 | 插件与截图证据 | 分级 | 处理 | +|---|---|---|---| +| 页面|我 `4:16` | 修复前四张字号画板结构漂移、指标错误、双 active、超大字重叠和末尾安全区不足;2026-07-17 已修为 `7:2` / `253:131` / `253:188` 三张同构稿并逐张截图复核 | **Figma 视觉已修;代码本地已实现并复核** | 发布后补真实设备触控、键盘、读屏和长数据回流验证,不再使用隐藏旧稿 | +| 页面|问 `21:3` | 修复前空回答态被固定底栏遮住 | **Figma 已修** | 内容末尾已增加 108px 安全占位并重新截图;真实实现仍验证长回答、键盘与滚动末尾 | +| SOP 反馈 `79:674` | 修复前底部说明被裁 6px | **Figma 已修** | 画板高度由 832 增至 860,完整说明已重新截图复核 | +| 案例语音上传 `79:706` | 修复前上传表单被裁 48px | **Figma 已修** | 画板高度由 860 增至 932,上传区完整显示并重新截图复核 | +| 工作积分排行榜 `90:91` | 修复前 6 行头像压住姓名,且全部使用“银城一号” | **Figma 已修;三期未实现** | 主行组件头像已按真实尺寸缩放,姓名改为可区分示例并重新截图复核 | +| 页面对照审计 `115:210` | 修复前末节被裁 116px | **Figma 已修** | 画板高度由 2200 增至 2364,末节完整显示并重新截图复核 | + +逐图检查还发现并修正了:`87:770` / `87:815` / `87:865`、`90:867`、`93:824`、`95:782` / `95:826`、`97:5` / `97:823` 的横向裁切或错误换行;`90:2`、`91:2`、`93:940`、`95:858` 的指标、状态与表格元数据字符级换行;`91:765`、`97:823` 的徽章占位文案和荣誉页标签换行;`97:5` 周节奏列表的 100px 异常行高已收紧为 56px,画板高度由 1346px 收紧为 1056px。其余重复状态词(如“待复盘”“查看详情”)经截图复核属于正常列表结构,没有按缺陷计数。 + +### 10.3 对研发排期的影响 + +- **可以继续按业务文档开发**:没有列入上表的当前阶段页面;Figma 仍只负责视觉,不负责补齐算法、权限和接口规则。 +- **已作为视觉实现基线并本地落地**:`4:16` 字号三档已修为同构稿;代码已补全局字号切换和持久化,真实设备的长内容/键盘/底栏与读屏仍待发布前验证。 +- **当前阶段 Figma 截断缺陷已修**:`21:3`、`79:674`、`79:706` 可作为新的视觉参考,但代码仍须以真实同尺寸截图和交互状态完成二次验收。 +- **后续阶段暂不进入当前主线**:工作积分、荣誉中心、个人 AI 助理、开放问题榜等虽已修复确定性视觉错误,功能仍按阶段二/三管理。 +- **索引必须更新后才能重新冻结**:当前真实口径为 81 页/78 张 `Screen/` 画板,结构复扫结果为默认 `Frame` 0、小于 12px 文本 0、根级越界 0;索引仍写 78/70,不能继续作为文件总数事实源。 + +### 10.4 修复后的再次复检边界 + +本轮将逐页视觉验收标准提升为“当次截图 + 版面 + 状态 + 可访问性风险”联合检查,不再把节点存在或图层数量当成视觉通过。调用额度恢复后,已重新枚举 81 个顶层页面、78 张当前 `Screen/` 画板,为 78 张画板逐一生成截图并人工检查;确定性缺陷修正后,又对 20 张受影响画板生成修复后截图复核,并对排行榜、荣誉墙和积分荣誉中心做第二轮细节截图复核。 + +当前完成层级必须严格区分: + +- **Figma 结构复核**:78 张画板,默认 `Frame` 0、小于 12px 文本 0、不可滚动根级越界 0。 +- **Figma 视觉复核**:78 张当次截图已逐张查看;本轮发现的裁切、头像重叠、占位数据和字符级换行已修并复截图。 +- **代码实现/浏览器复核**:本轮没有修改移动端或管理端代码,不能把 Figma 修复写成代码已对齐;同尺寸实现截图、滚动、键盘、长数据、触控和读屏仍需单独验证。 +- **发布/生产验证**:本轮未发布,不改变现网完成状态。 + +### 10.5 后续代码恢复与浏览器复核(2026-07-17 晚间) + +上述“本轮没有修改代码”仅描述 Figma 设计修复批次。随后已完成一轮独立的代码恢复和浏览器复核: + +- 以 400×900 视口对登录、员工今日、问师傅、练习、我的三级字号、主管工作台/学习端和专项派发进行当次截图检查;同时实际操作登录、身份模式切换、姓名多选、快捷日期、批量提交、SOP 查询、设备语音降级、总结卡生成与图片下载。 +- “我”页三级字号保持同一功能结构,页面通过全局模式缩放和响应式布局自增高;超大字下关键页未见横向溢出、底栏遮挡或卡片重叠。 +- 主管派发已用 2 名真实团队成员完成 2/2 成功提交;主管学习端已由主管登录态进入员工首页并可返回工作台。 +- 总结卡在无聊天模型配置时只摘录相关 SOP 原文并标注来源,排除低相关片段;浏览器已显示下载成功提示。 +- TTS 模型未配置时,H5 已从后端 TTS 自动切换到设备语音,按钮从“语音播报”进入“停止播报”,随后可手动停止。 + +该批次的完成层级为 **本地实现、自动化验证、浏览器交互验证和视觉验证**;仍未发布、未做生产验证,也未完成真实手机上的麦克风/相册权限与读屏验收。 + +## 11. 代码实现的逐页视觉复核(2026-07-18) + +本轮在 Figma 文件质量审计之后,又对真实运行代码执行了独立视觉和交互验收,避免把“画板已修”误写成“代码已对齐”: + +- 员工端覆盖今日、练习、问师傅、个人页、学习、全网 AI、问题榜/详情/发布、积分和考试;另覆盖超大字及 430px 宽度状态。 +- 主管端覆盖工作台、团队、专项派发、个人页、考试列表、组卷和成绩页。 +- 管理端使用真实登录后覆盖常见难题、问题榜运营抽屉和全网 AI 配置页,并实际点击问题记录打开审核详情。 +- 运行结果共 24 个移动端状态和 3 个管理端页面:均无页面级横向溢出、控制台错误、失败请求或可见加载错误。 +- 人工截图复核发现并修复了代码中的确定性问题:11px 业务文字、SOP 步骤号/问答徽标 8–10px、输入框盒模型溢出,以及问题榜四个筛选标签在标准/超大字下被裁切。 +- Figma `95:6`、`95:782`、`95:826`、`95:858` 的问题榜/详情/最佳答案/管理稿已与真实实现逐项对照;代码保留相同信息层级,同时以当前组织身份、积分分账和审核接口为功能事实源。 + +当次证据保存在 `.data/visual-audit-20260718/runtime/`:`contact-employee-standard.png`、`contact-employee-accessibility.png`、`contact-supervisor.png` 和 `contact-admin.png`。这些证据只证明本地运行与视觉验证,仍不等于发布和生产验证。 + +## 12. 关联文档 + +- [员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md) +- [FIGMA_DESIGN_INVENTORY.md](FIGMA_DESIGN_INVENTORY.md) +- [FIGMA_IMPLEMENTATION_MATRIX.md](FIGMA_IMPLEMENTATION_MATRIX.md) +- [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) +- [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) diff --git a/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md b/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md new file mode 100644 index 00000000..30e9efb2 --- /dev/null +++ b/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md @@ -0,0 +1,189 @@ +# 多租户知识空间平台运行手册 + +## 1. 适用范围与安全边界 + +本手册用于银城与美途作为两个独立 SaaS 租户的知识空间初始化、内容迁移、授权、外部应用联调和回滚。租户、调用应用和知识空间是三层独立边界:内部用户的有效查询范围取“当前租户 + 当前调用应用绑定空间 + 用户/角色授权空间”的交集;外部应用只能访问其所属租户内显式绑定的空间。 + +禁止把集团账号、公司名称、客户端传入的 `tenantId/userId/extPartyId` 当作授权依据。禁止在浏览器、小程序包、日志、工单或文档中保存 API_TOKEN。回滚优先停用应用或解绑空间,不删除知识数据、附件或 OSS 对象。 + +## 2. 初始化前确认 + +### 2.1 确认生产租户 ID + +先只读查询生产库,不凭公司简称猜租户 ID: + +```sql +select tenant_id, company_name, status, del_flag +from sys_tenant +where del_flag = '0' +order by tenant_id; +``` + +由业务负责人确认银城、美途分别对应的 `tenant_id` 和公司全称,并确认二者不同。执行脚本还会再次查询并打印 `tenant_id:company_name`,操作者必须核对后才继续。 + +### 2.2 备份与迁移 + +先备份数据库,再执行 `backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql`。迁移可重复执行,但生产仍应先在结构副本验证。执行后至少核对: + +```sql +show tables like 'aihr_knowledge_%'; +show create table aihr_knowledge_info; +show create table aihr_knowledge_app; +show create table aihr_knowledge_space_grant; +``` + +## 3. 幂等初始化 + +先 dry-run,不连接数据库、不输出密码或令牌: + +```bash +./scripts/provision-knowledge-platform.sh \ + --silver-tenant-id '<银城租户ID>' \ + --meitu-tenant-id '<美途租户ID>' \ + --dry-run +``` + +确认输出恰好包含银城 3 个空间、美途 1 个空间、3 个应用及预期绑定后,再实际执行: + +```bash +AIHR_DB_HOST='<数据库地址>' \ +AIHR_DB_PORT='3306' \ +AIHR_DB_NAME='ry-vue' \ +AIHR_DB_USER='<账号>' \ +AIHR_DB_PASSWORD='<从安全环境注入>' \ +./scripts/provision-knowledge-platform.sh \ + --silver-tenant-id '<银城租户ID>' \ + --meitu-tenant-id '<美途租户ID>' \ + --confirm +``` + +不设置 `AIHR_DB_HOST` 时,脚本使用本地 `wygj-mysql` 容器。脚本是幂等 upsert,可重复执行;它不会生成 API_TOKEN,也不会把现有细粒度知识库改名、搬迁或删除。 + +执行后核对: + +```sql +select tenant_id, code, name, space_type, sensitivity_level, status +from aihr_knowledge_info +where code in ('yc_public_policy','yc_property_sop','yc_management_ops','mt_customer_service') +order by tenant_id, code; + +select tenant_id, app_code, auth_type, internal_client_key, status, rate_limit_per_minute +from aihr_knowledge_app +where app_code in ('yc_admin','yc_mobile','mt_card_miniapp') +order by tenant_id, app_code; +``` + +`mt_card_miniapp` 初始化后必须是 `DISABLED` 且 `token_hash` 为空;银城与美途之间不得存在应用—空间绑定。 + +## 4. 空间、授权与应用管理 + +管理端“知识空间”页面提供空间、主体授权和调用应用管理。空间编码创建后不可修改;停用空间会让检索立即失去该空间,但不删除资料。`MANAGE` 授权可以给用户或角色,`READ` 只决定主体能否读;SESSION 应用与主体授权取交集。 + +外部美途应用的启用顺序固定为: + +1. 核对只绑定 `mt_customer_service`。 +2. 核对空间只含批准对外发布的内容。 +3. 设置合理的每分钟限流和过期时间。 +4. 在管理端轮换令牌;明文只显示一次。 +5. 立即写入小程序云函数、API 网关或可信后端的密钥系统。 +6. 确认后端联调通过,再把应用状态改为 `ACTIVE`。 + +轮换令牌后旧令牌立即失效。发生泄露或暂停联调时,先把应用改为 `DISABLED`;需要恢复时重新轮换,不复用曾暴露的明文。前端、小程序源码和用户设备绝不能直接持有令牌。 + +## 5. 内容导入与 legacy 空间迁移 + +### 5.1 新内容导入 + +在 SOP 或资料处理页显式选择一个或多个可管理空间。多空间上传只解析一次、复用同一个 OSS 对象,但为每个空间建立独立附件成员关系和检索片段。解绑或停用一个空间不得影响另一个空间的内容。 + +导入后核对: + +```sql +select a.tenant_id, k.code, a.doc_id, a.name, a.oss_id, a.status, + count(f.id) fragment_count +from aihr_knowledge_attach a +join aihr_knowledge_info k + on k.tenant_id = a.tenant_id and k.id = a.knowledge_id +left join aihr_knowledge_fragment f + on f.tenant_id = a.tenant_id and f.knowledge_id = a.knowledge_id and f.doc_id = a.doc_id +group by a.tenant_id, k.code, a.doc_id, a.name, a.oss_id, a.status +order by a.id desc; +``` + +空间解绑会先删除当前成员、片段和向量点;只有跨租户附件引用计数为零时才尝试删除 OSS。对象存储删除失败不会恢复已解绑成员,而是留下可审计的孤立对象,按下列查询确认后由独立清理流程处理: + +```sql +select o.oss_id, o.tenant_id, o.original_name +from sys_oss o +left join aihr_knowledge_attach a + on a.tenant_id = o.tenant_id and a.oss_id = o.oss_id +where a.id is null and o.ext1 in ('aihr-knowledge','aihr-knowledge-staging'); +``` + +### 5.2 细粒度 SOP 迁入 `yc_property_sop` + +现有“投诉处理 SOP、催缴沟通 SOP、报修跟进 SOP”等 `legacy_*` 空间不直接改名。按以下顺序执行: + +1. 业务负责人导出现有空间的文件清单,逐项标记目标空间、密级、版本日期、维护人和是否允许复用。 +2. 通过多空间导入或 OSS 成员复用把批准内容加入 `yc_property_sop`,保留原 legacy 成员关系。 +3. 对新空间至少运行 10 个标准问题,逐题核对答案、文件标题、引用片段和空间编码。 +4. 核对新应用只绑定新空间,legacy 空间不向新应用授权。 +5. 业务确认后把不再使用的 legacy 空间改为 `DISABLED`,观察查询日志一个完整业务周期。 +6. 若出现缺失,重新启用 legacy 空间或恢复应用绑定;不要搬回附件、删除片段或清理 OSS。 + +## 6. 查询、日志与限流观察 + +内部统一入口为 `POST /api/knowledge/query`,外部入口为 `POST /api/open/knowledge/query`。外部请求使用 `Authorization: Bearer `。查询日志只保存问题哈希、有效空间、来源类型、状态、耗时和提示版本,不保存完整问题或令牌。 + +```sql +select tenant_id, app_id, status, source_types, count(*) calls, + round(avg(latency_ms)) avg_latency_ms, max(create_time) last_time +from aihr_knowledge_query_log +group by tenant_id, app_id, status, source_types +order by last_time desc; +``` + +重点观察 `REJECTED`、`FAILED`、`NO_EVIDENCE`、连续 `429` 和异常高耗时。外部应用的限流依赖 Redis;Redis 不可用时外部查询应失败关闭并返回服务不可用,不能绕过限流继续调用。 + +## 7. 验证与发布闸门 + +自动合同测试: + +```bash +bash scripts/tests/provision-knowledge-platform.test.sh +node --test scripts/tests/knowledge-platform-security.test.mjs +``` + +真实环境验证器从环境变量读取令牌,绝不写入命令脚本或仓库: + +```bash +AIHR_BASE_URL='https://wygj-api.localhost' \ +AIHR_SILVER_ADMIN_TOKEN='<管理端登录令牌>' \ +AIHR_SILVER_EMPLOYEE_TOKEN='<员工登录令牌>' \ +AIHR_SILVER_SUPERVISOR_TOKEN='<主管登录令牌>' \ +AIHR_MEITU_APP_TOKEN='<美途应用令牌>' \ +AIHR_MEITU_QUERY='<已确认能命中公开内容的美途标准题>' \ +node scripts/verify-knowledge-platform.mjs +``` + +在预先导入了同一测试文件到 `yc_public_policy` 与 `yc_property_sop` 的非生产环境,可额外执行破坏性解绑验证;它会删除公共制度空间中的测试成员,因此必须使用专用测试文件: + +```bash +AIHR_VERIFY_UNBIND=true \ +AIHR_SHARED_DOC_NAME='<专用测试文件名>' \ +AIHR_SHARED_DOC_QUERY='<测试文件唯一标记>' \ +node scripts/verify-knowledge-platform.mjs +``` + +只有工程回归、真实 HTTP 正反例、数据库租户归属、Qdrant tenant+knowledge 过滤和每空间标准题都通过,才可标记“联调可用”。生产发布、真实内容负责人/授权矩阵签字和试点样本未完成时,不得标记“线上生效”或“正式试点验收”。 + +## 8. 回滚 + +按影响最小顺序处理: + +1. 停用 `mt_card_miniapp` 或对应 SESSION 应用。 +2. 解绑错误空间或把有问题的新空间置为 `DISABLED`。 +3. 客户端临时回到兼容 `/api/knowledge/search`,该入口仍经过统一授权服务。 +4. 恢复旧应用绑定或重新启用 legacy 空间。 + +不要删除新表,不删除查询审计,不把附件移动回旧空间,不清理 OSS,不回滚已经写入的稳定空间编码。数据库和静态资源发布仍遵循项目既有备份与发布口径。 diff --git a/docs/README.md b/docs/README.md index 28eee6da..14716017 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,27 +6,32 @@ | 文档 | 用途 | |---|---| -| [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、移动端 H5 静态发布 | +| [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、管理端与移动端 H5 静态发布 | | [FIGMA_DESIGN_INVENTORY.md](FIGMA_DESIGN_INVENTORY.md) | Figma 页面节点索引、设计批次范围、取消项和全文件一致性审计结果 | | [FIGMA_DESIGN_SYSTEM_GUIDE.md](FIGMA_DESIGN_SYSTEM_GUIDE.md) | 员工端通用视觉元素、业务元素、状态映射和 LLM 组页规则 | | [FIGMA_IMPLEMENTATION_MATRIX.md](FIGMA_IMPLEMENTATION_MATRIX.md) | 70 张正式画板与代码/线上实现状态、二期边界和剩余动作 | | [FIGMA_ONLINE_DELTA.md](FIGMA_ONLINE_DELTA.md) | Figma 目标与线上 H5 实测差异矩阵、当前实施边界和验收口径 | +| [FIGMA需求覆盖与版本偏差审计-20260717.md](FIGMA需求覆盖与版本偏差审计-20260717.md) | 旧版功能、已确认需求、Figma 实际画板与当前实现的四方对照,以及防止遗漏旧功能和误拉后续阶段的事实源规则 | | [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 | | [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 | +| [KNOWLEDGE_PLATFORM_RUNBOOK.md](KNOWLEDGE_PLATFORM_RUNBOOK.md) | 银城/美途知识空间初始化、授权、令牌、内容迁移、监控验证和安全回滚手册 | | [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 | +| [superpowers/specs/2026-07-16-multi-tenant-knowledge-platform-design.md](superpowers/specs/2026-07-16-multi-tenant-knowledge-platform-design.md) | 银城/美途独立租户、多知识空间、多调用应用统一问答、授权交集、受控数据工具和安全验收的完整需求方案 | +| [superpowers/plans/2026-07-16-multi-tenant-knowledge-platform.md](superpowers/plans/2026-07-16-multi-tenant-knowledge-platform.md) | 多租户多知识空间统一问答平台按 schema、授权、检索、上传、数据工具、管理端、多端联调和发布拆分的实施计划 | | [AI人力资源系统项目规划方案与AI接口说明.md](AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位、AI 接口和供应商规划 | | [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) | 三阶段总纲:陪练 → 大喇叭+知识学习平台(含学模块、开放问题榜、员工个人 AI 助理) → 北森对接;身份三轴模型与防遗漏分发策略 | | [个人AI助理阶段二专项TechSpec.md](个人AI助理阶段二专项TechSpec.md) | 员工个人知识空间的物理分域、数据模型、API、检索融合、权限、安全、删除和测试规格 | | [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 个人 AI 助理阶段二按 P0/P1 拆分的逐任务实施、测试、提交和试点闸门 | | [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 | -| [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | BRD 功能、权限、迁移、线上资源和未完成项的证据记录;不替代正式发布验收 | +| [员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md) | 历史可运行版、历史需求/设计、当前 `mobile-uni`、本地待发布修复与客户逐条反馈的版本对比和优先级 | +| [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | 顶部维护现阶段结论与三端输入/BRD 匹配矩阵,下方保留功能、权限、迁移、线上资源和未完成项证据;不替代正式发布验收 | | [BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | AIHR BRD 生产 schema/内置内容迁移顺序、只读核验和发布后回归口径 | | [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | 当前用户侧 `mobile-uni/` 重建计划、三端页面拆分、portless 入口、真实浏览器验证和验收进展 | | [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 产品定义(学练问报+问题榜+学分晋升+个人 AI 助理)、半年会发布会方案分层及客户需求逐项处置对照 | | [20260708/发布会演示候选题单.md](20260708/发布会演示候选题单.md) | 2026-07-08 发布会演示候选题单,作为产品讨论留档,不作为阶段一开发入口 | | [基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get 笔记】.md](基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get%20笔记】.md) | 2026-07-07 立项会纪要原文(总纲的输入源,只作留档) | | [20260708/企业内部AI培训学习APP产品需求与发布会内容讨论(纪要)](20260708/) | 2026-07-08 产品讨论纪要原文及客户"第三版"docx(整合方案的输入源,只作留档) | -| [open-org-sync-api-design-v1.md](open-org-sync-api-design-v1.md) | 开放组织同步系统 `/open/v1` 接口设计,`POST /api/aihr/org/sync` 的外部契约 | +| [open-org-sync-api-design-v1.md](open-org-sync-api-design-v1.md) | 开放组织同步系统 `/api/open/v1` 接口设计,`POST /api/aihr/org/sync` 的外部契约 | | [物业AI人力资源系统业务需求文档BRD.md](物业AI人力资源系统业务需求文档BRD.md) | 业务需求、范围边界、角色、风险与验收 | | [物业AI人力资源系统开发规格TechSpec.md](物业AI人力资源系统开发规格TechSpec.md) | 工程结构、数据模型、页面路由、API 和核心实现规格 | | [BACKLOG.md](BACKLOG.md) | 需求池与延后事项(B1–B8:数据权限/人设语音/生成图片/生成视频/员工画像/视觉检索/数字师傅) | diff --git a/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md b/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md index 77eb72b0..d0242d94 100644 --- a/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md +++ b/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md @@ -17,7 +17,7 @@ | 2. 模型能力 | `chat_provider / chat_model` 改造为 `aihr_model_*`,最小 OpenAI-compatible 调用边界 | 多模型市场、图像、SSE、成本看板 | 模型列表可查;数据库配置后可调用 `/chat/completions` | | 3. 文档解析 | 文本/Markdown/PDF/Word/Excel/PPT loader、OSS-first 上传与分片 | 异步重试队列、图片智能分析 | txt/md/PDF/Word/Excel/PPT 文件可落 OSS 并解析为 fragment | | 4. 检索与 embedding | MySQL Fulltext + OpenAI-compatible embedding + Qdrant 最小向量召回 | Milvus/Weaviate 全量适配、独立向量库后台 | 同一问题返回带分数片段,配置 vector 模型后片段有 embedding,Qdrant 可用时参与 RRF 融合 | -| 5. 资料处理状态 | 解析任务状态页、资料分类、处理链路、多文件/目录选择上传、服务端后台目录导入任务 | 单失败文件重试、分布式队列 | 能查看等待解析/解析中/已完成/失败、片段数、向量化状态并导入 MVP 样例文件 | +| 5. 资料处理状态 | 解析任务状态页、资料分类、处理链路、多文件/ZIP 异步上传、单文件重试、服务端运维目录导入接口 | 分布式队列 | 能查看等待解析/解析中/已完成/失败、片段数、向量化状态,并从页面批量导入 MVP 样例文件 | | 6. RAG 回答 | 带引用回答、训练题生成 | 多模型市场、成本看板 | SOP 页面展示真实引用 | | 7. Chat | 最小会话、消息、引用来源 | 多智能体、工作流、MCP、Skills | 可围绕 SOP 连续追问 | @@ -72,9 +72,9 @@ - 新增后端 API:`GET /api/knowledge/processing/overview`。 - 新增前端页面:`/knowledge/processing`,菜单名“资料处理”;`SOP知识库` 菜单保持独立不改名。 -- 页面聚合展示资料总量、已完成、处理中、失败、资料分类、解析任务表、处理链路、规则风险和事件列表。 -- 批量导入先复用 `POST /api/knowledge/doc/upload`,支持多文件和浏览器目录选择。 -- 新增 `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks` 和 `POST /api/knowledge/doc/import-tasks/{id}/cancel`,只读取 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,写入 `aihr_knowledge_import_task` 后后台逐文件复用上传解析链路,页面轮询进度并支持取消运行中任务;`POST /api/knowledge/doc/import-local` 保留同步调试。 +- 页面聚合展示资料总量、已完成、处理中、失败、资料分类、解析任务表、处理链路、规则风险和事件列表;解析任务表按当前筛选结果前端分页,默认 10 条/页,可切换 20/50 条,筛选或刷新时回到第一页。 +- 页面批量导入走 `POST /api/knowledge/doc/upload-async`,支持多文件和 ZIP,按批次轮询并可单文件重试;不提供浏览器目录选择。 +- `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` 和同步 `POST /api/knowledge/doc/import-local` 仅保留为运维/调试接口,不在资料处理页暴露。 ## 当前第六片 @@ -92,5 +92,5 @@ - 工作流、多智能体、MCP、Skills - 全量模型管理后台 - 完整流式 chat 与消息持久化 -- 单失败文件重试、导入任务暂停、分布式队列 +- 导入任务暂停、分布式队列 - 大规模资料的异步向量重建进度条 diff --git a/docs/open-org-sync-api-design-v1.md b/docs/open-org-sync-api-design-v1.md index dbeab024..822c9cba 100644 --- a/docs/open-org-sync-api-design-v1.md +++ b/docs/open-org-sync-api-design-v1.md @@ -1,5 +1,7 @@ # 开放平台组织与预算同步 API 设计方案 v1 +> 线上前缀修正(2026-07-15):当前域名 `https://wuye.meihe.cc` 只有 `/api/*` 会代理到后端开放平台;实际接入必须使用 `https://wuye.meihe.cc/api/open/v1`。不要使用 `https://wuye.meihe.cc/open/v1`,否则可能命中前端 SPA 或 nginx,返回 HTML 而不是开放接口 JSON。 + ## 1. 文档目标 本文档用于定义本系统对外开放的组织与预算同步方案,覆盖以下对象: @@ -47,7 +49,7 @@ ### 3.2 架构原则 -- 对外统一入口:全部走 `backend` 的 `/open/v1/*` +- 对外统一入口:全部走 `backend` 的 `/api/open/v1/*` - 内外账号隔离:外部系统不用内部员工登录账号 - 事件通知最小化:通知消息只传事件元信息,不传完整业务数据 - 数据主动拉取:业务明细通过开放接口主动拉取 @@ -68,7 +70,7 @@ ↓ 订阅者收到消息 ↓ -7天内调用 /open/v1/sync/changes 或对象接口主动拉取 +7天内调用 /api/open/v1/sync/changes 或对象接口主动拉取 ↓ 记录同步任务、同步明细、游标、审计日志 ↓ @@ -470,66 +472,66 @@ X-Nonce 开放平台统一前缀: ```text -/open/v1 +/api/open/v1 ``` ### 9.1 认证接口 -- `POST /open/v1/auth/token` -- `POST /open/v1/auth/refresh` -- `GET /open/v1/auth/me` +- `POST /api/open/v1/auth/token` +- `POST /api/open/v1/auth/refresh` +- `GET /api/open/v1/auth/me` ### 9.2 订阅管理接口 -- `POST /open/v1/subscriptions` -- `GET /open/v1/subscriptions` -- `GET /open/v1/subscriptions/{subscription_id}` -- `PATCH /open/v1/subscriptions/{subscription_id}` -- `POST /open/v1/subscriptions/{subscription_id}/enable` -- `POST /open/v1/subscriptions/{subscription_id}/disable` +- `POST /api/open/v1/subscriptions` +- `GET /api/open/v1/subscriptions` +- `GET /api/open/v1/subscriptions/{subscription_id}` +- `PATCH /api/open/v1/subscriptions/{subscription_id}` +- `POST /api/open/v1/subscriptions/{subscription_id}/enable` +- `POST /api/open/v1/subscriptions/{subscription_id}/disable` ### 9.3 事件通知接口 -- `GET /open/v1/events` -- `GET /open/v1/events/{event_id}` -- `POST /open/v1/events/{event_id}/ack` -- `POST /open/v1/events/{event_id}/nack` +- `GET /api/open/v1/events` +- `GET /api/open/v1/events/{event_id}` +- `POST /api/open/v1/events/{event_id}/ack` +- `POST /api/open/v1/events/{event_id}/nack` ### 9.4 数据拉取接口 -- `GET /open/v1/sync/changes` -- `GET /open/v1/sync/snapshot` -- `GET /open/v1/groups/{id}` -- `GET /open/v1/companies/{id}` -- `GET /open/v1/departments/{id}` -- `GET /open/v1/employees/{id}` -- `GET /open/v1/budget-versions/{id}` -- `GET /open/v1/budget-subjects/{id}` -- `GET /open/v1/budget-items/{id}` -- `GET /open/v1/budget-plans/{id}` +- `GET /api/open/v1/sync/changes` +- `GET /api/open/v1/sync/snapshot` +- `GET /api/open/v1/groups/{id}` +- `GET /api/open/v1/companies/{id}` +- `GET /api/open/v1/departments/{id}` +- `GET /api/open/v1/employees/{id}` +- `GET /api/open/v1/budget-versions/{id}` +- `GET /api/open/v1/budget-subjects/{id}` +- `GET /api/open/v1/budget-items/{id}` +- `GET /api/open/v1/budget-plans/{id}` ### 9.5 同步任务接口 -- `POST /open/v1/sync/jobs` -- `GET /open/v1/sync/jobs/{job_id}` -- `POST /open/v1/sync/jobs/{job_id}/complete` +- `POST /api/open/v1/sync/jobs` +- `GET /api/open/v1/sync/jobs/{job_id}` +- `POST /api/open/v1/sync/jobs/{job_id}/complete` ### 9.6 补数申请接口 -- `POST /open/v1/replay-requests` -- `GET /open/v1/replay-requests` -- `GET /open/v1/replay-requests/{request_id}` -- `POST /open/v1/replay-requests/{request_id}/cancel` -- `POST /open/v1/replay-requests/{request_id}/approve` -- `POST /open/v1/replay-requests/{request_id}/reject` -- `POST /open/v1/replay-requests/{request_id}/execute` +- `POST /api/open/v1/replay-requests` +- `GET /api/open/v1/replay-requests` +- `GET /api/open/v1/replay-requests/{request_id}` +- `POST /api/open/v1/replay-requests/{request_id}/cancel` +- `POST /api/open/v1/replay-requests/{request_id}/approve` +- `POST /api/open/v1/replay-requests/{request_id}/reject` +- `POST /api/open/v1/replay-requests/{request_id}/execute` ### 9.7 追踪与审计接口 -- `GET /open/v1/traces/{trace_id}` -- `GET /open/v1/audits` -- `GET /open/v1/subscriptions/{subscription_id}/deliveries` -- `GET /open/v1/subscriptions/{subscription_id}/sync-records` +- `GET /api/open/v1/traces/{trace_id}` +- `GET /api/open/v1/audits` +- `GET /api/open/v1/subscriptions/{subscription_id}/deliveries` +- `GET /api/open/v1/subscriptions/{subscription_id}/sync-records` --- @@ -537,7 +539,7 @@ X-Nonce ### 10.1 获取令牌 -`POST /open/v1/auth/token` +`POST /api/open/v1/auth/token` 请求示例: @@ -565,11 +567,11 @@ X-Nonce ### 10.2 刷新令牌 -`POST /open/v1/auth/refresh` +`POST /api/open/v1/auth/refresh` ### 10.3 查询当前应用 -`GET /open/v1/auth/me` +`GET /api/open/v1/auth/me` 返回内容: @@ -587,7 +589,7 @@ X-Nonce ### 11.1 创建订阅 -`POST /open/v1/subscriptions` +`POST /api/open/v1/subscriptions` 请求示例: @@ -633,7 +635,7 @@ X-Nonce ### 11.2 查询订阅列表 -`GET /open/v1/subscriptions` +`GET /api/open/v1/subscriptions` 支持筛选: @@ -643,8 +645,8 @@ X-Nonce ### 11.3 启停订阅 -- `POST /open/v1/subscriptions/{subscription_id}/enable` -- `POST /open/v1/subscriptions/{subscription_id}/disable` +- `POST /api/open/v1/subscriptions/{subscription_id}/enable` +- `POST /api/open/v1/subscriptions/{subscription_id}/disable` --- @@ -652,7 +654,7 @@ X-Nonce ### 12.1 拉取事件列表 -`GET /open/v1/events` +`GET /api/open/v1/events` 查询参数: @@ -685,7 +687,7 @@ X-Nonce ### 12.2 事件确认收到 -`POST /open/v1/events/{event_id}/ack` +`POST /api/open/v1/events/{event_id}/ack` 请求示例: @@ -698,7 +700,7 @@ X-Nonce ### 12.3 事件确认失败 -`POST /open/v1/events/{event_id}/nack` +`POST /api/open/v1/events/{event_id}/nack` 请求示例: @@ -715,7 +717,7 @@ X-Nonce ### 13.1 增量拉取 -`GET /open/v1/sync/changes` +`GET /api/open/v1/sync/changes` 查询参数: @@ -758,7 +760,7 @@ X-Nonce ### 13.2 全量快照拉取 -`GET /open/v1/sync/snapshot` +`GET /api/open/v1/sync/snapshot` 查询参数: @@ -779,35 +781,35 @@ X-Nonce #### 集团 -`GET /open/v1/groups/{id}` +`GET /api/open/v1/groups/{id}` #### 公司 -`GET /open/v1/companies/{id}` +`GET /api/open/v1/companies/{id}` #### 部门 -`GET /open/v1/departments/{id}` +`GET /api/open/v1/departments/{id}` #### 员工 -`GET /open/v1/employees/{id}` +`GET /api/open/v1/employees/{id}` #### 预算版本 -`GET /open/v1/budget-versions/{id}` +`GET /api/open/v1/budget-versions/{id}` #### 预算科目 -`GET /open/v1/budget-subjects/{id}` +`GET /api/open/v1/budget-subjects/{id}` #### 预算项 -`GET /open/v1/budget-items/{id}` +`GET /api/open/v1/budget-items/{id}` #### 预算计划 -`GET /open/v1/budget-plans/{id}` +`GET /api/open/v1/budget-plans/{id}` ### 13.4 单对象接口通用参数 @@ -821,7 +823,7 @@ X-Nonce ### 14.1 创建同步任务 -`POST /open/v1/sync/jobs` +`POST /api/open/v1/sync/jobs` 用途: @@ -853,11 +855,11 @@ X-Nonce ### 14.2 查询同步任务 -`GET /open/v1/sync/jobs/{job_id}` +`GET /api/open/v1/sync/jobs/{job_id}` ### 14.3 回报同步结果 -`POST /open/v1/sync/jobs/{job_id}/complete` +`POST /api/open/v1/sync/jobs/{job_id}/complete` 请求示例: @@ -932,7 +934,7 @@ X-Nonce ### 16.3 发起补数申请 -`POST /open/v1/replay-requests` +`POST /api/open/v1/replay-requests` 请求示例: @@ -963,29 +965,29 @@ X-Nonce ### 16.4 查询补数申请 -`GET /open/v1/replay-requests` +`GET /api/open/v1/replay-requests` ### 16.5 查询补数申请详情 -`GET /open/v1/replay-requests/{request_id}` +`GET /api/open/v1/replay-requests/{request_id}` ### 16.6 取消补数申请 -`POST /open/v1/replay-requests/{request_id}/cancel` +`POST /api/open/v1/replay-requests/{request_id}/cancel` ### 16.7 审批补数申请 #### 批准 -`POST /open/v1/replay-requests/{request_id}/approve` +`POST /api/open/v1/replay-requests/{request_id}/approve` #### 驳回 -`POST /open/v1/replay-requests/{request_id}/reject` +`POST /api/open/v1/replay-requests/{request_id}/reject` ### 16.8 执行补数 -`POST /open/v1/replay-requests/{request_id}/execute` +`POST /api/open/v1/replay-requests/{request_id}/execute` 执行方式: @@ -999,7 +1001,7 @@ X-Nonce ### 17.1 查询链路追踪 -`GET /open/v1/traces/{trace_id}` +`GET /api/open/v1/traces/{trace_id}` 返回应包含: @@ -1014,7 +1016,7 @@ X-Nonce ### 17.2 查询审计日志 -`GET /open/v1/audits` +`GET /api/open/v1/audits` 可按以下条件筛选: diff --git a/docs/superpowers/plans/2026-07-16-multi-tenant-knowledge-platform.md b/docs/superpowers/plans/2026-07-16-multi-tenant-knowledge-platform.md new file mode 100644 index 00000000..40686b3d --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-multi-tenant-knowledge-platform.md @@ -0,0 +1,1028 @@ +# 多租户多知识空间统一问答平台 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在保留现有 AI 陪练与 SOP 问答兼容性的前提下,交付银城、美途独立租户、多知识空间、多调用应用统一问答、受控实时数据查询和完整授权审计能力。 + +**Architecture:** 复用 `aihr_knowledge_info/attach/fragment`、现有 RAG、组织快照、训练统计与 `LoginUser.clientKey`;新增知识空间元数据、应用—空间和主体—空间授权。内部与外部保留不同认证入口,但共享授权、检索、生成、引用和审计服务;所有召回强制执行 `tenant_id + effectiveKnowledgeIds`。 + +**Tech Stack:** Java 21、Spring Boot、JdbcTemplate、Sa-Token、MySQL 8 Fulltext、Qdrant REST、Redis、MinIO/sys_oss、Vue 3 + Element Plus、uni-app Vue 3、TypeScript、JUnit 5、Vitest/Node test runner。 + +--- + +## 2026-07-16 执行状态 + +- 工程范围已实施完成:Task 1—10 的 schema、授权、应用认证、统一查询、多空间资料、数据工具、管理端、多端迁移、初始化和运行手册均已落地。 +- Task 11 的自动化回归、真实本地 HTTP 正反例、数据库与 Qdrant 证据、四层成熟度审计已完成;本地双租户验证结果为 `18/18`。 +- 生产发布与正式试点未执行:它们需要生产变更授权以及业务负责人、内容清单、角色矩阵、标准题和密钥托管材料,不能由工程实现自动代替。 +- 当前分支为 `codex/multi-tenant-knowledge-platform`。为保护进入本轮前已存在且与实现文件重叠的用户改动,没有把全部工作区文件强行合并为一个提交;这不影响本地测试结论,但进入正式发布批次前必须由用户确认提交边界。 + +--- + +## 0. 执行边界与排期 + +### 0.1 交付批次 + +| 批次 | 范围 | 参考工作量 | 完成闸门 | +|---|---|---:|---| +| A:隔离与授权地基 | schema、空间、应用、主体授权、外部令牌 | 5 人日 | 跨租户/应用/角色反例自动化测试通过 | +| B:检索与内容 | 统一 query、受限 MySQL/Qdrant、兼容接口、多空间上传 | 5 人日 | 任意检索都带 tenant + space;同文件多空间不互相影响 | +| C:数据工具与管理端 | 两个训练工具、空间/授权/应用管理页 | 4 人日 | 本人/团队范围验证;管理端完成完整配置闭环 | +| D:多端联调与试点准备 | 移动端/管理端迁移、美途外部联调、审计、发布验证 | 4 人日 | 真实 HTTP、构建、越权矩阵和回滚开关验证通过 | + +单人顺序执行约 18 个开发人日,不含业务整理知识文件和标准题的时间。并行执行时,批次 B 必须等待批次 A 的数据模型与授权 API 稳定,不能为了赶进度先在客户端硬编码空间。 + +### 0.2 第一版固定边界 + +- 银城、美途作为独立租户。 +- 银城首批上线:`yc_public_policy`、`yc_property_sop`、`yc_management_ops`。 +- 美途首批上线:`mt_customer_service`。 +- 财务专业、经营决策只预留方案,有真实内容、负责人和授权矩阵后再启用。 +- 不做跨租户共享、个人知识空间、任意 SQL、工资财务明细、美途订单查询。 +- 第一版数据工具仅 `MY_PRACTICE_SUMMARY` 和 `TEAM_PRACTICE_SUMMARY`。 +- 当前阶段一验收继续按 `docs/DEMO_ACCEPTANCE.md` 和 `docs/AI陪练二期开发推进计划.md`,本计划不改变其范围。 + +### 0.3 实施总顺序 + +```text +schema + → 服务端主体与空间授权 + → 内外部应用认证 + → 受限统一检索 + → 多空间文件成员关系 + → 受控数据工具 + → 管理端配置 + → 内部多端迁移 + → 银城/美途初始化 + → 安全联调与发布 +``` + +--- + +### Task 1: 建立知识空间平台 schema 与迁移 + +**Files:** +- Modify: `backend/script/sql/aihr_knowledge_mysql8.sql` +- Create: `backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql` +- Modify: `scripts/reset-dev-db.sh` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrKnowledgePlatformSchemaTest.java` + +- [ ] **Step 1: 写失败的 schema 契约测试** + +测试读取两个 SQL 文件并断言: + +```java +@Test +void schemaDefinesSpacesAppsGrantsAndAuditWithoutPersonalTables() throws Exception { + String base = readRepoFile("backend/script/sql/aihr_knowledge_mysql8.sql"); + String migration = readRepoFile( + "backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql"); + String sql = base + migration; + + assertTrue(sql.contains("`code` varchar(100)")); + assertTrue(sql.contains("uk_aihr_knowledge_info_code")); + assertTrue(sql.contains("aihr_knowledge_space_grant")); + assertTrue(sql.contains("aihr_knowledge_app")); + assertTrue(sql.contains("aihr_knowledge_app_space")); + assertTrue(sql.contains("aihr_knowledge_query_log")); + assertTrue(sql.contains("space_codes_json")); + assertFalse(sql.contains("aihr_personal_")); +} +``` + +- [ ] **Step 2: 运行测试并确认先失败** + +Run: + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgePlatformSchemaTest test +``` + +Expected: FAIL,提示迁移文件不存在或目标表/字段缺失。 + +- [ ] **Step 3: 编写向前兼容迁移** + +迁移必须包含: + +1. `aihr_knowledge_info` 新增 `code/space_type/sensitivity_level/status`,建立 `UNIQUE(tenant_id, code)`。 +2. 创建 `aihr_knowledge_space_grant`、`aihr_knowledge_app`、`aihr_knowledge_app_space`、`aihr_knowledge_query_log`。 +3. `aihr_knowledge_upload_item` 增加 `space_codes_json json NULL`,保留原 `category` 供兼容读取。 +4. `token_hash` 长度固定为 64,表中不允许出现 `token_plain`、`secret` 明文字段。 +5. 所有业务表包含 `tenant_id` 和必要联合索引。 +6. 基础 SQL 与 update SQL 定义一致;重复执行 update 不破坏既有 attach、fragment 和 OSS 关系。 + +迁移旧行时按安全顺序执行:先增加可空 `code`,再将空值确定性回填为 `legacy_`,核对租户内无重复后改成 `NOT NULL` 并建立唯一键;`space_type/sensitivity_level/status` 分别回填 `BUSINESS/INTERNAL/ACTIVE`。不得因为补空间字段移动原 attach 或 fragment。 + +关键 DDL 约束: + +```sql +UNIQUE KEY uk_aihr_knowledge_info_code (tenant_id, code); +UNIQUE KEY uk_aihr_space_grant + (tenant_id, knowledge_id, principal_type, principal_value, permission); +UNIQUE KEY uk_aihr_knowledge_app_code (app_code); +UNIQUE KEY uk_aihr_app_space (tenant_id, app_id, knowledge_id); +UNIQUE KEY uk_aihr_query_request (request_id); +``` + +- [ ] **Step 4: 将基础 SQL 接入本地 reset** + +保证 `scripts/reset-dev-db.sh` 在知识基础表后加载新增结构,所有 MySQL 导入继续使用 `--default-character-set=utf8mb4`。 + +- [ ] **Step 5: 运行 schema 测试和真实本地迁移** + +Run: + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgePlatformSchemaTest test +./scripts/reset-dev-db.sh +docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue -e \ + "show tables like 'aihr_knowledge_app%'; show columns from aihr_knowledge_info;" +``` + +Expected: 测试 PASS;reset 成功;新表和字段存在;原 `aihr_knowledge_attach/fragment` 数据结构仍在。 + +- [ ] **Step 6: 提交 Task 1** + +```bash +git add backend/script/sql/aihr_knowledge_mysql8.sql \ + backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql \ + scripts/reset-dev-db.sh \ + backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrKnowledgePlatformSchemaTest.java +git commit -m "feat(knowledge): add multi-space platform schema" +``` + +--- + +### Task 2: 实现主体解析与有效空间授权交集 + +**Files:** +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgePrincipal.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAccessService.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAccessServiceTest.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java`(只复用或暴露已有只读身份解析,不改同步语义) + +- [ ] **Step 1: 写授权交集失败测试** + +至少覆盖: + +```java +@Test void appAndPrincipalGrantsAreIntersected(); +@Test void requestedSpacesCanOnlyNarrowEffectiveScope(); +@Test void requestedForeignTenantSpaceIsRejectedBeforeRetrieval(); +@Test void emptyEffectiveScopeIsRejectedBeforeRetrieval(); +@Test void appUserSupervisorRoleComesFromOrgSnapshotNotRequestBody(); +@Test void sysUserRolesComeFromLoginUserRolePermission(); +``` + +核心断言示例:应用允许 `{1,2}`、主体允许 `{2,3}` 时,只返回 `{2}`;请求 `{2,3}` 时因包含未授权空间返回 403,而不是返回 `{2}` 后静默成功。 + +- [ ] **Step 2: 运行测试确认失败** + +Run: + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeAccessServiceTest test +``` + +Expected: FAIL,目标类不存在。 + +- [ ] **Step 3: 实现不可伪造的主体对象** + +`AihrKnowledgePrincipal` 至少包含: + +```java +public record AihrKnowledgePrincipal( + String tenantId, + Long userId, + String userType, + String extPartyId, + Set roles, + Set projectCodes, + String clientKey +) {} +``` + +`AihrKnowledgePrincipalResolver` 只从 `LoginHelper.getLoginUser()`、`rolePermission` 和组织快照生成该对象;Controller 请求 DTO 不允许出现 `tenantId/userId/extPartyId/projectCodes/roles`。 + +- [ ] **Step 4: 实现空间授权计算** + +`AihrKnowledgeAccessService` 提供: + +```java +Set resolveInternalSpaceIds( + AihrKnowledgePrincipal principal, + String appCode, + List requestedSpaceCodes); + +Set resolveExternalSpaceIds( + AuthenticatedKnowledgeApp app, + List requestedSpaceCodes); +``` + +内部算法固定为租户空间、应用授权、主体授权三者交集;外部算法固定为租户空间与应用授权交集。所有 SQL 显式包含 `tenant_id`,返回前验证空间状态为 `ACTIVE`。 + +- [ ] **Step 5: 运行测试** + +Run: + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ + -Dtest=AihrKnowledgeAccessServiceTest,AihrTenantContextContractTest test +``` + +Expected: PASS,现有租户上下文契约继续通过。 + +- [ ] **Step 6: 提交 Task 2** + +```bash +git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge \ + backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAccessServiceTest.java \ + backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java +git commit -m "feat(knowledge): enforce app and principal space grants" +``` + +--- + +### Task 3: 实现知识调用应用、外部令牌与限流 + +**Files:** +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeAppDto.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppAuthService.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAppServiceTest.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/pom.xml`(仅在当前模块无法访问 `RedisUtils` 时增加现有内部模块依赖,不引入第三方库) + +- [ ] **Step 1: 写令牌安全失败测试** + +至少覆盖: + +```java +@Test void createApiTokenReturnsPlainTextOnceAndPersistsOnlySha256(); +@Test void rotateTokenInvalidatesOldTokenImmediately(); +@Test void disabledExpiredOrWrongTokenIsRejected(); +@Test void tokenAppCodeCannotEscapeItsTenant(); +@Test void sessionAppResolvesByTenantAndClientKey(); +@Test void rateLimitUsesAppIdAsRedisDimension(); +``` + +测试数据库写入捕获中不得出现完整 `ak_` 令牌。 + +- [ ] **Step 2: 运行测试确认失败** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeAppServiceTest test +``` + +Expected: FAIL。 + +- [ ] **Step 3: 实现令牌生命周期** + +规则固定为: + +- 格式:`ak__`。 +- `app_code` 使用 `[a-z0-9_]{3,64}`。 +- 使用 SHA-256 保存完整令牌摘要,常量时间比较。 +- 创建、轮换返回 `TokenIssuedResponse(appCode, plainToken, expiresTime)`;普通详情和列表 DTO 永不包含明文或 hash。 +- 轮换后旧摘要在同一事务中失效,不做双令牌宽限期。 + +- [ ] **Step 4: 实现应用级限流** + +在认证成功、检索前使用现有 Redis 限流,key 固定含 `knowledge:app:{appId}`,速率读取 `rate_limit_per_minute`。超限抛出映射为 HTTP 429 的业务异常;Redis 异常按安全策略拒绝外部请求,不绕过限流。 + +- [ ] **Step 5: 运行安全测试和依赖检查** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ + -Dtest=AihrKnowledgeAppServiceTest test +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr dependency:tree \ + -Dincludes=org.dromara:ruoyi-common-redis +``` + +Expected: 测试 PASS;依赖来自本仓已有模块,无新第三方密钥库依赖。 + +- [ ] **Step 6: 提交 Task 3** + +```bash +git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge \ + backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAppServiceTest.java \ + backend/ruoyi-modules/ruoyi-aihr/pom.xml +git commit -m "feat(knowledge): add application tokens and rate limits" +``` + +--- + +### Task 4: 建立统一 query 服务并锁定 MySQL/Qdrant 检索范围 + +**Files:** +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrOpenKnowledgeController.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java` + +- [ ] **Step 1: 写查询边界失败测试** + +至少覆盖: + +```java +@Test void mysqlFulltextReceivesTenantAndAllowedKnowledgeIds(); +@Test void qdrantFilterContainsTenantAndKnowledgeIdMatchAny(); +@Test void emptyScopeStopsBeforeMysqlQdrantAndLlm(); +@Test void citationsOutsideEffectiveScopeAreRemovedAndLogged(); +@Test void noEvidenceDoesNotUseModelKnowledgeAsCompanyPolicy(); +@Test void legacySopSearchMapsToAuthorizedSopSpaceNotAllTenantSpaces(); +@Test void internalAndExternalControllersShareTheSameQueryService(); +``` + +源代码契约中额外断言新查询路径不通过空 `category` 表示全租户。 + +- [ ] **Step 2: 运行测试确认失败** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ + -Dtest=AihrKnowledgeQueryServiceTest,AihrSopSeedServiceTest test +``` + +Expected: 新测试 FAIL;现有测试保持可运行。 + +- [ ] **Step 3: 定义统一契约** + +`QueryRequest` 字段固定为: + +```java +String queryText; +List spaceCodes; +String category; +String position; +String source; +Integer limit; +String toolCode; +``` + +`QueryResponse` 至少包含: + +```java +String requestId; +String queryText; +String answer; +List citations; +List usedSpaceCodes; +boolean noEvidence; +String promptVersion; +LegacySearchPayload legacy; +``` + +请求校验:问题 1–1000 字,空间编码最多 20 个,limit 1–10。 + +- [ ] **Step 4: 改造现有 RAG 为显式空间集合** + +在 `AihrSopSeedService` 增加带 `Set allowedKnowledgeIds` 的受限检索入口,并让以下路径使用同一个范围: + +- MySQL Fulltext; +- 本地 embedding 兜底; +- Qdrant 查询; +- rerank 候选集; +- 引用返回前复核; +- 搜索评审和 answer feedback 关联结果。 + +Qdrant filter 使用 `tenant_id == currentTenant` 与 `knowledge_id match any allowedKnowledgeIds`。不支持 `match.any` 的内部 helper 必须补充实现,不能循环时遗漏 tenant filter。 + +- [ ] **Step 5: 实现两个认证入口、一个业务服务** + +- `POST /api/knowledge/query`:`@SaCheckLogin`,从内部会话解析 principal 与 SESSION app。 +- `POST /api/open/knowledge/query`:不加 `@SaCheckLogin`,显式读取 `Authorization: Bearer ...`,先认证 API_TOKEN app 再限流。 +- 两个 Controller 都只能调用 `AihrKnowledgeQueryService.query(context, request)`。 +- 外部入口拒绝 `toolCode`。 + +- [ ] **Step 6: 将旧 `/search` 改为兼容包装器** + +现有 `SearchRequest`、`SearchResponse` 保留给当前前端,但内部转成 `QueryRequest`。`category=sop` 映射到当前主体已授权且类型匹配的空间;没有授权返回无权限,不回退到租户全库或 seed 假成功。 + +- [ ] **Step 7: 写最小化查询日志** + +日志只保存 requestId、租户、应用、内部用户 ID、问题 SHA-256、使用空间、状态、耗时、模型和 prompt 版本;默认不写完整问题、答案和引用正文。 + +- [ ] **Step 8: 运行测试** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ + -Dtest=AihrKnowledgeQueryServiceTest,AihrSopSeedServiceTest,AihrTenantContextContractTest test +``` + +Expected: PASS;空范围时验证 mock 的 MySQL、Qdrant、LLM 均为零调用。 + +- [ ] **Step 9: 提交 Task 4** + +```bash +git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge \ + backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java \ + backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java \ + backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java \ + backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java \ + backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +git commit -m "feat(knowledge): add authorized unified query service" +``` + +--- + +### Task 5: 支持一份文件进入多个空间且不破坏原成员关系 + +**Files:** +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java` + +- [ ] **Step 1: 写复现现有破坏性逻辑的失败测试** + +当前 `reuseDuplicateAttach` 会更新原 attach 的 `knowledge_id` 并删除原空间 fragment。新增测试必须锁定正确行为: + +```java +@Test void sameFileInTwoSpacesCreatesTwoAttachMembershipsWithOneOssId(); +@Test void addingSecondSpaceNeverUpdatesFirstAttachKnowledgeId(); +@Test void addingSecondSpaceNeverDeletesFirstSpaceFragments(); +@Test void unbindingOneSpaceKeepsOtherMembershipAndOssObject(); +@Test void embeddingsAreGeneratedOnceAndPersistedForEveryTargetSpace(); +@Test void uploadRejectsAnyTargetSpaceWithoutManagePermission(); +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ + -Dtest=AihrSopSeedServiceTest,AihrUploadQueueServiceTest test +``` + +Expected: 至少“原空间不删除”和“双 attach”用例 FAIL。 + +- [ ] **Step 3: 扩展上传契约并保持旧 category 兼容** + +- 新请求参数:重复表单字段 `spaceCodes`,至少 1 个、最多 20 个。 +- 旧 `category` 仍可传;服务端只在未传 `spaceCodes` 时将其解析为一个授权空间。 +- 异步队列将稳定编码数组保存到 `space_codes_json`,重试沿用原始目标集合。 +- 入队前一次性校验当前主体对所有目标空间均有 `MANAGE` 权限;部分授权不执行部分导入。 + +- [ ] **Step 4: 将 attach 明确作为空间成员关系** + +重写 duplicate 处理: + +1. 通过内容 hash 或已存在文件找到可复用 `oss_id`。 +2. 对每个目标空间插入或更新该空间自己的 attach;不移动其他空间 attach。 +3. 仅清理目标空间内同名旧 fragment/Qdrant point。 +4. 解绑时统计 `oss_id` 引用数,只有归零才删除 OSS。 + +- [ ] **Step 5: 拆分一次生成、多次持久化的 embedding 流程** + +将现有单空间 `embedFragments(...)` 拆为等价职责: + +```java +EmbeddingBatch generateEmbeddings(List fragments); +void persistEmbeddings(long knowledgeId, String docId, EmbeddingBatch batch); +void upsertQdrant(long knowledgeId, String docId, EmbeddingBatch batch); +``` + +同一上传任务只执行一次 `generateEmbeddings`,再为每个目标空间写 fragment embedding 和携带各自 `knowledge_id` 的 Qdrant point。 + +- [ ] **Step 6: 运行测试和上传回归** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ + -Dtest=AihrSopSeedServiceTest,AihrUploadQueueServiceTest,AihrMultipartFilesTest test +``` + +Expected: PASS;已有单空间上传、图片/视频处理与异步重试不回退。 + +- [ ] **Step 7: 提交 Task 5** + +```bash +git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java \ + backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java \ + backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java \ + backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java \ + backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java \ + backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java +git commit -m "fix(knowledge): preserve multi-space document memberships" +``` + +--- + +### Task 6: 实现两个受控训练数据工具 + +**Files:** +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java` +- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java`(仅在需要时抽取现有只读 DTO,不改既有接口范围) + +- [ ] **Step 1: 写数据范围失败测试** + +```java +@Test void myPracticeSummaryAlwaysUsesCurrentAppUserExtPartyId(); +@Test void teamPracticeSummaryUsesResolvedSupervisorScope(); +@Test void employeeCannotCallTeamPracticeSummary(); +@Test void externalAppCannotCallAnyDataTool(); +@Test void unknownToolCodeIsRejectedWithoutDatabaseCall(); +@Test void requestCannotProvideTenantUserOrExtPartyId(); +@Test void toolFailureNeverFallsBackToSeedOrLlmGuess(); +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeDataToolServiceTest test +``` + +- [ ] **Step 3: 实现固定枚举分派** + +只允许: + +```java +MY_PRACTICE_SUMMARY -> mobileSeedService.practiceHistory(principal.extPartyId()) +TEAM_PRACTICE_SUMMARY -> mobileSeedService.practiceTeam(resolvedSupervisorExtPartyId) +``` + +不得接收表名、字段名、SQL、外部 ID 或任意工具 URL。结果包装为 `sourceType=DATA_TOOL` 的引用,并记录统计窗口和工具编码。 + +- [ ] **Step 4: 接入统一 query 服务** + +当 `toolCode` 非空时跳过文档 RAG;内部 SESSION app 才能进入工具分派。外部 API_TOKEN、未知工具、角色不符直接拒绝,不调用 LLM 做权限判断。 + +- [ ] **Step 5: 运行测试** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ + -Dtest=AihrKnowledgeDataToolServiceTest,AihrMobileSeedServiceTest,AihrPracticeSeedServiceTest test +``` + +Expected: PASS;现有移动端本人/团队接口权限测试继续通过。 + +- [ ] **Step 6: 提交 Task 6** + +```bash +git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java \ + backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java \ + backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java \ + backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java +git commit -m "feat(knowledge): add scoped training data tools" +``` + +--- + +### Task 7: 提供空间、授权和应用管理 API + +**Files:** +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeSpaceController.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeSpaceDto.java` +- Create: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java` + +- [ ] **Step 1: 写管理越权失败测试** + +覆盖: + +```java +@Test void listReturnsOnlyCurrentTenantSpaces(); +@Test void codeCannotChangeAfterSpaceCreation(); +@Test void cannotBindAppAndSpaceFromDifferentTenants(); +@Test void readPermissionDoesNotGrantManagePermission(); +@Test void tokenHashNeverAppearsInListOrDetailDto(); +@Test void disablingSpaceRemovesItFromEffectiveScopeImmediately(); +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeSpaceAdminServiceTest test +``` + +- [ ] **Step 3: 实现管理 API** + +统一前缀 `/api/knowledge/admin`: + +| 方法 | 路径 | 权限 | +|---|---|---| +| GET/POST | `/spaces` | 列表 / 创建 | +| PUT | `/spaces/{id}` | 编辑名称、类型、密级、状态;code 不可改 | +| GET/PUT | `/spaces/{id}/grants` | 查看 / 全量替换授权 | +| GET/POST | `/apps` | 列表 / 创建 | +| PUT | `/apps/{id}` | 编辑名称、状态、限流、到期时间 | +| PUT | `/apps/{id}/spaces` | 全量替换应用空间绑定 | +| POST | `/apps/{id}/rotate-token` | 轮换外部令牌,仅返回一次明文 | + +Controller 使用现有 super admin 或 `hr_operator` 管理角色作为首版管理入口;service 仍校验租户和 MANAGE 权限,不能只依赖菜单隐藏。 + +- [ ] **Step 4: 记录所有授权变化** + +创建、启停、密级变化、角色/用户授权、应用绑定和令牌轮换均记录操作人、租户、对象、前后状态摘要和时间,不记录 token 明文。 + +- [ ] **Step 5: 运行测试** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ + -Dtest=AihrKnowledgeSpaceAdminServiceTest,AihrKnowledgeAccessServiceTest,AihrKnowledgeAppServiceTest test +``` + +- [ ] **Step 6: 提交 Task 7** + +```bash +git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge \ + backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java +git commit -m "feat(knowledge): add space and application administration" +``` + +--- + +### Task 8: 建立管理端知识空间页面并改造上传入口 + +**Files:** +- Create: `frontend/src/api/aihr/space.ts` +- Create: `frontend/src/views/knowledge/spaces.vue` +- Create: `frontend/src/views/knowledge/spaces.test.ts` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/layout/components/Sidebar/index.vue` +- Modify: `frontend/src/views/knowledge/sop.vue` +- Modify: `frontend/src/views/knowledge/processing.vue` +- Modify: `frontend/src/api/aihr/sop.ts` + +- [ ] **Step 1: 写页面契约失败测试** + +使用 Vitest 读取源码,至少断言: + +- 路由存在 `/knowledge/spaces`,标题为“知识空间”。 +- 页面存在“知识空间 / 空间授权 / 调用应用”三个页签。 +- 普通应用列表不渲染 `tokenHash`。 +- 令牌创建/轮换弹窗明确“仅显示一次”。 +- SOP 和资料处理上传提交 `spaceCodes`,不再只依赖 category。 + +- [ ] **Step 2: 运行测试确认失败** + +```bash +npm --prefix frontend exec vitest run src/views/knowledge/spaces.test.ts +``` + +Expected: FAIL,页面和 API 尚不存在。 + +- [ ] **Step 3: 实现类型化 API** + +`space.ts` 封装 Task 7 的所有 API;应用列表类型不定义 `tokenHash`。创建和轮换响应单独定义 `IssuedToken`,明文只保存在当前弹窗状态,关闭后清空。 + +- [ ] **Step 4: 实现三个页签** + +- 空间:表格、创建/编辑、状态、密级、文档/片段数。 +- 授权:选择空间后配置 ROLE/USER 与 READ/MANAGE。 +- 应用:SESSION/API_TOKEN、空间多选、限流、到期时间、轮换和停用。 + +页面内所有错误展示服务端业务消息;不在 localStorage/sessionStorage 保存明文 token。 + +- [ ] **Step 5: 改造 SOP 和资料处理上传** + +- 上传对话框从单 category 改为空间多选。 +- 只显示当前用户拥有 MANAGE 的 ACTIVE 空间。 +- 新请求发送多个 `spaceCodes`;旧 category 只作为兼容回退,不由新 UI 发送。 +- 资料处理列表展示目标空间名称集合。 + +- [ ] **Step 6: 加入路由与受控侧栏** + +在 `frontend/src/router/index.ts` 增加静态路由,并将“知识空间”加入当前侧栏白名单;不恢复若依默认全量菜单。 + +- [ ] **Step 7: 运行前端测试、类型和构建** + +```bash +npm --prefix frontend exec vitest run src/views/knowledge/spaces.test.ts +npm --prefix frontend run build:prod +``` + +Expected: 测试和生产构建 PASS。 + +- [ ] **Step 8: 提交 Task 8** + +```bash +git add frontend/src/api/aihr/space.ts \ + frontend/src/views/knowledge/spaces.vue \ + frontend/src/views/knowledge/spaces.test.ts \ + frontend/src/router/index.ts \ + frontend/src/layout/components/Sidebar/index.vue \ + frontend/src/views/knowledge/sop.vue \ + frontend/src/views/knowledge/processing.vue \ + frontend/src/api/aihr/sop.ts +git commit -m "feat(admin): add knowledge space management" +``` + +--- + +### Task 9: 迁移内部 Web/H5 到统一 query,并增加训练快捷查询 + +**Files:** +- Modify: `mobile-uni/src/services/knowledge.ts` +- Modify: `mobile-uni/src/types/api.ts` +- Modify: `mobile-uni/src/pages/user/sop/index.vue` +- Modify: `mobile-uni/src/pages/user/today/index.vue` +- Modify: `mobile-uni/tests/mobile-user-pages.test.mjs` +- Modify: `frontend/src/api/aihr/sop.ts` +- Modify: `frontend/src/views/knowledge/sop.vue` +- Create: `scripts/tests/knowledge-query-client-contract.test.mjs` + +- [ ] **Step 1: 写客户端契约失败测试** + +断言: + +- 移动端和管理端都调用 `/api/knowledge/query`。 +- 请求中没有 `tenantId/userId/extPartyId/projectCodes/roles`。 +- UI 不提供能扩大权限的任意空间输入框。 +- 答案展示 `citations` 和 `noEvidence`。 +- 训练快捷入口只发送固定 `MY_PRACTICE_SUMMARY` 或 `TEAM_PRACTICE_SUMMARY`。 + +- [ ] **Step 2: 运行测试确认失败** + +```bash +node --test scripts/tests/knowledge-query-client-contract.test.mjs +npm --prefix mobile-uni run test:unit +``` + +Expected: 新接口断言 FAIL;现有移动端测试基线可见。 + +- [ ] **Step 3: 更新共享类型和移动端服务** + +`searchKnowledge` 改用 `/api/knowledge/query`,由服务端计算空间。页面展示引用所属空间,但不提供任意空间选择器。保留现有 snippet 兼容归一化,避免已上线答案卡回退。 + +- [ ] **Step 4: 增加固定训练快捷操作** + +- 普通员工显示“我的训练概况”,发送 `toolCode=MY_PRACTICE_SUMMARY`。 +- 主管端或主管身份显示“团队训练概况”,发送 `toolCode=TEAM_PRACTICE_SUMMARY`。 +- 不把用户输入自然语言直接转换为 toolCode。 +- 工具失败显示真实错误,不展示本地假训练数据。 + +- [ ] **Step 5: 更新管理端 SOP 搜索** + +管理端调用相同 `/api/knowledge/query`;可在已授权空间中多选做范围收窄。引用显示空间和标题,反馈继续带 request/review 关联信息。 + +- [ ] **Step 6: 运行客户端测试、类型检查和构建** + +```bash +node --test scripts/tests/knowledge-query-client-contract.test.mjs +npm --prefix mobile-uni run test:unit +npm --prefix mobile-uni run typecheck +npm --prefix mobile-uni run build:h5 +npm --prefix frontend run build:prod +``` + +Expected: 全部 PASS;员工端“问师傅”现有文字问答和总结卡不回退。 + +- [ ] **Step 7: 提交 Task 9** + +```bash +git add mobile-uni/src/services/knowledge.ts \ + mobile-uni/src/types/api.ts \ + mobile-uni/src/pages/user/sop/index.vue \ + mobile-uni/src/pages/user/today/index.vue \ + mobile-uni/tests/mobile-user-pages.test.mjs \ + frontend/src/api/aihr/sop.ts \ + frontend/src/views/knowledge/sop.vue \ + scripts/tests/knowledge-query-client-contract.test.mjs +git commit -m "feat(clients): use unified authorized knowledge query" +``` + +--- + +### Task 10: 初始化银城、美途空间、应用与默认授权 + +**Files:** +- Create: `scripts/provision-knowledge-platform.sh` +- Create: `scripts/tests/provision-knowledge-platform.test.sh` +- Create: `docs/KNOWLEDGE_PLATFORM_RUNBOOK.md` +- Modify: `docs/API_INTEGRATION.md` +- Modify: `docs/README.md` + +- [ ] **Step 1: 写幂等初始化脚本失败测试** + +脚本接口固定为: + +```bash +./scripts/provision-knowledge-platform.sh \ + --silver-tenant-id 000000 \ + --meitu-tenant-id 100001 \ + --dry-run +``` + +测试断言 dry-run 输出恰好包含四个 ACTIVE 空间、三个应用及预期绑定,不输出 SQL 密码或 API_TOKEN 明文;缺少任一租户参数时退出非零。 + +- [ ] **Step 2: 运行测试确认失败** + +```bash +bash scripts/tests/provision-knowledge-platform.test.sh +``` + +- [ ] **Step 3: 实现显式、幂等初始化** + +脚本通过参数接收实际租户 ID,先验证 `sys_tenant` 中存在且名称由操作者确认,再 upsert: + +**银城空间:** + +- `yc_public_policy` / 公共制度 / PUBLIC / INTERNAL / ACTIVE +- `yc_property_sop` / 物业业务 SOP / BUSINESS / INTERNAL / ACTIVE +- `yc_management_ops` / 管理运营 / MANAGEMENT / CONFIDENTIAL / ACTIVE + +**美途空间:** + +- `mt_customer_service` / 美途客户咨询 / EXTERNAL / PUBLIC / ACTIVE + +**应用:** + +- 银城 `yc_admin` / SESSION / `pc`,绑定三个银城空间。 +- 银城 `yc_mobile` / SESSION / `app`,绑定公共制度、物业业务 SOP、管理运营;主体授权决定最终交集。 +- 美途 `mt_card_miniapp` / API_TOKEN,绑定美途客户咨询;初始化时保持 `DISABLED`,联调前由管理端启用并生成令牌。 + +默认角色模板:employee 读公共制度和物业业务 SOP;supervisor 额外读管理运营;管理角色拥有相应 MANAGE。脚本不得创建跨租户绑定。 + +现有“投诉处理 SOP / 催缴沟通 SOP / 报修跟进 SOP”等细粒度 `aihr_knowledge_info` 不直接改名为新空间。初始化脚本核对 Task 1 已生成的租户内唯一 `legacy_*` 编码且不向新应用授权;业务确认源文件清单后,通过 Task 5 的多空间导入或 OSS 成员复用进入 `yc_property_sop`。新空间标准题验证通过后再将不再使用的 legacy 空间置为 `DISABLED`,回滚时可重新启用。 + +- [ ] **Step 4: 编写运行手册** + +`KNOWLEDGE_PLATFORM_RUNBOOK.md` 必须包含: + +- 如何确认两个生产租户 ID; +- dry-run、执行、结果核对和重复执行; +- 如何生成/轮换/停用美途令牌; +- 密钥只能进入小程序云函数或后端; +- 内容导入、空间解绑、孤立 OSS 核对; +- 现有细粒度 SOP 到 `yc_property_sop` 的清单映射、验证和 legacy 停用顺序; +- 查询日志与限流观察; +- 回滚优先停用 app,不删除知识数据。 + +- [ ] **Step 5: 运行脚本测试和本地 dry-run** + +```bash +bash scripts/tests/provision-knowledge-platform.test.sh +./scripts/provision-knowledge-platform.sh \ + --silver-tenant-id 000000 \ + --meitu-tenant-id 100001 \ + --dry-run +``` + +Expected: 测试 PASS;输出四空间、三应用和绑定差异,不执行写入。 + +- [ ] **Step 6: 提交 Task 10** + +```bash +git add scripts/provision-knowledge-platform.sh \ + scripts/tests/provision-knowledge-platform.test.sh \ + docs/KNOWLEDGE_PLATFORM_RUNBOOK.md \ + docs/API_INTEGRATION.md \ + docs/README.md +git commit -m "docs(knowledge): add tenant provisioning and runbook" +``` + +--- + +### Task 11: 完成真实 HTTP、安全矩阵和发布闸门验证 + +**Files:** +- Create: `scripts/verify-knowledge-platform.mjs` +- Create: `scripts/tests/knowledge-platform-security.test.mjs` +- Modify: `docs/KNOWLEDGE_PLATFORM_RUNBOOK.md` +- Modify: `docs/BRD_IMPLEMENTATION_AUDIT.md` + +- [x] **Step 1: 写真实接口验证器** + +脚本读取环境变量而不硬编码密钥: + +```text +AIHR_BASE_URL +AIHR_SILVER_ADMIN_TOKEN +AIHR_SILVER_EMPLOYEE_TOKEN +AIHR_SILVER_SUPERVISOR_TOKEN +AIHR_MEITU_APP_TOKEN +``` + +覆盖以下正例: + +1. 银城员工查询公共制度和物业业务 SOP。 +2. 银城主管查询管理运营。 +3. 员工查询本人训练概况。 +4. 主管查询团队训练概况。 +5. 美途应用查询客户咨询并获得美途空间引用。 +6. 同一文件在两个银城空间分别可检索,解绑一个后另一个仍可检索。 + +- [x] **Step 2: 写安全反例测试** + +至少覆盖: + +```text +银城 token + mt_customer_service -> 403 +美途 app token + yc_public_policy -> 403 +员工调用 TEAM_PRACTICE_SUMMARY -> 403 +美途 app 调用 MY_PRACTICE_SUMMARY -> 403 +禁用/过期/错误 app token -> 401 +超过 app rate_limit_per_minute -> 429 +请求体伪造 tenantId/userId/extPartyId -> 不影响服务端身份 +没有有效空间 -> MySQL/Qdrant/LLM 零调用 +``` + +- [x] **Step 3: 运行完整后端和前端回归** + +```bash +mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr test +npm --prefix frontend run build:prod +npm --prefix mobile-uni run test:unit +npm --prefix mobile-uni run typecheck +npm --prefix mobile-uni run build:h5 +git diff --check +``` + +Expected: 全部 PASS,无 whitespace error。 + +- [x] **Step 4: 启动本地服务并运行真实 HTTP 验证** + +Run backend with portless: + +```bash +portless run --name wygj-api ./scripts/dev-backend.sh +``` + +在另一个终端运行: + +```bash +AIHR_BASE_URL=https://wygj-api.localhost \ +AIHR_SILVER_ADMIN_TOKEN='' \ +AIHR_SILVER_EMPLOYEE_TOKEN='' \ +AIHR_SILVER_SUPERVISOR_TOKEN='' \ +AIHR_MEITU_APP_TOKEN='' \ +node scripts/verify-knowledge-platform.mjs +``` + +Expected: 正例全部 PASS,安全反例返回预期 401/403/429;日志中 requestId 可追踪,未打印令牌或完整问题。 + +- [x] **Step 5: 检查真实数据库与 Qdrant 隔离证据** + +```bash +docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue -e \ + "select tenant_id,code,status from aihr_knowledge_info order by tenant_id,code; \ + select tenant_id,app_code,auth_type,status from aihr_knowledge_app order by tenant_id,app_code; \ + select tenant_id,status,count(*) from aihr_knowledge_query_log group by tenant_id,status;" +curl -fsS http://127.0.0.1:6333/collections/aihr_knowledge | head -c 1000 +``` + +Expected: 两租户空间和应用归属正确;日志按租户可分;Qdrant collection 健康。再从服务测试日志确认查询 filter 同时包含 tenant 和 knowledge IDs。 + +- [x] **Step 6: 按四层成熟度更新审计文档** + +只在取得对应证据后分别标记: + +- 开发完成; +- 联调可用; +- 线上生效; +- 正式试点验收。 + +缺少业务负责人、内容清单、授权矩阵或标准题结果时,不标记正式试点验收。 + +- [ ] **Step 7: 生产发布与回滚验证(需生产变更授权,第一版工程交付不冒充线上生效)** + +发布前:备份数据库与 `/opt/wygj/www`,先执行 migration dry-run/结构核对,再发后端、管理端和 H5。管理端发布不得对 `/opt/wygj/www/` 根目录使用 `--delete`;移动端只对 `/opt/wygj/www/h5/` 使用既定 `--delete` 口径。 + +发布后:重跑真实 HTTP 正反例、健康检查和查询日志核对。回滚优先停用 `mt_card_miniapp` 和新 SESSION app,客户端临时回到兼容 `/search`;不删除新表、不搬回 attach、不清理 OSS。 + +- [ ] **Step 8: 提交 Task 11(待用户确认与既有未提交改动的提交边界)** + +```bash +git add scripts/verify-knowledge-platform.mjs \ + scripts/tests/knowledge-platform-security.test.mjs \ + docs/KNOWLEDGE_PLATFORM_RUNBOOK.md \ + docs/BRD_IMPLEMENTATION_AUDIT.md +git commit -m "test(knowledge): verify tenant and space isolation" +``` + +--- + +## 12. 最终完成清单 + +### 12.1 工程完成 + +- [x] schema 和 update migration 可重复执行,原知识数据无损。 +- [x] 内部/外部 query 共用服务,认证边界独立。 +- [x] 内部有效空间按 app 与 principal 交集计算。 +- [x] MySQL 和 Qdrant 都强制 tenant + knowledge IDs。 +- [x] 旧 `/search` 兼容但不再搜索租户全库。 +- [x] 同文件多空间不会移动或删除原成员关系。 +- [x] 两个数据工具只按服务端身份和范围执行。 +- [x] 管理端可以管理空间、授权、应用、令牌与限流。 +- [x] 移动端和管理端完成新契约迁移。 +- [x] 自动化测试、类型检查、构建、真实 HTTP 和数据库核对通过。 + +### 12.2 业务试点准入 + +- [ ] 银城三个空间和美途一个空间的负责人、维护人、审批人已确认。 +- [ ] 初始文件清单、密级、禁止内容和版本日期已确认。 +- [ ] 银城角色—应用—空间—项目范围矩阵已签字确认。 +- [ ] 美途令牌已放在可信后端/云函数,密钥保管人和轮换责任人明确。 +- [ ] 每空间至少 10 个标准问题完成真实引用验证。 +- [ ] 日调用量、限流值、日志保留周期和告警责任人已确认。 + +只有工程完成和业务准入两张清单都通过,才能进入正式试点验收。 diff --git a/docs/superpowers/specs/2026-07-16-multi-tenant-knowledge-platform-design.md b/docs/superpowers/specs/2026-07-16-multi-tenant-knowledge-platform-design.md new file mode 100644 index 00000000..ab5a793a --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-multi-tenant-knowledge-platform-design.md @@ -0,0 +1,556 @@ +# 多租户多知识空间统一问答平台需求与总体方案 + +> 版本:v1.0 +> +> 日期:2026-07-16 +> +> 需求来源:客户关于“多个独立知识库、多个调用端、数据安全与数据库查询”的讨论,以及《物业AI人力资源系统业务需求文档BRD》 +> +> 项目归属:《银城员工端 APP 分阶段实施总纲》阶段二的平台化能力 +> +> 实施边界:本方案不改变当前阶段一 AI 陪练验收口径,也不把方案评审或演示可用表述为正式试点验收完成。 + +## 1. 结论摘要 + +本次需求不是简单的“再建几个知识库”,而是把现有知识问答能力升级为一个可服务多个公司的多租户知识平台: + +1. **公司是最高隔离边界。** 银城、美途分别作为独立租户,账号、知识、应用、检索、日志和数据工具默认互不可见。 +2. **知识空间是租户内的内容与授权单元。** 同一租户可按业务或受众建立多个知识空间,例如公共制度、物业业务 SOP、管理运营、客户咨询。 +3. **调用应用与知识空间是多对多关系。** 一个应用可使用多个空间,一个空间也可供员工端、主管端、管理端等多个应用使用。 +4. **用户或角色与知识空间也是独立的多对多关系。** 对内部登录用户,最终可检索范围是“应用获授权空间”与“用户/角色获授权空间”的交集。 +5. **所有端复用同一套问答能力和响应契约。** 内部端和外部端因认证边界不同保留两个入口,但共用授权解析、检索、生成、引用和审计服务。 +6. **知识文件和实时业务数据分两条链路。** 文件进入 RAG 知识检索;员工训练记录等实时数据只能通过预定义、受控的数据工具读取,不允许模型生成或执行任意 SQL。 +7. **第一版不做跨租户共享。** 即使银城与美途存在集团或合作关系,也默认互相不可见;未来共享必须经过明确授权、脱敏、复制或发布流程。 +8. **同一原始文件可以属于多个知识空间。** OSS 原文件只保存一份,每个空间保留独立的成员关系、片段和检索权限;解绑某个空间不得影响其他空间。 + +## 2. 需求背景与讨论解读 + +客户原始表达包含四层诉求: + +- “再加几个独立库”:希望按公司、业务和受众划分可见内容。 +- “给别的系统提供接口”:希望名片小程序、员工端、主管端、管理端等多个入口复用问答能力。 +- “限定调用哪个库”:希望每个入口只能查询被授权的知识范围。 +- “不用担心数据安全、行业问题”:希望通过隔离、权限和受控数据访问降低泄露风险。 + +其中前三点已经形成明确产品模型,第四点需要更准确地表述:**多知识空间有助于安全,但空间拆分本身不等于安全完成。** 真正的安全控制需要同时覆盖租户、应用、用户/角色、空间、项目/组织范围、数据行列权限、接口认证和审计日志。 + +客户后续确认“知识空间跟可访问的接口是多对多关系”“好多端需要调用问答”,说明其已理解并确认多端复用和应用—空间多对多这一核心方向。以下细节属于本方案给出的工程化定案:双入口共享服务、授权取交集、外部应用密钥、数据工具白名单、日志最小化和同文件多空间成员模型。 + +### 2.1 当前确认状态 + +| 层级 | 已形成的结论 | 当前性质 | +|---|---|---| +| 租户 | 银城、美途分别作为独立租户 | 已确认 | +| 多端 | 名片小程序、员工端、主管端、管理端等多个端需要调用问答 | 已确认 | +| 应用授权 | 调用应用与知识空间建立多对多关系 | 已确认 | +| 跨公司 | 第一版不做跨租户共享 | 已确认 | +| 首批空间 | 银城 3 个、美途 1 个上线,其他空间有内容和负责人后再启用 | 本方案建议,纳入第一版基线 | +| 主体授权 | 内部范围取应用授权与用户/角色授权交集 | 为满足数据安全必须采用的工程规则 | +| 外部认证 | 外部端使用独立应用令牌、内部端沿用登录态,底层共用 query service | 本方案工程定案 | +| 数据查询 | 第一版只开放本人训练概况、团队训练概况两个固定工具 | 本方案建议,纳入第一版基线 | + +客户已确认的是产品方向,不等于空间内容清单、具体角色 key、负责人、外部密钥保管和标准题已经完成业务验收;这些仍按第 17 节作为正式试点准入材料。 + +## 3. 目标与非目标 + +### 3.1 业务目标 + +1. 支持银城、美途等多个公司使用同一平台,但数据与权限相互隔离。 +2. 支持每个租户按业务、受众和敏感级别建立多个知识空间。 +3. 支持多个应用统一调用问答能力,并为每个应用配置可访问空间。 +4. 支持内部员工按角色、人员、项目或组织范围取得不同知识权限。 +5. 支持答案带来源引用、无依据时明确提示,避免跨空间引用和无依据生成。 +6. 支持同一文件一次存储、授权到多个空间,降低重复上传和维护成本。 +7. 首版复用银城现有组织、训练与主管数据,提供两个安全的结构化数据查询工具。 +8. 提供空间、授权、应用和查询审计的管理能力。 + +### 3.2 第一版非目标 + +- 不做跨租户实时检索、集团账号天然读取子公司数据或跨租户联合统计。 +- 不做员工个人知识空间;个人 AI 助理继续按独立专项 TechSpec 和计划实施。 +- 不接工资、薪酬、银行账户、完整人事档案、财务明细等高敏数据。 +- 不接美途订单、客户交易流水或任意业务数据库。 +- 不允许自然语言生成 SQL、任意 SQL、任意表名或任意字段查询。 +- 不做知识空间之间的自动去重、自动合并或自动同步。 +- 不为每个租户单独部署一套应用、MySQL 或 Qdrant;首版采用共享基础设施上的逻辑隔离。 +- 不开放浏览器或小程序前端直连平台密钥。 +- 不把本方案的完成等同于生产发布或正式试点验收。 + +## 4. 概念模型 + +### 4.1 核心对象 + +| 对象 | 定义 | 第一版示例 | +|---|---|---| +| 租户 Tenant | 公司级最高安全、运营和审计边界 | 银城、美途 | +| 知识空间 Space | 租户内独立的内容集合和授权单元 | 公共制度、物业业务 SOP、美途客户咨询 | +| 调用应用 App | 调用统一问答能力的业务入口 | 员工端、主管端、管理端、名片小程序 | +| 访问主体 Principal | 被授予空间权限的人或角色 | employee、supervisor、finance、指定用户 | +| 文档成员 Attachment | 原始文档在某个空间中的成员关系 | 同一制度文件同时进入公共制度和管理运营 | +| 数据工具 Data Tool | 受控读取实时结构化数据的固定服务 | 我的训练概况、团队训练概况 | +| 查询日志 Query Log | 不保存敏感正文的调用审计记录 | 应用、用户、空间、耗时、结果状态 | + +### 4.2 边界层级 + +```mermaid +flowchart TD + T["租户:公司级隔离"] --> A["调用应用授权"] + T --> P["用户/角色授权"] + T --> S["知识空间"] + A --> X["应用允许的空间集合"] + P --> Y["主体允许的空间集合"] + X --> I["内部最终范围:X 与 Y 的交集"] + Y --> I + S --> I + I --> R["按 tenant_id + knowledge_id 检索"] + R --> O["带引用答案"] +``` + +租户内的项目、部门、岗位不是新租户,而是组织或数据范围。知识空间负责内容边界,项目/部门范围负责回答“谁能看哪些项目数据”;两者不能互相替代。 + +### 4.3 授权计算规则 + +内部登录用户: + +```text +effectiveSpaceIds + = tenantSpaceIds + ∩ appGrantedSpaceIds + ∩ principalGrantedSpaceIds +``` + +外部匿名应用: + +```text +effectiveSpaceIds + = tenantSpaceIds + ∩ appGrantedSpaceIds +``` + +补充规则: + +1. 客户端传入 `spaceCodes` 只能缩小服务端算出的范围,不能扩大范围。 +2. 明确请求了未授权空间时返回 403,不静默扩大或替换为其他空间。 +3. 没有任何有效空间时不执行 MySQL、Qdrant 或 LLM 查询,直接返回无权限。 +4. 所有 MySQL 和 Qdrant 查询必须同时带 `tenant_id` 与有效 `knowledge_id` 集合。 +5. 管理员拥有“配置空间”的权限,不代表天然拥有“读取全部空间正文”的权限;读取仍按显式授权。 + +## 5. 租户方案与安全考虑 + +### 5.1 租户划分 + +| 租户 | 定位 | 默认策略 | +|---|---|---| +| 银城 | 物业员工、主管、HR、财务和管理层使用的企业内部知识与业务数据 | 只允许银城身份和银城应用访问 | +| 美途 | 客户咨询和后续内部业务知识 | 只允许美途身份和美途应用访问 | + +银城、美途即使由同一集团管理,也保持独立租户,原因如下: + +1. **法人和责任边界不同。** 两家公司对员工信息、客户信息和经营资料承担不同的数据管理责任。 +2. **最小权限。** 集团管理关系不能自动推导出每个账号有权读取所有子公司原文。 +3. **降低事故影响面。** 一个租户的密钥泄漏、错误授权或导入失误不能扩大到另一个租户。 +4. **便于审计追责。** 每次访问可明确归属到哪个公司、哪个应用和哪个主体。 +5. **便于独立运营。** 两家公司可分别配置知识负责人、密钥轮换、限流、下线和数据保留策略。 +6. **适应组织变化。** 后续新增公司、独立交付、合同终止或组织调整时,不需要拆分已混合的数据。 + +第一版跨租户访问固定为默认拒绝。未来如果出现集团共享需求,推荐使用“发布脱敏副本到目标租户”的方式,并保留来源、审批人、版本和撤回记录;不推荐直接让一个租户在线检索另一个租户的原始空间。 + +## 6. 第一批知识空间建设建议 + +### 6.1 银城首批上线空间 + +| 空间名称 | 建议编码 | 内容范围 | 初始使用者/应用 | 第一批状态 | +|---|---|---|---|---| +| 公共制度 | `yc_public_policy` | 公司制度、员工应知、员工权益、行政通知、公开流程 | 员工、主管、HR;员工端和管理端 | 必建、上线 | +| 物业业务 SOP | `yc_property_sop` | 管家、客服、保洁、保安、工程、收费等岗位流程与标准 | 员工、主管、培训/品质;员工端、主管端、管理端 | 必建、上线 | +| 管理运营 | `yc_management_ops` | 项目管理、巡检、培训、品质、复盘、团队管理方法 | 主管、项目经理、HR/培训/品质;主管端和管理端 | 必建、上线 | +| 财务专业 | `yc_finance_professional` | 财务制度、预算规则、报销和结算规范 | 财务角色;管理端 | 有真实内容和负责人后启用 | +| 经营决策 | `yc_business_decision` | 经营分析口径、管理报告、决策参考 | 高管及明确授权人员;管理端 | 有脱敏规则和负责人后启用 | + +首批业务验收以前三个上线空间为准。财务专业、经营决策不是把同一批普通制度换个名字重复入库;只有在内容清单、负责人、密级和角色授权同时明确后才启用。 + +### 6.2 美途首批上线空间 + +| 空间名称 | 建议编码 | 内容范围 | 初始使用者/应用 | 第一批状态 | +|---|---|---|---|---| +| 美途客户咨询 | `mt_customer_service` | 服务介绍、常见问题、业务流程、公开价格口径、客户沟通话术 | 名片小程序;后续客服工作台 | 必建、上线 | +| 美途内部工作 | `mt_internal_work` | 内部流程、员工工作指引 | 美途内部员工应用 | 第一版不建 | +| 美途管理 | `mt_management` | 经营和团队管理资料 | 主管/管理层 | 第一版不建 | +| 美途财务 | `mt_finance` | 财务制度和内部财务资料 | 财务角色 | 第一版不建 | + +美途客户咨询空间只能放可对外回答的内容。内部制度、客户隐私、合同原件、交易明细不得通过“同一问题看起来相近”而混入该空间。 + +### 6.3 角色与空间建议 + +| 主体 | 默认可读空间 | 附加限制 | +|---|---|---| +| 银城普通员工 | 公共制度 + 与岗位相关的物业业务 SOP | 项目相关内容按本人项目范围过滤 | +| 银城主管/项目经理 | 公共制度 + 物业业务 SOP + 管理运营 | 团队数据限本人管理范围 | +| HR/培训/品质 | 公共制度 + 物业业务 SOP + 管理运营 | 仅限职能授权范围,不能天然读取财务或经营原文 | +| 财务 | 公共制度 + 财务专业 | 财务空间启用后显式授予 | +| 高管 | 公共制度 + 管理运营 + 经营决策 | 默认看汇总;原始明细仍需单独授权 | +| 美途名片小程序 | 美途客户咨询 | 外部匿名应用,无用户角色扩权能力 | + +这些是默认授权模板,不代替业务负责人对具体人员、岗位和项目范围的确认。 + +### 6.4 内容治理要求 + +每个上线空间必须具备: + +- 一名业务负责人和一名内容维护人; +- 明确的内容准入范围与禁止内容; +- 初始文件清单和版本日期; +- 密级:`PUBLIC`、`INTERNAL`、`CONFIDENTIAL` 三选一; +- 适用角色、应用和项目范围; +- 更新周期和失效内容处理规则; +- 至少 10 个标准问题及预期引用,用于上线前验证。 + +## 7. 统一问答能力 + +### 7.1 入口与复用方式 + +| 场景 | API | 认证 | 说明 | +|---|---|---|---| +| 内部员工端、主管端、管理端 | `POST /api/knowledge/query` | 现有 Sa-Token 登录态 | 解析租户、用户、角色、`clientKey` 和项目范围 | +| 美途名片小程序等外部应用 | `POST /api/open/knowledge/query` | Bearer 应用令牌 | 只使用应用授权空间,不接受用户或角色扩权 | + +两个入口共用 `AihrKnowledgeQueryService`,因此“统一接口”的准确含义是:统一请求/响应契约和统一业务能力,而不是强行让内部用户登录令牌与外部应用密钥共用一个认证入口。 + +### 7.2 请求契约 + +```json +{ + "queryText": "装修施工人员进入小区需要什么手续?", + "spaceCodes": ["yc_property_sop"], + "category": "sop", + "position": "生活顾问", + "source": "mobile_uni_sop", + "limit": 5, + "toolCode": null +} +``` + +约束: + +- `queryText` 必填,去首尾空白后 1–1000 字。 +- `spaceCodes` 可不传;不传表示在有效授权范围内检索,传入表示进一步收窄。 +- `limit` 默认 5,范围 1–10。 +- `toolCode` 为空时执行知识问答;传入时只允许第一版白名单工具。 +- `category/position/source` 保留兼容和分析用途,不能决定或扩大授权空间。 + +### 7.3 响应契约 + +```json +{ + "requestId": "01JZZ...", + "queryText": "装修施工人员进入小区需要什么手续?", + "answer": "根据《装修人员进场管理 SOP》……", + "citations": [ + { + "spaceCode": "yc_property_sop", + "sourceType": "DOCUMENT", + "docId": "doc_123", + "title": "装修人员进场管理 SOP", + "snippet": "施工人员进场前应完成……", + "fragmentId": 456 + } + ], + "usedSpaceCodes": ["yc_property_sop"], + "noEvidence": false, + "promptVersion": "knowledge-query-v1", + "legacy": { + "reference": "装修人员进场管理 SOP", + "docs": [], + "snippets": [] + } +} +``` + +响应要求: + +1. 每条文档结论必须能定位到被授权空间的引用。 +2. 没有足够依据时返回 `noEvidence=true` 和明确提示,不用其他租户、其他空间或模型常识补齐公司规则。 +3. 数据工具结果的 `sourceType` 为 `DATA_TOOL`,引用工具编码和统计窗口,不伪装成文档片段。 +4. `requestId` 贯穿认证、授权、检索、LLM 和审计日志。 + +### 7.4 查询处理流程 + +```text +认证内部会话或外部应用令牌 + → 锁定 tenantId 与 appId + → 内部用户解析角色、组织、项目范围 + → 计算有效知识空间集合 + → 校验请求是否仅收窄范围 + → 知识问答:MySQL Fulltext + Qdrant 混合召回 + → 数据工具:调用固定 service,并执行本人/团队范围检查 + → Rerank(可用时) + → LLM 仅使用已授权证据生成答案 + → 返回引用与无依据标记 + → 写入最小化查询日志 +``` + +### 7.5 现有接口兼容 + +现有 `POST /api/knowledge/search` 暂保留,作为兼容包装器转发到新查询服务。必须修正现有 `category=sop` 可能变成租户内全库检索的行为:兼容请求只能映射到当前主体已获授权的银城物业业务 SOP 等空间,不能将空分类当作“搜索全部空间”。 + +## 8. 实时数据库查询方案 + +### 8.1 为什么不能把数据库直接当知识库 + +文档知识适合切片、向量检索和引用;训练记录、人员状态等实时结构化数据具有强身份、行权限和时效要求。如果让模型直接访问数据库或生成 SQL,空间授权无法阻止它读取错误的表、行或字段,也无法稳定审计查询目的。 + +因此第一版采用“数据工具”模式:每个工具有固定编码、固定参数、固定 service、固定权限和固定返回字段。 + +### 8.2 第一版数据工具 + +| 工具编码 | 用途 | 数据来源 | 权限 | +|---|---|---|---| +| `MY_PRACTICE_SUMMARY` | 查询本人指定窗口内的训练次数、最近训练和能力摘要 | 复用 `AihrMobileSeedService.practiceHistory(extPartyId)` | 仅本人;服务端从登录态取身份 | +| `TEAM_PRACTICE_SUMMARY` | 查询本人管理团队的完训、待复盘和汇总 | 复用 `AihrMobileSeedService.practiceTeam(supervisorExtPartyId)` | 仅主管;服务端解析主管范围 | + +第一版由客户端快捷入口显式传 `toolCode`。不从任意自然语言自动决定并执行数据库查询,不接工资、财务、美途订单或其他任意表。 + +### 8.3 工具安全规则 + +1. 请求体不接受 `tenantId`、`userId`、`extPartyId`、`supervisorExtPartyId`。 +2. 身份和数据范围只从服务端登录态与组织快照取得。 +3. 工具返回 DTO,不返回数据库字段全集、SQL、堆栈或内部主键集合。 +4. 外部 API_TOKEN 应用第一版不得调用任何数据工具。 +5. 工具失败不回退到模型猜测或 seed 假数据。 +6. 审计日志记录工具编码、窗口和结果状态,不记录个人训练原文。 + +## 9. 数据模型 + +### 9.1 复用 `aihr_knowledge_info` 作为知识空间 + +在现有表上新增: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | varchar(100) | 租户内稳定编码,`UNIQUE(tenant_id, code)` | +| `space_type` | varchar(30) | `BUSINESS` / `MANAGEMENT` / `PUBLIC` / `EXTERNAL` | +| `sensitivity_level` | varchar(30) | `PUBLIC` / `INTERNAL` / `CONFIDENTIAL` | +| `status` | varchar(20) | `DRAFT` / `ACTIVE` / `DISABLED` | + +继续复用原有 `tenant_id`、`name`、设置与片段关系。客户端和授权表使用稳定 `code`,不再以可修改的中文名称作为接口主键。 + +### 9.2 `aihr_knowledge_space_grant` + +| 字段 | 说明 | +|---|---| +| `id/tenant_id/knowledge_id` | 主键、租户和空间 | +| `principal_type` | `ROLE` 或 `USER` | +| `principal_value` | 角色 key 或用户 ID 字符串 | +| `permission` | `READ` 或 `MANAGE` | +| `status` | `ACTIVE` / `DISABLED` | +| `create_by/create_time/update_by/update_time` | 审计字段 | + +唯一约束:`(tenant_id, knowledge_id, principal_type, principal_value, permission)`。 + +### 9.3 `aihr_knowledge_app` + +| 字段 | 说明 | +|---|---| +| `id/tenant_id` | 应用主键和所属租户 | +| `app_code` | 全局唯一、稳定编码 | +| `app_name` | 应用名称 | +| `auth_type` | `SESSION` 或 `API_TOKEN` | +| `internal_client_key` | 内部端对应现有 `LoginUser.clientKey`,外部端为空 | +| `token_hash` | 外部应用完整令牌的 SHA-256,仅保存摘要 | +| `status` | `ACTIVE` / `DISABLED` | +| `rate_limit_per_minute` | 应用级动态限流 | +| `expires_time` | 令牌到期时间,可空 | +| 审计字段 | 创建、更新、轮换人员和时间 | + +应用令牌建议格式为 `ak__<32字节随机值>`。明文仅在创建或轮换成功后显示一次,服务端只保存完整令牌 SHA-256,并使用常量时间比较。令牌只能保存在美途小程序云函数或业务后端,不能写进小程序包或浏览器 JavaScript。 + +### 9.4 `aihr_knowledge_app_space` + +字段:`id/tenant_id/app_id/knowledge_id/create_by/create_time`。唯一约束:`(tenant_id, app_id, knowledge_id)`。应用和空间必须属于同一租户,服务端在写入时双重校验。 + +### 9.5 `aihr_knowledge_query_log` + +保存:`request_id`、`tenant_id`、`app_id`、内部用户 ID(可空)、问题摘要哈希、使用空间编码、来源类型、结果状态、耗时、模型和 prompt 版本、创建时间。 + +默认不保存完整问题、完整答案、训练明细和文档正文。业务若以后要求质检原文,必须另行确定脱敏、权限和保存期限,不能直接扩大本表。 + +### 9.6 同一文件属于多个空间 + +第一版不新增抽象的文档—空间关系表,直接复用 `aihr_knowledge_attach` 作为空间成员关系: + +- 同一 OSS 原文件可以对应多条 attach 记录;每条记录属于一个 `knowledge_id`。 +- 同一空间内同名约束继续由 `(knowledge_id, name)` 保证。 +- 不同空间的 attach 可复用相同 `oss_id`,但使用各自 `doc_id`/片段/权限。 +- 解除某空间成员关系时,仅删除该空间的 attach、fragment 和 Qdrant points。 +- 只有不存在其他 attach 引用同一 `oss_id` 时,才允许删除原始 OSS 对象。 +- 脱敏、改写或裁剪后的版本视为新文件、新 hash 和新版本,不能假装与原文件完全相同。 + +异步上传队列增加 `space_codes_json`,支持一次上传选择多个目标空间。解析和 embedding 只调用一次,生成结果再分别持久化到各空间,避免重复付费调用模型。 + +## 10. 应用、角色与身份解析 + +### 10.1 内部应用 + +现有 `sys_client` 和 `LoginUser.clientKey` 用于识别内部调用端。新增 `aihr_knowledge_app` 保存与知识平台有关的应用策略,例如: + +| app_code | auth_type | internal_client_key | 场景 | +|---|---|---|---| +| `yc_admin` | SESSION | `pc` | 银城管理端 | +| `yc_mobile` | SESSION | `app` | 银城员工/主管端 | +| `mt_card_miniapp` | API_TOKEN | 空 | 美途名片小程序后端 | + +同一 `clientKey` 若在不同租户使用,按 `tenant_id + internal_client_key` 解析,不能跨租户命中应用。 + +### 10.2 内部主体 + +- `SYS_USER`:从 `LoginUser.rolePermission` 解析角色授权。 +- `APP_USER`:从组织快照和岗位级别解析业务角色;普通人员至少映射 `employee`,主管/项目负责人映射 `supervisor`。 +- 用户级授权:按服务端登录用户 ID 叠加,但仍受应用授权限制。 +- 项目范围:继续复用组织同步数据,不接受客户端自报项目编码扩权。 + +## 11. 检索与向量隔离 + +### 11.1 MySQL Fulltext + +所有召回 SQL 必须包含: + +```sql +WHERE f.tenant_id = :tenantId + AND f.knowledge_id IN (:effectiveKnowledgeIds) +``` + +禁止继续只用知识库中文名称或空分类控制范围。 + +### 11.2 Qdrant + +第一版继续使用共享 collection `aihr_knowledge`,payload 已包含 `tenant_id` 和 `knowledge_id`。查询过滤固定为: + +```text +must: + tenant_id == currentTenantId + knowledge_id match any effectiveKnowledgeIds +``` + +如果 Qdrant 客户端尚不支持 `match.any`,先实现该过滤结构;不得为了省事去掉 `knowledge_id` 条件。应用请求不触发跨 collection 或跨租户合并。 + +### 11.3 生成约束 + +- LLM 输入仅包含已授权、重排后的证据。 +- system prompt 明确公司制度类问题不得依靠模型常识补齐。 +- 引用结果在返回前再次核对 `tenant_id + knowledge_id`。 +- 外部应用使用专用 prompt,禁止输出内部操作说明、内部人员信息或“可能存在”的内部内容。 + +## 12. 管理端方案 + +新增管理页面 `/knowledge/spaces`,菜单名“知识空间”,包含三个页签: + +1. **知识空间**:查看、创建、编辑、启停;显示租户、编码、密级、文档数、片段数、负责人和状态。 +2. **空间授权**:按角色或指定用户配置 READ/MANAGE,展示实际生效范围。 +3. **调用应用**:创建内部/外部应用、绑定空间、配置限流、轮换或停用令牌;明文令牌只显示一次。 + +现有 SOP 知识库页和资料处理页改为以空间为目标: + +- 搜索时使用服务端授权范围,可选择授权内空间做收窄。 +- 同步单文件上传可选择一个或多个空间。 +- 异步批量上传记录保存目标空间集合。 +- 管理端不得仅靠隐藏下拉选项实现权限;服务端必须重新校验 MANAGE 权限。 + +移动端不暴露任意空间选择器,避免用户误认为“看见空间名称就有权限”。页面继续以“问师傅”等业务语言呈现,空间选择由服务端按应用、角色和岗位自动计算。 + +## 13. 外部应用安全 + +1. 外部接口必须使用 HTTPS。 +2. Bearer 令牌只能由服务端、云函数或可信后端持有。 +3. 每个应用独立令牌、独立授权空间、独立限流和独立停用开关。 +4. 使用现有 Redis 限流能力按 `appId` 控制每分钟请求数;超限返回 429。 +5. 认证失败统一返回 401,不泄露 appCode 是否存在、令牌是否过期等内部细节。 +6. 停用或到期应用不得检索、调用 LLM 或数据工具。 +7. 轮换令牌生成新摘要并使旧令牌立即失效;第一版不保留双令牌宽限期。 +8. 外部响应不返回 OSS 原始地址、内部用户 ID、数据库主键或管理字段。 +9. 对外客户咨询设置长度限制、超时、并发、敏感词和输出脱敏。 + +## 14. 审计、监控与运维 + +### 14.1 必备指标 + +- 按租户、应用统计请求量、成功率、无依据率、P50/P95 延迟。 +- 按知识空间统计命中量、零命中问题和引用分布。 +- 按错误类型统计认证失败、无权限、限流、检索失败、模型失败。 +- 跟踪外部应用令牌到期时间和最近使用时间。 +- 跟踪同一文件多空间成员数和孤立 OSS 数量。 + +### 14.2 安全审计事件 + +- 应用创建、令牌创建/轮换/停用; +- 空间创建、启停、密级变化; +- 应用—空间和主体—空间授权变化; +- 明确请求未授权空间; +- 数据工具调用和拒绝; +- 跨租户 ID 组合、无有效空间仍尝试查询等异常。 + +## 15. 验收标准 + +### 15.1 功能验收 + +1. 银城、美途各自登录或调用时,只能列出本租户空间和应用。 +2. 银城首批三个空间、美途客户咨询空间均有唯一编码、负责人、授权和验证题单。 +3. 一个内部应用可绑定多个空间;一个空间可同时绑定多个内部应用。 +4. 内部用户的最终范围严格等于应用授权与主体授权交集。 +5. 美途名片小程序只能命中 `mt_customer_service`,不能访问银城或美途内部预留空间。 +6. 显式传未授权 `spaceCodes` 返回 403;不传时只在有效空间内查询。 +7. 回答包含空间编码和文档引用;无证据时返回 `noEvidence=true`。 +8. 同一文件加入两个空间后,两边均能独立检索;从一个空间解绑不影响另一个空间和 OSS 原文件。 +9. 本人训练工具不能查询他人;主管工具只能返回本人管理范围;外部应用调用工具被拒绝。 +10. 原 `/api/knowledge/search` 兼容现有员工端,但不再因 `category=sop` 搜索租户全部空间。 + +### 15.2 安全验收 + +至少覆盖以下自动化和真实 API 反例: + +| 测试 | 预期 | +|---|---| +| 银城 token + 美途 spaceCode | 403,MySQL/Qdrant/LLM 均未执行 | +| 美途 app token + 银城 spaceCode | 403 | +| 员工端 app 已授权、用户角色未授权 | 403 或空有效范围 | +| 用户已授权、当前 app 未授权 | 403 或空有效范围 | +| 禁用/过期/错误 app token | 401 | +| 超过应用限流 | 429 | +| APP_USER 请求其他 extPartyId | 服务端忽略请求值并按本人身份处理 | +| 普通员工调用团队训练工具 | 403 | +| 外部应用调用任一数据工具 | 403 | +| 删除一个空间的文件成员关系 | 其他空间仍可检索,OSS 不被删除 | + +### 15.3 交付成熟度 + +验收结论必须分开记录: + +- **开发完成**:代码、迁移、自动化测试和构建通过。 +- **联调可用**:银城内部端与美途测试应用完成真实 HTTP 联调。 +- **线上生效**:生产迁移、配置、部署、健康检查和回归完成。 +- **正式试点验收**:业务负责人确认内容清单、授权矩阵、标准题命中、日志和运营指标。 + +任一前置层完成都不能替代后续层。 + +## 16. 发布与迁移策略 + +1. 先上线 schema 与后端兼容层,默认不开启任何外部应用。 +2. 为现有细粒度 SOP 知识库补齐 `legacy_*` 稳定编码,先保留为回滚基线,不直接把“投诉处理 SOP”等旧库改名冒充新的“物业业务 SOP”空间。 +3. 创建银城三个空间和美途客户咨询空间,按业务确认后的源文件清单重新导入或复用 OSS 成员关系;迁移前后核对 attach、fragment 和 Qdrant payload 数量。新空间验证通过前保持调用应用禁用,验证通过后再停用不再使用的 legacy 空间。 +4. 配置内部应用、角色授权并做反向越权测试。 +5. 切换管理端和移动端到新 `/query`,原 `/search` 保持兼容。 +6. 为美途创建测试 API_TOKEN,在云函数/后端保存,完成限流和错误令牌测试。 +7. 观察无依据率、跨空间拒绝、模型错误和 P95 延迟后,再启用生产外部应用。 +8. 发布失败时先停用对应 `aihr_knowledge_app`,内部兼容接口可继续服务;数据库迁移只做前向修复,不破坏已有文档和片段。 + +## 17. 业务上线前必须提交的材料 + +以下不是系统设计缺口,而是正式试点必须由业务侧提交的准入材料: + +1. 四个首批上线空间的负责人、维护人和审批人名单。 +2. 每个空间的初始文件清单、版本日期、密级和禁止内容清单。 +3. 银城角色—空间—应用授权矩阵,并确认主管、HR/培训/品质、高管的具体角色 key。 +4. 美途名片小程序的可信服务端或云函数部署位置、密钥保管人和轮换责任人。 +5. 每个空间至少 10 个标准问题、预期答案要点和预期引用。 +6. 查询日志保留周期、外部接口日调用量预估和每分钟限流值。 + +在这些材料缺失时,可以完成开发和联调,但不能宣布正式试点验收完成。 diff --git a/docs/下一步工作计划-BRD对齐-20260715.md b/docs/下一步工作计划-BRD对齐-20260715.md new file mode 100644 index 00000000..1ac12747 --- /dev/null +++ b/docs/下一步工作计划-BRD对齐-20260715.md @@ -0,0 +1,128 @@ +# 下一步工作计划(BRD 对齐版) + +> 版本:v2.1(2026-07-15 二次修订:发布前只跑本地预检、remote match+schema 移到发布后;案例底线补齐算术;CER 阈值归 B4 确认;B7 按生产实测字段改写) +> 基准:《物业AI人力资源系统业务需求文档BRD.md》v1.2(唯一事实源) +> 阶段边界:《银城员工端APP分阶段实施总纲.md》阶段一(陪练)+ 2026-07-08《数字师傅学练问报整合方案》定案 +> 现状证据:《BRD_IMPLEMENTATION_AUDIT.md》2026-07-15 最新条目 + 本日生产只读复核 +> 时间锚点:半年会产品发布(须带一线真实使用数据)→ 2026-09 收费季前试点可用 + +--- + +## 0. 当前基线(2026-07-15 生产实测) + +**已完成**(不再列入待办): +- 生产已发布至 `eacf1a0a` 批次并有后续静态增量,线上 H5 = `index-rPeDLbpR.js`;发布预检通过资源/后端哈希与健康检查。 +- 生产 schema **16/16 齐备**,内置 Prompt **6/6**,生活顾问启用场景 **14 条**(含服务推介/增值 1、日常服务 1)。 +- **组织同步已线上闭环**:开放平台拉取公司 17 / 部门 963 / 员工 3417;`aihr_org_snapshot` 3417 行,手机号可映射 **3392**,在职 2943。主管身份与项目数据权限的数据基础已就位。 + +**真实缺口**(本计划的全部工作对象): +- 正式试点证据全部为 0:评分校准 0/20、SOP 人工评审 0/14、案例 0/20、满意度 0、候选资料 0(L1-B/C/D)。 +- SOP 文档仅 3/5,缺服务推介/增值与日常服务两类的真实业务内容确认(4.3.4、L1-B)。 +- 方言 ASR 零实测(9.1 MVP 阻塞项、L1-C ≤5s)。 +- 仓库有 17 个已跟踪修改 + 2 个未跟踪文件未收拢,发布预检拒绝任何脏工作区,当前修复批次无法发布。 +- 25 名员工手机号不可用(上游数据问题,不阻塞,需反馈开放平台)。 +- PII 出境条款、成本计量契约、HR 学分规则等外部输入未闭合(G3/G6、4.5)。 + +**结论:最大阻塞已从"发布与组织数据"转移到"试点圈定(B3)与内容/评审样本"。** 所有新增页面/功能默认冻结(守 MVP 切割线,BRD 附录 C 头号风险 = 范围失控)。 + +--- + +## 1. 主线 A:锁定发布批次,原子发布(本周内) + +目标:把 `eacf1a0a` 之后累积的修复(G3 脱敏补强、案例音频收紧、岗位-SOP 收口等)作为一个命名 release batch 发布,线上 = release commit。 + +| # | 任务 | 依据 | 完成定义 | 期限 | +|---|---|---|---|---| +| A1 | 收拢工作区:对当前 17 个已跟踪修改按内容分批提交(后端服务/测试、文档、前端各一批),2 个未跟踪文件(本计划、`processing-import-ui.test.sh`)一并提交或显式移出;形成命名 release commit | `release-preflight.sh` 拒绝任何脏工作区 | `git status --porcelain` 为空;release commit 落字 | 07-16 | +| A2 | 确定发布窗口 + 备份/回滚责任人 | 12.1、Runbook 前置条件 1 | 窗口时间、执行人、回滚人三项落字 | 07-16 | +| A3 | **一次原子切换**:锁定提交 → 干净 worktree 构建三端 → **本地预检**(`release-preflight.sh` 不带 remote 开关;发布前线上还是旧版本,remote match 必然失败)→ DB/静态资源/后端备份 → 同步静态资源与后端、重启。**当前批次未包含 SQL 迁移;若收拢后出现新迁移,从本批次拆出并按 Runbook 单独执行** | 6.3、L1-C、preflight 脚本 71–72 行开关耦合 | 本地预检通过 → 发布完成;任一步失败按备份回滚,当天不重试第二次 | 07-17 | +| A4 | **发布后远端复检与回归**:`RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh`(此时线上=本地构建,match 可通过)+ 认证态浏览器回归(员工/主管/管理端各 1 条路径,正式 OTP)+ 非 strict `./scripts/demo-check.sh` 输出数据现状(**strict 门禁留到 B3 窗口确定后**,strict 模式强制要求 `AIHR_PILOT_START_DATE/END_DATE`) | 10.2、`demo-check.sh` 参数约束 | 远端静态资源、后端 hash 与 schema 复检通过;三端认证态回归留证;数据缺口清单留档审计记录 | 07-17 | + +--- + +## 2. 主线 B:外部输入催办清单(今天发出,B3 为最大阻塞) + +每项需在发出时填负责人与回复期限;到兜底触发日仍无答复即升级 HR 负责人(BRD 第 13 章"集成负责人指派"至今未闭合)。 + +| # | 需要的输入 | 提供方 | 负责人 | 回复期限 | 兜底触发日 | 阻塞的 BRD 条款 | 不给的后果 | +|---|---|---|---|---|---|---|---| +| B3 | **试点项目圈定(1–2 个住宅项目)+ 窗口起止日期 + ≥20 名员工名单**(组织数据已就位,可直接从 3392 条手机号映射中圈人) | HR 负责人 | 待指派 | 07-18 | 07-22 | 10.2-D | M5 全部指标无分母;半年会没有"真实使用数据" | +| B2 | 补 2 份真实 SOP 文档(现 3/5),并由业务确认全部五类核心流程的成功条件、价格/取消规则、回访口径;随后完成 14 条场景/SOP 的人工评审(现 0/14) | 内容负责人/HR | 待指派 | 07-20 | 07-24 | 4.3.4、L1-B(SOP≥5) | SOP 覆盖与 RAG 可用率(≥80%)无法验收 | +| B9 | **历史案例批量提供**:试点前 **≥20 条**历史案例语音/材料入库并脱敏审核,不依赖试点新增量补齐验收底线;试点期继续按"每周每项目 1 条"采集新案例 | 各项目负责人 | 待指派 | 07-22 | 07-25 | 4.6、L1-B(案例≥20) | 案例库验收必然失败 | +| B4 | 方言 ASR 厂商选型(**阶段一只选 1 种方言**,按试点区域确认;BRD 9.1 只要求 1 条路径)+ 费用授权 + **同时确认 CER 达标阈值**(BRD 只定了 ≤5s 响应,识别准确率的验收线需业务拍板,否则 C3 实测无法判定"达标") | 技术组+HR | 待指派 | 07-20 | 07-24 | 4.3.3、9.1、L1-C | MVP 阻塞项零实测,且测了也无法判定通过 | +| B5 | 案例讲解视频预渲染样片 1 条(半年会素材;**不阻塞案例上传闭环验收**——L0-4 只要求"语音上传→AI 整理") | 业务 | 待指派 | 07-31 | 08-05 | 4.6、9.1 | 半年会演示缺 1 条示范视频(止损线内最低配置) | +| B6 | 大模型/ASR/TTS 供应商 PII 留存与跨境条款确认(音频本体、图片本体外发部分) | 法务/安全 | 待指派 | 07-24 | 07-31 | G3、G6、F3 | 生产前闸门无法关闭 | +| B7 | 组织快照最小字段清单与保留期:生产实测姓名 0、部门 0(展示字段默认关已生效),但 `project_name` 仍保留 3417 行;需法务/安全核对 `project_name`、`person_phone` 等仍保留字段是否在最小清单内,及历史清理范围 | 法务/安全 | 待指派 | 07-24 | 07-31 | 5.4(只存 party_id 硬约束) | P0 存储边界无法关闭 | +| B8 | 认证等级/学分规则契约(初/中/高级条件、学分来源与分值、防刷) | HR | 待指派 | 07-31 | 08-07 | 4.5、10.2-A | 成长页只能继续挂"规则待配置、不生效" | +| B10 | 25 名员工手机号不可用 → 反馈开放平台修数(低优先级,不阻塞) | 开放平台 | 待指派 | 07-31 | — | 5.4 | 25 人无法登录 APP | + +追办顺序:**B3 → B9 → B2 → B4**,其余并行。 + +--- + +## 3. 主线 C:工程收尾(小、快、不开新战线) + +| # | 任务 | 依据 | 完成定义 | 期限 | +|---|---|---|---|---| +| C1 | "问师傅"文案对齐:`pages/user/today/index.vue` 的"语音提问,快速解答"**直接改为符合现状的文字**(语音问答入 backlog,届时复用 `speech.ts` + `/api/ai/asr`,不新增接口) | 4.7、2026-07-15 审计 | 首页承诺与 SOP 页能力一致 | 07-16(随 A1 批次发布) | +| C2 | 主管身份与项目数据权限验收:组织数据已就位,用正式主管账号认证态验证——本项目团队可见、跨项目不可见、训练记录/待复盘按项目过滤 | 5.4、G3、L1-B | 认证态回归留证写入审计记录 | 07-18 | +| C3 | 方言实测(B4 选型后):**1 种方言**,固定样本集(≥20 条真实短音频),记录字错率(CER)与单轮响应 P95(≤5s,L1-C);CER 与 B4 确认的阈值比对——**阈值未确认前只记录数据、不判定达标** | 4.3.3、9.1、L1-C | 样本数、CER、P95 三项数据落审计记录;CER ≤ B4 阈值且 P95 ≤5s 才在验收表标"通过" | B4+3 天 | +| C4 | LLM/ASR/TTS 调用计量:待 B6/财务给出计费字段与单价后,新增租户范围 `llm_call_log`(模块/模型/token/延迟/费用)+ 月度上限告警与自动降级;此前维持手动断路器,不硬编码价格 | 6.3、G6 | 有持久化计量与可配置上限 | B6 后一周内 | +| C5 | 案例入库流水线支援 B9:批量导入入口复用现有上传/整理/筛选链路,脱敏门禁逐条通过;不为批量导入新做页面 | 4.6、L1-B | ≥20 条历史案例状态"已入库"且脱敏扫描 0 命中 | 07-25 | + +--- + +## 4. 主线 D:正式试点与 L1 验收(B3 就位后启动,目标 8 月中出证据包) + +按 BRD 10.2 四组验收逐项闭合,全部以严格门禁 + 正式窗口数据为准,**禁止 seed/烟测/演示账号冒充**(审计第 5 节完成定义)。 + +| 周次 | 动作 | 闭合的验收项 | +|---|---|---| +| 试点第 0 周(预备) | 试点名单在组织快照中逐一核对(手机号可登录);SOP 5 流程入库 + 14 条评审完成(B2);**历史案例 ≥20 条入库审核完成(B9/C5)**;员工开通与培训动员(配 G4 激励沟通) | L1-B:SOP≥5、组织导入、项目级权限 | +| 试点第 1–2 周 | ≥20 名员工真实对练(人均目标 10 次);每日一练推送;主管完成复盘与派发闭环;同步采集满意度(1–5 分);案例按"每周每项目 1 条"继续新增 | L1-A 育闭环;L1-D 人均次数/完训率/满意度 | +| 试点第 2 周起 | 人工校准 ≥20 条,计算 AI/人工分档一致率(目标 ≥70%);统计 RAG 可用回答率(目标 ≥80%);抽查人工复核可否决 AI 分的证据(G1/G2) | L1-C 一致率/可用率;G1/G2 | +| 试点收口 | 案例库 ≥20 条(存量+增量,脱敏门禁通过);`GET /api/train/practice/export` 按窗口导出正式 CSV;以试点窗口日期跑 `AIHR_PILOT_STRICT=true AIHR_PILOT_START_DATE=<起> AIHR_PILOT_END_DATE=<止> ./scripts/demo-check.sh` 全绿;形成 L1 验收证据包 | L1 全项;半年会"真实使用数据" | + +**半年会演示止损线**(07-08 定案):只保「生活顾问的一天」主线,实操**问 + 练**两个模块;陪练 + 总结卡 + 1 条示范视频为最低配置,其余全部可砍。 + +--- + +## 5. 明确不做(守边界,防范围失控) + +| 项 | 依据 | +|---|---| +| 个人 AI 助理/个人知识空间(文字/图片/文件/链接收藏、混合问答、PPT) | BRD 4.8 明确阶段二;有独立 TechSpec 与推进计划 | +| 员工端图片/视频训练输入(4.3.3 终态缺口) | 《AI陪练二期开发推进计划》约定文本场景稳定后独立评估;届时只落有业务目的的场景(拍照报修、品检) | +| 开放问题榜、积分/排行榜/荣誉墙运营化 | 总纲阶段二;07-08 定案排行榜默认关 | +| 师徒关系/拜师池/带徒福利 | 业务已否决(07-08) | +| G5 情绪树洞 | 业务/法务/安全决策未定,在匿名边界与零落库合同明确前保持未实现,不用陪练情绪数据冒充 | +| 企微/钉钉/工单集成、SSO、知识图谱、多项目类型 | D2/D4/D8 既定决策不变 | + +--- + +## 6. 里程碑总览 + +``` +07-15(今日) B 催办清单发出(B3 置顶) + C1 文案修改 +07-16 A1 收拢工作区成 release commit + A2 发布窗口落字 +07-17 A3 原子发布(预检→备份→切换→回归) + A4 结构回归与数据缺口留档 +07-18 C2 主管身份/项目权限认证态验收(组织数据已就位,不依赖外部) +07-18~07-25 B2 SOP 补齐与评审 / B9+C5 历史案例 ≥20 条入库 / C3 方言实测(依赖 B4) +07-28~08-08 D 试点窗口(1–2 项目、≥20 人、2 周;以 B3 实际答复为准) +08-11~08-15 L1 证据包收口:strict 门禁全绿(带试点窗口日期)+ CSV 导出 +半年会前 「生活顾问的一天」演示彩排 ×2 + 录屏兜底(沿用 BRD 11.2 彩排纪律) +2026-09 前 收费季试点可用状态确认 +``` + +## 7. 风险与止损 + +1. **B3 试点圈定拖延是当前最大单点风险**(组织数据已就位,决策成本已最低):07-22 仍无答复,升级 HR 负责人;试点每晚一周,半年会"真实使用数据"窗口就少一周。 +2. **案例 20 条**:依赖 B9 存量导入;若试点前不足 20 条,立即提高采集频率(每项目每周 2–3 条)并如实下调收口预期,不用未脱敏或演示数据凑数。 +3. **发布失败**:A3 原子流程任一步失败即按备份回滚,当天不重试第二次,先归因。 +4. **范围失控**(BRD 附录 C 头号风险):任何新页面/新功能请求一律进 `docs/BACKLOG.md`;每周对照本计划复盘一次偏差。 +5. **方言不达标**:若 CER/P95 不达标,按 BRD 9.2 止损——保"1 条方言路径可演示",验收表如实标注差距,不虚标通过。 +6. **计划保鲜**:《BRD_IMPLEMENTATION_AUDIT.md》条目非严格时序,引用前须以"生产只读复核 + 线上实测"确认基线;本计划每次执行前先校验第 0 节事实是否仍成立。 + +--- + +*本计划由工程侧起草,v2.1 已按 Codex 审查修订;B 表负责人/期限需业务方确认后生效。执行进展回写《BRD_IMPLEMENTATION_AUDIT.md》,本文件只改计划不记证据。* diff --git a/docs/员工端版本功能对比与功能对齐清单-20260717.md b/docs/员工端版本功能对比与功能对齐清单-20260717.md new file mode 100644 index 00000000..b6de2384 --- /dev/null +++ b/docs/员工端版本功能对比与功能对齐清单-20260717.md @@ -0,0 +1,201 @@ +# 员工端版本功能对比与功能对齐清单 + +> 日期:2026-07-17 +> 范围:截至 2026-07-17 09:46 收到的客户反馈,重点比较历史可运行移动端、历史需求/设计、当前 `mobile-uni` 实现和本地待发布修复。 +> 重要边界:本清单核对的是仓库源码和项目文档,不等同于线上生产版本验收。本轮未重新核验线上 H5,因此“当前实现”不能直接表述为“线上已生效”。 +> +> Figma 需求覆盖专项审计见:[FIGMA需求覆盖与版本偏差审计-20260717.md](FIGMA需求覆盖与版本偏差审计-20260717.md)。该审计确认:Figma 只能作为视觉与交互事实源,不能单独替代功能需求和阶段边界。 + +## 1. 先给结论 + +客户所说的“主要功能被删完了”不能原样认定为事实,但反映了真实的版本连续性问题。当前反馈实际混合了四类情况: + +1. **确认回退**:历史可运行移动端确实有、切换到 `mobile-uni` 后缺失或弱化,例如“问师傅”语音提问、SOP 回答和总结卡语音播报。 +2. **历史规划未完成**:需求文档或 Figma 已设计,但没有证据证明曾形成可运行版本,例如开放问题榜/有奖问答、完整课程考试、主管批量派发等。 +3. **新增或扩大范围**:本轮才明确增加,例如“问 AI(全网)”、总结卡下载图片、生成题目与点评逐条播报、主管一键切换学员身份。 +4. **正确收口或体验缺陷**:岗位不应由员工随意切换,这是权限纠正,不是删功能;“提交没反应”“录音归属错误”“快捷问题错位”属于缺陷,不应包装成功能调整。 + +因此,后续不能再用“旧版/新版”两个模糊概念沟通。必须按本清单逐项确认:**旧版是否真有、历史是否承诺、当前是否实现、是否已经发布**。 + +## 2. 对比口径 + +| 对比层 | 本清单定义 | 主要证据 | 能证明什么 | +|---|---|---|---| +| 历史可运行版 | 仓库保留的旧移动端 `mobile/` | `mobile/src/App.vue`、`mobile/src/data/*` | 能证明旧源码里实际存在过的交互 | +| 历史需求/设计 | BRD、分阶段总纲、推进计划、会议纪要、Figma 清单 | 本文末尾“证据来源” | 能证明功能被提出、规划或设计过,不能单独证明已经开发上线 | +| 当前开发版 | 当前权威用户侧工程 `mobile-uni/` | `mobile-uni/src/pages/*`、后端 `ruoyi-aihr` | 能证明当前工作树的实现状态 | +| 本地修复版 | 当前工作树里尚未发布的反馈修复 | `git diff` 与相关测试 | 只能表述为“本地已修”,不能表述为“客户已可用” | +| 线上生产版 | `https://peilian.njzhmj.top/h5/` 的真实部署 | 线上资源、认证账号、真实接口和浏览器回归 | 本轮未复核,暂不下结论 | + +状态说明: + +- **保留**:当前仍有等价主链路。 +- **部分保留**:有基础能力,但范围、内容或角色未覆盖完整。 +- **确认回退**:历史可运行源码里有,当前权威工程缺失。 +- **本地已修未发布**:当前工作树已补,但尚未完成线上发布验证。 +- **历史规划未实现**:需求/设计里有,没有证据证明旧版真正交付。 +- **新增需求**:本轮扩大了原有范围,应重新评估安全、成本和排期。 +- **待确认**:现有证据不能支持客户记忆或开发方判断,需要原版本包、旧链接或验收记录。 + +### 2.1 本轮恢复结果(2026-07-17 晚间,本地未发布) + +本轮已按“功能真实可用 + 同尺寸视觉复核”执行,不再只检查页面结构: + +- 恢复问师傅语音录制与选择音频兜底,继续复用 `/api/ai/asr`;恢复 SOP 回答、总结卡、每日题、参考答案、AI 对话、训练结果和候选人面试内容播报。TTS 优先走 `/api/ai/tts`,本地未配置 TTS 模型时 H5 自动降级到设备语音,并已实际切换到“停止播报”状态。 +- 恢复总结卡生成与 PNG 下载。无对话模型时只摘录检索命中的 SOP 原文,并标明来源,不编造推荐话术或异议答案;本地已实际生成并触发下载成功提示。 +- 恢复主管“工作台/学习端”切换,切换只改变显示模式,不修改组织系统中的主管身份;已用主管真实登录态进入员工今日页并返回主管工作台。 +- 恢复主管按姓名多选、当天/明天/3 天内/7 天内快捷截止日期和逐人派发结果;已向 2 名团队成员真实提交,接口返回 2/2 成功。 +- 每日题从固定 3 题改为 7 组共 21 题,按日期、岗位和员工身份确定性轮换;同一员工同一天稳定、相邻日期不重复,仍保持每日 3 题。 +- 全局字号恢复标准/大字/超大字三档并持久化;员工端关键页已在 400×900 下逐档截图,超大字页验证了换行、卡片自增高、底栏安全区和无横向溢出。 + +上述段落记录的是 2026-07-17 晚间的中间状态。用户随后要求“一并实现”,最终状态以 2.2 为准;第 3–5 节保留为需求来源与实施前差距快照,不再代表当前工作树完成度。 + +### 2.2 全量实现结果(2026-07-18,本地未发布) + +| 客户反馈能力 | 本轮落地结果 | 本地证据 | +|---|---|---| +| 不同角色、岗位内容与主管学习 | 员工/候选人/主管独立入口继续保留;主管可在工作台和学员模式间切换且不改变组织身份;岗位、项目和人员范围由组织数据约束 | 角色深链、主管学员模式、岗位只读和团队范围测试通过 | +| 问师傅语音、生成内容播报 | 语音录制、音频选择兜底、ASR、TTS/设备语音降级恢复;每日题、参考答案、评分点评、候选题目、问题内容支持播报 | H5 交互和服务调用已验证 | +| 图片/视频与总结卡下载 | 问师傅支持仅本次查询使用的图片/视频附件;总结卡可生成并下载 PNG,来源继续可追溯 | 浏览器交互已验证 | +| 全局字号 | 标准/大字/超大字三档全局持久化;可见业务文字最低 12px,输入框采用 `border-box`,问题榜四个筛选标签在窄屏完整显示 | 400×900、430px 和超大字状态逐页截图复核 | +| 训练题变化、反馈与常见难题 | 每日三题按日期、岗位和员工身份轮换;每日题/专项支持有用与无用反馈;主管和管理端可按岗位、时间范围、次数/人数/均分查看难题并一键派专项 | 真实 API 创建专项成功,管理端页面已登录复核 | +| 指派专项 | 显示真实姓名,支持最多 100 人多选,提供当天/明天/3 天内/7 天内快捷截止日期;批量提交具有请求幂等和逐人结果 | 真实 API 指派和超时重进机制已验证 | +| 开放问题榜、有奖征问题/答案 | 已实现提问、全员回答、审核、最佳答案、积分规则、积分/学习学分双账户和后台运营;奖励明确不是现金且不能提现 | 真实 API 完成提问→回答→审核→最佳答案→发奖,重复选优不重复发奖 | +| 完整岗位考试 | 已实现主管选择岗位、项目、真实员工多选、组卷、发布、截止日期和成绩查询;员工可答题、播报、提交、查看逐题结果;提交具备幂等重放 | 真实 API 发布考试并完成 100 分提交;同请求重放返回相同记录和时间 | +| 问 AI(全网) | 与企业问师傅分入口、分接口、分提示;仅允许已测试启用的 HTTPS 提供方,禁用或未配置时明确不可用且不生成假答案 | 能力探测、禁用查询、安全边界和管理页已验证;真实外部回答待配置正式密钥后验证 | +| “点提交没反应”、录音串号、快捷标签错位 | 已补明确错误/加载状态、账号与页面代次隔离、旧请求回写保护;修正提交/输入宽度与筛选标签布局 | 自动化测试、真实浏览器请求和逐页截图均通过 | + +最终本地完成层级为:**已实现、自动化验证、真实 API 验证、浏览器交互验证、逐页视觉验证**。尚未执行生产发布,因此不能表述为“客户线上已可用”。“员工每天赚钱”按已批准的积分/学习学分落地,没有擅自实现现金、提现、预算或税务流程。 + +## 3. 核心功能版本矩阵 + +### 3.1 角色、岗位与学习 + +| 功能 | 历史可运行版 | 历史要求/设计 | 当前开发版 | 客观判断 | 建议 | +|---|---|---|---|---|---| +| 员工、候选人、主管看到不同页面 | 旧版已有三类角色入口与不同首页 | BRD 和 Figma 均要求分角色 | `mobile-uni` 仍有员工、候选人、主管独立页面 | **保留**,不是整体删除 | 继续用正式账号做三角色回归,不用截图代替权限验证 | +| 不同岗位看到不同内容 | 旧版有岗位标签和部分展示差异,业务内容仍以生活顾问为主 | 总纲要求按条线/岗位分发,第一波生活顾问,项目经理第二波 | 当前员工岗位来自组织身份,但训练派发仍固定查询“生活顾问”场景 | **部分保留**,岗位身份已接,岗位内容尚未完整产品化 | P1 建岗位→场景→SOP→每日题映射,先明确“四保一服”首批岗位清单 | +| 主管同时作为学员学习 | 没有找到稳定的双身份切换证据 | 客户本轮明确提出“主管也要学习” | 本地已增加“工作台/学习端”显式切换,组织角色保持主管;已用主管登录态双向切换 | **新增需求,本地已实现并交互验证,未发布** | 发布后用正式主管账号复验工作台权限正例/反例 | +| 员工自行切换岗位 | 旧版存在演示性岗位切换痕迹 | 正式口径要求岗位来自人力/组织系统 | 本地已移除员工自由切换,改为 HR 组织同步只读 | **正确收口**,不应恢复随意切换 | 保持只读;提供“岗位有误,联系 HR”入口和同步状态 | +| 岗前/入职任务 | 旧版没有形成正式任务闭环证据 | BRD 一期要求岗位 SOP、岗前/入职任务和资格门禁 | 当前有员工岗前任务、完成回写和资格状态页面,但正式规则/数据未配置 | **部分实现** | HR 提供正式任务与资格规则后再验收,不用空表或 seed 宣称完成 | +| 主管/经理配置岗位入职测试 | 未找到旧版已实现证据 | 阶段二“学”模块要求课程、考试、截止时间;BRD 有上岗资格 | 当前无主管/经理端出题、组卷、发布考试闭环 | **历史规划未实现/待确认** | 让客户提供曾用旧链接或验收截图;若确认要做,纳入学模块专项 | +| 全局字号调节 | 未找到旧版字号开关 | Figma `4:16` 已修为标准/大字/超大字三张同构画板 | 本地已实现全局三档、持久化和页面响应式自增高;400×900 逐档及关键页超大字已截图复核 | **新增无障碍需求,本地已实现并视觉验证,未发布** | 发布后补真实 iOS/Android 与 375–430px 触控、键盘和读屏复验 | + +### 3.2 “问师傅”、语音与多媒体 + +| 功能 | 历史可运行版 | 历史要求/设计 | 当前开发版 | 客观判断 | 建议 | +|---|---|---|---|---|---| +| 问师傅文字提问 | 旧版有 SOP 搜索 | 一期核心 | 当前保留,查询企业授权知识空间并显示引用 | **保留** | 继续保持“有依据才回答”,不兜造假答案 | +| 问师傅语音提问 | 旧版明确有“语音提问/选择语音”并走 ASR | 产品方案明确“问”支持语音提问 | 本地已恢复录音、停止录音、转写和选择音频兜底,复用 `speech.ts` 与 `/api/ai/asr` | **确认回退已在本地恢复,未发布** | 发布前补正式 ASR 配置和真机麦克风权限/弱网复验 | +| SOP 回答语音播报 | 旧版回答有“听一遍” | 历史要求 TTS,产品强调语音优先 | 本地已补“语音播报” | **本地已修未发布** | 发布后用真实 TTS 配置验证播放、停止、失败提示 | +| 总结卡语音播报 | 旧版有总结卡 TTS | 总纲明确“长文总结卡 + 语音播报(能力已有)” | 本地已补总结卡播报 | **本地已修未发布** | 与回答播报共用控制器,避免同时播放 | +| “每个文字都能点播报” | 旧版能确认的是回答与总结卡播报,不能证明每段文字逐字可点 | 设计原则是话术大字+语音,并非所有文字元素都可播 | 当前只对答案、总结卡、每日题、AI 对话等关键内容提供播报 | **客户表述扩大** | 对业务关键内容提供统一播报按钮,不做所有 UI 文本逐元素点击 | +| 生成题目/AI 对话/点评播报 | 未找到旧版完整覆盖证据 | 语音优先原则支持该方向 | 本地已覆盖每日题题干、AI 角色回复、点评、参考答案、训练结果和候选面试内容 | **新增增强,本地已实现并构建验证,未发布** | 发布后逐页验证播放、停止、切换内容时释放音频和设备语音降级 | +| 员工作答语音转文字 | 旧版已有训练语音能力方向 | 一期明确 ASR→文本→提交→评分 | 当前每日题和对练支持录音/选音频、转写后编辑提交 | **保留** | 发布后验证录音权限、账号归属和弱网降级 | +| 录音“不属于当前账号” | 不属于产品功能 | 无历史需求差异 | 本地已修请求身份/归属处理,提交失败也补可见提示 | **缺陷,本地已修未发布** | 用两个真实账号交叉验证自己的录音可提交、他人资源不可读 | +| 提交按钮无反应 | 不属于产品功能 | 无历史需求差异 | 本地增加失败 toast 和输入区域/按钮可见性修正 | **缺陷,本地已修未发布** | 仍需手机端真实点击回归,不能只靠单测关闭 | +| 问师傅上传图片/视频 | 旧版问师傅未确认有此能力 | BRD/缺口文档把图片/视频情境列为新增或后置能力 | 本地已增加图片/视频仅用于本次现场分析,不写入共享知识库 | **历史规划项,本地已补最小版未发布** | 先验收大小限制、权限、PII 外发和失败提示,再决定是否留存 | +| 总结卡下载图片 | 未找到旧版证据 | 历史文档强调总结卡,没有明确图片下载验收 | 本地已补 PNG 下载;无聊天模型时可基于真实命中原文生成,并已实际触发下载成功提示 | **新增需求,本地已功能与视觉验证,未发布** | 发布后验证 iOS/Android H5 下载、文件名和相册/文件权限 | +| 问 AI(全网) | 旧版没有 | 2026-07-08 会议明确当时是“不联网的内部知识” | 当前问师傅只查授权的企业/个人知识空间,不是公网搜索 | **新增范围** | 单独立项为“外部 AI”,与问师傅分入口、分来源、分免责声明;先评估版权、错误信息和数据外发 | + +### 3.3 练习、每日题与内容变化 + +| 功能 | 历史可运行版 | 历史要求/设计 | 当前开发版 | 客观判断 | 建议 | +|---|---|---|---|---|---| +| 场景对练、提交、AI 评分 | 旧版有演示主链路 | 一期核心 | 当前文字/语音作答、AI 评分、记录、低分再练均在 | **保留并增强** | 用正式场景和人工校准验证,不用 seed 结果代替业务验收 | +| “专项”的定义 | 旧版主要是主管给员工派场景 | 计划中专项=按岗位/短板派发的训练任务或训练营 | 当前实现为“主管给一名员工派一个训练场景”,不是固定的一道题 | **功能在,但产品解释不清** | 页面改名为“专项训练任务”,显示场景、对象、原因、截止时间和内容版本 | +| 同一专项每次内容不同 | 旧版没有内容版本/去重保证 | 历史目标是 AI 控制生成、动态难度与多场景 | 当前真 LLM 可让对话变化,但 seed 降级与场景锚点可能重复;没有题目版本和去重契约 | **部分实现** | P0 加 `content_version`/题目快照/最近使用去重,保留 seed 降级但不可每天原样重复 | +| 每日三题每天变化 | 未找到旧版动态题库闭环 | 总纲要求 3 个月覆盖 100 个常见场景 | 本地已扩为 7 组共 21 题,按日期、岗位和员工身份轮换;同日稳定、相邻日不重复 | **明确缺口已完成第一阶段修复,未发布** | 后续继续把正式岗位题库扩到 100 个常见场景,并保留题目快照与版本审计 | +| 选择岗位/条线生成题目和参考答案 | 未找到旧版已实现证据 | 历史要求按岗位推题,后续 AI 辅助出题 | 当前没有主管按条线生成题库的完整入口 | **历史规划未实现** | P1 先做审核式生成:选择岗位→生成草稿→人工确认→版本化发布 | +| 学员评价题目有用/无用 | 旧版 SOP 答案已有赞/踩,不等于训练题评价 | 二期计划明确问答打勾/打叉 | 当前 SOP 答案可反馈;每日题/专项题没有逐题有用/无用 | **部分实现** | P1 复用反馈模型到训练题,区分“答案准确性”和“题目有用性” | +| 管理端按常见难题排序 | 旧版主管端有薄弱项/风险展示 | BRD 要求错题、团队短板、专项派发 | 当前主管团队已有错题聚合、次数/人数/均分等数据,但运营排序闭环不完整 | **部分实现** | P1 增加按出现次数、涉及人数、低分程度排序,并支持一键生成专项 | +| 训练后整体满意度 | 旧版没有完整正式窗口证据 | 试点要求收集反馈 | 当前有 1–5 分满意度和意见 | **保留/新增完成** | 与“逐题有用性”分开统计,避免一个指标代替另一个 | + +### 3.4 主管派发、激励与问题榜 + +| 功能 | 历史可运行版 | 历史要求/设计 | 当前开发版 | 客观判断 | 建议 | +|---|---|---|---|---|---| +| 指派时显示员工姓名 | 旧版多为演示数据 | 正式权限要求稳定组织身份 | 本地已改为从当前项目在职人员中选择姓名,不再手填人员编号 | **本地已修未发布** | 生产需正式组织同步和项目权限数据,不能用本地姓名 seed 验收 | +| 一次多选人员 | 未找到旧版已实现证据 | Figma 有“人员/岗位/场景/截止时间”,但不证明已开发 | 本地已支持按姓名多选、最多 100 人、单次批量接口和逐人结果,超时重进可复用幂等请求 ID | **新增需求,本地已实现并真实提交 2/2 成功,未发布** | 发布后以大团队验证 100 人上限、权限、超时重试和逐人失败隔离 | +| 简化字段、默认值 | 未找到旧版完整证据 | 属于交互优化 | 当前已有默认场景/默认原因,但表单仍偏技术化 | **体验改进** | 保留对象、场景、截止时间三项必填,其他默认折叠 | +| 今天/明天/3天内/7天内快捷日期 | 未找到旧版已实现证据 | Figma 明确有截止时间 | 本地已增加 `dueDate`、四个快捷项和逐人到期结果;3 天内真实派发显示 2026-07-20 | **历史设计细化已在本地实现,未发布** | 发布后复验时区、当天边界和后台准确日期 | +| 有奖征问题/有奖答案 | 旧版没有可运行证据 | 2026-07-08 已定为阶段二“开放问题榜”,有双入口、全员作答、最佳答案和奖励 | 当前阶段一没有问题榜 UI/业务闭环 | **历史规划未实现,不是当前版误删** | P2 按阶段二单独实施,不应临时塞进“问师傅”页 | +| “员工每天可以赚钱” | 旧版没有 | 历史口径是积分/悬赏/学分分账,奖励规则需后台配置,并未批准现金日结 | 当前没有现金收益闭环 | **新增且有运营/财务风险** | 先由业务确认积分、礼品或现金及税务/预算规则,研发不直接实现“赚钱”文案 | + +## 4. 客户逐条反馈归类 + +| 客户反馈 | 归类 | 当前结论 | +|---|---|---| +| “不同岗位看到的页面不一样?” | 能力确认 | 三角色页面不同;不同员工岗位内容只部分覆盖,不能笼统回答“都不一样” | +| “有奖征问题和征答案放哪里了?” | 历史阶段二规划 | 设计和总纲里有,当前阶段一未实现;应作为开放问题榜,不塞进问师傅 | +| “字体能调大吗?有很多老年人” | 新增无障碍需求 | 没有旧版字号开关证据;大字设计不等于可调字号 | +| “原来很多优点都没了,例如每个文字语音播报” | 部分属实、部分扩大 | 旧版可确认回答/总结卡 TTS,不能确认所有文字可点;关键播报已在本地恢复/扩展 | +| “录音不属于当前账号” | 缺陷 | 本地已修,待双账号和线上验证 | +| “点提交没反应” | 缺陷 | 本地补错误反馈,待真机回归 | +| “快捷问题错位” | 视觉缺陷 | 需要按正式 Figma/手机宽度做视觉验收,不能只看 DOM 顺序判断已修 | +| “要能上传图片和视频” | 历史规划/本地最小实现 | 已做仅本次查询附件分析,未发布,不等于共享知识库上传 | +| “生成的问题也要语音播放” | 新增增强 | 本地已覆盖每日题/AI 对话,其他生成内容仍需统一核对 | +| “总结卡可以下载图片” | 新增增强 | 本地已补 PNG 下载,未发布 | +| “语音播报亮点全没了” | 确认回退 | 问师傅语音输入和关键输出播报均已在本地恢复;无 TTS 模型时 H5 已验证设备语音降级 | +| “岗位身份在哪里修改?” | 权限口径冲突 | 员工不应自行修改;本地已改为 HR 组织同步只读 | +| “指派要显示人名,不是编号” | 缺陷/体验 | 本地已改项目内在职员工姓名选择,依赖正式组织数据 | +| “增加问 AI(全网)” | 新增范围 | 当前会议基线是内部知识,不联网;需独立产品和安全决策 | +| “指派人员可多选、字段更简、快捷日期” | 新增 + 历史设计细化 | 本地已完成姓名多选、字段收敛、四档快捷日期、逐人结果和幂等批量提交 | +| “专项是什么?今天明天会不会同一道题?” | 产品定义/内容机制 | 专项是场景任务;当前无题目版本和去重保证,客户担忧成立 | +| “主管也要学习、不同岗位内容不同” | 历史目标 + 当前缺口 | 主管学习端切换本地已实现且不改组织角色;全岗位内容矩阵仍未完成,不能把生活顾问内容冒充全岗位覆盖 | +| “每天题目不一样、学员评有用无用” | 历史目标 + 新机制 | 每日题已本地改为 21 题按人按日轮换;SOP 有赞踩,训练题逐题有用/无用仍未实现 | +| “岗位入职测试保留在主管/经理角色” | 待确认 | 当前只有员工任务/资格与候选人面试;未找到旧版主管端考试配置证据 | + +## 5. 优先级建议 + +### P0:先恢复版本连续性和可用性 + +1. 恢复“问师傅”语音输入,解决首页“语音提问”文案与实际能力不一致。 +2. 完成回答、总结卡、每日题、AI 对话、评分点评的统一播报验收。 +3. 真机复验录音归属、提交反馈、附件上传、总结卡下载。 +4. 建每日题/专项的内容快照、版本号和去重规则,停止固定三题反复出现。 +5. 用正式组织账号验证岗位只读、员工姓名派发和项目范围权限。 + +### P1:补齐阶段一产品完整性 + +1. 主管“工作台/学习端”双模式。 +2. 岗位→场景→SOP→每日题的统一映射,明确首批岗位内容清单。 +3. 指派多人、截止时间和快捷日期;每人生成独立任务记录。 +4. 训练题有用/无用反馈、团队常见难题排序和一键派专项。 +5. 全局字号三档与老年用户真机可读性验收。 + +### P2:按原阶段二单独立项 + +1. 开放问题榜、有奖征问题、全员回答、最佳答案、激励规则。 +2. 完整课程、考试、题库、截止时间和成绩留档。 +3. “问 AI(全网)”外部信息能力;必须与内部问师傅分来源和权限。 +4. 现金型奖励需业务、财务、法务先确认,不作为普通前端功能直接上线。 + +## 6. 对客户的建议回复 + +> 之前的功能要求文档和旧版源码已经找到,并已按“旧版实际有过、历史规划但未上线、本轮新增”三类逐项对齐。问师傅语音输入、回答/总结卡播报等确认回退已在本地恢复;同时补齐了三级字号、总结卡图片下载、主管学习端、批量派发/快捷日期和每日题轮换。有奖问答、完整学习考试和问 AI 全网仍分别属于后续阶段或新增专项,不会因为 Figma 已画就混入当前版本。岗位继续以人力系统身份为准,员工不能随意改岗。当前结论是“本地实现并完成相应功能/视觉验证”,尚未发布生产。 + +## 7. 对齐与验收流程 + +1. **客户确认分类**:逐项确认“旧版真有/历史规划/新增需求/不再需要”。 +2. **冻结对比基线**:给旧版源码、当前开发包和目标版本各自标记 commit、构建时间和访问地址。 +3. **先修 P0**:只处理确认回退和阻断缺陷,不同时扩张阶段二范围。 +4. **测试版验收**:按账号、岗位、页面、操作、期望结果记录,不再用聊天截图代替验收单。 +5. **发布后复核**:本地完成、构建通过、线上资源同步、正式账号验证分别记录,不合并成一个“已完成”。 + +## 8. 证据来源 + +- 当前工程边界:[README.md](../README.md)、[AGENTS.md](../AGENTS.md)。 +- 当前实现与试点事实:[BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md)、[FIGMA_IMPLEMENTATION_MATRIX.md](FIGMA_IMPLEMENTATION_MATRIX.md)。 +- 阶段边界:[银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md)、[AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md)。 +- 产品定案:[20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md)、[2026-07-08 产品讨论纪要](20260708/企业内部AI培训学习APP产品需求与发布会内容讨论-2026年07月08日-来自【Get%20笔记】.md)。 +- 业务原始范围:[物业AI人力资源系统业务需求文档BRD.md](物业AI人力资源系统业务需求文档BRD.md)、[legacy/物业AI陪练系统一期建设实施方案.md](legacy/物业AI陪练系统一期建设实施方案.md)。 +- 设计状态:[FIGMA_DESIGN_INVENTORY.md](FIGMA_DESIGN_INVENTORY.md)、[H5_REDESIGN.md](H5_REDESIGN.md)。注意:Figma 清单中的 `[x]` 只证明设计画板或对应页面状态,不能单独证明线上功能已交付。 +- 历史可运行源码:`mobile/src/App.vue`(问师傅语音输入约 1009–1017 行、回答播报约 1021–1027 行、回答/总结卡 TTS 约 1804–1894 行)。 +- 当前权威源码:`mobile-uni/src/pages/user/*`、`mobile-uni/src/pages/supervisor/*`、`mobile-uni/src/services/position.ts`、`backend/ruoyi-modules/ruoyi-aihr/`。 + +## 9. 尚需补齐的证据 + +- 客户口中的“上一版”具体访问地址、安装包、发布时间或 commit 尚未确认;当前只能用仓库保留的 `mobile/` 作为历史可运行源码基线。 +- “主管/经理曾能配置岗位入职测试”“每个文字都能点播报”暂未找到足够旧版证据,需要旧录屏或验收单确认。 +- 当前本地反馈修复尚未发布,线上是否仍是旧资源、本地修复是否在 iOS/Android H5 上工作,需在发布窗口单独验证。 diff --git a/docs/物业AI人力资源系统开发规格TechSpec.md b/docs/物业AI人力资源系统开发规格TechSpec.md index 93b8e07a..cb2f299d 100644 --- a/docs/物业AI人力资源系统开发规格TechSpec.md +++ b/docs/物业AI人力资源系统开发规格TechSpec.md @@ -181,7 +181,7 @@ mobile/ | `/train/mistakes` | 错题本 | P1 | | `/knowledge/sop` | SOP 库(住宅类检索→引用→训练题) | P0跑通 | | `/knowledge/cases` | **案例库(英雄路径B:语音上传→整理)** | P0跑通 | -| `/knowledge/processing` | 资料处理(解析状态、处理链路、浏览器/服务端导入) | P1已提前落地 | +| `/knowledge/processing` | 资料处理(解析状态、处理链路、多文件/ZIP 批量导入) | P1已提前落地 | | `/system/model` | 模型配置(供应商 Key/Base URL/模型启停/测试、向量库状态与重建) | P1已提前落地 | | `/competency/radar` | 能力雷达图 | P1 | | `/competency/cert` | 认证/激励 | P1 | @@ -268,7 +268,7 @@ POST /api/ai/score req:{ rubricId, dialogue } resp:{ dimensions, co - 同步:`POST /api/sync/pull`(拉取组织人员,MVP 可读快照文件) - 知识检索(RAG):`POST /api/knowledge/search` → `{ queryText, category, answer, reference, docs[], snippets[], training[], records[] }` - 知识上传解析:`POST /api/knowledge/doc/upload` → `multipart/form-data { file, category }`,支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx`、100MB 内;先落 `sys_oss` 并绑定 `aihr_knowledge_attach.oss_id`,返回 `{ docId, ossId, fileName, category, fragments, snippets[] }`;管理端上传请求 timeout 为 180s。 -- 服务端目录导入:`POST /api/knowledge/doc/import-local-task` → `{ directory, category, limit }`,只允许读取 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,写入后台导入任务并逐文件复用上传解析链路;`GET /api/knowledge/doc/import-tasks` 返回任务进度列表;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 取消运行中任务;`POST /api/knowledge/doc/import-local` 保留同步调试。 +- 服务端目录导入:`POST /api/knowledge/doc/import-local-task` → `{ directory, category, limit }`,只允许读取 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,写入后台导入任务并逐文件复用上传解析链路;`GET /api/knowledge/doc/import-tasks` 返回任务进度列表;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 取消运行中任务;`POST /api/knowledge/doc/import-local` 保留同步调试。这组接口仅供运维/调试,不在 `/knowledge/processing` 页面暴露。 - 资料处理状态:`GET /api/knowledge/processing/overview` → 聚合附件状态、片段数、向量化状态、分类、处理链路和事件列表。 - 片段向量化:同一上传接口在 `category=vector` 模型配置可用时调用 OpenAI-compatible `/embeddings`,写入 `embedding_json/embedding_model/embedding_time`,并尽力 upsert 到 Qdrant;未配置或 Qdrant 不可用不阻塞上传。 - 向量库状态与重建:`GET /api/knowledge/doc/vector-index-status` 返回当前 vector 模型维度、Qdrant collection 维度、点数和片段向量数;`POST /api/knowledge/doc/rebuild-vector-index` 清空旧 embedding、删除 collection,并按当前 vector 模型重建。外部 embedding 成功但 Qdrant 维度不一致时不降级为本地 hash。 diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..bf58a25d --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,6131 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@element-plus/icons-vue': + specifier: 2.3.2 + version: 2.3.2(vue@3.5.30(typescript@5.9.3)) + '@highlightjs/vue-plugin': + specifier: 2.1.2 + version: 2.1.2(highlight.js@11.11.1)(vue@3.5.30(typescript@5.9.3)) + '@vueup/vue-quill': + specifier: 1.2.0 + version: 1.2.0(vue@3.5.30(typescript@5.9.3)) + '@vueuse/core': + specifier: 14.2.1 + version: 14.2.1(vue@3.5.30(typescript@5.9.3)) + animate.css: + specifier: 4.1.1 + version: 4.1.1 + await-to-js: + specifier: 3.0.0 + version: 3.0.0 + axios: + specifier: 1.13.6 + version: 1.13.6 + crypto-js: + specifier: 4.2.0 + version: 4.2.0 + echarts: + specifier: 6.0.0 + version: 6.0.0 + element-plus: + specifier: 2.13.5 + version: 2.13.5(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + file-saver: + specifier: 2.0.5 + version: 2.0.5 + highlight.js: + specifier: 11.11.1 + version: 11.11.1 + image-conversion: + specifier: 2.1.1 + version: 2.1.1 + js-cookie: + specifier: 3.0.5 + version: 3.0.5 + jsencrypt: + specifier: 3.5.4 + version: 3.5.4 + nprogress: + specifier: 0.2.0 + version: 0.2.0 + pinia: + specifier: 3.0.4 + version: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + screenfull: + specifier: 6.0.2 + version: 6.0.2 + vue: + specifier: 3.5.30 + version: 3.5.30(typescript@5.9.3) + vue-cropper: + specifier: 1.1.4 + version: 1.1.4 + vue-i18n: + specifier: 11.3.0 + version: 11.3.0(vue@3.5.30(typescript@5.9.3)) + vue-json-pretty: + specifier: 2.6.0 + version: 2.6.0(vue@3.5.30(typescript@5.9.3)) + vue-router: + specifier: 5.0.3 + version: 5.0.3(@vue/compiler-sfc@3.5.30)(esbuild@0.27.7)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)))(rollup@4.62.2)(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)) + vue-types: + specifier: 6.0.0 + version: 6.0.0(vue@3.5.30(typescript@5.9.3)) + vxe-table: + specifier: 4.18.1 + version: 4.18.1(vue@3.5.30(typescript@5.9.3)) + devDependencies: + '@iconify/json': + specifier: ^2.2.448 + version: 2.2.500 + '@types/crypto-js': + specifier: 4.2.2 + version: 4.2.2 + '@types/file-saver': + specifier: 2.0.7 + version: 2.0.7 + '@types/js-cookie': + specifier: 3.0.6 + version: 3.0.6 + '@types/node': + specifier: ^25.4.0 + version: 25.9.5 + '@types/nprogress': + specifier: 0.2.3 + version: 0.2.3 + '@unocss/preset-attributify': + specifier: 66.6.6 + version: 66.6.6 + '@unocss/preset-icons': + specifier: 66.6.6 + version: 66.6.6 + '@unocss/preset-uno': + specifier: 66.6.6 + version: 66.6.6 + '@vitejs/plugin-vue': + specifier: 6.0.4 + version: 6.0.4(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)) + '@vue/compiler-sfc': + specifier: 3.5.30 + version: 3.5.30 + '@vue/eslint-config-prettier': + specifier: 10.2.0 + version: 10.2.0(eslint@9.39.1(jiti@2.7.0))(prettier@3.8.1) + '@vue/eslint-config-typescript': + specifier: 14.6.0 + version: 14.6.0(eslint-plugin-vue@9.33.0(eslint@9.39.1(jiti@2.7.0)))(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + autoprefixer: + specifier: 10.4.27 + version: 10.4.27(postcss@8.5.19) + eslint: + specifier: 9.39.1 + version: 9.39.1(jiti@2.7.0) + eslint-plugin-prettier: + specifier: 5.5.5 + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.7.0)))(eslint@9.39.1(jiti@2.7.0))(prettier@3.8.1) + eslint-plugin-vue: + specifier: 9.33.0 + version: 9.33.0(eslint@9.39.1(jiti@2.7.0)) + globals: + specifier: 17.4.0 + version: 17.4.0 + prettier: + specifier: 3.8.1 + version: 3.8.1 + sass: + specifier: 1.98.0 + version: 1.98.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + unocss: + specifier: 66.6.6 + version: 66.6.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)) + unplugin-auto-import: + specifier: 21.0.0 + version: 21.0.0(@vueuse/core@14.2.1(vue@3.5.30(typescript@5.9.3))) + unplugin-icons: + specifier: 23.0.1 + version: 23.0.1(@vue/compiler-sfc@3.5.30) + unplugin-vue-components: + specifier: 31.0.0 + version: 31.0.0(vue@3.5.30(typescript@5.9.3)) + unplugin-vue-setup-extend-plus: + specifier: 1.0.1 + version: 1.0.1 + vite: + specifier: 7.3.2 + version: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + vite-plugin-svg-icons-ng: + specifier: ^1.5.2 + version: 1.9.2(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)) + vite-plugin-vue-devtools: + specifier: 8.0.7 + version: 8.0.7(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)) + vitest: + specifier: 4.0.18 + version: 4.0.18(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + vue-tsc: + specifier: ^3.2.5 + version: 3.3.7(typescript@5.9.3) + +packages: + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.7': + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.29.7': + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.1': + resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@highlightjs/vue-plugin@2.1.2': + resolution: {integrity: sha512-P7s9DLBGpBsbltk3ZKuYUu9lwDCEfpttjJ5ErI6Wco6GQWIsjcSCJzCoMHgXhRUNkFDtZo7ZgeF8M10POPbDLw==} + peerDependencies: + highlight.js: ^11.0.1 + vue: ^3 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify/json@2.2.500': + resolution: {integrity: sha512-bATR7cvwt8wnN64lYPrd5IJQexowKut76r+TpZnmHnfatLp94TItwzLd5gd3fEC+5HW8TWE7flV7uct6qZaFWA==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + + '@intlify/core-base@11.3.0': + resolution: {integrity: sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==} + engines: {node: '>= 16'} + + '@intlify/devtools-types@11.3.0': + resolution: {integrity: sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==} + engines: {node: '>= 16'} + + '@intlify/message-compiler@11.3.0': + resolution: {integrity: sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==} + engines: {node: '>= 16'} + + '@intlify/shared@11.3.0': + resolution: {integrity: sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==} + engines: {node: '>= 16'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.4.10': + resolution: {integrity: sha512-Ht8wIW5v165atIX1p+JvKR5ONzUyF4Ac8DZIQ5kZs9zrb6M8SJNXpx1zn04rn65VjBMygRoMXcyYwNK0fT7bEg==} + + '@jridgewell/sourcemap-codec@1.4.14': + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + + '@jridgewell/sourcemap-codec@1.4.15': + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + resolution: {integrity: sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.115.0': + resolution: {integrity: sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.115.0': + resolution: {integrity: sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.115.0': + resolution: {integrity: sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.115.0': + resolution: {integrity: sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + resolution: {integrity: sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + resolution: {integrity: sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + resolution: {integrity: sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + resolution: {integrity: sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + resolution: {integrity: sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + resolution: {integrity: sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + resolution: {integrity: sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + resolution: {integrity: sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + resolution: {integrity: sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + resolution: {integrity: sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + resolution: {integrity: sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.115.0': + resolution: {integrity: sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + resolution: {integrity: sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + resolution: {integrity: sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + resolution: {integrity: sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/file-saver@2.0.7': + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + + '@types/nprogress@0.2.3': + resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@typescript-eslint/eslint-plugin@8.64.0': + resolution: {integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.64.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.64.0': + resolution: {integrity: sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.64.0': + resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.64.0': + resolution: {integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.64.0': + resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.64.0': + resolution: {integrity: sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.64.0': + resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.64.0': + resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.64.0': + resolution: {integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.64.0': + resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unocss/cli@66.6.6': + resolution: {integrity: sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg==} + engines: {node: '>=14'} + hasBin: true + + '@unocss/config@66.6.6': + resolution: {integrity: sha512-menlnkqAFX/4wR2aandY8hSqrt01JE+rOzvtQxWaBt8kf1du62b0sS72FE5Z40n6HlEsEbF91N9FCfhnzG6i6g==} + engines: {node: '>=14'} + + '@unocss/core@66.6.6': + resolution: {integrity: sha512-Sbbx0ZQqmV8K2lg8E+z9MJzWb1MgRtJnvqzxDIrNuBjXasKhbcFt5wEMBtEZJOr63Z4ck0xThhZK53HmYT2jmg==} + + '@unocss/extractor-arbitrary-variants@66.6.6': + resolution: {integrity: sha512-uMzekF2miZRUwSZGvy3yYQiBAcSAs9LiXK8e3NjldxEw8xcRDWgTErxgStRoBeAD6UyzDcg/Cvwtf2guMbtR+g==} + + '@unocss/inspector@66.6.6': + resolution: {integrity: sha512-CpXIsqHwxCXJtUjUz6S29diHCIA+EJ1u5WML/6m2YPI4ObgWAVKrExy09inSg2icS52lFkWWdWQSeqc9kl5W6Q==} + + '@unocss/preset-attributify@66.6.6': + resolution: {integrity: sha512-3H12UI1rBt60PQy+S4IEeFYWu1/WQFuc2yhJ5mu/RCvX5/qwlIGanBpuh+xzTPXU1fWBlZN68yyO9uWOQgTqZQ==} + + '@unocss/preset-icons@66.6.6': + resolution: {integrity: sha512-HfIEEqf3jyKexOB2Sux556n0NkPoUftb2H4+Cf7prJvKHopMkZ/OUkXjwvUlxt1e5UpAEaIa0A2Ir7+ApxXoGA==} + + '@unocss/preset-mini@66.6.6': + resolution: {integrity: sha512-k+/95PKMPOK57cJcSmz34VkIFem8BlujRRx6/L0Yusw7vLJMh98k0rPhC5s+NomZ/d9ZPgbNylskLhItJlak3w==} + + '@unocss/preset-tagify@66.6.6': + resolution: {integrity: sha512-KgBXYPYS0g4TVC3NLiIB78YIqUlvDLanz1EHIDo34rOTUfMgY8Uf5VuDJAzMu4Sc0LiwwBJbk6nIG9/Zm7ufWg==} + + '@unocss/preset-typography@66.6.6': + resolution: {integrity: sha512-SM1km5nqt15z4sTabfOobSC633I5Ol5nnme6JFTra4wiyCUNs+Cg31nJ6jnopWDUT4SEAXqfUH7jKSSoCnI6ZA==} + + '@unocss/preset-uno@66.6.6': + resolution: {integrity: sha512-40PcBDtlhW7QP7e/WOxC684IhN5T1dXvj1dgx9ZzK+8lEDGjcX7bN2noW4aSenzSrHymeSsMrL/0ltL4ED/5Zw==} + + '@unocss/preset-web-fonts@66.6.6': + resolution: {integrity: sha512-5ikwgrJB8VPzKd0bqgGNgYUGix90KFnVtKJPjWTP5qsv3+ZtZnea1rRbAFl8i2t52hg35msNBsQo+40IC3xB6A==} + + '@unocss/preset-wind3@66.6.6': + resolution: {integrity: sha512-rk6gPPIQ7z2DVucOqp7XZ4vGpKAuzBV1vtUDvDh5WscxzO/QlqaeTfTALk5YgGpmLaF4+ns6FrTgLjV+wHgHuQ==} + + '@unocss/preset-wind4@66.6.6': + resolution: {integrity: sha512-caTDM9rZSlp4tyPWWAnwMvQr2PXq53LsEYwd3N8zj0ou2hcsqptJvF+mFvyhvGF66x26wWJr/FwuUEhh7qycaw==} + + '@unocss/preset-wind@66.6.6': + resolution: {integrity: sha512-TMy3lZ35FP/4QqDHOLWZmV+RoOGWUDqnDEOTjOKI1CQARGta0ppUmq+IZMuI1ZJLuOa4OZ9V6SfnwMXwRLgXmw==} + + '@unocss/rule-utils@66.6.6': + resolution: {integrity: sha512-krWtQKGshOaqQMuxeGq1NOA8NL35VdpYlmQEWOe39BY6TACT51bgQFu40MRfsAIMZZtoGS2YYTrnHojgR92omw==} + engines: {node: '>=14'} + + '@unocss/transformer-attributify-jsx@66.6.6': + resolution: {integrity: sha512-NnDchmN2EeFLy4lfVqDgNe9j1+w2RLL2L9zKECXs5g6rDVfeeEK6FNgxSq3XnPcKltjNCy1pF4MaDOROG7r8yA==} + + '@unocss/transformer-compile-class@66.6.6': + resolution: {integrity: sha512-KKssJxU8fZ9x84yznIirbtta2sB0LN/3lm0bp+Wl1298HITaNiVeG2n26iStQ3N7r240xRN2RarxncSVCMFwWw==} + + '@unocss/transformer-directives@66.6.6': + resolution: {integrity: sha512-CReFTcBfMtKkRvzIqxL20VptWt5C1Om27dwoKzyVFBXv0jzViWysbu0y0AQg3bsgD4cFqndFyAGyeL84j0nbKg==} + + '@unocss/transformer-variant-group@66.6.6': + resolution: {integrity: sha512-j4L/0Tw6AdMVB2dDnuBlDbevyL1/0CAk88a77VF/VjgEIBwB9VXsCCUsxz+2Dohcl7N2GMm7+kpaWA6qt2PSaA==} + + '@unocss/vite@66.6.6': + resolution: {integrity: sha512-DgG7KcUUMtoDhPOlFf2l4dR+66xZ23SdZvTYpikk5nZfLCzZd62vedutD7x0bTR6VpK2YRq39B+F+Z6TktNY/w==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue-macros/common@3.1.3': + resolution: {integrity: sha512-pphnexn8CDKugcA4TYSKlg1XanBYPbILST+eZK9ZGqG8sVbNR5L0kXEpRqs8+iSznosHt/Jo2k1FGl0tnWIpyg==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue/babel-helper-vue-transform-on@1.1.5': + resolution: {integrity: sha512-SgUymFpMoAyWeYWLAY+MkCK3QEROsiUnfaw5zxOVD/M64KQs8D/4oK6Q5omVA2hnvEOE0SCkH2TZxs/jnnUj7w==} + + '@vue/babel-plugin-jsx@1.1.5': + resolution: {integrity: sha512-nKs1/Bg9U1n3qSWnsHhCVQtAzI6aQXqua8j/bZrau8ywT1ilXQbK4FwEJGmU8fV7tcpuFvWmmN7TMmV1OBma1g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@vue/compiler-core@3.5.30': + resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} + + '@vue/compiler-core@3.5.40': + resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} + + '@vue/compiler-dom@3.5.30': + resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} + + '@vue/compiler-dom@3.5.40': + resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==} + + '@vue/compiler-sfc@3.5.30': + resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} + + '@vue/compiler-ssr@3.5.30': + resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} + + '@vue/devtools-api@6.5.0': + resolution: {integrity: sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-api@8.1.5': + resolution: {integrity: sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==} + + '@vue/devtools-core@8.1.5': + resolution: {integrity: sha512-5e5jQOEssCdZA1wlFEUkIDtb+cAOWuLNWJ52fm4PBWbF7e3oTnM2fneaL42E5lJoolAaUQ678tv/XEb3h4e86Q==} + peerDependencies: + vue: ^3.0.0 + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-kit@8.1.5': + resolution: {integrity: sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/devtools-shared@8.1.5': + resolution: {integrity: sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==} + + '@vue/eslint-config-prettier@10.2.0': + resolution: {integrity: sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==} + peerDependencies: + eslint: '>= 8.21.0' + prettier: '>= 3.0.0' + + '@vue/eslint-config-typescript@14.6.0': + resolution: {integrity: sha512-UpiRY/7go4Yps4mYCjkvlIbVWmn9YvPGQDxTAlcKLphyaD77LjIu3plH4Y9zNT0GB4f3K5tMmhhtRhPOgrQ/bQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 + eslint-plugin-vue: ^9.28.0 || ^10.0.0 + typescript: '>=4.8.4' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@3.3.7': + resolution: {integrity: sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==} + + '@vue/reactivity@3.5.30': + resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} + + '@vue/runtime-core@3.5.30': + resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} + + '@vue/runtime-dom@3.5.30': + resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} + + '@vue/server-renderer@3.5.30': + resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} + peerDependencies: + vue: 3.5.30 + + '@vue/shared@3.5.30': + resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} + + '@vue/shared@3.5.40': + resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + + '@vueup/vue-quill@1.2.0': + resolution: {integrity: sha512-kd5QPSHMDpycklojPXno2Kw2JSiKMYduKYQckTm1RJoVDA557MnyUXgcuuDpry4HY/Rny9nGNcK+m3AHk94wag==} + peerDependencies: + vue: ^3.2.41 + + '@vueuse/core@12.0.0': + resolution: {integrity: sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==} + + '@vueuse/core@14.2.1': + resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@12.0.0': + resolution: {integrity: sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==} + + '@vueuse/metadata@14.2.1': + resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==} + + '@vueuse/shared@12.0.0': + resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==} + + '@vueuse/shared@14.2.1': + resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==} + peerDependencies: + vue: ^3.5.0 + + '@vxe-ui/core@4.4.18': + resolution: {integrity: sha512-1w9eSWxUe5gIgkZB9QTTWiAcYd9fnnhWeyZNILIp7SwYA8s8962S6bNi2xYv4FFj5ipXrZjfIXdOUJzb3d3aYw==} + peerDependencies: + vue: ^3.2.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + animate.css@4.1.1: + resolution: {integrity: sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-kit@2.1.2: + resolution: {integrity: sha512-cl76xfBQM6pztbrFWRnxbrDm9EOqDr1BF6+qQnnDZG2Co2LjyUktkN9GTJfBAfdae+DbT2nJf2nCGAdDDN7W2g==} + engines: {node: '>=20.18.0'} + + ast-kit@2.1.3: + resolution: {integrity: sha512-TH+b3Lv6pUjy/Nu0m6A2JULtdzLpmqF9x1Dhj00ZoEiML8qvVA9j1flkzTKNYgdEhWrjDwtWNpyyCUbfQe514g==} + engines: {node: '>=20.19.0'} + + ast-walker-scope@0.8.3: + resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} + engines: {node: '>=20.19.0'} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + await-to-js@3.0.0: + resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} + engines: {node: '>=6.0.0'} + + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + + balanced-match@1.0.0: + resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + birpc@2.3.0: + resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==} + + birpc@2.6.1: + resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} + + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-tree@2.2.0: + resolution: {integrity: sha512-7y32czN0VBL8WkevhC/mrHnoHOmQaJ1Wvp8sjRuTz6/n9cjL83jQaUru2MvP7kzjpGVwrSy5CE4XyQObWGIHQQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-equal@1.1.2: + resolution: {integrity: sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==} + engines: {node: '>= 0.4'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-zindex@1.0.7: + resolution: {integrity: sha512-cKU/h8v8IPBgdZOTPbPmq3Ib+Ac5C+kKoh9I4LbGR9BM3GwbmB16KYWKJcj5M2BavnA66EbgYzxYDLd1IytnlQ==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.2: + resolution: {integrity: sha512-pr8ToPIuwBonzUy42STpc5Cf0m69zsQ7gtCLLvKrTbhVRnRohT2pLiJmGp3PAh16nDVWpYpcRpdjuk1vFmnQUg==} + engines: {node: '>= 4'} + + domutils@3.0.1: + resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + echarts@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + + electron-to-chromium@1.5.392: + resolution: {integrity: sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==} + + element-plus@2.13.5: + resolution: {integrity: sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==} + peerDependencies: + vue: ^3.3.0 + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + entities@4.2.0: + resolution: {integrity: sha512-wEJa03bJgqEwPnkUqYdgmcfUXfm6+4hePQhntIvRy/1/+C4dFuhYHsgKBRjbQ6OWBh42P+VhAoCDO77DUh0e/Q==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-vue@9.33.0: + resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-scope@7.1.1: + resolution: {integrity: sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.3.0: + resolution: {integrity: sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.1: + resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + espree@9.3.1: + resolution: {integrity: sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@2.0.3: + resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exsolve@1.1.0: + resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.1.2: + resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==} + + fast-diff@1.2.0: + resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-xml-builder@1.3.0: + resolution: {integrity: sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==} + + fast-xml-parser@5.10.1: + resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} + hasBin: true + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.0: + resolution: {integrity: sha512-EryKbCE/wxpxKniQlyas6PY1I9vwtF3uCBweX+N8KYTCn3Y12RTGtQAJ/bd5pl7kxUAc8v/R3Ake/N17OZiFqA==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.1.0: + resolution: {integrity: sha512-lXeSPRCndWPaipZbtI4CkvTZpF6OPsy19dkvf7+5AHeJD+w+iAKPc9Q78xWBmX4SdR+8xrtY9jTXs/YDv8q+Ug==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + ignore@5.2.0: + resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + image-conversion@2.1.1: + resolution: {integrity: sha512-hnMOmP7q2jxA+52FZ+wHNhg3fdFRlgfngsQH2JQHEQkafY7tj/8F15e6Rv/RxDegc872jvyaRHwMbkTZK1Cjbg==} + + immutable@5.1.9: + resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} + + import-fresh@3.2.1: + resolution: {integrity: sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-unsafe@2.0.0: + resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.1.2: + resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==} + engines: {node: '>=14'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsencrypt@3.5.4: + resolution: {integrity: sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + + magic-string-ast@1.0.2: + resolution: {integrity: sha512-8ngQgLhcT0t3YBdn9CGkZqCYlvwW9pm7aWJwd7AxseVWf1RU8ZHCQvG1mt3N5vvUme+pXTcHB8G/7fE666U8Vw==} + engines: {node: '>=20.18.0'} + + magic-string@0.26.2: + resolution: {integrity: sha512-NzzlXpclt5zAbmo6h6jNc8zl2gNRGHvmsZW4IvZhTC4W7k4OlLP+S5YLussa/r3ixNT66KOQfNORlXHSOy/X4A==} + engines: {node: '>=12'} + + magic-string@0.30.12: + resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@0.30.4: + resolution: {integrity: sha512-Q/TKtsC5BPm0kGqgBIF9oXAs/xEf2vRKiIB4wCRQTJOQIByZ1d+NnUOotvJOvNpi5RNIgVOMC3pOuaP1ZTDlVg==} + engines: {node: '>=12'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-addon-api@7.0.0: + resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + nprogress@0.2.0: + resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + + nth-check@2.0.1: + resolution: {integrity: sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + oxc-parser@0.115.0: + resolution: {integrity: sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-walker@0.7.0: + resolution: {integrity: sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==} + peerDependencies: + oxc-parser: '>=0.98.0' + + p-limit@3.0.2: + resolution: {integrity: sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.7.0: + resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + + parchment@1.1.4: + resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quill-delta@3.6.3: + resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==} + engines: {node: '>=0.10'} + + quill-delta@4.2.2: + resolution: {integrity: sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==} + + quill@1.3.7: + resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@5.0.5: + resolution: {integrity: sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==} + engines: {node: '>=14'} + hasBin: true + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.98.0: + resolution: {integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==} + engines: {node: '>=14.0.0'} + hasBin: true + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + screenfull@6.0.2: + resolution: {integrity: sha512-AQdy8s4WhNvUZ6P8F6PB21tSPIYKniic+Ogx0AacBMjKP1GUHN2E9URxQHtCusiwxudnCKkdy4GrHXPPJSkCCw==} + engines: {node: ^14.13.1 || >=16.0.0} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.0: + resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + svg-icon-baker@2.1.0: + resolution: {integrity: sha512-MaXBjc1rJfEHoUsxD50HYPmu0EPcSoN3UJT7cQPkmr/3es0XBxyd+8I9l1b/Wuma718hg0J+CeoUZsmbe+oz4g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + svgo@4.0.2: + resolution: {integrity: sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==} + engines: {node: '>=16'} + hasBin: true + + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-level-regexp@0.1.17: + resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + + typescript-eslint@8.64.0: + resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unimport@5.7.0: + resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} + engines: {node: '>=18.12.0'} + + unocss@66.6.6: + resolution: {integrity: sha512-PRKK945e2oZKHV664MA5Z9CDHbvY/V79IvTOUWKZ514jpl3UsJU3sS+skgxmKJSmwrWvXE5OVcmPthJrD/7vxg==} + engines: {node: '>=14'} + peerDependencies: + '@unocss/astro': 66.6.6 + '@unocss/postcss': 66.6.6 + '@unocss/webpack': 66.6.6 + peerDependenciesMeta: + '@unocss/astro': + optional: true + '@unocss/postcss': + optional: true + '@unocss/webpack': + optional: true + + unplugin-auto-import@21.0.0: + resolution: {integrity: sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^4.0.0 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-icons@23.0.1: + resolution: {integrity: sha512-rv0XEJepajKzDLvRUWASM8K+8+/CCfZn2jtogXqg6RIp7kpatRc/aFrVJn8ANQA09e++lPEEv9yX8cC9enc+QQ==} + peerDependencies: + '@svgr/core': '>=7.0.0' + '@svgx/core': ^1.0.1 + '@vue/compiler-sfc': ^3.0.2 + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@svgr/core': + optional: true + '@svgx/core': + optional: true + '@vue/compiler-sfc': + optional: true + svelte: + optional: true + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@31.0.0: + resolution: {integrity: sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^3.2.2 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + unplugin-vue-setup-extend-plus@1.0.1: + resolution: {integrity: sha512-mW2IzkyJITyspAV/LEdnEyE1CJip9jB5fCeaVv7Q6X0oJyDrOxXoB+jyet0q5pRJNjErbjQx950/8NPTvbqLTQ==} + + unplugin@1.3.1: + resolution: {integrity: sha512-h4uUTIvFBQRxUKS2Wjys6ivoeofGhxzTe2sRWlooyjHXVttcVfV/JiavNd3d4+jty0SVV0dxGw9AkY9MwiaCEw==} + + unplugin@2.0.0: + resolution: {integrity: sha512-26eihuX14zPtiW6gzz8B112Buhi9CaWH/5ezO67pzBhKoz3MfHyc2lz/QOMOyEd/DWk+OnS0zCiYixnm8Q3dqA==} + engines: {node: '>=18.12.0'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-dev-rpc@2.0.0: + resolution: {integrity: sha512-yKwbTwdHKSD2k/aGqyWpPHepo45OQc8lH3/6IfT4ZqeKE26ooKvi4WIEKzqWav8v+9Is8u1k8q54hvOmqASazA==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 || ^8.0.0 + + vite-hot-client@2.2.0: + resolution: {integrity: sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w==} + peerDependencies: + vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0 + + vite-plugin-inspect@11.4.1: + resolution: {integrity: sha512-ShOFe2PURXGvRS5OrgmOLZOCwDTD7dEBVt0tMpFPKb9AsvqXKCRGM8QgKrUbRbJYFXScHvDPpGRd28rYidC0tA==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: ^6.0.0 || ^7.0.0-0 || ^8.0.0-0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + vite-plugin-svg-icons-ng@1.9.2: + resolution: {integrity: sha512-C/hLJw4irOUVNJZB7AZi5YghCSFbu59py4jl2B6AJvV2gAk43pPjk8h1pn57/ngzOrWX9+cEr/qbvBuLxCasAw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + peerDependencies: + vite: '>=5.0.0' + + vite-plugin-vue-devtools@8.0.7: + resolution: {integrity: sha512-BWj/ykGpqVAJVdPyHmSTUm44buz3jPv+6jnvuFdQSRH0kAgP1cEIE4doHiFyqHXOmuB5EQVR/nh2g9YRiRNs9g==} + engines: {node: '>=v14.21.3'} + peerDependencies: + vite: ^6.0.0 || ^7.0.0-0 || ^8.0.0-0 + + vite-plugin-vue-inspector@5.4.0: + resolution: {integrity: sha512-Iq/024CydcE46FZqWPU4t4lw4uYOdLnFSO1RNxJVt2qY9zxIjmnkBqhHnYaReWM82kmNnaXs7OkfgRrV2GEjyw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-cropper@1.1.4: + resolution: {integrity: sha512-5m98vBsCEI9rbS4JxELxXidtAui3qNyTHLHg67Qbn7g8cg+E6LcnC+hh3SM/p94x6mFh6KRxT1ttnta+wCYqWA==} + + vue-eslint-parser@10.4.1: + resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-eslint-parser@9.4.3: + resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + + vue-i18n@11.3.0: + resolution: {integrity: sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==} + engines: {node: '>= 16'} + peerDependencies: + vue: ^3.0.0 + + vue-json-pretty@2.6.0: + resolution: {integrity: sha512-glz1aBVS35EO8+S9agIl3WOQaW2cJZW192UVKTuGmryx01ZvOVWc4pR3t+5UcyY4jdOfBUgVHjcpRpcnjRhCAg==} + engines: {node: '>= 10.0.0', npm: '>= 5.0.0'} + peerDependencies: + vue: '>=3.0.0' + + vue-router@5.0.3: + resolution: {integrity: sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.17 + pinia: ^3.0.4 + vue: ^3.5.0 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + + vue-tsc@3.3.7: + resolution: {integrity: sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue-types@6.0.0: + resolution: {integrity: sha512-fBgCA4nrBrB8SCU/AN40tFq8HUxLGBvU2ds7a5+SEDse6dYc+TJyvy8mWiwwL8oWIC/aGS/8nTqmhwxApgU5eA==} + engines: {node: '>=14.0.0'} + peerDependencies: + vue: ^3.0.0 + peerDependenciesMeta: + vue: + optional: true + + vue@3.5.30: + resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vxe-pc-ui@4.16.13: + resolution: {integrity: sha512-JjNUkNX5h36Ny0f2FXLWfSbAZCrbEDKffVhMA0ZGFzIKPiKOLIqKesj8z+ATrRiuqroW5J8bAD1W3Ay8TdnjyA==} + + vxe-table@4.18.1: + resolution: {integrity: sha512-+FvU3hGoEpTK2SlbUMNjy0uTz8Zkc6fn2LU8RLl8g681jUfYT7CeMt8I5DhizgQagikTI0jDsLIXABKdyDz++g==} + + webpack-sources@3.5.1: + resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.5.0: + resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@2.0.1: + resolution: {integrity: sha512-N7GBZOTswtB9lkQBZA4+zAXrjEIWAUOB93AvzUiudRzRxhUdLURQ7D/gAIMY1gatT/LTbmbcv8SiYazy3eYB7w==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + xe-utils@4.0.11: + resolution: {integrity: sha512-r3v1o10AnHXKRZ5ikuAOQ+Mxw/hy1XWjFr2/Q9L+LEZYq/PRxmiZ6/J53DEH53XqklEXddsmx1vYjOaOACq8IQ==} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + +snapshots: + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.7.0 + tinyexec: 1.2.4 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@ctrl/tinycolor@4.2.0': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.30(typescript@5.9.3))': + dependencies: + vue: 3.5.30(typescript@5.9.3) + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.1(jiti@2.7.0))': + dependencies: + eslint: 9.39.1(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.2.0 + import-fresh: 3.2.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.1': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@highlightjs/vue-plugin@2.1.2(highlight.js@11.11.1)(vue@3.5.30(typescript@5.9.3))': + dependencies: + highlight.js: 11.11.1 + vue: 3.5.30(typescript@5.9.3) + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify/json@2.2.500': + dependencies: + '@iconify/types': 2.0.0 + pathe: 2.0.3 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@intlify/core-base@11.3.0': + dependencies: + '@intlify/devtools-types': 11.3.0 + '@intlify/message-compiler': 11.3.0 + '@intlify/shared': 11.3.0 + + '@intlify/devtools-types@11.3.0': + dependencies: + '@intlify/core-base': 11.3.0 + '@intlify/shared': 11.3.0 + + '@intlify/message-compiler@11.3.0': + dependencies: + '@intlify/shared': 11.3.0 + source-map-js: 1.2.1 + + '@intlify/shared@11.3.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.0 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.12': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.10 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.4.10': {} + + '@jridgewell/sourcemap-codec@1.4.14': {} + + '@jridgewell/sourcemap-codec@1.4.15': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@nodable/entities@3.0.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.115.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.115.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + optional: true + + '@oxc-project/types@0.115.0': {} + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.0.0 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.3.6': {} + + '@polka/url@1.0.0-next.29': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@sxzz/popperjs-es@2.11.8': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/crypto-js@4.2.2': {} + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/file-saver@2.0.7': {} + + '@types/js-cookie@3.0.6': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + + '@types/nprogress@0.2.3': {} + + '@types/web-bluetooth@0.0.20': {} + + '@types/web-bluetooth@0.0.21': {} + + '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/type-utils': 8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.64.0 + eslint: 9.39.1(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.64.0 + debug: 4.4.3 + eslint: 9.39.1(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.64.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.9.3) + '@typescript-eslint/types': 8.64.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.64.0': + dependencies: + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 + + '@typescript-eslint/tsconfig-utils@8.64.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.64.0': {} + + '@typescript-eslint/typescript-estree@8.64.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.64.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.9.3) + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.9.3) + eslint: 9.39.1(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.64.0': + dependencies: + '@typescript-eslint/types': 8.64.0 + eslint-visitor-keys: 5.0.1 + + '@unocss/cli@66.6.6': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + cac: 6.7.14 + chokidar: 5.0.0 + colorette: 2.0.20 + consola: 3.4.2 + magic-string: 0.30.21 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.2 + + '@unocss/config@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + colorette: 2.0.20 + consola: 3.4.2 + unconfig: 7.5.0 + + '@unocss/core@66.6.6': {} + + '@unocss/extractor-arbitrary-variants@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/inspector@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + colorette: 2.0.20 + gzip-size: 6.0.0 + sirv: 3.0.2 + + '@unocss/preset-attributify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-icons@66.6.6': + dependencies: + '@iconify/utils': 3.1.4 + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-mini@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-tagify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-typography@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-uno@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/preset-web-fonts@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-wind3@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind4@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/rule-utils@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + magic-string: 0.30.21 + + '@unocss/transformer-attributify-jsx@66.6.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@unocss/core': 66.6.6 + oxc-parser: 0.115.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + oxc-walker: 0.7.0(oxc-parser@0.115.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@unocss/transformer-compile-class@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/transformer-directives@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + css-tree: 3.2.1 + + '@unocss/transformer-variant-group@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/vite@66.6.6(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0))': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/inspector': 66.6.6 + chokidar: 5.0.0 + magic-string: 0.30.21 + pathe: 2.0.3 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.2 + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + + '@vitejs/plugin-vue@6.0.4(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + vue: 3.5.30(typescript@5.9.3) + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.0.18(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue-macros/common@3.1.3(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/compiler-sfc': 3.5.30 + ast-kit: 2.1.2 + local-pkg: 1.2.1 + magic-string-ast: 1.0.2 + unplugin-utils: 0.3.2 + optionalDependencies: + vue: 3.5.30(typescript@5.9.3) + + '@vue/babel-helper-vue-transform-on@1.1.5': {} + + '@vue/babel-plugin-jsx@1.1.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@vue/babel-helper-vue-transform-on': 1.1.5 + camelcase: 6.3.0 + html-tags: 3.3.1 + svg-tags: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@vue/compiler-core@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.30 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-core@3.5.40': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.40 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.30': + dependencies: + '@vue/compiler-core': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-dom@3.5.40': + dependencies: + '@vue/compiler-core': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-sfc@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.30 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.19 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.30': + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/devtools-api@6.5.0': {} + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-api@8.1.5': + dependencies: + '@vue/devtools-kit': 8.1.5 + + '@vue/devtools-core@8.1.5(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/devtools-kit': 8.1.5 + '@vue/devtools-shared': 8.1.5 + vue: 3.5.30(typescript@5.9.3) + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.3.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-kit@8.1.5': + dependencies: + '@vue/devtools-shared': 8.1.5 + birpc: 2.6.1 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/devtools-shared@8.1.5': {} + + '@vue/eslint-config-prettier@10.2.0(eslint@9.39.1(jiti@2.7.0))(prettier@3.8.1)': + dependencies: + eslint: 9.39.1(jiti@2.7.0) + eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.7.0)) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.7.0)))(eslint@9.39.1(jiti@2.7.0))(prettier@3.8.1) + prettier: 3.8.1 + transitivePeerDependencies: + - '@types/eslint' + + '@vue/eslint-config-typescript@14.6.0(eslint-plugin-vue@9.33.0(eslint@9.39.1(jiti@2.7.0)))(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.7.0) + eslint-plugin-vue: 9.33.0(eslint@9.39.1(jiti@2.7.0)) + fast-glob: 3.3.3 + typescript-eslint: 8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + vue-eslint-parser: 10.4.1(eslint@9.39.1(jiti@2.7.0)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@3.3.7': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.40 + '@vue/shared': 3.5.40 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.5 + + '@vue/reactivity@3.5.30': + dependencies: + '@vue/shared': 3.5.30 + + '@vue/runtime-core@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/runtime-dom@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/runtime-core': 3.5.30 + '@vue/shared': 3.5.30 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + vue: 3.5.30(typescript@5.9.3) + + '@vue/shared@3.5.30': {} + + '@vue/shared@3.5.40': {} + + '@vueup/vue-quill@1.2.0(vue@3.5.30(typescript@5.9.3))': + dependencies: + quill: 1.3.7 + quill-delta: 4.2.2 + vue: 3.5.30(typescript@5.9.3) + + '@vueuse/core@12.0.0(typescript@5.9.3)': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 12.0.0 + '@vueuse/shared': 12.0.0(typescript@5.9.3) + vue: 3.5.30(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/core@14.2.1(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.2.1 + '@vueuse/shared': 14.2.1(vue@3.5.30(typescript@5.9.3)) + vue: 3.5.30(typescript@5.9.3) + + '@vueuse/metadata@12.0.0': {} + + '@vueuse/metadata@14.2.1': {} + + '@vueuse/shared@12.0.0(typescript@5.9.3)': + dependencies: + vue: 3.5.30(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/shared@14.2.1(vue@3.5.30(typescript@5.9.3))': + dependencies: + vue: 3.5.30(typescript@5.9.3) + + '@vxe-ui/core@4.4.18(vue@3.5.30(typescript@5.9.3))': + dependencies: + dom-zindex: 1.0.7 + vue: 3.5.30(typescript@5.9.3) + xe-utils: 4.0.11 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + alien-signals@3.2.1: {} + + animate.css@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.3.1: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + anynum@1.0.1: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + ast-kit@2.1.2: + dependencies: + '@babel/parser': 7.29.7 + pathe: 2.0.3 + + ast-kit@2.1.3: + dependencies: + '@babel/parser': 7.29.7 + pathe: 2.0.3 + + ast-walker-scope@0.8.3: + dependencies: + '@babel/parser': 7.29.7 + ast-kit: 2.1.3 + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + autoprefixer@10.4.27(postcss@8.5.19): + dependencies: + browserslist: 4.28.6 + caniuse-lite: 1.0.30001806 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.19 + postcss-value-parser: 4.2.0 + + await-to-js@3.0.0: {} + + axios@1.13.6: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.0: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.43: {} + + binary-extensions@2.3.0: {} + + birpc@2.3.0: {} + + birpc@2.6.1: {} + + birpc@4.0.0: {} + + boolbase@1.0.0: {} + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.0 + concat-map: 0.0.1 + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.0 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.392 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001806: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + clone@2.1.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@11.1.0: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.1 + + crypto-js@4.2.0: {} + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.2 + domutils: 3.0.1 + nth-check: 2.0.1 + + css-tree@2.2.0: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.1.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.0 + + csstype@3.2.3: {} + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-equal@1.1.2: + dependencies: + is-arguments: 1.2.0 + is-date-object: 1.1.0 + is-regex: 1.2.1 + object-is: 1.1.6 + object-keys: 1.1.1 + regexp.prototype.flags: 1.5.4 + + deep-is@0.1.4: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@3.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: + optional: true + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.2 + entities: 4.2.0 + + dom-zindex@1.0.7: {} + + domelementtype@2.3.0: {} + + domhandler@5.0.2: + dependencies: + domelementtype: 2.3.0 + + domutils@3.0.1: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.2 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} + + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + + electron-to-chromium@1.5.392: {} + + element-plus@2.13.5(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@ctrl/tinycolor': 4.2.0 + '@element-plus/icons-vue': 2.3.2(vue@3.5.30(typescript@5.9.3)) + '@floating-ui/dom': 1.8.0 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.24 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 12.0.0(typescript@5.9.3) + async-validator: 4.2.5 + dayjs: 1.11.21 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.30(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + entities@4.2.0: {} + + entities@7.0.1: {} + + error-stack-parser-es@1.0.5: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.7.0)): + dependencies: + eslint: 9.39.1(jiti@2.7.0) + + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.7.0)))(eslint@9.39.1(jiti@2.7.0))(prettier@3.8.1): + dependencies: + eslint: 9.39.1(jiti@2.7.0) + prettier: 3.8.1 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.7.0)) + + eslint-plugin-vue@9.33.0(eslint@9.39.1(jiti@2.7.0)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.7.0)) + eslint: 9.39.1(jiti@2.7.0) + globals: 13.24.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.4 + semver: 7.8.5 + vue-eslint-parser: 9.4.3(eslint@9.39.1(jiti@2.7.0)) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + + eslint-scope@7.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.3.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.1(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.1 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.2.0 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + espree@9.3.1: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 3.3.0 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + eventemitter3@2.0.3: {} + + expect-type@1.4.0: {} + + exsolve@1.1.0: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.1.2: {} + + fast-diff@1.2.0: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-xml-builder@1.3.0: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.10.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.0 + is-unsafe: 2.0.0 + path-expression-matcher: 1.6.2 + strnum: 2.4.1 + xml-naming: 0.3.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.0 + + file-saver@2.0.5: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.0: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + rimraf: 5.0.5 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + foreground-child@3.1.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + functions-have-names@1.2.3: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.1.0 + jackspeak: 3.1.2 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globals@14.0.0: {} + + globals@17.4.0: {} + + gopd@1.2.0: {} + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + highlight.js@11.11.1: {} + + hookable@5.5.3: {} + + html-tags@3.3.1: {} + + ignore@5.2.0: {} + + ignore@7.0.6: {} + + image-conversion@2.1.1: {} + + immutable@5.1.9: {} + + import-fresh@3.2.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-unsafe@2.0.0: {} + + is-what@5.5.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + jackspeak@3.1.2: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.7.0: {} + + js-cookie@3.0.5: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsencrypt@3.5.4: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kolorist@1.8.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash.clonedeep@4.5.0: {} + + lodash.isequal@4.5.0: {} + + lodash.merge@4.6.2: {} + + lodash@4.18.1: {} + + lru-cache@10.2.0: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.12 + mlly: 1.8.2 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.4 + unplugin: 2.0.0 + + magic-string-ast@1.0.2: + dependencies: + magic-string: 0.30.21 + + magic-string@0.26.2: + dependencies: + sourcemap-codec: 1.4.8 + + magic-string@0.30.12: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + memoize-one@6.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minipass@7.1.3: {} + + mitt@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.16: {} + + natural-compare@1.4.0: {} + + node-addon-api@7.0.0: + optional: true + + node-fetch-native@1.6.7: {} + + node-releases@2.0.51: {} + + normalize-path@3.0.0: {} + + normalize-wheel-es@1.2.0: {} + + nprogress@0.2.0: {} + + nth-check@2.0.1: + dependencies: + boolbase: 1.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + obug@2.1.3: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.11: {} + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + oxc-parser@0.115.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2): + dependencies: + '@oxc-project/types': 0.115.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.115.0 + '@oxc-parser/binding-android-arm64': 0.115.0 + '@oxc-parser/binding-darwin-arm64': 0.115.0 + '@oxc-parser/binding-darwin-x64': 0.115.0 + '@oxc-parser/binding-freebsd-x64': 0.115.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.115.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.115.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.115.0 + '@oxc-parser/binding-linux-arm64-musl': 0.115.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.115.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-musl': 0.115.0 + '@oxc-parser/binding-openharmony-arm64': 0.115.0 + '@oxc-parser/binding-wasm32-wasi': 0.115.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@oxc-parser/binding-win32-arm64-msvc': 0.115.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.115.0 + '@oxc-parser/binding-win32-x64-msvc': 0.115.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-walker@0.7.0(oxc-parser@0.115.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)): + dependencies: + magic-regexp: 0.10.0 + oxc-parser: 0.115.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + + p-limit@3.0.2: + dependencies: + p-try: 2.2.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.0.2 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.7.0: {} + + parchment@1.1.4: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-expression-matcher@1.6.2: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.2.0 + minipass: 7.1.3 + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.10 + vue: 3.5.30(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.0 + pathe: 2.0.3 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.19: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.8.1: {} + + proxy-from-env@1.1.0: {} + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + quill-delta@3.6.3: + dependencies: + deep-equal: 1.1.2 + extend: 3.0.2 + fast-diff: 1.1.2 + + quill-delta@4.2.2: + dependencies: + fast-diff: 1.2.0 + lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 + + quill@1.3.7: + dependencies: + clone: 2.1.2 + deep-equal: 1.1.2 + eventemitter3: 2.0.3 + extend: 3.0.2 + parchment: 1.1.4 + quill-delta: 3.6.3 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + regexp-tree@0.1.27: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@5.0.5: + dependencies: + glob: 10.5.0 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.98.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.9 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.6 + + sax@1.6.0: {} + + screenfull@6.0.2: {} + + scule@1.3.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + sourcemap-codec@1.4.8: {} + + speakingurl@14.0.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.0: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + svg-icon-baker@2.1.0: + dependencies: + css-tree: 3.2.1 + fast-xml-builder: 1.3.0 + fast-xml-parser: 5.10.1 + svgo: 4.0.2 + + svg-tags@1.0.0: {} + + svgo@4.0.2: + dependencies: + commander: 11.1.0 + css-select: 5.1.0 + css-tree: 3.2.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + totalist@3.0.1: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.3.0: {} + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-level-regexp@0.1.17: {} + + typescript-eslint@8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.7 + jiti: 2.7.0 + quansync: 1.0.0 + unconfig-core: 7.5.0 + + undici-types@7.24.6: {} + + unimport@5.7.0: + dependencies: + acorn: 8.17.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.5 + pkg-types: 2.3.1 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.17 + unplugin: 2.3.11 + unplugin-utils: 0.3.2 + + unocss@66.6.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)): + dependencies: + '@unocss/cli': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-attributify': 66.6.6 + '@unocss/preset-icons': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/preset-tagify': 66.6.6 + '@unocss/preset-typography': 66.6.6 + '@unocss/preset-uno': 66.6.6 + '@unocss/preset-web-fonts': 66.6.6 + '@unocss/preset-wind': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-attributify-jsx': 66.6.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@unocss/transformer-compile-class': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + '@unocss/transformer-variant-group': 66.6.6 + '@unocss/vite': 66.6.6(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - vite + + unplugin-auto-import@21.0.0(@vueuse/core@14.2.1(vue@3.5.30(typescript@5.9.3))): + dependencies: + local-pkg: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.5 + unimport: 5.7.0 + unplugin: 2.3.11 + unplugin-utils: 0.3.2 + optionalDependencies: + '@vueuse/core': 14.2.1(vue@3.5.30(typescript@5.9.3)) + + unplugin-icons@23.0.1(@vue/compiler-sfc@3.5.30): + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/utils': 3.1.4 + local-pkg: 1.2.1 + obug: 2.1.3 + unplugin: 2.3.11 + optionalDependencies: + '@vue/compiler-sfc': 3.5.30 + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.5 + + unplugin-vue-components@31.0.0(vue@3.5.30(typescript@5.9.3)): + dependencies: + chokidar: 5.0.0 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + obug: 2.1.3 + picomatch: 4.0.5 + tinyglobby: 0.2.17 + unplugin: 2.3.11 + unplugin-utils: 0.3.2 + vue: 3.5.30(typescript@5.9.3) + + unplugin-vue-setup-extend-plus@1.0.1: + dependencies: + '@vue/compiler-sfc': 3.5.30 + magic-string: 0.26.2 + unplugin: 1.3.1 + + unplugin@1.3.1: + dependencies: + acorn: 8.17.0 + chokidar: 3.6.0 + webpack-sources: 3.5.1 + webpack-virtual-modules: 0.5.0 + + unplugin@2.0.0: + dependencies: + acorn: 8.17.0 + webpack-virtual-modules: 0.6.2 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + + unplugin@3.3.0(esbuild@0.27.7)(rollup@4.62.2)(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + esbuild: 0.27.7 + rollup: 4.62.2 + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-dev-rpc@2.0.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)): + dependencies: + birpc: 4.0.0 + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + vite-hot-client: 2.2.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)) + + vite-hot-client@2.2.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)): + dependencies: + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + + vite-plugin-inspect@11.4.1(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)): + dependencies: + ansis: 4.3.1 + error-stack-parser-es: 1.0.5 + obug: 2.1.3 + ohash: 2.0.11 + open: 11.0.0 + perfect-debounce: 2.1.0 + sirv: 3.0.2 + unplugin-utils: 0.3.2 + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + vite-dev-rpc: 2.0.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)) + + vite-plugin-svg-icons-ng@1.9.2(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)): + dependencies: + svg-icon-baker: 2.1.0 + tinyglobby: 0.2.17 + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + + vite-plugin-vue-devtools@8.0.7(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-core': 8.1.5(vue@3.5.30(typescript@5.9.3)) + '@vue/devtools-kit': 8.1.5 + '@vue/devtools-shared': 8.1.5 + sirv: 3.0.2 + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + vite-plugin-inspect: 11.4.1(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)) + vite-plugin-vue-inspector: 5.4.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)) + transitivePeerDependencies: + - '@nuxt/kit' + - supports-color + - vue + + vite-plugin-vue-inspector@5.4.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@vue/babel-plugin-jsx': 1.1.5(@babel/core@7.29.7) + '@vue/compiler-dom': 3.5.40 + kolorist: 1.8.0 + magic-string: 0.30.4 + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.19 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.5 + fsevents: 2.3.3 + jiti: 2.7.0 + sass: 1.98.0 + yaml: 2.9.0 + + vitest@4.0.18(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.5 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vscode-uri@3.1.0: {} + + vue-cropper@1.1.4: {} + + vue-eslint-parser@10.4.1(eslint@9.39.1(jiti@2.7.0)): + dependencies: + debug: 4.4.3 + eslint: 9.39.1(jiti@2.7.0) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-eslint-parser@9.4.3(eslint@9.39.1(jiti@2.7.0)): + dependencies: + debug: 4.4.3 + eslint: 9.39.1(jiti@2.7.0) + eslint-scope: 7.1.1 + eslint-visitor-keys: 3.3.0 + espree: 9.3.1 + esquery: 1.7.0 + lodash: 4.18.1 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-i18n@11.3.0(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@intlify/core-base': 11.3.0 + '@intlify/devtools-types': 11.3.0 + '@intlify/shared': 11.3.0 + '@vue/devtools-api': 6.5.0 + vue: 3.5.30(typescript@5.9.3) + + vue-json-pretty@2.6.0(vue@3.5.30(typescript@5.9.3)): + dependencies: + vue: 3.5.30(typescript@5.9.3) + + vue-router@5.0.3(@vue/compiler-sfc@3.5.30)(esbuild@0.27.7)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)))(rollup@4.62.2)(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@babel/generator': 7.29.7 + '@vue-macros/common': 3.1.3(vue@3.5.30(typescript@5.9.3)) + '@vue/devtools-api': 8.1.5 + ast-walker-scope: 0.8.3 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + pathe: 2.0.3 + picomatch: 4.0.5 + scule: 1.3.0 + tinyglobby: 0.2.17 + unplugin: 3.3.0(esbuild@0.27.7)(rollup@4.62.2)(vite@7.3.2(@types/node@25.9.5)(jiti@2.7.0)(sass@1.98.0)(yaml@2.9.0)) + unplugin-utils: 0.3.2 + vue: 3.5.30(typescript@5.9.3) + yaml: 2.9.0 + optionalDependencies: + '@vue/compiler-sfc': 3.5.30 + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - vite + - webpack + + vue-tsc@3.3.7(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.7 + typescript: 5.9.3 + + vue-types@6.0.0(vue@3.5.30(typescript@5.9.3)): + optionalDependencies: + vue: 3.5.30(typescript@5.9.3) + + vue@3.5.30(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) + '@vue/shared': 3.5.30 + optionalDependencies: + typescript: 5.9.3 + + vxe-pc-ui@4.16.13(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vxe-ui/core': 4.4.18(vue@3.5.30(typescript@5.9.3)) + transitivePeerDependencies: + - vue + + vxe-table@4.18.1(vue@3.5.30(typescript@5.9.3)): + dependencies: + vxe-pc-ui: 4.16.13(vue@3.5.30(typescript@5.9.3)) + transitivePeerDependencies: + - vue + + webpack-sources@3.5.1: {} + + webpack-virtual-modules@0.5.0: {} + + webpack-virtual-modules@0.6.2: {} + + which@2.0.1: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.0 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + xe-utils@4.0.11: {} + + xml-name-validator@4.0.0: {} + + xml-naming@0.3.0: {} + + yallist@3.1.1: {} + + yaml@2.9.0: {} + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 00000000..b9a5d61b --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@parcel/watcher': set this to true or false + esbuild: set this to true or false diff --git a/frontend/src/api/aihr/mobile-admin.ts b/frontend/src/api/aihr/mobile-admin.ts index 0afaf01b..622d867c 100644 --- a/frontend/src/api/aihr/mobile-admin.ts +++ b/frontend/src/api/aihr/mobile-admin.ts @@ -83,6 +83,30 @@ export type PracticeAssignmentRequest = { reason: string; }; +export type PracticeDifficulty = { + scenarioId: string; + scenarioName: string; + cause: string; + count: number; + affectedPeople: number; + averageScore: number; + latestTime: string; + position?: string; + assignmentStatus?: string; + attempts?: number; + mistakeCount?: number; + difficultyScore?: number; +}; + +export type PracticeAssignmentBatchResponse = { + requestId: string; + total: number; + created: number; + duplicate: number; + failed: number; + results: Array<{ extPartyId: string; traineeName: string; status: 'CREATED' | 'DUPLICATE' | 'FAILED'; message: string }>; +}; + export type PromotionEvidence = { extPartyId: string; currentLevel: string; @@ -244,6 +268,38 @@ export function createPracticeAssignment(data: PracticeAssignmentRequest): Promi }); } +export function listPracticeDifficulties(params?: { windowDays?: number; position?: string }): Promise> { + type RawDifficulty = Omit & { attempts: number; mistakeCount: number }; + return request>({ + url: '/api/aihr/mobile/practice/difficulties', + method: 'get', + params + }).then((result) => ({ + ...result, + data: (result.data || []).map((item) => ({ + ...item, + cause: item.mistakeCount >= Math.max(2, Math.ceil(item.attempts / 2)) ? '高频错题或低分' : '近期低分记录', + count: item.mistakeCount + })) + })); +} + +export function assignPracticeDifficulty(data: { + scenarioId: string; + cause: string; + dueDate: string; + requestId: string; + windowDays: number; + position?: string; +}): Promise> { + return request({ + url: '/api/aihr/mobile/practice/difficulties/assign', + method: 'post', + data, + headers: { repeatSubmit: false } + }); +} + export function syncOrgSnapshot(data: OrgSyncRequest): Promise> { return request({ url: '/api/aihr/org/sync', diff --git a/frontend/src/api/aihr/questions.ts b/frontend/src/api/aihr/questions.ts new file mode 100644 index 00000000..0fc322e5 --- /dev/null +++ b/frontend/src/api/aihr/questions.ts @@ -0,0 +1,92 @@ +import request from '@/utils/request'; + +type ApiResult = { code: number; msg: string; data: T }; + +export type AdminQuestionAnswer = { + id: number; + questionId: number; + content: string; + authorName: string; + authorPosition?: string; + status: string; + best: boolean; + recommended?: boolean; + createTime: string; +}; + +export type AdminQuestion = { + id: number; + type: 'HELP' | 'BOUNTY' | string; + title: string; + content: string; + category: string; + authorName: string; + status: string; + rewardPoints: number; + rewardAccountType: 'POINTS' | 'STUDY_CREDIT' | string; + answerCount: number; + deadline?: string | null; + createTime: string; + bestAnswerId?: number | null; + answers?: AdminQuestionAnswer[]; +}; + +export type IncentiveRule = { + id?: number; + ruleCode: string; + displayName: string; + accountType: 'POINTS' | 'STUDY_CREDIT'; + value: number; + dailyCap?: number | null; + enabled: boolean; +}; + +type RawAdminQuestionAnswer = Omit & { createdAt: string }; +type RawAdminQuestion = Omit & { + createdAt: string; + answers?: RawAdminQuestionAnswer[]; +}; + +const normalizeAnswer = (item: RawAdminQuestionAnswer): AdminQuestionAnswer => ({ + ...item, + createTime: item.createdAt +}); + +const normalizeQuestion = (item: RawAdminQuestion): AdminQuestion => ({ + ...item, + createTime: item.createdAt, + answers: (item.answers || []).map(normalizeAnswer) +}); + +export const listAdminQuestions = (status?: string): Promise> => + request>({ + url: '/api/aihr/question-admin/questions', method: 'get', params: { status } + }).then((result) => ({ ...result, data: (result.data || []).map(normalizeQuestion) })); + +export const createBountyQuestion = (data: { title: string; content: string; category: string; rewardPoints: number; deadline: string }): Promise> => + request>({ + url: '/api/aihr/question-admin/questions', method: 'post', data: { ...data, ruleCode: 'BEST_ANSWER' }, headers: { repeatSubmit: false } + }).then((result) => ({ ...result, data: normalizeQuestion(result.data) })); + +export const reviewQuestion = (id: number, decision: 'APPROVE' | 'REJECT', note = ''): Promise> => + request>({ + url: `/api/aihr/question-admin/questions/${id}/review`, method: 'post', data: { decision, note }, headers: { repeatSubmit: false } + }).then((result) => ({ ...result, data: normalizeQuestion(result.data) })); + +export const reviewQuestionAnswer = (id: number, decision: 'APPROVE' | 'REJECT', note = ''): Promise> => + request>({ + url: `/api/aihr/question-admin/answers/${id}/review`, method: 'post', data: { decision, note }, headers: { repeatSubmit: false } + }).then((result) => ({ ...result, data: normalizeAnswer(result.data) })); + +export const selectBestAnswer = (questionId: number, answerId: number): Promise> => request({ + url: `/api/aihr/question-admin/questions/${questionId}/best-answer`, method: 'post', data: { answerId }, headers: { repeatSubmit: false } +}); + +export const listIncentiveRules = (): Promise> => request({ + url: '/api/aihr/incentive-admin/rules', method: 'get' +}); + +export const saveIncentiveRule = (data: IncentiveRule): Promise> => request({ + url: '/api/aihr/incentive-admin/rules', + method: data.id ? 'put' : 'post', data, headers: { repeatSubmit: false } +}); diff --git a/frontend/src/api/aihr/sop.ts b/frontend/src/api/aihr/sop.ts index d88c0fb0..b881a6b4 100644 --- a/frontend/src/api/aihr/sop.ts +++ b/frontend/src/api/aihr/sop.ts @@ -16,6 +16,9 @@ export type SopDoc = { export type SopSnippet = { title: string; text: string; + fragmentId?: number | null; + spaceCode?: string | null; + sourceType?: string; }; export type SopRecord = { @@ -29,6 +32,7 @@ export type SopRecord = { }; export type SopSearchResponse = { + requestId?: string; queryText: string; category: string; answer: string; @@ -41,6 +45,31 @@ export type SopSearchResponse = { cautions?: string[]; promptVersion?: string | null; reviewId?: number; + citations?: SopCitation[]; + usedSpaceCodes?: string[]; + noEvidence?: boolean; + data?: unknown; +}; + +export type SopCitation = { + spaceCode?: string | null; + sourceType: string; + docId?: string | null; + title: string; + snippet: string; + fragmentId?: number | null; +}; + +type UnifiedQueryResponse = { + requestId: string; + queryText: string; + answer: string; + citations: SopCitation[]; + usedSpaceCodes: string[]; + noEvidence: boolean; + promptVersion?: string | null; + legacy?: SopSearchResponse | null; + data?: unknown; }; export type SopReviewResponse = { @@ -92,12 +121,46 @@ export function searchKnowledgeSop(data: { position?: string; source?: string; limit: number; + spaceCodes?: string[]; + toolCode?: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'; }): Promise> { - return request({ - url: '/api/knowledge/search', + return request>({ + url: '/api/knowledge/query', method: 'post', data, headers: { repeatSubmit: false } + }).then((result) => { + const response = result.data; + const legacy = response.legacy || ({} as SopSearchResponse); + const snippets = legacy.snippets?.length + ? legacy.snippets + : (response.citations || []).map((item) => ({ + title: item.title, + text: item.snippet, + fragmentId: item.fragmentId, + spaceCode: item.spaceCode, + sourceType: item.sourceType + })); + return { + ...result, + data: { + ...legacy, + requestId: response.requestId, + queryText: response.queryText, + category: legacy.category || data.category || 'sop', + answer: response.answer, + reference: legacy.reference || response.citations.map((item) => item.title).join('、'), + docs: legacy.docs || [], + snippets, + training: legacy.training || [], + records: legacy.records || [], + citations: response.citations || [], + usedSpaceCodes: response.usedSpaceCodes || [], + noEvidence: response.noEvidence, + promptVersion: response.promptVersion, + data: response.data + } + }; }); } @@ -149,6 +212,7 @@ export type UploadItem = { batchId: string; fileName: string; category: string; + spaceCodes: string[]; status: number; statusLabel: string; error: string | null; diff --git a/frontend/src/api/aihr/space.ts b/frontend/src/api/aihr/space.ts new file mode 100644 index 00000000..bed0bf1b --- /dev/null +++ b/frontend/src/api/aihr/space.ts @@ -0,0 +1,107 @@ +import request from '@/utils/request'; + +type ApiResult = { code: number; msg: string; data: T }; + +export type KnowledgeSpace = { + id: number; + code: string; + name: string; + description: string; + spaceType: 'PUBLIC' | 'BUSINESS' | 'MANAGEMENT' | 'EXTERNAL'; + sensitivityLevel: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'; + status: 'DRAFT' | 'ACTIVE' | 'DISABLED'; + documentCount: number; + fragmentCount: number; +}; + +export type SpaceForm = Pick; + +export type SpaceGrant = { + principalType: 'ROLE' | 'USER'; + principalValue: string; + permission: 'READ' | 'MANAGE'; + status: 'ACTIVE' | 'DISABLED'; +}; + +export type KnowledgeDocument = { + attachId: number; + docId: string; + name: string; + type: string; + status: string; + ossId?: number | null; + fragmentCount: number; + updateTime?: string | null; +}; + +export type UnbindDocumentResult = { + attachId: number; + spaceCode: string; + ossId?: number | null; + ossDeleted: boolean; +}; + +export type KnowledgeApp = { + id: number; + appCode: string; + appName: string; + authType: 'SESSION' | 'API_TOKEN'; + internalClientKey?: string | null; + status: 'ACTIVE' | 'DISABLED'; + rateLimitPerMinute: number; + expiresTime?: string | null; + lastUsedTime?: string | null; + spaceCodes: string[]; +}; + +export type AppForm = { + appCode: string; + appName: string; + authType: 'SESSION' | 'API_TOKEN'; + internalClientKey?: string | null; + status: 'ACTIVE' | 'DISABLED'; + rateLimitPerMinute: number; + expiresTime?: string | null; + spaceCodes: string[]; +}; + +export type IssuedToken = { appCode: string; token: string; expiresTime?: string | null }; +export type CreatedApp = { app: KnowledgeApp; token?: string | null }; + +export const listKnowledgeSpaces = (): Promise> => + request({ url: '/api/knowledge/admin/spaces', method: 'get' }); + +export const createKnowledgeSpace = (data: SpaceForm): Promise> => + request({ url: '/api/knowledge/admin/spaces', method: 'post', data }); + +export const updateKnowledgeSpace = (id: number, data: SpaceForm): Promise> => + request({ url: `/api/knowledge/admin/spaces/${id}`, method: 'put', data }); + +export const listSpaceDocuments = (id: number): Promise> => + request({ url: `/api/knowledge/admin/spaces/${id}/documents`, method: 'get' }); + +export const unbindSpaceDocument = (id: number, attachId: number): Promise> => + request({ url: `/api/knowledge/admin/spaces/${id}/documents/${attachId}`, method: 'delete' }); + +export const listSpaceGrants = (id: number): Promise> => + request({ url: `/api/knowledge/admin/spaces/${id}/grants`, method: 'get' }); + +export const replaceSpaceGrants = (id: number, grants: SpaceGrant[]): Promise> => + request({ url: `/api/knowledge/admin/spaces/${id}/grants`, method: 'put', data: { grants } }); + +export const listKnowledgeApps = (): Promise> => + request({ url: '/api/knowledge/admin/apps', method: 'get' }); + +export const createKnowledgeApp = (data: AppForm): Promise> => + request({ url: '/api/knowledge/admin/apps', method: 'post', data }); + +export const updateKnowledgeApp = ( + id: number, + data: Pick +): Promise> => request({ url: `/api/knowledge/admin/apps/${id}`, method: 'put', data }); + +export const replaceKnowledgeAppSpaces = (id: number, spaceCodes: string[]): Promise> => + request({ url: `/api/knowledge/admin/apps/${id}/spaces`, method: 'put', data: { spaceCodes } }); + +export const rotateKnowledgeAppToken = (id: number, expiresTime?: string | null): Promise> => + request({ url: `/api/knowledge/admin/apps/${id}/rotate-token`, method: 'post', data: { expiresTime } }); diff --git a/frontend/src/api/aihr/web-search.ts b/frontend/src/api/aihr/web-search.ts new file mode 100644 index 00000000..482fb258 --- /dev/null +++ b/frontend/src/api/aihr/web-search.ts @@ -0,0 +1,47 @@ +import request from '@/utils/request'; + +type ApiResult = { code: number; msg: string; data: T }; + +export interface WebSearchProvider { + id: number; + name: string; + providerCode: 'tavily' | string; + endpoint: string; + configured: boolean; + enabled: boolean; + lastTestOk: boolean; + lastTestTime?: string | null; +} + +export interface WebSearchProviderForm { + id?: number; + name: string; + providerCode: 'tavily'; + endpoint: string; + apiKey: string; +} + +export const listWebSearchProviders = (): Promise> => request({ + url: '/api/aihr/web-search/providers', + method: 'get' +}); + +export const saveWebSearchProvider = (data: WebSearchProviderForm): Promise> => request({ + url: '/api/aihr/web-search/providers', + method: data.id ? 'put' : 'post', + data, + headers: { repeatSubmit: false } +}); + +export const toggleWebSearchProvider = (id: number, enabled: boolean): Promise> => request({ + url: `/api/aihr/web-search/providers/${id}/enabled`, + method: 'patch', + data: { enabled }, + headers: { repeatSubmit: false } +}); + +export const testWebSearchProvider = (id: number): Promise> => request({ + url: `/api/aihr/web-search/providers/${id}/test`, + method: 'post', + headers: { repeatSubmit: false } +}); diff --git a/frontend/src/layout/components/Sidebar/index.vue b/frontend/src/layout/components/Sidebar/index.vue index a7007c79..76272cfd 100644 --- a/frontend/src/layout/components/Sidebar/index.vue +++ b/frontend/src/layout/components/Sidebar/index.vue @@ -43,11 +43,14 @@ const demoMenuTitles = new Set([ '三角色对练', '复盘校准', '案例沉淀', + '知识空间', 'SOP知识库', '资料处理', '模型配置', + '全网AI配置', '组织与权限', - '成长激励' + '成长激励', + '问题榜运营' ]); const getRouteMenuTitle = (route: RouteRecordRaw) => route.children?.find((child) => !child.hidden)?.meta?.title || route.meta?.title; const sidebarRouters = computed(() => diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3b9eec5b..a2a460be 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -118,6 +118,24 @@ export const constantRoutes: RouteRecordRaw[] = [ component: () => import('@/views/train/reviews.vue'), name: 'TrainReviews', meta: { title: '复盘校准', icon: 'finish' } + }, + { + path: 'difficulties', + component: () => import('@/views/train/difficulties.vue'), + name: 'TrainDifficulties', + meta: { title: '常见难题', icon: 'chart' } + } + ] + }, + { + path: '/community/questions', + component: Layout, + children: [ + { + path: '', + component: () => import('@/views/community/questions.vue'), + name: 'CommunityQuestions', + meta: { title: '问题榜运营', icon: 'message' } } ] }, @@ -133,6 +151,18 @@ export const constantRoutes: RouteRecordRaw[] = [ } ] }, + { + path: '/knowledge/spaces', + component: Layout, + children: [ + { + path: '', + component: () => import('@/views/knowledge/spaces.vue'), + name: 'KnowledgeSpaces', + meta: { title: '知识空间', icon: 'tree-table' } + } + ] + }, { path: '/knowledge/sop', component: Layout, @@ -175,6 +205,12 @@ export const constantRoutes: RouteRecordRaw[] = [ component: () => import('@/views/system/prompts.vue'), name: 'SystemPromptTemplates', meta: { title: '提示词模板', icon: 'edit' } + }, + { + path: 'web-search', + component: () => import('@/views/system/web-search.vue'), + name: 'SystemWebSearch', + meta: { title: '全网AI配置', icon: 'search' } } ] }, diff --git a/frontend/src/views/community/questions.vue b/frontend/src/views/community/questions.vue new file mode 100644 index 00000000..6df54606 --- /dev/null +++ b/frontend/src/views/community/questions.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/frontend/src/views/knowledge/processing.vue b/frontend/src/views/knowledge/processing.vue index ee22c617..ba02a2c9 100644 --- a/frontend/src/views/knowledge/processing.vue +++ b/frontend/src/views/knowledge/processing.vue @@ -1,7 +1,6 @@