feat(aihr): finalize practice growth and access boundaries

This commit is contained in:
2026-07-25 00:42:19 +08:00
parent a90f40e4d8
commit c9d2012ec1
87 changed files with 6804 additions and 2538 deletions
+12 -11
View File
@@ -2,7 +2,7 @@
## 项目定位 ## 项目定位
本项目是**银城员工端 APP**,按“陪练 → 大喇叭+知识学习平台 → 北森对接”三阶段推进。正式验收主线仍是阶段一试点;提前发布的阶段二/三单项能力不等于整体验收。`position` 沿用现有值,MVP 资料已归档;结论必须区分生产部署、正式账号/真机验证和严格试点。 本项目是**银城员工端 APP**,按“陪练 → 大喇叭+知识学习平台 → 北森对接”三阶段推进。正式验收主线是阶段一试点;后续能力提前发布不等于整体验收。结论须区分生产部署、正式账号/真机验证和严格试点。
## 工程边界 ## 工程边界
@@ -19,8 +19,8 @@
- MinIO:API `127.0.0.1:9000`,Console `127.0.0.1:9001`,账号 `ruoyi / ruoyi123`,bucket `ruoyi`。 - MinIO:API `127.0.0.1:9000`,Console `127.0.0.1:9001`,账号 `ruoyi / ruoyi123`,bucket `ruoyi`。
- Qdrant:REST `127.0.0.1:6333`,默认 collection `aihr_knowledge`;可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。 - Qdrant:REST `127.0.0.1:6333`,默认 collection `aihr_knowledge`;可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。
- 资料导入根目录:默认 `./.data/import`,由 `scripts/dev-backend.sh` 传入 `aihr.import.root`;也可用 `AIHR_IMPORT_ROOT` 覆盖。 - 资料导入根目录:默认 `./.data/import`,由 `scripts/dev-backend.sh` 传入 `aihr.import.root`;也可用 `AIHR_IMPORT_ROOT` 覆盖。
- 本地私密配置:根目录 `.env.local` 已被 `.gitignore` 忽略,`scripts/dev-backend.sh` 会自动加载;阿里云短信密钥只放这里或外部环境变量,不写入 `application-*.yml`。 - 本地私密配置:根目录 `.env.local` 已被 `.gitignore` 忽略,`scripts/dev-backend.sh` 会自动加载;阿里云短信密钥只放这里或外部环境变量,不写入 `application-*.yml`。管理端图形验证码本地默认关闭,只有 `AIHR_DEV_CAPTCHA_ENABLED=true` 才开启;该开发开关不进入生产启动。
- 后端:默认通过 portless 启动,入口 `https://wygj-api.localhost/`;命令为 `portless run --name wygj-api ./scripts/dev-backend.sh` 或根目录 `./scripts/dev.sh`,显式 `PORTLESS=0` 时才回退端口 `8080`。管理端 Vite 代理目标由 `frontend/.env.development` 的 `VITE_APP_PROXY_TARGET` 指向该入口。 - 后端默认通过 portless 启动,入口 `https://wygj-api.localhost/`;使用 `portless run --name wygj-api ./scripts/dev-backend.sh` 或 `./scripts/dev.sh`,`PORTLESS=0` 回退 `8080`。管理端代理目标在 `frontend/.env.development`。
- 管理端前端:`./scripts/dev-frontend.sh`,默认走 portless,入口 `https://wygj-admin.localhost/`;显式 `PORTLESS=0` 时回退端口 `5173`。 - 管理端前端:`./scripts/dev-frontend.sh`,默认走 portless,入口 `https://wygj-admin.localhost/`;显式 `PORTLESS=0` 时回退端口 `5173`。
- 当前用户侧前端:`portless` 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/`。 - 当前用户侧前端:`portless` 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/`。
- 旧移动端兜底:`./scripts/dev-mobile.sh`,端口 `5174`,只用于回看 MVP 演示壳。 - 旧移动端兜底:`./scripts/dev-mobile.sh`,端口 `5174`,只用于回看 MVP 演示壳。
@@ -44,12 +44,14 @@
- 只发布移动端静态资源时,用 `npm --prefix mobile-uni run build:h5` 后备份远端 `/opt/wygj/www/h5`,再 `rsync -az --delete mobile-uni/dist/build/h5/ YCWY:/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/`;前端静态修复不需要重启后端。
- 原生 App 不复用 H5 静态发布流程;`npm --prefix mobile-uni run build:app` 仅生成 HBuilderX 出包资源,未提供法务 HTTPS 链接、签名或真机验收前不得称为已出包或上架。 - 原生 App 不复用 H5 静态发布流程;`npm --prefix mobile-uni run build:app` 仅生成 HBuilderX 出包资源,未提供法务 HTTPS 链接、签名或真机验收前不得称为已出包或上架。
- 完整发布收口须按 `docs/DEV_SETUP.md` 运行同时启用远端静态、后端和 schema 的 `release-preflight`;只有三项都通过,才可称当前本地构建已与线上完整包匹配。单纯 H5 资源匹配不等同于完整发布验证。 - 完整发布收口须按 `docs/DEV_SETUP.md` 运行同时启用远端静态、后端和 schema 的 `release-preflight`;只有三项都通过,才可称当前本地构建已与线上完整包匹配。单纯 H5 资源匹配不等同于完整发布验证。
- `aihr.practice.runtime-schema-bootstrap` / `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP` 仅限本地开发逃生开关,Spring 默认关闭;生产请求只能校验表、列和关键索引,任何 DDL 都必须由正式 SQL 迁移完成,完整预检会拒绝开启该开关的服务。
## 当前业务边界 ## 当前业务边界
- 管理端业务路由集中在 `frontend/src/router/index.ts`,覆盖首页、AI 面试、训练运营、问题榜、案例、知识空间、SOP、资料处理、系统配置、组织权限和成长激励;侧栏过滤在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。 - 管理端业务路由集中在 `frontend/src/router/index.ts`,覆盖首页、AI 面试、训练运营、问题榜、案例、知识空间、SOP、资料处理、大喇叭、五渠道直通反馈、系统配置、组织权限和成长激励;侧栏过滤在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。
- 用户侧只改 `mobile-uni/src/pages/*`,旧 `/h5/user|candidate|supervisor|employee|admin` 只做兼容重定向,底栏固定「今日/练/问/我」。“问”统一调用 `/api/aihr/agent/messages|messages/media`,服务端规划意图并按固定策略选择既有工具,客户端不得提交 `toolCode`;旧知识查询仅作底层/兼容。短会话、资源重新鉴权、全网外发同意和不透明 `draftId` 确认均沿用 `docs/API_INTEGRATION.md` 契约;COMPANY 只表示 `PENDING`,`aihr_agent_run` 不保存问题、答案或附件。 - 用户侧只改 `mobile-uni/src/pages/*`;员工、候选人、主管首页分别为 `/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index`,旧 `/h5/user|candidate|supervisor|employee|admin` 只做兼容重定向,底栏固定「今日/练/问/我」。“问”统一调用 `/api/aihr/agent/messages|messages/media`,服务端规划意图并按固定策略选择既有工具,客户端不得提交 `toolCode`;旧知识查询仅作底层/兼容。登录端短期会话为 30 分钟/最近 6 轮,原文件/视频经 `/api/knowledge/resources/{attachmentId}/content` 重新鉴权,总结卡失败不兜假数据。全网外发必须先同意,显式记忆先出确认卡,再通过不透明 `draftId` 按 `PRIVATE/COMPANY` 确认;`COMPANY` 仅为 `PENDING`,不得冒充已派单或送达。`aihr_agent_run` 不保存问题、答案或附件,外部知识 API 保持无状态。
- 大喇叭详情追问只让客户端传 `broadcastMessageId`,消息正文、附件文本、租户和身份均由服务端重新校验并加载;撤回、跨租户或非在职员工不得继续使用消息上下文。消息/知识回答不得把通用建议包装成当前员工的真实待办,强行动入口只能来自服务端已授权的真实任务数据。 - 大喇叭详情追问只让客户端传 `broadcastMessageId`,消息正文、附件文本、租户和身份均由服务端重新校验并加载;撤回、跨租户或非在职员工不得继续使用消息上下文。单条消息最多绑定一个属于当前租户、当前上传人且状态为 `READY` 的公司文件;员工端只返回摘要与受控下载入口,不返回提取全文或原始 OSS 地址。消息/知识回答不得把通用建议包装成当前员工的真实待办,强行动入口只能来自服务端已授权的真实任务数据。
- 直通车固定为总裁、财务、人力、审计、运营五个渠道;业务匿名只隐藏处理端的提交人展示,服务端仍保留内部提交账号供本人查询、幂等和审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 只处理各自渠道,`superadmin` 可处理全部;当前产品边界只允许一次正式回复,不扩展为多轮聊天、工单、转派或 SLA。
- 术语和数据边界:工作助手承载日常事实与确认式记忆;现有 `/pages/user/report/index`、`/api/aihr/work-report/**` 和 `aihr_work_report` 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿审核,产品名为“成果投稿”;“今日工作成果”按员工 + 项目 + 自然日从已确认记录确定性聚合,员工页为 `/pages/user/work-results/index`,主管页为 `/pages/supervisor/work-results/index`,默认当日但允许查看历史日期且不得选择未来日期。不要把三条链路合表或自动互写。多项目交互只让用户按项目名称选择,内部项目编码必须由服务端按当前身份复核,不让用户输入编码。 - 术语和数据边界:工作助手承载日常事实与确认式记忆;现有 `/pages/user/report/index`、`/api/aihr/work-report/**` 和 `aihr_work_report` 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿审核,产品名为“成果投稿”;“今日工作成果”按员工 + 项目 + 自然日从已确认记录确定性聚合,员工页为 `/pages/user/work-results/index`,主管页为 `/pages/supervisor/work-results/index`,默认当日但允许查看历史日期且不得选择未来日期。不要把三条链路合表或自动互写。多项目交互只让用户按项目名称选择,内部项目编码必须由服务端按当前身份复核,不让用户输入编码。
- 不要把三端概括成全模态:员工对练/每日题和问师傅支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。 - 不要把三端概括成全模态:员工对练/每日题和问师傅支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。
- 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。 - 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。
@@ -57,7 +59,7 @@
- 多租户知识平台的有效范围是“租户 + 调用应用绑定 + 主体授权”的交集;不信任客户端传入身份,API_TOKEN 不得进入浏览器或小程序包。生产排序规则以 `aihr_knowledge_info.tenant_id` 为基准,新表最后执行 `aihr_20260718_release_collation_compat_mysql8.sql`。 - 多租户知识平台的有效范围是“租户 + 调用应用绑定 + 主体授权”的交集;不信任客户端传入身份,API_TOKEN 不得进入浏览器或小程序包。生产排序规则以 `aihr_knowledge_info.tenant_id` 为基准,新表最后执行 `aihr_20260718_release_collation_compat_mysql8.sql`。
- 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。 - 移动端首页接口:`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`,停用时删除固定码并关闭生产开关。 - 移动端手机号登录:验证码发送走 `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`。 - 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;服务端强制当前 APP 身份和 `mode=mobile`,才会写入 `aihr_practice_session` 并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。APP 身份只能按认证手机号精确匹配 `person_phone`(`/me`、问师傅主体解析同样只走 `activeByMobilePhone`),运营/调度身份只能按 `ext_party_id` 解析;严禁把两种字段混作替代查询键,映射碰撞或不唯一时必须拒绝任务读写。员工历史、画像、成长进度、晋升证据仅可使用该手机号服务端验证得到的受控别名集合;后台不得借 `/api/aihr/mobile/**` 传入组织 `ext_party_id` 读取员工历史、错题、画像、证据、主管复盘或录音;后台只保留 `/api/train/practice/**` 的内容/任务运营,以及服务端固定 `operator:{userId}`、`mode=preview` 的不绑定员工身份预览。主管团队范围只保存外部 ID;旧手机号仅当其唯一对应一个外部 ID,且该外部 ID 也唯一对应一个在职手机号时,才可规范化读取/统计;该相同的失败关闭规则必须用于团队列表、详情、复盘与音频授权。案例项目范围和岗位 SOP 同样复用已验证 APP 主体的精确项目范围,不得以 `person_phone OR ext_party_id` 或管理端组织模糊查询作为后备。`superadmin/hr_operator` 管理端仅可走服务端绑定的 `operator:{userId}`、`mode=preview` 运营预览,忽略客户端员工身份/模式/任务,且预览不得进入员工历史、复盘、成长或试点统计;其他后台身份一律拒绝。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。
- 正式试点 CSV 走 `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`,起止日必填;训练、校准和 SOP 评审必须按同一窗口、唯一在职组织身份统计,完训定义为每人至少 10 次。CSV 应保留原始校准命中/SOP 可用计数,严格预检用 `AIHR_PILOT_START_DATE`、`AIHR_PILOT_END_DATE` 与 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`;不允许用历史 seed、开发身份或四舍五入比率伪造正式试点通过。 - 正式试点 CSV 走 `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`,起止日必填;训练、校准和 SOP 评审必须按同一窗口、唯一在职组织身份统计,完训定义为每人至少 10 次。CSV 应保留原始校准命中/SOP 可用计数,严格预检用 `AIHR_PILOT_START_DATE`、`AIHR_PILOT_END_DATE` 与 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`;不允许用历史 seed、开发身份或四舍五入比率伪造正式试点通过。
- 移动端候选人闭环:候选人端“开始面试/面试练习”复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”走认证接口 `POST /api/aihr/mobile/candidate/materials`(multipart `file` + `candidateId/candidateName/materialType`)和 `GET /api/aihr/mobile/candidate/materials`,文件先写 `sys_oss`/MinIO,再写 `aihr_candidate_material` 状态 `待审核`。HR 审核复用管理端 `/recruit/interview` 页,接口为 `GET /api/aihr/hr/candidate/materials` 和 `POST /api/aihr/hr/candidate/materials/{id}/review`,状态只用 `待审核/已通过/已驳回`。上传接口不要放进 class-level `@SaIgnore` 的 `AihrMobileController`。 - 移动端候选人闭环:候选人端“开始面试/面试练习”复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”走认证接口 `POST /api/aihr/mobile/candidate/materials`(multipart `file` + `candidateId/candidateName/materialType`)和 `GET /api/aihr/mobile/candidate/materials`,文件先写 `sys_oss`/MinIO,再写 `aihr_candidate_material` 状态 `待审核`。HR 审核复用管理端 `/recruit/interview` 页,接口为 `GET /api/aihr/hr/candidate/materials` 和 `POST /api/aihr/hr/candidate/materials/{id}/review`,状态只用 `待审核/已通过/已驳回`。上传接口不要放进 class-level `@SaIgnore` 的 `AihrMobileController`。
- 后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 使用 `AihrPracticeLlmService`,必须保留“未配置/失败回退 seed”的降级链。 - 后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 使用 `AihrPracticeLlmService`,必须保留“未配置/失败回退 seed”的降级链。
@@ -70,10 +72,9 @@
- 向量库状态接口是 `GET /api/knowledge/doc/vector-index-status`,重建接口是 `POST /api/knowledge/doc/rebuild-vector-index`;外部 embedding 成功但 Qdrant collection 维度不一致时不要静默降级,走状态提示和重建。 - 向量库状态接口是 `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/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` 下的相对路径。 - 资料处理接口是 `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` 下的相对路径。
- `ruoyi-ai` 只按 `docs/RUOYI_AI_INCREMENTAL_MIGRATION.md` 分片迁移到 `ruoyi-aihr`,不要整包搬 `ruoyi-chat`;已落地页面保持真实接口/模型优先与 seed fallback,不能退回静态壳。 - `ruoyi-ai` 只按 `docs/RUOYI_AI_INCREMENTAL_MIGRATION.md` 分片迁移到 `ruoyi-aihr`,不要整包搬 `ruoyi-chat`;已落地页面保持真实接口/模型优先与 seed fallback,不能退回静态壳。
- 自有 AI 表统一用 `aihr_*`;知识、短会话、Agent 审计、确认式采集、训练和组织快照等表的完整契约以 `docs/API_INTEGRATION.md` 和生产迁移 Runbook 为准。`aihr_org_snapshot` 本地 reset 可带 Demo seed,生产/试点只从开放组织系统经 `POST /api/aihr/org/sync` 同步。 - 本项目 AI 表前缀用 `aihr_*`:知识库是 `aihr_knowledge_info/attach/fragment`,问师傅短期会话是 `aihr_knowledge_conversation`,Agent 审计是 `aihr_agent_run`,大喇叭公司文件是 `aihr_broadcast_attachment`,直通车反馈是 `aihr_direct_feedback`,确认式采集是 `aihr_memory_candidate` / `aihr_assistant_capture`(旧项目记录兼容 `aihr_service_memory/version`),上传队列是 `aihr_knowledge_upload_item`,导入任务是 `aihr_knowledge_import_task`,模型配置是 `aihr_model_provider/config`,移动端训练记录是 `aihr_practice_session`,候选人补充资料是 `aihr_candidate_material`,组织人员快照是 `aihr_org_snapshot`(本地 reset 有 Demo seed;生产/试点从 `docs/open-org-sync-api-design-v1.md` 对应开放组织系统同步,入口 `POST /api/aihr/org/sync`)。
## 业务文档 ## 业务文档
- 总入口和索引是 `README.md`、`docs/README.md`;后者定义事实源优先级。 - 索引与范围:`README.md`、`docs/README.md`、`docs/银城员工端APP分阶段实施总纲.md`、`docs/AI陪练完整交付计划-20260724.md`;旧二期计划只作追溯。
- 实现与契约看 `docs/BRD_IMPLEMENTATION_AUDIT.md`、生产 Runbook、`docs/API_INTEGRATION.md` 和 BRD/TechSpec。 - 当前事实与契约:`docs/BRD_IMPLEMENTATION_AUDIT.md`、`docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md`、`docs/API_INTEGRATION.md`、BRD/TechSpec 与 `docs/FIGMA_DESIGN_INVENTORY.md`。
- Figma 实现与验收看 `docs/FIGMA_DESIGN_INVENTORY.md`,分开记录实现、视觉、发布和生产验证。
+8 -7
View File
@@ -10,15 +10,15 @@
- 本地依赖:MySQL + Redis + MinIO + Qdrant 由 `docker-compose.dev.yml` 启动。 - 本地依赖:MySQL + Redis + MinIO + Qdrant 由 `docker-compose.dev.yml` 启动。
- 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。 - 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。
- 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。 - 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。
- 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含常见难题、问题榜运营、知识空间、全网 AI 配置、组织权限和成长激励。 - 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含大喇叭发布与单文件提炼、五渠道直通反馈收件箱、内容/任务运营、问题榜运营、知识空间、全网 AI 配置和组织权限;员工个人训练、主管复盘、团队难题、成长证据与训练录音仅在认证 APP 按项目范围访问。
- 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;“问”已在本地改为数字师傅 Agent,文字、ASR 转写和图片/视频统一先做意图规划,再按服务端权限调用知识、训练、待办、记忆、媒体或全网工具,寒暄和不完整表达不再机械落入 RAG。短期多轮、受控原文件/视频、确认式“记一下”和 PRIVATE/COMPANY 边界继续复用既有领域能力;该 Agent 增量尚未部署或完成真机正式账号验收。旧 `mobile/` 只用于历史回溯。 - 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;“问”已在本地改为数字师傅 Agent,文字、ASR 转写和图片/视频统一先做意图规划,再按服务端权限调用知识、训练、待办、记忆、媒体或全网工具,寒暄和不完整表达不再机械落入 RAG。现有能力还包括手机号登录、30 分钟短期多轮、原文件/视频受控交付、大喇叭公司文件摘要/下载/追问、五渠道直通反馈、“记一下”确认卡、三级字号、训练/题目反馈、问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式;该 Agent 增量尚未部署或完成真机正式账号验收。旧 `mobile/` 只用于历史回溯。
- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。 - 英雄路径: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` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。后端目录导入接口仅保留为运维/调试能力。 - 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。后端目录导入接口仅保留为运维/调试能力。
- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;生产通过 `POST /api/aihr/org/sync` 同步外部组织。最近记录的生产快照为 3417 人、2938 人在职、3392 个手机号映射、175 名在职主管和 20 名项目经理;人数属于时点事实,发布或验收前应重新查询。 - 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;生产通过 `POST /api/aihr/org/sync` 同步外部组织。最近记录的生产快照为 3417 人、2938 人在职、3392 个手机号映射、175 名在职主管和 20 名项目经理;人数属于时点事实,发布或验收前应重新查询。
- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。组织身份同步和至少 20 名手机号映射门槛已具备;下一步仍需选定真实试点窗口,产生员工训练、人工校准和 SOP 评审样本后重跑严格门禁,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。 - 正式试点:服务端提供按起止日期导出的受角色保护 CSV;当前管理端不展示个人训练或试点导出入口,正式试点需在明确独立后台契约后再开放。严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。组织身份同步和至少 20 名手机号映射门槛已具备;下一步仍需选定真实试点窗口,产生员工训练、人工校准和 SOP 评审样本后重跑严格门禁,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。
- 侧栏:只展示当前 AIHR 业务菜单;过滤名单在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。 - 侧栏:只展示当前 AIHR 业务菜单;过滤名单在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。
- 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。 - 本地认证:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时短信验证码固定 `123456`;管理端图形验证码默认关闭,需要时设置 `AIHR_DEV_CAPTCHA_ENABLED=true`。生产不读取该图形验证码开发开关,短信固定码也保持默认关闭,只有试点明确同时开启两项生产固定码配置时才生效。
- 生产状态属于易变事实,不在总入口固定某次提交或迁移数量;发布前后按 [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md)、[生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) 和正式预检结果确认。正式 OTP 写链路、真机能力和严格试点仍需独立验收。 - 生产状态属于易变事实,不在总入口固定某次提交或迁移数量;发布前后按 [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md)、[生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) 和正式预检结果确认。已授权的生产登录方式可直接用于认证验收;真机能力和严格试点仍需独立验收。
## 快速启动 ## 快速启动
@@ -63,7 +63,8 @@
| [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | 生产 schema/内置内容迁移、只读预检、发布和回滚边界 | | [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | 生产 schema/内置内容迁移、只读预检、发布和回滚边界 |
| [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 | | [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 |
| [项目规划方案与 AI 接口说明](docs/AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位和 AI 接口规划 | | [项目规划方案与 AI 接口说明](docs/AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位和 AI 接口规划 |
| [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用 | | [AI 陪练完整交付计划](docs/AI陪练完整交付计划-20260724.md) | 当前阶段一“练”模块固定范围:住宅物业 × 五岗位 × 三级成长,含现有基线、收口动作和验收门槛 |
| [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md) | 2026-07-11 历史推进记录;用于追溯已完成的 M0-M5,不再作为后续范围切分依据 |
| [银城员工端APP分阶段实施总纲](docs/银城员工端APP分阶段实施总纲.md) | 2026-07-07 立项会后的三阶段总纲与阶段二/三边界 | | [银城员工端APP分阶段实施总纲](docs/银城员工端APP分阶段实施总纲.md) | 2026-07-07 立项会后的三阶段总纲与阶段二/三边界 |
| [数字师傅学练问报整合方案](docs/20260708/数字师傅学练问报整合方案.md) | 2026-07-08 产品讨论定案:学练问报四模块、开放问题榜、学分晋升、发布会「生活顾问的一天」 | | [数字师傅学练问报整合方案](docs/20260708/数字师傅学练问报整合方案.md) | 2026-07-08 产品讨论定案:学练问报四模块、开放问题榜、学分晋升、发布会「生活顾问的一天」 |
| [AI 陪练缺口修订版](docs/AI陪练功能优化与缺口分析%28修订%29.md) | 24 项陪练缺口的代码实况校对和二期排期依据 | | [AI 陪练缺口修订版](docs/AI陪练功能优化与缺口分析%28修订%29.md) | 24 项陪练缺口的代码实况校对和二期排期依据 |
@@ -73,4 +74,4 @@
## 下一步 ## 下一步
当前主线不是继续补页面,而是用正式 OTP、正式角色和真实手机完成读写/权限/设备验收,再选定 1–2 个项目、至少 20 名员工和固定时间窗,产生训练、校准、SOP 评审、满意度与案例样本后重跑严格门禁。功能现状看 [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md),客户反馈看 [版本功能对比](docs/员工端版本功能对比与功能对齐清单-20260717.md),正式试点看 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。 当前主线不是继续补页面,而是用已授权的生产登录方式、正式角色和真实手机完成剩余读写/权限/设备验收,再选定 1–2 个项目、至少 20 名员工和固定时间窗,产生训练、校准、SOP 评审、满意度与案例样本后重跑严格门禁。功能现状看 [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md),客户反馈看 [版本功能对比](docs/员工端版本功能对比与功能对齐清单-20260717.md),正式试点看 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。
@@ -11,7 +11,9 @@ import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse;
import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest; import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder; import org.springframework.jdbc.support.KeyHolder;
@@ -40,6 +42,9 @@ public class AihrCommunityService {
private final AihrCommunityProperties properties; private final AihrCommunityProperties properties;
private final AihrIncentiveService incentiveService; private final AihrIncentiveService incentiveService;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean tablesReady; private volatile boolean tablesReady;
public List<QuestionResponse> publicQuestions(String status, boolean mine) { public List<QuestionResponse> publicQuestions(String status, boolean mine) {
@@ -433,6 +438,13 @@ public class AihrCommunityService {
if (tablesReady) { if (tablesReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate,
"aihr_community_question", "aihr_community_answer", "aihr_points_ledger", "aihr_incentive_rule");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_points_ledger", "account_type");
tablesReady = true;
return;
}
for (String ddl : tableDdls()) { for (String ddl : tableDdls()) {
jdbcTemplate.execute(ddl); jdbcTemplate.execute(ddl);
} }
@@ -2,6 +2,8 @@ package org.dromara.aihr.community;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder; import org.springframework.jdbc.support.KeyHolder;
@@ -17,6 +19,10 @@ import java.util.Locale;
public class AihrIncentiveService { public class AihrIncentiveService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean tableReady; private volatile boolean tableReady;
public List<IncentiveRuleResponse> list(AihrKnowledgePrincipal principal, boolean includeDisabled) { public List<IncentiveRuleResponse> list(AihrKnowledgePrincipal principal, boolean includeDisabled) {
@@ -133,6 +139,11 @@ public class AihrIncentiveService {
if (tableReady) { if (tableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_incentive_rule");
tableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_incentive_rule` ( CREATE TABLE IF NOT EXISTS `aihr_incentive_rule` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
@@ -18,6 +18,7 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest; import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.service.AihrCaseService; import org.dromara.aihr.service.AihrCaseService;
import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.system.service.ISysOssService; import org.dromara.system.service.ISysOssService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -46,6 +47,7 @@ public class AihrCaseController {
private static final String HR_OPERATOR_ROLE = "hr_operator"; private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrCaseService caseService; private final AihrCaseService caseService;
private final AihrMobileSeedService mobileSeedService;
private final ISysOssService ossService; private final ISysOssService ossService;
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -125,7 +127,7 @@ public class AihrCaseController {
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return List.of(); return List.of();
} }
return caseService.projectScopesForAppUser(loginUser.getUsername()); return caseService.projectScopesForAppUser(currentAppMobilePhone(loginUser));
} }
private boolean canManageCases() { private boolean canManageCases() {
@@ -135,7 +137,7 @@ public class AihrCaseController {
} }
return loginUser != null return loginUser != null
&& UserType.APP_USER.getUserType().equals(loginUser.getUserType()) && UserType.APP_USER.getUserType().equals(loginUser.getUserType())
&& caseService.isCaseContributor(loginUser.getUsername()); && caseService.isCaseContributor(currentAppMobilePhone(loginUser));
} }
private boolean canViewCases() { private boolean canViewCases() {
@@ -145,4 +147,11 @@ public class AihrCaseController {
} }
return loginUser != null && UserType.APP_USER.getUserType().equals(loginUser.getUserType()); return loginUser != null && UserType.APP_USER.getUserType().equals(loginUser.getUserType());
} }
private String currentAppMobilePhone(LoginUser loginUser) {
String username = loginUser == null || loginUser.getUsername() == null ? "" : loginUser.getUsername().trim();
// Keep the collision checks shared with training and knowledge access;
// only then may this phone reach the exact org-snapshot predicates.
return mobileSeedService.requireMobileIdentity(username);
}
} }
@@ -2,7 +2,6 @@ package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaIgnore; import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.stp.StpUtil;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -52,7 +51,6 @@ import org.dromara.aihr.service.AihrSensitiveText;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType; import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.service.ISysOssService; import org.dromara.system.service.ISysOssService;
@@ -99,10 +97,13 @@ public class AihrMobileController {
|| identity.isBlank()) { || identity.isBlank()) {
return R.ok(mobileSeedService.publicHome(role)); return R.ok(mobileSeedService.publicHome(role));
} }
String resolvedIdentity = identity;
if (supervisorRoleRequested(role)) { if (supervisorRoleRequested(role)) {
mobileSeedService.requireSupervisorIdentity(identity); resolvedIdentity = mobileSeedService.requireMobileSupervisorIdentity(identity);
} else if (!"candidate".equalsIgnoreCase(role)) {
mobileSeedService.requireMobileIdentity(identity);
} }
return R.ok(mobileSeedService.home(role, identity)); return R.ok(mobileSeedService.home(role, resolvedIdentity));
} }
/** /**
@@ -115,11 +116,14 @@ public class AihrMobileController {
if (phone.isBlank()) { if (phone.isBlank()) {
return R.ok(new IdentityResponse("user", "员工端", "", "一线", List.of())); return R.ok(new IdentityResponse("user", "员工端", "", "一线", List.of()));
} }
// APP username is a phone number. Validate it before it reaches the
// organization lookup so a phone-shaped external ID cannot borrow
// another employee's role or project scope.
mobileSeedService.requireMobileIdentity(phone);
List<OrgPersonRow> people = List.of(); List<OrgPersonRow> people = List.of();
var row = java.util.Optional.<OrgPersonRow>empty(); var row = java.util.Optional.<OrgPersonRow>empty();
try { try {
var snapshot = orgSyncService.snapshot(phone, null, null, "active", 1, 500, 500); var snapshotPeople = orgSyncService.activeByMobilePhone(phone);
var snapshotPeople = snapshot.rows();
people = snapshotPeople; people = snapshotPeople;
row = snapshotPeople.stream() row = snapshotPeople.stream()
.filter(person -> "主管".equals(person.positionLevel()) || "项目经理".equals(person.positionLevel())) .filter(person -> "主管".equals(person.positionLevel()) || "项目经理".equals(person.positionLevel()))
@@ -204,19 +208,11 @@ public class AihrMobileController {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED); response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return; return;
} }
if (UserType.APP_USER.getUserType().equals(loginUser.getUserType()) if (!UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
&& !mobileSeedService.canReadPracticeAudio(ossId, currentAppUsername())) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音"); response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音");
return; return;
} }
if (UserType.SYS_USER.getUserType().equals(loginUser.getUserType()) if (!mobileSeedService.canReadPracticeAudio(ossId, currentAppUsername())) {
&& (!StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator")
|| !mobileSeedService.canReadPracticeAudioForTenant(ossId))) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音");
return;
}
if (!UserType.APP_USER.getUserType().equals(loginUser.getUserType())
&& !UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音"); response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音");
return; return;
} }
@@ -245,7 +241,7 @@ public class AihrMobileController {
@GetMapping("/practice/assignments") @GetMapping("/practice/assignments")
public R<List<PracticeAssignmentResponse>> practiceAssignments(@RequestParam(required = false) String extPartyId) { public R<List<PracticeAssignmentResponse>> practiceAssignments(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId), currentAppUsername())); return R.ok(mobileSeedService.practiceAssignments(ownMobileExtPartyId(extPartyId)));
} }
@GetMapping("/onboard/tasks") @GetMapping("/onboard/tasks")
@@ -368,36 +364,30 @@ public class AihrMobileController {
return response == null ? R.fail("满意度提交失败,请确认训练已完成且评分为1-5分") : R.ok(response); return response == null ? R.fail("满意度提交失败,请确认训练已完成且评分为1-5分") : R.ok(response);
} }
private static String ownMobileExtPartyId(String requested) { private String ownMobileExtPartyId(String ignoredRequested) {
String username = currentAppUsername(); String username = currentAppUsername();
if (!username.isBlank()) { if (username.isBlank()) {
return username;
}
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())
&& !StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator")) {
throw new ServiceException("无权访问员工个人数据"); throw new ServiceException("无权访问员工个人数据");
} }
return requested; return mobileSeedService.requireMobileIdentity(username);
} }
private static String scopedAssignmentExtPartyId(String requested) { private boolean isSystemOperator() {
return ownMobileExtPartyId(requested); LoginUser loginUser = LoginHelper.getLoginUser();
return loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType());
} }
/** /**
* APP supervisor tokens are org-scoped; management console sys_user keeps unscoped access * Supervisor coaching endpoints are APP-only and always scoped to the
* so admin review/growth pages continue to work against the same mobile APIs. * authenticated supervisor's organization identity. Management preview
* must use its own preview path; an unscoped sys_user must never become a
* substitute supervisor for employee reviews or growth evidence.
*/ */
private String supervisorScopeExtPartyId() { private String supervisorScopeExtPartyId() {
LoginUser loginUser = LoginHelper.getLoginUser(); if (isSystemOperator()) {
if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) { throw new ServiceException("后台管理账号不能使用主管带教接口");
if (!StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator")) {
throw new ServiceException("无主管权限,无法访问主管功能");
}
return "";
} }
return mobileSeedService.requireSupervisorIdentity(currentAppUsername()); return mobileSeedService.requireMobileSupervisorIdentity(currentAppUsername());
} }
private static String currentAppUsername() { private static String currentAppUsername() {
@@ -423,7 +413,8 @@ public class AihrMobileController {
request == null ? null : request.advice(), request == null ? null : request.advice(),
request == null ? null : request.incentivePoint(), request == null ? null : request.incentivePoint(),
request == null ? null : request.nextScenarioId(), request == null ? null : request.nextScenarioId(),
request == null ? null : request.nextReason() request == null ? null : request.nextReason(),
request == null ? null : request.confirmedGrowthLevel()
); );
} }
@@ -452,6 +443,12 @@ public class AihrMobileController {
data.put("time", detail.time()); data.put("time", detail.time());
data.put("trainee", detail.trainee()); data.put("trainee", detail.trainee());
data.put("scene", detail.scene()); data.put("scene", detail.scene());
data.put("position", detail.position());
data.put("projectType", detail.projectType());
data.put("growthLevel", detail.growthLevel());
data.put("competencyCode", detail.competencyCode());
data.put("collaborationPositions", detail.collaborationPositions());
data.put("curriculumVersion", detail.curriculumVersion());
data.put("score", detail.score()); data.put("score", detail.score());
data.put("status", detail.status()); data.put("status", detail.status());
data.put("summary", maskSensitiveText(detail.summary())); data.put("summary", maskSensitiveText(detail.summary()));
@@ -459,6 +456,9 @@ public class AihrMobileController {
data.put("aiComment", maskSensitiveText(detail.aiComment())); data.put("aiComment", maskSensitiveText(detail.aiComment()));
data.put("reviewAdvice", maskSensitiveText(detail.reviewAdvice())); data.put("reviewAdvice", maskSensitiveText(detail.reviewAdvice()));
data.put("incentivePoint", detail.incentivePoint()); data.put("incentivePoint", detail.incentivePoint());
data.put("confirmedGrowthLevel", detail.confirmedGrowthLevel());
data.put("growthConfirmedBy", detail.growthConfirmedBy());
data.put("growthConfirmedTime", detail.growthConfirmedTime());
data.put("extPartyId", detail.extPartyId()); data.put("extPartyId", detail.extPartyId());
data.put("scoreItems", detail.scoreItems().stream().map(item -> Map.of( data.put("scoreItems", detail.scoreItems().stream().map(item -> Map.of(
"label", item.label(), "label", item.label(),
@@ -11,6 +11,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.AssignmentCancelRequest; import org.dromara.aihr.domain.AihrPracticeDto.AssignmentCancelRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest; import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse; import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.CurriculumCapabilityResponse;
import org.dromara.aihr.domain.AihrPracticeDto.CurriculumMatrixResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest; import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse; import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse; import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse;
@@ -29,6 +31,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioReviewStatusRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioReviewStatusResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest; import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
@@ -41,6 +45,7 @@ import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType; import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
@@ -81,7 +86,7 @@ public class AihrPracticeController {
@GetMapping("/scenarios/{id}") @GetMapping("/scenarios/{id}")
public R<ScenarioResponse> scenario(@PathVariable String id) { public R<ScenarioResponse> scenario(@PathVariable String id) {
return R.ok(practiceSeedService.scenario(id)); return R.ok(practiceSeedService.scenario(id, currentOperatorCanManageContent()));
} }
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@@ -96,6 +101,26 @@ public class AihrPracticeController {
return practiceSeedService.updateScenarioEnabled(id, request) ? R.ok() : R.fail("训练场景不存在"); return practiceSeedService.updateScenarioEnabled(id, request) ? R.ok() : R.fail("训练场景不存在");
} }
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PatchMapping("/scenarios/{id}/review-status")
public R<ScenarioReviewStatusResponse> updateScenarioReviewStatus(@PathVariable String id, @RequestBody ScenarioReviewStatusRequest request) {
return R.ok(practiceSeedService.updateScenarioReviewStatus(id, request,
currentOperatorUserId(), currentOperatorUsername()));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/curriculum/capabilities")
public R<List<CurriculumCapabilityResponse>> curriculumCapabilities(@RequestParam(required = false) String position,
@RequestParam(required = false) String growthLevel) {
return R.ok(practiceSeedService.curriculumCapabilities(position, growthLevel));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/curriculum/matrix")
public R<List<CurriculumMatrixResponse>> curriculumMatrix(@RequestParam(required = false) String position) {
return R.ok(practiceSeedService.curriculumMatrix(position));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/questions") @GetMapping("/questions")
public R<List<PracticeQuestionResponse>> questions(@RequestParam(required = false) String position, public R<List<PracticeQuestionResponse>> questions(@RequestParam(required = false) String position,
@@ -193,7 +218,7 @@ public class AihrPracticeController {
@PostMapping("/start") @PostMapping("/start")
public R<StartResponse> start(@RequestBody StartRequest request) { public R<StartResponse> start(@RequestBody StartRequest request) {
return R.ok(practiceSeedService.start(bindMobileStartIdentity(request))); return R.ok(practiceSeedService.start(bindPracticeStartIdentity(request)));
} }
/** /**
@@ -210,12 +235,12 @@ public class AihrPracticeController {
@PostMapping("/turn") @PostMapping("/turn")
public R<TurnResponse> turn(@RequestBody TurnRequest request) { public R<TurnResponse> turn(@RequestBody TurnRequest request) {
return R.ok(practiceSeedService.turn(request, currentAppUsername(), currentAppUserId())); return R.ok(practiceSeedService.turn(request, requirePracticeActorIdentity("继续训练"), currentAppUserId()));
} }
@PostMapping("/finish") @PostMapping("/finish")
public R<FinishResponse> finish(@RequestBody FinishRequest request) { public R<FinishResponse> finish(@RequestBody FinishRequest request) {
return R.ok(practiceSeedService.finish(request, currentAppUsername())); return R.ok(practiceSeedService.finish(request, requirePracticeActorIdentity("完成训练")));
} }
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@@ -246,15 +271,52 @@ public class AihrPracticeController {
); );
} }
private static StartRequest bindMobileStartIdentity(StartRequest request) { private static StartRequest bindPracticeStartIdentity(StartRequest request) {
if (request == null || !"mobile".equalsIgnoreCase(request.mode())) { if (request == null) {
return request; throw new ServiceException("训练请求不能为空");
} }
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser != null && UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
String username = requireCurrentAppUsername("开始训练");
// Employee endpoints never accept a client-selected identity or mode.
return new StartRequest(username, request.scenarioId(), "mobile", request.assignmentId());
}
if (canUsePracticePreview(loginUser)) {
// Operations may exercise the live model path, but never impersonate an employee or create mobile records.
return new StartRequest(practicePreviewActor(loginUser), request.scenarioId(), "preview", null);
}
throw new ServiceException("请使用员工端账号或有权限的运营账号开始训练");
}
private static String requireCurrentAppUsername(String action) {
String username = currentAppUsername(); String username = currentAppUsername();
if (username.isBlank()) { if (username.isBlank()) {
return request; throw new ServiceException("请使用员工端账号" + action);
} }
return new StartRequest(username, request.scenarioId(), request.mode(), request.assignmentId()); return username;
}
private static String requirePracticeActorIdentity(String action) {
LoginUser loginUser = LoginHelper.getLoginUser();
String username = currentAppUsername();
if (!username.isBlank()) {
return username;
}
if (canUsePracticePreview(loginUser)) {
return practicePreviewActor(loginUser);
}
throw new ServiceException("请使用员工端账号或有权限的运营账号" + action);
}
private static boolean canUsePracticePreview(LoginUser loginUser) {
return loginUser != null
&& UserType.SYS_USER.getUserType().equals(loginUser.getUserType())
&& currentOperatorCanManageContent()
&& loginUser.getUserId() != null;
}
private static String practicePreviewActor(LoginUser loginUser) {
return "operator:" + loginUser.getUserId();
} }
private static String currentAppUsername() { private static String currentAppUsername() {
@@ -282,6 +344,14 @@ public class AihrPracticeController {
return loginUser.getUsername().trim(); return loginUser.getUsername().trim();
} }
private static Long currentOperatorUserId() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || loginUser.getUserId() == null) {
throw new ServiceException("无法识别当前内容审核人");
}
return loginUser.getUserId();
}
private static boolean currentOperatorCanManageContent() { private static boolean currentOperatorCanManageContent() {
return StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE); return StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE);
} }
@@ -25,9 +25,7 @@ import org.dromara.aihr.domain.AihrSopDto.UploadItemResponse;
import org.dromara.aihr.domain.AihrSopDto.UploadResponse; import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse; import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse; import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
import org.dromara.aihr.service.AihrSopSeedService; import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.aihr.service.AihrUploadQueueService; import org.dromara.aihr.service.AihrUploadQueueService;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
@@ -51,7 +49,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -67,7 +64,6 @@ public class AihrSopController {
private static final String DEMO_VERIFY_SOURCE = "verify_demo_questions"; private static final String DEMO_VERIFY_SOURCE = "verify_demo_questions";
private final AihrSopSeedService sopSeedService; private final AihrSopSeedService sopSeedService;
private final AihrOrgSyncService orgSyncService;
private final AihrUploadQueueService uploadQueueService; private final AihrUploadQueueService uploadQueueService;
private final AihrKnowledgeAccessService knowledgeAccessService; private final AihrKnowledgeAccessService knowledgeAccessService;
private final AihrKnowledgeAppService knowledgeAppService; private final AihrKnowledgeAppService knowledgeAppService;
@@ -111,14 +107,6 @@ public class AihrSopController {
)).legacy()); )).legacy());
} }
private String currentRequesterExtPartyId() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
return loginUser.getUsername();
}
private SearchRequest restrictDemoVerificationSource(SearchRequest request) { private SearchRequest restrictDemoVerificationSource(SearchRequest request) {
if (request == null || !DEMO_VERIFY_SOURCE.equals(request.source()) if (request == null || !DEMO_VERIFY_SOURCE.equals(request.source())
|| StpUtil.hasRole(TenantConstants.SUPER_ADMIN_ROLE_KEY)) { || StpUtil.hasRole(TenantConstants.SUPER_ADMIN_ROLE_KEY)) {
@@ -139,15 +127,11 @@ public class AihrSopController {
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return null; return null;
} }
String identity = currentRequesterExtPartyId();
if (identity.isBlank()) {
return Set.of();
}
try { try {
return orgSyncService.snapshot(identity, null, null, "active", 1, 500, 500).rows().stream() // The resolver validates an APP phone against the active snapshot
.map(OrgPersonRow::projectCode) // before returning project scopes. Do not pass APP usernames to the
.filter(code -> code != null && !code.isBlank()) // management-console fuzzy snapshot search here.
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); return knowledgePrincipalResolver.current().projectCodes();
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
return Set.of(); return Set.of();
} }
@@ -33,6 +33,19 @@ public final class AihrPracticeDto {
String position, String position,
String scenarioType, String scenarioType,
String projectType, String projectType,
String growthLevel,
String competencyCode,
String collaborationPositions,
String reviewStatus,
String riskLevel,
boolean requiresDualReview,
String curriculumVersion,
String reviewer,
Long reviewerUserId,
String reviewedTime,
String secondReviewer,
Long secondReviewerUserId,
String secondReviewedTime,
Integer difficulty, Integer difficulty,
String customerPersona, String customerPersona,
String openingText, String openingText,
@@ -66,6 +79,11 @@ public final class AihrPracticeDto {
String position, String position,
String scenarioType, String scenarioType,
String projectType, String projectType,
String growthLevel,
String competencyCode,
String collaborationPositions,
String riskLevel,
String curriculumVersion,
Integer difficulty, Integer difficulty,
String customerPersona, String customerPersona,
String openingText, String openingText,
@@ -85,6 +103,38 @@ public final class AihrPracticeDto {
public record ScenarioEnabledRequest(Boolean enabled) { public record ScenarioEnabledRequest(Boolean enabled) {
} }
/**
* Content review is deliberately separate from editing a scenario. A
* client may propose a status, but the server records the authenticated
* operator as the reviewer.
*/
public record ScenarioReviewStatusRequest(String reviewStatus) {
}
/**
* A high-risk scenario remains pending until two different operators have
* reviewed the same content version. The response lets the console show
* that first approval is recorded but is not publication yet.
*/
public record ScenarioReviewStatusResponse(String reviewStatus, boolean awaitingSecondReview, String message) {
}
public record CurriculumCapabilityResponse(String position, String growthLevel, String code, String name) {
}
public record CurriculumMatrixResponse(
String position,
String growthLevel,
String competencyCode,
String competencyName,
Integer publishedCount,
Integer pendingReviewCount,
Integer draftCount,
Integer offlineCount,
String coverageStatus
) {
}
public record RubricResponse(String code, String name, String version, List<RubricDimensionResponse> dimensions) { public record RubricResponse(String code, String name, String version, List<RubricDimensionResponse> dimensions) {
} }
@@ -269,7 +319,12 @@ public final class AihrPracticeDto {
public record CalibrationResponse(Long id, String sessionId, Integer originalScore, Integer correctedScore, String reviewer, String reason, String status, String createTime) { public record CalibrationResponse(Long id, String sessionId, Integer originalScore, Integer correctedScore, String reviewer, String reason, String status, String createTime) {
} }
public record ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId, String nextReason) { public record ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId,
String nextReason, String confirmedGrowthLevel) {
public ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId,
String nextReason) {
this(reviewer, advice, incentivePoint, nextScenarioId, nextReason, null);
}
} }
public record ReviewDetailResponse( public record ReviewDetailResponse(
@@ -278,6 +333,12 @@ public final class AihrPracticeDto {
String time, String time,
String trainee, String trainee,
String scene, String scene,
String position,
String projectType,
String growthLevel,
String competencyCode,
String collaborationPositions,
String curriculumVersion,
Integer score, Integer score,
String status, String status,
String summary, String summary,
@@ -288,8 +349,22 @@ public final class AihrPracticeDto {
List<DimensionResponse> scoreItems, List<DimensionResponse> scoreItems,
List<DialogueResponse> dialogue, List<DialogueResponse> dialogue,
List<AnnotationResponse> annotations, List<AnnotationResponse> annotations,
String extPartyId String extPartyId,
String confirmedGrowthLevel,
String growthConfirmedBy,
String growthConfirmedTime
) { ) {
public ReviewDetailResponse(Long id, String sessionId, String time, String trainee, String scene,
String position, String projectType, String growthLevel, String competencyCode,
String collaborationPositions, String curriculumVersion, Integer score, String status,
String summary, String mentorRewrite, String aiComment, String reviewAdvice,
Integer incentivePoint, List<DimensionResponse> scoreItems,
List<DialogueResponse> dialogue, List<AnnotationResponse> annotations,
String extPartyId) {
this(id, sessionId, time, trainee, scene, position, projectType, growthLevel, competencyCode,
collaborationPositions, curriculumVersion, score, status, summary, mentorRewrite, aiComment,
reviewAdvice, incentivePoint, scoreItems, dialogue, annotations, extPartyId, "", "", "");
}
} }
public record CompetencyResponse( public record CompetencyResponse(
@@ -301,13 +376,41 @@ public final class AihrPracticeDto {
Integer assessmentScore, Integer assessmentScore,
String aiLevel, String aiLevel,
List<DimensionResponse> dimensions, List<DimensionResponse> dimensions,
List<GrowthStageResponse> growthPath List<GrowthStageResponse> growthPath,
List<CapabilityProgressResponse> capabilityProgress,
List<GrowthConfirmationResponse> growthConfirmations
) { ) {
public CompetencyResponse(Integer score, Integer completed, Integer pendingReview, Integer trainingMinutes,
Integer contribution, Integer assessmentScore, String aiLevel,
List<DimensionResponse> dimensions, List<GrowthStageResponse> growthPath,
List<CapabilityProgressResponse> capabilityProgress) {
this(score, completed, pendingReview, trainingMinutes, contribution, assessmentScore, aiLevel,
dimensions, growthPath, capabilityProgress, List.of());
}
} }
public record GrowthStageResponse(String level, Integer progress, String status, String evidence) { public record GrowthStageResponse(String level, Integer progress, String status, String evidence) {
} }
public record GrowthConfirmationResponse(String position, String confirmedGrowthLevel, String reviewer,
String confirmedTime) {
}
/**
* A completed-session aggregate keyed by the curriculum snapshot captured
* when the employee started the practice. It deliberately reports
* training evidence only; it is not a personnel-grade or promotion result.
*/
public record CapabilityProgressResponse(
String position,
String growthLevel,
String competencyCode,
Integer completed,
Integer averageScore,
String status
) {
}
public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) { public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) {
} }
@@ -28,11 +28,15 @@ public class AihrKnowledgeDataToolService {
if (principal == null || !hasText(principal.extPartyId())) { if (principal == null || !hasText(principal.extPartyId())) {
throw new ServiceException("当前登录身份不能使用训练数据工具", HttpStatus.FORBIDDEN); throw new ServiceException("当前登录身份不能使用训练数据工具", HttpStatus.FORBIDDEN);
} }
// In the APP knowledge flow this field carries the authenticated mobile
// username. Validate it once at the tool boundary before any legacy
// training query can resolve aliases from organization data.
String mobilePhone = mobileSeedService.requireMobileIdentity(principal.extPartyId());
String toolCode = rawToolCode == null ? "" : rawToolCode.trim().toUpperCase(java.util.Locale.ROOT); String toolCode = rawToolCode == null ? "" : rawToolCode.trim().toUpperCase(java.util.Locale.ROOT);
return switch (toolCode) { return switch (toolCode) {
case MY_PRACTICE_SUMMARY -> myPractice(principal.extPartyId()); case MY_PRACTICE_SUMMARY -> myPractice(mobilePhone);
case TEAM_PRACTICE_SUMMARY -> teamPractice(principal); case TEAM_PRACTICE_SUMMARY -> teamPractice(principal, mobilePhone);
case MY_CURRENT_TASKS -> myCurrentTasks(principal.extPartyId()); case MY_CURRENT_TASKS -> myCurrentTasks(mobilePhone);
default -> throw new ServiceException("不支持的数据工具", HttpStatus.BAD_REQUEST); default -> throw new ServiceException("不支持的数据工具", HttpStatus.BAD_REQUEST);
}; };
} }
@@ -92,11 +96,11 @@ public class AihrKnowledgeDataToolService {
return new ToolResult(MY_PRACTICE_SUMMARY, "我的训练概况", "RECENT_5", answer, data); return new ToolResult(MY_PRACTICE_SUMMARY, "我的训练概况", "RECENT_5", answer, data);
} }
private ToolResult teamPractice(AihrKnowledgePrincipal principal) { private ToolResult teamPractice(AihrKnowledgePrincipal principal, String mobilePhone) {
if (!principal.roles().contains("supervisor")) { if (!principal.roles().contains("supervisor")) {
throw new ServiceException("只有主管可以查询团队训练概况", HttpStatus.FORBIDDEN); throw new ServiceException("只有主管可以查询团队训练概况", HttpStatus.FORBIDDEN);
} }
String supervisorIdentity = mobileSeedService.requireSupervisorIdentity(principal.extPartyId()); String supervisorIdentity = mobileSeedService.requireMobileSupervisorIdentity(mobilePhone);
PracticeTeamResponse snapshot = mobileSeedService.practiceTeam(supervisorIdentity); PracticeTeamResponse snapshot = mobileSeedService.practiceTeam(supervisorIdentity);
List<RecordResponse> records = snapshot.records() == null ? List.of() : snapshot.records(); List<RecordResponse> records = snapshot.records() == null ? List.of() : snapshot.records();
int memberCount = snapshot.members() == null ? 0 : snapshot.members().size(); int memberCount = snapshot.members() == null ? 0 : snapshot.members().size();
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.aihr.service.AihrOrgSyncService; import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType; import org.dromara.common.core.enums.UserType;
@@ -22,6 +23,7 @@ import java.util.Set;
public class AihrKnowledgePrincipalResolver { public class AihrKnowledgePrincipalResolver {
private final AihrOrgSyncService orgSyncService; private final AihrOrgSyncService orgSyncService;
private final AihrMobileSeedService mobileSeedService;
public AihrKnowledgePrincipal current() { public AihrKnowledgePrincipal current() {
LoginUser loginUser = LoginHelper.getLoginUser(); LoginUser loginUser = LoginHelper.getLoginUser();
@@ -41,7 +43,7 @@ public class AihrKnowledgePrincipalResolver {
} }
if (UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { if (UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
roles.add("employee"); roles.add("employee");
extPartyId = trim(loginUser.getUsername()); extPartyId = mobileSeedService.requireMobileIdentity(trim(loginUser.getUsername()));
List<OrgPersonRow> rows = orgRows(extPartyId); List<OrgPersonRow> rows = orgRows(extPartyId);
if (rows.stream().anyMatch(AihrKnowledgePrincipalResolver::isSupervisor)) { if (rows.stream().anyMatch(AihrKnowledgePrincipalResolver::isSupervisor)) {
roles.add("supervisor"); roles.add("supervisor");
@@ -56,12 +58,12 @@ public class AihrKnowledgePrincipalResolver {
roles, projectCodes, trim(loginUser.getClientKey())); roles, projectCodes, trim(loginUser.getClientKey()));
} }
private List<OrgPersonRow> orgRows(String extPartyId) { private List<OrgPersonRow> orgRows(String mobilePhone) {
if (extPartyId.isBlank()) { if (mobilePhone.isBlank()) {
return List.of(); return List.of();
} }
try { try {
return orgSyncService.snapshot(extPartyId, null, null, "active", 1, 500, 500).rows(); return orgSyncService.activeByMobilePhone(mobilePhone);
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
log.warn("knowledge principal org lookup failed, keeping employee-only scope"); log.warn("knowledge principal org lookup failed, keeping employee-only scope");
return List.of(); return List.of();
@@ -22,8 +22,10 @@ import org.dromara.aihr.learning.AihrLearningDto.ExamTargetDraft;
import org.dromara.aihr.learning.AihrLearningDto.ExamTargetResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamTargetResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest; import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest;
import org.dromara.aihr.service.AihrModelSeedService; import org.dromara.aihr.service.AihrModelSeedService;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper; import org.dromara.common.tenant.helper.TenantHelper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -61,6 +63,9 @@ public class AihrExamService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final AihrModelSeedService modelSeedService; private final AihrModelSeedService modelSeedService;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean schemaReady; private volatile boolean schemaReady;
@PostConstruct @PostConstruct
@@ -792,6 +797,16 @@ public class AihrExamService {
if (schemaReady) { if (schemaReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate,
"aihr_onboard_exam", "aihr_onboard_exam_question", "aihr_onboard_exam_target",
"aihr_onboard_exam_attempt", "aihr_onboard_exam_answer");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_exam", "publish_request_key");
AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_onboard_exam",
"uk_aihr_onboard_exam_publish_request");
schemaReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_onboard_exam ( CREATE TABLE IF NOT EXISTS aihr_onboard_exam (
id bigint NOT NULL AUTO_INCREMENT, id bigint NOT NULL AUTO_INCREMENT,
@@ -899,6 +914,9 @@ public class AihrExamService {
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?
""", Integer.class, table, column); """, Integer.class, table, column);
if (count == null || count == 0) { if (count == null || count == 0) {
if (!runtimeSchemaBootstrap) {
throw new ServiceException("数据库结构缺失 " + table + "." + column + ",请先执行正式 SQL 迁移");
}
jdbcTemplate.execute(ddl); jdbcTemplate.execute(ddl);
} }
} }
@@ -909,6 +927,9 @@ public class AihrExamService {
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?
""", Integer.class, table, index); """, Integer.class, table, index);
if (count == null || count == 0) { if (count == null || count == 0) {
if (!runtimeSchemaBootstrap) {
throw new ServiceException("数据库索引缺失 " + table + "." + index + ",请先执行正式 SQL 迁移");
}
jdbcTemplate.execute(ddl); jdbcTemplate.execute(ddl);
} }
} }
@@ -12,9 +12,11 @@ import org.dromara.aihr.learning.AihrLearningDto.LearningMatrixResponse;
import org.dromara.aihr.learning.AihrLearningDto.LearningQuestionResponse; import org.dromara.aihr.learning.AihrLearningDto.LearningQuestionResponse;
import org.dromara.aihr.learning.AihrLearningDto.PositionMatrixResponse; import org.dromara.aihr.learning.AihrLearningDto.PositionMatrixResponse;
import org.dromara.aihr.learning.AihrLearningDto.ScenarioMatrixResponse; import org.dromara.aihr.learning.AihrLearningDto.ScenarioMatrixResponse;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
import org.dromara.aihr.service.AihrPracticeSeedService; import org.dromara.aihr.service.AihrPracticeSeedService;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper; import org.dromara.common.tenant.helper.TenantHelper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -47,6 +49,9 @@ public class AihrLearningService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final AihrPracticeSeedService practiceSeedService; private final AihrPracticeSeedService practiceSeedService;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean schemaReady; private volatile boolean schemaReady;
@PostConstruct @PostConstruct
@@ -365,6 +370,14 @@ public class AihrLearningService {
if (schemaReady) { if (schemaReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate,
"aihr_learning_question", "aihr_practice_question_feedback", "aihr_practice_assignment");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_practice_assignment",
"content_version", "question_snapshot", "reference_answer_snapshot", "content_hash");
schemaReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_learning_question ( CREATE TABLE IF NOT EXISTS aihr_learning_question (
id bigint NOT NULL AUTO_INCREMENT, id bigint NOT NULL AUTO_INCREMENT,
@@ -416,6 +429,9 @@ public class AihrLearningService {
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_practice_assignment' AND COLUMN_NAME = ? WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_practice_assignment' AND COLUMN_NAME = ?
""", Integer.class, column); """, Integer.class, column);
if (exists == null || exists == 0) { if (exists == null || exists == 0) {
if (!runtimeSchemaBootstrap) {
throw new ServiceException("数据库结构缺失 aihr_practice_assignment." + column + ",请先执行正式 SQL 迁移");
}
jdbcTemplate.execute(ddl); jdbcTemplate.execute(ddl);
} }
} }
@@ -15,12 +15,14 @@ import org.dromara.aihr.report.AihrWorkReportDto.OrganizeResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse; import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest; import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
import org.dromara.aihr.service.AihrModelSeedService; import org.dromara.aihr.service.AihrModelSeedService;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
import org.dromara.aihr.service.AihrSensitiveText; import org.dromara.aihr.service.AihrSensitiveText;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService; import org.dromara.system.service.ISysOssService;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -89,6 +91,9 @@ public class AihrWorkReportService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ISysOssService ossService; private final ISysOssService ossService;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean tableReady; private volatile boolean tableReady;
public OrganizeResponse organize(OrganizeRequest request) { public OrganizeResponse organize(OrganizeRequest request) {
@@ -669,6 +674,12 @@ public class AihrWorkReportService {
if (tableReady) { if (tableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_work_report");
requireIdempotencySchema();
tableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_work_report` ( CREATE TABLE IF NOT EXISTS `aihr_work_report` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -7,6 +7,7 @@ import org.dromara.common.core.exception.ServiceException;
import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService; import org.dromara.system.service.ISysOssService;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder; import org.springframework.jdbc.support.KeyHolder;
@@ -36,6 +37,9 @@ public class AihrCandidateMaterialService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final ISysOssService ossService; private final ISysOssService ossService;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean tableReady; private volatile boolean tableReady;
public MaterialResponse upload(MultipartFile file, String candidateId, String candidateName, String materialType) { public MaterialResponse upload(MultipartFile file, String candidateId, String candidateName, String materialType) {
@@ -161,6 +165,12 @@ public class AihrCandidateMaterialService {
if (tableReady) { if (tableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_candidate_material");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_candidate_material", "reviewer", "reviewed_time");
tableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_candidate_material` ( CREATE TABLE IF NOT EXISTS `aihr_candidate_material` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -19,6 +19,7 @@ import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService; import org.dromara.system.service.ISysOssService;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -52,6 +53,10 @@ public class AihrCaseService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final ISysOssService ossService; private final ISysOssService ossService;
private final Map<String, CaseState> cases = new ConcurrentHashMap<>(); private final Map<String, CaseState> cases = new ConcurrentHashMap<>();
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean tableReady; private volatile boolean tableReady;
public UploadResponse upload(MultipartFile file, String projectExtOrgId, List<String> projectScopes) { public UploadResponse upload(MultipartFile file, String projectExtOrgId, List<String> projectScopes) {
@@ -237,9 +242,14 @@ public class AihrCaseService {
.orElse(null); .orElse(null);
} }
public List<String> projectScopesForAppUser(String identity) { /**
String username = clean(identity); * APP callers are authenticated by mobile phone. This must remain an exact
if (username.isBlank()) { * phone predicate: treating the same value as an external ID lets a phone
* collision borrow another employee's project scope.
*/
public List<String> projectScopesForAppUser(String mobilePhone) {
String phone = clean(mobilePhone);
if (phone.isBlank()) {
return List.of(); return List.of();
} }
try { try {
@@ -252,33 +262,33 @@ public class AihrCaseService {
AND employment_status = 'active' AND employment_status = 'active'
AND project_code IS NOT NULL AND project_code IS NOT NULL
AND project_code <> '' AND project_code <> ''
AND (person_phone = ? OR ext_party_id = ?) AND person_phone = ?
ORDER BY project_code ORDER BY project_code
""", (rs, rowNum) -> rs.getString("project_code"), tenantId(), username, username); """, (rs, rowNum) -> rs.getString("project_code"), tenantId(), phone);
} catch (DataAccessException e) { } catch (DataAccessException e) {
log.warn("resolve app user project scope failed(处理错误已隐藏)"); log.warn("resolve app user project scope failed(处理错误已隐藏)");
return List.of(); return List.of();
} }
} }
public boolean isCaseContributor(String identity) { public boolean isCaseContributor(String mobilePhone) {
String username = clean(identity); String phone = clean(mobilePhone);
if (username.isBlank()) { if (phone.isBlank()) {
return false; return false;
} }
try { try {
Integer count = jdbcTemplate.queryForObject(""" Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) SELECT COUNT(*)
FROM aihr_org_snapshot FROM aihr_org_snapshot
WHERE tenant_id = ? WHERE tenant_id = ?
AND employment_status = 'active' AND employment_status = 'active'
AND (person_phone = ? OR ext_party_id = ?) AND person_phone = ?
AND (position_level IN ('主管', '项目经理') AND (position_level IN ('主管', '项目经理')
OR position_name LIKE '%主管%' OR position_name LIKE '%主管%'
OR position_name LIKE '%经理%' OR position_name LIKE '%经理%'
OR position_name LIKE '%负责人%' OR position_name LIKE '%负责人%'
OR position_name LIKE '%组长%') OR position_name LIKE '%组长%')
""", Integer.class, tenantId(), username, username); """, Integer.class, tenantId(), phone);
return count != null && count > 0; return count != null && count > 0;
} catch (DataAccessException e) { } catch (DataAccessException e) {
log.warn("resolve case contributor failed(处理错误已隐藏)"); log.warn("resolve case contributor failed(处理错误已隐藏)");
@@ -331,6 +341,13 @@ public class AihrCaseService {
if (tableReady) { if (tableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_case_record");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_case_record",
"media_oss_id", "media_url", "learning_points", "supervisor_comment");
tableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_case_record` ( CREATE TABLE IF NOT EXISTS `aihr_case_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -392,14 +392,19 @@ public class AihrDashboardService {
} }
private CalibrationStats calibrationStats() { private CalibrationStats calibrationStats() {
if (!tableExists("aihr_practice_calibration")) { if (!tableExists("aihr_practice_calibration")
|| !tableExists("aihr_practice_session")
|| !columnExists("aihr_practice_session", "mode")) {
return CalibrationStats.unavailable(); return CalibrationStats.unavailable();
} }
try { try {
CalibrationStats stats = jdbcTemplate.queryForObject(""" CalibrationStats stats = jdbcTemplate.queryForObject("""
SELECT COUNT(DISTINCT session_id) sessions SELECT COUNT(DISTINCT c.session_id) sessions
FROM aihr_practice_calibration FROM aihr_practice_calibration c
WHERE tenant_id = ? AND original_score IS NOT NULL AND corrected_score IS NOT NULL JOIN aihr_practice_session s
ON s.tenant_id = c.tenant_id AND s.session_id = c.session_id
WHERE c.tenant_id = ? AND s.mode = 'mobile'
AND c.original_score IS NOT NULL AND c.corrected_score IS NOT NULL
""", (rs, rowNum) -> new CalibrationStats(rs.getInt("sessions"), true), tenantId()); """, (rs, rowNum) -> new CalibrationStats(rs.getInt("sessions"), true), tenantId());
return stats == null ? CalibrationStats.unavailable() : stats; return stats == null ? CalibrationStats.unavailable() : stats;
} catch (DataAccessException e) { } catch (DataAccessException e) {
@@ -20,6 +20,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse; import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -48,6 +49,10 @@ public class AihrInterviewService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>(); private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
private final Map<String, CandidateProfile> candidates = buildCandidates(); private final Map<String, CandidateProfile> candidates = buildCandidates();
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean tableReady; private volatile boolean tableReady;
private volatile boolean candidateLinkTableReady; private volatile boolean candidateLinkTableReady;
@@ -376,6 +381,13 @@ public class AihrInterviewService {
if (tableReady) { if (tableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_interview_result");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_interview_result",
"questions_json", "reviewed_score", "review_status", "reviewer", "review_note", "reviewed_time");
tableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_interview_result` ( CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -446,6 +458,11 @@ public class AihrInterviewService {
if (candidateLinkTableReady) { if (candidateLinkTableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_candidate_employee_link");
candidateLinkTableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` ( CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -86,10 +86,18 @@ public class AihrMobileSeedService {
return practiceSeedService.mobileHistory(extPartyId, 5); return practiceSeedService.mobileHistory(extPartyId, 5);
} }
public List<RecordResponse> organizationPracticeHistory(String organizationExtPartyId) {
return practiceSeedService.organizationHistory(organizationExtPartyId, 5);
}
public List<MistakeResponse> practiceMistakes(String extPartyId) { public List<MistakeResponse> practiceMistakes(String extPartyId) {
return practiceSeedService.mobileMistakes(extPartyId, 20); return practiceSeedService.mobileMistakes(extPartyId, 20);
} }
public List<MistakeResponse> organizationPracticeMistakes(String organizationExtPartyId) {
return practiceSeedService.organizationMistakes(organizationExtPartyId, 20);
}
public List<RecordResponse> practiceReviews() { public List<RecordResponse> practiceReviews() {
return practiceSeedService.pendingReviewRecords(5); return practiceSeedService.pendingReviewRecords(5);
} }
@@ -114,6 +122,22 @@ public class AihrMobileSeedService {
return practiceSeedService.requireSupervisorIdentity(authenticatedIdentity); return practiceSeedService.requireSupervisorIdentity(authenticatedIdentity);
} }
/**
* Validates an APP principal, whose authenticated username is always a
* mobile phone number rather than an organization external ID.
*/
public String requireMobileIdentity(String mobilePhone) {
return practiceSeedService.requireMobileIdentity(mobilePhone);
}
/**
* Resolves an APP supervisor phone to the verified organization external
* ID used by team-scope queries.
*/
public String requireMobileSupervisorIdentity(String mobilePhone) {
return practiceSeedService.requireMobileSupervisorIdentity(mobilePhone);
}
public ReviewDetailResponse practiceReview(Long id) { public ReviewDetailResponse practiceReview(Long id) {
return practiceSeedService.reviewDetail(id); return practiceSeedService.reviewDetail(id);
} }
@@ -154,10 +178,18 @@ public class AihrMobileSeedService {
return practiceSeedService.competency(extPartyId); return practiceSeedService.competency(extPartyId);
} }
public CompetencyResponse organizationProfile(String organizationExtPartyId) {
return practiceSeedService.organizationCompetency(organizationExtPartyId);
}
public PromotionEvidenceResponse promotionEvidence(String extPartyId) { public PromotionEvidenceResponse promotionEvidence(String extPartyId) {
return practiceSeedService.promotionEvidence(extPartyId); return practiceSeedService.promotionEvidence(extPartyId);
} }
public PromotionEvidenceResponse organizationPromotionEvidence(String organizationExtPartyId) {
return practiceSeedService.organizationPromotionEvidence(organizationExtPartyId);
}
public List<PracticeAlertResponse> practiceAlerts() { public List<PracticeAlertResponse> practiceAlerts() {
return practiceSeedService.alerts(); return practiceSeedService.alerts();
} }
@@ -170,6 +202,10 @@ public class AihrMobileSeedService {
return practiceSeedService.assignments(extPartyId, 10); return practiceSeedService.assignments(extPartyId, 10);
} }
public List<PracticeAssignmentResponse> organizationPracticeAssignments(String organizationExtPartyId) {
return practiceSeedService.organizationAssignments(organizationExtPartyId, 10);
}
public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId, String supervisorExtPartyId) { public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId, String supervisorExtPartyId) {
return practiceSeedService.assignments(extPartyId, supervisorExtPartyId, 10); return practiceSeedService.assignments(extPartyId, supervisorExtPartyId, 10);
} }
@@ -77,6 +77,9 @@ public class AihrOrgSyncService {
@Value("${aihr.org-sync.position-map-json:${AIHR_ORG_POSITION_MAP_JSON:}}") @Value("${aihr.org-sync.position-map-json:${AIHR_ORG_POSITION_MAP_JSON:}}")
private String configuredPositionMapJson; private String configuredPositionMapJson;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
public SyncResponse sync(SyncRequest request) { public SyncResponse sync(SyncRequest request) {
SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request; SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request;
if (req.dryRun() == null) { if (req.dryRun() == null) {
@@ -280,6 +283,38 @@ public class AihrOrgSyncService {
return new OrgSnapshotResponse(total, active, departed, phoneLinked, suspectText, safePageNum, safePageSize, orgProjects(), orgLevels(), rows); return new OrgSnapshotResponse(total, active, departed, phoneLinked, suspectText, safePageNum, safePageSize, orgProjects(), orgLevels(), rows);
} }
/**
* APP identity resolution must not use the management-console fuzzy search:
* an APP username is a phone number and only an exact active phone match is
* eligible to contribute role or project scope.
*/
public List<OrgPersonRow> activeByMobilePhone(String mobilePhone) {
requireSnapshotTable();
String phone = clean(mobilePhone);
if (phone.isBlank()) {
return List.of();
}
return jdbcTemplate.query("""
select id, project_code, project_name, dept_name, ext_party_id, person_name,
position_name, position_level, employment_status,
date_format(snapshot_date, '%Y-%m-%d') snapshot_date
from aihr_org_snapshot
where tenant_id = ? and person_phone = ? and employment_status = 'active'
order by project_name, dept_name, position_level, person_name, id
""", (rs, rowNum) -> new OrgPersonRow(
rs.getLong("id"),
rs.getString("project_code"),
rs.getString("project_name"),
storeDisplayFields ? rs.getString("dept_name") : "",
rs.getString("ext_party_id"),
storeDisplayFields ? rs.getString("person_name") : "",
rs.getString("position_name"),
rs.getString("position_level"),
rs.getString("employment_status"),
rs.getString("snapshot_date")
), tenantId(), phone);
}
private List<JsonNode> fetchOptional(String baseUrl, String token, SyncRequest req, String resourceType, private List<JsonNode> fetchOptional(String baseUrl, String token, SyncRequest req, String resourceType,
int pageSize, int maxPages, List<String> warnings) { int pageSize, int maxPages, List<String> warnings) {
try { try {
@@ -850,6 +885,12 @@ public class AihrOrgSyncService {
} }
private void requireSnapshotTable() { private void requireSnapshotTable() {
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_org_snapshot");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_org_snapshot", "person_phone", "hire_date");
AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_org_snapshot", "idx_aihr_org_phone");
return;
}
try { try {
Integer count = jdbcTemplate.queryForObject(""" Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) SELECT COUNT(*)
@@ -132,15 +132,41 @@ public class AihrPracticeLlmService {
int defaultTrust, int defaultTrust,
String defaultCoachHint, String defaultCoachHint,
String styleInstruction String styleInstruction
) {
return customerTurn(personaTraits, project, goal, dialogue, traineeText, nextBeat, defaultEmotion, defaultTrust,
defaultCoachHint, styleInstruction, "");
}
/**
* The curriculum context is loaded from the server-side scenario snapshot.
* It constrains the model but must never be exposed in the simulated
* customer's own wording.
*/
public Optional<PracticeTurn> customerTurn(
String personaTraits,
String project,
String goal,
List<DialogueResponse> dialogue,
String traineeText,
String nextBeat,
int defaultEmotion,
int defaultTrust,
String defaultCoachHint,
String styleInstruction,
String curriculumContext
) { ) {
String styleRule = styleInstruction == null || styleInstruction.isBlank() String styleRule = styleInstruction == null || styleInstruction.isBlank()
? "" ? ""
: "\n 6. 本次只调整 coachHint 风格:" + styleInstruction.trim(); : "\n 6. 本次只调整 coachHint 风格:" + styleInstruction.trim();
String curriculumRule = curriculumContext == null || curriculumContext.isBlank()
? "未配置额外目录约束"
: curriculumContext.trim();
String system = """ String system = """
你只扮演物业小区业主,同时作为训练引擎给出结构化回合状态。 你只扮演物业小区业主,同时作为训练引擎给出结构化回合状态。
人设特征:%s 人设特征:%s
场景:%s 场景:%s
隐性诉求:%s 隐性诉求:%s
训练目录约束(仅约束角色和评分提示,绝不在客户话术中暴露):%s
规则: 规则:
1. customerText 是业主下一句话,一句以内,不超过%d个字,口语化。 1. customerText 是业主下一句话,一句以内,不超过%d个字,口语化。
2. 禁止在 customerText 中提及系统、人设、评分、SOP、AI、训练。 2. 禁止在 customerText 中提及系统、人设、评分、SOP、AI、训练。
@@ -150,7 +176,7 @@ public class AihrPracticeLlmService {
%s %s
只输出 JSON,不要输出其他文字: 只输出 JSON,不要输出其他文字:
{"customerText":"","emotion":%d,"trust":%d,"coachHint":"","redFlag":false} {"customerText":"","emotion":%d,"trust":%d,"coachHint":"","redFlag":false}
""".formatted(personaTraits, project, goal, MAX_CUSTOMER_CHARS, nextBeat, styleRule, defaultEmotion, defaultTrust); """.formatted(personaTraits, project, goal, curriculumRule, MAX_CUSTOMER_CHARS, nextBeat, styleRule, defaultEmotion, defaultTrust);
String user = "对话记录:\n" + renderDialogue(dialogue) String user = "对话记录:\n" + renderDialogue(dialogue)
+ "\n员工刚才说:" + traineeText + "\n员工刚才说:" + traineeText
+ "\n默认教练提示:" + defaultCoachHint + "\n默认教练提示:" + defaultCoachHint
@@ -174,10 +200,18 @@ public class AihrPracticeLlmService {
*/ */
public Optional<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> dialogue, public Optional<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> dialogue,
List<RubricDimensionResponse> rubric) { List<RubricDimensionResponse> rubric) {
return score(scenarioName, goal, strategy, dialogue, rubric, "");
}
public Optional<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> dialogue,
List<RubricDimensionResponse> rubric, String curriculumContext) {
if (dialogue.stream().noneMatch(turn -> "trainee".equals(turn.role()) && !turn.text().isBlank())) { if (dialogue.stream().noneMatch(turn -> "trainee".equals(turn.role()) && !turn.text().isBlank())) {
return Optional.empty(); return Optional.empty();
} }
String rubricText = renderRubric(rubric); String rubricText = renderRubric(rubric);
String curriculumRule = curriculumContext == null || curriculumContext.isBlank()
? "未配置额外目录约束"
: curriculumContext.trim();
String system = """ String system = """
你是物业行业培训考官(prompt版本 %s)。根据对练记录给员工话术评分。 你是物业行业培训考官(prompt版本 %s)。根据对练记录给员工话术评分。
评分锚点:60=及格线(有明显缺失);75=合格(覆盖主要要点);90=优秀(完整且超预期)。 评分锚点:60=及格线(有明显缺失);75=合格(覆盖主要要点);90=优秀(完整且超预期)。
@@ -186,7 +220,8 @@ public class AihrPracticeLlmService {
{"total":0-100,"taskCompletion":0-100,"compliance":0-100,"communication":0-100,"emotion":0-100,"responseTimeliness":0-100或-1,"marketing":0-100,"rewrite":"给员工的示范话术改写,80字内","summary":"一句话点评,60字内"} {"total":0-100,"taskCompletion":0-100,"compliance":0-100,"communication":0-100,"emotion":0-100,"responseTimeliness":0-100或-1,"marketing":0-100,"rewrite":"给员工的示范话术改写,80字内","summary":"一句话点评,60字内"}
taskCompletion 评估问题是否真正被推进到可执行闭环;responseTimeliness 只有对话中存在明确响应时效证据时填写,否则填 -1。 taskCompletion 评估问题是否真正被推进到可执行闭环;responseTimeliness 只有对话中存在明确响应时效证据时填写,否则填 -1。
本场评分规则:%s 本场评分规则:%s
""".formatted(PROMPT_VERSION, rubricText); 训练目录约束(仅用于判断岗位边界和能力项,不得臆造员工未说过的动作):%s
""".formatted(PROMPT_VERSION, rubricText, curriculumRule);
String user = """ String user = """
训练场景:%s 训练场景:%s
训练目标:%s 训练目标:%s
@@ -0,0 +1,105 @@
package org.dromara.aihr.service;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.regex.Pattern;
/**
* Fails closed when a release migration has not created an AIHR table.
* Runtime schema bootstrap remains an explicit development-only escape hatch.
*/
public final class AihrSchemaMigrationGuard {
private static final Pattern AIHR_TABLE = Pattern.compile("aihr_[a-z0-9_]+");
private static final Pattern AIHR_IDENTIFIER = Pattern.compile("[a-z][a-z0-9_]*");
private AihrSchemaMigrationGuard() {
}
public static void requireTables(JdbcTemplate jdbcTemplate, String... tableNames) {
for (String tableName : tableNames) {
requireAihrTableName(tableName);
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?
""", Integer.class, tableName);
if (count == null || count == 0) {
throw new ServiceException("数据库结构缺失 " + tableName + ",请先执行正式 SQL 迁移");
}
}
}
public static void requireColumns(JdbcTemplate jdbcTemplate, String tableName, String... columnNames) {
requireAihrTableName(tableName);
for (String columnName : columnNames) {
requireIdentifier(columnName, "列名");
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?
""", Integer.class, tableName, columnName);
if (count == null || count == 0) {
throw new ServiceException("数据库结构缺失 " + tableName + "." + columnName + ",请先执行正式 SQL 迁移");
}
}
}
public static void requireIndexes(JdbcTemplate jdbcTemplate, String tableName, String... indexNames) {
requireAihrTableName(tableName);
for (String indexName : indexNames) {
requireIdentifier(indexName, "索引名");
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?
""", Integer.class, tableName, indexName);
if (count == null || count == 0) {
throw new ServiceException("数据库结构缺失 " + tableName + "." + indexName + " 索引,请先执行正式 SQL 迁移");
}
}
}
/**
* Verifies the exact index contract required for a correctness-sensitive path.
* A same-named prefix or non-unique index is not equivalent to the formal migration.
*/
public static void requireIndexDefinition(JdbcTemplate jdbcTemplate, String tableName, String indexName,
boolean unique, String... columnNames) {
requireAihrTableName(tableName);
requireIdentifier(indexName, "索引名");
if (columnNames == null || columnNames.length == 0) {
throw new IllegalArgumentException("索引至少需要一个列名");
}
for (String columnName : columnNames) {
requireIdentifier(columnName, "列名");
}
String actual = jdbcTemplate.queryForObject("""
SELECT CONCAT(
COALESCE(MIN(non_unique), -1), '|',
COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|',
COALESCE(SUM(sub_part IS NOT NULL), 0)
)
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?
""", String.class, tableName, indexName);
String expected = (unique ? "0" : "1") + "|" + String.join(",", columnNames) + "|0";
if (!expected.equals(actual)) {
throw new ServiceException("数据库索引定义不匹配 " + tableName + "." + indexName
+ ",请先执行正式 SQL 迁移");
}
}
private static void requireAihrTableName(String tableName) {
if (tableName == null || !AIHR_TABLE.matcher(tableName).matches()) {
throw new IllegalArgumentException("非法 AIHR 表名");
}
}
private static void requireIdentifier(String value, String label) {
if (value == null || !AIHR_IDENTIFIER.matcher(value).matches()) {
throw new IllegalArgumentException("非法 AIHR " + label);
}
}
}
@@ -137,6 +137,10 @@ public class AihrSopSeedService {
private final AihrVideoService videoService; private final AihrVideoService videoService;
private final AihrModelSeedService modelService; private final AihrModelSeedService modelService;
private final TransactionTemplate requiresNewTransaction; private final TransactionTemplate requiresNewTransaction;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean knowledgeGapTableReady; private volatile boolean knowledgeGapTableReady;
private volatile boolean sopReviewTableReady; private volatile boolean sopReviewTableReady;
private volatile boolean answerFeedbackTableReady; private volatile boolean answerFeedbackTableReady;
@@ -2603,6 +2607,13 @@ public class AihrSopSeedService {
if (sopReviewTableReady) { if (sopReviewTableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_sop_answer_review");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_sop_answer_review",
"source", "requester_ext_party_id", "prompt_version");
sopReviewTableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` ( CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -2641,6 +2652,13 @@ public class AihrSopSeedService {
if (answerFeedbackTableReady) { if (answerFeedbackTableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_knowledge_answer_feedback");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_knowledge_answer_feedback",
"review_id", "feedback_reason_codes", "feedback_comment", "reviewer", "reviewed_time");
answerFeedbackTableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` ( CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -2683,6 +2701,11 @@ public class AihrSopSeedService {
if (knowledgeGapTableReady) { if (knowledgeGapTableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_knowledge_gap");
knowledgeGapTableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` ( CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -2708,6 +2731,9 @@ public class AihrSopSeedService {
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
""", Integer.class, table, column); """, Integer.class, table, column);
if (exists == null || exists == 0) { if (exists == null || exists == 0) {
if (!runtimeSchemaBootstrap) {
throw new ServiceException("数据库结构缺失 " + table + "." + column + ",请先执行正式 SQL 迁移");
}
jdbcTemplate.execute(ddl); jdbcTemplate.execute(ddl);
} }
} }
@@ -71,6 +71,10 @@ public class AihrUploadQueueService {
private final ScheduledExecutorService scheduledExecutorService; private final ScheduledExecutorService scheduledExecutorService;
private final Path stagingRoot; private final Path stagingRoot;
private final Semaphore workerPermits = new Semaphore(WORKER_PERMITS); private final Semaphore workerPermits = new Semaphore(WORKER_PERMITS);
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean tableReady; private volatile boolean tableReady;
public AihrUploadQueueService(AihrSopSeedService sopSeedService, public AihrUploadQueueService(AihrSopSeedService sopSeedService,
@@ -660,6 +664,11 @@ public class AihrUploadQueueService {
if (tableReady) { if (tableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_knowledge_upload_item");
tableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_knowledge_upload_item` ( CREATE TABLE IF NOT EXISTS `aihr_knowledge_upload_item` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -4,12 +4,14 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest; import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest;
import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse; import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse;
import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse; import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
import org.redisson.api.RateType; import org.redisson.api.RateType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -26,6 +28,9 @@ public class AihrWebAiService {
private final AihrKnowledgePrincipalResolver principalResolver; private final AihrKnowledgePrincipalResolver principalResolver;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean auditTableReady; private volatile boolean auditTableReady;
public QueryResponse query(QueryRequest request) { public QueryResponse query(QueryRequest request) {
@@ -136,6 +141,11 @@ public class AihrWebAiService {
if (auditTableReady) { if (auditTableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_web_ai_query_audit");
auditTableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_web_ai_query_audit` ( CREATE TABLE IF NOT EXISTS `aihr_web_ai_query_audit` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
@@ -3,7 +3,9 @@ package org.dromara.aihr.webai;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder; import org.springframework.jdbc.support.KeyHolder;
@@ -24,6 +26,10 @@ public class AihrWebSearchProviderService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final AihrWebSecretCodec secretCodec; private final AihrWebSecretCodec secretCodec;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile boolean tableReady; private volatile boolean tableReady;
public List<ProviderResponse> list(AihrKnowledgePrincipal principal) { public List<ProviderResponse> list(AihrKnowledgePrincipal principal) {
@@ -220,6 +226,11 @@ public class AihrWebSearchProviderService {
if (tableReady) { if (tableReady) {
return; return;
} }
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_web_search_provider");
tableReady = true;
return;
}
jdbcTemplate.execute(""" jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_web_search_provider` ( CREATE TABLE IF NOT EXISTS `aihr_web_search_provider` (
`id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL, `id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL,
@@ -1,7 +1,9 @@
package org.dromara.aihr.webai; package org.dromara.aihr.webai;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -22,6 +24,10 @@ class AihrWebSecretCodec {
private static final SecureRandom RANDOM = new SecureRandom(); private static final SecureRandom RANDOM = new SecureRandom();
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
private boolean runtimeSchemaBootstrap = true;
private volatile SecretKeySpec databaseKey; private volatile SecretKeySpec databaseKey;
String encrypt(String plaintext) { String encrypt(String plaintext) {
@@ -79,14 +85,18 @@ class AihrWebSecretCodec {
if (databaseKey != null) { if (databaseKey != null) {
return databaseKey; return databaseKey;
} }
jdbcTemplate.execute(""" if (!runtimeSchemaBootstrap) {
CREATE TABLE IF NOT EXISTS `aihr_web_ai_secret` ( AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_web_ai_secret");
`config_key` varchar(64) NOT NULL, } else {
`config_value` varchar(255) NOT NULL, jdbcTemplate.execute("""
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, CREATE TABLE IF NOT EXISTS `aihr_web_ai_secret` (
PRIMARY KEY (`config_key`) `config_key` varchar(64) NOT NULL,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci `config_value` varchar(255) NOT NULL,
"""); `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`config_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
}
byte[] random = new byte[32]; byte[] random = new byte[32];
RANDOM.nextBytes(random); RANDOM.nextBytes(random);
jdbcTemplate.update(""" jdbcTemplate.update("""
@@ -46,7 +46,7 @@ class AihrAgentMediaTest {
return new AihrAgentOrchestrator( return new AihrAgentOrchestrator(
new AihrAgentPlanner(null, new ObjectMapper()), new AihrAgentPlanner(null, new ObjectMapper()),
new AihrAgentPolicy(), new AihrAgentPolicy(),
new AihrKnowledgePrincipalResolver(null) { new AihrKnowledgePrincipalResolver(null, null) {
@Override @Override
public AihrKnowledgePrincipal current() { public AihrKnowledgePrincipal current() {
return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
@@ -141,7 +141,7 @@ class AihrAgentOrchestratorTest {
return new AihrAgentOrchestrator( return new AihrAgentOrchestrator(
new AihrAgentPlanner(null, new ObjectMapper()), new AihrAgentPlanner(null, new ObjectMapper()),
new AihrAgentPolicy(), new AihrAgentPolicy(),
new AihrKnowledgePrincipalResolver(null) { new AihrKnowledgePrincipalResolver(null, null) {
@Override @Override
public AihrKnowledgePrincipal current() { public AihrKnowledgePrincipal current() {
return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
@@ -59,7 +59,7 @@ class AihrAgentWebResearchTest {
return new AihrAgentOrchestrator( return new AihrAgentOrchestrator(
new AihrAgentPlanner(null, new ObjectMapper()), new AihrAgentPlanner(null, new ObjectMapper()),
new AihrAgentPolicy(), new AihrAgentPolicy(),
new AihrKnowledgePrincipalResolver(null) { new AihrKnowledgePrincipalResolver(null, null) {
@Override @Override
public AihrKnowledgePrincipal current() { public AihrKnowledgePrincipal current() {
return principal; return principal;
@@ -107,7 +107,7 @@ class AihrBroadcastM1ServiceTest {
assertTrue(jdbcTemplate.publishReplaySql.contains("unmatched_target_count")); assertTrue(jdbcTemplate.publishReplaySql.contains("unmatched_target_count"));
} }
private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null) { private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null, null) {
@Override @Override
public AihrKnowledgePrincipal current() { public AihrKnowledgePrincipal current() {
return new AihrKnowledgePrincipal( return new AihrKnowledgePrincipal(
@@ -316,7 +316,7 @@ class AihrBroadcastServiceTest {
Method publish = AihrBroadcastController.class.getMethod( Method publish = AihrBroadcastController.class.getMethod(
"publish", AihrBroadcastDto.PublishRequest.class); "publish", AihrBroadcastDto.PublishRequest.class);
Constructor<?> constructor = java.util.Arrays.stream(AihrBroadcastDto.PublishRequest.class.getDeclaredConstructors()) Constructor<?> constructor = java.util.Arrays.stream(AihrBroadcastDto.PublishRequest.class.getDeclaredConstructors())
.filter(candidate -> candidate.getParameterCount() == 6) .filter(candidate -> candidate.getParameterCount() == 7)
.findFirst() .findFirst()
.orElseThrow(); .orElseThrow();
@@ -347,7 +347,7 @@ class AihrBroadcastServiceTest {
assertEquals(cn.dev33.satoken.annotation.SaMode.OR, role.mode()); assertEquals(cn.dev33.satoken.annotation.SaMode.OR, role.mode());
} }
private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null) { private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null, null) {
@Override @Override
public AihrKnowledgePrincipal current() { public AihrKnowledgePrincipal current() {
return new AihrKnowledgePrincipal( return new AihrKnowledgePrincipal(
@@ -30,12 +30,14 @@ class AihrKnowledgeDataToolServiceTest {
@Test @Test
void myPracticeSummaryAlwaysUsesCurrentAppUserIdentity() { void myPracticeSummaryAlwaysUsesCurrentAppUserIdentity() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.practiceHistory("employee-1")).thenReturn(List.of( when(mobile.practiceHistory("employee-1")).thenReturn(List.of(
new RecordResponse(9L, "session", "07-16 09:00", "张三", "投诉沟通", 88, "已完成", "表达清晰", "employee-1"))); new RecordResponse(9L, "session", "07-16 09:00", "张三", "投诉沟通", 88, "已完成", "表达清晰", "employee-1")));
var service = new AihrKnowledgeDataToolService(mobile); var service = new AihrKnowledgeDataToolService(mobile);
var result = service.execute(employee(), AihrKnowledgeDataToolService.MY_PRACTICE_SUMMARY); var result = service.execute(employee(), AihrKnowledgeDataToolService.MY_PRACTICE_SUMMARY);
verify(mobile).requireMobileIdentity("employee-1");
verify(mobile).practiceHistory("employee-1"); verify(mobile).practiceHistory("employee-1");
var data = (AihrKnowledgeDataToolService.MyPracticeSummary) result.data(); var data = (AihrKnowledgeDataToolService.MyPracticeSummary) result.data();
assertEquals(1, data.practiceCount()); assertEquals(1, data.practiceCount());
@@ -45,7 +47,8 @@ class AihrKnowledgeDataToolServiceTest {
@Test @Test
void teamPracticeSummaryUsesResolvedSupervisorScope() { void teamPracticeSummaryUsesResolvedSupervisorScope() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor"); when(mobile.requireMobileIdentity("supervisor-1")).thenReturn("supervisor-1");
when(mobile.requireMobileSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor");
when(mobile.practiceTeam("canonical-supervisor")) when(mobile.practiceTeam("canonical-supervisor"))
.thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of())); .thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of()));
var service = new AihrKnowledgeDataToolService(mobile); var service = new AihrKnowledgeDataToolService(mobile);
@@ -54,13 +57,15 @@ class AihrKnowledgeDataToolServiceTest {
service.execute(principal, AihrKnowledgeDataToolService.TEAM_PRACTICE_SUMMARY); service.execute(principal, AihrKnowledgeDataToolService.TEAM_PRACTICE_SUMMARY);
verify(mobile).requireSupervisorIdentity("supervisor-1"); verify(mobile).requireMobileIdentity("supervisor-1");
verify(mobile).requireMobileSupervisorIdentity("supervisor-1");
verify(mobile).practiceTeam("canonical-supervisor"); verify(mobile).practiceTeam("canonical-supervisor");
} }
@Test @Test
void employeeCannotCallTeamPracticeSummary() { void employeeCannotCallTeamPracticeSummary() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
var service = new AihrKnowledgeDataToolService(mobile); var service = new AihrKnowledgeDataToolService(mobile);
assertThrows(ServiceException.class, assertThrows(ServiceException.class,
@@ -71,6 +76,7 @@ class AihrKnowledgeDataToolServiceTest {
@Test @Test
void currentTasksUsesOnlyCurrentIdentityAndReturnsNoTaskExplicitly() { void currentTasksUsesOnlyCurrentIdentityAndReturnsNoTaskExplicitly() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot( when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(
List.of(), List.of())); List.of(), List.of()));
var service = new AihrKnowledgeDataToolService(mobile); var service = new AihrKnowledgeDataToolService(mobile);
@@ -87,6 +93,7 @@ class AihrKnowledgeDataToolServiceTest {
@Test @Test
void currentTasksKeepsOnlyIncompleteOnboardingRows() { void currentTasksKeepsOnlyIncompleteOnboardingRows() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(List.of( when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(List.of(
new OnboardTaskResponse(1L, "P1", "客服", null, null, "岗前学习", "待完成", "主管", null, new OnboardTaskResponse(1L, "P1", "客服", null, null, "岗前学习", "待完成", "主管", null,
"2026-07-30", null, null), "2026-07-30", null, null),
@@ -106,6 +113,7 @@ class AihrKnowledgeDataToolServiceTest {
@Test @Test
void currentTasksNeverTurnsAnUnavailableSourceIntoNoTasks() { void currentTasksNeverTurnsAnUnavailableSourceIntoNoTasks() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.currentTasks("employee-1")) when(mobile.currentTasks("employee-1"))
.thenThrow(new ServiceException("当前任务数据读取失败,暂时无法确认待办,请稍后再试", HttpStatus.ERROR)); .thenThrow(new ServiceException("当前任务数据读取失败,暂时无法确认待办,请稍后再试", HttpStatus.ERROR));
var service = new AihrKnowledgeDataToolService(mobile); var service = new AihrKnowledgeDataToolService(mobile);
@@ -121,6 +129,7 @@ class AihrKnowledgeDataToolServiceTest {
@Test @Test
void unknownToolIsRejectedWithoutReadingTrainingData() { void unknownToolIsRejectedWithoutReadingTrainingData() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
var service = new AihrKnowledgeDataToolService(mobile); var service = new AihrKnowledgeDataToolService(mobile);
assertThrows(ServiceException.class, () -> service.execute(employee(), "RAW_SQL")); assertThrows(ServiceException.class, () -> service.execute(employee(), "RAW_SQL"));
@@ -174,11 +174,18 @@ class AihrKnowledgeQueryServiceTest {
var access = mock(AihrKnowledgeAccessService.class); var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class); var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class); var jdbc = mock(JdbcTemplate.class);
var conversations = mock(AihrKnowledgeConversationService.class);
var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", Set.of("employee"), Set.of(), "app"); 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); var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var request = new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null,
"conversation_123", 0L);
var context = new ConversationContext("conversation_123", true, true, 0L, List.of(),
"电梯这里该怎么办", "QA", null);
when(resolver.current()).thenReturn(principal); when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app); when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L)); when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(conversations.prepare(principal, app, request)).thenReturn(context);
when(conversations.append(any(), any(), any(), anyString(), anyString(), any())).thenReturn(1L);
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop")); 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}); var media = new MockMultipartFile("file", "lift.jpg", "image/jpeg", new byte[]{1});
when(sop.extractQuestionMedia(media, "电梯这里该怎么办")) when(sop.extractQuestionMedia(media, "电梯这里该怎么办"))
@@ -188,17 +195,20 @@ class AihrKnowledgeQueryServiceTest {
List.of(), List.of(), List.of(), List.of(), "test-v1", null)); List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class)); conversations, mock(AihrMemoryService.class));
var result = service.queryInternalMedia( var result = service.queryInternalMedia(request, media);
new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null),
media);
var requestCaptor = ArgumentCaptor.forClass(SearchRequest.class); var requestCaptor = ArgumentCaptor.forClass(SearchRequest.class);
verify(sop).searchAuthorized(requestCaptor.capture(), any(), any()); verify(sop).searchAuthorized(requestCaptor.capture(), any(), any());
assertEquals("电梯这里该怎么办", result.queryText()); assertEquals("电梯这里该怎么办", result.queryText());
assertEquals(true, requestCaptor.getValue().queryText().contains("附件现场信息")); assertEquals(true, requestCaptor.getValue().queryText().contains("附件现场信息"));
assertEquals(true, requestCaptor.getValue().queryText().contains("电梯门无法打开")); assertEquals(true, requestCaptor.getValue().queryText().contains("电梯门无法打开"));
assertTrue(result.noEvidence());
assertTrue(result.answer().contains("附件识别结果:画面显示电梯门无法打开,楼层指示灯闪烁"));
var appendedAnswer = ArgumentCaptor.forClass(String.class);
verify(conversations).append(any(), any(), any(), anyString(), appendedAnswer.capture(), any());
assertTrue(appendedAnswer.getValue().contains("附件识别结果:画面显示电梯门无法打开,楼层指示灯闪烁"));
} }
@Test @Test
@@ -1,7 +1,7 @@
package org.dromara.aihr.knowledge.service; package org.dromara.aihr.knowledge.service;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse; import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.aihr.service.AihrOrgSyncService; import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType; import org.dromara.common.core.enums.UserType;
@@ -16,8 +16,6 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -27,13 +25,14 @@ class AihrKnowledgePrincipalResolverTest {
@Test @Test
void appSupervisorRoleAndProjectsComeFromOrgSnapshot() { void appSupervisorRoleAndProjectsComeFromOrgSnapshot() {
AihrOrgSyncService orgService = mock(AihrOrgSyncService.class); AihrOrgSyncService orgService = mock(AihrOrgSyncService.class);
when(orgService.snapshot(eq("party-1"), any(), any(), eq("active"), eq(1), eq(500), eq(500))) AihrMobileSeedService mobileSeedService = mock(AihrMobileSeedService.class);
.thenReturn(new OrgSnapshotResponse(1, 1, 0, 1, 0, 1, 500, List.of(), List.of(), List.of( when(mobileSeedService.requireMobileIdentity("13900001111")).thenReturn("13900001111");
new OrgPersonRow(1L, "P01", "", "", "party-1", "", "项目经理", "项目经理", "active", "2026-07-16") when(orgService.activeByMobilePhone("13900001111")).thenReturn(List.of(
))); new OrgPersonRow(1L, "P01", "", "", "party-1", "", "项目经理", "项目经理", "active", "2026-07-16")
LoginUser login = login(UserType.APP_USER.getUserType(), "party-1", Set.of()); ));
LoginUser login = login(UserType.APP_USER.getUserType(), "13900001111", Set.of());
var principal = new AihrKnowledgePrincipalResolver(orgService).from(login); var principal = new AihrKnowledgePrincipalResolver(orgService, mobileSeedService).from(login);
assertEquals(Set.of("employee", "supervisor"), principal.roles()); assertEquals(Set.of("employee", "supervisor"), principal.roles());
assertEquals(Set.of("P01"), principal.projectCodes()); assertEquals(Set.of("P01"), principal.projectCodes());
@@ -42,7 +41,7 @@ class AihrKnowledgePrincipalResolverTest {
@Test @Test
void sysUserRolesComeFromLoginUser() { void sysUserRolesComeFromLoginUser() {
LoginUser login = login(UserType.SYS_USER.getUserType(), "admin", Set.of("hr_operator")); LoginUser login = login(UserType.SYS_USER.getUserType(), "admin", Set.of("hr_operator"));
var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class)).from(login); var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class), mock(AihrMobileSeedService.class)).from(login);
assertEquals("000000", principal.tenantId()); assertEquals("000000", principal.tenantId());
assertTrue(principal.roles().contains("hr_operator")); assertTrue(principal.roles().contains("hr_operator"));
assertTrue(principal.projectCodes().isEmpty()); assertTrue(principal.projectCodes().isEmpty());
@@ -55,7 +54,7 @@ class AihrKnowledgePrincipalResolverTest {
try (MockedStatic<TenantHelper> tenantHelper = Mockito.mockStatic(TenantHelper.class)) { try (MockedStatic<TenantHelper> tenantHelper = Mockito.mockStatic(TenantHelper.class)) {
tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-b"); tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-b");
var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class)).from(login); var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class), mock(AihrMobileSeedService.class)).from(login);
assertEquals("tenant-b", principal.tenantId()); assertEquals("tenant-b", principal.tenantId());
} }
@@ -234,6 +234,26 @@ class AihrCaseServiceTest {
assertTrue(controllerSource.contains("HR_OPERATOR_ROLE = \"hr_operator\"")); assertTrue(controllerSource.contains("HR_OPERATOR_ROLE = \"hr_operator\""));
assertTrue(controllerSource.contains("private boolean canViewCases()")); assertTrue(controllerSource.contains("private boolean canViewCases()"));
assertTrue(controllerSource.contains("@GetMapping(\"/capabilities\")")); assertTrue(controllerSource.contains("@GetMapping(\"/capabilities\")"));
assertTrue(controllerSource.contains("mobileSeedService.requireMobileIdentity(username)"));
assertTrue(controllerSource.contains("caseService.projectScopesForAppUser(currentAppMobilePhone(loginUser))"));
assertTrue(controllerSource.contains("caseService.isCaseContributor(currentAppMobilePhone(loginUser))"));
}
@Test
@Tag("dev")
void appCaseScopeAndContributorQueriesUseOnlyExactMobilePhone() {
CaseIdentityJdbcTemplate jdbcTemplate = new CaseIdentityJdbcTemplate();
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
service.projectScopesForAppUser("13900001111");
service.isCaseContributor("13900001111");
assertTrue(jdbcTemplate.scopeSql.contains("AND person_phone = ?"));
assertFalse(jdbcTemplate.scopeSql.contains("OR ext_party_id"));
assertEquals(List.of("000000", "13900001111"), List.of(jdbcTemplate.scopeArgs));
assertTrue(jdbcTemplate.contributorSql.contains("AND person_phone = ?"));
assertFalse(jdbcTemplate.contributorSql.contains("OR ext_party_id"));
assertEquals(List.of("000000", "13900001111"), List.of(jdbcTemplate.contributorArgs));
} }
@Test @Test
@@ -309,4 +329,25 @@ class AihrCaseServiceTest {
return List.of(); return List.of();
} }
} }
private static final class CaseIdentityJdbcTemplate extends CaseJdbcTemplate {
private String scopeSql = "";
private Object[] scopeArgs = new Object[0];
private String contributorSql = "";
private Object[] contributorArgs = new Object[0];
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
scopeSql = sql;
scopeArgs = args;
return List.of();
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
contributorSql = sql;
contributorArgs = args;
return requiredType.cast(0);
}
}
} }
@@ -41,5 +41,7 @@ class AihrDashboardServiceTest {
assertTrue(code.contains("\"训练数据源未就绪\", \"info\", practice.available())")); assertTrue(code.contains("\"训练数据源未就绪\", \"info\", practice.available())"));
assertTrue(code.contains("\"校准数据源未就绪\", \"info\", calibrations.available())")); assertTrue(code.contains("\"校准数据源未就绪\", \"info\", calibrations.available())"));
assertTrue(code.contains("\"组织快照数据源未就绪\", \"info\", org.available())")); assertTrue(code.contains("\"组织快照数据源未就绪\", \"info\", org.available())"));
assertTrue(code.contains("JOIN aihr_practice_session s"));
assertTrue(code.contains("s.mode = 'mobile'"));
} }
} }
@@ -0,0 +1,99 @@
package org.dromara.aihr.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.env.MapPropertySource;
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.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrRuntimeSchemaBootstrapContractTest {
private static final String RUNTIME_SCHEMA_BOOTSTRAP_EXPRESSION =
"${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}";
@Test
void everyRuntimeDdlOwnerUsesTheSharedStaticSchemaGuard() throws Exception {
Path sourceRoot = projectPath("src/main/java");
List<Path> owners;
try (Stream<Path> files = Files.walk(sourceRoot)) {
owners = files.filter(path -> path.toString().endsWith(".java"))
.filter(path -> containsRuntimeDdl(path))
.toList();
}
assertFalse(owners.isEmpty(), "至少应覆盖一个运行时 DDL 服务");
for (Path sourcePath : owners) {
String sourceFile = sourceRoot.relativize(sourcePath).toString();
String source = Files.readString(sourcePath);
int firstDdl = firstDdlPosition(source);
int guard = source.indexOf("if (!runtimeSchemaBootstrap)");
assertTrue(firstDdl >= 0, () -> sourceFile + " must contain runtime DDL");
assertTrue(source.contains(RUNTIME_SCHEMA_BOOTSTRAP_EXPRESSION),
() -> sourceFile + " must use the shared disabled-by-default runtime schema switch");
assertTrue(guard >= 0 && guard < firstDdl,
() -> sourceFile + " must return through the disabled-bootstrap guard before its DDL");
}
}
@Test
void runtimeSchemaBootstrapDefaultsToFalseInSpring() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(RuntimeSchemaBootstrapProbe.class);
context.refresh();
assertFalse(context.getBean(RuntimeSchemaBootstrapProbe.class).enabled);
}
}
@Test
void runtimeSchemaBootstrapCanOnlyBeEnabledExplicitlyInSpring() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test", Map.of(
"aihr.practice.runtime-schema-bootstrap", "true"
)));
context.registerBean(RuntimeSchemaBootstrapProbe.class);
context.refresh();
assertTrue(context.getBean(RuntimeSchemaBootstrapProbe.class).enabled);
}
}
private static final class RuntimeSchemaBootstrapProbe {
@Value(RUNTIME_SCHEMA_BOOTSTRAP_EXPRESSION)
private boolean enabled = true;
}
private static boolean containsRuntimeDdl(Path sourcePath) {
try {
int ddlPosition = firstDdlPosition(Files.readString(sourcePath));
return ddlPosition >= 0;
} catch (Exception error) {
throw new IllegalStateException("无法读取 " + sourcePath, error);
}
}
private static int firstDdlPosition(String source) {
int createTable = source.indexOf("CREATE TABLE IF NOT EXISTS");
int alterTable = source.indexOf("ALTER TABLE");
if (createTable < 0) {
return alterTable;
}
return alterTable < 0 ? createTable : Math.min(createTable, alterTable);
}
private static Path projectPath(String relative) {
Path direct = Path.of(relative);
return Files.exists(direct) ? direct : Path.of("ruoyi-modules/ruoyi-aihr").resolve(relative).normalize();
}
}
@@ -0,0 +1,145 @@
package org.dromara.aihr.service;
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.util.ArrayList;
import java.util.List;
import java.util.Map;
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 AihrSchemaMigrationGuardTest {
@Test
void verifiesEveryRequestedAihrTable() {
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_practice_session", "aihr_learning_question");
assertEquals(List.of("aihr_practice_session", "aihr_learning_question"), jdbcTemplate.checkedTables);
}
@Test
void failsClosedWhenTheFormalMigrationIsMissing() {
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
jdbcTemplate.missingTables.add("aihr_practice_session");
ServiceException error = assertThrows(ServiceException.class,
() -> AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_practice_session"));
assertTrue(error.getMessage().contains("aihr_practice_session"));
}
@Test
void verifiesColumnsAndIndexesWithoutExecutingDdl() {
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_practice_assignment", "content_hash");
AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_practice_assignment", "idx_aihr_assignment_recent_content");
assertEquals(List.of("aihr_practice_assignment.content_hash"), jdbcTemplate.checkedColumns);
assertEquals(List.of("aihr_practice_assignment.idx_aihr_assignment_recent_content"), jdbcTemplate.checkedIndexes);
}
@Test
void failsClosedWhenARequiredColumnIsMissing() {
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
jdbcTemplate.missingColumns.add("aihr_practice_assignment.content_hash");
ServiceException error = assertThrows(ServiceException.class,
() -> AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_practice_assignment", "content_hash"));
assertTrue(error.getMessage().contains("aihr_practice_assignment.content_hash"));
}
@Test
void failsClosedWhenARequiredIndexIsMissing() {
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
jdbcTemplate.missingIndexes.add("aihr_practice_assignment.uk_aihr_assignment_request");
ServiceException error = assertThrows(ServiceException.class,
() -> AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_practice_assignment",
"uk_aihr_assignment_request"));
assertTrue(error.getMessage().contains("aihr_practice_assignment.uk_aihr_assignment_request"));
}
@Test
void verifiesACompleteIndexDefinition() {
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
jdbcTemplate.indexDefinitions.put("aihr_practice_assignment.uk_aihr_assignment_request",
"0|tenant_id,request_key,ext_party_id|0");
AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_practice_assignment",
"uk_aihr_assignment_request", true, "tenant_id", "request_key", "ext_party_id");
assertEquals(List.of("aihr_practice_assignment.uk_aihr_assignment_request"), jdbcTemplate.checkedIndexDefinitions);
}
@Test
void failsClosedWhenAnIndexDefinitionIsWrong() {
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
jdbcTemplate.indexDefinitions.put("aihr_practice_assignment.uk_aihr_assignment_request",
"1|tenant_id,request_key,ext_party_id|0");
ServiceException error = assertThrows(ServiceException.class,
() -> AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_practice_assignment",
"uk_aihr_assignment_request", true, "tenant_id", "request_key", "ext_party_id"));
assertTrue(error.getMessage().contains("数据库索引定义不匹配"));
}
@Test
void rejectsNonAihrTablesAndUnsafeIdentifiers() {
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
assertThrows(IllegalArgumentException.class,
() -> AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "sys_user"));
assertThrows(IllegalArgumentException.class,
() -> AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_practice_assignment", "content-hash"));
assertThrows(IllegalArgumentException.class,
() -> AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_practice_assignment", "idx;drop"));
assertThrows(IllegalArgumentException.class,
() -> AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_practice_assignment",
"uk_aihr_assignment_request", true, "request-key"));
}
private static final class SchemaJdbcTemplate extends JdbcTemplate {
private final List<String> checkedTables = new ArrayList<>();
private final List<String> missingTables = new ArrayList<>();
private final List<String> checkedColumns = new ArrayList<>();
private final List<String> missingColumns = new ArrayList<>();
private final List<String> checkedIndexes = new ArrayList<>();
private final List<String> missingIndexes = new ArrayList<>();
private final List<String> checkedIndexDefinitions = new ArrayList<>();
private final Map<String, String> indexDefinitions = new java.util.HashMap<>();
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (sql.contains("GROUP_CONCAT(column_name ORDER BY seq_in_index)")) {
String key = args[0] + "." + args[1];
checkedIndexDefinitions.add(key);
return requiredType.cast(indexDefinitions.getOrDefault(key, "-1||0"));
}
if (sql.contains("information_schema.columns")) {
String key = args[0] + "." + args[1];
checkedColumns.add(key);
return requiredType.cast(missingColumns.contains(key) ? 0 : 1);
}
if (sql.contains("information_schema.statistics")) {
String key = args[0] + "." + args[1];
checkedIndexes.add(key);
return requiredType.cast(missingIndexes.contains(key) ? 0 : 1);
}
String tableName = String.valueOf(args[0]);
checkedTables.add(tableName);
return requiredType.cast(missingTables.contains(tableName) ? 0 : 1);
}
}
}
@@ -247,7 +247,8 @@ public class AihrSopSeedServiceTest {
String serviceCode = Files.readString(service); String serviceCode = Files.readString(service);
assertTrue(controllerCode.contains("UserType.APP_USER.getUserType()")); assertTrue(controllerCode.contains("UserType.APP_USER.getUserType()"));
assertTrue(controllerCode.contains("currentRequesterExtPartyId()")); assertTrue(controllerCode.contains("knowledgePrincipalResolver.current().projectCodes()"));
assertFalse(controllerCode.contains("orgSyncService.snapshot("));
assertTrue(serviceCode.contains("requester_ext_party_id")); assertTrue(serviceCode.contains("requester_ext_party_id"));
assertTrue(serviceCode.contains("NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL")); assertTrue(serviceCode.contains("NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL"));
assertTrue(serviceCode.contains("FROM aihr_org_snapshot o")); assertTrue(serviceCode.contains("FROM aihr_org_snapshot o"));
@@ -308,8 +309,9 @@ public class AihrSopSeedServiceTest {
String code = Files.readString(source); String code = Files.readString(source);
assertTrue(code.contains("sopSeedService.positionSop(position, projectType, currentAppProjectCodes())")); assertTrue(code.contains("sopSeedService.positionSop(position, projectType, currentAppProjectCodes())"));
assertTrue(code.contains("orgSyncService.snapshot(identity, null, null, \"active\", 1, 500, 500)")); assertTrue(code.contains("knowledgePrincipalResolver.current().projectCodes()"));
assertTrue(code.contains("OrgPersonRow::projectCode")); assertFalse(code.contains("orgSyncService.snapshot(identity, null, null, \"active\", 1, 500, 500)"));
assertFalse(code.contains("OrgPersonRow::projectCode"));
} }
@Test @Test
+53 -1
View File
@@ -32,6 +32,15 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
`trainee_name` varchar(100) DEFAULT NULL COMMENT '学员名称', `trainee_name` varchar(100) DEFAULT NULL COMMENT '学员名称',
`scenario_id` varchar(80) DEFAULT NULL COMMENT '场景ID', `scenario_id` varchar(80) DEFAULT NULL COMMENT '场景ID',
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称', `scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
`position_snapshot` varchar(80) DEFAULT NULL COMMENT '训练开始时岗位快照',
`project_type_snapshot` varchar(80) DEFAULT NULL COMMENT '训练开始时项目类型快照',
`growth_level` varchar(20) DEFAULT NULL COMMENT '训练开始时成长层级快照',
`competency_code` varchar(100) DEFAULT NULL COMMENT '训练开始时能力项快照',
`collaboration_positions` varchar(500) DEFAULT NULL COMMENT '训练开始时协同岗位快照',
`curriculum_version` varchar(30) DEFAULT NULL COMMENT '训练开始时成长目录版本快照',
`growth_confirmation_level` varchar(20) DEFAULT NULL COMMENT '主管确认可进入的训练层级',
`growth_confirmed_by` varchar(100) DEFAULT NULL COMMENT '训练层级确认主管',
`growth_confirmed_time` datetime DEFAULT NULL COMMENT '训练层级确认时间',
`mode` varchar(30) DEFAULT 'text' COMMENT '训练模式', `mode` varchar(30) DEFAULT 'text' COMMENT '训练模式',
`total_score` int DEFAULT NULL COMMENT '总分', `total_score` int DEFAULT NULL COMMENT '总分',
`dim_task_completion` int DEFAULT NULL COMMENT '任务完成度分', `dim_task_completion` int DEFAULT NULL COMMENT '任务完成度分',
@@ -181,6 +190,17 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` (
`scenario_type` varchar(50) NOT NULL COMMENT '场景类型', `scenario_type` varchar(50) NOT NULL COMMENT '场景类型',
`project_type` varchar(80) DEFAULT NULL COMMENT '项目类型', `project_type` varchar(80) DEFAULT NULL COMMENT '项目类型',
`difficulty` tinyint DEFAULT 1 COMMENT '难度', `difficulty` tinyint DEFAULT 1 COMMENT '难度',
`growth_level` varchar(20) NOT NULL DEFAULT '初级' COMMENT '训练成长层级:初级/中级/高级',
`competency_code` varchar(100) NOT NULL DEFAULT '待归类' COMMENT '岗位能力项编码;由内容运营归入固定矩阵',
`collaboration_positions` varchar(500) NOT NULL DEFAULT '' COMMENT '协同岗位,使用中文顿号分隔',
`review_status` varchar(20) NOT NULL DEFAULT '草稿' COMMENT '内容状态:草稿/待业务审核/已发布/已下线',
`risk_level` varchar(20) NOT NULL DEFAULT '待评估' COMMENT '内容风险:待评估/常规/高风险',
`reviewer` varchar(100) DEFAULT NULL COMMENT '首位内容审核操作人',
`reviewer_user_id` bigint DEFAULT NULL COMMENT '首位内容审核人稳定用户ID',
`reviewed_time` datetime DEFAULT NULL COMMENT '首位内容审核时间',
`second_reviewer` varchar(100) DEFAULT NULL COMMENT '高风险内容第二位审核操作人',
`second_reviewer_user_id` bigint DEFAULT NULL COMMENT '高风险内容第二位审核人稳定用户ID',
`second_reviewed_time` datetime DEFAULT NULL COMMENT '高风险内容第二位审核时间',
`customer_persona` varchar(500) DEFAULT NULL COMMENT '业主人设', `customer_persona` varchar(500) DEFAULT NULL COMMENT '业主人设',
`opening_text` varchar(500) DEFAULT NULL COMMENT '开场白', `opening_text` varchar(500) DEFAULT NULL COMMENT '开场白',
`explicit_need` varchar(300) DEFAULT NULL COMMENT '显性诉求', `explicit_need` varchar(300) DEFAULT NULL COMMENT '显性诉求',
@@ -191,17 +211,21 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` (
`success_criteria` varchar(500) DEFAULT NULL COMMENT '成功条件', `success_criteria` varchar(500) DEFAULT NULL COMMENT '成功条件',
`strategy` varchar(500) DEFAULT NULL COMMENT '训练策略', `strategy` varchar(500) DEFAULT NULL COMMENT '训练策略',
`goal` varchar(500) DEFAULT NULL COMMENT '训练目标', `goal` varchar(500) DEFAULT NULL COMMENT '训练目标',
`curriculum_version` varchar(30) NOT NULL DEFAULT '住宅五岗-v1' COMMENT '成长目录版本',
`trainee_name` varchar(100) DEFAULT '张三' COMMENT '默认学员', `trainee_name` varchar(100) DEFAULT '张三' COMMENT '默认学员',
`trust_start` int DEFAULT 60 COMMENT '起始信任', `trust_start` int DEFAULT 60 COMMENT '起始信任',
`trust_end` int DEFAULT 80 COMMENT '目标信任', `trust_end` int DEFAULT 80 COMMENT '目标信任',
`rounds_json` text DEFAULT NULL COMMENT '回合seed JSON', `rounds_json` text DEFAULT NULL COMMENT '回合seed JSON',
`content_version` varchar(30) NOT NULL DEFAULT 'v1.0' COMMENT '内容版本',
`content_hash` char(64) DEFAULT NULL COMMENT '内容快照哈希',
`enabled` tinyint DEFAULT 1 COMMENT '是否启用', `enabled` tinyint DEFAULT 1 COMMENT '是否启用',
`sort_order` int DEFAULT 0 COMMENT '排序', `sort_order` int DEFAULT 0 COMMENT '排序',
`create_time` datetime DEFAULT NULL COMMENT '创建时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_scenario` (`tenant_id`, `scenario_code`), UNIQUE KEY `uk_aihr_practice_scenario` (`tenant_id`, `scenario_code`),
KEY `idx_aihr_practice_scenario_filter` (`tenant_id`, `position`, `scenario_type`, `enabled`, `sort_order`) KEY `idx_aihr_practice_scenario_filter` (`tenant_id`, `position`, `scenario_type`, `enabled`, `sort_order`),
KEY `idx_aihr_practice_scenario_curriculum` (`tenant_id`, `position`, `growth_level`, `competency_code`, `review_status`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练场景'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练场景';
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` ( CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` (
@@ -398,6 +422,34 @@ ON DUPLICATE KEY UPDATE
`sort_order` = VALUES(`sort_order`), `sort_order` = VALUES(`sort_order`),
`update_time` = VALUES(`update_time`); `update_time` = VALUES(`update_time`);
-- 此文件仅用于全新开发库初始化;内置种子只是待审核素材,不能从 enabled 推断为正式发布。
-- 新增/编辑内容必须走审核状态流转后才向员工可见。
UPDATE `aihr_practice_scenario`
SET `risk_level` = CASE
WHEN CONCAT_WS('\n', `scenario_name`, `scenario_type`, `customer_persona`, `opening_text`, `explicit_need`, `hidden_need`,
`irritants`, `delights`, `sop_refs`, `success_criteria`, `strategy`, `goal`, `rounds_json`)
REGEXP '投诉|客诉|纠纷|收费|催费|费用|停车费|退款|减免|安全|事故|消防|燃气|漏电|电梯|高空|受伤|人身伤害|舆情' THEN '高风险'
WHEN `risk_level` IS NULL OR `risk_level` = '' OR `risk_level` NOT IN ('待评估', '常规', '高风险') THEN '待评估'
ELSE `risk_level`
END,
`review_status` = CASE
WHEN `review_status` IS NULL OR `review_status` = '' OR `review_status` = '草稿' THEN '待业务审核'
WHEN `review_status` = '已发布' AND (`competency_code` IS NULL OR `competency_code` = '' OR `competency_code` = '待归类'
OR `risk_level` = '待评估'
OR (`risk_level` = '高风险' AND (`reviewer_user_id` IS NULL OR `second_reviewer_user_id` IS NULL))) THEN '待业务审核'
ELSE `review_status`
END,
-- 重跑初始化脚本时不得清空“待业务审核”中的首审记录;高风险场景可能正等待第二人复核。
-- 草稿没有有效审核结论,才允许清除其历史审核字段。
`reviewer` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `reviewer` END,
`reviewer_user_id` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `reviewer_user_id` END,
`reviewed_time` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `reviewed_time` END,
`second_reviewer` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `second_reviewer` END,
`second_reviewer_user_id` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `second_reviewer_user_id` END,
`second_reviewed_time` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `second_reviewed_time` END,
`curriculum_version` = COALESCE(NULLIF(`curriculum_version`, ''), '住宅五岗-v1')
WHERE `tenant_id` = '000000';
INSERT INTO `aihr_practice_rubric` INSERT INTO `aihr_practice_rubric`
(`tenant_id`, `rubric_code`, `scenario_code`, `rubric_name`, `version`, `enabled`, `description`, `create_time`, `update_time`) (`tenant_id`, `rubric_code`, `scenario_code`, `rubric_name`, `version`, `enabled`, `description`, `create_time`, `update_time`)
SELECT SELECT
@@ -0,0 +1,362 @@
-- 阶段一陪练成长目录:场景的岗位/层级/能力项/审核语义及新会话快照。
-- 不创建第二套课程引擎;只扩展 aihr_practice_scenario 与 aihr_practice_session。
-- 可重复执行。历史会话不回填推测出的课程归属,避免改写历史训练事实。
SET @aihr_schema = DATABASE();
SET @growth_level_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'growth_level'
);
SET @growth_level_ddl = IF(@growth_level_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN growth_level varchar(20) NOT NULL DEFAULT ''初级'' COMMENT ''训练成长层级:初级/中级/高级'' AFTER difficulty',
'SELECT 1');
PREPARE aihr_growth_level_stmt FROM @growth_level_ddl;
EXECUTE aihr_growth_level_stmt;
DEALLOCATE PREPARE aihr_growth_level_stmt;
SET @competency_code_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'competency_code'
);
SET @competency_code_ddl = IF(@competency_code_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN competency_code varchar(100) NOT NULL DEFAULT ''待归类'' COMMENT ''岗位能力项编码;由内容运营归入固定矩阵'' AFTER growth_level',
'SELECT 1');
PREPARE aihr_competency_code_stmt FROM @competency_code_ddl;
EXECUTE aihr_competency_code_stmt;
DEALLOCATE PREPARE aihr_competency_code_stmt;
SET @collaboration_positions_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'collaboration_positions'
);
SET @collaboration_positions_ddl = IF(@collaboration_positions_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN collaboration_positions varchar(500) NOT NULL DEFAULT '''' COMMENT ''协同岗位,使用中文顿号分隔'' AFTER competency_code',
'SELECT 1');
PREPARE aihr_collaboration_positions_stmt FROM @collaboration_positions_ddl;
EXECUTE aihr_collaboration_positions_stmt;
DEALLOCATE PREPARE aihr_collaboration_positions_stmt;
SET @review_status_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'review_status'
);
SET @review_status_ddl = IF(@review_status_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN review_status varchar(20) NOT NULL DEFAULT ''草稿'' COMMENT ''内容状态:草稿/待业务审核/已发布/已下线'' AFTER collaboration_positions',
'SELECT 1');
PREPARE aihr_review_status_stmt FROM @review_status_ddl;
EXECUTE aihr_review_status_stmt;
DEALLOCATE PREPARE aihr_review_status_stmt;
SET @risk_level_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'risk_level'
);
SET @risk_level_ddl = IF(@risk_level_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN risk_level varchar(20) NOT NULL DEFAULT ''待评估'' COMMENT ''内容风险:待评估/常规/高风险'' AFTER review_status',
'SELECT 1');
PREPARE aihr_risk_level_stmt FROM @risk_level_ddl;
EXECUTE aihr_risk_level_stmt;
DEALLOCATE PREPARE aihr_risk_level_stmt;
SET @curriculum_version_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'curriculum_version'
);
SET @curriculum_version_ddl = IF(@curriculum_version_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN curriculum_version varchar(30) NOT NULL DEFAULT ''住宅五岗-v1'' COMMENT ''成长目录版本'' AFTER goal',
'SELECT 1');
PREPARE aihr_curriculum_version_stmt FROM @curriculum_version_ddl;
EXECUTE aihr_curriculum_version_stmt;
DEALLOCATE PREPARE aihr_curriculum_version_stmt;
SET @reviewer_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'reviewer'
);
SET @reviewer_ddl = IF(@reviewer_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN reviewer varchar(100) DEFAULT NULL COMMENT ''首位内容审核操作人'' AFTER risk_level',
'SELECT 1');
PREPARE aihr_reviewer_stmt FROM @reviewer_ddl;
EXECUTE aihr_reviewer_stmt;
DEALLOCATE PREPARE aihr_reviewer_stmt;
SET @reviewer_user_id_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'reviewer_user_id'
);
SET @reviewer_user_id_ddl = IF(@reviewer_user_id_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN reviewer_user_id bigint DEFAULT NULL COMMENT ''首位内容审核人稳定用户ID'' AFTER reviewer',
'SELECT 1');
PREPARE aihr_reviewer_user_id_stmt FROM @reviewer_user_id_ddl;
EXECUTE aihr_reviewer_user_id_stmt;
DEALLOCATE PREPARE aihr_reviewer_user_id_stmt;
SET @reviewed_time_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'reviewed_time'
);
SET @reviewed_time_ddl = IF(@reviewed_time_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT ''首位内容审核时间'' AFTER reviewer_user_id',
'SELECT 1');
PREPARE aihr_reviewed_time_stmt FROM @reviewed_time_ddl;
EXECUTE aihr_reviewed_time_stmt;
DEALLOCATE PREPARE aihr_reviewed_time_stmt;
SET @second_reviewer_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'second_reviewer'
);
SET @second_reviewer_ddl = IF(@second_reviewer_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN second_reviewer varchar(100) DEFAULT NULL COMMENT ''高风险内容第二位审核操作人'' AFTER reviewed_time',
'SELECT 1');
PREPARE aihr_second_reviewer_stmt FROM @second_reviewer_ddl;
EXECUTE aihr_second_reviewer_stmt;
DEALLOCATE PREPARE aihr_second_reviewer_stmt;
SET @second_reviewer_user_id_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'second_reviewer_user_id'
);
SET @second_reviewer_user_id_ddl = IF(@second_reviewer_user_id_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN second_reviewer_user_id bigint DEFAULT NULL COMMENT ''高风险内容第二位审核人稳定用户ID'' AFTER second_reviewer',
'SELECT 1');
PREPARE aihr_second_reviewer_user_id_stmt FROM @second_reviewer_user_id_ddl;
EXECUTE aihr_second_reviewer_user_id_stmt;
DEALLOCATE PREPARE aihr_second_reviewer_user_id_stmt;
SET @second_reviewed_time_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'second_reviewed_time'
);
SET @second_reviewed_time_ddl = IF(@second_reviewed_time_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN second_reviewed_time datetime DEFAULT NULL COMMENT ''高风险内容第二位审核时间'' AFTER second_reviewer_user_id',
'SELECT 1');
PREPARE aihr_second_reviewed_time_stmt FROM @second_reviewed_time_ddl;
EXECUTE aihr_second_reviewed_time_stmt;
DEALLOCATE PREPARE aihr_second_reviewed_time_stmt;
-- 既有场景必须重新进入业务审核,不能从旧的 enabled 状态推断“已发布”。
-- 同时收口曾由早期脚本自动标记为已发布、但仍未归类能力项的场景。
-- 注意:这份 migration 可以重复执行。不得清空已完成首审、正在等待第二人复核的待审记录。
-- 对于本次新增稳定审核人 ID 前已发布的旧内容,一律退回待业务审核,重新建立可审计链路。
UPDATE aihr_practice_scenario
SET growth_level = CASE
WHEN @growth_level_exists = 0 OR growth_level IS NULL OR growth_level = '' THEN CASE
WHEN difficulty >= 3 THEN '高级'
WHEN difficulty = 2 THEN '中级'
ELSE '初级'
END
ELSE growth_level
END,
competency_code = CASE WHEN competency_code IS NULL OR competency_code = '' THEN '待归类' ELSE competency_code END,
collaboration_positions = COALESCE(collaboration_positions, ''),
risk_level = CASE
-- 旧表刚新增风险字段时,没有可信的既有分类,必须等待运营重新评估。
WHEN @risk_level_exists = 0 THEN '待评估'
-- 兼容早期紧凑表:只依赖其已存在的编码和目标字段;完整内容由服务端保存时逐字段复核。
WHEN CONCAT_WS('\n', scenario_code, goal)
REGEXP '投诉|客诉|纠纷|收费|催费|费用|停车费|退款|减免|安全|事故|消防|燃气|漏电|电梯|高空|受伤|人身伤害|舆情' THEN '高风险'
WHEN risk_level IS NULL OR risk_level = '' OR risk_level NOT IN ('待评估', '常规', '高风险') THEN '待评估'
ELSE risk_level
END,
review_status = CASE
WHEN @review_status_exists = 0 THEN '待业务审核'
WHEN review_status IS NULL OR review_status = '' THEN '待业务审核'
WHEN @risk_level_exists = 0 AND review_status = '已发布' THEN '待业务审核'
WHEN @reviewer_user_id_exists = 0 AND review_status = '已发布' THEN '待业务审核'
WHEN review_status = '已发布' AND (competency_code IS NULL OR competency_code = '' OR competency_code = '待归类') THEN '待业务审核'
WHEN review_status = '已发布' AND risk_level = '待评估' THEN '待业务审核'
WHEN review_status = '已发布' AND risk_level = '高风险'
AND (reviewer_user_id IS NULL OR second_reviewer_user_id IS NULL) THEN '待业务审核'
ELSE review_status
END,
curriculum_version = CASE
WHEN @curriculum_version_exists = 0 OR curriculum_version IS NULL OR curriculum_version = '' THEN '住宅五岗-v1'
ELSE curriculum_version
END;
SET @position_snapshot_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'position_snapshot'
);
SET @position_snapshot_ddl = IF(@position_snapshot_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN position_snapshot varchar(80) DEFAULT NULL COMMENT ''训练开始时岗位快照'' AFTER scenario_name',
'SELECT 1');
PREPARE aihr_position_snapshot_stmt FROM @position_snapshot_ddl;
EXECUTE aihr_position_snapshot_stmt;
DEALLOCATE PREPARE aihr_position_snapshot_stmt;
SET @project_type_snapshot_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'project_type_snapshot'
);
SET @project_type_snapshot_ddl = IF(@project_type_snapshot_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN project_type_snapshot varchar(80) DEFAULT NULL COMMENT ''训练开始时项目类型快照'' AFTER position_snapshot',
'SELECT 1');
PREPARE aihr_project_type_snapshot_stmt FROM @project_type_snapshot_ddl;
EXECUTE aihr_project_type_snapshot_stmt;
DEALLOCATE PREPARE aihr_project_type_snapshot_stmt;
SET @session_growth_level_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'growth_level'
);
SET @session_growth_level_ddl = IF(@session_growth_level_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN growth_level varchar(20) DEFAULT NULL COMMENT ''训练开始时成长层级快照'' AFTER project_type_snapshot',
'SELECT 1');
PREPARE aihr_session_growth_level_stmt FROM @session_growth_level_ddl;
EXECUTE aihr_session_growth_level_stmt;
DEALLOCATE PREPARE aihr_session_growth_level_stmt;
SET @session_competency_code_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'competency_code'
);
SET @session_competency_code_ddl = IF(@session_competency_code_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN competency_code varchar(100) DEFAULT NULL COMMENT ''训练开始时能力项快照'' AFTER growth_level',
'SELECT 1');
PREPARE aihr_session_competency_code_stmt FROM @session_competency_code_ddl;
EXECUTE aihr_session_competency_code_stmt;
DEALLOCATE PREPARE aihr_session_competency_code_stmt;
SET @session_collaboration_positions_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'collaboration_positions'
);
SET @session_collaboration_positions_ddl = IF(@session_collaboration_positions_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN collaboration_positions varchar(500) DEFAULT NULL COMMENT ''训练开始时协同岗位快照'' AFTER competency_code',
'SELECT 1');
PREPARE aihr_session_collaboration_positions_stmt FROM @session_collaboration_positions_ddl;
EXECUTE aihr_session_collaboration_positions_stmt;
DEALLOCATE PREPARE aihr_session_collaboration_positions_stmt;
SET @session_curriculum_version_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'curriculum_version'
);
SET @session_curriculum_version_ddl = IF(@session_curriculum_version_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN curriculum_version varchar(30) DEFAULT NULL COMMENT ''训练开始时成长目录版本快照'' AFTER collaboration_positions',
'SELECT 1');
PREPARE aihr_session_curriculum_version_stmt FROM @session_curriculum_version_ddl;
EXECUTE aihr_session_curriculum_version_stmt;
DEALLOCATE PREPARE aihr_session_curriculum_version_stmt;
-- 主管只可在复盘时确认“可进入下一训练层级”。确认附着于原训练会话,
-- 不改变人事职级、薪酬、上岗资格或历史训练快照。
SET @growth_confirmation_level_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'growth_confirmation_level'
);
SET @growth_confirmation_level_ddl = IF(@growth_confirmation_level_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN growth_confirmation_level varchar(20) DEFAULT NULL COMMENT ''主管确认可进入的训练层级'' AFTER curriculum_version',
'SELECT 1');
PREPARE aihr_growth_confirmation_level_stmt FROM @growth_confirmation_level_ddl;
EXECUTE aihr_growth_confirmation_level_stmt;
DEALLOCATE PREPARE aihr_growth_confirmation_level_stmt;
SET @growth_confirmed_by_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'growth_confirmed_by'
);
SET @growth_confirmed_by_ddl = IF(@growth_confirmed_by_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN growth_confirmed_by varchar(100) DEFAULT NULL COMMENT ''训练层级确认主管'' AFTER growth_confirmation_level',
'SELECT 1');
PREPARE aihr_growth_confirmed_by_stmt FROM @growth_confirmed_by_ddl;
EXECUTE aihr_growth_confirmed_by_stmt;
DEALLOCATE PREPARE aihr_growth_confirmed_by_stmt;
SET @growth_confirmed_time_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'growth_confirmed_time'
);
SET @growth_confirmed_time_ddl = IF(@growth_confirmed_time_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN growth_confirmed_time datetime DEFAULT NULL COMMENT ''训练层级确认时间'' AFTER growth_confirmed_by',
'SELECT 1');
PREPARE aihr_growth_confirmed_time_stmt FROM @growth_confirmed_time_ddl;
EXECUTE aihr_growth_confirmed_time_stmt;
DEALLOCATE PREPARE aihr_growth_confirmed_time_stmt;
SET @scenario_curriculum_index_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario'
AND index_name = 'idx_aihr_practice_scenario_curriculum'
);
SET @scenario_curriculum_index_ddl = IF(@scenario_curriculum_index_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD KEY idx_aihr_practice_scenario_curriculum (tenant_id, position, growth_level, competency_code, review_status, enabled)',
'SELECT 1');
PREPARE aihr_scenario_curriculum_index_stmt FROM @scenario_curriculum_index_ddl;
EXECUTE aihr_scenario_curriculum_index_stmt;
DEALLOCATE PREPARE aihr_scenario_curriculum_index_stmt;
-- 派发请求的重放依赖稳定唯一键;最近内容过滤依赖其复合索引。
-- 这两项由正式迁移维护,运行环境不能依赖服务启动时补 DDL。
SET @assignment_request_index_count = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
AND index_name = 'uk_aihr_assignment_request'
);
SET @assignment_request_index_non_unique = (
SELECT COALESCE(MIN(non_unique), 1) FROM information_schema.statistics
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
AND index_name = 'uk_aihr_assignment_request'
);
SET @assignment_request_index_columns = (
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
AND index_name = 'uk_aihr_assignment_request'
);
SET @assignment_request_index_prefixes = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
AND index_name = 'uk_aihr_assignment_request' AND sub_part IS NOT NULL
);
SET @assignment_request_index_ddl = IF(
@assignment_request_index_count = 3
AND @assignment_request_index_non_unique = 0
AND @assignment_request_index_columns = 'tenant_id,request_key,ext_party_id'
AND @assignment_request_index_prefixes = 0,
'SELECT 1',
IF(@assignment_request_index_count = 0,
'ALTER TABLE aihr_practice_assignment ADD UNIQUE KEY uk_aihr_assignment_request (tenant_id, request_key, ext_party_id)',
'ALTER TABLE aihr_practice_assignment DROP INDEX uk_aihr_assignment_request, ADD UNIQUE KEY uk_aihr_assignment_request (tenant_id, request_key, ext_party_id)'
)
);
PREPARE aihr_assignment_request_index_stmt FROM @assignment_request_index_ddl;
EXECUTE aihr_assignment_request_index_stmt;
DEALLOCATE PREPARE aihr_assignment_request_index_stmt;
SET @assignment_recent_index_count = (
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 @assignment_recent_index_non_unique = (
SELECT COALESCE(MIN(non_unique), 1) FROM information_schema.statistics
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
AND index_name = 'idx_aihr_assignment_recent_content'
);
SET @assignment_recent_index_columns = (
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
AND index_name = 'idx_aihr_assignment_recent_content'
);
SET @assignment_recent_index_prefixes = (
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' AND sub_part IS NOT NULL
);
SET @assignment_recent_index_ddl = IF(
@assignment_recent_index_count = 5
AND @assignment_recent_index_non_unique = 1
AND @assignment_recent_index_columns = 'tenant_id,ext_party_id,source,content_hash,create_time'
AND @assignment_recent_index_prefixes = 0,
'SELECT 1',
IF(@assignment_recent_index_count = 0,
'ALTER TABLE aihr_practice_assignment ADD KEY idx_aihr_assignment_recent_content (tenant_id, ext_party_id, source, content_hash, create_time)',
'ALTER TABLE aihr_practice_assignment DROP INDEX idx_aihr_assignment_recent_content, ADD KEY idx_aihr_assignment_recent_content (tenant_id, ext_party_id, source, content_hash, create_time)'
)
);
PREPARE aihr_assignment_recent_index_stmt FROM @assignment_recent_index_ddl;
EXECUTE aihr_assignment_recent_index_stmt;
DEALLOCATE PREPARE aihr_assignment_recent_index_stmt;
@@ -10,7 +10,7 @@
> v1.6 更新(2026-07-19):“记一下”升级为确认式统一采集。系统先建议服务线索、住户画像、优秀案例、个人想法、待跟进事项或工作记录,用户选择仅自己保存或提交公司处理;位置字段缺失不阻断保存,公司处理在正式接口接入前只标记待流转。 > v1.6 更新(2026-07-19):“记一下”升级为确认式统一采集。系统先建议服务线索、住户画像、优秀案例、个人想法、待跟进事项或工作记录,用户选择仅自己保存或提交公司处理;位置字段缺失不阻断保存,公司处理在正式接口接入前只标记待流转。
> v1.7 收口(2026-07-21):日常工作采集统一归“工作助手”,按自然日汇总为“今日工作成果”;现有 CASE/VIDEO/SOP/KNOWLEDGE 审核链产品语义收敛为“成果投稿”,当前页面和 API 名称仍保留“工作上报”。项目工作记录后续必须从项目名称下拉列表确认当前项目,不要求用户输入项目编码。施工顺序见[专项迭代计划](../工作助手与今日工作成果迭代计划-20260721.md)。 > v1.7 收口(2026-07-21):日常工作采集统一归“工作助手”,按自然日汇总为“今日工作成果”;现有 CASE/VIDEO/SOP/KNOWLEDGE 审核链产品语义收敛为“成果投稿”,当前页面和 API 名称仍保留“工作上报”。项目工作记录后续必须从项目名称下拉列表确认当前项目,不要求用户输入项目编码。施工顺序见[专项迭代计划](../工作助手与今日工作成果迭代计划-20260721.md)。
> v1.8 更新(2026-07-24):“问”从单一 RAG 问答升级为数字师傅 Agent 入口。服务端先识别知识、实时工作、训练、确认式记录、媒体、全网、澄清或闲聊意图,再按固定权限策略调用既有领域服务;SOP 检索是受控工具之一,不再是所有消息的默认去向。完整边界见[专项设计](../superpowers/specs/2026-07-24-digital-master-agent-design.md)。 > v1.8 更新(2026-07-24):“问”从单一 RAG 问答升级为数字师傅 Agent 入口。服务端先识别知识、实时工作、训练、确认式记录、媒体、全网、澄清或闲聊意图,再按固定权限策略调用既有领域服务;SOP 检索是受控工具之一,不再是所有消息的默认去向。完整边界见[专项设计](../superpowers/specs/2026-07-24-digital-master-agent-design.md)。
> 与《银城员工端APP分阶段实施总纲》《AI陪练二期开发推进计划》的关系:本方案是增量修订,不推翻既有阶段划分;§4.2 回写清单已于 2026-07-08 执行——总纲升 v0.3、二期计划升 v0.8。BRD 已升至 v1.4,同步个人助理与工作上报交互定案;BRD 4.4 师徒演练等旧口径仍待后续版本继续修订。 > 与《银城员工端APP分阶段实施总纲》《AI陪练二期开发推进计划》的关系:本方案是增量修订,不推翻既有阶段划分;§4.2 回写清单已于 2026-07-08 执行——总纲升 v0.3、二期计划升 v0.8。2026-07-24 起,阶段一后续施工范围统一以[《AI陪练完整交付计划》](../AI陪练完整交付计划-20260724.md)为准,旧二期计划仅保留工程追溯。BRD 已升至 v1.4,同步个人助理与工作上报交互定案;BRD 4.4 师徒演练等旧口径仍待后续版本继续修订。
--- ---
+1
View File
@@ -1,6 +1,7 @@
# AI 陪练二期开发推进计划 # AI 陪练二期开发推进计划
> 版本:v0.10 | 日期:2026-07-11 > 版本:v0.10 | 日期:2026-07-11
> 状态同步(2026-07-24):本文件保留 M0-M5 的历史推进和工程追溯;阶段一后续施工、范围控制与结项标准统一以[《AI陪练完整交付计划》](AI陪练完整交付计划-20260724.md)为准。
> 输入源:《AI陪练功能优化与缺口分析(修订版 · 2026-07-06)》+《物业行业 AI 人力资源系统 · BRD v1.2》。 > 输入源:《AI陪练功能优化与缺口分析(修订版 · 2026-07-06)》+《物业行业 AI 人力资源系统 · BRD v1.2》。
> v0.2 修订:与两份输入源逐项复核后补齐 6 处缺漏——#6/#9/#16/师徒演练的明确处置(§2)、#13 定向再练的里程碑落点(M4)、画像雷达图与 BRD L1「留」段验收承载(M4)、TechSpec P1 表映射与岗位身份前置(M1)、场景管理权限口径(M1/§7)、G6 成本上限量化(§7)。 > v0.2 修订:与两份输入源逐项复核后补齐 6 处缺漏——#6/#9/#16/师徒演练的明确处置(§2)、#13 定向再练的里程碑落点(M4)、画像雷达图与 BRD L1「留」段验收承载(M4)、TechSpec P1 表映射与岗位身份前置(M1)、场景管理权限口径(M1/§7)、G6 成本上限量化(§7)。
> v0.3 修订:补齐管理者“传帮带”角色和初/中/高级生活顾问成长路径;训练数据只作晋升评级证据包,不让 AI 自动决定晋升。 > v0.3 修订:补齐管理者“传帮带”角色和初/中/高级生活顾问成长路径;训练数据只作晋升评级证据包,不让 AI 自动决定晋升。
+297
View File
@@ -0,0 +1,297 @@
# AI陪练完整交付计划(固定范围版)
> 版本:v1.0 | 日期:2026-07-24 | 状态:工程实施中;内容校审与真实试点未完成
>
> 本文是总纲“阶段一:陪练”的唯一后续施工范围。它不新开一个“第 N 期”,也不因每次发现差异而继续拆分产品期数;后续工作只是在本计划的固定范围内完成、验收或明确取消。
## 1. 结论先行
### 1.1 当前版本处在什么位置
当前已上线的“练”不是零散 Demo,而是**五岗位基础训练闭环**:场景、评分标准、每日/专项/再练任务、2-6 回合对练、训练总结、训练历史、主管复盘和任务运营均已有主链路。
它仍不是“练模块完整交付”,原因不是缺少更多页面,而是缺少一套被业务确认的成长语义:
- 场景有 `position`、`project_type` 和 `difficulty`,但没有正式的“住宅物业 + 岗位 + 初/中/高级 + 能力项”目录;
- 每日/专项题类型与 `daily/camp/retry/manual` 任务来源都已存在,不能被误当作能力等级;
- 员工尚不能清楚知道自己处于哪个成长层、下一步该练什么、为什么被解锁或暂未解锁;
- 现有场景需要完成业务校审和矩阵归类,不能以“库里有多少条”替代内容可用。
因此,本计划的完成目标是:
> **把“练”收口为住宅物业五岗位、三级成长、可运营、可复盘、可证明的训练体系;生活顾问是首个深度试点岗位,不是唯一岗位。**
### 1.2 阶段口径一次定清
| 名称 | 含义 | 是否再拆成产品期 |
|---|---|---|
| 系统阶段一“陪练” | 银城员工端三阶段总纲中的第一个业务阶段 | 否。本文就是其完整收口范围。 |
| 当前五岗位基础闭环 | 已实现的工程与运营底座 | 不是新一期,是本计划的起点。 |
| 三级成长 | 同一陪练阶段内的内容组织和员工进阶规则 | 不是三个发布期,三层共同构成一个目标。 |
| 执行序列 | 为降低风险而安排的开发、内容、试点顺序 | 只是施工顺序,不产生新的产品边界。 |
除非业务明确批准“新增行业”或“新增岗位”,否则后续不再另列“陪练三期、四期……”。未完成项只按本文的交付清单标记为“未开始 / 实施中 / 已验收 / 取消”。
## 2. 固定范围:一个三维矩阵
| 维度 | 本计划固定值 | 处理原则 |
|---|---|---|
| 行业/业态 | **住宅物业** | 先跑通住宅场景,不纳入写字楼、园区、医院、学校等新业态。 |
| 岗位 | **生活顾问、保安、保洁、保修(工程)、客服** | 沿用系统现有岗位值和别名归一,不迁移历史 `position`。 |
| 成长层级 | **初级、中级、高级** | 代表训练复杂度和可练内容,不等于自动职级晋升。 |
| 能力项 | 每个场景归属一个岗位能力项 | 能力项是任务分配、进度展示和复盘聚合的共同语言。 |
### 2.1 “生活顾问做深”的正确含义
生活顾问是第一条深度主线:优先完成内容校审、三级成长规则、种子用户试点和指标验证。其余四岗不是只练“与生活顾问配合”这一件事;各岗位都必须保留与自身安全、服务和专业职责相关的核心训练,再补跨岗位协同场景。
例如,保修(工程)不能只练“接到生活顾问派单后如何说话”,还必须有安全确认、报修诊断、上门沟通和完工交接等经业务批准的专业场景。这样既避免首期铺得过大,也不会把岗位训练做成表面覆盖。
### 2.2 固定能力目录
下表定义固定交付范围必须覆盖的能力域。它是内容校审清单,不要求为了凑数新造场景;先将现有场景归位,只补缺失单元。
| 岗位 | 初级:能上岗 | 中级:能独立处理 | 高级:能处理复杂协同 | 首批深度优先级 |
|---|---|---|---|---|
| 生活顾问 | 接待与记录、日常巡检、报事报修受理、基础服务规范 | 投诉/催费/停车费沟通、跨部门跟进、回访闭环 | 重大投诉服务恢复、复杂协同、群体沟通与风险预判 | 最高 |
| 保安 | 门岗礼仪、访客/车辆、巡查和异常上报 | 纠纷初处、夜间/高峰处置、与管家协同 | 突发事件协同、现场秩序组织、复盘交接 | 第二梯队 |
| 保洁 | 作业标准、工具安全、现场反馈 | 异常卫生/投诉处理、时间与区域协调 | 重大活动保障、跨工种协同、品质复盘 | 第二梯队 |
| 保修(工程) | 安全确认、报修受理、上门服务与完工说明 | 故障初判、延期沟通、跨部门转办 | 高风险故障协同、服务恢复、复杂工单闭环 | 第二梯队 |
| 客服 | 咨询受理、信息记录、基础话术 | 投诉分级、情绪安抚、跟进协调 | 复杂客诉、群体沟通、服务升级与复盘 | 第二梯队 |
每个能力域至少要有:一条经审批的对练场景、一套可追溯 SOP/制度依据、一份对应 Rubric,以及一个明确的“不应这样做”的红线。生活顾问优先完成所有三级能力域;其余四岗先完成初级全部能力域和高频跨岗位协同,再补中/高级内容。
## 3. 当前实现基线与明确缺口
以下结论来自当前代码、迁移脚本和已发布的五岗位内容运营闭环;它说明“从哪里继续”,不把源码存在等同于业务验收完成。
| 能力 | 当前基线 | 本计划的收口动作 |
|---|---|---|
| 场景库 | 已有岗位、场景类型、项目类型、难度、人设、SOP、回合内容、启停和内容版本。 | 用既有 `project_type` 固定住宅范围;补成长层、能力项、协同岗位、内容审核状态,并完成现有内容归类。 |
| 对练 | 已支持 2-6 回合、文字/语音、LLM 失败时 seed 回退、训练前预习和训练中求助。 | 在生成上下文中带入住宅项目类型/岗位/层级/能力项;保证 seed 回退拥有同一语义。 |
| 评分 | 已有 Rubric、五维评分和会话评分规则快照。 | Rubric 与能力项、成长层绑定;评分只决定训练建议/解锁资格,不自动决定职级。 |
| 任务 | 已有 `daily`、`camp`、`retry`、`manual` 来源和主管批量派发。 | 按当前成长层和短板选题;保留来源含义,绝不拿来源代替等级。 |
| 复盘 | 已有训练总结、逐句标注、主管查看与复盘后再派专项;本机增量已可把主管的可选“下一训练层级确认”附着在本次复盘会话上。 | 复盘页显示能力项、所属层级、下一步推荐;主管可人工确认“可进入下一层”。 |
| 员工视图 | 已有今日训练、训练历史、能力画像和训练反馈。 | 增加“当前层级、已完成能力项、下一解锁条件、推荐训练”四项,不新建第二个学习入口。 |
| 管理运营 | 已有训练内容、题库、Rubric、任务管理页和权限控制。 | 在现有页面加矩阵筛选、审核状态、覆盖缺口和内容版本追踪,不另建平行后台。 |
| 试点证明 | 已有按日期导出、唯一在职组织身份和每人至少 10 次完训的严格统计口径。 | 用同一口径完成真实住宅项目试点,不以 seed、测试账号或截图替代。 |
### 3.1 代码与数据复用边界
本计划不新建第二套培训引擎。继续复用:
- `aihr_practice_scenario`:唯一的场景内容载体;
- `aihr_practice_rubric` / `aihr_practice_rubric_dimension`:唯一的评分标准载体;
- `aihr_practice_assignment`:唯一的每日、专项、再练、人工任务载体;
- `aihr_practice_session`:唯一的训练与历史快照载体;
- 现有 `/api/train/practice/**`、管理端训练运营页面和员工/主管端页面。
首轮只在既有 `scenario` 与 `session` 上增加必要元数据和历史快照:`growth_level`、`competency_code`、`collaboration_positions`、`review_status`、`risk_level`、首/第二审核人的稳定用户 ID、显示名及时间、`curriculum_version`。主管确认只保存到支撑该判断的原训练会话:`growth_confirmation_level`、`growth_confirmed_by`、`growth_confirmed_time`,不新建第二套成长引擎,也不改写原训练层级。住宅物业是本计划固定范围,直接复用既有 `project_type`,不新加一个永远只有单值的 `industry` 字段。任务从场景继承这些信息,不复制出另一张课程或训练营表。
## 4. 目标功能安排
### 4.1 内容运营:先让内容能被管理和证明
1. 在现有“训练内容”页为场景增加以下字段和筛选:既有住宅 `project_type`、岗位、初/中/高级、能力项、协同岗位、审核状态、风险级别、版本。
2. 审核状态固定为 `草稿 → 待业务审核 → 已发布 → 已下线`。员工只可看到“已发布且启用”的场景,且不可读取审核人身份和审核时间;管理员停用不是删除历史。运营可标注“常规”或“高风险”,但收费、安全、投诉等受控词及所有训练回合会由服务端自动升级为高风险,待评估内容不可发布;高风险场景的第一位审核人只会留下带稳定用户 ID 的待复核记录,必须由第二位不同的已认证运营人员复核同一内容版本才会发布。
3. 每条场景保存:业务来源/SOP 引用、目标、成功条件、红线、业主人设、回合数、Rubric、风险级别、首/第二审核人及时间和版本。
4. 内容修改后只影响新会话;既有 `session` 保存当时的课程/评分快照,避免历史成绩被新规则改写。
5. 后台提供“矩阵缺口”视图:按住宅物业 × 岗位 × 层级 × 能力项列出已发布、待审核、缺失,成为内容补齐的唯一清单。
### 4.2 员工端:从“有训练”变成“知道为什么练”
员工仍从底栏“练”进入,不增加“成长学院”等第二入口。页面由上到下固定为:
1. **我的成长位置**:当前岗位、当前层级、已覆盖能力项和本层待完成项;
2. **今天该练什么**:优先显示已派发专项、到期再练、当前层级的每日训练;
3. **场景训练**:按当前可练内容展示;未解锁的中/高级场景显示原因和所需条件,不做假进度;
4. **训练后总结**:沿用五维评分、证据句、红线、可说话术和下一步建议;
5. **我的历史**:按能力项与层级看趋势,且可回看主管已给出的复盘建议。
员工能够跳过或重练已开放的内容;“锁定”只用于业务确认需要前置训练的复杂场景,绝不能拦住紧急 SOP 查询或实际工作求助。
### 4.3 任务分配:来源与成长层各司其职
| 任务来源 | 产生规则 | 与成长层的关系 |
|---|---|---|
| 每日训练 `daily` | 从当前层级尚未覆盖或需巩固的能力项中选取。 | 是日常练习,不等于“初级”。 |
| 专项训练 `camp` / 人工 `manual` | 主管根据真实问题、复盘或业务活动派发。 | 可跨层派发,但需说明原因并留痕。 |
| 定向再练 `retry` | 同一能力项持续低分、触发红线或主管要求后生成。 | 用于巩固,不把一次低分直接降级。 |
| 自主训练 | 员工从已开放场景选择。 | 计入能力覆盖和成长证据。 |
成长规则采用一套**固定、可审计的规则版本**,不建设复杂的可视化规则引擎:
- 初级:完成岗位基础能力项的训练与业务确认;
- 中级:满足规定的初级能力覆盖、最低有效评分和必要复盘;
- 高级:满足规定的中级能力覆盖、复杂协同场景和主管复盘证据;
- 阈值由业务负责人和 HR 在开发前一次确认,写入版本化规则;规则调整只影响之后的训练推荐,不反写已完成历史;
- 主管的人工确认仅可基于其有权限查看的已完成会话,且只能从会话快照的初级确认至中级、或从中级确认至高级;它是训练证据,不能绕过尚待业务确认的正式解锁阈值;
- 系统给出“达到训练层级条件”的提示,**绝不自动调整员工职级、薪酬、晋升或资格结论**。
### 4.4 主管与运营端:把带教做成闭环,而非定向师徒
1. 主管查看员工时,先看到岗位、当前层级、能力缺口、连续低分/逾期风险和待复盘会话;
2. 进入训练详情后,沿用音频、逐句标注、五维评分和复盘建议;
3. 主管可一次完成“写建议 → 选能力项/场景 → 派下一次专项 → 标记已复盘”,并可选确认“可进入下一训练层级”;服务端按已绑定的主管范围与本次会话原始层级校验,只记录会话证据,不生成职级、薪酬或资格结论;
4. 运营端可按岗位/层级/能力项查看覆盖率、完训率、低分聚集和内容未命中;
5. 不做员工与员工之间的强制“师带徒”关系。员工经验共创仍由“问题榜/案例”承接,主管带教由训练复盘和任务派发承接。
### 4.5 AI 与知识边界
- LLM 提示词必须携带经过服务端校验的住宅 `project_type`、岗位、层级、场景、SOP 和 Rubric,不相信客户端自报的岗位或等级;
- 输出继续保持结构化:角色回复、情绪/信任、红线/纠偏、证据、总结与下一步训练建议;
- 模型不可用时,seed 回退也必须按同一场景元数据运行,不能退回跨岗位通用话术;
- “问”模块可在训练中提供已授权的 SOP 提示,未命中问题进入知识缺口,但不将问答回答伪装成员工真实待办;
- 案例/问题榜被采纳后可作为成长证据输入,是否计入学分或晋升资格仍遵循既有人工审核与规则,不由模型直接判定。
## 5. 固定实施序列
以下是完成本计划的施工顺序,不是新的产品分期。每一项的退出条件满足后才进入下一项;任何未通过项仍留在同一计划内处理。
### A. 冻结目录和业务规则
**工作**
- 确认住宅物业、五岗位、三级成长和上表能力域为唯一范围;
- 为每一能力域指定业务内容负责人和审核人;
- 将存量场景逐条归入矩阵,标记“可发布 / 待补资料 / 不适用 / 下线”;
- 一次确认初/中/高级的解锁阈值、是否需要主管复盘、试点项目与种子人员。
**退出条件**:矩阵没有未归属的已发布场景;每个待补单元有明确内容责任人;不再讨论新增行业/岗位作为本轮插单。
### B. 补齐最小数据语义与兼容迁移
**工作**
- 扩展现有场景、会话 DTO/SQL/服务端校验和筛选;
- 写可重复执行的迁移:不重写历史 `project_type`;成长层和能力项只能由内容运营确认后发布;
- 启动新会话时复制课程语义与版本到会话快照;
- 保持现有 `daily/camp/retry/manual`、场景编码、Rubric、已完成会话和训练历史可用。
**退出条件**:旧接口兼容;新字段可查、可筛、可审计;历史训练不因内容更新改变所属版本。
### C. 在既有页面完成运营与员工体验
**工作**
- 管理端训练内容、Rubric、任务管理页增加矩阵筛选、审核状态和覆盖缺口;
- 员工“练”页增加当前层级、能力进度、下一步训练和可解释锁定;
- 主管复盘与派发页展示层级/能力项/推荐再练;
- 完成手机 H5 常用尺寸的布局、可访问名称、登录/空态/错误态与文字对齐检查。
**退出条件**:员工、主管、运营三种身份都能从同一条真实数据看到一致的岗位/层级/能力信息,并完成一轮“发布内容 → 派发 → 训练 → 复盘 → 再练”。
### D. 内容补齐与质量校审
**工作**
- 先完整发布生活顾问三级能力域;
- 再完成四岗位初级能力域与高频协同,再补中/高级;
- 每条内容按“来源资料 → 场景卡 → 业务审核 → 模型回归 → 发布”流转;
- 对红线、高风险话术、收费/安全/投诉内容执行双人审核;
- 用真实但脱敏的典型对话做回归集,验证不同模型与 seed 回退。
**退出条件**:矩阵中每个承诺的能力域均有已发布、可运行、可追溯的内容;不存在仅靠 LLM 临时生成且未审核的正式训练场景。
### E. 真实试点、复盘和结项
**工作**
- 在同一住宅项目/组织窗口内,用真实在职身份开展试点;
- 跟踪完训、评分校准、主管复盘、内容反馈、知识缺口与异常中断;
- 用现有显式起止日期导出统计,复核唯一组织身份和每人至少 10 次已完成训练;
- 召开一次内容与产品复盘,只允许作“规则/内容修订”,不把未证实需求扩进范围。
**退出条件**:达到下文“完成定义”的工程、内容、试点三类门槛,并形成结项报告与继续/暂停/扩行业的正式决策。
## 6. 完成定义与验收证据
“计划完成”必须同时满足以下三层;任一层未满足,都只能称已实现或已部署,不能称整个练模块已完成。
| 层级 | 必须通过的证据 |
|---|---|
| 工程完成 | 数据迁移可重复执行;后端单元/集成测试通过;管理员、员工、主管真实接口贯通;H5/admin 构建通过;受影响页面实际操作并截图检查。 |
| 发布完成 | 静态管理端、移动 H5、后端 AIHR 模块和远端 schema 同时通过完整 `release-preflight`;生产健康检查通过;不把单纯 H5 发布写成完整发布。 |
| 业务试点完成 | 在同一明确起止日内,使用真实在职组织身份;每人至少 10 次已完成训练;有业务内容审核、主管复盘样本、满意度/有效性结论和问题清单。测试账号、seed 会话、开发固定验证码流程都不能充当此证据。 |
试点结项还应回答四个业务问题:
1. 生活顾问是否能用训练解决真实高频服务场景,而非只完成题目?
2. 主管是否能根据复盘和任务进行下一次带教?
3. 五岗位内容是否各自符合岗位职责和安全边界?
4. 知识缺口、低分能力项和业务反馈是否能推动下一次内容修订?
## 7. 明确不在本计划内
- 新行业:写字楼/园区、医院、学校、城市服务等;
- 新岗位:项目经理及第六个以后的岗位;
- 自动晋升、自动绩效、薪酬/资格自动决策;
- 员工间强制定向师徒关系;
- 实时主管旁听、全量录像、完整离线对练;
- 视觉/视频情境作为正式训练主链路,或方言能力的正式验收;
- 学模块课程/考试、问题榜、案例运营、北森对接的独立建设。
这些内容并非否定,而是要在本计划完成并经真实试点证明后,由新的、显式批准的需求进入阶段二或阶段三。不得以“顺手补一下”为由混入当前陪练收口。
## 8. 变更控制
本计划冻结后,任何新增请求先按下列规则处理:
| 请求类型 | 处理方式 |
|---|---|
| 能使住宅五岗位三级矩阵某一单元可验收 | 允许进入对应工作项,并更新矩阵状态。 |
| 改善已存在链路的缺陷、性能、可访问性或安全 | 允许作为缺陷修复,不改变范围。 |
| 新岗位、新行业、新业务模块、自动人事决策 | 记录为后续候选项,不插入当前计划。 |
| 仅有演示需要、没有业务审核素材 | 只可做受控 Demo,不能标为正式已发布内容。 |
## 9. 相关事实源与实现入口
- 总阶段边界:[银城员工端APP分阶段实施总纲](银城员工端APP分阶段实施总纲.md)
- 历史实施记录(保留追溯,不再作为后续范围切分依据):[AI陪练二期开发推进计划](AI陪练二期开发推进计划.md)
- 当前接口与训练闭环:[API_INTEGRATION](API_INTEGRATION.md)
- 当前生产/试点证据边界:[BRD_IMPLEMENTATION_AUDIT](BRD_IMPLEMENTATION_AUDIT.md)
- 场景、任务和评分的实际实现:[`AihrPracticeController`](../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java)、[`AihrPracticeSeedService`](../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java)、[`aihr_practice_mysql8.sql`](../backend/script/sql/aihr_practice_mysql8.sql)
> 执行原则:先完成矩阵内已承诺的能力,再以试点证据决定是否扩展;不再把“发现一个差异”误包装成“又要开一个新一期”。
## 10. 本次工程实施记录(2026-07-24)
本节只记录已落地的工程事实,不能替代业务内容审核或真实试点结项。
| 固定实施序列 | 当前工程状态 | 已落地内容 | 尚需业务动作 |
|---|---|---|---|
| A. 冻结目录和业务规则 | 待业务确认 | 五岗位、三级与能力目录已固化为服务端目录;矩阵会明确显示“待归类”。 | 指定内容负责人/审核人;逐条归类存量场景;确认真实解锁阈值与试点名单。 |
| B. 最小数据语义与兼容迁移 | 已实现、已本机回归 | `scenario` 增长层/能力项/协同岗位/审核/风险/版本;首/第二审核人同时保存稳定用户 ID、显示名和时间;`session` 保存岗位、项目类型与课程快照,以及可选的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`。确认只能写在被复盘会话上,不能覆盖原层级快照;迁移可重复执行,且不会清空高风险内容已完成首审、正在等待第二人复核的审核轨迹。旧 `enabled` 场景一律进入“待业务审核”,不从启停状态推断“已发布”;历史详情只读会话快照,缺失时明确标注“历史未记录/未归类”。已完成会话的重复 `/finish` 仅回读持久化评分与快照,绝不按当前场景重新归类或写回。运行环境的全部 `ensure*` 路径默认只校验 schema、绝不执行 DDL;派发请求的幂等唯一键与最近内容索引必须同时满足唯一性、完整列序和无前缀,偏离即失败关闭。远端完整预检会同时校验这两个索引定义及 `wygj-aihr.service` 的有效环境未开启 runtime bootstrap。 | 在目标环境按发布顺序执行正式迁移;内容运营逐条归类、审核后再发布。 |
| C. 既有页面体验 | 已实现、已本机真实页面验收(部分) | 管理端场景页新增目录字段、审核流、风险选择、审核轨迹与缺口矩阵;新增场景弹窗已在本机真实登录后的页面检查,风险下拉明确展示“待评估 / 常规 / 高风险(需两位不同审核人)”。员工“练”页显示成长位置、能力证据和已发布场景,无可用场景或目录加载失败时清空旧卡片并禁止开始;每日三题只会从已发布、已启用、风险审核完成的同岗位场景题库取题,并优先补当前最低未覆盖的成长层/能力项(仅作训练推荐,不自动改职级或形成硬性解锁)。主管复盘页对初级/中级会话提供可选的下一层确认,员工画像回显该会话证据;服务端只接受初级→中级或中级→高级,且复用既有主管范围校验。移动端仅按认证手机号精确解析 `person_phone`;后台不得向 `/api/aihr/mobile/**` 传入 `ext_party_id` 读取个人训练或主管数据,后台运营仅通过独立 `/api/train/practice/**` 契约以及不绑定员工身份的 `operator:{userId}`、`mode=preview` 预览完成;`/me` 与问师傅主体不再使用组织模糊查询。若手机号与其他人在职外部 ID 碰撞、同手机号关联多名在职员工,或同一外部 ID 映射多个在职手机号,则拒绝猜测、读取或写入任务;员工历史、画像、成长进度和晋升证据只从同一服务端核验的别名集合聚合。主管范围只保存外部 ID;旧手机号必须唯一映射到一个外部 ID,且该外部 ID 也唯一拥有该手机号,才可读出和统计;这一规范化结果复用于团队列表、详情、复盘、音频授权与后续任务,歧义记录一律排除。案例项目范围与岗位 SOP 同样复用已验证 APP 主体的精确项目范围,不再以手机号/外部 ID 混合查询或组织模糊快照兜底。员工端账号由服务端强制按 `mobile` 模式和组织岗位校验。管理端对练看板可走服务端绑定的 `preview` 运营预览,但不能传入或读取员工身份,预览记录不进入员工/主管/试点统计。主管复盘显示本次课程快照,只在有岗位快照时加载同岗再练场景。 | 用具备主管权限的真实身份,且至少有一条经业务审批的已发布场景,走完发布→训练→复盘(含可选下一层确认)→再练。 |
| D. 内容补齐与质量校审 | 工程门禁已实现,业务内容未开始 | 后台已提供内容归类、审核、下线、风险分类、版本和审核轨迹入口;发布前强制校验业务来源/SOP、红线、目标、成功标准、回合、能力项和五维 Rubric;待评估风险不能发布,高风险内容必须由两位不同审核人完成双审。 | 先完成生活顾问三级内容与四岗初级内容的来源、Rubric、红线和实际双人审核;不得将测试/seed 内容标为正式发布。 |
| E. 真实试点与结项 | 未开始 | 已保留既有导出和严格试点统计口径。 | 在同一项目、明确日期、真实在职身份下完成每人至少 10 次训练与结项复盘。 |
#### 10.1 管理端与移动端的权限收口(2026-07-24)
> 以下为本地待发布的安全收口,尚未部署到生产环境。
- `/api/aihr/mobile/**` 的员工历史、画像、任务、主管复盘和团队难题仅由认证 APP 身份使用;员工端不采信客户端传入的 `extPartyId`,主管端从当前 APP 手机号解析项目范围。
- 对练录音下载同样只接受经 APP 身份和会话范围校验的请求;后台账号不能通过移动端 OSS 路径按对象编号读取员工录音。
- 管理端预览固定为 `operator:{userId}` 与 `mode=preview`,只能验证场景、模型和评分链路;不得读取员工身份、训练历史、主管复盘、成长证据或试点统计。
- 原后台“复盘校准 / 常见难题 / 成长激励”个人数据入口已撤出菜单并重定向到“任务运营”。后台继续保留已授权的内容审核、场景/Rubric 管理与批量任务运营;真实复盘、团队难题和定向再练由主管 APP 完成。
### 本次验证证据
- `mvn -pl ruoyi-modules/ruoyi-aihr -am -DskipTests=false -Dtest=AihrDashboardServiceTest,AihrPracticeSeedServiceTest,AihrPracticeLlmServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`:96 项通过;
- `mvn -pl ruoyi-modules/ruoyi-aihr -am -DskipTests=false -Dtest=AihrPracticeSeedServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`:本轮每日三题、题库审核失效与移动身份别名回归 89 项通过;
- `bash scripts/tests/aihr-schema-migrations.test.sh`:隔离 MySQL 数据库内重复执行迁移通过;成长目录迁移会补齐并修复派发幂等唯一键和最近内容索引。
- `pnpm --dir frontend build:dev`:管理端构建通过;
- `npm --prefix mobile-uni run typecheck && npm --prefix mobile-uni run build:h5`:员工/主管 H5 类型检查与构建通过。
- `node --test mobile-uni/tests/mobile-user-pages.test.mjs`:本轮新增的“当前已选场景启动”和“目录加载失败禁用开始”断言通过;全套仍有 3 条既有语音/方言断言与当前语音源码不一致,未在本计划中改写无关语音行为。
- 本机浏览器:管理端已认证打开“场景与评分”,验证矩阵刷新与场景元数据编辑对话框;员工端按“获取验证码 → 开发固定码 → 登录”完成认证,查看“我的成长位置 / 场景训练 / 错题练习 / 每日三题”。当前账号没有已发布场景时,页面明确提示需内容运营审核发布;员工身份直达“待复盘”返回“当前账号无主管权限”,未显示团队数据。登录手机号和验证码输入已改为 `tel`,保留数字键盘并避免浏览器将手机号当数值近似显示或丢失验证码前导零语义。
- `mvn -pl ruoyi-modules/ruoyi-aihr -am -Dtest=AihrPracticeSeedServiceTest,AihrKnowledgePrincipalResolverTest,AihrCaseServiceTest,AihrSopSeedServiceTest,AihrSchemaMigrationGuardTest,AihrRuntimeSchemaBootstrapContractTest -Dsurefire.failIfNoSpecifiedTests=false -DskipTests=false -Dmaven.test.skip=false test`:164 项通过,覆盖 APP/组织身份分流、历史手机号团队映射、案例范围、岗位 SOP 范围与 runtime schema 守卫。
- `mvn -q -pl ruoyi-modules/ruoyi-aihr -am -DskipTests=false test`:AIHR 模块全量 588 项通过。过程中修正了“问”模块训练数据工具的旧单测 mock:它必须先验证 APP 手机号,再读本人训练或经验证主管范围,不能继续按旧组织身份契约断言。
- `mvn -pl ruoyi-modules/ruoyi-aihr -am -Dtest=AihrSchemaMigrationGuardTest,AihrRuntimeSchemaBootstrapContractTest,AihrPracticeSeedServiceTest,AihrLearningContractTest,AihrExamIdempotencyTest -Dsurefire.failIfNoSpecifiedTests=false -DskipTests=false -Dmaven.test.skip=false test`:116 项通过;覆盖表/列/索引缺失时失败、派发幂等唯一键或最近内容索引存在但唯一性/列序/前缀错误时在任何 DDL 前失败、全部运行时 DDL 服务的静态门禁以及 Spring 实际属性缺省为关闭的行为。
- `mvn -pl ruoyi-modules/ruoyi-aihr -am -Dtest=AihrPracticeSeedServiceTest,AihrRuntimeSchemaBootstrapContractTest,AihrSchemaMigrationGuardTest -Dsurefire.failIfNoSpecifiedTests=false -DskipTests=false -Dmaven.test.skip=false test`:109 项通过;覆盖主管确认只能写入下一训练层级、确认写入原复盘会话、员工画像回显和新增字段的 schema 守卫。
- `bash scripts/tests/aihr-schema-migrations.test.sh`:在隔离 MySQL 内重跑通过,包含 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time` 三列的重复迁移。
- `FRONTEND_URL=http://127.0.0.1:4347 MOBILE_URL=http://127.0.0.1:4408 bash scripts/demo-check.sh`:通过;包含管理端、移动端 H5、租户/验证码接口、真实流程标记与严格试点门槛检查。门槛输出仍显示业务内容与真实试点未完成,不能把该通过当作试点结论。
- 本地开发库只读快照:五岗位各 20 条场景,全部为“初级 / 草稿 / 待评估”;正式训练会话 5 条、已复盘 3 条、达到每人 10 次完训的人数为 0。这些是开发 seed/本机验证数据,不可作为内容审核或试点证据。
- 线上 H5 静态包可访问,但只读检查仍显示登录字段为 `type=\"number\"`;本机 `tel` 修复尚未部署。当前工作区含其他未提交改动,不能以整包静态发布替代经过发布预检的受控版本。
- 独立本机后端以 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=false` 启动并监听 `127.0.0.1:4910`;启动日志显示学习/考试仅查询 `information_schema`,未执行 `CREATE/ALTER`,`GET /api/aihr/mobile/home/employee` 返回 HTTP/业务码 `200`。这是本机运行态证据,尚未部署或复核生产服务环境。
上述均为本机构建、隔离数据库或本机认证页面证据;当前尚未完成生产发布、主管身份的真实复盘链路、业务内容签字或严格试点,不能称“陪练模块完整交付完成”。
+21 -10
View File
@@ -8,26 +8,28 @@
|---|---|---| |---|---|---|
| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish`、`GET /records`、`POST /records/{sessionId}/review` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数。AI 分数只作辅助参考,HR/管理员可提交 `reviewedScore/reviewNote` 完成人工复核;记录同时保留 AI 分、人工复核分、最终采用分和复核状态 | | AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish`、`GET /records`、`POST /records/{sessionId}/review` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数。AI 分数只作辅助参考,HR/管理员可提交 `reviewedScore/reviewNote` 完成人工复核;记录同时保留 AI 分、人工复核分、最终采用分和复核状态 |
| 候选人入职主体关联 `/recruit/interview` | `GET/POST /api/recruit/interview/candidate-links` | HR/管理员在当前租户范围内把本地候选人 ID 关联到已同步的在职 `ext_party_id`;只保存外部主体 ID,不复制姓名、部门等组织字段,重复关联同一主体幂等,候选人更换主体或同一主体已关联其他候选人会拒绝 | | 候选人入职主体关联 `/recruit/interview` | `GET/POST /api/recruit/interview/candidate-links` | HR/管理员在当前租户范围内把本地候选人 ID 关联到已同步的在职 `ext_party_id`;只保存外部主体 ID,不复制姓名、部门等组织字段,重复关联同一主体幂等,候选人更换主体或同一主体已关联其他候选人会拒绝 |
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);移动端 `turn/finish` 校验当前手机号与启动会话归属,未知或他人会话拒绝;模型未配置或调用失败自动回退 seed,契约不变 | | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(五维分、导师改写、点评)。APP 员工请求由服务端绑定当前登录手机号并强制 `mode=mobile`,不信任客户端的身份或模式;`turn/finish` 只允许该员工操作本人活动/已完成会话。`superadmin` 或 `hr_operator` 的管理端预览可复用同一路径,但服务端固定为 `operator:{userId}` 与 `mode=preview`,忽略客户端 `extPartyId/mode/assignmentId`;预览记录不进入员工历史、主管复盘、成长、试点或正式校准统计。其他后台身份拒绝。已完成会话的重复 `/finish` 只读取持久化结果,绝不按当前场景目录重算或回写历史快照;模型未配置或调用失败自动回退 seed,契约不变。 |
| M2 训练预习与求助 | `GET /api/train/practice/scenarios/{id}/prep-card`、`POST /api/train/practice/sessions/{sessionId}/help` | 均要求已登录;预习卡只使用当前租户已启用的 `prep_card/json_prep` 模板,模型返回必须严格为 3 条要点、3 条红线、2 条话术,否则回退场景卡。求助接口只允许 APP 员工访问其本人、同租户的活动训练会话;客户端可传 `roundIndex` 仅为兼容字段,服务端按会话实际进度落库,返回 `{sessionId,scenarioId,roundIndex,recordedAt}`。迁移表为 `aihr_practice_help_event`,不在请求路径执行 DDL。该契约当前仅完成本地实现与验证,线上尚未有该表。 | | 训练场景运营 `/train/scenarios` | 内容读取 `GET /api/train/practice/scenarios`、`GET /scenarios/{id}`;管理写入 `POST /scenarios`、`PATCH /scenarios/{id}/enabled`、`PATCH /scenarios/{id}/review-status`;目录 `GET /curriculum/capabilities`、`GET /curriculum/matrix` | 读取默认只返回当前租户“已发布且启用”的场景,且不返回审核人、稳定用户 ID 或审核时间;仅 `superadmin/hr_operator` 可请求 `includeDisabled=true`、编辑、启停和推进审核。状态只能按 `草稿 → 待业务审核 → 已发布 → 已下线` 流转;发布时服务端重新读取场景并校验住宅范围、固定五岗位、成长能力项、业务来源/SOP、服务红线、目标、成功标准、至少两轮回合和完整五维 Rubric,不信任客户端传入的发布内容。每次编辑已发布内容会退回待业务审核并刷新内容版本/哈希及审核留痕。运营可以选择风险级别,但服务端会对收费、安全、投诉等受控词及全部回合文本自动升级为“高风险”;待评估内容不可发布。高风险场景先记录第一位审核人的稳定用户 ID 和显示名并保持待业务审核,只有第二位不同的已认证运营人员复核同一内容版本后才发布;迁移重跑不得清空等待复核的首审记录。员工可见和可启动路径始终同时要求 `review_status=已发布 AND enabled=1`。 |
| M2 训练预习与求助 | `GET /api/train/practice/scenarios/{id}/prep-card`、`POST /api/train/practice/sessions/{sessionId}/help` | 均要求已登录;预习卡只使用当前租户已启用的 `prep_card/json_prep` 模板,模型返回必须严格为 3 条要点、3 条红线、2 条话术,否则回退场景卡。求助接口只允许 APP 员工访问其本人、同租户的活动训练会话;客户端可传 `roundIndex` 仅为兼容字段,服务端按会话实际进度落库,返回 `{sessionId,scenarioId,roundIndex,recordedAt}`。迁移表为 `aihr_practice_help_event`,不在请求路径执行 DDL。该表已随 2026-07-24 完整包发布并纳入远端 `62/62` 结构预检;完整训练写入与正式试点仍需另验。 |
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}, practiceContext?:{sessionId,turnIndex,role:'customer'}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口;生产 SiliconFlow CosyVoice2 按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。`practiceContext` 仅允许已认证 APP 员工把本次服务端生成的业主 TTS 绑定到本人当前或刚完成会话的指定原话术回合;服务端重新校验租户、员工身份、回合和文本,不接受员工端传 OSS ID 或任意角色。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}, practiceContext?:{sessionId,turnIndex,role:'customer'}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口;生产 SiliconFlow CosyVoice2 按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。`practiceContext` 仅允许已认证 APP 员工把本次服务端生成的业主 TTS 绑定到本人当前或刚完成会话的指定原话术回合;服务端重新校验租户、员工身份、回合和文本,不接受员工端传 OSS ID 或任意角色。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;移动端 OSS 下载只允许经本人或主管项目范围验证的 APP 身份,后台账号若需回放必须使用未来独立、受审计的管理端契约。 |
| 案例沉淀 `/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` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例沉淀 `/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 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、入库、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 |
| 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL |
| 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 | | 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 |
| SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`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` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最近 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时,只传 `broadcastMessageId`,服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息;撤回、跨租户或非在职员工均拒绝。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。 | | SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`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` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最近 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回、跨租户或非在职员工均拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。岗位 SOP 的项目范围复用当前 APP 已验证主体的 `projectCodes`,不得以管理端组织快照的模糊/别名查询作为后备。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`。详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。 |
| 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | Agent 主入口返回 `actionDraft`,确认/忽略走 `/api/aihr/agent/actions/{draftId}/confirm|dismiss`;旧接口 `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm|dismiss` 继续兼容;确认记录读写仍使用 `/api/aihr/personal-assistant/**`,旧项目记录继续使用 `/api/aihr/service-memories` | 文字、ASR 转写和现场媒体复用同一候选检测。项目工作必须从 `/api/aihr/mobile/me` 返回的项目名称列表选择当前项目,内部 `projectCode` 仍由服务端按当前登录主体授权收窄;个人笔记可以不绑定项目。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,系统建议 `ATTENDANCE/INSPECTION/SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转。状态更新只允许 `RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID`,幂等键不得跨记录或目标状态复用。来源文件重新鉴权交付;正式外部接收端未配置前不得显示已送达 | | 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | Agent 主入口返回 `actionDraft`,确认/忽略走 `/api/aihr/agent/actions/{draftId}/confirm|dismiss`;旧接口 `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm|dismiss` 继续兼容;确认记录读写使用 `GET /api/aihr/personal-assistant/assistant-captures?limit=50`、`POST /assistant-captures/{id}/status`、`GET /assistant-captures/{id}/status-history`、`GET /assistant-captures/{id}/source`,旧项目记录继续使用 `/api/aihr/service-memories` | 文字、ASR 转写和现场媒体复用同一候选检测。项目工作必须从 `/api/aihr/mobile/me` 返回的项目名称列表选择当前项目,内部 `projectCode` 仍由服务端按当前登录主体授权收窄;个人笔记可以不绑定项目。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,系统建议 `ATTENDANCE/INSPECTION/SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转。状态更新只允许 `RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID`,幂等键不得跨记录或目标状态复用。来源文件重新鉴权交付;正式外部接收端未配置前不得显示已送达。 |
| 今日工作成果 `/pages/user/work-results/index`、`/pages/supervisor/work-results/index` | 员工 `POST /api/aihr/work-results/mine/generate?projectCode=...&workDate=YYYY-MM-DD`;主管 `GET /api/aihr/work-results/project?projectCode=...&workDate=YYYY-MM-DD` | 按员工 + 项目 + Asia/Shanghai 自然日确定性聚合已确认记录;两页默认当天并可按日期查看历史成果,客户端禁止选择未来日期。内容未变化时重复生成不增加版本,记录或状态变化后版本递增。主管只可查看本人在目标项目具备主管身份的团队结果;APP 登录身份同时兼容组织快照中的外部人员 ID 与手机号,普通员工或跨项目请求返回 403。外部接口未接通时只显示本地状态和 PENDING,不伪造派单或考勤同步成功 | | 今日工作成果 `/pages/user/work-results/index`、`/pages/supervisor/work-results/index` | 员工 `POST /api/aihr/work-results/mine/generate?projectCode=...&workDate=YYYY-MM-DD`;主管 `GET /api/aihr/work-results/project?projectCode=...&workDate=YYYY-MM-DD` | 按员工 + 项目 + Asia/Shanghai 自然日确定性聚合已确认记录;两页默认当天并可按日期查看历史成果,客户端禁止选择未来日期。内容未变化时重复生成不增加版本,记录或状态变化后版本递增。主管只可查看本人在目标项目具备主管身份的团队结果;APP 登录身份同时兼容组织快照中的外部人员 ID 与手机号,普通员工或跨项目请求返回 403。外部接口未接通时只显示本地状态和 PENDING,不伪造派单或考勤同步成功 |
| 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages?pageNum=&pageSize=&status=PUBLISHED|WITHDRAWN|all`(响应含服务端确认的 `tenantId`)、`POST /api/aihr/broadcast/messages`(`{requestId,title,content,expectedTenantId}`)、`POST /api/aihr/broadcast/messages/{id}/withdraw`(`{reason,expectedTenantId}`) | 员工端只返回当前登录租户的 `PUBLISHED` 公开消息,租户和阅读用户均由服务端登录身份取得;阅读表以 `(tenant_id,message_id,user_id)` 唯一约束保证重复打开幂等。详情“问这条消息”只在本地暂存消息 ID,问模块重新读取并由服务端可信解析消息;撤回、跨租户或非在职员工均不可继续追问。管理读写使用服务端当前有效租户(动态租户优先);`expectedTenantId` 只能回显刚读取的服务端范围,不用于选择租户,若另一标签页已切换范围则返回 `409`,避免旧页面误发或误撤回。发布以 `(tenant_id,published_by,requestId)` 幂等:同键同内容重放原 `{id,version:1}`,同键异内容返回 `409`;一个事务内写主消息和不可变 v1 内容快照。管理列表可按已发布/已撤回筛选并返回正文、发布/撤回审计与固定 `currentVersion=1`。撤回原因必填、去首尾空白且最多 500 字符;首次只允许 `PUBLISHED -> WITHDRAWN`,重复撤回成功但不得覆盖原撤回人、时间或原因。管理端将未知发布请求按“有效租户 + 管理员”的 `v2` 本地记录保存,存储不可用时拒绝发起发布。首版不含修订、定向目标、附件或 AI 摘要;管理接口只允许 `superadmin` 或 `hr_operator`。 | | 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`、`GET /api/aihr/broadcast/attachments/{id}/content`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages`、`POST /api/aihr/broadcast/admin/attachments`、`GET /api/aihr/broadcast/admin/attachments/{id}`、`POST /api/aihr/broadcast/messages`、`POST /api/aihr/broadcast/messages/{id}/withdraw` | 发布支持全员或按部门/岗位/人员定向、必读、单个公司文件与异步提炼。文件支持 txt/md/PDF/Word/Excel/PPT,100MB 内;先受控写入 OSS,再异步解析并复用现有模型生成摘要,只有 `READY` 且未绑定、属于当前租户和上传人的文件才可随消息发布。员工详情只返回摘要和受控下载信息,不返回提取全文或原始 OSS 地址;下载和追问每次重新校验当前 APP 在职身份、租户和消息状态,追问上下文由服务端拼接消息正文与提取文本,客户端仍只传 `broadcastMessageId`。定向目标用于“与我有关”和必读提醒,不改变全租户公开频道的可见性。发布、阅读和撤回保持既有幂等与审计约束;管理接口只允许 `superadmin` 或 `hr_operator`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 |
| 员工直通车 `/pages/user/direct/index`、管理端 `/content/direct` | 员工 `GET /api/aihr/direct/channels`、`POST /api/aihr/direct/feedback`、`GET /api/aihr/direct/mine`、`GET /api/aihr/direct/mine/{id}`;处理端 `GET /api/aihr/direct/admin/feedback`、`POST /api/aihr/direct/admin/feedback/{id}/reply` | 员工可选择总裁、财务、人力、审计、运营并点对点提交。总裁/审计默认匿名;匿名仅表示业务处理界面不显示提交人,系统仍保存内部账号和姓名快照供本人查询、幂等与审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 仅处理各自频道,`superadmin` 可处理全部;列表、详情和回复均由服务端按角色收窄。一个反馈只允许一次正式回复,员工可在“我的反馈”查看状态和回复;当前不扩展为工单 SLA、转派或多轮聊天。 |
| 成果投稿(当前页面名“工作上报”)`/pages/user/report/index` | `POST /api/aihr/work-report/organize`、`POST /attachment`、`POST /reports`、`GET /reports/mine`;主管/运营另有列表和审核接口 | 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿。会话式页面、无状态整理、附件、幂等正式提交和历史状态已部署;审核通过不自动入知识库。当前整理服务不读取图片/视频内容,只把附件名称作为不可信元数据;不得与日常工作记录或“今日工作成果”混用 | | 成果投稿(当前页面名“工作上报”)`/pages/user/report/index` | `POST /api/aihr/work-report/organize`、`POST /attachment`、`POST /reports`、`GET /reports/mine`;主管/运营另有列表和审核接口 | 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿。会话式页面、无状态整理、附件、幂等正式提交和历史状态已部署;审核通过不自动入知识库。当前整理服务不读取图片/视频内容,只把附件名称作为不可信元数据;不得与日常工作记录或“今日工作成果”混用 |
| 资料处理 `/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 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 | | 资料处理 `/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/employee_project_assignment` 快照,分页参数使用 `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` 才允许覆盖。相同员工可由多条有效项目分配展开为多个项目成员行,唯一约束为 `tenant_id + project_code + 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。该统计发生在多项目成员迁移前;发布迁移后必须重新 dry-run 并核对展开后的项目成员行数。当前剩余 25 人手机号不可用属于上游数据质量问题 | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee/employee_project_assignment` 快照,分页参数使用 `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` 才允许覆盖。相同员工可由多条有效项目分配展开为多个项目成员行,唯一约束为 `tenant_id + project_code + 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。该统计发生在多项目成员迁移前;发布迁移后必须重新 dry-run 并核对展开后的项目成员行数。当前剩余 25 人手机号不可用属于上游数据质量问题 |
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;移动专用接口固定服务端默认租户,客户端不传也不能选择 `tenantId`;验证码按“默认租户 + 手机号”隔离。校验在手机号粒度的分布式锁内完成:仅匹配成功才消费,输错不会作废原验证码。移动端令牌只关联 `app_user`;若同一手机号存在任何后台/系统账号(即使同时存在 `app_user`),一律拒绝登录而不复用或并置身份;手机号完全不存在时才自动注册 `app_user`。`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;移动专用接口固定服务端默认租户,客户端不传也不能选择 `tenantId`;验证码按“默认租户 + 手机号”隔离。校验在手机号粒度的分布式锁内完成:仅匹配成功才消费,输错不会作废原验证码。移动端令牌只关联 `app_user`;若同一手机号存在任何后台/系统账号(即使同时存在 `app_user`),一律拒绝登录而不复用或并置身份;手机号完全不存在时才自动注册 `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 保演示 | | 用户侧三端首页 `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` 为“主管/项目经理”时进入主管端,否则进入员工端。响应返回去重后的 `projects[]` 供页面按项目名称选择,不要求用户输入或记忆编码;同一人员在多个项目的快照行按 `tenant_id + project_code + ext_party_id` 保留。接口失败回退员工端 | | 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后仅以手机号精确查询 `person_phone`(`activeByMobilePhone`,不使用组织模糊搜索);`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端。响应返回去重后的 `projects[]` 供页面按项目名称选择,不要求用户输入或记忆编码;同一人员在多个项目的快照行按 `tenant_id + project_code + ext_party_id` 保留。接口失败回退员工端。 |
| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度。每日三题正式只对 `hire_date` 在当前日期前三个月内的在职员工派发;没有入职日期时不使用训练次数推断,开发 Demo 的旧回退只有在 `dev/local` profile 且由 `aihr.practice.allow-legacy-daily-drill-fallback` 显式开启时生效,生产 profile 强制关闭。 | | 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;服务端固定身份和 `mode=mobile` 后才写入 `aihr_practice_session` 并进入员工/主管统计。员工历史、错题、画像、成长进度、晋升证据和满意度均只使用当前认证手机号经服务端验证得到的受控历史别名;后台内容/任务运营只通过独立 `/api/train/practice/**` 契约,不得向 `/api/aihr/mobile/**` 传入外部 ID 读取个人或主管数据。主管 `/practice/team` 的范围只含已验证外部 ID;历史训练或任务若存的是同一员工的旧手机号,只在该手机号能无碰撞规范化到团队外部 ID 时才展示/统计,避免把另一人的外部 ID 误并入团队。管理端 `mode=preview` 仅是运营调试记录,不能伪装成员工训练、满意度、复盘、成长或试点证据。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号先解析为在职组织外部 ID,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度。每日三题正式只对 `hire_date` 在当前日期前三个月内的在职员工派发,且只会从已发布、已启用、风险审核完成的同岗位场景题库取题;选择时优先补最低未覆盖的成长层/能力项,仅作为训练推荐,不自动调整职级或形成硬性解锁。没有入职日期时不使用训练次数推断,开发 Demo 的旧回退只有在 `dev/local` profile 且由 `aihr.practice.allow-legacy-daily-drill-fallback` 显式开启时生效,生产 profile 强制关闭。 |
| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试支持文字作答和复用 `/api/ai/asr` 的录音转文字/选音频,`/answer` 可带可选 `answerAudioOssId`,服务端只接受当前候选人名下处于 `staged/bound` 状态的音频并将 `ossId` 写入问题快照,不向客户端暴露原始 OSS URL;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | | 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试支持文字作答和复用 `/api/ai/asr` 的录音转文字/选音频,`/answer` 可带可选 `answerAudioOssId`,服务端只接受当前候选人名下处于 `staged/bound` 状态的音频并将 `ossId` 写入问题快照,不向客户端暴露原始 OSS URL;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 |
| 每日题反馈与常见难题 | `POST /api/aihr/mobile/practice/assignments/{id}/feedback`、`GET /api/aihr/mobile/practice/difficulties`、`POST /api/aihr/mobile/practice/difficulties/assign` | 反馈区分题目有用性与答案正确性;主管/管理端按岗位、时间、次数、人数和均分聚合,并可基于难题创建专项;身份和项目范围由服务端解析 | | 每日题反馈与常见难题 | `POST /api/aihr/mobile/practice/assignments/{id}/feedback`、`GET /api/aihr/mobile/practice/difficulties`、`POST /api/aihr/mobile/practice/difficulties/assign` | 反馈区分题目有用性与答案正确性;仅认证 APP 主管可按其项目范围聚合岗位、时间、次数、人数和均分,并基于难题创建专项。管理端不得调用移动主管接口;后台人工任务只走受角色保护的 `/api/train/practice/assignments/manage/**`。 |
| 岗位考试 | 员工 `GET /api/aihr/mobile/exams`、`GET /exams/{id}`、`POST /exams/{id}/submit`;主管 `GET/POST /exams/supervisor`、`GET/PUT /exams/supervisor/{id}`、`POST /exams/supervisor/{id}/publish`、`GET /exams/supervisor/{id}/results` | 主管按项目范围选择真实员工、组卷、发布和查成绩;员工只读本人目标考试。发布和提交接受请求 ID 并保持幂等,不得重复生成成绩或奖励 | | 岗位考试 | 员工 `GET /api/aihr/mobile/exams`、`GET /exams/{id}`、`POST /exams/{id}/submit`;主管 `GET/POST /exams/supervisor`、`GET/PUT /exams/supervisor/{id}`、`POST /exams/supervisor/{id}/publish`、`GET /exams/supervisor/{id}/results` | 主管按项目范围选择真实员工、组卷、发布和查成绩;员工只读本人目标考试。发布和提交接受请求 ID 并保持幂等,不得重复生成成绩或奖励 |
| 开放问题榜与积分 | 移动端 `/api/aihr/community/questions`、`questions/{id}`、`questions/{id}/answers`、`questions/{id}/best-answer`、`points/me`;后台 `/api/aihr/question-admin/**`、`/api/aihr/incentive-admin/rules` | 问题、答案、最佳答案和奖励全程按租户/身份授权;重复选优不重复入账。奖励只有积分与学习学分,不代表现金或提现 | | 开放问题榜与积分 | 移动端 `/api/aihr/community/questions`、`questions/{id}`、`questions/{id}/answers`、`questions/{id}/best-answer`、`points/me`;后台 `/api/aihr/question-admin/**`、`/api/aihr/incentive-admin/rules` | 问题、答案、最佳答案和奖励全程按租户/身份授权;重复选优不重复入账。奖励只有积分与学习学分,不代表现金或提现 |
| 全网 AI | 员工 `GET /api/aihr/web-ai/capabilities`、`POST /api/aihr/web-ai/query`;提供方管理 `/api/aihr/web-search/providers/**` | 与企业知识问答分入口和来源。仅接受公网 HTTPS 提供方,地址或密钥变更后必须重新连接测试;未配置/未启用时返回明确不可用,不生成假答案 | | 全网 AI | 员工 `GET /api/aihr/web-ai/capabilities`、`POST /api/aihr/web-ai/query`;提供方管理 `/api/aihr/web-search/providers/**` | 与企业知识问答分入口和来源。仅接受公网 HTTPS 提供方,地址或密钥变更后必须重新连接测试;未配置/未启用时返回明确不可用,不生成假答案 |
@@ -68,6 +70,15 @@ curl -fsS -X POST "$API_BASE/api/aihr/agent/actions/<draftId>/dismiss" \
校验失败返回业务码 `400`;角色/工具/项目/知识空间越权返回 `403`;会话版本、候选版本或幂等冲突返回 `409`;草稿票据 30 分钟过期后必须重新生成。Agent 审计只记录最小路由元数据,不保存上述问题、回答或附件。 校验失败返回业务码 `400`;角色/工具/项目/知识空间越权返回 `403`;会话版本、候选版本或幂等冲突返回 `409`;草稿票据 30 分钟过期后必须重新生成。Agent 审计只记录最小路由元数据,不保存上述问题、回答或附件。
### 练模块身份映射
#### 训练层级确认补充契约
- 主管在 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` 的既有复盘请求中可选传 `confirmedGrowthLevel`,仅可为 `中级` 或 `高级`。服务端先按当前登录的 APP 主管身份及项目范围校验该会话,再锁定并读取该会话的 `growth_level`:只允许 `初级 → 中级`、`中级 → 高级`,其他值或跨级均返回业务错误。
- 确认只会写入该 `aihr_practice_session` 的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`,并随主管复盘详情和员工 `GET /api/aihr/mobile/profile` 的 `growthConfirmations[]` 回显。它是可追溯的训练带教证据,不创建独立人事记录,不自动变更职级、薪酬、晋升、上岗或资格结论,也不替代待业务确认的正式解锁阈值。
移动端登录主体只按认证手机号精确匹配 `person_phone`;`/me`、问师傅主体和移动训练入口均使用该精确映射,不得回落到组织模糊搜索。`/api/aihr/mobile/**` 的员工历史、画像、成长进度、任务、主管带教和对练录音下载只接受 APP 身份:员工端忽略客户端 `extPartyId`,主管端从当前 APP 手机号解析其组织外部 ID;`SYS_USER` 不得借这些接口传入员工标识读取、复盘、派发、聚合个人训练数据或按 OSS 对象编号下载录音。管理端运营预览固定为 `operator:{userId}` 与 `mode=preview`,不进入员工历史、主管复盘、成长或试点统计;后台仅通过受角色保护的 `/api/train/practice/**` 内容与任务运营契约工作。两个身份来源绝不互作替代查询键。训练任务的读取、启动、完成、每日题、画像、成长进度和晋升证据由服务端按该来源生成受控的历史别名集合,客户端不能传入替代身份。主管团队范围只保存外部 ID;旧手机号存储的记录仅当它唯一映射到一个外部 ID,且该外部 ID 在全部在职快照中也只映射到该一个手机号时,才可规范化到团队范围。规范化结果必须在团队列表、详情、复盘、音频授权与后续任务创建中复用;任一碰撞或多映射都返回无身份、拒绝猜测或读写任务。新任务仅使用唯一映射后的存储身份,旧任务仅能在同一受控别名集合内读取。
## 后端落点 ## 后端落点
- 业务模块:`backend/ruoyi-modules/ruoyi-aihr` - 业务模块:`backend/ruoyi-modules/ruoyi-aihr`
+32 -26
View File
@@ -1,6 +1,12 @@
# BRD 功能审查与未完成项记录 # BRD 功能审查与未完成项记录
> 当前基线:2026-07-24。最新完整发布源仍为 `f023417a`(含“大喇叭 + 问模块纠偏”业务提交 `4b31848e`);2026-07-24 的数字师傅 Agent 为本地实现,尚未发布。当前真相以本文件、[生产迁移 Runbook](BRD_PRODUCTION_MIGRATION_RUNBOOK.md)和每次发布的只读预检为准。 > 当前基线:2026-07-24。最新完整发布源为 `342f84a9`,包含五岗位训练内容运营、M2—M3 训练闭环,以及公司消息 R1—R3;当前真相以本文件、[生产迁移 Runbook](BRD_PRODUCTION_MIGRATION_RUNBOOK.md)和每次发布的只读预检为准。
>
> 2026-07-24 的数字师傅 Agent、`aihr_agent_run` 和对应移动端改造已完成本地实现与分支合并,但尚未发布;不得把本地验证外推为生产部署。
>
> 2026-07-24 本地待发布修订:后台不再暴露个人训练、主管复盘或成长证据入口;这些能力仅留在认证 APP。本文的历史生产证据不代表该修订已上线。
>
> 2026-07-24 已发布修订:公司消息 R1—R3 已补齐员工直通车、部门分频道处理、业务匿名、一次正式回复,以及大喇叭单文件上传、异步提炼、摘要展示、受控下载和文件内容追问;生产认证态闭环已验证。
> >
> 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。 > 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。
@@ -12,28 +18,26 @@
| 层级 | 当前状态 | 证据与边界 | | 层级 | 当前状态 | 证据与边界 |
|---|---|---| |---|---|---|
| 已实现 | 是 | 已有阶段一主功能,并新增大喇叭最小闭环、消息上下文追问和“问”模块的待办/会话区纠偏;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) | | 已实现 | 是 | 已有阶段一主功能,公司消息 R1—R3 已实现直通车与公司文件提炼闭环;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) |
| 本地自动化/API 验证 | 是 | 本批定向后端测试 `91` 项、管理端测试 `18` 项、移动端测试 `129` 项及三端构建通过;具体范围仍以各测试与专项方案为准 | | 本地自动化/API 验证 | 是 | 训练运营定向后端测试 `76` 项;公司消息新增后端 `30` 项、管理端 `2` 项、移动端 `2` 项;管理端/H5 构建通过 |
| 本地浏览器/视觉验证 | 部分 | 既有移动端和管理端视觉回归已完成;本批最终生产截图只复核未登录 H5 页面,不能替代正式认证态消息/追问流程的视觉验收 | | 本地浏览器/视觉验证 | 部分 | 已检查管理端目录/任务、两轮动态对练、岗位不匹配拦截、直通处理和文件消息;线上已以认证态员工完成公司消息回归。训练完整写入和真机验收仍未完成 |
| 已部署 | 是 | 管理端、H5、后端和本批四个迁移已发布;线上静态资源及 AIHR 模块与本地构建一致 | | 已部署 | 是 | 管理端、H5、后端、公司消息 R1—R3、训练内容运营、M2 求助事件和租户组织治理迁移已发布;线上静态资源及 AIHR 模块与本地构建一致 |
| 生产基础验证 | 是 | 服务 `active`;根站、H5、租户接口和员工首页 API 正常;发布时的远端 schema 清单为 `57/57`,后续只读复核确认 M1 定向表与必读字段存在;私有个人资料存储配置有效 | | 生产基础验证 | 是 | 服务 `active`;根站、H5、租户接口和员工首页 API 正常;远端完整预检为 `64/64`,且管理端/H5 资源、AIHR 模块与本地构建匹配 |
| 生产完整业务验收 | 否 | 未用正式认证账号重放管理发布—员工阅读—消息追问,也未在真实手机复验麦克风、文件/相册、下载、读屏和设备语音;严格试点窗口同样未完成 | | 生产完整业务验收 | 部分 | 公司消息已完成认证态全员文件消息和直通车闭环;训练写入、主管复盘、定向人群正反例、正式处理人绑定、真机媒体与严格试点窗口仍未完成 |
“已部署”不等于“正式试点通过”。当前可以确认生产包和基础运行状态,不能把未执行的正式账号、真机和业务窗口验收写成已完成。 “已部署”不等于“正式试点通过”。当前可以确认生产包和基础运行状态,不能把未执行的正式账号、真机和业务窗口验收写成已完成。
### 2026-07-23 大喇叭与问模块增量边界 ### 2026-07-24 公司消息 R1—R3 发布边界
本批已部署的公司消息能力是全员公开频道、未读/已读、当前租户的管理发布/撤回,以及以 `broadcastMessageId` 进入“问”的受控文字追问。服务端每轮重新校验租户、在职身份和消息状态,不把正文或附件文本信任为客户端上下文;消息撤回后续问即被拒绝。 当前公司消息能力包括全员公开频道、目标快照、必读、未读/已读、管理端发布/撤回、单文件异步提炼,以及以 `broadcastMessageId` 进入“问”的受控追问。服务端每轮重新校验租户、在职身份和消息状态,正文与附件内容均由服务端加载;消息撤回后续问和文件访问即被拒绝。
“问”页面将连续会话设为主体,消息/知识回答不再把通用培训或岗位资料伪造成当前员工待办。M1 的必读和目标快照 schema 已由线上只读查询确认,但正式认证态的定向发布、阅读和必读闭环尚未在本轮重放;附件、AI 摘要、“与我有关”、修订、补推、强触达和外部流程仍不在已验收范围内。 “问”页面将连续会话设为主体,消息/知识回答不再把通用培训或岗位资料伪造成当前员工待办。生产已验证全员文件消息发布、摘要、认证下载、文件内容追问和撤回;测试消息已撤回。直通车按高管、财务、人力、审计和运营五个通道分权,处理端只见“匿名员工”,系统保留内部身份审计,每条仅一次正式回复;生产已验证员工提交、审计处理和员工查看回复。定向人群正反例、正式处理人绑定、修订、补推、强触达和外部流程仍待补齐。
### 2026-07-23 M2 本地实现边界 ### 2026-07-24 M2—M3 与五岗位训练运营发布边界
训练预习/求助、知识缺口留痕、动态情绪与红线纠偏、回复风格重生成、答案反馈屏蔽和方言语气参数均在本地工作区实现并按本地模型配置验证。它们尚未进入本次生产包:线上虽已有既有的录音/标注表,但缺少本批 `aihr_practice_help_event` 表;不得将 M2 的本地测试或模型配置同步写成生产部署或正式试点验收。 当前完整包已包含训练预习/求助、知识缺口留痕、动态纠偏、受控录音/回放、逐句标注和五岗位内容运营。生产迁移包含 `aihr_practice_help_event`,并落入当前 `64/64` 远端结构预检;五岗位共 `100` 个场景,其中 `86` 个启用、`14` 个高风险场景保持禁用,另有 `100` 条启用 Rubric 与 `500` 条评分维度。
### 2026-07-23 M3 本地实现边界 上述证据只说明代码、结构、内置内容和认证态训练入口已经发布。尚未在生产写入完整训练会话、完成主管复盘或验证真实手机麦克风、弱网、方言与正式试点账号,不能把本地浏览器验证外推为正式业务验收。
对练的员工录音 OSS 关联、业主 TTS 的受控会话/回合绑定、主管复盘详情的受保护回放及 finish 后一次性逐句标注均已在本地实现并完成浏览器闭环。该验收使用本机项目经理与员工测试身份,只确认同项目权限范围、OSS 引用和页面交互;尚未发布生产,也未在真机麦克风、弱网或正式试点账号下验收。
### 2026-07-24 数字师傅 Agent 本地实现边界 ### 2026-07-24 数字师傅 Agent 本地实现边界
@@ -62,9 +66,10 @@
| 不同角色/岗位内容 | 员工、候选人、主管独立入口;岗位来自组织系统,主管可切换学员模式但不改变组织身份 | 生产包已部署;全岗位内容矩阵和正式权限正反例仍需业务数据 | | 不同角色/岗位内容 | 员工、候选人、主管独立入口;岗位来自组织系统,主管可切换学员模式但不改变组织身份 | 生产包已部署;全岗位内容矩阵和正式权限正反例仍需业务数据 |
| 字号适老化 | 标准/大字/超大字全局持久化,同一页面结构响应式增高 | 本地多视口视觉通过;真实手机触控、键盘和读屏未验收 | | 字号适老化 | 标准/大字/超大字全局持久化,同一页面结构响应式增高 | 本地多视口视觉通过;真实手机触控、键盘和读屏未验收 |
| 问师傅语音与播报 | 录音、选择音频、ASR、TTS/设备语音降级恢复;答案、总结卡、每日题、点评等关键内容可播报 | 生产包已部署;生产 CosyVoice2 已按老师傅/业主/面试官区分音色,业主情绪语气与语速已在生产 H5 验证;真实手机权限、弱网、方言和正式 ASR 仍未验收 | | 问师傅语音与播报 | 录音、选择音频、ASR、TTS/设备语音降级恢复;答案、总结卡、每日题、点评等关键内容可播报 | 生产包已部署;生产 CosyVoice2 已按老师傅/业主/面试官区分音色,业主情绪语气与语速已在生产 H5 验证;真实手机权限、弱网、方言和正式 ASR 仍未验收 |
| 问师傅连续对话与资料 | 认证用户短期会话保留 30 分钟/最近 6 轮,追问改写并路由 `QA/FILE/VIDEO/DATA_TOOL`;原文件/视频只从当前命中且实时授权的附件交付,无资源不造假链接 | 生产包、schema、哈希和匿名“新对话”页面已验证;正式 OTP 认证态三轮与真机下载未在生产重放 | | 问师傅连续对话与资料 | 认证用户短期会话保留 30 分钟/最近 6 轮,追问改写并路由 `QA/FILE/VIDEO/DATA_TOOL`;原文件/视频只从当前命中且实时授权的附件交付,无资源不造假链接 | 公司文件消息的认证下载与追问已生产验证;一般知识命中文件/视频的真机下载仍待复验 |
| 问页面有效会话区与消息追问 | 会话开始后收起占位辅助区;大喇叭详情仅以消息 ID 受控进入短会话,消息/知识回答不伪造待办 | 当前完整包和未登录 H5 页面已生产复核;正式认证态发布—阅读—追问与 390×844 最终截图仍待验收 | | 问页面有效会话区与消息追问 | 会话开始后收起占位辅助区;大喇叭详情仅以消息 ID 受控进入短会话,消息/知识回答不伪造待办 | 生产认证态发布—阅读—文件追问已验证;390×844 最终截图和真机体验仍待补 |
| 银城大喇叭 | 全员公开消息、未读/已读、当前租户的管理发布/撤回已部署 | 未实现定向、附件、摘要、相关性、修订和补推;不得表述为完整公司消息运营系统 | | 银城大喇叭 | 全员/定向、必读、未读/已读、发布/撤回、单文件异步提炼、摘要、受控下载和文件追问已部署 | 全员文件消息闭环已生产认证验证,测试消息已撤回;仍不含修订、补推和外部强触达,定向正反例待补 |
| 员工直通车 | 已部署高管、财务、人力、审计、运营五个点对点频道,业务匿名、分角色收件和一次正式回复 | 生产已验证员工提交—审计处理—员工查看;五类角色已建,真实处理人员仍须按业务名单绑定 |
| 图片/视频与总结卡图片 | 问师傅附件仅用于本次现场分析;总结卡可下载 PNG | 生产包已部署;iOS/Android H5 下载和相册/文件权限未复验 | | 图片/视频与总结卡图片 | 问师傅附件仅用于本次现场分析;总结卡可下载 PNG | 生产包已部署;iOS/Android H5 下载和相册/文件权限未复验 |
| 录音归属、提交无反馈、筛选错位 | 账号/页面代次隔离、旧请求回写保护、明确加载/失败状态和窄屏布局已修 | 本地双账号/交互测试通过;生产认证态未重放 | | 录音归属、提交无反馈、筛选错位 | 账号/页面代次隔离、旧请求回写保护、明确加载/失败状态和窄屏布局已修 | 本地双账号/交互测试通过;生产认证态未重放 |
| 每日题变化与题目反馈 | 五岗位题库按日期、岗位和员工身份轮换;每日题/专项支持有用/无用反馈 | 生产 schema 和包已部署;正式题库内容审核与业务样本待补 | | 每日题变化与题目反馈 | 五岗位题库按日期、岗位和员工身份轮换;每日题/专项支持有用/无用反馈 | 生产 schema 和包已部署;正式题库内容审核与业务样本待补 |
@@ -80,25 +85,25 @@
### P0:生产认证与正式试点 ### P0:生产认证与正式试点
1. 选定 1–2 个真实项目、正式员工名单和起止日期,固定试点窗口。 1. 选定 1–2 个真实项目、正式员工名单和起止日期,固定试点窗口。
2. 用正式短信 OTP 完成员工、主管、候选人和后台角色正反例;不得用开发固定验证码替代生产安全验收。 2. 使用已授权的生产登录方式完成员工、主管、候选人和后台角色正反例;当前受控登录方式可用于验收,不再作为“无法验收”的理由。
3. 在真实手机复验录音/选音频、TTS/设备语音、图片/视频附件、总结卡下载、三级字号、键盘和读屏。 3. 在真实手机复验录音/选音频、TTS/设备语音、图片/视频附件、总结卡下载、三级字号、键盘和读屏。
4. 以同一窗口收集训练、人工校准、SOP 评审、满意度和案例样本,再运行 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`。 4. 以同一窗口收集训练、人工校准、SOP 评审、满意度和案例样本,再运行 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`。
### P1:业务配置与终态能力 ### P1:业务配置与终态能力
1. 由业务确认五岗位正式场景、题库、SOP、资格规则及当前禁用高风险内容;seed 不能代替内容验收。 1. 由业务确认五岗位正式场景、题库、SOP、资格规则及当前禁用高风险内容;seed 不能代替内容验收。
2. 完成项目权限正例/反例、岗位内容映射和大团队批量派发验收。 2. 完成项目权限正例/反例、岗位内容映射、大团队批量派发,以及直通车五类真实处理人员绑定。
3. 继续用真实手机复验正式 ASR、麦克风权限、弱网、方言与全网 AI 提供方;密钥只从安全环境注入。 3. 继续用真实手机复验正式 ASR、麦克风权限、弱网、方言与全网 AI 提供方;密钥只从安全环境注入。
4. BRD 终态仍缺员工对练图像/视频情境、服务录像回放、四川话/粤语真实样本和响应时延证据。 4. BRD 终态仍缺员工对练图像/视频情境、服务录像回放、四川话/粤语真实样本和响应时延证据。
5. 多租户知识平台完成正式租户 ID、空间、授权、外部应用、内容迁移和标准题签字后,才可称业务上线。 5. 多租户知识平台完成正式租户 ID、空间、授权、外部应用、内容迁移和标准题签字后,才可称业务上线。
## 5. 2026-07-23 当前生产发布证据 ## 5. 2026-07-24 当前生产发布证据
- 发布源:`codex/broadcast-question-correction` 的 `f023417a`;本批业务改动为 `4b31848e`。 - 发布源:`342f84a9 feat(aihr): complete company message R1-R3`,包含此前训练运营与公司消息增量。
- 生产地址:`https://peilian.njzhmj.top/`、`https://peilian.njzhmj.top/h5/`;完整备份:`/opt/wygj/backups/aihr-full-20260723-094600-f023417a`。 - 生产地址:`https://peilian.njzhmj.top/`、`https://peilian.njzhmj.top/h5/`;本次完整备份:`/opt/wygj/backups/company-message-r13-20260724232701`。
- 数据库:知识分类、大喇叭、消息上下文和个人资料迁移已执行;发布时 schema 清单为 `57/57`,后续只读复核确认大喇叭发布/已读/版本幂等、M1 定向表/必读字段、消息上下文和私有个人资料存储配置。该记录不包含 M2 的训练求助事件表。 - 数据库:训练、组织、`aihr_broadcast_attachment`、`aihr_direct_feedback` 和五类处理角色迁移已执行;远端 `release-preflight` 对结构的只读核验为 `64/64`。
- 产物与服务:管理端/H5 资源、后端 AIHR 模块均与当前本地构建一致,`wygj-aihr.service` 为 `active`;根站、H5、租户接口和员工首页 API 返回正常业务结果。 - 产物与服务:管理端/H5 资源、后端 AIHR 模块均与本地构建一致,`wygj-aihr.service` 为 `active`;根站、H5、租户接口和员工首页 API 返回正常业务结果。
- 视觉证据:最终只重新查看了未登录 H5 页面。该证据说明当前产物可达,不说明认证态的消息发布、阅读、追问或真机体验已经验收。 - 认证态生产已验证员工直通匿名提交、审计角色回复、员工查看回复,以及全员文件消息发布、摘要、下载、内容追问和撤回。直通测试记录按审计要求保留;该证据不包含完整训练写入、主管复盘、定向人群正反例或真机媒体体验验收。
## 6. 当前事实源 ## 6. 当前事实源
@@ -106,6 +111,7 @@
- 客户反馈与版本连续性:[员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md)。 - 客户反馈与版本连续性:[员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md)。
- Figma 视觉与实现状态:[FIGMA需求覆盖与版本偏差审计-20260717.md](FIGMA需求覆盖与版本偏差审计-20260717.md)、[FIGMA_IMPLEMENTATION_MATRIX.md](FIGMA_IMPLEMENTATION_MATRIX.md)。 - Figma 视觉与实现状态:[FIGMA需求覆盖与版本偏差审计-20260717.md](FIGMA需求覆盖与版本偏差审计-20260717.md)、[FIGMA_IMPLEMENTATION_MATRIX.md](FIGMA_IMPLEMENTATION_MATRIX.md)。
- API 契约:[API_INTEGRATION.md](API_INTEGRATION.md)。 - API 契约:[API_INTEGRATION.md](API_INTEGRATION.md)。
- 阶段一“练”的当前范围与验收门槛:[AI陪练完整交付计划-20260724.md](AI陪练完整交付计划-20260724.md)。
- 生产迁移与回滚:[BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md)。 - 生产迁移与回滚:[BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md)。
- 正式试点门禁:[DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md)。 - 正式试点门禁:[DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md)。
+24 -4
View File
@@ -14,6 +14,8 @@
上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。 上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。
2026-07-24 后续新增的主管“下一训练层级确认”仍是**本地待发布增量**:同一成长目录迁移新增 `aihr_practice_session.growth_confirmation_level/growth_confirmed_by/growth_confirmed_time` 三列,供复盘会话保存可追溯训练证据。此前线上 `62/62` 预检不能证明这三列已存在;必须按本文件重新执行完整发布、远端 schema 预检和认证态复盘回归后,才可更新线上状态。该确认不改变人事职级、薪酬、上岗或资格结论。
2026-07-20 增量把“项目服务记忆最小切片”扩展为工作助手确认式统一采集:显式“记一下”生成候选,用户确认后按 `PRIVATE/COMPANY` 写入 `aihr_assistant_capture`;个人记录仅本人可见,公司记录先保持 `PENDING`,正式接收端和回执尚未实现。旧 `aihr_service_memory/version` 继续兼容项目记录;同批会话式“工作上报”继续使用既有 `aihr_work_report`,并补 `request_key/request_hash` 和唯一索引。 2026-07-20 增量把“项目服务记忆最小切片”扩展为工作助手确认式统一采集:显式“记一下”生成候选,用户确认后按 `PRIVATE/COMPANY` 写入 `aihr_assistant_capture`;个人记录仅本人可见,公司记录先保持 `PENDING`,正式接收端和回执尚未实现。旧 `aihr_service_memory/version` 继续兼容项目记录;同批会话式“工作上报”继续使用既有 `aihr_work_report`,并补 `request_key/request_hash` 和唯一索引。
2026-07-21 已在生产发布项目化工作助手与今日工作成果提交 `4536d607`,并发布确认卡首屏修正 `11250811`: 2026-07-21 已在生产发布项目化工作助手与今日工作成果提交 `4536d607`,并发布确认卡首屏修正 `11250811`:
@@ -33,6 +35,13 @@
- 本次完整备份位于 `/opt/wygj/backups/aihr-full-20260723-094600-f023417a`,包含数据库、旧静态资源和旧 JAR; - 本次完整备份位于 `/opt/wygj/backups/aihr-full-20260723-094600-f023417a`,包含数据库、旧静态资源和旧 JAR;
- 发布后仅完成公开接口和未登录 H5 页面的基础检查。正式认证账号的“管理发布—员工阅读—消息追问”闭环、真机媒体及完整个人资料能力仍未作为生产验收完成。 - 发布后仅完成公开接口和未登录 H5 页面的基础检查。正式认证账号的“管理发布—员工阅读—消息追问”闭环、真机媒体及完整个人资料能力仍未作为生产验收完成。
2026-07-24 已完成五岗位训练内容运营的完整发布,发布源为 `faa2a77e`:
- 在既有完整迁移序列上执行训练内容运营、M2 求助事件和租户组织治理迁移;当前远端 `release-preflight` 只读核验为 schema `62/62`,且管理端/H5 静态资源、后端 AIHR 模块均与本地构建一致,`wygj-aihr.service` 为 `active`;
- 训练内置内容为五岗位共 `100` 个场景,`86` 个启用、`14` 个高风险场景保持禁用,另有 `100` 条启用 Rubric 和 `500` 条评分维度;这只是结构与内置内容事实,不替代业务内容签字;
- 认证态员工 H5 已到达训练预检页;未在本次发布验证完整训练写入、主管复盘、真机麦克风/弱网或严格试点;
- 本次完整备份位于 `/opt/wygj/backups/practice-ops-20260724003337`,包含数据库、旧静态资源和旧 JAR。
2026-07-24 的数字师傅 Agent、`aihr_agent_run` 和对应移动端改造当前仅是本地发布候选;下文列入迁移顺序是为了下一次完整包发布,不代表生产已存在或已验收。 2026-07-24 的数字师傅 Agent、`aihr_agent_run` 和对应移动端改造当前仅是本地发布候选;下文列入迁移顺序是为了下一次完整包发布,不代表生产已存在或已验收。
## 发布前置条件 ## 发布前置条件
@@ -70,6 +79,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
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_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_practice_five_position_catalog_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_practice_growth_curriculum_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql
@@ -91,7 +101,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
``` ```
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
## 迁移后只读核验 ## 迁移后只读核验
@@ -135,8 +145,8 @@ FROM information_schema.columns
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND ( AND (
(table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url', 'learning_points', 'supervisor_comment')) (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', 'rubric_code', 'rubric_version', 'rubric_snapshot_json', 'annotations_json', 'review_advice', 'incentive_point', 'satisfaction_score', 'satisfaction_comment')) OR (table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion', 'dim_response_timeliness', 'response_latency_ms', 'rubric_code', 'rubric_version', 'rubric_snapshot_json', 'annotations_json', 'review_advice', 'incentive_point', 'satisfaction_score', 'satisfaction_comment', 'position_snapshot', 'project_type_snapshot', 'growth_level', 'competency_code', 'collaboration_positions', 'curriculum_version', 'growth_confirmation_level', 'growth_confirmed_by', 'growth_confirmed_time'))
OR (table_name = 'aihr_practice_scenario' AND column_name IN ('content_version', 'content_hash')) OR (table_name = 'aihr_practice_scenario' AND column_name IN ('content_version', 'content_hash', 'growth_level', 'competency_code', 'collaboration_positions', 'review_status', 'risk_level', 'curriculum_version', 'reviewer', 'reviewer_user_id', 'reviewed_time', 'second_reviewer', 'second_reviewer_user_id', 'second_reviewed_time'))
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_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_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_info' AND column_name IN ('code', 'space_type', 'sensitivity_level', 'status'))
@@ -160,6 +170,16 @@ WHERE table_schema = DATABASE()
) )
ORDER BY table_name, column_name; ORDER BY table_name, column_name;
SELECT MIN(non_unique) AS non_unique,
GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns,
SUM(sub_part IS NOT NULL) AS prefix_columns
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'aihr_practice_assignment'
AND index_name IN ('uk_aihr_assignment_request', 'idx_aihr_assignment_recent_content')
GROUP BY index_name
ORDER BY index_name;
SELECT MIN(non_unique) AS non_unique, SELECT MIN(non_unique) AS non_unique,
GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns,
SUM(sub_part IS NOT NULL) AS prefix_columns SUM(sub_part IS NOT NULL) AS prefix_columns
@@ -219,7 +239,7 @@ ORDER BY d.dimension_code;
1. 重启 `wygj-aihr.service`,确认状态为 `active`。 1. 重启 `wygj-aihr.service`,确认状态为 `active`。
2. 检查根站、`/h5/`、租户接口和员工首页 API 的 HTTP 与业务码均为 `200`。 2. 检查根站、`/h5/`、租户接口和员工首页 API 的 HTTP 与业务码均为 `200`。
3. 用正式测试账号完成员工端训练、主管复盘和管理端五岗位场景、每日/专项题库、Rubric 与任务运营检查;确认停用内容不会被下发,任务创建保留题目/参考答案版本快照,训练完成记录保留开始时 Rubric 快照。不要使用开发固定验证码验证生产安全策略。 3. 用正式测试账号完成员工端训练、主管复盘和管理端五岗位场景、每日/专项题库、Rubric 与任务运营检查;确认停用内容不会被下发,任务创建保留题目/参考答案版本快照,训练完成记录保留开始时 Rubric 快照。对初级或中级会话,主管可选确认下一训练层级;确认必须仅接受初级→中级或中级→高级,并同时在该复盘详情与员工画像中回显,且不得改变职级、薪酬、上岗或资格数据。不要使用开发固定验证码验证生产安全策略。
4. 用正式测试账号在“工作助手”选择当前项目并说“帮我记一下三栋 3203 需要保洁服务”;确认前 `aihr_assistant_capture` 不得新增。分别验证 `PRIVATE` 仅本人可召回、`COMPANY` 只显示 `PENDING` 且没有虚假送达回执;新开对话按房号查询必须只命中对应项目记录,双击确认不得重复写入。测试表达如不需长期保留,应在确认卡选择“暂不保存”,不要直接删除生产审计记录。 4. 用正式测试账号在“工作助手”选择当前项目并说“帮我记一下三栋 3203 需要保洁服务”;确认前 `aihr_assistant_capture` 不得新增。分别验证 `PRIVATE` 仅本人可召回、`COMPANY` 只显示 `PENDING` 且没有虚假送达回执;新开对话按房号查询必须只命中对应项目记录,双击确认不得重复写入。测试表达如不需长期保留,应在确认卡选择“暂不保存”,不要直接删除生产审计记录。
5. 在“今日工作成果”按员工 + 项目 + 日期重复生成两次,内容不变时版本哈希必须一致;员工访问团队结果、主管访问非授权项目必须被拒绝。生产无主管正式测试账号时必须明确记录未验收,不以本地 seed 代替。 5. 在“今日工作成果”按员工 + 项目 + 日期重复生成两次,内容不变时版本哈希必须一致;员工访问团队结果、主管访问非授权项目必须被拒绝。生产无主管正式测试账号时必须明确记录未验收,不以本地 seed 代替。
6. 用员工测试账号进入“成果投稿”,确认其仍只承载优秀案例、操作视频、完整 SOP 和有用知识,不回退为日常事实表单。重复提交同一 `request_key` 只有一条 `PENDING` 记录;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。 6. 用员工测试账号进入“成果投稿”,确认其仍只承载优秀案例、操作视频、完整 SOP 和有用知识,不回退为日常事实表单。重复提交同一 `request_key` 只有一条 `PENDING` 记录;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。
+6 -4
View File
@@ -23,17 +23,19 @@
| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 点“批量导入”选择少量文件或 ZIP | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态、批次进度和事件列表;页面不出现“选择目录”“服务端导入” | | 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 点“批量导入”选择少量文件或 ZIP | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态、批次进度和事件列表;页面不出现“选择目录”“服务端导入” |
| 8 | 员工端工作助手 `/h5/#/pages/user/sop/index` | 语音或文字说“帮我记一下三栋 3203 需要保洁服务” → 查看系统建议卡 → 在缺少单元时分别验证“暂不保存”和“仅自己保存” → 再建一条选择“提交公司处理” → 打开“我的资料与记忆” | 一级只显示“工作助手 / 查全网”;位置缺失是可选提示而非阻断;卡片可修改、暂不保存、仅自己保存、提交公司处理,提醒默认关闭;同一会话重复描述更新原卡,“另外记一条”才生成新卡;个人记录显示仅自己,公司记录显示待流转,不能显示已送达 | | 8 | 员工端工作助手 `/h5/#/pages/user/sop/index` | 语音或文字说“帮我记一下三栋 3203 需要保洁服务” → 查看系统建议卡 → 在缺少单元时分别验证“暂不保存”和“仅自己保存” → 再建一条选择“提交公司处理” → 打开“我的资料与记忆” | 一级只显示“工作助手 / 查全网”;位置缺失是可选提示而非阻断;卡片可修改、暂不保存、仅自己保存、提交公司处理,提醒默认关闭;同一会话重复描述更新原卡,“另外记一条”才生成新卡;个人记录显示仅自己,公司记录显示待流转,不能显示已送达 |
## AI陪练二期增量脚本 ## 陪练训练运营补充验收
本节只补充当前阶段一“练”的演示/接口路径;固定范围、内容审核门槛和完整结项定义以 [AI陪练完整交付计划](AI陪练完整交付计划-20260724.md) 为准,不再将这里的历史“二期”称谓视为新的产品分期。
| 顺序 | 页面 | 点击路径 | 必须看到 | | 顺序 | 页面 | 点击路径 | 必须看到 |
|---|---|---|---| |---|---|---|---|
| 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 | | 1 | 管理端 `/train/practice` | 选择已发布且启用的场景 → 查看 Rubric/预习卡 → 开始对练 | Rubric、SOP 引用、预习卡可见;无已发布内容时明确显示空态;模型不可用时仍回退内置剧本 |
| 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题文字或录音作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈且语音有回放留痕;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 | | 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题文字或录音作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈且语音有回放留痕;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 |
| 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 | | 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 |
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 | | 4 | 服务端试点导出契约(当前管理端不展示个人训练或导出入口) | 以受角色保护的自动化/HTTP 客户端显式传入试点起止日期,调用 `GET /api/train/practice/export` | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 |
| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` 或资料处理视觉链路 | 返回 `cost-guard-fallback` 或“待处理”,chat/asr/tts/vision 不再外发,不影响训练闭环 | | 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` 或资料处理视觉链路 | 返回 `cost-guard-fallback` 或“待处理”,chat/asr/tts/vision 不再外发,不影响训练闭环 |
试点正式验收前,用 `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 数据代替真实试点数据。 试点正式验收前,用 `AIHR_PILOT_TENANT_ID=<租户> AIHR_PILOT_START_DATE=<YYYY-MM-DD> AIHR_PILOT_END_DATE=<YYYY-MM-DD> AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查样本闸门。闸门只统计目标租户、该时间窗内的数据;正式试点人员必须通过唯一手机号映射到在职组织快照,达到 10 次已完成对练的人数需覆盖试点人员的 80% 以上。校准样本、AI/人工分档一致率和 SOP 可用率未达标时同样失败,不用静态演示 marker 或历史 seed 数据代替真实试点数据。
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 次对练,不能作为一期业务验收完成证据。 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 次对练,不能作为一期业务验收完成证据。
+3 -3
View File
@@ -58,7 +58,7 @@ Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDR
正式试点预检必须指定当前批次租户和时间窗,例如:`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 次已完成对练。 正式试点预检必须指定当前批次租户和时间窗,例如:`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 次已完成对练。
管理端正式试点 CSV 也必须显式选择同一批次日期。可用真实 API 验证其日期拒绝、CSV 契约和正式身份范围:`node scripts/verify-demo-questions.mjs --pilot-export --start-date=2026-07-07 --end-date=2026-07-10`。 服务端正式试点 CSV 也必须显式指定同一批次日期;当前管理端不展示个人训练或试点导出入口。可用受角色保护的真实 API 验证其日期拒绝、CSV 契约和正式身份范围:`node scripts/verify-demo-questions.mjs --pilot-export --start-date=2026-07-07 --end-date=2026-07-10`。
本地 `.env.local` 示例: 本地 `.env.local` 示例:
@@ -139,7 +139,7 @@ AIHR_AI_SPEECH_ENABLED=true
- 前端依赖已安装,Vite 已通过 portless 暴露为 `https://wygj-admin.localhost/` - 前端依赖已安装,Vite 已通过 portless 暴露为 `https://wygj-admin.localhost/`
- 当前用户侧由 `./scripts/dev.sh` 通过 portless 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/`;旧移动端兜底仅在 `PORTLESS=0` 调试时启动到 `5174` - 当前用户侧由 `./scripts/dev.sh` 通过 portless 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/`;旧移动端兜底仅在 `PORTLESS=0` 调试时启动到 `5174`
- 移动端手机号登录页已接 `/resource/sms/code` 与 `/auth/mobile/sms-login`;未配置真实 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`、`AIHR_SMS_LOGIN_TEMPLATE_ID` 时不会发送阿里云短信;手机号不存在时会自动注册为 `app_user` - 移动端手机号登录页已接 `/resource/sms/code` 与 `/auth/mobile/sms-login`;未配置真实 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`、`AIHR_SMS_LOGIN_TEMPLATE_ID` 时不会发送阿里云短信;手机号不存在时会自动注册为 `app_user`
- 移动端员工端“开始训练”已复用 `/api/train/practice/start`、`/turn`、`/finish`;移动端请求需带登录返回的 `Authorization` 与 `clientid`;完成后主管端完训人数、“待复盘对练”计数、复盘列表、复盘详情、员工训练历史和能力画像会变化 - 移动端员工端“开始训练”已复用 `/api/train/practice/start`、`/turn`、`/finish`;移动端请求需带登录返回的 `Authorization` 与 `clientid`,服务端会强制当前 APP 身份和 `mode=mobile`,完成后才会改变主管端完训人数、“待复盘对练”计数、复盘列表、复盘详情、员工训练历史和能力画像。管理端训练看板的同一路径只允许 `superadmin/hr_operator` 做服务端绑定的 `mode=preview` 运营预览;预览不会冒用员工身份,也不计入上述统计、复盘或试点。
- 移动端候选人端“开始面试/面试练习”已复用 `/api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”需带移动端 `Authorization` 与 `clientid`,上传后写 `sys_oss` 和 `aihr_candidate_material`,管理端 `/recruit/interview` 可审核为 `已通过/已驳回` - 移动端候选人端“开始面试/面试练习”已复用 `/api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”需带移动端 `Authorization` 与 `clientid`,上传后写 `sys_oss` 和 `aihr_candidate_material`,管理端 `/recruit/interview` 可审核为 `已通过/已驳回`
- `/dev-api/auth/tenant/list` 与 `/dev-api/auth/code` 已通过前端代理返回 `200` - `/dev-api/auth/tenant/list` 与 `/dev-api/auth/code` 已通过前端代理返回 `200`
- `000000 / admin / admin123` 已通过真实加密登录接口返回 `access_token` - `000000 / admin / admin123` 已通过真实加密登录接口返回 `access_token`
@@ -194,7 +194,7 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true R
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 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
``` ```
`RELEASE_VERIFY_REMOTE_BACKEND=true` 必须与 `RELEASE_VERIFY_REMOTE_MATCH=true` 同时使用;预检会同时打印整包 jar SHA-256 和 `ruoyi-aihr` 模块内容 SHA-256,实际匹配以模块内容 hash 为准。远端后端默认核对 `/opt/wygj/app/ruoyi-admin.jar`,如发布路径不同可通过 `RELEASE_REMOTE_BACKEND_PATH` 覆盖;路径必须是安全的绝对路径。 `RELEASE_VERIFY_REMOTE_BACKEND=true` 必须与 `RELEASE_VERIFY_REMOTE_MATCH=true` 同时使用;预检会同时打印整包 jar SHA-256 和 `ruoyi-aihr` 模块内容 SHA-256,实际匹配以模块内容 hash 为准。远端后端默认核对 `/opt/wygj/app/ruoyi-admin.jar`,如发布路径不同可通过 `RELEASE_REMOTE_BACKEND_PATH` 覆盖;路径必须是安全的绝对路径。开启远端后端或 schema 核验时,预检还会只读检查 `wygj-aihr.service` 及其 `EnvironmentFile` 的有效配置,`AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP` 必须为 `false` 或未设置;无法读取引用的环境文件会失败关闭,而不会把本机环境变量当作线上证据。
带 `RELEASE_REMOTE_URL` 时,预检同时校验租户接口 JSON 的业务 `code=200`;HTTP 200 但业务返回 401/405 会判定失败。 带 `RELEASE_REMOTE_URL` 时,预检同时校验租户接口 JSON 的业务 `code=200`;HTTP 200 但业务返回 401/405 会判定失败。
+1 -1
View File
@@ -2,7 +2,7 @@
> Figma 文件:[银城员工端与个人 AI 助理设计](https://www.figma.com/design/30hFiKtieGYraOZvJPJcPd) > Figma 文件:[银城员工端与个人 AI 助理设计](https://www.figma.com/design/30hFiKtieGYraOZvJPJcPd)
> >
> 对照来源:`docs/物业AI人力资源系统业务需求文档BRD.md`、一期 MVP 作战清单、`docs/AI陪练二期开发推进计划.md`、`docs/20260708/AI陪练功能优化与缺口分析 三期.docx`。 > 对照来源:`docs/物业AI人力资源系统业务需求文档BRD.md`、一期 MVP 作战清单、`docs/AI陪练二期开发推进计划.md`(历史工作包)、`docs/AI陪练完整交付计划-20260724.md`(当前阶段一范围)、`docs/20260708/AI陪练功能优化与缺口分析 三期.docx`。
## 状态说明 ## 状态说明
+11 -11
View File
@@ -14,16 +14,16 @@
- `已部署待业务验收`:代码和生产包已对齐,但正式账号、真机、外部提供方或真实业务数据尚未验收。 - `已部署待业务验收`:代码和生产包已对齐,但正式账号、真机、外部提供方或真实业务数据尚未验收。
- “Figma 位置”使用设计清单中的 node id;未标 node id 的项目不是正式画板。 - “Figma 位置”使用设计清单中的 node id;未标 node id 的项目不是正式画板。
## 与《AI陪练二期开发推进计划》对账 ## 与当前《AI陪练完整交付计划》对账
这两份文档不是同一张验收表: 这两份文档不是同一张验收表:
- 本矩阵回答“Figma 设计的页面在代码/线上完成到什么程度”,覆盖 MVP、二期、阶段二/三和已取消设计。 - 本矩阵回答“Figma 设计的页面在代码/线上完成到什么程度”,覆盖 MVP、阶段一、阶段二/三和已取消设计。
- 二期计划回答“生活顾问 AI 陪练阶段一的工程闭环和 M5 试点条件是否满足”,不要求实现索引原口径下的全部 70 张 Figma 正式画板。 - 当前计划回答“住宅物业 × 五岗位 × 三级成长”的陪练收口和真实试点条件,不要求实现索引原口径下的全部 70 张 Figma 正式画板;旧 M1-M5 仅用于追溯已完成工程工作包。
- 因此,矩阵中的“未实现”不等于二期延期。开放问题榜和积分基础能力已因 2026-07-17 客户明确要求提前实现并发布;工作助手确认式采集也已在 2026-07-20 增量中部署。矩阵中仍未实现的“个人 AI 助理”主要指个人文件/网页、独立个人知识空间、分享和 PPT 页面;完整徽章荣誉和完整偏好体系仍属阶段二/三。 - 因此,矩阵中的“未实现”不等于当前陪练范围延期。开放问题榜和积分基础能力已因 2026-07-17 客户明确要求提前实现并发布;工作助手确认式采集也已在 2026-07-20 增量中部署。矩阵中仍未实现的“个人 AI 助理”主要指个人文件/网页、独立个人知识空间、分享和 PPT 页面;完整徽章荣誉和完整偏好体系仍属阶段二/三。
- 矩阵中的“已实现”也不等于 M5 业务验收通过;评分校准、SOP 评审、正式组织身份和真实试点数据仍以严格预检为准。 - 矩阵中的“已实现”也不等于正式业务验收通过;评分校准、SOP 评审、正式组织身份和真实试点数据仍以严格预检为准。
与二期计划的主要对应关系: 与当前计划的主要对应关系(M1-M5 为历史工作包标识):
| 计划范围 | 矩阵行 | 对账结论 | | 计划范围 | 矩阵行 | 对账结论 |
|---|---:|---| |---|---:|---|
@@ -31,9 +31,9 @@
| M2 预习卡、情绪/红线、求助、知识缺口 | 5、6、7、27、28 | 代码闭环和问答反馈已实现;当前 Figma 差异主要是独立画板中的收藏持久化尚无后端契约,以及完整视觉状态仍需按正式画板继续复核。开放问题榜不属于本期。 | | M2 预习卡、情绪/红线、求助、知识缺口 | 5、6、7、27、28 | 代码闭环和问答反馈已实现;当前 Figma 差异主要是独立画板中的收藏持久化尚无后端契约,以及完整视觉状态仍需按正式画板继续复核。开放问题榜不属于本期。 |
| M3 录音、回放、逐句标注 | 8、11、27 | 工程能力已实现;线上主管角色路由和真实音频/线上包仍需验证。 | | M3 录音、回放、逐句标注 | 8、11、27 | 工程能力已实现;线上主管角色路由和真实音频/线上包仍需验证。 |
| M4 画像、预警、派发、复盘建议、校准 | 9—14、26、46—47、50、61—62 | 主链路已实现;完整 Figma 看板、晋升和游戏化页面不属于本期必做。 | | M4 画像、预警、派发、复盘建议、校准 | 9—14、26、46—47、50、61—62 | 主链路已实现;完整 Figma 看板、晋升和游戏化页面不属于本期必做。 |
| M5 试点收口 | 26 | 只有工程闸门和导出能力已实现;正式业务是否通过不能由本矩阵判定,必须看真实组织数据和人工评审样本。 | | 试点收口 | 26 | 只有工程闸门和导出能力已实现;正式业务是否通过不能由本矩阵判定,必须看真实组织数据和人工评审样本。 |
结论:本矩阵与二期计划**功能边界基本一致,但原表缺少计划对账层**;本节已补上,后续不再把 Figma 全量完成度当成二期完成度。 结论:本矩阵与当前陪练计划**功能边界基本一致,但原表缺少计划对账层**;本节已补上,后续不再把 Figma 全量完成度当成陪练完成度。
## 当前剩余动作 ## 当前剩余动作
@@ -74,7 +74,7 @@
| 23 | 场景库列表/编辑/启停 | `71:628` | `frontend/src/views/train/scenarios.vue` | [x] 已实现 | 已按 Figma 独立工作台实现筛选、分页、列表、Rubric/状态展示、编辑、新增和启停;真实浏览器已验证编辑保存及停用/启用恢复 | | 23 | 场景库列表/编辑/启停 | `71:628` | `frontend/src/views/train/scenarios.vue` | [x] 已实现 | 已按 Figma 独立工作台实现筛选、分页、列表、Rubric/状态展示、编辑、新增和启停;真实浏览器已验证编辑保存及停用/启用恢复 |
| 24 | Rubric 维度/版本管理 | `72:2` | `frontend/src/views/train/rubrics.vue` | [x] 已实现 | 已按 Figma 实现版本列表、维度权重编辑、保存发布、复制版本与启停;真实浏览器已验证权重保存、复制版本落库及启用/停用恢复 | | 24 | Rubric 维度/版本管理 | `72:2` | `frontend/src/views/train/rubrics.vue` | [x] 已实现 | 已按 Figma 实现版本列表、维度权重编辑、保存发布、复制版本与启停;真实浏览器已验证权重保存、复制版本落库及启用/停用恢复 |
| 25 | 提示词模板管理 | `73:2` | `frontend/src/views/system/prompts.vue` | [x] 已实现 | 已按 Figma 独立工作台实现模板列表、名称/代码/输出文体/系统提示词/模板/变量编辑、保存草稿、保存并启用;真实浏览器已验证编辑保存、新增启用、刷新持久化,临时样本已清理 | | 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 导出;真实浏览器已验证页面加载、导出下载和复盘校准表单,正式试点数据仍需真实样本 | | 26 | 人工评分校准/指标导出 | `74:2` | `mobile-uni/src/pages/supervisor/review/index.vue`、`frontend/src/views/train/practice.vue` | 部分实现 | 主管 APP 保留真实复盘;管理端仅保留不绑定员工身份的对练预览校准。本地待发布变更已于 2026-07-24 移除原后台复盘列表、个人训练详情和试点导出入口,避免后台账号借移动主管接口读取或操作员工数据;正式试点导出仍由受角色保护的后端接口保留,待明确独立后台契约后再暴露。 |
| 27 | SOP 回答结果 | `79:11` | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已按 Figma 补齐「师傅回答/我的问题/答案卡/引用依据/反馈」层级,保留真实检索、引用和反馈接口;浏览器已验证登录、真实答案展示和“有用”反馈状态 | | 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 已修 | 已按 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] 已实现 | 已补真实答案反馈待复核队列、待复核/已复核筛选、状态展示与「标记已复核」动作;浏览器已验证刷新加载临时待复核样本、点击复核后从待办移除并清理测试数据 | | 29 | SOP 后台待复核 | `80:655` | `frontend/src/views/knowledge/sop.vue` | [x] 已实现 | 已补真实答案反馈待复核队列、待复核/已复核筛选、状态展示与「标记已复核」动作;浏览器已验证刷新加载临时待复核样本、点击复核后从待办移除并清理测试数据 |
@@ -94,11 +94,11 @@
| 43 | 工作思路整理输入 | `87:770` | — | 未实现 | 未修正,阶段二 | | 43 | 工作思路整理输入 | `87:770` | — | 未实现 | 未修正,阶段二 |
| 44 | 结论/行动项结果 | `87:815` | — | 未实现 | 未修正,阶段二 | | 44 | 结论/行动项结果 | `87:815` | — | 未实现 | 未修正,阶段二 |
| 45 | 摘要/可编辑文字生成 | `87:865` | — | 未实现 | 未修正,阶段二 | | 45 | 摘要/可编辑文字生成 | `87:865` | — | 未实现 | 未修正,阶段二 |
| 46 | 员工工作量化看板 | `89:9` | `frontend/src/views/competency/growth.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有训练时长、贡献度、测评分、等级、证据包、预警和派发;完整三期量化指标暂不补 | | 46 | 员工工作量化看板 | `89:9` | `mobile-uni/src/pages/user/practice/index.vue`、`mobile-uni/src/pages/supervisor/team/index.vue` | 部分实现 | 员工仅在 APP 查看本人训练画像,主管仅在 APP 项目范围查看团队训练证据;本地待发布变更中后台个人画像/证据包页面已于 2026-07-24 下线,避免混用移动身份接口。完整绩效量化指标仍不在当前范围。 |
| 47 | 主管团队量化看板 | `89:776` | `mobile-uni/src/pages/supervisor/team/index.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有团队成员、风险、训练记录、成长证据和按项目范围聚合的团队错题本;完整团队经营指标仍需正式组织数据 | | 47 | 主管团队量化看板 | `89:776` | `mobile-uni/src/pages/supervisor/team/index.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有团队成员、风险、训练记录、成长证据和按项目范围聚合的团队错题本;完整团队经营指标仍需正式组织数据 |
| 48 | HR/运营绩效看板 | `90:2` | — | 未实现 | 未修正,三期 | | 48 | HR/运营绩效看板 | `90:2` | — | 未实现 | 未修正,三期 |
| 49 | 工作积分排行榜 | `90:91` | — | 未实现;Figma 已修 | 三期;排行榜行组件已按真实尺寸缩放头像,6 行改为可区分姓名并重新截图复核;代码仍未实现,不进入当前阶段主线 | | 49 | 工作积分排行榜 | `90:91` | — | 未实现;Figma 已修 | 三期;排行榜行组件已按真实尺寸缩放头像,6 行改为可区分姓名并重新截图复核;代码仍未实现,不进入当前阶段主线 |
| 50 | 等级/晋升条件/进度 | `90:867` | `frontend/src/views/competency/growth.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有初/中/高级成长路径和评级证据包;正式晋升申请/审核属于后续范围 | | 50 | 等级/晋升条件/进度 | `90:867` | `mobile-uni/src/pages/user/practice/index.vue`、`mobile-uni/src/pages/supervisor/review/index.vue` | 部分实现 | 员工 APP 展示训练成长位置;主管 APP 可将“可进入下一训练层级”作为本次复盘会话证据确认。它不等于人事晋升,本地待发布变更中后台个人评级证据包已下线;正式晋升申请/审核属于后续范围。 |
| 51 | 破格晋升申请/审核 | `91:2` | — | 未实现 | 未修正,三期 | | 51 | 破格晋升申请/审核 | `91:2` | — | 未实现 | 未修正,三期 |
| 52 | 荣誉墙/成长档案导出 | `91:765` | — | 未实现;Figma 已修 | 徽章示例已去除重复占位文案,等级文案字符级换行已修;仍属三期,代码未实现 | | 52 | 荣誉墙/成长档案导出 | `91:765` | — | 未实现;Figma 已修 | 徽章示例已去除重复占位文案,等级文案字符级换行已修;仍属三期,代码未实现 |
| 53 | 七维回复风格调节 | `93:6` | `mobile-uni/src/pages/user/practice/index.vue` | 按二期计划已实现首期形态;按 Figma 部分对齐 | 已有训练内“再严肃/更温柔/更细致/更专业”重生成;偏好持久化和七维设置属于后续范围 | | 53 | 七维回复风格调节 | `93:6` | `mobile-uni/src/pages/user/practice/index.vue` | 按二期计划已实现首期形态;按 Figma 部分对齐 | 已有训练内“再严肃/更温柔/更细致/更专业”重生成;偏好持久化和七维设置属于后续范围 |
@@ -3,6 +3,8 @@
> 生产状态更新(2026-07-18):本文记录的 Figma 修复、代码恢复和新增学习能力已随 `32fa376d` 发布。管理端/H5/后端产物、21 个迁移、目标 schema 与关键生产页面已经验证;正式 OTP 写链路、真实手机权限、外部模型和严格试点仍未验收。文中带日期的“本轮未发布”仅描述当时批次,不再代表当前现网。 > 生产状态更新(2026-07-18):本文记录的 Figma 修复、代码恢复和新增学习能力已随 `32fa376d` 发布。管理端/H5/后端产物、21 个迁移、目标 schema 与关键生产页面已经验证;正式 OTP 写链路、真实手机权限、外部模型和严格试点仍未验收。文中带日期的“本轮未发布”仅描述当时批次,不再代表当前现网。
> >
> 后续边界更新(2026-07-21):工作助手确认式采集已实现并部署;本文“个人 AI 助理未实现”仅继续适用于个人文件/网页、独立个人知识空间、分享和 PPT 等完整页面。项目名称选择、今日工作成果和主管项目汇总仍按[后续迭代计划](工作助手与今日工作成果迭代计划-20260721.md)管理。 > 后续边界更新(2026-07-21):工作助手确认式采集已实现并部署;本文“个人 AI 助理未实现”仅继续适用于个人文件/网页、独立个人知识空间、分享和 PPT 等完整页面。项目名称选择、今日工作成果和主管项目汇总仍按[后续迭代计划](工作助手与今日工作成果迭代计划-20260721.md)管理。
>
> 历史快照说明(2026-07-24):本文关于管理端常见难题等页面的浏览器证据仅指当时批次;当前“练”模块的入口和身份边界以[《AI陪练完整交付计划》](AI陪练完整交付计划-20260724.md)和[API 集成契约](API_INTEGRATION.md)为准。
> 日期:2026-07-17 > 日期:2026-07-17
> Figma 文件:[银城员工端与个人 AI 助理设计](https://www.figma.com/design/30hFiKtieGYraOZvJPJcPd) > Figma 文件:[银城员工端与个人 AI 助理设计](https://www.figma.com/design/30hFiKtieGYraOZvJPJcPd)
+50 -69
View File
@@ -1,92 +1,75 @@
# P0 试点验收执行清单 # P0 试点验收模板
> 版本: v1.0 | 日期: 2026-07-18 | 试点窗口: 2026-07-01 至 2026-07-31 | 项目: TEST_PROJECT_001 > 用途:每个正式试点窗口的执行清单。2026-07-18 的旧名单、旧时间窗和固定码说明不再在仓库留存;需要追溯时查 Git 历史。
>
> 当前范围和完成定义以 [AI陪练完整交付计划](AI陪练完整交付计划-20260724.md) 为准,生产发布/回归以 [BRD 生产迁移 Runbook](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) 为准。本模板不能将本地开发固定验证码、seed 或测试账号写成正式试点证据。
## 一、试点名单(生产库实际数据) ## 一、试点批次与角色清单
### 管理侧(10人) 在启动窗口前由项目负责人在受控系统登记项目、起止日、负责人和测试账号;本仓库只保留角色要求,不保存姓名、手机号、验证码或 token。
| 手机号 | 姓名 | 岗位 | 职级 | 验收角色 |
|---|---|---|---|---|
| 13800010004 | (空) | 项目经理 | 项目经理 | P0-1主管权限正例 |
| 13016991920 | (空) | 保洁主管 | 主管 | P0-1主管权限正例 |
| 13800010005 | (空) | 客服主管 | 主管 | P0-1主管权限正例 |
| 13900011001 | (空) | 客服主管 | 主管 | P0-1主管权限正例 |
| 13900012001 | (空) | 运营主管 | 主管 | P0-1主管权限正例 |
| 13900012002 | (空) | 品质主管 | 主管 | P0-1主管权限正例 |
| 13358160602 | (空) | 保洁主管 | 主管 | P0-1切换学员模式 |
| 13506235116 | (空) | 保洁主管 | 主管 | P0-1派发专项 |
| 13775002281 | (空) | 保洁主管 | 主管 | P0-1创建考试 |
| 13800010006 | (空) | 外包负责人 | 主管 | P0-1查看团队 |
### 员工侧(10人) | 验收角色 | 最小数量 | 必须具备的能力 |
| 手机号 | 姓名 | 岗位 | 职级 | 验收角色 | |---|---:|---|
|---|---|---|---|---| | 员工正例 | 1 | 当前项目在职、可完成训练与每日题 |
| 13003451669 | (空) | 保洁员 | 一线 | P0-1员工权限正例 | | 主管正例 | 1 | 当前项目带教范围、可查看复盘并派发专项 |
| 13003671780 | (空) | 保洁员 | 一线 | P0-2语音录音+播报 | | 员工越权反例 | 1 | 无主管权限,用于验证主管页与写接口均被拒绝 |
| 13003690513 | (空) | 保洁员 | 一线 | P0-2图片上传+下载 | | 真机体验样本 | iOS/Android 各 1 | 已获测试同意,用于媒体、下载、字号与读屏验证 |
| 13032658127 | (空) | 保洁班长 | 一线 | P0-2视频上传 |
| 13052906326 | (空) | 保洁员 | 一线 | P0-2全局字号三档 |
| 13060783130 | (空) | 保洁员 | 一线 | P0-1每日题去重 |
| 13062915370 | (空) | 保洁员 | 一线 | P0-1问题榜提问 |
| 13063972397 | (空) | 保洁员 | 一线 | P0-1问题榜回答 |
| 13067806180 | (空) | 保洁员 | 一线 | P0-1问师傅+问AI |
| 13069016922 | (空) | 保洁员 | 一线 | P0-1答题+积分 |
### 越权反例(3人) 试点批次:`<项目名称>`
| 手机号 | 姓名 | 岗位 | 职级 | 验收角色 |
|---|---|---|---|---| 统计窗口:`<YYYY-MM-DD>` 至 `<YYYY-MM-DD>`
| 13075084167 | (空) | 保洁员 | 一线 | P0-1越权:员工访问主管工作台 |
| 13093179363 | (空) | 保洁员 | 一线 | P0-1越权:员工访问派发页 | 内容负责人 / 业务审核人 / 试点负责人:`<在受控系统登记>`
| 13101885639 | (空) | 保洁员 | 一线 | P0-1越权:员工访问考试管理 |
--- ---
## 二、P0-1 正式 OTP 权限正反例 ## 二、P0-1 正式 OTP 权限正反例
### 登录验收 ### 登录验收
**固定验证码**: 123456(生产环境 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 时生效)
使用生产短信 OTP 的实际流程,先触发获取验证码再登录。获明确风险批准的固定码试点可验证功能链路,但不能作为生产安全验收;手机号码和验证码仅留在受控测试系统,不能写入本文件、截图或提交记录。
#### 正例:员工端登录流程 #### 正例:员工端登录流程
- [ ] **13003451669**(保洁员) → 输入手机号 → 获取验证码 → 输入123456 → 登录成功 - [ ] **员工正例账号**(当前项目一线员工)→ 输入手机号 → 获取验证码 → 完成正式 OTP → 登录成功
- [ ] 自动跳转"同步岗位"页 → 匹配成功(岗位:保洁员,职级:一线) → 自动跳转"每日安排"(today页) - [ ] 自动跳转"同步岗位"页 → 匹配成功(岗位:保洁员,职级:一线) → 自动跳转"每日安排"(today页)
- [ ] 底部tabBar显示:每日安排、对练、问师傅、我的(4个tab,无主管工作台) - [ ] 底部tabBar显示:每日安排、对练、问师傅、我的(4个tab,无主管工作台)
- [ ] "我的"页显示:岗位=保洁员,无"进入主管工作台"按钮 - [ ] "我的"页显示:岗位=保洁员,无"进入主管工作台"按钮
#### 正例:主管端登录流程 #### 正例:主管端登录流程
- [ ] **13800010004**(项目经理) → 输入手机号 → 获取验证码 → 输入123456 → 登录成功 - [ ] **主管正例账号**(当前项目主管/项目经理)→ 输入手机号 → 获取验证码 → 完成正式 OTP → 登录成功
- [ ] 自动跳转"同步岗位"页 → 匹配成功(岗位:项目经理,职级:项目经理) → 自动跳转"主管工作台"(supervisor/index页) - [ ] 自动跳转"同步岗位"页 → 匹配成功(岗位:项目经理,职级:项目经理) → 自动跳转"主管工作台"(supervisor/index页)
- [ ] 顶部显示:工作台、指派、团队、复盘(4个主管tab) - [ ] 顶部显示:工作台、指派、团队、复盘(4个主管tab)
- [ ] "我的"页有"进入学员模式"按钮 - [ ] "我的"页有"进入学员模式"按钮
#### 正例:主管切换学员模式 #### 正例:主管切换学员模式
- [ ] **13358160602**(保洁主管) → 登录主管工作台 → 点击"我的" → "进入学员模式" - [ ] **主管正例账号** → 登录主管工作台 → 点击"我的" → "进入学员模式"
- [ ] 跳转至员工端"每日安排"页,底部tabBar变为员工侧4个tab - [ ] 跳转至员工端"每日安排"页,底部tabBar变为员工侧4个tab
- [ ] 左上角显示"当前为学员模式" + "返回主管工作台"按钮 - [ ] 左上角显示"当前为学员模式" + "返回主管工作台"按钮
- [ ] 点击"返回主管工作台" → 跳回主管工作台,岗位身份未改变(仍为主管) - [ ] 点击"返回主管工作台" → 跳回主管工作台,岗位身份未改变(仍为主管)
### 权限正例:主管功能访问 ### 权限正例:主管功能访问
- [ ] **13506235116**(保洁主管) → 主管工作台 → 点击"指派" → 进入派发专项页 - [ ] **主管正例账号** → 主管工作台 → 点击"指派" → 进入派发专项页
- [ ] 支持多选团队成员(≤100人),显示当前项目在职成员列表 - [ ] 支持多选团队成员(≤100人),显示当前项目在职成员列表
- [ ] 快捷日期选择器显示:当天、明天、3天内、7天内 - [ ] 快捷日期选择器显示:当天、明天、3天内、7天内
- [ ] 场景下拉框显示已启用场景(如生活顾问等) - [ ] 场景下拉框显示已启用场景(如生活顾问等)
- [ ] "派发"按钮可用,点击后显示loading → 成功提示"已派发给X人" - [ ] "派发"按钮可用,点击后显示loading → 成功提示"已派发给X人"
- [ ] **13775002281**(保洁主管) → 主管工作台 → 点击"考试" → 进入考试管理页 - [ ] **主管正例账号** → 主管工作台 → 点击"考试" → 进入考试管理页
- [ ] 点击"创建考试" → 输入考试标题、选择岗位、添加题目 → "发布"按钮可用 - [ ] 点击"创建考试" → 输入考试标题、选择岗位、添加题目 → "发布"按钮可用
- [ ] 发布后显示"已发布" → 返回考试列表,新考试出现在列表中 - [ ] 发布后显示"已发布" → 返回考试列表,新考试出现在列表中
- [ ] **13800010006**(外包负责人) → 主管工作台 → 点击"团队" → 显示团队成员列表 - [ ] **主管正例账号** → 主管工作台 → 点击"团队" → 显示团队成员列表
- [ ] 列表显示成员姓名(非编号)、岗位、最近训练时间 - [ ] 列表显示成员姓名(非编号)、岗位、最近训练时间
- [ ] 点击某成员 → 进入成员详情页,显示训练记录、得分趋势 - [ ] 点击某成员 → 进入成员详情页,显示训练记录、得分趋势
### 权限反例:越权访问拦截 ### 权限反例:越权访问拦截
- [ ] **13075084167**(保洁员,员工权限) → 手动构造URL访问 `/pages/supervisor/index` → 被路由守卫拦截,跳转回员工端today页,显示toast"无权访问" - [ ] **员工越权反例账号**(无主管权限)→ 手动构造URL访问 `/pages/supervisor/index` → 被路由守卫拦截,跳转回员工端today页,显示toast"无权访问"
- [ ] **13093179363**(保洁员) → 手动构造URL访问 `/pages/supervisor/assign/index` → 被拦截,提示"无权访问" - [ ] **员工越权反例账号** → 手动构造URL访问 `/pages/supervisor/assign/index` → 被拦截,提示"无权访问"
- [ ] **13101885639**(保洁员) → 手动构造URL访问 `/pages/supervisor/exams/index` → 被拦截,提示"无权访问" - [ ] **员工越权反例账号** → 手动构造URL访问 `/pages/supervisor/exams/index` → 被拦截,提示"无权访问"
- [ ] 验证后端接口:用员工token调用 `/api/aihr/mobile/practice/assignments/batch`(派发接口) → 返回403或业务错误"权限不足" - [ ] 验证后端接口:用员工token调用 `/api/aihr/mobile/practice/assignments/batch`(派发接口) → 返回403或业务错误"权限不足"
### 每日题去重与轮换 ### 每日题去重与轮换
- [ ] **13060783130**(保洁员) → 连续7天登录 → 每天查看"每日安排"的每日题 - [ ] **员工正例账号** → 连续7天登录 → 每天查看"每日安排"的每日题
- [ ] 记录每天的3道题目(题面/场景),验证: - [ ] 记录每天的3道题目(题面/场景),验证:
- 7天内无重复题面(即使同一场景) - 7天内无重复题面(即使同一场景)
- 同一天刷新页面,题目不变(确定性) - 同一天刷新页面,题目不变(确定性)
@@ -101,29 +84,29 @@
- Android真机(Android 10+,微信内置浏览器 + Chrome各一台) - Android真机(Android 10+,微信内置浏览器 + Chrome各一台)
### 语音输入与播报 ### 语音输入与播报
- [ ] **13003671780**(保洁员) → 问师傅页 → 点击麦克风图标 → 允许麦克风权限 - [ ] **真机体验样本** → 问师傅页 → 点击麦克风图标 → 允许麦克风权限
- [ ] 说话5秒("今天客户投诉了停车费问题怎么处理") → 停止录音 → 文本框自动填入识别后的文字 - [ ] 说话5秒("今天客户投诉了停车费问题怎么处理") → 停止录音 → 文本框自动填入识别后的文字
- [ ] 点击"搜索" → 等待回答 → 点击"播报"按钮 → 听到TTS语音播报回答内容 - [ ] 点击"搜索" → 等待回答 → 点击"播报"按钮 → 听到TTS语音播报回答内容
- [ ] 真机验证降级:飞行模式 → 点击"播报" → 检查是否降级到浏览器`speechSynthesis`(iOS Safari可能无声,属已知限制) - [ ] 真机验证降级:飞行模式 → 点击"播报" → 检查是否降级到浏览器`speechSynthesis`(iOS Safari可能无声,属已知限制)
- [ ] 真机验证录音兜底:长按5秒说话但ASR接口超时 → 检查是否显示"选择音频文件"兜底选项 - [ ] 真机验证录音兜底:长按5秒说话但ASR接口超时 → 检查是否显示"选择音频文件"兜底选项
### 图片/视频上传 ### 图片/视频上传
- [ ] **13003690513**(保洁员) → 问师傅页 → 点击"上传图片"图标 → 从相册选择一张图片(≤10MB) - [ ] **真机体验样本** → 问师傅页 → 点击"上传图片"图标 → 从相册选择一张图片(≤10MB)
- [ ] 页面显示图片预览缩略图 + "仅用于本次现场分析"提示 - [ ] 页面显示图片预览缩略图 + "仅用于本次现场分析"提示
- [ ] 输入问题"这个地方怎么清洁" → 点击"搜索" → 回答中包含对图片内容的分析 - [ ] 输入问题"这个地方怎么清洁" → 点击"搜索" → 回答中包含对图片内容的分析
- [ ] **13032658127**(保洁班长) → 问师傅页 → 点击"上传视频"图标 → 从相册选择一段视频(≤100MB,<30秒) - [ ] **真机体验样本** → 问师傅页 → 点击"上传视频"图标 → 从相册选择一段视频(≤100MB,<30秒)
- [ ] 页面显示视频预览 + "仅用于本次现场分析"提示 - [ ] 页面显示视频预览 + "仅用于本次现场分析"提示
- [ ] 输入问题"这个操作对吗" → 点击"搜索" → 回答中包含对视频内容的分析 - [ ] 输入问题"这个操作对吗" → 点击"搜索" → 回答中包含对视频内容的分析
### 总结卡下载 ### 总结卡下载
- [ ] **13003690513**(保洁员) → 完成一次对练 → 查看训练总结页 - [ ] **真机体验样本** → 完成一次对练 → 查看训练总结页
- [ ] 点击"下载总结卡"按钮 → loading状态显示 - [ ] 点击"下载总结卡"按钮 → loading状态显示
- [ ] iOS:检查是否弹出系统分享菜单,选择"存储图像" → 打开相册验证PNG图片已保存 - [ ] iOS:检查是否弹出系统分享菜单,选择"存储图像" → 打开相册验证PNG图片已保存
- [ ] Android:检查是否弹出下载确认,点击"下载" → 打开文件管理器验证PNG图片已保存到Downloads目录 - [ ] Android:检查是否弹出下载确认,点击"下载" → 打开文件管理器验证PNG图片已保存到Downloads目录
### 全局字号三档 ### 全局字号三档
- [ ] **13052906326**(保洁员) → 登录员工端 → 点击"我的" → 点击"字体大小" - [ ] **真机体验样本** → 登录员工端 → 点击"我的" → 点击"字体大小"
- [ ] 选择"标准" → 返回today页,目测卡片标题、正文字号正常(16px/14px左右) - [ ] 选择"标准" → 返回today页,目测卡片标题、正文字号正常(16px/14px左右)
- [ ] 再次进入"字体大小" → 选择"大" → 返回today页,目测字号明显增大(约1.12倍) - [ ] 再次进入"字体大小" → 选择"大" → 返回today页,目测字号明显增大(约1.12倍)
- [ ] 再次进入"字体大小" → 选择"超大" → 返回today页,目测字号最大(约1.25倍) - [ ] 再次进入"字体大小" → 选择"超大" → 返回today页,目测字号最大(约1.25倍)
@@ -140,23 +123,22 @@
## 四、P0-3 严格试点窗口统计 ## 四、P0-3 严格试点窗口统计
**试点窗口**: 2026-07-01 至 2026-07-31(31天) **试点窗口**: 以本批已登记的起止日期为准
**完成定义**: 每人≥10次训练(对练 + 每日题,不含问师傅单次查询) **完成定义**: 每人≥10次训练(对练 + 每日题,不含问师傅单次查询)
**样本目标**: 20人完成,≥80%完成率 **样本目标**: 20人完成,≥80%完成率
### 训练量统计 ### 训练量统计
- [ ] 截至2026-07-18,已有1人登录,2次训练,0人达标(当前完成率0%) - [ ] 以正式 CSV 导出记录当前已完成人数、试点总人数和完成率,不把 seed 或旧批次数据计入
- [ ] 窗口结束前5天(2026-07-26)复查:已完成人数/目标20人,完成率是否≥80% - [ ] 窗口结束前 5 天复查:已完成人数/目标20人,完成率是否≥80%
- [ ] 若完成率<80%,召集主管催促:每位主管至少带2名员工完成10次训练 - [ ] 若完成率<80%,召集主管催促:每位主管至少带2名员工完成10次训练
### 人工校准样本 ### 人工校准样本
- [ ] 目标:20份对练录音 → 主管先打分 → AI再打分 → 对比分差 - [ ] 目标:20份对练录音 → 主管先打分 → AI再打分 → 对比分差
- [ ] 当前:0份校准样本 - [ ] 窗口结束前 7 天复查:已收集校准样本数/20,AI-人工分数段一致率是否≥70%
- [ ] 窗口结束前7天(2026-07-24)复查:已收集校准样本数/20,AI-人工分数段一致率是否≥70%
### SOP评审样本 ### SOP评审样本
- [ ] 目标:问师傅回答可用率≥80% - [ ] 目标:问师傅回答可用率≥80%
- [ ] 当前:2份样本,2份可用,可用率100%(已达标✅) - [ ] 以本批已签字样本计算可用率,不沿用历史样本结论
- [ ] 窗口结束时再抽查20份问师傅记录,验证可用率稳定在80%+ - [ ] 窗口结束时再抽查20份问师傅记录,验证可用率稳定在80%+
### 满意度收集 ### 满意度收集
@@ -192,27 +174,26 @@
## 六、验收执行时间表 ## 六、验收执行时间表
| 日期 | 里程碑 | 责任人 | 状态 | | 相对时间 | 里程碑 | 责任人 | 状态 |
|---|---|---|---| |---|---|---|---|
| 2026-07-18 | P0清单起草完成 | 开发 | ✅ | | 窗口开始前 | P0-1权限正反例全部验证 | 开发+测试 | 待执行 |
| 2026-07-19 | P0-1权限正反例全部验证 | 开发+测试 | 待执行 | | 窗口开始前 | P0-2真机能力验证(iOS+Android各2台) | 测试 | 待执行 |
| 2026-07-19 | P0-2真机能力验证(iOS+Android各2台) | 测试 | 待执行 | | 窗口中点 | P0-3中期检查(完成率/校准样本) | 项目经理 | 待执行 |
| 2026-07-24 | P0-3中期检查(完成率/校准样本) | 项目经理 | 待执行 | | 窗口结束前 5 天 | P0-3末期检查(完成率冲刺) | 主管团队 | 待执行 |
| 2026-07-26 | P0-3末期检查(完成率冲刺) | 主管团队 | 待执行 | | 窗口结束日 | 统计最终数据 | 项目经理 | 待执行 |
| 2026-07-31 | 试点窗口结束,统计最终数据 | 项目经理 | 待执行 | | 窗口结束后 7 天 | 满意度问卷收集完成 | 项目经理 | 待执行 |
| 2026-08-07 | 满意度问卷收集完成 | 项目经理 | 待执行 | | 问卷完成后 | P0验收报告输出 | 开发+项目经理 | 待执行 |
| 2026-08-08 | P0验收报告输出 | 开发+项目经理 | 待执行 |
--- ---
## 七、风险与应急预案 ## 七、风险与应急预案
### 风险1:完成率不达标 ### 风险1:完成率不达标
- **触发条件**: 2026-07-26时完成率<50% - **触发条件**: 窗口结束前 5 天完成率<50%
- **应急预案**: - **应急预案**:
- 主管团队每人认领2-3名员工,1对1催促 - 主管团队每人认领2-3名员工,1对1催促
- 开放"每日题+1次对练"任务包,完成即可算2次训练(加速达标) - 开放"每日题+1次对练"任务包,完成即可算2次训练(加速达标)
- 必要时延长试点窗口至2026-08-07(+7天) - 必要时经业务负责人批准延长窗口,并重新登记起止日期
### 风险2:真机能力不可用 ### 风险2:真机能力不可用
- **触发条件**: P0-2验证时iOS/Android某项能力失败率>50% - **触发条件**: P0-2验证时iOS/Android某项能力失败率>50%
@@ -231,4 +212,4 @@
--- ---
**清单版本**: v1.0 | **最后更新**: 2026-07-18 02:45 | **下次更新**: 2026-07-19(P0-1执行后) **模板状态**: 等待本批项目、窗口、受控测试身份和业务审核人登记;具体完成事实只回写到对应试点报告与 [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md)。
+8 -5
View File
@@ -11,7 +11,7 @@
3. 专项 TechSpec 细化单个领域;实施计划可记录本地实现与验证,但部署和生产状态仍以审查文档、接口事实和真实环境证据为准。 3. 专项 TechSpec 细化单个领域;实施计划可记录本地实现与验证,但部署和生产状态仍以审查文档、接口事实和真实环境证据为准。
4. `prototypes/` 只作视觉回归基准;会议纪要、历史 Prompt、`legacy/` 与 `archive/` 只作需求来源和追溯材料。 4. `prototypes/` 只作视觉回归基准;会议纪要、历史 Prompt、`legacy/` 与 `archive/` 只作需求来源和追溯材料。
“问”模块的最终 Agent 边界见[《数字师傅工作 Agent 总体设计》](superpowers/specs/2026-07-24-digital-master-agent-design.md),本地实施与验证进度见[《实施计划》](superpowers/plans/2026-07-24-digital-master-agent.md);工作助手相关术语和领域数据关系见[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md)。不得把本地验证外推为已部署 Agent、完整个人知识空间、完整大喇叭运营能力或外部流转已经实现。 “问”模块的最终 Agent 边界见[《数字师傅工作 Agent 总体设计》](superpowers/specs/2026-07-24-digital-master-agent-design.md),本地实施与验证进度见[《实施计划》](superpowers/plans/2026-07-24-digital-master-agent.md);工作助手相关术语、实施状态与文档关系集中在[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md)。当前产品优先级与本轮增量见[《银城帮道产品功能优先级与实施计划》](银城帮道产品功能优先级与实施计划-20260721.md),阶段一“练”的唯一后续施工范围见[《AI陪练完整交付计划》](AI陪练完整交付计划-20260724.md),公司消息现状见[九项能力设计](superpowers/specs/2026-07-24-company-message-nine-capabilities-design.md)与[大喇叭纠偏增量方案](银城大喇叭与问模块纠偏增量方案-20260722.md)。不得把本地 Agent 验证或已验证的全员文件消息和直通车闭环外推为已部署 Agent、消息修订、强触达、工单流转、完整个人知识空间或完整试点已经完成。
## 当前项目文档 ## 当前项目文档
@@ -29,7 +29,8 @@
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、数字师傅 Agent、正式试点 CSV、移动端训练/复盘、候选资料和 SOP 接口 | | [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、数字师傅 Agent、正式试点 CSV、移动端训练/复盘、候选资料和 SOP 接口 |
| [superpowers/specs/2026-07-24-digital-master-agent-design.md](superpowers/specs/2026-07-24-digital-master-agent-design.md) | “问”模块最终 Agent 产品/架构边界,以及当前已实现与后续工具范围 | | [superpowers/specs/2026-07-24-digital-master-agent-design.md](superpowers/specs/2026-07-24-digital-master-agent-design.md) | “问”模块最终 Agent 产品/架构边界,以及当前已实现与后续工具范围 |
| [superpowers/plans/2026-07-24-digital-master-agent.md](superpowers/plans/2026-07-24-digital-master-agent.md) | Agent 本地实施、自动化/H5/浏览器验证记录和剩余部署/正式验收边界 | | [superpowers/plans/2026-07-24-digital-master-agent.md](superpowers/plans/2026-07-24-digital-master-agent.md) | Agent 本地实施、自动化/H5/浏览器验证记录和剩余部署/正式验收边界 |
| [20260718/问师傅多轮会话与原始资料交付设计.md](20260718/问师傅多轮会话与原始资料交付设计.md) | Agent 底层复用的知识短会话、指代改写和受控原文件/视频交付契约 | | [P0_PILOT_ACCEPTANCE_CHECKLIST.md](P0_PILOT_ACCEPTANCE_CHECKLIST.md) | 正式试点执行模板:按当前批次填写角色、窗口与证据;不在仓库保存个人身份、验证码或旧窗口结论 |
| [20260718/问师傅多轮会话与原始资料交付设计.md](20260718/问师傅多轮会话与原始资料交付设计.md) | Agent 底层复用的知识短会话、指代改写、意图路由和受控原文件/视频交付契约 |
| [KNOWLEDGE_PLATFORM_RUNBOOK.md](KNOWLEDGE_PLATFORM_RUNBOOK.md) | 银城/美途知识空间初始化、授权、令牌、内容迁移、监控验证和安全回滚手册 | | [KNOWLEDGE_PLATFORM_RUNBOOK.md](KNOWLEDGE_PLATFORM_RUNBOOK.md) | 银城/美途知识空间初始化、授权、令牌、内容迁移、监控验证和安全回滚手册 |
| [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 | | [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/specs/2026-07-16-multi-tenant-knowledge-platform-design.md](superpowers/specs/2026-07-16-multi-tenant-knowledge-platform-design.md) | 银城/美途独立租户、多知识空间、多调用应用统一问答、授权交集、受控数据工具和安全验收的完整需求方案 |
@@ -39,7 +40,8 @@
| [个人AI助理阶段二专项TechSpec.md](个人AI助理阶段二专项TechSpec.md) | 工作助手确认式统一采集的当前契约,以及后续个人知识空间的数据分域、检索、权限、安全和测试规格 | | [个人AI助理阶段二专项TechSpec.md](个人AI助理阶段二专项TechSpec.md) | 工作助手确认式统一采集的当前契约,以及后续个人知识空间的数据分域、检索、权限、安全和测试规格 |
| [工作助手与今日工作成果迭代计划-20260721.md](工作助手与今日工作成果迭代计划-20260721.md) | 工作助手项目选择、统一采集、来源追溯、今日成果、主管视图、成果投稿和外部流转的分阶段计划;含本地实施状态、390×844 证据和剩余生产边界 | | [工作助手与今日工作成果迭代计划-20260721.md](工作助手与今日工作成果迭代计划-20260721.md) | 工作助手项目选择、统一采集、来源追溯、今日成果、主管视图、成果投稿和外部流转的分阶段计划;含本地实施状态、390×844 证据和剩余生产边界 |
| [银城帮道产品功能优先级与实施计划-20260721.md](银城帮道产品功能优先级与实施计划-20260721.md) | 对《银城帮道介绍》十项能力进行事实校正,给出 P0-P3、明确不做项、发布批次和本轮实施记录;产品全景施工入口 | | [银城帮道产品功能优先级与实施计划-20260721.md](银城帮道产品功能优先级与实施计划-20260721.md) | 对《银城帮道介绍》十项能力进行事实校正,给出 P0-P3、明确不做项、发布批次和本轮实施记录;产品全景施工入口 |
| [银城大喇叭与问模块纠偏增量方案-20260722.md](银城大喇叭与问模块纠偏增量方案-20260722.md) | 已部署的大喇叭核心、消息追问与“问”模块纠偏范围,以及尚未纳入首版的定向、附件、摘要和运营能力 | | [银城大喇叭与问模块纠偏增量方案-20260722.md](银城大喇叭与问模块纠偏增量方案-20260722.md) | 已部署的大喇叭、单文件提炼、消息追问和独立五通道直通车边界,以及仍待补齐的定向正反例、修订、补推和强触达 |
| [superpowers/specs/2026-07-24-company-message-nine-capabilities-design.md](superpowers/specs/2026-07-24-company-message-nine-capabilities-design.md) | 图示九项公司消息能力的归并口径、R1—R3 当前架构与明确不承担的聊天、工单、SLA 边界 |
| [M0自动化验收说明.md](M0自动化验收说明.md) | 本机受控写入的“发布—阅读—追问—撤回”接口验收边界、运行方式与清理约束 | | [M0自动化验收说明.md](M0自动化验收说明.md) | 本机受控写入的“发布—阅读—追问—撤回”接口验收边界、运行方式与清理约束 |
| [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 后续个人资料/网页/PPT 能力的 P0/P1 任务计划;“记一下”当前实现以专项 TechSpec 为准 | | [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 后续个人资料/网页/PPT 能力的 P0/P1 任务计划;“记一下”当前实现以专项 TechSpec 为准 |
| [prototypes/personal-assistant-work-helper-voice-first-v2.png](prototypes/personal-assistant-work-helper-voice-first-v2.png) | 个人助理并入「问 · 数字师傅」后的语音优先会话、自动内部路由与确认记忆卡实现基准 | | [prototypes/personal-assistant-work-helper-voice-first-v2.png](prototypes/personal-assistant-work-helper-voice-first-v2.png) | 个人助理并入「问 · 数字师傅」后的语音优先会话、自动内部路由与确认记忆卡实现基准 |
@@ -47,10 +49,11 @@
| [prototypes/work-assistant-project-context-v3.png](prototypes/work-assistant-project-context-v3.png) | 工作助手多项目、项目化确认卡与今日成果的权威高保真输入;运行截图另见迭代计划 §1.5 | | [prototypes/work-assistant-project-context-v3.png](prototypes/work-assistant-project-context-v3.png) | 工作助手多项目、项目化确认卡与今日成果的权威高保真输入;运行截图另见迭代计划 §1.5 |
| [visual-evidence/20260721/](visual-evidence/20260721/) | 工作助手项目选择、确认卡、员工今日成果和主管项目成果的 390×844 本地运行截图证据 | | [visual-evidence/20260721/](visual-evidence/20260721/) | 工作助手项目选择、确认卡、员工今日成果和主管项目成果的 390×844 本地运行截图证据 |
| [presentations/数字师傅工作助手迭代汇报-20260721.pptx](presentations/数字师傅工作助手迭代汇报-20260721.pptx) | 工作助手、多项目、今日成果、权限与发布边界的 5 页内部汇报稿 | | [presentations/数字师傅工作助手迭代汇报-20260721.pptx](presentations/数字师傅工作助手迭代汇报-20260721.pptx) | 工作助手、多项目、今日成果、权限与发布边界的 5 页内部汇报稿 |
| [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 | | [AI陪练完整交付计划-20260724.md](AI陪练完整交付计划-20260724.md) | 当前阶段一“练”模块的唯一后续施工范围:住宅物业 × 五岗位 × 三级成长;定义现有基线、收口动作、验收门槛与明确不做项 |
| [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 2026-07-11 历史推进记录;保留 M0-M5 的工程追溯,不再作为当前施工范围 |
| [员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md) | 旧版/历史需求/当前实现/生产验收的版本对比,以及 2026-07-17 客户反馈逐项处理结果 | | [员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md) | 旧版/历史需求/当前实现/生产验收的版本对比,以及 2026-07-17 客户反馈逐项处理结果 |
| [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | 当前生产基线、三端输入/BRD 匹配、客户反馈完成分层和正式试点剩余硬门槛;旧发布流水从当前真相源移除 | | [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | 当前生产基线、三端输入/BRD 匹配、客户反馈完成分层和正式试点剩余硬门槛;旧发布流水从当前真相源移除 |
| [BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | AIHR BRD 生产 schema/内置内容迁移顺序、57 项结构核验、私有个人资料存储配置和发布后回归口径 | | [BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | AIHR BRD 生产 schema/内置内容迁移顺序、64 项结构核验、私有个人资料存储配置和发布后回归口径 |
| [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | `mobile-uni/` 重建阶段留档;当前完成状态以 BRD 审查和版本功能对比为准 | | [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | `mobile-uni/` 重建阶段留档;当前完成状态以 BRD 审查和版本功能对比为准 |
| [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 产品定义(学练问报+问题榜+学分晋升+个人 AI 助理)及 v1.8 “问”模块 Agent 关联与实施边界 | | [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 产品定义(学练问报+问题榜+学分晋升+个人 AI 助理)及 v1.8 “问”模块 Agent 关联与实施边界 |
| [20260708/发布会演示候选题单.md](20260708/发布会演示候选题单.md) | 2026-07-08 发布会演示候选题单,作为产品讨论留档,不作为阶段一开发入口 | | [20260708/发布会演示候选题单.md](20260708/发布会演示候选题单.md) | 2026-07-08 发布会演示候选题单,作为产品讨论留档,不作为阶段一开发入口 |
@@ -40,7 +40,7 @@
- 财务专业、经营决策只预留方案,有真实内容、负责人和授权矩阵后再启用。 - 财务专业、经营决策只预留方案,有真实内容、负责人和授权矩阵后再启用。
- 不做跨租户共享、个人知识空间、任意 SQL、工资财务明细、美途订单查询。 - 不做跨租户共享、个人知识空间、任意 SQL、工资财务明细、美途订单查询。
- 第一版数据工具仅 `MY_PRACTICE_SUMMARY` 和 `TEAM_PRACTICE_SUMMARY`。 - 第一版数据工具仅 `MY_PRACTICE_SUMMARY` 和 `TEAM_PRACTICE_SUMMARY`。
- 当前阶段一验收继续按 `docs/DEMO_ACCEPTANCE.md` 和 `docs/AI陪练二期开发推进计划.md`,本计划不改变其范围。 - 当前阶段一验收继续按 `docs/DEMO_ACCEPTANCE.md` 和 `docs/AI陪练完整交付计划-20260724.md`;旧二期计划只用于追溯已完成工程工作包,本计划不改变当前阶段一范围。
### 0.3 实施总顺序 ### 0.3 实施总顺序
@@ -1,6 +1,7 @@
# 公司消息系统 R1–R3 实施计划 # 公司消息系统 R1–R3 实施计划
> 依据:`docs/superpowers/specs/2026-07-24-company-message-nine-capabilities-design.md` > 依据:`docs/superpowers/specs/2026-07-24-company-message-nine-capabilities-design.md`
> 状态:2026-07-24 已完成实现、发布与生产认证验收;本文件只保留实施拆分供追溯
## R1:统一直通车 ## R1:统一直通车
@@ -13,7 +14,7 @@
## R2:公司文件提炼 ## R2:公司文件提炼
1. 新增正式 SQL 迁移:大喇叭附件和片段表。 1. 新增正式 SQL 迁移:大喇叭附件表及消息附件关联列。
2. 复用现有 OSS、文档解析器和模型配置,实现大喇叭附件异步处理服务。 2. 复用现有 OSS、文档解析器和模型配置,实现大喇叭附件异步处理服务。
3. 扩展管理端发布接口:先上传附件,处理完成后带附件 ID 发布。 3. 扩展管理端发布接口:先上传附件,处理完成后带附件 ID 发布。
4. 扩展员工详情和原文件下载接口,按当前身份重新鉴权。 4. 扩展员工详情和原文件下载接口,按当前身份重新鉴权。
@@ -27,4 +28,4 @@
3. 运行后端、前端、移动端和脚本验证。 3. 运行后端、前端、移动端和脚本验证。
4. 启动本地界面,完成移动端和管理端截图及交互验证。 4. 启动本地界面,完成移动端和管理端截图及交互验证。
5. 构建完整制品并按发布 Runbook 发布。 5. 构建完整制品并按发布 Runbook 发布。
6. 使用线上已有手机号和验证码 `123456` 完成生产认证闭环,复核远端 schema、静态资源、后端模块和服务状态。 6. 使用线上已有受控账号完成生产认证闭环,复核远端 schema、静态资源、后端模块和服务状态。
@@ -2,6 +2,7 @@
> 日期:2026-07-24 > 日期:2026-07-24
> 范围:R1 统一直通车、R2 公司文件提炼、R3 系统整合与验收 > 范围:R1 统一直通车、R2 公司文件提炼、R3 系统整合与验收
> 状态:已实现、发布并完成生产认证闭环;五个处理角色已预置,具体人员仍由管理员按实际职责绑定
## 1. 九项能力落位 ## 1. 九项能力落位
@@ -37,9 +38,9 @@
## 3. R2:公司文件提炼 ## 3. R2:公司文件提炼
管理端发布大喇叭消息时可上传一个文件。文件先进入 OSS,再由现有解析器异步提取正文、切片并生成公共摘要。解析结果保存在大喇叭专属附件和片段表,不自动进入企业知识库。 管理端发布大喇叭消息时可上传一个文件。文件先进入 OSS,再由现有解析器异步提取正文并生成公共摘要。提取文本和摘要保存在大喇叭专属附件表,不自动进入企业知识库。
文件状态为 `QUEUED/PARSING/READY/FAILED`。只有 `READY` 文件可随消息发布;失败时明确显示原因,不生成假摘要。员工详情显示摘要、目标命中原因和原文件入口。客户端下载原文件时服务端按租户、消息发布状态和当前员工身份重新鉴权。 文件状态为 `QUEUED/PROCESSING/READY/FAILED`。只有 `READY` 文件可随消息发布;失败时明确显示原因,不生成假摘要。员工详情显示摘要、目标命中原因和原文件入口。客户端下载原文件时服务端按租户、消息发布状态和当前员工身份重新鉴权。
客户端追问仍只传 `broadcastMessageId`。服务端重新加载消息及其已就绪附件片段,把受控文本加入现有消息上下文;撤回、跨租户或附件不可用时立即拒绝继续使用。 客户端追问仍只传 `broadcastMessageId`。服务端重新加载消息及其已就绪附件片段,把受控文本加入现有消息上下文;撤回、跨租户或附件不可用时立即拒绝继续使用。
@@ -60,7 +61,7 @@
## 6. 验收 ## 6. 验收
生产认证验收直接使用线上系统已有手机号和验证码 `123456`,手机号不得出现在报告、截图或命令输出中。 生产认证验收使用线上系统已有受控账号;账号标识不得出现在报告、截图或命令输出中。
- 五渠道提交、收件和回复闭环。 - 五渠道提交、收件和回复闭环。
- 匿名反馈不向业务处理端泄露提交人。 - 匿名反馈不向业务处理端泄露提交人。
@@ -93,7 +93,7 @@
| 项 | 依据 | | 项 | 依据 |
|---|---| |---|---|
| 个人 AI 助理/个人知识空间(文字/图片/文件/链接收藏、混合问答、PPT) | BRD 4.8 明确阶段二;有独立 TechSpec 与推进计划 | | 个人 AI 助理/个人知识空间(文字/图片/文件/链接收藏、混合问答、PPT) | BRD 4.8 明确阶段二;有独立 TechSpec 与推进计划 |
| 员工端图片/视频训练输入(4.3.3 终态缺口) | 《AI陪练二期开发推进计划》约定文本场景稳定后独立评估;届时只落有业务目的的场景(拍照报修、品检) | | 员工端图片/视频训练输入(4.3.3 终态缺口) | 当前《AI陪练完整交付计划》明确不纳入住宅五岗位三级训练收口;后续仅在该计划完成并经独立业务批准后,评估有明确目的的场景(拍照报修、品检) |
| 开放问题榜、积分/排行榜/荣誉墙运营化 | 总纲阶段二;07-08 定案排行榜默认关 | | 开放问题榜、积分/排行榜/荣誉墙运营化 | 总纲阶段二;07-08 定案排行榜默认关 |
| 师徒关系/拜师池/带徒福利 | 业务已否决(07-08) | | 师徒关系/拜师池/带徒福利 | 业务已否决(07-08) |
| G5 情绪树洞 | 业务/法务/安全决策未定,在匿名边界与零落库合同明确前保持未实现,不用陪练情绪数据冒充 | | G5 情绪树洞 | 业务/法务/安全决策未定,在匿名边界与零落库合同明确前保持未实现,不用陪练情绪数据冒充 |
@@ -3,6 +3,8 @@
> 客户反馈基线:2026-07-17;当前工程与生产状态更新:2026-07-18。 > 客户反馈基线:2026-07-17;当前工程与生产状态更新:2026-07-18。
> >
> 本清单用于区分“旧版真有、历史规划、本轮新增、当前实现和生产验收”。视觉事实以 Figma 正式画板为准,功能事实以业务文档、当前代码和真实运行证据为准;Figma 未画不等于可以删除,Figma 已画也不等于已经开发或属于当前阶段。 > 本清单用于区分“旧版真有、历史规划、本轮新增、当前实现和生产验收”。视觉事实以 Figma 正式画板为准,功能事实以业务文档、当前代码和真实运行证据为准;Figma 未画不等于可以删除,Figma 已画也不等于已经开发或属于当前阶段。
>
> 历史快照说明(2026-07-24):本文的“管理端常见难题”等描述记录 2026-07-18 的版本验证,不代表当前后台入口。当前“练”模块的范围和权限以[《AI陪练完整交付计划》](AI陪练完整交付计划-20260724.md)及其 API 契约为准。
## 1. 客观结论 ## 1. 客观结论
@@ -1,8 +1,8 @@
# 银城员工端 APP 分阶段实施总纲 # 银城员工端 APP 分阶段实施总纲
> 版本:v0.6 | 日期:2026-07-21 > 版本:v0.6 | 核心边界冻结:2026-07-21 | 阶段一执行入口同步:2026-07-24
> 输入源:《基于AI的银城员工端APP项目立项与开发规划会议(2026-07-07)》纪要 +《BRD v1.1》+《AI陪练二期开发推进计划 v0.8》+《数字师傅学练问报整合方案 v1.1》(docs/20260708/)。 > 输入源:《基于AI的银城员工端APP项目立项与开发规划会议(2026-07-07)》纪要 +《BRD v1.1》+《AI陪练二期开发推进计划 v0.8》+《数字师傅学练问报整合方案 v1.1》(docs/20260708/)。
> 定位:立项会把交付物从"AI 人力资源系统"升级为**银城员工端 APP**(员工服务载体)。本总纲定义三阶段顺序与边界;各阶段的执行细节仍由对应专项计划承载(阶段一 = 二期推进计划)。 > 定位:立项会把交付物从"AI 人力资源系统"升级为**银城员工端 APP**(员工服务载体)。本总纲定义三阶段顺序与边界;阶段一当前执行入口为《AI陪练完整交付计划》,旧《AI陪练二期开发推进计划》仅保留已完成工作的追溯。
> 分阶段口径(已定):**第一步陪练相关全部工作 → 第二步银城大喇叭 + 知识学习平台 → 第三步北森人事考勤对接**。 > 分阶段口径(已定):**第一步陪练相关全部工作 → 第二步银城大喇叭 + 知识学习平台 → 第三步北森人事考勤对接**。
> v0.2 确认(2026-07-07,客户方):①"管家"与场景库"生活顾问"为**同一岗位**,术语统一为"管家(生活顾问)",`position` 标签沿用现有值不迁移;②APP 形态定为 **H5 套壳 APP**(WebView 包装可安装),原生重写不做,D6 决策维持。 > v0.2 确认(2026-07-07,客户方):①"管家"与场景库"生活顾问"为**同一岗位**,术语统一为"管家(生活顾问)",`position` 标签沿用现有值不迁移;②APP 形态定为 **H5 套壳 APP**(WebView 包装可安装),原生重写不做,D6 决策维持。
> v0.3 修订(2026-07-08,客户产品讨论):①阶段二"知识学习平台"扩充**学模块**——标准学习计划/课程/防快进视频/最短阅读时长/不定期考试,见 §4.5;②"新问题挂榜共创"产品形态定案为**开放问题榜**,定向师带徒经会议否决不做,见 §4.4;③新增知识分级访问权限工程需求;④三项待确认已获客户回复(2026-07-08):多端跳转暂缓(只保证能打开小程序页面,D6 不动)、排行榜保留为可选开关、员工评管理层按组织架构权限控制,见 §8 与《数字师傅学练问报整合方案 v1.2》§3.3。 > v0.3 修订(2026-07-08,客户产品讨论):①阶段二"知识学习平台"扩充**学模块**——标准学习计划/课程/防快进视频/最短阅读时长/不定期考试,见 §4.5;②"新问题挂榜共创"产品形态定案为**开放问题榜**,定向师带徒经会议否决不做,见 §4.4;③新增知识分级访问权限工程需求;④三项待确认已获客户回复(2026-07-08):多端跳转暂缓(只保证能打开小程序页面,D6 不动)、排行榜保留为可选开关、员工评管理层按组织架构权限控制,见 §8 与《数字师傅学练问报整合方案 v1.2》§3.3。
@@ -27,15 +27,15 @@
| 阶段 | 内容 | 时间锚点 | 承载文档 | | 阶段 | 内容 | 时间锚点 | 承载文档 |
|---|---|---|---| |---|---|---|---|
| **一、陪练** | 陪练相关全部工作:场景库、语音对手戏、复盘、画像、派发、新员工每日三题、传帮带 | 2026-07-10 Alpha → 2026-07-13 当周 Beta → 半年会发布 → 9 月收费季前试点可用 | 《AI陪练二期开发推进计划》v0.8 | | **一、陪练** | 住宅物业五岗位、三级成长的场景库、语音对手戏、复盘、画像、派发、每日训练与主管带教闭环 | 2026-07-10 Alpha → 2026-07-13 当周 Beta → 半年会发布 → 9 月收费季前试点可用 | [《AI陪练完整交付计划》](AI陪练完整交付计划-20260724.md) |
| **二、大喇叭 + 知识学习平台** | 下行:公司信息直达推送(不过滤);每份文件 AI 总结 + 分身份"跟我相关"提取(见 §4 身份模型);**学模块:标准学习计划 + 知识课程 + 不定期考试(见 §4.5)**;**个人 AI 助理:个人资料/链接收藏、检索、总结与分析(见 §4.6)**;上行:品检翻转(员工拍照上报+AI分析+积分)、开放问题榜(见 §4.4) | 半年会后启动,与阶段一试点并行推进;**学模块岗前课程最小版视资源提前到阶段一试点后** | 本总纲 §4/§5 +《数字师傅学练问报整合方案》(后续出专项计划) | | **二、大喇叭 + 知识学习平台** | 下行:公司信息直达推送(不过滤);每份文件 AI 总结 + 分身份"跟我相关"提取(见 §4 身份模型);**学模块:标准学习计划 + 知识课程 + 不定期考试(见 §4.5)**;**个人 AI 助理:个人资料/链接收藏、检索、总结与分析(见 §4.6)**;上行:品检翻转(员工拍照上报+AI分析+积分)、开放问题榜(见 §4.4) | 半年会后启动,与阶段一试点并行推进;**学模块岗前课程最小版视资源提前到阶段一试点后** | 本总纲 §4/§5 +《数字师傅学练问报整合方案》(后续出专项计划) |
| **三、北森对接** | SSO/组织人员同步、考勤请假入口整合;报事闭环接口对接("一键走天下") | 阶段二稳定后;报事流程梳理由闫鑫牵头业务侧先行 | 本总纲 §6(后续出集成方案) | | **三、北森对接** | SSO/组织人员同步、考勤请假入口整合;报事闭环接口对接("一键走天下") | 阶段二稳定后;报事流程梳理由闫鑫牵头业务侧先行 | 本总纲 §6(后续出集成方案) |
排序理由(立项会原话):先陪练可倒逼知识沉淀 + 缓解 9 月收费压力;不求大求全,先做员工必用核心功能。 排序理由(立项会原话):先陪练可倒逼知识沉淀 + 缓解 9 月收费压力;不求大求全,先做员工必用核心功能。
## 3. 阶段一:陪练(对二期计划的增量调整) ## 3. 阶段一:陪练(当前固定范围)
二期计划主体不变,立项会带来 6 处调整(已并入 v0.6,岗位与 APP 形态两项确认已并入 v0.7,2026-07-08 产品讨论增量已并入 v0.8,详见该文档): 当前施工和结项以[《AI陪练完整交付计划》](AI陪练完整交付计划-20260724.md)为准:范围固定为“住宅物业 × 五岗位 × 三级成长”,不因发现差异继续另开产品期。以下保留立项会对旧二期计划的调整记录;如与当前计划冲突,以当前计划为准:
1. **时间锚点收紧**:8 周里程碑映射到 Alpha(2026-07-10)/Beta(2026-07-13 当周)/半年会发布/9 月收费季四个外部节点;半年会前必须收集到一线员工真实使用数据 → M1/M2 的可用增量要提前放给种子用户,不能等 M5 才见人。 1. **时间锚点收紧**:8 周里程碑映射到 Alpha(2026-07-10)/Beta(2026-07-13 当周)/半年会发布/9 月收费季四个外部节点;半年会前必须收集到一线员工真实使用数据 → M1/M2 的可用增量要提前放给种子用户,不能等 M5 才见人。
2. **岗位口径**:**已确认(2026-07-07)**——立项会所说"管家"与场景库"生活顾问"为同一岗位,文档统一写"管家(生活顾问)",`position` 标签沿用现有值不迁移;**项目经理为第二波人设**,场景由内容侧供给,不阻塞第一波。 2. **岗位口径**:**已确认(2026-07-07)**——立项会所说"管家"与场景库"生活顾问"为同一岗位,文档统一写"管家(生活顾问)",`position` 标签沿用现有值不迁移;**项目经理为第二波人设**,场景由内容侧供给,不阻塞第一波。
@@ -1,8 +1,8 @@
# “银城大喇叭 + 问模块纠偏”增量方案 # “银城大喇叭 + 问模块纠偏”增量方案
> 版本:v1.1 > 版本:v1.2
> 日期:2026-07-23 > 日期:2026-07-24
> 状态:A 的事实纠偏、B 的最小消息闭环和 C 的消息上下文追问已部署,并完成当前构建、线上产物、后端模块和 `57/57` schema 的一致性核验;本方案列出的完整 B 能力和 D—E 仍待实施。 > 状态:问模块纠偏、大喇叭发布/阅读/追问、定向/必读结构、单文件提炼,以及独立五通道直通车均已部署;当前完整包已通过线上静态资源、后端模块和 `64/64` schema 一致性核验,并完成全员文件消息与直通车的生产认证态回归。定向人群正反例、正式处理人绑定、消息修订/补推和强触达仍待补齐。
> 适用范围:银城帮道员工端、管理端与 `ruoyi-aihr` 业务模块 > 适用范围:银城帮道员工端、管理端与 `ruoyi-aihr` 业务模块
> 关联方案:[产品功能优先级与实施计划](银城帮道产品功能优先级与实施计划-20260721.md)、[员工端 APP 分阶段实施总纲](银城员工端APP分阶段实施总纲.md)、[问师傅多轮会话与原始资料交付设计](20260718/问师傅多轮会话与原始资料交付设计.md) > 关联方案:[产品功能优先级与实施计划](银城帮道产品功能优先级与实施计划-20260721.md)、[员工端 APP 分阶段实施总纲](银城员工端APP分阶段实施总纲.md)、[问师傅多轮会话与原始资料交付设计](20260718/问师傅多轮会话与原始资料交付设计.md)
@@ -14,19 +14,20 @@
2. 已发布的首版消息面向本租户全员可查;按项目、岗位、层级的匹配用于**置顶、提醒和排序**,不应导致员工完全看不到公司公开信息。 2. 已发布的首版消息面向本租户全员可查;按项目、岗位、层级的匹配用于**置顶、提醒和排序**,不应导致员工完全看不到公司公开信息。
3. 员工可在消息详情中点击“就这条消息问数字师傅”,带着受服务端校验的消息上下文进入现有“问”模块。 3. 员工可在消息详情中点击“就这条消息问数字师傅”,带着受服务端校验的消息上下文进入现有“问”模块。
4. “问”回答“我现在该做什么”时,必须先查该员工当前真实任务;没有任务就明确说明“当前没有查到待办”,只能给非强制建议,不能把通用培训内容伪装为已下发工作。 4. “问”回答“我现在该做什么”时,必须先查该员工当前真实任务;没有任务就明确说明“当前没有查到待办”,只能给非强制建议,不能把通用培训内容伪装为已下发工作。
5. 首版不包含高管直通、匿名建议、企业微信/短信强触达、考勤请假审批、工单派发或自动创建任务。这些能力另立流程和权限,不混入大喇叭 MVP。 5. 员工直通车已作为独立领域实现,不复用大喇叭消息表;企业微信/短信强触达、考勤请假审批、工单派发或自动创建任务仍不纳入大喇叭。
这项增量同时服务两个目的:一是让制度、通知、文件不再散落在群聊中;二是让员工在接收信息后能基于原文继续追问,并获得有来源、不过度承诺的回答。 这项增量同时服务两个目的:一是让制度、通知、文件不再散落在群聊中;二是让员工在接收信息后能基于原文继续追问,并获得有来源、不过度承诺的回答。
### 0.1 2026-07-23 实际交付边界 ### 0.1 2026-07-24 实际交付边界
| 范围 | 当前状态 | 证据边界 | | 范围 | 当前状态 | 证据边界 |
|---|---|---| |---|---|---|
| A:问模块纠偏 | 已实现并部署 | 会话开始后收起占位辅助区;消息与知识回答不得伪造员工待办,强行动入口只接受服务端真实任务事实。认证态的完整生产回归仍待执行。 | | A:问模块纠偏 | 已实现、部署并回归 | 会话开始后收起占位辅助区;消息与知识回答不得伪造员工待办,强行动入口只接受服务端真实任务事实。 |
| B:大喇叭最小闭环 | 已实现并部署 | 员工可查看本租户已发布消息、读取未读数和写入幂等已读;管理端可按当前租户发布、列表查看和撤回。首版不含定向规则、附件、AI 摘要、“与我有关”、修订版本和人工补推。 | | B:大喇叭消息闭环 | 已实现、部署并回归 | 支持全员公开、项目/岗位/层级目标快照、必读、已读、撤回,以及单文件上传、异步提炼、摘要展示和受控下载。生产已验证全员文件消息,测试消息随后撤回。 |
| C:消息上下文追问 | 已实现并部署 | 详情页只传 `broadcastMessageId`;服务端重验当前身份、租户、在职状态和 `PUBLISHED` 状态,同一短会话不可改绑消息,撤回后续问即拒绝。 | | C:消息上下文追问 | 已实现、部署并回归 | 详情页只传 `broadcastMessageId`;服务端重验身份、租户、在职状态和消息状态,并从服务端加载附件内容。生产已验证基于文件内容回答;撤回后不可继续访问。 |
| 发布基础验证 | 已完成 | 当前构建、线上 H5/管理端资源、后端模块与 schema 已核验;服务处于 `active`。最终发布后只查看了未登录 H5 页面,尚未以正式认证账号重放“发布—阅读—追问”闭环。 | | D:员工直通车 | 已实现、部署并回归 | 员工选择高管、财务、人力、审计或运营后业务匿名提交;内部保留身份审计,五类角色分权处理,每条仅一次正式回复。生产已验证匿名提交、审计处理、员工查看回复。 |
| 后续范围 | 未实施 | 项目/岗位/层级定向、附件与摘要、相关性说明、修订/补推、强触达和外部流程仍按本方案后续批次处理。 | | 发布基础验证 | 已完成 | 当前构建与线上 H5/管理端资源、后端模块一致,服务 `active`,schema 只读预检为 `64/64`。 |
| 后续范围 | 部分待补 | 定向人群正反例、正式处理人绑定、消息修订/补推、强触达和外部流程仍待完成。 |
本节是当前实现状态的唯一摘要;第 1—11 节保留目标设计和验收标准,不能因其中的完整设计而推断所有能力均已上线。 本节是当前实现状态的唯一摘要;第 1—11 节保留目标设计和验收标准,不能因其中的完整设计而推断所有能力均已上线。
@@ -71,7 +72,7 @@
| 银城大喇叭 | 公司信息如何可靠触达、回看和理解 | 消息发布、频道浏览、定向提醒/置顶、阅读审计、附件与摘要 | 审批流、工单系统、即时聊天、匿名建议箱 | | 银城大喇叭 | 公司信息如何可靠触达、回看和理解 | 消息发布、频道浏览、定向提醒/置顶、阅读审计、附件与摘要 | 审批流、工单系统、即时聊天、匿名建议箱 |
| 问 · 数字师傅 | 员工如何理解制度、消息和工作知识 | 有来源的消息解读、SOP/知识问答、真实任务状态说明 | 擅自派工、自动创建任务、承诺外部部门已经处理 | | 问 · 数字师傅 | 员工如何理解制度、消息和工作知识 | 有来源的消息解读、SOP/知识问答、真实任务状态说明 | 擅自派工、自动创建任务、承诺外部部门已经处理 |
| 学 / 练 | 员工如何获得并完成训练 | 已有学习、陪练、训练任务承接 | 承担公司通知分发 | | 学 / 练 | 员工如何获得并完成训练 | 已有学习、陪练、训练任务承接 | 承担公司通知分发 |
| 后续员工直达 | 员工如何向管理层提出敏感意见或求助 | 后续单独设计匿名、实名、收件人、回复 SLA 与审计 | 复用大喇叭消息表“顺手实现” | | 员工直通车 | 员工如何向明确职能负责人提出敏感意见或求助 | 五个固定通道、业务匿名、内部审计、分权处理和一次正式回复 | 复用大喇叭消息表、即时聊天、工单转派或承诺 SLA |
| 北森/外部 HR | 请假、考勤、正式人事流程如何办理 | 后续有正式接口合同后承接 | 用问答或大喇叭模拟审批成功 | | 北森/外部 HR | 请假、考勤、正式人事流程如何办理 | 后续有正式接口合同后承接 | 用问答或大喇叭模拟审批成功 |
### 2.2 本次纳入范围 ### 2.2 本次纳入范围
@@ -85,7 +86,7 @@
### 2.3 明确不纳入首版 ### 2.3 明确不纳入首版
- 高管直通、财务/人力/审计直达、匿名建议、投诉受理及其工单分派。 - 直通反馈的附件、多轮聊天、工单转派和 SLA。
- 企业微信、短信、电话等强触达渠道;H5 的页面内提醒不能被表述为系统级推送。 - 企业微信、短信、电话等强触达渠道;H5 的页面内提醒不能被表述为系统级推送。
- 正式请假、考勤、排班、工单、线索、客户画像和北森审批。 - 正式请假、考勤、排班、工单、线索、客户画像和北森审批。
- 依据一条消息自动创建训练任务、工作任务、成果记录或 `COMPANY/PENDING` 外部流转。 - 依据一条消息自动创建训练任务、工作任务、成果记录或 `COMPANY/PENDING` 外部流转。
@@ -297,14 +298,13 @@ flowchart LR
| B:大喇叭核心 | 消息领域模型、发布/修订/撤回、首页入口、列表、详情、已读未读 | 发布角色、可公开内容范围、组织匹配字段确认 | 发布—阅读—版本—审计闭环;390×844 页面截图与交互记录 | | B:大喇叭核心 | 消息领域模型、发布/修订/撤回、首页入口、列表、详情、已读未读 | 发布角色、可公开内容范围、组织匹配字段确认 | 发布—阅读—版本—审计闭环;390×844 页面截图与交互记录 |
| C:消息追问融合 | `messageId` 受控上下文、来源条、消息引用、附件再鉴权 | B 已有可审计的消息版本和资源引用 | 员工从详情追问、连续追问、撤回/换版本/越权三类回归 | | C:消息追问融合 | `messageId` 受控上下文、来源条、消息引用、附件再鉴权 | B 已有可审计的消息版本和资源引用 | 员工从详情追问、连续追问、撤回/换版本/越权三类回归 |
| D:运营增强 | AI 摘要、相关性说明、人工补推、失败队列与报表 | 核心阅读与组织匹配运行稳定 | 摘要可回原件、低置信提示、补推审计与失败可见 | | D:运营增强 | AI 摘要、相关性说明、人工补推、失败队列与报表 | 核心阅读与组织匹配运行稳定 | 摘要可回原件、低置信提示、补推审计与失败可见 |
| E:另案能力 | 员工直达/匿名建议、企业微信或短信、北森/审批/工单 | 业务负责人、权限、回执与 SLA 明确 | 独立 BRD、TechSpec、接口和正式环境验收 | | E:另案能力 | 独立五通道直通车;企业微信或短信、北森/审批/工单仍另案 | 直通车依赖五类处理角色;外部流程另需接口和回执契约 | 直通车已完成生产认证闭环;外部流程仍待独立验收 |
### 8.1 建议实施顺序 ### 8.1 当前实施状态
1. 先完成 A:解决“问”会误导员工的质量问题,并为消息追问准备可信任务结论。 1. A、B、C 已实现并完成生产回归;当前 B 不含发布后版本修订。
2. 再完成 B:让消息具有稳定的发布、版本、阅读和权限事实。 2. D 已实现单文件摘要与受控下载,人工补推、失败报表和强触达仍待后续。
3. 完成 C:将“就这条消息问”接入成熟的短会话机制。 3. E 中直通车已独立实现;企业微信/短信、北森、审批和工单仍保持另案。
4. 最后才做 D 的 AI 摘要和智能相关性。即使 AI 服务不可用,B/C 的原文阅读和人工提问仍应可用。
### 8.2 代码与页面影响面(实施前复核) ### 8.2 代码与页面影响面(实施前复核)
@@ -0,0 +1,279 @@
# 银城帮道:“练”与“银城大喇叭”功能介绍
> 文档用途:产品介绍、项目汇报和员工/主管使用说明。
>
> 版本:v1.0 | 日期:2026-07-24
>
> 状态说明:本文以当前实施计划、接口契约和发布审计为依据。文中的“已具备”指已有实现或已发布基础闭环;业务内容校审、真实用户试点和正式验收仍须以相应证据单独确认。
## 1. 产品全景:让信息被理解,让标准被练会
银城帮道中的“练”和“银城大喇叭”不是两套孤立功能。
- **银城大喇叭**:把公司的通知、制度和重要提醒可靠地送到员工手中;
- **问 · 数字师傅**:让员工读完后还能继续问清楚“这是什么意思、与我有什么关系、应该如何理解”;
- **练**:把需要掌握的岗位标准变成可反复训练、可复盘提升的服务能力。
```mermaid
flowchart LR
A[公司发布制度或通知] --> B[银城大喇叭]
B --> C[员工阅读]
C --> D[问:围绕消息追问]
D --> E[理解规则与SOP]
E --> F[练:场景化对练]
F --> G[评分、复盘、再练]
G --> H[主管带教与内容优化]
```
| 模块 | 解决的问题 | 主要使用者 | 形成的闭环 |
|---|---|---|---|
| 练 | 服务标准如何变成员工真正会做的动作 | 员工、主管、内容运营人员 | 任务 → 对练 → 反馈 → 复盘 → 再练 |
| 银城大喇叭 | 公司重要信息如何统一发布、阅读和回看 | 员工、内容运营人员 | 发布 → 阅读 → 已读记录 → 撤回审计 |
| 问 · 数字师傅 | 员工看完消息后如何获得可信解释 | 员工 | 消息上下文 → 追问 → 有依据的回答 |
## 2. 当前范围与口径
| 能力 | 当前定位 | 不能误认为已经完成的内容 |
|---|---|---|
| 练 | 住宅物业五岗位的基础训练闭环,并按初级、中级、高级组织成长内容 | 全部训练内容已经业务审核、真实试点已通过、自动人事决策 |
| 银城大喇叭 M0 | 当前租户全员公开文本消息、发布/撤回、未读与已读、列表/详情、消息追问 | 定向发布、强制必读、附件、AI 摘要、修订重发、外部强触达 |
| 问 | 对通知、制度和内部知识进行解释;消息追问由服务端校验上下文 | 凭 AI 猜测或生成员工真实待办、工单、审批结果 |
“已发布的基础能力”不等同于“已完成全部业务验收”。尤其是“练”,还需要完成场景内容校审、真实员工训练数据、主管复盘样本和正式试点证据。
---
## 3. “练”:AI 陪练与岗位能力提升
### 3.1 产品定位
“练”不是简单的题库,也不是把员工拉进一个聊天机器人。它将岗位 SOP、服务规范、常见风险和客户沟通情境组织成模拟训练,让员工在低风险环境中先练会,再用于真实服务。
它的目标是帮助员工完成三件事:
1. **知道标准**:理解服务步骤、关键话术和不能触碰的红线;
2. **练出反应**:在业主、客户或协作岗位的连续追问中作出应对;
3. **形成改进**:知道这次哪里做得好、哪里有风险、下一次具体怎样表达。
初级、中级、高级只表示训练复杂度和内容进阶,不会自动改变员工职级、薪酬、晋升资格或人事档案。
### 3.2 场景与岗位范围
当前固定聚焦**住宅物业**,覆盖以下五个岗位。
| 岗位 | 初级:能上岗 | 中级:能独立处理 | 高级:能处理复杂协同 |
|---|---|---|---|
| 生活顾问 | 接待与记录、巡检、报事报修受理、基础服务规范 | 投诉、催费、停车费沟通、跨部门跟进、回访 | 重大投诉服务恢复、群体沟通、风险预判 |
| 保安 | 门岗礼仪、访客车辆、巡查与异常上报 | 纠纷初处、夜间/高峰处置、与管家协同 | 突发事件协同、现场秩序组织、复盘交接 |
| 保洁 | 作业标准、工具安全、现场反馈 | 卫生异常、投诉处理、时间与区域协调 | 重大活动保障、跨工种协同、品质复盘 |
| 保修(工程) | 安全确认、报修受理、上门服务、完工说明 | 故障初判、延期沟通、跨部门转办 | 高风险故障协同、复杂工单、服务恢复 |
| 客服 | 咨询受理、信息记录、基础话术 | 投诉分级、情绪安抚、跟进协调 | 复杂客诉、群体沟通、服务升级与复盘 |
每个可开放给员工的训练单元都应具备四项基础:经审核的场景、可追溯的 SOP/制度依据、对应的评分标准,以及明确的服务或安全红线。
### 3.3 员工端功能与用法
#### 训练任务
员工在“练”模块中可以看到以下来源的训练:
| 任务类型 | 适用场景 | 员工看到的含义 |
|---|---|---|
| 每日训练 | 按当前成长层和待巩固能力安排 | 今天建议优先完成的练习 |
| 专项训练 | 主管针对某一服务问题或活动安排 | 需要重点提升的具体能力 |
| 再练任务 | 分数偏低、触发红线或主管要求强化 | 应针对上一次问题重新练习 |
| 自主练习 | 已开放的场景 | 员工可主动选择需要巩固的内容 |
任务来源不等于能力等级。例如,专项训练可以跨越员工当前层级,但应有明确的业务或带教原因。
#### 一次训练怎么完成
1. 进入“练”,选择今日任务或自主练习场景。
2. 先查看训练准备卡:核心注意点、服务红线和推荐表达。
3. 通过文字或语音,和 AI 扮演的业主、客户或协作岗位进行多轮模拟对话。
4. 训练过程中可请求与当前场景相关的 SOP 提示;它只帮助理解,不会替员工虚构真实任务。
5. 结束训练后,查看总结并决定是否立即再练。
语音训练与文字训练使用同一业务闭环:语音可以转写为对话输入,也可以播放角色回复。麦克风不可用时,客户端仍应提供可选择/录制音频后继续识别的后备路径。
#### 训练结束后能看到什么
一份训练总结至少应包含:
- 多维能力评分;
- 本次表现较好的证据句;
- 需要注意的服务、安全或沟通红线;
- 更合适的表达建议;
- 下一步推荐训练、是否需要重练;
- 个人训练历史、易错点和能力进度。
典型例子:生活顾问练习“业主投诉维修迟迟未完成”。员工先练习安抚与信息确认,再练习与工程协同、解释时限和回访承诺。训练总结会指出是否遗漏风险说明、是否过度承诺时限,以及下一次可如何表达得更准确。
### 3.4 主管端功能与用法
主管的作用不是只查看分数,而是完成带教闭环:
1. 查看团队训练完成情况、风险提醒和待复盘记录;
2. 打开训练详情,回听录音并查看关键对话、评分依据和逐句标注;
3. 写下复盘建议,指出员工应改善的服务动作或表达;
4. 为员工派发专项训练或再练任务;
5. 在有训练证据的前提下,人工确认员工可以尝试下一层训练内容。
主管确认只是一项训练带教证据,不会自动写入 HR 等级、薪酬、任职资格或晋升结论。
### 3.5 内容运营功能与用法
内容运营人员在既有训练运营后台中管理“练”的内容,而不是另建一套培训系统:
- 配置岗位、能力项、场景、目标、角色人设和多轮对话;
- 绑定 SOP、制度或业务来源;
- 配置 Rubric 和服务红线;
- 管理场景生命周期:`草稿 → 待业务审核 → 已发布 → 已下线`;
- 对收费、安全、投诉等高风险内容执行双人审核;
- 按“住宅物业 × 岗位 × 成长层 × 能力项”查看内容覆盖缺口;
- 保留内容版本与训练会话快照,避免新规则改写历史训练结论。
员工只能看到“已发布且启用”的内容;未审核、下线或高风险审核未完成的内容不能作为员工训练任务开放。
### 3.6 “练”的业务价值
- 新员工:先练标准接待、报修受理和基础安全规范,降低首次上岗的不确定性;
- 一线员工:把投诉、协同和复杂沟通放进可重复练习的场景,减少只听课不会用;
- 主管:从“凭感觉带人”转为有训练记录、有问题证据、有再练安排的带教;
- 运营:发现哪些 SOP 被频繁问、哪些话术容易触发风险,再反向优化场景和制度说明。
### 3.7 “练”的明确边界
- 不以 AI 评分直接决定员工人事结果;
- 不以种子数据、测试账号或截图替代真实试点证据;
- 当前不将视频情境训练、服务录像回放、方言正式验收包装为已完成能力;
- 当前不因每发现一个需求就另起“第 N 期”,五岗位、三级成长已是阶段一固定范围。
---
## 4. “银城大喇叭”:公司消息的正式入口
### 4.1 产品定位
银城大喇叭是员工端的**公司公共信息频道**。它承载正式、需要统一口径的公司信息,避免重要内容分散在微信群或聊天记录中而无法回看、无法确认是否阅读。
适合发布的内容包括:
- 公司通知、制度更新、服务标准;
- 重要培训、活动和专项工作提醒;
- 面向全员的安全、品质、运营要求;
- 需要员工查看原文并进一步理解的规则说明。
员工通过首页右上角的喇叭入口进入,不新增第五个底部 Tab。
### 4.2 当前基础版(M0)功能
| 角色 | 已具备能力 | 使用方式 |
|---|---|---|
| 员工 | 未读红点 | 首页喇叭提示有新的公司消息 |
| 员工 | 消息列表与详情 | 查看当前租户已发布的全员公开消息 |
| 员工 | 已读记录 | 打开详情后写入幂等的已读记录,支持审计 |
| 员工 | 围绕消息追问 | 从详情进入“问”,不需要复制粘贴原文 |
| 内容运营人员 | 发布消息 | 在管理端“内容运营 → 银城大喇叭”填写标题和正文并发布 |
| 内容运营人员 | 撤回消息 | 误发或失效时填写撤回原因,保留发布与撤回审计 |
当前 M0 的消息是**面向当前租户全员公开发布的文本消息**。发布后员工可见;撤回后员工不能继续查看,但管理端保留可追溯的发布和撤回记录。
### 4.3 员工如何使用
1. 在首页看到喇叭未读提示后,进入“银城大喇叭”。
2. 在列表中选择消息,阅读标题、正文和发布时间。
3. 打开详情后,系统记录本人的已读状态;重复打开不会重复写入多条记录。
4. 如有疑问,点击“就这条消息问数字师傅”,围绕该消息继续追问。
适合追问的问题包括:
- “这次制度和原来相比改了什么?”
- “我所在岗位应该重点注意哪几项?”
- “通知里提到的 SOP 在哪里可以查看?”
- “遇到业主质疑时,推荐怎样解释?”
消息追问只绑定消息 ID,由服务端重新校验消息状态、当前租户和员工身份。消息已撤回、跨租户或员工不再在职时,不能继续以该消息作为上下文追问。
### 4.4 管理端如何发布和撤回
1. 进入管理端 **内容运营 → 银城大喇叭**。
2. 新建消息,填写标题和正文。
3. 确认向当前租户全员公开发布。
4. 发布后在消息管理列表中查看状态和审计信息。
5. 如内容误发或失效,填写原因后撤回。
消息以首次发布的内容快照为准。当前不支持直接编辑已发布消息;需要停止展示时,应撤回并保留原因,避免历史口径被无痕改写。
### 4.5 消息追问与“问”模块的关系
大喇叭负责“**告诉员工是什么**”,问负责“**帮助员工理解为什么和怎么做**”。
系统可以解释通知、制度、SOP 和建议动作,但必须遵守以下边界:
- 可以说“这项制度通常要求先完成哪些步骤”;
- 可以基于授权资料说明“与你的岗位相关的注意点”;
- 不可以在没有真实任务数据时说“主管已经给你派了任务”;
- 不可以把通用建议伪装成当前员工的真实待办、工单或审批结果。
如果员工问“我现在要做什么”,系统只能根据服务端已授权的真实任务数据回答“有任务”“当前没有查到待办”或“无权确认”,不能由大模型自行生成任务结论。
### 4.6 当前不属于 M0 的能力
以下能力可作为后续增强,但不能在当前产品介绍中表述为已正式验收:
- 按项目、岗位、成长层级定向发布;
- “与我有关”自动筛选和优先级排序;
- 必读、未读催办和强制阅读闭环;
- 图片、文件、制度附件下发;
- AI 摘要、版本修订、补发和重推;
- 企业微信、短信、电话等外部强触达;
- 高管直通、匿名建议、审批、工单派发等独立业务流程。
---
## 5. 两个模块的联动场景
### 场景一:制度更新后的理解与训练
1. 公司通过大喇叭发布《投诉处理时效更新》;
2. 员工阅读后追问新旧要求、适用范围和推荐话术;
3. 主管发现该制度需要强化后,人工指派“投诉升级处理”专项训练;
4. 员工在“练”中完成模拟对话和训练总结;
5. 主管复盘并安排再练,运营根据高频问题完善制度说明或训练场景。
> 当前边界:一条通知不会自动创建训练任务、工作任务或成果记录;是否安排训练仍由主管或运营人员按真实业务流程决定。
### 场景二:高风险服务规范强化
1. 大喇叭发布安全或收费相关服务提醒;
2. 员工先阅读原文并追问红线与 SOP;
3. 运营人员将高风险训练场景完成双人审核后开放;
4. 员工练习沟通和处置,主管重点复盘是否触碰红线。
### 场景三:从员工问题反哺内容
1. 员工持续围绕某条制度提出相似问题;
2. 运营人员识别出原通知解释不足或现有训练场景缺失;
3. 更新 SOP 说明、补充场景并经过审核;
4. 后续员工在大喇叭、问和练中获得更一致的口径。
这是一条人工运营闭环,不应由系统在无审核的情况下自动改写通知、SOP 或训练内容。
## 6. 对外介绍时建议统一使用的表述
| 主题 | 推荐表述 |
|---|---|
| 练 | “面向住宅物业五岗位的 AI 场景化陪练,帮助员工把 SOP 和服务规范练成可执行动作。” |
| 主管带教 | “主管可基于训练证据复盘、点评和安排再练,不以 AI 自动决定人事结果。” |
| 银城大喇叭 | “公司面向当前租户全员的正式消息频道,支持发布、阅读、撤回和围绕消息追问。” |
| 问 | “员工可基于通知原文、制度和授权知识持续追问,答案不越权、不虚构真实待办。” |
| 当前进度 | “基础闭环已具备;完整内容审核、真实试点和后续定向/必读能力按独立验收口径推进。” |
## 7. 依据与关联文档
- [AI陪练完整交付计划(固定范围版)](AI陪练完整交付计划-20260724.md)
- [银城大喇叭与问模块纠偏增量方案](银城大喇叭与问模块纠偏增量方案-20260722.md)
- [API 对接与接口边界](API_INTEGRATION.md)
- [BRD 实现与发布审计](BRD_IMPLEMENTATION_AUDIT.md)
- [员工端 APP 分阶段实施总纲](银城员工端APP分阶段实施总纲.md)
@@ -2,7 +2,7 @@
> 版本:v1.3 > 版本:v1.3
> 日期:2026-07-23 > 日期:2026-07-23
> 状态:2026-07-21 的多项目、确认式采集和今日工作成果,以及 R0 的历史日期选择、未来日期拒绝和主管手机号授权均已部署;R2 的大喇叭最小闭环和消息追问已于 2026-07-23 随全量迁移发布,完成本地测试、构建和远端服务、产物、后端模块及 `57/57` schema 核验。 > 状态:2026-07-21 的多项目、确认式采集和今日工作成果,以及 R0 的历史日期选择、未来日期拒绝和主管手机号授权均已部署;R2 的大喇叭最小闭环和消息追问已于 2026-07-23 随全量迁移发布。其 `57/57` 为首轮历史核验;2026-07-24 当前完整包已再次通过远端服务、产物、后端模块及 `62/62` schema 核验。
> 需求输入:微信临时文件《银城帮道 介绍(1).html》、2026-07-19/20 客户沟通、当前 BRD/TechSpec 与线上事实 > 需求输入:微信临时文件《银城帮道 介绍(1).html》、2026-07-19/20 客户沟通、当前 BRD/TechSpec 与线上事实
> 适用范围:员工端、主管端、管理端及后续外部业务接口 > 适用范围:员工端、主管端、管理端及后续外部业务接口
@@ -190,6 +190,6 @@
后续每个 Agent 开始开发前先阅读本计划,再按对应专项文档实施: 后续每个 Agent 开始开发前先阅读本计划,再按对应专项文档实施:
- 工作助手与日报:[工作助手与今日工作成果迭代计划](工作助手与今日工作成果迭代计划-20260721.md) - 工作助手与日报:[工作助手与今日工作成果迭代计划](工作助手与今日工作成果迭代计划-20260721.md)
- AI 陪练:[AI 陪练二期开发推进计划](AI陪练二期开发推进计划.md) - AI 陪练:[AI陪练完整交付计划](AI陪练完整交付计划-20260724.md)(旧二期计划仅作历史追溯)
- 总阶段边界:[银城员工端 APP 分阶段实施总纲](银城员工端APP分阶段实施总纲.md) - 总阶段边界:[银城员工端 APP 分阶段实施总纲](银城员工端APP分阶段实施总纲.md)
- 工程契约:[物业 AI 人力资源系统开发规格 TechSpec](物业AI人力资源系统开发规格TechSpec.md) - 工程契约:[物业 AI 人力资源系统开发规格 TechSpec](物业AI人力资源系统开发规格TechSpec.md)
+1 -240
View File
@@ -1,5 +1,4 @@
import axios from 'axios'; import request from '@/utils/request';
import request, { globalHeaders, reconcileTenantContextBlobResponse } from '@/utils/request';
type ApiResult<T> = { type ApiResult<T> = {
code: number; code: number;
@@ -7,123 +6,6 @@ type ApiResult<T> = {
data: T; data: T;
}; };
export type ReviewRecord = {
id: number;
sessionId: string;
time: string;
trainee: string;
scene: string;
score: number;
status: string;
summary: string;
extPartyId: string;
};
export type ReviewDetail = ReviewRecord & {
mentorRewrite: string;
aiComment: string;
reviewAdvice: string;
incentivePoint: number | null;
scoreItems: Array<{ label: string; value: number | string; note: string }>;
dialogue: Array<{ role: string; label: string; text: string; audioUrl?: string; audioOssId?: number | string }>;
annotations: Array<{ turnIndex: number; sourceText: string; issue: string; rewrite: string; sopPoint: string }>;
};
export type ReviewAdviceRequest = {
reviewer?: string;
advice?: string;
incentivePoint?: number;
nextScenarioId?: string;
nextReason?: string;
};
export type CompetencyProfile = {
score: number;
completed: number;
pendingReview: number;
trainingMinutes: number;
contribution: number;
assessmentScore: number;
aiLevel: string;
dimensions: Array<{ label: string; value: number | string | null; note: string }>;
growthPath: Array<{ level: string; progress: number; status: string; evidence: string }>;
};
export type PracticeAlert = {
type: string;
title: string;
desc: string;
severity: string;
extPartyId: string;
};
export type PracticeAssignment = {
id: number;
extPartyId: string;
traineeName: string;
scenarioId: string;
scenarioName: string;
source: string;
reason: string;
status: string;
createTime: string;
question?: string;
referenceAnswer?: string;
userAnswer?: string;
feedback?: string;
correct?: boolean;
};
export type PracticeAssignmentRequest = {
extPartyId: string;
traineeName?: string;
scenarioId: string;
scenarioName?: string;
source: string;
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;
assessmentScore: number;
trainingMinutes: number;
contribution: number;
completed: number;
pendingReview: number;
dimensions: Array<{ label: string; value: number | string | null; note: string }>;
latestRecords: ReviewRecord[];
assignments: PracticeAssignment[];
evidenceItems: string[];
humanAssessmentScore?: number | null;
finalAdoptedScore?: number | null;
calibrationCount?: number;
};
export type OrgSyncRequest = { export type OrgSyncRequest = {
dryRun?: boolean; dryRun?: boolean;
replaceExisting?: boolean; replaceExisting?: boolean;
@@ -188,127 +70,6 @@ export type OrgSnapshotResponse = {
rows: OrgPersonRow[]; rows: OrgPersonRow[];
}; };
export type ReviewListParams = {
projectCode?: string;
extPartyId?: string;
limit?: number;
};
export function listPracticeReviews(params?: ReviewListParams): Promise<ApiResult<ReviewRecord[]>> {
return request({
url: '/api/aihr/mobile/practice/reviews',
method: 'get',
params
});
}
export function getPracticeReview(id: number): Promise<ApiResult<ReviewDetail>> {
return request({
url: `/api/aihr/mobile/practice/reviews/${id}`,
method: 'get'
});
}
export async function getPracticeAudioBlobUrl(ossId: number | string): Promise<string> {
try {
const response = await axios({
method: 'get',
url: `${import.meta.env.VITE_APP_BASE_API}/api/aihr/mobile/oss/${ossId}`,
responseType: 'blob',
headers: globalHeaders()
});
const contentType = String(response.headers['content-type'] || '');
if (contentType.includes('application/json') && (await reconcileTenantContextBlobResponse(response.data))) {
throw new Error('租户上下文已变更,请刷新后重试');
}
return URL.createObjectURL(response.data);
} catch (error) {
await reconcileTenantContextBlobResponse(axios.isAxiosError(error) ? error.response?.data : undefined);
throw error;
}
}
export function markPracticeReviewed(id: number, data: ReviewAdviceRequest): Promise<ApiResult<null>> {
return request({
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function getCompetencyProfile(extPartyId?: string): Promise<ApiResult<CompetencyProfile>> {
return request({
url: '/api/aihr/mobile/profile',
method: 'get',
params: { extPartyId }
});
}
export function getPromotionEvidence(extPartyId?: string): Promise<ApiResult<PromotionEvidence>> {
return request({
url: '/api/aihr/mobile/profile/promotion-evidence',
method: 'get',
params: { extPartyId }
});
}
export function listPracticeAlerts(): Promise<ApiResult<PracticeAlert[]>> {
return request({
url: '/api/aihr/mobile/practice/alerts',
method: 'get'
});
}
export function listPracticeAssignments(extPartyId?: string): Promise<ApiResult<PracticeAssignment[]>> {
return request({
url: '/api/aihr/mobile/practice/assignments',
method: 'get',
params: { extPartyId }
});
}
export function createPracticeAssignment(data: PracticeAssignmentRequest): Promise<ApiResult<PracticeAssignment>> {
return request({
url: '/api/aihr/mobile/practice/assignments',
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function listPracticeDifficulties(params?: { windowDays?: number; position?: string }): Promise<ApiResult<PracticeDifficulty[]>> {
type RawDifficulty = Omit<PracticeDifficulty, 'cause' | 'count'> & { attempts: number; mistakeCount: number };
return request<unknown, ApiResult<RawDifficulty[]>>({
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<ApiResult<PracticeAssignmentBatchResponse>> {
return request({
url: '/api/aihr/mobile/practice/difficulties/assign',
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function syncOrgSnapshot(data: OrgSyncRequest): Promise<ApiResult<OrgSyncResponse>> { export function syncOrgSnapshot(data: OrgSyncRequest): Promise<ApiResult<OrgSyncResponse>> {
return request({ return request({
url: '/api/aihr/org/sync', url: '/api/aihr/org/sync',
+69
View File
@@ -18,6 +18,17 @@ export type PracticeScenarioResponse = {
position: string; position: string;
scenarioType: string; scenarioType: string;
projectType: string; projectType: string;
growthLevel: string;
competencyCode: string;
collaborationPositions: string;
reviewStatus: string;
riskLevel: '待评估' | '常规' | '高风险';
requiresDualReview: boolean;
curriculumVersion: string;
reviewer?: string;
reviewedTime?: string;
secondReviewer?: string;
secondReviewedTime?: string;
difficulty: number; difficulty: number;
customerPersona: string; customerPersona: string;
openingText: string; openingText: string;
@@ -58,6 +69,11 @@ export type PracticeScenarioSaveRequest = {
position: string; position: string;
scenarioType: string; scenarioType: string;
projectType: string; projectType: string;
growthLevel: string;
competencyCode: string;
collaborationPositions: string;
riskLevel: '待评估' | '常规' | '高风险';
curriculumVersion: string;
difficulty: number; difficulty: number;
customerPersona: string; customerPersona: string;
openingText: string; openingText: string;
@@ -73,6 +89,35 @@ export type PracticeScenarioSaveRequest = {
rounds: PracticeScenarioRound[]; rounds: PracticeScenarioRound[];
}; };
export type PracticeScenarioReviewStatusRequest = {
reviewStatus: '草稿' | '待业务审核' | '已发布' | '已下线';
};
export type PracticeScenarioReviewStatusResponse = {
reviewStatus: '草稿' | '待业务审核' | '已发布' | '已下线';
awaitingSecondReview: boolean;
message: string;
};
export type PracticeCurriculumCapability = {
position: string;
growthLevel: string;
code: string;
name: string;
};
export type PracticeCurriculumMatrixRow = {
position: string;
growthLevel: string;
competencyCode: string;
competencyName: string;
publishedCount: number;
pendingReviewCount: number;
draftCount: number;
offlineCount: number;
coverageStatus: string;
};
export type PracticeRubricDimension = { export type PracticeRubricDimension = {
code: string; code: string;
name: string; name: string;
@@ -289,6 +334,30 @@ export function updatePracticeScenarioEnabled(id: string, data: PracticeScenario
}); });
} }
export function updatePracticeScenarioReviewStatus(
id: string,
data: PracticeScenarioReviewStatusRequest
): Promise<ApiResult<PracticeScenarioReviewStatusResponse>> {
return request({
url: `/api/train/practice/scenarios/${encodeURIComponent(id)}/review-status`,
method: 'patch',
data
});
}
export function listPracticeCurriculumCapabilities(params?: {
position?: string;
growthLevel?: string;
}): Promise<ApiResult<PracticeCurriculumCapability[]>> {
return request({ url: '/api/train/practice/curriculum/capabilities', method: 'get', params });
}
export function listPracticeCurriculumMatrix(params?: {
position?: string;
}): Promise<ApiResult<PracticeCurriculumMatrixRow[]>> {
return request({ url: '/api/train/practice/curriculum/matrix', method: 'get', params });
}
export function savePracticeScenario(data: PracticeScenarioSaveRequest): Promise<ApiResult<PracticeScenarioResponse>> { export function savePracticeScenario(data: PracticeScenarioSaveRequest): Promise<ApiResult<PracticeScenarioResponse>> {
return request({ return request({
url: '/api/train/practice/scenarios', url: '/api/train/practice/scenarios',
+9 -16
View File
@@ -141,16 +141,17 @@ export const constantRoutes: RouteRecordRaw[] = [
}, },
{ {
path: 'reviews', path: 'reviews',
component: () => import('@/views/train/reviews.vue'),
name: 'TrainReviews', name: 'TrainReviews',
meta: { title: '复盘校准', icon: 'finish' } redirect: '/train/tasks',
hidden: true,
meta: { title: '复盘校准', icon: 'finish', activeMenu: '/train/tasks' }
}, },
{ {
path: 'difficulties', path: 'difficulties',
component: () => import('@/views/train/difficulties.vue'),
name: 'TrainDifficulties', name: 'TrainDifficulties',
redirect: '/train/tasks',
hidden: true, hidden: true,
meta: { title: '常见难题', icon: 'chart', activeMenu: '/train/scenarios' } meta: { title: '常见难题', icon: 'chart', activeMenu: '/train/tasks' }
} }
] ]
}, },
@@ -285,19 +286,11 @@ export const constantRoutes: RouteRecordRaw[] = [
] ]
}, },
{ {
path: '/competency', path: '/competency/growth',
component: Layout, name: 'CompetencyGrowth',
redirect: '/train/tasks',
hidden: true, hidden: true,
redirect: '/competency/growth', meta: { title: '成长激励', icon: 'chart', roles: operationsConsoleRoles }
meta: { roles: operationsConsoleRoles },
children: [
{
path: 'growth',
component: () => import('@/views/competency/growth.vue'),
name: 'CompetencyGrowth',
meta: { title: '成长激励', icon: 'chart' }
}
]
}, },
{ {
path: '/user', path: '/user',
-509
View File
@@ -1,509 +0,0 @@
<template>
<div class="growth-page">
<section class="page-head">
<div>
<h1>成长激励</h1>
<p>展示能力画像、初/中/高级证据包、预警、待训任务和待配置的学分口径。</p>
</div>
<div class="head-actions">
<el-select v-model="selectedProjectCode" clearable filterable placeholder="项目范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
<el-select v-model="extPartyId" clearable filterable allow-create default-first-option placeholder="组织人员或 extPartyId">
<el-option
v-for="person in orgPeople"
:key="person.extPartyId"
:label="`${person.personName || person.extPartyId} / ${person.positionName}`"
:value="person.extPartyId"
/>
</el-select>
<el-button :icon="Download" :disabled="!evidence" @click="exportEvidenceCsv">导出证据包</el-button>
<el-button type="danger" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
</div>
</section>
<section class="metric-grid">
<article class="metric-card">
<span>AI训练综合分</span>
<strong>{{ profile?.assessmentScore ?? '-' }}</strong>
<small>训练参考分,不等于 BRD 五维绩效总分</small>
</article>
<article class="metric-card">
<span>训练画像等级(参考)</span>
<strong>{{ evidence?.currentLevel || profile?.aiLevel || '-' }}</strong>
<small>需 HR/主管复核,不作为晋升结论</small>
</article>
<article class="metric-card">
<span>训练时长</span>
<strong>{{ profile?.trainingMinutes ?? 0 }} 分钟</strong>
<small>来自移动端训练记录</small>
</article>
<article class="metric-card">
<span>人工贡献分</span>
<strong>{{ profile?.contribution ?? 0 }}</strong>
<small>仅计入主管明确确认的有效贡献分,训练次数单独统计</small>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>能力维度</h2>
</div>
<div class="dimension-list">
<div v-for="item in profile?.dimensions || []" :key="item.label" class="dimension-item">
<div>
<strong>{{ item.label }}</strong>
<span>{{ item.note }}</span>
</div>
<el-progress
v-if="item.value !== null && item.value !== undefined"
:percentage="Number(item.value) || 0"
:stroke-width="10"
color="#e60012"
/>
<span v-else class="dimension-unavailable">待采集</span>
</div>
</div>
</article>
<article class="panel">
<div class="panel-title">
<h2>成长参考路径</h2>
</div>
<div class="stage-list">
<div v-for="stage in profile?.growthPath || []" :key="stage.level" class="stage-item">
<div class="stage-head">
<strong>{{ stage.level }}</strong>
<el-tag :type="stage.status === '已达成' ? 'success' : 'info'">{{ stage.status }}</el-tag>
</div>
<el-progress :percentage="stage.progress" :stroke-width="9" />
<small>{{ stage.evidence }}</small>
</div>
</div>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>晋升证据包(待复核)</h2>
</div>
<ul class="evidence-list">
<li v-for="item in evidence?.evidenceItems || []" :key="item">{{ item }}</li>
</ul>
<div class="evidence-score-row">
<span>人工校准分:{{ evidence?.humanAssessmentScore ?? '待采集' }}</span>
<span>最终采用分:{{ evidence?.finalAdoptedScore ?? evidence?.assessmentScore ?? '-' }}</span>
<span>校准样本:{{ evidence?.calibrationCount ?? 0 }}</span>
</div>
</article>
<article class="panel">
<div class="panel-title">
<h2>预警</h2>
</div>
<div class="alert-list">
<div v-for="item in alerts" :key="item.title" class="alert-item" :class="`severity-${item.severity}`">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>待训任务</h2>
<el-button type="danger" plain @click="createManualAssignment">派发专项</el-button>
</div>
<div class="assignment-form">
<el-select v-model="assignmentScenarioId" filterable placeholder="选择专项场景">
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
</el-select>
<el-input v-model="assignmentReason" placeholder="派发原因" />
</div>
<el-table :data="assignments" size="small">
<el-table-column prop="traineeName" label="学员" width="110" />
<el-table-column prop="scenarioName" label="训练" min-width="160" />
<el-table-column prop="source" label="来源" width="92" />
<el-table-column prop="status" label="状态" width="92" />
<el-table-column prop="reason" label="原因" min-width="180" show-overflow-tooltip />
</el-table>
</article>
<article class="panel">
<div class="panel-title">
<h2>学分规则(待 HR 配置)</h2>
<el-tag type="info" effect="plain">规划参考,当前不生效</el-tag>
</div>
<el-table :data="rules" size="small">
<el-table-column prop="source" label="来源" width="130" />
<el-table-column prop="points" label="分值" width="90" />
<el-table-column prop="guard" label="防刷分口径" min-width="220" />
</el-table>
</article>
</section>
</div>
</template>
<script setup name="CompetencyGrowth" lang="ts">
import { Download, Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import FileSaver from 'file-saver';
import { computed, onMounted, ref, watch } from 'vue';
import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import {
createPracticeAssignment,
getCompetencyProfile,
getOrgSnapshot,
getPromotionEvidence,
listPracticeAlerts,
listPracticeAssignments,
type CompetencyProfile,
type OrgSnapshotResponse,
type PracticeAlert,
type PracticeAssignment,
type PromotionEvidence
} from '@/api/aihr/mobile-admin';
const extPartyId = ref('');
const selectedProjectCode = ref('');
const loading = ref(false);
const profile = ref<CompetencyProfile | null>(null);
const evidence = ref<PromotionEvidence | null>(null);
const alerts = ref<PracticeAlert[]>([]);
const assignments = ref<PracticeAssignment[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const scenarios = ref<PracticeScenarioResponse[]>([]);
const assignmentScenarioId = ref('');
const assignmentReason = ref('HR在成长激励页派发专项训练');
const rules = [
{ source: '训练完成', points: '待配置', guard: '只认完成记录,不按点击或打开计分' },
{ source: '主管复盘采纳', points: '待配置', guard: '由主管复盘时人工确认' },
{ source: '案例被采纳', points: '待配置', guard: '必须审核入库并署名' },
{ source: '课程考试通过', points: '待配置', guard: '阶段二课程考试上线后启用' }
];
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const selectedPerson = computed(() => orgPeople.value.find((item) => item.extPartyId === extPartyId.value));
const loadOrgSnapshot = async () => {
try {
const result = await getOrgSnapshot({
status: 'active',
projectCode: selectedProjectCode.value,
pageNum: 1,
pageSize: 500
});
orgSnapshot.value = result.data;
} catch (error) {
orgSnapshot.value = null;
}
};
const loadAll = async () => {
loading.value = true;
const party = extPartyId.value.trim();
try {
const [profileResult, evidenceResult, alertsResult, assignmentsResult] = await Promise.all([
getCompetencyProfile(party),
getPromotionEvidence(party),
listPracticeAlerts(),
listPracticeAssignments(party)
]);
profile.value = profileResult.data;
evidence.value = evidenceResult.data;
alerts.value = alertsResult.data || [];
assignments.value = assignmentsResult.data || [];
} catch (error) {
ElMessage.error('成长数据加载失败');
} finally {
loading.value = false;
}
};
const loadScenarios = async () => {
try {
scenarios.value = ((await listPracticeScenarios()).data || []).filter((item) => item.enabled);
assignmentScenarioId.value = assignmentScenarioId.value || scenarios.value[0]?.id || '';
} catch (error) {
scenarios.value = [];
}
};
const createManualAssignment = async () => {
const party = extPartyId.value.trim() || evidence.value?.extPartyId || '';
if (!party) {
ElMessage.warning('请先输入员工手机号或 extPartyId');
return;
}
const scenario = scenarios.value.find((item) => item.id === assignmentScenarioId.value);
if (!scenario) {
ElMessage.warning('请先选择专项场景');
return;
}
try {
await createPracticeAssignment({
extPartyId: party,
traineeName: selectedPerson.value?.personName || party,
scenarioId: scenario.id,
scenarioName: scenario.name,
source: 'manual',
reason: assignmentReason.value.trim() || 'HR在成长激励页派发专项训练'
});
ElMessage.success('专项训练已派发');
await loadAll();
} catch (error) {
ElMessage.error('专项训练派发失败');
}
};
const csvCell = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const appendCsvRow = (rows: string[], ...values: unknown[]) => {
rows.push(values.map(csvCell).join(','));
};
const exportEvidenceCsv = () => {
const party = extPartyId.value.trim() || evidence.value?.extPartyId?.trim() || 'all';
if (!evidence.value) {
ElMessage.warning('证据包数据加载后才能导出');
return;
}
const rows: string[] = [];
const personName = selectedPerson.value?.personName || (party === 'all' ? '全部员工' : party);
appendCsvRow(rows, 'section', 'field', 'value');
appendCsvRow(rows, 'summary', 'employee', personName);
appendCsvRow(rows, 'summary', 'ext_party_id', party);
appendCsvRow(rows, 'summary', 'current_level', evidence.value.currentLevel);
appendCsvRow(rows, 'summary', 'assessment_score', evidence.value.assessmentScore);
appendCsvRow(rows, 'summary', 'human_assessment_score', evidence.value.humanAssessmentScore ?? '待采集');
appendCsvRow(rows, 'summary', 'final_adopted_score', evidence.value.finalAdoptedScore ?? evidence.value.assessmentScore);
appendCsvRow(rows, 'summary', 'calibration_count', evidence.value.calibrationCount ?? 0);
appendCsvRow(rows, 'summary', 'training_minutes', evidence.value.trainingMinutes);
appendCsvRow(rows, 'summary', 'contribution', evidence.value.contribution);
appendCsvRow(rows, 'summary', 'completed', evidence.value.completed);
appendCsvRow(rows, 'summary', 'pending_review', evidence.value.pendingReview);
profile.value?.growthPath.forEach((stage) =>
appendCsvRow(rows, 'growth_path', `${stage.level} ${stage.status}`, `${stage.progress}% ${stage.evidence}`)
);
evidence.value.dimensions.forEach((item) => {
const value = item.value === null || item.value === undefined ? '待采集' : item.value;
appendCsvRow(rows, 'dimension', item.label, `${value} ${item.note}`);
});
evidence.value.evidenceItems.forEach((item, index) => appendCsvRow(rows, 'evidence_item', index + 1, item));
evidence.value.latestRecords.forEach((item) =>
appendCsvRow(rows, 'latest_record', item.scene, `${item.score} ${item.status} ${item.time} ${item.summary}`)
);
evidence.value.assignments.forEach((item) =>
appendCsvRow(rows, 'assignment', item.scenarioName, `${item.status} ${item.createTime} ${item.reason}`)
);
FileSaver.saveAs(new Blob([`\uFEFF${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), `aihr-promotion-evidence-${party}.csv`);
ElMessage.success('成长证据包 CSV 已导出');
};
watch(selectedProjectCode, async () => {
extPartyId.value = '';
await loadOrgSnapshot();
});
watch(extPartyId, () => {
loadAll();
});
onMounted(() => {
loadOrgSnapshot();
loadScenarios();
loadAll();
});
</script>
<style scoped lang="scss">
.growth-page {
min-height: 100%;
padding: 24px;
background: #f5f7fb;
color: #1f2937;
}
.page-head,
.panel,
.metric-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
h1 {
margin: 0 0 8px;
font-size: 24px;
}
p {
margin: 0;
color: #667085;
}
}
.head-actions {
display: grid;
grid-template-columns: 220px 280px auto auto;
gap: 10px;
}
.metric-grid,
.main-grid {
display: grid;
gap: 16px;
margin-top: 16px;
}
.metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.main-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.metric-card,
.panel {
padding: 18px;
}
.metric-card {
span,
small {
display: block;
color: #667085;
}
strong {
display: block;
margin: 8px 0;
font-size: 24px;
}
}
.panel-title,
.stage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-title {
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 17px;
}
}
.assignment-form {
display: grid;
grid-template-columns: minmax(180px, 0.75fr) minmax(220px, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.dimension-list,
.stage-list,
.alert-list {
display: grid;
gap: 14px;
}
.dimension-item strong,
.stage-item strong {
display: block;
margin-bottom: 4px;
}
.dimension-item span,
.stage-item small {
display: block;
margin-bottom: 8px;
color: #667085;
}
.dimension-item .dimension-unavailable {
margin-bottom: 8px;
color: #98a2b3;
font-weight: 600;
}
.evidence-list {
margin: 0;
padding-left: 18px;
li {
margin-bottom: 10px;
color: #475467;
}
}
.evidence-score-row {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-top: 14px;
color: #667085;
font-size: 13px;
}
.alert-item {
border-radius: 8px;
padding: 12px;
background: #f8fafc;
strong,
span {
display: block;
}
span {
margin-top: 4px;
color: #667085;
}
}
.severity-high {
background: #fff1f3;
}
.severity-medium {
background: #fff7ed;
}
@media (max-width: 1080px) {
.page-head,
.main-grid,
.assignment-form,
.metric-grid {
grid-template-columns: 1fr;
}
.page-head {
display: block;
}
.head-actions {
display: grid;
grid-template-columns: 1fr;
margin-top: 14px;
}
}
</style>
+2 -2
View File
@@ -122,8 +122,8 @@
<article class="panel training-panel"> <article class="panel training-panel">
<div class="panel-title table-title"> <div class="panel-title table-title">
<h2>最近训练与复盘</h2> <h2>最近训练与复盘</h2>
<el-button link type="primary" @click="go('/train/reviews')" <el-button link type="primary" @click="go('/train/tasks')"
>查看全部 <el-icon><ArrowRight /></el-icon >任务运营 <el-icon><ArrowRight /></el-icon
></el-button> ></el-button>
</div> </div>
<div class="record-table-wrap" tabindex="0" role="region" aria-label="最近训练与复盘表,可横向滚动"> <div class="record-table-wrap" tabindex="0" role="region" aria-label="最近训练与复盘表,可横向滚动">
-173
View File
@@ -1,173 +0,0 @@
<template>
<div class="difficulty-page">
<section class="page-head">
<div>
<h1>常见难题</h1>
<p>按岗位和项目权限聚合低分、红线与错题原因,并一键生成定向专项。</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
</section>
<section class="filter-panel panel">
<el-select v-model="position" clearable placeholder="全部岗位" @change="load">
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="windowDays" placeholder="统计窗口" @change="load">
<el-option label="近7天" :value="7" />
<el-option label="近30天" :value="30" />
<el-option label="近90天" :value="90" />
</el-select>
<el-select v-model="sortKey" placeholder="排序方式">
<el-option label="出现次数" value="count" />
<el-option label="涉及人数" value="people" />
<el-option label="低分优先" value="score" />
<el-option label="最近发生" value="latest" />
</el-select>
</section>
<section class="metric-grid">
<div class="metric-card"><span>难题组合</span><strong>{{ rows.length }}</strong></div>
<div class="metric-card"><span>累计出现</span><strong>{{ totalCount }}</strong></div>
<div class="metric-card"><span>单项最多涉及</span><strong>{{ totalPeople }}</strong></div>
<div class="metric-card"><span>平均得分</span><strong>{{ averageScore }}</strong></div>
</section>
<section class="panel table-panel">
<el-table v-loading="loading" :data="sortedRows" empty-text="当前项目范围内暂无常见难题">
<el-table-column prop="scenarioName" label="场景" min-width="180" show-overflow-tooltip />
<el-table-column prop="position" label="岗位" width="110" />
<el-table-column prop="cause" label="归因" min-width="150" show-overflow-tooltip />
<el-table-column prop="count" label="出现次数" width="96" sortable />
<el-table-column prop="affectedPeople" label="涉及人数" width="96" sortable />
<el-table-column prop="averageScore" label="平均分" width="90" sortable>
<template #default="{ row }"><span :class="row.averageScore < 80 ? 'low-score' : ''">{{ row.averageScore }}</span></template>
</el-table-column>
<el-table-column prop="latestTime" label="最近发生" width="170" />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }"><el-button type="danger" link @click="openAssign(row)">一键派专项</el-button></template>
</el-table-column>
</el-table>
</section>
<el-dialog v-model="assignVisible" title="一键生成定向专项" width="520px">
<div v-if="activeRow" class="assign-summary">
<strong>{{ activeRow.scenarioName }}</strong>
<span>{{ activeRow.cause }} · {{ activeRow.affectedPeople }} 人涉及</span>
<el-alert type="info" :closable="false" title="人员范围由服务端按当前主管项目和该难题低分记录确定,前端不能越权指定。" />
<el-date-picker v-model="dueDate" value-format="YYYY-MM-DD" type="date" :disabled-date="disablePast" placeholder="截止日期" />
<div class="quick-dates">
<el-button v-for="item in quickDates" :key="item.days" size="small" @click="dueDate = dateAfter(item.days)">{{ item.label }}</el-button>
</div>
</div>
<template #footer>
<el-button @click="assignVisible = false">取消</el-button>
<el-button type="danger" :loading="assigning" :disabled="!dueDate" @click="submitAssign">确认派发</el-button>
</template>
</el-dialog>
<el-dialog v-model="resultVisible" title="派发结果" width="620px">
<el-result v-if="batchResult" :icon="batchResult.failed ? 'warning' : 'success'" :title="`成功 ${batchResult.created + batchResult.duplicate} 人,失败 ${batchResult.failed} 人`" />
<el-table v-if="batchResult?.results?.length" :data="batchResult.results" max-height="320">
<el-table-column prop="traineeName" label="员工" min-width="120" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="message" label="说明" min-width="180" />
</el-table>
</el-dialog>
</div>
</template>
<script setup name="TrainDifficulties" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import {
assignPracticeDifficulty,
listPracticeDifficulties,
type PracticeAssignmentBatchResponse,
type PracticeDifficulty
} from '@/api/aihr/mobile-admin';
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
const quickDates = [{ label: '当天', days: 0 }, { label: '明天', days: 1 }, { label: '3天内', days: 3 }, { label: '7天内', days: 7 }];
const rows = ref<PracticeDifficulty[]>([]);
const loading = ref(false);
const assigning = ref(false);
const position = ref('');
const windowDays = ref(30);
const sortKey = ref<'count' | 'people' | 'score' | 'latest'>('count');
const assignVisible = ref(false);
const resultVisible = ref(false);
const activeRow = ref<PracticeDifficulty | null>(null);
const dueDate = ref('');
const batchResult = ref<PracticeAssignmentBatchResponse | null>(null);
const sortedRows = computed(() => [...rows.value].sort((a, b) => {
if (sortKey.value === 'people') return b.affectedPeople - a.affectedPeople;
if (sortKey.value === 'score') return a.averageScore - b.averageScore;
if (sortKey.value === 'latest') return String(b.latestTime).localeCompare(String(a.latestTime));
return b.count - a.count;
}));
const totalCount = computed(() => rows.value.reduce((sum, item) => sum + item.count, 0));
const totalPeople = computed(() => rows.value.length ? Math.max(0, ...rows.value.map((item) => item.affectedPeople)) : 0);
const averageScore = computed(() => rows.value.length
? Math.round(rows.value.reduce((sum, item) => sum + item.averageScore * item.count, 0) / Math.max(1, totalCount.value))
: '--');
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const dateAfter = (days: number) => { const date = new Date(); date.setHours(12, 0, 0, 0); date.setDate(date.getDate() + days); return formatDate(date); };
const disablePast = (date: Date) => date.getTime() < new Date(new Date().setHours(0, 0, 0, 0)).getTime();
const load = async () => {
loading.value = true;
try {
const response = await listPracticeDifficulties({ windowDays: windowDays.value, position: position.value || undefined });
rows.value = response.data || [];
} catch {
rows.value = [];
ElMessage.error('常见难题读取失败');
} finally { loading.value = false; }
};
const openAssign = (row: PracticeDifficulty) => { activeRow.value = row; dueDate.value = dateAfter(1); batchResult.value = null; assignVisible.value = true; };
const submitAssign = async () => {
if (!activeRow.value || !dueDate.value || assigning.value) return;
assigning.value = true;
try {
const response = await assignPracticeDifficulty({
scenarioId: activeRow.value.scenarioId,
cause: activeRow.value.cause,
dueDate: dueDate.value,
requestId: `difficulty:${activeRow.value.scenarioId}:${Date.now()}`,
windowDays: windowDays.value,
position: activeRow.value.position
});
batchResult.value = response.data;
assignVisible.value = false;
resultVisible.value = true;
await load();
} catch { ElMessage.error('专项派发失败'); }
finally { assigning.value = false; }
};
onMounted(load);
</script>
<style scoped>
.difficulty-page { padding: 24px; color: #172033; }
.page-head, .filter-panel, .metric-grid { display: flex; gap: 16px; align-items: center; }
.page-head { justify-content: space-between; margin-bottom: 18px; }
.page-head h1 { margin: 0; font-size: 28px; }
.page-head p { margin: 8px 0 0; color: #667085; }
.panel { border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; box-shadow: 0 4px 18px rgba(16,24,40,.05); }
.filter-panel { padding: 16px; }
.filter-panel :deep(.el-select) { width: 200px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 16px 0; }
.metric-card { display: grid; gap: 8px; padding: 18px; border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; }
.metric-card span { color: #667085; }
.metric-card strong { font-size: 28px; }
.table-panel { overflow: hidden; padding: 8px; }
.low-score { color: #d92d20; font-weight: 800; }
.assign-summary { display: grid; gap: 14px; }
.assign-summary > span { color: #667085; }
.quick-dates { display: flex; gap: 8px; }
@media (max-width: 1100px) {
.difficulty-page { padding: 16px; }
.filter-panel { flex-wrap: wrap; }
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
+17 -122
View File
@@ -8,29 +8,17 @@
<div class="head-actions"> <div class="head-actions">
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span> <span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
<span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span> <span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span>
<el-date-picker
v-model="pilotDateRange"
class="pilot-date-range"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="试点开始"
end-placeholder="试点结束"
unlink-panels
/>
<el-button :icon="Download" :loading="exportingPilot" :disabled="!pilotExportWindow" @click="exportPilotCsv">导出试点CSV</el-button>
<el-button @click="router.push('/index')">返回首页</el-button> <el-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置流程</el-button> <el-button type="danger" :icon="Refresh" @click="resetPractice">重置流程</el-button>
</div> </div>
</section> </section>
<el-alert <el-alert
v-if="recordsFallback"
class="data-warning" class="data-warning"
type="warning" type="info"
:closable="false" :closable="false"
show-icon show-icon
title="训练记录接口暂不可用,当前显示演示记录,不代表真实训练数据。" title="此页仅用于不绑定员工身份的运营预览;真实训练历史、主管复盘、团队难题和试点数据均不在此页展示。"
/> />
<section class="flow-strip"> <section class="flow-strip">
@@ -182,7 +170,7 @@
<div class="calibration-card"> <div class="calibration-card">
<div class="calibration-head"> <div class="calibration-head">
<strong>人工校准</strong> <strong>人工校准</strong>
<small>{{ calibrationMessage || (canCalibrate ? '用于 M5 分档一致率样本' : '真实 API 评分后可记录') }}</small> <small>{{ calibrationMessage || (canCalibrate ? '仅作运营预览校准,不计入员工成长或正式试点' : '完成后端运营预览评分即可记录') }}</small>
</div> </div>
<el-input-number v-model="calibrationScore" :min="0" :max="100" :disabled="!canCalibrate" /> <el-input-number v-model="calibrationScore" :min="0" :max="100" :disabled="!canCalibrate" />
<el-input v-model="calibrationReason" type="textarea" :rows="2" resize="none" :disabled="!canCalibrate" placeholder="校准原因" /> <el-input v-model="calibrationReason" type="textarea" :rows="2" resize="none" :disabled="!canCalibrate" placeholder="校准原因" />
@@ -194,21 +182,6 @@
</article> </article>
</section> </section>
<section class="panel record-panel">
<div class="panel-title">
<el-icon><Tickets /></el-icon>
<h2>最近对练记录</h2>
</div>
<el-table v-loading="recordsLoading" :data="records" size="small" class="record-table">
<el-table-column prop="time" label="时间" width="128" />
<el-table-column prop="trainee" label="学员" width="120" />
<el-table-column prop="scene" label="场景" min-width="150" />
<el-table-column prop="score" label="得分" width="80" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column prop="summary" label="AI 摘要" min-width="260" />
</el-table>
</section>
<section class="panel prompt-panel"> <section class="panel prompt-panel">
<div class="panel-title title-with-extra"> <div class="panel-title title-with-extra">
<div> <div>
@@ -311,14 +284,12 @@
<script setup name="TrainPractice" lang="ts"> <script setup name="TrainPractice" lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { Download, Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue'; import { Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import FileSaver from 'file-saver';
import { import {
calibratePracticeSession, calibratePracticeSession,
createPromptTemplate, createPromptTemplate,
exportPracticePilotCsv,
finishPracticeSession, finishPracticeSession,
getPracticePrepCard, getPracticePrepCard,
listPromptTemplates, listPromptTemplates,
@@ -331,9 +302,7 @@ import {
type PracticePrepCardResponse, type PracticePrepCardResponse,
type PracticeScenarioResponse type PracticeScenarioResponse
} from '@/api/aihr/practice'; } from '@/api/aihr/practice';
import { listPracticeReviews } from '@/api/aihr/mobile-admin';
import { searchKnowledgeSop, type SopSearchResponse } from '@/api/aihr/sop'; import { searchKnowledgeSop, type SopSearchResponse } from '@/api/aihr/sop';
import { resolvePilotDateWindow } from './pilot-export';
type ChatRole = 'customer' | 'trainee' | 'coach'; type ChatRole = 'customer' | 'trainee' | 'coach';
@@ -459,12 +428,6 @@ const fallbackScenarios: ScenarioSeed[] = [
} }
]; ];
const baseRecords = [
{ time: '07-02 09:32', trainee: '张三', scene: '客服应对投诉', score: 86, status: '已完成', summary: '能先安抚并确认诉求,但反馈时限需要更明确。' },
{ time: '07-02 09:12', trainee: '李四', scene: '催缴沟通技巧', score: 78, status: '已完成', summary: '费用争议回应完整,服务改进承诺偏弱。' },
{ time: '07-02 08:47', trainee: '王五', scene: '突发事件处理', score: 72, status: '进行中', summary: '多方协调表达不够清晰,建议继续一轮。' }
];
const scenarios = ref<ScenarioSeed[]>([...fallbackScenarios]); const scenarios = ref<ScenarioSeed[]>([...fallbackScenarios]);
const scenarioId = ref(fallbackScenarios[0].id); const scenarioId = ref(fallbackScenarios[0].id);
const started = ref(false); const started = ref(false);
@@ -474,7 +437,7 @@ const showGoal = ref(false);
const activeRound = ref(0); const activeRound = ref(0);
const draftReply = ref(''); const draftReply = ref('');
const sessionId = ref(''); const sessionId = ref('');
const apiMode = ref<'local' | 'api' | 'fallback'>('local'); const apiMode = ref<'local' | 'preview' | 'fallback'>('local');
const apiScores = ref<ScenarioSeed['scores'] | null>(null); const apiScores = ref<ScenarioSeed['scores'] | null>(null);
const apiTotal = ref<number | null>(null); const apiTotal = ref<number | null>(null);
const apiRewrite = ref(''); const apiRewrite = ref('');
@@ -487,9 +450,6 @@ const sopHelp = ref<SopSearchResponse | null>(null);
const sopHelpLoading = ref(false); const sopHelpLoading = ref(false);
const sopHelpMessage = ref(''); const sopHelpMessage = ref('');
const conversation = ref<ChatTurn[]>([]); const conversation = ref<ChatTurn[]>([]);
const records = ref<Array<(typeof baseRecords)[number]>>([]);
const recordsLoading = ref(true);
const recordsFallback = ref(false);
const promptTemplates = ref<PromptTemplateResponse[]>([]); const promptTemplates = ref<PromptTemplateResponse[]>([]);
const promptLoading = ref(false); const promptLoading = ref(false);
const promptSaving = ref(false); const promptSaving = ref(false);
@@ -497,9 +457,6 @@ const calibrationScore = ref(0);
const calibrationReason = ref('管理端人工校准'); const calibrationReason = ref('管理端人工校准');
const calibrationSaving = ref(false); const calibrationSaving = ref(false);
const calibrationMessage = ref(''); const calibrationMessage = ref('');
const exportingPilot = ref(false);
const pilotDateRange = ref<string[] | null>(null);
const pilotExportWindow = computed(() => resolvePilotDateWindow(pilotDateRange.value));
const promptCategoryOptions = ['practice', 'knowledge', 'supervision']; const promptCategoryOptions = ['practice', 'knowledge', 'supervision'];
const defaultPromptForm = (): PromptTemplateForm => ({ const defaultPromptForm = (): PromptTemplateForm => ({
id: null, id: null,
@@ -521,7 +478,7 @@ const selectedScenario = computed(
const promptFormMode = computed(() => (promptForm.value.id ? '编辑模板' : '新增模板')); const promptFormMode = computed(() => (promptForm.value.id ? '编辑模板' : '新增模板'));
const completedRounds = computed(() => conversation.value.filter((turn) => turn.role === 'trainee').length); const completedRounds = computed(() => conversation.value.filter((turn) => turn.role === 'trainee').length);
const canScore = computed(() => finished.value && !scored.value); const canScore = computed(() => finished.value && !scored.value);
const canCalibrate = computed(() => scored.value && apiMode.value === 'api' && Boolean(sessionId.value)); const canCalibrate = computed(() => scored.value && apiMode.value === 'preview' && Boolean(sessionId.value));
const displayScores = computed(() => apiScores.value ?? selectedScenario.value.scores); const displayScores = computed(() => apiScores.value ?? selectedScenario.value.scores);
const displayTotal = computed(() => apiTotal.value ?? selectedScenario.value.total); const displayTotal = computed(() => apiTotal.value ?? selectedScenario.value.total);
const displayRewrite = computed(() => apiRewrite.value || selectedScenario.value.rewrite); const displayRewrite = computed(() => apiRewrite.value || selectedScenario.value.rewrite);
@@ -548,8 +505,8 @@ const emotionScore = computed(() => {
return Math.max(45, 76 - completedRounds.value * 8); return Math.max(45, 76 - completedRounds.value * 8);
}); });
const apiPill = computed(() => { const apiPill = computed(() => {
if (apiMode.value === 'api') { if (apiMode.value === 'preview') {
return { text: '真实 API', className: 'pill-info' }; return { text: '运营预览 API', className: 'pill-info' };
} }
if (apiMode.value === 'fallback') { if (apiMode.value === 'fallback') {
return { text: '本地剧本', className: 'pill-warning' }; return { text: '本地剧本', className: 'pill-warning' };
@@ -563,7 +520,7 @@ const flowPill = computed(() => {
if (!scored.value) { if (!scored.value) {
return { text: '对练进行中', className: 'pill-warning' }; return { text: '对练进行中', className: 'pill-warning' };
} }
return { text: '已完成闭环', className: 'pill-success' }; return { text: '已完成运营预览', className: 'pill-success' };
}); });
const flowSteps = computed(() => [ const flowSteps = computed(() => [
{ index: '1', title: '选场景', desc: selectedScenario.value.name, active: !started.value, done: started.value || scored.value }, { index: '1', title: '选场景', desc: selectedScenario.value.name, active: !started.value, done: started.value || scored.value },
@@ -657,27 +614,6 @@ const loadScenarioOptions = async () => {
await loadPrepCard(); await loadPrepCard();
}; };
const loadRecentRecords = async () => {
recordsLoading.value = true;
try {
const { data } = await listPracticeReviews({ limit: 10 });
records.value = (data || []).map((item) => ({
time: item.time,
trainee: item.trainee,
scene: item.scene,
score: item.score,
status: item.status,
summary: item.summary
}));
recordsFallback.value = false;
} catch {
records.value = [...baseRecords];
recordsFallback.value = true;
} finally {
recordsLoading.value = false;
}
};
const appendTurn = (role: ChatRole, text: string) => { const appendTurn = (role: ChatRole, text: string) => {
const nameMap = { customer: 'AI 业主', trainee: '学员', coach: 'AI 教练' }; const nameMap = { customer: 'AI 业主', trainee: '学员', coach: 'AI 教练' };
conversation.value.push({ id: `${role}-${conversation.value.length}`, role, name: nameMap[role], text }); conversation.value.push({ id: `${role}-${conversation.value.length}`, role, name: nameMap[role], text });
@@ -721,11 +657,11 @@ const startPractice = async () => {
mode: 'real' mode: 'real'
}); });
sessionId.value = data.sessionId; sessionId.value = data.sessionId;
apiMode.value = 'api'; apiMode.value = 'preview';
apiTrust.value = data.trust; apiTrust.value = data.trust;
apiEmotion.value = data.emotion; apiEmotion.value = data.emotion;
appendTurn('customer', data.customerText || fallbackCustomer); appendTurn('customer', data.customerText || fallbackCustomer);
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} 对练`); ElMessage.success(`已进入 ${selectedScenario.value.name} 的后端运营预览;不会使用或生成员工身份`);
} catch { } catch {
sessionId.value = ''; sessionId.value = '';
apiMode.value = 'fallback'; apiMode.value = 'fallback';
@@ -745,7 +681,7 @@ const continueRound = async () => {
appendTurn('trainee', traineeText); appendTurn('trainee', traineeText);
draftReply.value = ''; draftReply.value = '';
if (apiMode.value === 'api' && sessionId.value) { if (apiMode.value === 'preview' && sessionId.value) {
try { try {
const { data } = await submitPracticeTurn({ const { data } = await submitPracticeTurn({
sessionId: sessionId.value, sessionId: sessionId.value,
@@ -803,16 +739,7 @@ const askSopHelp = async () => {
}; };
const finishScore = async () => { const finishScore = async () => {
let nextRecord = { if (apiMode.value === 'preview' && sessionId.value) {
time: '07-02 10:42',
trainee: selectedScenario.value.trainee,
scene: selectedScenario.value.name,
score: displayTotal.value,
status: '已完成',
summary: displaySummary.value
};
if (apiMode.value === 'api' && sessionId.value) {
try { try {
const { data } = await finishPracticeSession({ sessionId: sessionId.value }); const { data } = await finishPracticeSession({ sessionId: sessionId.value });
apiScores.value = data.score; apiScores.value = data.score;
@@ -820,8 +747,7 @@ const finishScore = async () => {
apiRewrite.value = data.mentorRewrite; apiRewrite.value = data.mentorRewrite;
apiSummary.value = data.aiComment; apiSummary.value = data.aiComment;
apiTrust.value = data.trustCurve?.[data.trustCurve.length - 1] ?? selectedScenario.value.trustEnd; apiTrust.value = data.trustCurve?.[data.trustCurve.length - 1] ?? selectedScenario.value.trustEnd;
nextRecord = data.records?.[0] ?? { ...nextRecord, score: data.totalScore, summary: data.aiComment }; ElMessage.success('运营预览评分已由后端 API 返回,不计入员工训练记录');
ElMessage.success('三角色对练评分已由后端 API 返回');
} catch { } catch {
apiMode.value = 'fallback'; apiMode.value = 'fallback';
apiScores.value = null; apiScores.value = null;
@@ -839,15 +765,14 @@ const finishScore = async () => {
scored.value = true; scored.value = true;
calibrationScore.value = Math.max(0, Math.min(100, Math.round(Number(displayTotal.value) || 0))); calibrationScore.value = Math.max(0, Math.min(100, Math.round(Number(displayTotal.value) || 0)));
records.value = [nextRecord, ...records.value]; if (apiMode.value !== 'preview') {
if (apiMode.value !== 'api') { ElMessage.success('考官评分已生成,仅保留在当前页面的本地演示记录');
ElMessage.success('考官评分已生成,并写入本地对练记录');
} }
}; };
const saveCalibration = async () => { const saveCalibration = async () => {
if (!canCalibrate.value) { if (!canCalibrate.value) {
ElMessage.warning('请先完成一次真实 API 对练评分'); ElMessage.warning('请先完成一次后端运营预览评分');
return; return;
} }
calibrationSaving.value = true; calibrationSaving.value = true;
@@ -866,25 +791,6 @@ const saveCalibration = async () => {
} }
}; };
const exportPilotCsv = async () => {
const dateWindow = pilotExportWindow.value;
if (!dateWindow) {
ElMessage.warning('请选择完整的试点批次日期');
return;
}
const { startDate, endDate } = dateWindow;
exportingPilot.value = true;
try {
const data = await exportPracticePilotCsv({ startDate, endDate });
FileSaver.saveAs(new Blob([data], { type: 'text/csv;charset=utf-8' }), `aihr-practice-pilot-${startDate}-${endDate}.csv`);
ElMessage.success('试点数据 CSV 已导出');
} catch {
ElMessage.error('试点数据导出失败,请检查权限');
} finally {
exportingPilot.value = false;
}
};
const promptTemplateToForm = (template: PromptTemplateResponse): PromptTemplateForm => ({ const promptTemplateToForm = (template: PromptTemplateResponse): PromptTemplateForm => ({
id: template.id, id: template.id,
code: template.code || '', code: template.code || '',
@@ -1004,7 +910,6 @@ const togglePromptTemplate = async (template: PromptTemplateResponse) => {
onMounted(() => { onMounted(() => {
void loadScenarioOptions(); void loadScenarioOptions();
void loadPromptTemplates(); void loadPromptTemplates();
void loadRecentRecords();
}); });
</script> </script>
@@ -1044,10 +949,6 @@ onMounted(() => {
flex-wrap: wrap; flex-wrap: wrap;
} }
.pilot-date-range {
width: 250px;
}
.flow-strip { .flow-strip {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1451,12 +1352,6 @@ onMounted(() => {
background: #edf4ff; background: #edf4ff;
} }
.record-panel {
:deep(.el-table__inner-wrapper::before) {
display: none;
}
}
.record-table { .record-table {
border: 1px solid #edf0f5; border: 1px solid #edf0f5;
border-radius: 8px; border-radius: 8px;
-610
View File
@@ -1,610 +0,0 @@
<template>
<div class="review-page">
<section class="page-head">
<div>
<h1>复盘校准</h1>
<p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p>
</div>
<div class="head-actions">
<el-button type="danger" :loading="exporting" @click="exportPilot">导出试点 CSV</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadReviews">刷新</el-button>
</div>
</section>
<section class="filter-panel panel">
<el-date-picker v-model="startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
<el-date-picker v-model="endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
<el-select v-model="selectedPosition" placeholder="岗位">
<el-option label="生活顾问" value="生活顾问" />
</el-select>
<el-select v-model="selectedProjectCode" clearable filterable placeholder="组织范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
</section>
<el-alert
v-if="reviewsFallback || metricsFallback"
class="data-warning"
type="warning"
:closable="false"
show-icon
title="部分真实数据接口暂不可用;当前页面不展示演示复盘记录或猜测性试点指标。"
/>
<section class="metric-grid">
<div class="metric-card">
<span>校准样本</span><strong>{{ calibrationMetric }}</strong>
</div>
<div class="metric-card">
<span>分档一致率</span><strong>{{ metrics.consistency }}</strong>
</div>
<div class="metric-card">
<span>SOP 可用率</span><strong>{{ metrics.sopUsable }}</strong>
</div>
<div class="metric-card">
<span>严格门禁</span><strong :class="metrics.ready ? 'is-ready' : 'is-pending'">{{ metrics.ready ? '通过' : '待补样本' }}</strong>
</div>
</section>
<section class="review-layout">
<article class="panel list-panel">
<div class="panel-title">
<h2>待复盘记录</h2>
<el-tag type="danger">{{ visibleReviews.length }} 条</el-tag>
</div>
<div class="list-filters">
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
<el-option
v-for="person in orgPeople"
:key="person.extPartyId"
:label="`${person.personName || person.extPartyId} / ${person.positionName}`"
:value="person.extPartyId"
/>
</el-select>
</div>
<el-table v-loading="loading" :data="visibleReviews" size="small" highlight-current-row @row-click="selectReview">
<el-table-column prop="trainee" label="学员" width="96" />
<el-table-column prop="scene" label="场景" min-width="150" show-overflow-tooltip />
<el-table-column prop="score" label="得分" width="70" />
<el-table-column prop="status" label="状态" width="92">
<template #default="{ row }">
<el-tag :type="row.status === '待复盘' ? 'warning' : 'success'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="time" label="时间" width="128" />
</el-table>
</article>
<article class="panel detail-panel">
<template v-if="detail">
<div class="panel-title">
<h2>{{ detail.trainee }} · {{ detail.scene }}</h2>
<el-tag :type="detail.score >= 80 ? 'success' : 'danger'">{{ detail.score }} 分</el-tag>
</div>
<div class="score-grid">
<div v-for="item in detail.scoreItems" :key="item.label" class="score-card">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.note }}</small>
</div>
</div>
<div class="dialogue-list">
<div v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-item">
<div class="dialogue-head">
<strong>{{ turn.label }}</strong>
<el-tag v-if="hasAudio(turn)" size="small" type="info">有录音</el-tag>
</div>
<p>{{ turn.text }}</p>
<div v-if="hasAudio(turn)" class="dialogue-audio-row">
<audio v-if="audioSource(turn, index)" controls :src="audioSource(turn, index)" />
<el-button v-else size="small" :loading="audioLoading[audioKey(index)]" @click.stop="loadAudio(turn, index)">加载录音</el-button>
<span v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</span>
</div>
</div>
</div>
<div class="annotation-list">
<h3>逐句标注</h3>
<el-table :data="detail.annotations" size="small" empty-text="暂无逐句标注">
<el-table-column prop="sourceText" label="原句" min-width="180" show-overflow-tooltip />
<el-table-column prop="issue" label="问题" min-width="160" />
<el-table-column prop="rewrite" label="推荐改写" min-width="220" show-overflow-tooltip />
<el-table-column prop="sopPoint" label="SOP点" min-width="160" />
</el-table>
</div>
<el-form class="review-form" label-position="top">
<el-form-item label="复盘建议">
<el-input v-model="reviewForm.advice" type="textarea" :rows="3" placeholder="写给员工的复盘建议" />
</el-form-item>
<div class="form-grid">
<el-form-item label="人工贡献分">
<el-input-number v-model="reviewForm.incentivePoint" :min="0" :max="100" placeholder="可选,不填不计分" />
</el-form-item>
<el-form-item label="人工校准总分">
<el-input-number v-model="reviewForm.correctedTotal" :min="0" :max="100" />
</el-form-item>
</div>
<el-form-item label="校准原因">
<el-input v-model="reviewForm.calibrationReason" placeholder="例如:AI 对安抚情绪权重偏低" />
</el-form-item>
<div class="form-grid">
<el-form-item label="下次训练">
<el-select v-model="reviewForm.nextScenarioId" clearable placeholder="不派发">
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="派发原因">
<el-input v-model="reviewForm.nextReason" placeholder="例如:低分维度定向再练" />
</el-form-item>
<div class="form-actions">
<el-button type="danger" :loading="saving" @click="submitReview">标记已复盘</el-button>
</div>
</el-form>
</template>
<el-empty v-else description="选择一条待复盘记录" />
</article>
</section>
</div>
</template>
<script setup name="TrainReviews" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { calibratePracticeSession, exportPracticePilotCsv, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import { resolvePilotDateWindow } from './pilot-export';
import {
getPracticeAudioBlobUrl,
getOrgSnapshot,
getPracticeReview,
listPracticeReviews,
markPracticeReviewed,
type OrgSnapshotResponse,
type ReviewDetail,
type ReviewRecord
} from '@/api/aihr/mobile-admin';
const loading = ref(false);
const saving = ref(false);
const reviews = ref<ReviewRecord[]>([]);
const detail = ref<ReviewDetail | null>(null);
const scenarios = ref<PracticeScenarioResponse[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const selectedProjectCode = ref('');
const selectedExtPartyId = ref('');
const selectedPosition = ref('生活顾问');
const exporting = ref(false);
const reviewsFallback = ref(false);
const metricsFallback = ref(false);
const startDate = ref('2026-07-01');
const endDate = ref('2026-07-31');
const metrics = reactive({ calibrationCount: null as number | null, consistency: '--', sopUsable: '--', ready: false });
const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
const pilotDateWindow = computed(() => resolvePilotDateWindow([startDate.value, endDate.value]));
const reviewForm = reactive({
reviewer: '主管',
advice: '',
incentivePoint: null as number | null,
correctedTotal: 0,
calibrationReason: '',
nextScenarioId: '',
nextReason: ''
});
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const visibleReviews = computed(() => reviews.value);
const calibrationMetric = computed(() => (metrics.calibrationCount === null ? '--' : `${metrics.calibrationCount} / 20`));
type ReviewDialogueTurn = ReviewDetail['dialogue'][number];
const audioKey = (index: number) => `${detail.value?.id || 'review'}-${index}`;
const inlineAudioSource = (turn: ReviewDialogueTurn) =>
turn.audioUrl?.startsWith('data:audio/') ? turn.audioUrl : '';
const hasAudio = (turn: ReviewDialogueTurn) => Boolean(turn.audioOssId || inlineAudioSource(turn));
const audioSource = (turn: ReviewDialogueTurn, index: number) => audioSources.value[audioKey(index)] || inlineAudioSource(turn);
const needsCalibration = () =>
Boolean(detail.value?.sessionId) && (reviewForm.correctedTotal !== detail.value.score || Boolean(reviewForm.calibrationReason.trim()));
const releaseAudioSources = () => {
Object.values(audioSources.value).forEach((url) => URL.revokeObjectURL(url));
audioSources.value = {};
audioLoading.value = {};
audioErrors.value = {};
};
const loadAudio = async (turn: ReviewDialogueTurn, index: number) => {
const key = audioKey(index);
if (audioSource(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
audioLoading.value = { ...audioLoading.value, [key]: true };
audioErrors.value = { ...audioErrors.value, [key]: '' };
try {
const url = await getPracticeAudioBlobUrl(turn.audioOssId);
audioSources.value = { ...audioSources.value, [key]: url };
} catch {
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
} finally {
audioLoading.value = { ...audioLoading.value, [key]: false };
}
};
const loadScenarios = async () => {
try {
const result = await listPracticeScenarios();
scenarios.value = result.data || [];
} catch (error) {
scenarios.value = [];
}
};
const loadOrgSnapshot = async () => {
try {
const result = await getOrgSnapshot({
status: 'active',
projectCode: selectedProjectCode.value,
pageNum: 1,
pageSize: 500
});
orgSnapshot.value = result.data;
} catch (error) {
orgSnapshot.value = null;
}
};
const loadReviews = async () => {
loading.value = true;
try {
const result = await listPracticeReviews({
projectCode: selectedProjectCode.value,
extPartyId: selectedExtPartyId.value,
limit: 50
});
reviews.value = result.data || [];
reviewsFallback.value = false;
await selectFirstVisibleReview();
} catch (error) {
reviews.value = [];
detail.value = null;
releaseAudioSources();
reviewsFallback.value = true;
ElMessage.error('待复盘列表加载失败');
} finally {
loading.value = false;
}
};
const loadMetrics = async () => {
const dateWindow = pilotDateWindow.value;
if (!dateWindow) {
metrics.calibrationCount = null;
metrics.consistency = '--';
metrics.sopUsable = '--';
metrics.ready = false;
metricsFallback.value = false;
return;
}
try {
const blob = await exportPracticePilotCsv(dateWindow);
const csv = await blob.text();
const value = (key: string) => csv.match(new RegExp(`(?:^|\\n)"?${key}"?,"?([^"\\n]*)`))?.[1] || '';
metrics.calibrationCount = Number(value('calibration_sample_count')) || 0;
metrics.consistency = value('ai_human_band_consistency_rate') || '--';
metrics.sopUsable = value('sop_answer_usable_rate') || '--';
metrics.ready = value('pilot_metric_ready') === 'true';
metricsFallback.value = false;
} catch {
metrics.calibrationCount = null;
metrics.consistency = '--';
metrics.sopUsable = '--';
metrics.ready = false;
metricsFallback.value = true;
}
};
const exportPilot = async () => {
const dateWindow = pilotDateWindow.value;
if (!dateWindow) {
ElMessage.warning('请选择有效的试点起止日期');
return;
}
exporting.value = true;
try {
const blob = await exportPracticePilotCsv(dateWindow);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `aihr-pilot-${dateWindow.startDate}-${dateWindow.endDate}.csv`;
link.click();
URL.revokeObjectURL(url);
ElMessage.success('试点 CSV 已导出');
} catch {
ElMessage.error('试点 CSV 导出失败');
} finally {
exporting.value = false;
}
};
const selectReview = async (row: ReviewRecord) => {
releaseAudioSources();
const result = await getPracticeReview(row.id);
detail.value = result.data;
reviewForm.advice = result.data.reviewAdvice || '';
reviewForm.incentivePoint = result.data.incentivePoint ?? null;
reviewForm.correctedTotal = result.data.score;
reviewForm.calibrationReason = '';
reviewForm.nextScenarioId = '';
reviewForm.nextReason = '';
};
const selectFirstVisibleReview = async () => {
if (visibleReviews.value.some((item) => item.id === detail.value?.id)) return;
const first = visibleReviews.value[0];
if (!first) {
releaseAudioSources();
detail.value = null;
return;
}
await selectReview(first);
};
const submitReview = async () => {
if (!detail.value) return;
saving.value = true;
try {
if (needsCalibration()) {
await calibratePracticeSession(detail.value.sessionId, {
reviewer: reviewForm.reviewer,
correctedTotal: reviewForm.correctedTotal,
reason: reviewForm.calibrationReason
});
}
await markPracticeReviewed(detail.value.id, {
reviewer: reviewForm.reviewer,
advice: reviewForm.advice,
incentivePoint: reviewForm.incentivePoint ?? undefined,
nextScenarioId: reviewForm.nextScenarioId,
nextReason: reviewForm.nextReason
});
ElMessage.success('已完成复盘处理');
releaseAudioSources();
detail.value = null;
await loadReviews();
} catch (error) {
ElMessage.error('复盘提交失败');
} finally {
saving.value = false;
}
};
watch(selectedProjectCode, async () => {
selectedExtPartyId.value = '';
await loadOrgSnapshot();
await loadReviews();
});
watch(selectedExtPartyId, () => {
loadReviews();
});
watch([startDate, endDate], () => {
loadMetrics();
});
onMounted(() => {
loadScenarios();
loadOrgSnapshot();
loadReviews();
loadMetrics();
});
onBeforeUnmount(releaseAudioSources);
</script>
<style scoped lang="scss">
.review-page {
min-height: 100%;
padding: 24px;
background: #f5f7fb;
color: #1f2937;
}
.page-head,
.panel,
.score-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
h1 {
margin: 0 0 8px;
font-size: 24px;
}
p {
margin: 0;
color: #667085;
}
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.filter-panel {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.metric-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
padding: 16px;
span {
display: block;
color: #667085;
font-size: 13px;
}
strong {
display: block;
margin-top: 8px;
color: #16864b;
font-size: 24px;
}
.is-pending {
color: #d97706;
font-size: 18px;
}
}
.review-layout {
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr);
gap: 16px;
margin-top: 16px;
}
.panel {
padding: 18px;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 17px;
}
}
.list-filters {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.score-grid,
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.score-card {
padding: 12px;
span,
small {
display: block;
color: #667085;
}
strong {
display: block;
margin: 6px 0;
font-size: 20px;
}
}
.dialogue-list {
display: grid;
gap: 10px;
margin-top: 16px;
}
.dialogue-item {
border-radius: 8px;
background: #f8fafc;
padding: 12px;
p {
margin: 6px 0 0;
color: #475467;
}
}
.dialogue-head,
.dialogue-audio-row {
display: flex;
align-items: center;
gap: 10px;
}
.dialogue-audio-row {
margin-top: 10px;
audio {
width: min(360px, 100%);
}
}
.audio-error {
color: #d92d20;
font-size: 12px;
}
.annotation-list {
margin-top: 18px;
h3 {
margin: 0 0 10px;
font-size: 15px;
}
}
.review-form {
margin-top: 18px;
}
.form-actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 1080px) {
.review-layout,
.list-filters,
.score-grid,
.form-grid,
.filter-panel,
.metric-grid {
grid-template-columns: 1fr;
}
.head-actions {
align-items: stretch;
flex-direction: column;
}
}
</style>
+277 -10
View File
@@ -3,7 +3,7 @@
<section class="page-head"> <section class="page-head">
<div> <div>
<h1>场景库管理</h1> <h1>场景库管理</h1>
<p>五岗位训练内容 · 场景回合、版本、Rubric 与启停维护</p> <p>住宅物业五岗位成长目录 · 场景回合、能力项、审核、版本与 Rubric 统一维护</p>
</div> </div>
<el-button type="danger" @click="openCreate">新增场景</el-button> <el-button type="danger" @click="openCreate">新增场景</el-button>
</section> </section>
@@ -15,6 +15,12 @@
<el-select v-model="filters.scenarioType" placeholder="场景分类" clearable> <el-select v-model="filters.scenarioType" placeholder="场景分类" clearable>
<el-option v-for="item in scenarioTypes" :key="item" :label="item" :value="item" /> <el-option v-for="item in scenarioTypes" :key="item" :label="item" :value="item" />
</el-select> </el-select>
<el-select v-model="filters.growthLevel" placeholder="成长层级" clearable>
<el-option v-for="item in growthLevels" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="filters.reviewStatus" placeholder="审核状态" clearable>
<el-option v-for="item in reviewStatuses" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="filters.enabled" placeholder="状态" clearable> <el-select v-model="filters.enabled" placeholder="状态" clearable>
<el-option label="启用" :value="true" /> <el-option label="启用" :value="true" />
<el-option label="停用" :value="false" /> <el-option label="停用" :value="false" />
@@ -23,18 +29,55 @@
<el-button type="danger" @click="queryScenes">查询</el-button> <el-button type="danger" @click="queryScenes">查询</el-button>
</section> </section>
<section class="matrix-card">
<div class="matrix-head">
<div>
<h2>矩阵缺口</h2>
<p>按住宅物业 × 岗位 × 层级 × 能力项核对。只有“已发布且启用”才计为可训练内容。</p>
</div>
<el-button text type="danger" @click="loadMatrix">刷新矩阵</el-button>
</div>
<el-table :data="visibleMatrixRows" size="small" max-height="240">
<el-table-column prop="position" label="岗位" width="100" />
<el-table-column prop="growthLevel" label="层级" width="90" />
<el-table-column prop="competencyName" label="能力项" min-width="180" />
<el-table-column prop="publishedCount" label="已发布" width="82" />
<el-table-column prop="pendingReviewCount" label="待审核" width="82" />
<el-table-column prop="draftCount" label="草稿" width="70" />
<el-table-column label="覆盖状态" width="100">
<template #default="{ row }">
<el-tag :type="matrixTagType(row.coverageStatus)" size="small">{{ row.coverageStatus }}</el-tag>
</template>
</el-table-column>
</el-table>
</section>
<section class="table-card"> <section class="table-card">
<el-table v-loading="loading" :data="pagedScenarios" row-key="id"> <el-table v-loading="loading" :data="pagedScenarios" row-key="id">
<el-table-column prop="position" label="岗位" width="100" /> <el-table-column prop="position" label="岗位" width="100" />
<el-table-column prop="name" label="场景名称" min-width="180" /> <el-table-column prop="name" label="场景名称" min-width="180" />
<el-table-column prop="scenarioType" label="分类" min-width="120" /> <el-table-column prop="scenarioType" label="分类" min-width="120" />
<el-table-column prop="growthLevel" label="层级" width="84" />
<el-table-column label="能力项" min-width="160">
<template #default="{ row }">{{ capabilityName(row) }}</template>
</el-table-column>
<el-table-column prop="difficulty" label="难度" width="92"> <el-table-column prop="difficulty" label="难度" width="92">
<template #default="{ row }">{{ difficultyLabel(row.difficulty) }}</template> <template #default="{ row }">{{ difficultyLabel(row.difficulty) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="Rubric" min-width="180"> <el-table-column label="Rubric" min-width="180">
<template #default="{ row }"> {{ row.rubric?.name || '未配置' }} {{ row.rubric?.version || '' }} </template> <template #default="{ row }"> {{ row.rubric?.name || '未配置' }} {{ row.rubric?.version || '' }} </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="88"> <el-table-column label="审核" width="98">
<template #default="{ row }">
<el-tag :type="reviewTagType(row.reviewStatus)" size="small">{{ row.reviewStatus || '草稿' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="业务审核" min-width="210" show-overflow-tooltip>
<template #default="{ row }">
{{ reviewTrail(row) }}
</template>
</el-table-column>
<el-table-column label="启停" width="72">
<template #default="{ row }"> <template #default="{ row }">
<span :class="['status-text', row.enabled === false ? 'disabled' : 'enabled']"> <span :class="['status-text', row.enabled === false ? 'disabled' : 'enabled']">
{{ row.enabled === false ? '停用' : '启用' }} {{ row.enabled === false ? '停用' : '启用' }}
@@ -42,9 +85,11 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="150" /> <el-table-column prop="updateTime" label="更新时间" width="150" />
<el-table-column label="操作" width="140" fixed="right"> <el-table-column label="操作" width="286" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="danger" @click="openEdit(row)">编辑</el-button> <el-button link type="danger" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="advanceReviewStatus(row)">{{ reviewActionLabel(row) }}</el-button>
<el-button v-if="row.reviewStatus === '待业务审核'" link type="info" @click="returnToDraft(row)">退回草稿</el-button>
<el-button link type="danger" @click="toggleEnabled(row)"> <el-button link type="danger" @click="toggleEnabled(row)">
{{ row.enabled === false ? '启用' : '停用' }} {{ row.enabled === false ? '启用' : '停用' }}
</el-button> </el-button>
@@ -89,12 +134,60 @@
<el-form-item label="场景分类"><el-input v-model="form.scenarioType" /></el-form-item> <el-form-item label="场景分类"><el-input v-model="form.scenarioType" /></el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item label="难度"><el-input-number v-model="form.difficulty" :min="1" :max="5" class="full-control" /></el-form-item> <el-form-item label="成长层级">
<el-select v-model="form.growthLevel" class="full-control">
<el-option v-for="item in growthLevels" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="项目类型"><el-input v-model="form.projectType" /></el-form-item> <el-row :gutter="16">
<el-col :span="12">
<el-form-item label="能力项">
<el-select v-model="form.competencyCode" class="full-control" placeholder="选择本岗位、层级的能力项">
<el-option
v-if="form.competencyCode && !currentCapabilityOptions.some((item) => item.code === form.competencyCode)"
:label="`${form.competencyCode}(待归类)`"
:value="form.competencyCode"
/>
<el-option v-for="item in currentCapabilityOptions" :key="item.code" :label="item.name" :value="item.code" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="难度"><el-input-number v-model="form.difficulty" :min="1" :max="5" class="full-control" /></el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="目录版本"><el-input v-model="form.curriculumVersion" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="项目类型"><el-input v-model="form.projectType" disabled /></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="内容风险" required>
<el-select v-model="form.riskLevel" class="full-control">
<el-option label="待评估" value="待评估" />
<el-option label="常规" value="常规" />
<el-option label="高风险(需两位不同审核人)" value="高风险" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8"><el-form-item label="协同岗位(可选)"><el-input v-model="form.collaborationPositions" placeholder="例如:保安、客服" /></el-form-item></el-col>
</el-row>
<el-alert
:closable="false"
type="info"
show-icon
title="保存后为草稿;已发布内容再次编辑会退回“待业务审核”。发布时服务端会校验住宅、五岗位、能力项、风险级别、业务来源/SOP、服务红线、回合与五维 Rubric;收费、安全、投诉等受控内容会自动升级为高风险,需两位不同审核人。"
/>
<el-form-item label="业主人设"><el-input v-model="form.customerPersona" /></el-form-item> <el-form-item label="业主人设"><el-input v-model="form.customerPersona" /></el-form-item>
<el-form-item label="开场话术" required><el-input v-model="form.openingText" type="textarea" :rows="2" /></el-form-item> <el-form-item label="开场话术" required><el-input v-model="form.openingText" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="业务来源 / SOP 引用" required>
<el-input v-model="form.sopRefs" type="textarea" :rows="2" placeholder="例如:SOP-LG-001 / 对应制度条款" />
</el-form-item>
<el-form-item label="服务红线(不应这样做)" required>
<el-input v-model="form.irritants" type="textarea" :rows="2" placeholder="例如:不得推诿、不得承诺无法兑现的时限" />
</el-form-item>
<el-form-item label="成功标准"><el-input v-model="form.successCriteria" type="textarea" :rows="2" /></el-form-item> <el-form-item label="成功标准"><el-input v-model="form.successCriteria" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="训练目标" required><el-input v-model="form.goal" type="textarea" :rows="2" /></el-form-item> <el-form-item label="训练目标" required><el-input v-model="form.goal" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="教练策略"><el-input v-model="form.strategy" /></el-form-item> <el-form-item label="教练策略"><el-input v-model="form.strategy" /></el-form-item>
@@ -126,8 +219,13 @@ import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { import {
listPracticeScenarios, listPracticeScenarios,
listPracticeCurriculumCapabilities,
listPracticeCurriculumMatrix,
savePracticeScenario, savePracticeScenario,
updatePracticeScenarioEnabled, updatePracticeScenarioEnabled,
updatePracticeScenarioReviewStatus,
type PracticeCurriculumCapability,
type PracticeCurriculumMatrixRow,
type PracticeScenarioResponse, type PracticeScenarioResponse,
type PracticeScenarioRound, type PracticeScenarioRound,
type PracticeScenarioSaveRequest type PracticeScenarioSaveRequest
@@ -138,11 +236,25 @@ const saving = ref(false);
const dialogVisible = ref(false); const dialogVisible = ref(false);
const editingId = ref(''); const editingId = ref('');
const scenarios = ref<PracticeScenarioResponse[]>([]); const scenarios = ref<PracticeScenarioResponse[]>([]);
const curriculumCapabilities = ref<PracticeCurriculumCapability[]>([]);
const matrixRows = ref<PracticeCurriculumMatrixRow[]>([]);
const positions = ['生活顾问', '保安', '保洁', '保修', '客服']; const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
const growthLevels = ['初级', '中级', '高级'];
const reviewStatuses = ['草稿', '待业务审核', '已发布', '已下线'];
const riskLevels = ['待评估', '常规', '高风险'];
const scenarioTypes = ref<string[]>([]); const scenarioTypes = ref<string[]>([]);
const filters = reactive<{ position: string; scenarioType: string; enabled: boolean | undefined; keyword: string }>({ const filters = reactive<{
position: string;
scenarioType: string;
growthLevel: string;
reviewStatus: string;
enabled: boolean | undefined;
keyword: string;
}>({
position: '', position: '',
scenarioType: '', scenarioType: '',
growthLevel: '',
reviewStatus: '',
enabled: undefined, enabled: undefined,
keyword: '' keyword: ''
}); });
@@ -153,6 +265,11 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
position: '生活顾问', position: '生活顾问',
scenarioType: '投诉处理', scenarioType: '投诉处理',
projectType: '住宅', projectType: '住宅',
growthLevel: '初级',
competencyCode: '',
collaborationPositions: '',
riskLevel: '待评估',
curriculumVersion: '住宅五岗-v1',
difficulty: 1, difficulty: 1,
customerPersona: '', customerPersona: '',
openingText: '', openingText: '',
@@ -160,7 +277,7 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
hiddenNeed: '', hiddenNeed: '',
irritants: '', irritants: '',
delights: '', delights: '',
sopRefs: '生活顾问SOP', sopRefs: '',
successCriteria: '', successCriteria: '',
strategy: '仅在漏关键步骤时提示', strategy: '仅在漏关键步骤时提示',
goal: '', goal: '',
@@ -178,6 +295,8 @@ const filteredScenarios = computed(() =>
return ( return (
(!filters.scenarioType || item.scenarioType === filters.scenarioType) && (!filters.scenarioType || item.scenarioType === filters.scenarioType) &&
(!filters.position || item.position === filters.position) && (!filters.position || item.position === filters.position) &&
(!filters.growthLevel || item.growthLevel === filters.growthLevel) &&
(!filters.reviewStatus || item.reviewStatus === filters.reviewStatus) &&
(filters.enabled === undefined || item.enabled === filters.enabled) && (filters.enabled === undefined || item.enabled === filters.enabled) &&
(!keyword || `${item.id}${item.name}`.toLowerCase().includes(keyword)) (!keyword || `${item.id}${item.name}`.toLowerCase().includes(keyword))
); );
@@ -188,6 +307,12 @@ const pagedScenarios = computed(() => {
return filteredScenarios.value.slice(start, start + pagination.size); return filteredScenarios.value.slice(start, start + pagination.size);
}); });
const enabledCount = computed(() => scenarios.value.filter((item) => item.enabled !== false).length); const enabledCount = computed(() => scenarios.value.filter((item) => item.enabled !== false).length);
const visibleMatrixRows = computed(() =>
matrixRows.value.filter((item) => !filters.position || item.position === filters.position)
);
const currentCapabilityOptions = computed(() =>
curriculumCapabilities.value.filter((item) => item.position === form.position && item.growthLevel === form.growthLevel)
);
const load = async () => { const load = async () => {
loading.value = true; loading.value = true;
@@ -195,11 +320,22 @@ const load = async () => {
const { data } = await listPracticeScenarios({ includeDisabled: true }); const { data } = await listPracticeScenarios({ includeDisabled: true });
scenarios.value = data || []; scenarios.value = data || [];
scenarioTypes.value = [...new Set(scenarios.value.map((item) => item.scenarioType).filter(Boolean))]; scenarioTypes.value = [...new Set(scenarios.value.map((item) => item.scenarioType).filter(Boolean))];
await Promise.all([loadCapabilities(), loadMatrix()]);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
const loadCapabilities = async () => {
const { data } = await listPracticeCurriculumCapabilities();
curriculumCapabilities.value = data || [];
};
const loadMatrix = async () => {
const { data } = await listPracticeCurriculumMatrix({ position: filters.position || undefined });
matrixRows.value = data || [];
};
const queryScenes = () => { const queryScenes = () => {
pagination.page = 1; pagination.page = 1;
}; };
@@ -217,6 +353,11 @@ const fillForm = (item?: PracticeScenarioResponse) => {
position: item.position, position: item.position,
scenarioType: item.scenarioType, scenarioType: item.scenarioType,
projectType: item.projectType, projectType: item.projectType,
growthLevel: item.growthLevel || '初级',
competencyCode: item.competencyCode || '',
collaborationPositions: item.collaborationPositions || '',
riskLevel: riskLevels.includes(item.riskLevel) ? item.riskLevel : '待评估',
curriculumVersion: item.curriculumVersion || '住宅五岗-v1',
difficulty: item.difficulty, difficulty: item.difficulty,
customerPersona: item.customerPersona, customerPersona: item.customerPersona,
openingText: item.openingText, openingText: item.openingText,
@@ -258,6 +399,84 @@ const toggleEnabled = async (item: PracticeScenarioResponse) => {
await updatePracticeScenarioEnabled(item.id, { enabled }); await updatePracticeScenarioEnabled(item.id, { enabled });
item.enabled = enabled; item.enabled = enabled;
ElMessage.success(enabled ? '场景已启用' : '场景已停用'); ElMessage.success(enabled ? '场景已启用' : '场景已停用');
await loadMatrix();
};
const reviewActionLabel = (item: PracticeScenarioResponse) => {
const status = item.reviewStatus || '草稿';
if (status === '待业务审核' && item.requiresDualReview && item.reviewer && !item.secondReviewer) {
return '复核并发布';
}
return ({
草稿: '提交审核',
待业务审核: '审核并发布',
已发布: '下线',
已下线: '重开草稿'
}[status] || '提交审核');
};
const nextReviewStatus = (status?: string): '草稿' | '待业务审核' | '已发布' | '已下线' => ({
草稿: '待业务审核',
待业务审核: '已发布',
已发布: '已下线',
已下线: '草稿'
}[status || '草稿'] || '待业务审核') as '草稿' | '待业务审核' | '已发布' | '已下线';
const advanceReviewStatus = async (item: PracticeScenarioResponse) => {
const reviewStatus = nextReviewStatus(item.reviewStatus);
const result = await updatePracticeScenarioReviewStatus(item.id, { reviewStatus });
await load();
ElMessage.success(result.data?.message || `场景已更新为“${reviewStatus}”`);
};
const returnToDraft = async (item: PracticeScenarioResponse) => {
const result = await updatePracticeScenarioReviewStatus(item.id, { reviewStatus: '草稿' });
await load();
ElMessage.success(result.data?.message || '场景已退回草稿');
};
const reviewerWithTime = (reviewer?: string, reviewedTime?: string) =>
reviewer ? `${reviewer}${reviewedTime ? `(${reviewedTime})` : ''}` : '未记录';
const reviewTrail = (item: PracticeScenarioResponse) => {
const status = item.reviewStatus || '草稿';
if (item.requiresDualReview) {
if (status === '草稿') {
return item.reviewer ? `高风险 · 已退回草稿(上次审核:${reviewerWithTime(item.reviewer, item.reviewedTime)})` : '高风险 · 草稿未提交';
}
if (status === '待业务审核') {
return item.reviewer
? `高风险 · 首审:${reviewerWithTime(item.reviewer, item.reviewedTime)} · 待第二人复核`
: '高风险 · 待首审';
}
if (status === '已发布') {
return `高风险 · 首审:${reviewerWithTime(item.reviewer, item.reviewedTime)};复审:${reviewerWithTime(item.secondReviewer, item.secondReviewedTime)} · 已双审`;
}
return '高风险 · 已下线';
}
if (status === '草稿') return item.reviewer ? `已退回草稿(上次审核:${reviewerWithTime(item.reviewer, item.reviewedTime)})` : '草稿未提交';
if (status === '待业务审核') return item.reviewer ? `审核:${reviewerWithTime(item.reviewer, item.reviewedTime)} · 待发布` : '待业务审核';
if (status === '已发布') return item.reviewer ? `审核:${reviewerWithTime(item.reviewer, item.reviewedTime)} · 已发布` : '已发布';
return '已下线';
};
const reviewTagType = (status?: string) => ({
草稿: 'info',
待业务审核: 'warning',
已发布: 'success',
已下线: 'danger'
}[status || '草稿'] || 'info') as 'success' | 'warning' | 'info' | 'danger';
const matrixTagType = (status?: string) => {
if (status === '已覆盖') return 'success';
if (status === '待审核' || status === '草稿待补全') return 'warning';
return 'danger';
};
const capabilityName = (scenario: PracticeScenarioResponse) => {
const item = curriculumCapabilities.value.find((capability) => capability.code === scenario.competencyCode
&& capability.position === scenario.position && capability.growthLevel === scenario.growthLevel);
return item?.name || scenario.competencyCode || '待归类';
}; };
const submit = async () => { const submit = async () => {
@@ -265,6 +484,14 @@ const submit = async () => {
ElMessage.warning('请填写场景名称、开场话术和训练目标'); ElMessage.warning('请填写场景名称、开场话术和训练目标');
return; return;
} }
if (!form.competencyCode.trim()) {
ElMessage.warning('请先选择场景所属的能力项;未归类内容不能发布');
return;
}
if (!form.sopRefs.trim() || !form.irritants.trim()) {
ElMessage.warning('请填写业务来源/SOP和服务红线;它们是业务审核与发布的必要依据');
return;
}
saving.value = true; saving.value = true;
try { try {
await savePracticeScenario({ ...form, id: form.id.trim() || undefined }); await savePracticeScenario({ ...form, id: form.id.trim() || undefined });
@@ -307,7 +534,8 @@ onMounted(load);
} }
.filter-card, .filter-card,
.table-card { .table-card,
.matrix-card {
padding: 18px; padding: 18px;
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
@@ -315,11 +543,46 @@ onMounted(load);
.filter-card { .filter-card {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 1.5fr 78px; grid-template-columns: repeat(5, minmax(120px, 1fr)) minmax(180px, 1.4fr) 78px;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.matrix-card {
margin-bottom: 12px;
:deep(.el-table) {
color: #111;
}
:deep(.el-table th.el-table__cell) {
color: #858991;
font-weight: 500;
background: #fafbfc;
}
}
.matrix-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
h2 {
margin: 0;
color: #25282d;
font-size: 16px;
}
p {
margin: 5px 0 0;
color: #858991;
font-size: 12px;
line-height: 1.5;
}
}
.table-card { .table-card {
:deep(.el-table) { :deep(.el-table) {
color: #111; color: #111;
@@ -361,6 +624,10 @@ onMounted(load);
:deep(.el-textarea__inner) { :deep(.el-textarea__inner) {
line-height: 1.6; line-height: 1.6;
} }
:deep(.el-alert) {
margin-top: 4px;
}
} }
.full-control { .full-control {
@@ -390,7 +657,7 @@ onMounted(load);
@media (max-width: 1100px) { @media (max-width: 1100px) {
.filter-card { .filter-card {
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.filter-card .el-button { .filter-card .el-button {
width: 100%; width: 100%;
+1 -1
View File
@@ -5,7 +5,7 @@ import path from 'path';
export default defineConfig(({ mode, command }) => { export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd()); const env = loadEnv(mode, process.cwd());
const proxyTarget = env.VITE_APP_PROXY_TARGET || 'https://wygj-api.localhost'; const proxyTarget = process.env.VITE_APP_PROXY_TARGET || env.VITE_APP_PROXY_TARGET || 'https://wygj-api.localhost';
return { return {
// 部署生产环境和开发环境下的URL。 // 部署生产环境和开发环境下的URL。
// 默认情况下,vite 会假设你的应用是被部署在一个域名的根路径上 // 默认情况下,vite 会假设你的应用是被部署在一个域名的根路径上
+2 -2
View File
@@ -34,7 +34,7 @@
<uni-easyinput <uni-easyinput
v-model="formData.phone" v-model="formData.phone"
aria-label="手机号" aria-label="手机号"
type="number" type="tel"
maxlength="11" maxlength="11"
:clearable="false" :clearable="false"
primary-color="#e60012" primary-color="#e60012"
@@ -53,7 +53,7 @@
v-model="formData.smsCode" v-model="formData.smsCode"
class="code-input" class="code-input"
aria-label="验证码" aria-label="验证码"
type="number" type="tel"
maxlength="6" maxlength="6"
:clearable="false" :clearable="false"
primary-color="#e60012" primary-color="#e60012"
@@ -42,6 +42,18 @@
<text class="head-title">{{ displayName }} · {{ detail.scene }}</text> <text class="head-title">{{ displayName }} · {{ detail.scene }}</text>
</view> </view>
<text class="head-meta">{{ detail.time }} · {{ detail.status }}</text> <text class="head-meta">{{ detail.time }} · {{ detail.status }}</text>
<view class="curriculum-summary">
<text class="curriculum-summary-label">训练归属</text>
<view class="curriculum-summary-tags">
<text class="curriculum-summary-tag">{{ detail.projectType || '住宅' }}</text>
<text class="curriculum-summary-tag">{{ detail.position || '岗位待补录' }}</text>
<text class="curriculum-summary-tag">{{ detail.growthLevel || '层级待补录' }}</text>
<text class="curriculum-summary-tag">{{ detail.competencyCode || '能力项待归类' }}</text>
</view>
<text v-if="detail.collaborationPositions" class="curriculum-summary-note">协同岗位:{{ detail.collaborationPositions }}</text>
<text v-if="detail.curriculumVersion && detail.curriculumVersion !== '历史未记录'" class="curriculum-summary-note">目录版本:{{ detail.curriculumVersion }}(本次训练快照)</text>
<text v-else class="curriculum-summary-note">目录版本未记录;该训练早于课程快照机制,不能按当前场景倒推。</text>
</view>
<view class="ai-summary"> <view class="ai-summary">
<view class="ai-summary-label"> <view class="ai-summary-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z"/></svg>
@@ -184,7 +196,7 @@
:class="{ active: nextScenarioId === item.id }" :class="{ active: nextScenarioId === item.id }"
@click="selectNextScenario(item.id)" @click="selectNextScenario(item.id)"
> >
{{ item.name }} {{ item.name }}<text v-if="item.growthLevel || item.competencyCode"> · {{ item.growthLevel || '基础' }}{{ item.competencyCode ? ` · ${item.competencyCode}` : '' }}</text>
</button> </button>
</view> </view>
<textarea <textarea
@@ -196,6 +208,23 @@
/> />
</view> </view>
<view v-if="nextConfirmableGrowthLevel" class="next-training-section">
<text class="field-label">训练层级确认(可选)</text>
<view class="scenario-row">
<button class="scenario-chip" :class="{ active: !confirmedGrowthLevel }" @click="confirmedGrowthLevel = ''">
暂不确认
</button>
<button
class="scenario-chip"
:class="{ active: confirmedGrowthLevel === nextConfirmableGrowthLevel }"
@click="confirmedGrowthLevel = nextConfirmableGrowthLevel"
>
确认可进入{{ nextConfirmableGrowthLevel }}
</button>
</view>
<text class="growth-confirmation-note">仅记录训练层级证据,不调整职级、薪酬或上岗资格。</text>
</view>
<button class="primary-button submit-button tabbar-safe-action" :disabled="marking" @click="markReviewed"> <button class="primary-button submit-button tabbar-safe-action" :disabled="marking" @click="markReviewed">
{{ marking ? '提交中…' : '标记已复盘' }} {{ marking ? '提交中…' : '标记已复盘' }}
</button> </button>
@@ -207,6 +236,9 @@
<text v-if="detail.incentivePoint != null" class="complete-point"> <text v-if="detail.incentivePoint != null" class="complete-point">
人工贡献分 {{ detail.incentivePoint }} 人工贡献分 {{ detail.incentivePoint }}
</text> </text>
<text v-if="detail.confirmedGrowthLevel" class="complete-point">
已确认可进入{{ detail.confirmedGrowthLevel }}{{ detail.growthConfirmedTime ? ` · ${detail.growthConfirmedTime}` : '' }}
</text>
</view> </view>
</template> </template>
@@ -226,7 +258,7 @@ import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app'; import { onLoad, onShow } from '@dcloudio/uni-app';
import type { ReviewDetail, ReviewDialogue } from '@/types/api'; import type { ReviewDetail, ReviewDialogue } from '@/types/api';
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api'; import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, rememberLoginRedirect, type UserPosition } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { getPracticeReviewDetail, getPracticeScenarios, getPracticeTeamSnapshot, markPracticeReviewed } from '@/services/practice'; import { getPracticeReviewDetail, getPracticeScenarios, getPracticeTeamSnapshot, markPracticeReviewed } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue'; import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
@@ -235,7 +267,6 @@ import {
createAsyncGenerationGuard, createAsyncGenerationGuard,
isReviewEditable, isReviewEditable,
isSupervisorAccessDenied, isSupervisorAccessDenied,
supervisorScenarioOptions,
type SupervisorScenarioOption, type SupervisorScenarioOption,
type SupervisorScenarioId type SupervisorScenarioId
} from '@/services/supervisor-view'; } from '@/services/supervisor-view';
@@ -246,8 +277,9 @@ const detail = ref<ReviewDetail | null>(null);
const advice = ref(''); const advice = ref('');
const incentivePoint = ref(''); const incentivePoint = ref('');
const nextScenarioId = ref<SupervisorScenarioId | ''>(''); const nextScenarioId = ref<SupervisorScenarioId | ''>('');
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]); const scenarioOptions = ref<SupervisorScenarioOption[]>([]);
const nextReason = ref(''); const nextReason = ref('');
const confirmedGrowthLevel = ref('');
const loggedIn = ref(false); const loggedIn = ref(false);
const accessDenied = ref(false); const accessDenied = ref(false);
const loading = ref(false); const loading = ref(false);
@@ -258,7 +290,16 @@ const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({}); const audioErrors = ref<Record<string, string>>({});
const audioRequests = createAsyncGenerationGuard(); const audioRequests = createAsyncGenerationGuard();
const memberNames = ref<Record<string, string>>({}); const memberNames = ref<Record<string, string>>({});
const scenarioPosition = (value?: string): UserPosition | null => {
const known: UserPosition[] = ['生活顾问', '保安', '保洁', '保修', '客服'];
return known.includes(value as UserPosition) ? value as UserPosition : null;
};
const editable = computed(() => isReviewEditable(detail.value?.status)); const editable = computed(() => isReviewEditable(detail.value?.status));
const nextConfirmableGrowthLevel = computed(() => {
if (detail.value?.growthLevel === '初级') return '中级';
if (detail.value?.growthLevel === '中级') return '高级';
return '';
});
const loadFailed = computed(() => !loading.value && !!message.value && !detail.value); const loadFailed = computed(() => !loading.value && !!message.value && !detail.value);
const firstLoading = computed(() => loading.value && !detail.value && !message.value); const firstLoading = computed(() => loading.value && !detail.value && !message.value);
@@ -315,6 +356,7 @@ const clearDetail = () => {
incentivePoint.value = ''; incentivePoint.value = '';
nextScenarioId.value = ''; nextScenarioId.value = '';
nextReason.value = ''; nextReason.value = '';
confirmedGrowthLevel.value = '';
}; };
const refreshAuthState = () => { const refreshAuthState = () => {
@@ -410,6 +452,8 @@ const loadDetail = async () => {
incentivePoint.value = detail.value.incentivePoint == null ? '' : String(detail.value.incentivePoint); incentivePoint.value = detail.value.incentivePoint == null ? '' : String(detail.value.incentivePoint);
nextScenarioId.value = ''; nextScenarioId.value = '';
nextReason.value = ''; nextReason.value = '';
confirmedGrowthLevel.value = '';
void loadScenarioOptions();
} catch (error) { } catch (error) {
clearDetail(); clearDetail();
handleRequestError(error, '复盘详情加载失败'); handleRequestError(error, '复盘详情加载失败');
@@ -419,18 +463,30 @@ const loadDetail = async () => {
}; };
const loadScenarioOptions = async () => { const loadScenarioOptions = async () => {
const position = scenarioPosition(detail.value?.position);
if (!position) {
scenarioOptions.value = [];
nextScenarioId.value = '';
return;
}
try { try {
const rows = await getPracticeScenarios('生活顾问'); const rows = await getPracticeScenarios(position);
const nextOptions = rows const nextOptions = rows
.filter((item) => item.enabled !== false && item.id && item.name) .filter((item) => item.enabled !== false && (!item.reviewStatus || item.reviewStatus === '已发布') && item.id && item.name)
.map((item) => ({ id: item.id, name: item.name })); .map((item) => ({
id: item.id,
name: item.name,
growthLevel: item.growthLevel,
competencyCode: item.competencyCode
}));
if (nextOptions.length) { if (nextOptions.length) {
scenarioOptions.value = nextOptions; scenarioOptions.value = nextOptions;
} else { } else {
scenarioOptions.value = []; scenarioOptions.value = [];
} }
} catch { } catch {
scenarioOptions.value = [...supervisorScenarioOptions]; scenarioOptions.value = [];
nextScenarioId.value = '';
} }
}; };
@@ -469,7 +525,8 @@ const markReviewed = async () => {
advice: advice.value.trim(), advice: advice.value.trim(),
incentivePoint: point, incentivePoint: point,
nextScenarioId: nextScenarioId.value || undefined, nextScenarioId: nextScenarioId.value || undefined,
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined,
confirmedGrowthLevel: confirmedGrowthLevel.value || undefined
}); });
const params = new URLSearchParams({ id: String(detail.value.id) }); const params = new URLSearchParams({ id: String(detail.value.id) });
if (assignedScenario) params.set('assigned', assignedScenario); if (assignedScenario) params.set('assigned', assignedScenario);
@@ -691,6 +748,51 @@ onBeforeUnmount(() => {
line-height: 1.4; line-height: 1.4;
} }
.curriculum-summary {
margin-top: 11px;
padding: 10px 11px;
border-radius: 12px;
background: #f7faff;
}
.curriculum-summary-label,
.curriculum-summary-note {
display: block;
}
.curriculum-summary-label {
color: #315b8f;
font-size: 11px;
font-weight: 800;
}
.curriculum-summary-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 7px;
}
.curriculum-summary-tag {
max-width: 100%;
padding: 3px 7px;
overflow: hidden;
border-radius: 999px;
background: #eaf2ff;
color: #4b6381;
font-size: 10px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.curriculum-summary-note {
margin-top: 5px;
color: #778395;
font-size: 10px;
line-height: 1.45;
}
.head-score { .head-score {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
@@ -1108,6 +1210,14 @@ onBeforeUnmount(() => {
line-height: 1.5; line-height: 1.5;
} }
.growth-confirmation-note {
display: block;
margin-top: 8px;
color: #7b8798;
font-size: 12px;
line-height: 1.55;
}
.submit-button { .submit-button {
display: flex; display: flex;
width: 100%; width: 100%;
+444 -21
View File
@@ -15,10 +15,69 @@
<button class="primary-button login-button" @click="goLogin">去登录</button> <button class="primary-button login-button" @click="goLogin">去登录</button>
</view> </view>
<view v-if="loggedIn && practiceView === 'prep'" class="panel scenario-card employee-card"> <view v-if="loggedIn && practiceView === 'prep'" class="panel growth-card employee-card">
<view class="growth-card-head">
<view>
<text class="panel-title">我的成长位置</text>
<text class="growth-meta">住宅物业 · {{ positionLabel }} · {{ currentGrowthLevel }}</text>
</view>
<text class="growth-level-badge">{{ currentGrowthLevel }}</text>
</view>
<text v-if="growthConfirmation" class="growth-confirmation">
主管已确认可进入{{ growthConfirmation.confirmedGrowthLevel }}{{ growthConfirmation.confirmedTime ? ` · ${growthConfirmation.confirmedTime}` : '' }}
</text>
<view v-if="growthPath.length" class="growth-path-list">
<view v-for="item in growthPath" :key="item.level" class="growth-path-row" :class="{ reached: item.status === '已达成' }">
<view class="growth-path-copy">
<text class="growth-path-level">{{ item.level }}</text>
<text class="growth-path-evidence">{{ item.evidence }}</text>
</view>
<text class="growth-path-status">{{ item.status }} · {{ item.progress }}%</text>
</view>
</view>
<view v-if="capabilityProgress.length" class="capability-progress-list">
<text class="capability-progress-title">已覆盖能力项</text>
<view v-for="item in capabilityProgress.slice(0, 3)" :key="`${item.growthLevel}-${item.competencyCode}`" class="capability-progress-row">
<text>{{ item.growthLevel }} · {{ item.competencyCode }}</text>
<text>{{ item.completed }} 次 · 均分 {{ item.averageScore }}</text>
</view>
</view>
<text v-else class="growth-empty">完成后会按“岗位 · 层级 · 能力项”记录训练证据;历史未归类记录不会被倒推改写。</text>
<text class="growth-note">这是训练建议与证据展示,不自动改变职级、薪酬或晋升资格。</text>
</view>
<view v-if="loggedIn && practiceView === 'prep'" class="panel scene-picker-card employee-card">
<view class="scene-picker-head">
<view>
<text class="panel-title">场景训练</text>
<text class="scene-picker-subtitle">选择已发布、已启用的 {{ positionLabel }} 场景</text>
</view>
<text v-if="curriculumLoading" class="scene-picker-loading">加载中</text>
</view>
<scroll-view v-if="availableScenarios.length" class="scene-option-scroll" scroll-x>
<view class="scene-option-list">
<button
v-for="item in availableScenarios"
:key="item.id"
class="scene-option"
:class="{ selected: selectedScenarioId === item.id }"
:disabled="busy"
@click="selectPracticeScenario(item)"
>
<text class="scene-option-level">{{ item.growthLevel || '基础' }}</text>
<text class="scene-option-name">{{ item.name }}</text>
<text class="scene-option-capability">{{ item.competencyCode || '待运营归类' }}</text>
</button>
</view>
</scroll-view>
<text v-else-if="!curriculumLoading" class="scene-picker-empty">当前岗位暂无已发布场景,请联系内容运营完成审核发布。</text>
<text class="scene-picker-note">复杂场景的正式前置解锁规则仍待业务确认;系统不会以估算分数硬性拦截已发布训练。</text>
</view>
<view v-if="loggedIn && practiceView === 'prep' && availableScenarios.length" class="panel scenario-card employee-card">
<view class="scenario-headline"> <view class="scenario-headline">
<text class="scenario-title">{{ activeScenarioName }}</text> <text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">{{ positionLabel }} · 业主服务场景</text> <text class="scenario-desc">{{ selectedScenarioDescription }}</text>
</view> </view>
<view class="scenario-persona"> <view class="scenario-persona">
<view class="persona-avatar">业</view> <view class="persona-avatar">业</view>
@@ -46,8 +105,8 @@
<button <button
v-if="status === 'idle' || status === 'finished'" v-if="status === 'idle' || status === 'finished'"
class="primary-button" class="primary-button"
:disabled="busy" :disabled="!canStartPractice || busy"
@click="start()" @click="start(selectedScenarioId)"
> >
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }} {{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
</button> </button>
@@ -66,7 +125,7 @@
<button <button
v-if="status === 'idle' || status === 'finished'" v-if="status === 'idle' || status === 'finished'"
class="secondary-button realtime-start-button" class="secondary-button realtime-start-button"
:disabled="busy" :disabled="!canStartPractice || busy"
@click="startRealtime" @click="startRealtime"
> >
实时陪练 Beta 实时陪练 Beta
@@ -373,7 +432,7 @@
<view v-if="result" class="result-actions"> <view v-if="result" class="result-actions">
<button class="secondary-button" @click="openPracticeHistory">查看训练记录</button> <button class="secondary-button" @click="openPracticeHistory">查看训练记录</button>
<button class="primary-button tabbar-safe-action" @click="start(selectedScenarioId)">再练一次</button> <button class="primary-button tabbar-safe-action" :disabled="!canStartPractice || busy" @click="start(selectedScenarioId)">再练一次</button>
</view> </view>
</view> </view>
</template> </template>
@@ -391,9 +450,13 @@ import type {
SpeechVoiceProfile, SpeechVoiceProfile,
SpeechPlaybackStatus, SpeechPlaybackStatus,
PracticeTurn, PracticeTurn,
PracticeRecord PracticeRecord,
PracticeScenarioOption,
CompetencyProfile,
GrowthStage,
CapabilityProgress
} from '@/types/api'; } from '@/types/api';
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, recordPracticeHelp, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice'; import { answerDailyDrill, finishPractice, getCompetencyProfile, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, getPracticeScenarios, recordPracticeHelp, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge'; import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
@@ -436,6 +499,9 @@ const satisfactionComment = ref('');
const satisfactionBusy = ref(false); const satisfactionBusy = ref(false);
const satisfactionSubmitted = ref(false); const satisfactionSubmitted = ref(false);
const selectedScenarioId = ref(defaultScenarioId); const selectedScenarioId = ref(defaultScenarioId);
const availableScenarios = ref<PracticeScenarioOption[]>([]);
const competencyProfile = ref<CompetencyProfile | null>(null);
const curriculumLoading = ref(false);
const prepCard = ref<PracticePrepCard | null>(null); const prepCard = ref<PracticePrepCard | null>(null);
const prepLoading = ref(false); const prepLoading = ref(false);
const activeScenarioName = ref(defaultScenarioName); const activeScenarioName = ref(defaultScenarioName);
@@ -512,11 +578,39 @@ const practicePageSubtitle = computed(() => {
if (practiceView.value === 'active') return `${activeScenarioName.value} · 第 ${Math.min(roundIndex.value + 1, activeRoundCount.value)}/${activeRoundCount.value} 轮`; if (practiceView.value === 'active') return `${activeScenarioName.value} · 第 ${Math.min(roundIndex.value + 1, activeRoundCount.value)}/${activeRoundCount.value} 轮`;
return '先掌握要点,再开始场景练习'; return '先掌握要点,再开始场景练习';
}); });
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished'); const selectedScenario = computed(() =>
availableScenarios.value.find((item) => item.id === selectedScenarioId.value)
);
const canStartPractice = computed(() =>
!curriculumLoading.value
&& (status.value === 'idle' || status.value === 'finished')
&& Boolean(selectedScenario.value?.id)
);
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee')); const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value); const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value); const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
const canAdjustCoach = computed(() => status.value === 'active' && lastCoachRoundIndex.value !== null && turns.value.some((item) => item.role === 'coach')); const canAdjustCoach = computed(() => status.value === 'active' && lastCoachRoundIndex.value !== null && turns.value.some((item) => item.role === 'coach'));
const selectedScenarioDescription = computed(() => {
const item = selectedScenario.value;
return [positionLabel.value, item?.growthLevel || '基础训练', item?.competencyCode || '业主服务场景']
.filter(Boolean)
.join(' · ');
});
const growthConfirmation = computed(() =>
(competencyProfile.value?.growthConfirmations || [])
.find((item) => item.position === positionLabel.value)
);
const currentGrowthLevel = computed(() => {
const confirmed = growthConfirmation.value?.confirmedGrowthLevel || '';
if (['初级', '中级', '高级'].includes(confirmed)) return confirmed;
const level = competencyProfile.value?.aiLevel || '';
return ['初级', '中级', '高级'].includes(level) ? level : '初级';
});
const growthPath = computed<GrowthStage[]>(() => competencyProfile.value?.growthPath || []);
const capabilityProgress = computed<CapabilityProgress[]>(() =>
(competencyProfile.value?.capabilityProgress || [])
.filter((item) => !item.position || item.position === positionLabel.value)
);
const recentPracticeItems = computed<RecentPracticeItem[]>(() => { const recentPracticeItems = computed<RecentPracticeItem[]>(() => {
const assignmentItems: RecentPracticeItem[] = assignments.value const assignmentItems: RecentPracticeItem[] = assignments.value
.filter((item) => item.source !== 'daily') .filter((item) => item.source !== 'daily')
@@ -781,6 +875,9 @@ const clearPracticeData = () => {
cancelRecording(); cancelRecording();
assignments.value = []; assignments.value = [];
practiceHistory.value = []; practiceHistory.value = [];
availableScenarios.value = [];
competencyProfile.value = null;
curriculumLoading.value = false;
drillAnswers.value = {}; drillAnswers.value = {};
dailyAudioUrls.value = {}; dailyAudioUrls.value = {};
dailyAudioOssIds.value = {}; dailyAudioOssIds.value = {};
@@ -1133,10 +1230,68 @@ const loadAssignments = async (request: RequestScopeTicket = requests.begin('ass
if (!requests.isCurrent(request)) return; if (!requests.isCurrent(request)) return;
handleError(error, '今日任务加载失败'); handleError(error, '今日任务加载失败');
} finally { } finally {
if (requests.isCurrent(request)) assignmentLoading.value = false; if (requests.isCurrent(request)) {
assignmentLoading.value = false;
consumePendingAssignment();
}
} }
}; };
const loadCurriculum = async (request: RequestScopeTicket = requests.begin('curriculum')) => {
refreshAuthState();
if (!loggedIn.value) {
availableScenarios.value = [];
competencyProfile.value = null;
return;
}
curriculumLoading.value = true;
try {
const [profileResult, scenarioResult] = await Promise.allSettled([
getCompetencyProfile(),
getPracticeScenarios(positionLabel.value)
]);
if (!requests.isCurrent(request)) return;
if (profileResult.status === 'fulfilled') {
competencyProfile.value = profileResult.value;
}
if (scenarioResult.status === 'fulfilled') {
const nextScenarios = (scenarioResult.value || []).filter((item) =>
item.enabled !== false && (!item.reviewStatus || item.reviewStatus === '已发布')
);
availableScenarios.value = nextScenarios;
const activeScenario = nextScenarios.find((item) => item.id === selectedScenarioId.value) || nextScenarios[0];
if (activeScenario) {
selectedScenarioId.value = activeScenario.id;
activeScenarioName.value = activeScenario.name || defaultScenarioName;
void loadPrepCard(activeScenario.id, requests.begin('prep-card'));
} else {
selectedScenarioId.value = '';
activeScenarioName.value = '暂无可用训练场景';
prepCard.value = null;
}
} else {
availableScenarios.value = [];
selectedScenarioId.value = '';
activeScenarioName.value = '暂无可用训练场景';
prepCard.value = null;
handleError(scenarioResult.reason, '训练场景加载失败');
}
} finally {
if (requests.isCurrent(request)) {
curriculumLoading.value = false;
consumePendingAssignment();
}
}
};
const selectPracticeScenario = (item: PracticeScenarioOption) => {
if (busy.value || !item?.id) return;
selectedScenarioId.value = item.id;
activeScenarioName.value = item.name || defaultScenarioName;
prepCard.value = null;
void loadPrepCard(item.id, requests.begin('prep-card'));
};
const openRecentItem = (item: RecentPracticeItem) => { const openRecentItem = (item: RecentPracticeItem) => {
if (item.assignment) { if (item.assignment) {
startAssignment(item.assignment); startAssignment(item.assignment);
@@ -1152,20 +1307,31 @@ const openPracticeHistory = () => {
const consumePendingAssignment = () => { const consumePendingAssignment = () => {
const raw = uni.getStorageSync(pendingAssignmentKey); const raw = uni.getStorageSync(pendingAssignmentKey);
if (!raw) return; if (!raw) return;
uni.removeStorageSync(pendingAssignmentKey);
const id = Number(raw); const id = Number(raw);
if (!Number.isFinite(id)) return; if (!Number.isFinite(id)) {
uni.removeStorageSync(pendingAssignmentKey);
return;
}
if (assignmentLoading.value || curriculumLoading.value) return;
const item = assignments.value.find((row) => row.id === id); const item = assignments.value.find((row) => row.id === id);
if (!item) { if (!item) {
uni.removeStorageSync(pendingAssignmentKey);
message.value = '首页任务已更新,请从列表选择'; message.value = '首页任务已更新,请从列表选择';
return; return;
} }
highlightedAssignmentId.value = item.id; highlightedAssignmentId.value = item.id;
if (item.source !== 'daily' && item.status === '待训练' && canStartPractice.value) { if (item.source === 'daily') {
startAssignment(item); uni.removeStorageSync(pendingAssignmentKey);
message.value = '已定位首页任务,请填写话术后提交';
return; return;
} }
message.value = item.source === 'daily' ? '已定位首页任务,请填写话术后提交' : '该任务已完成,请选择其他任务'; if (item.status !== '待训练') {
uni.removeStorageSync(pendingAssignmentKey);
message.value = item.status === '已完成' ? '该任务已完成,请选择其他任务' : `该任务当前为${item.status || '不可训练'},请从列表查看`;
return;
}
startAssignment(item);
uni.removeStorageSync(pendingAssignmentKey);
}; };
const submitDaily = async (item: PracticeAssignment) => { const submitDaily = async (item: PracticeAssignment) => {
@@ -1227,11 +1393,22 @@ const submitItemFeedback = async (assignmentId: number, verdict: 'useful' | 'not
}; };
const startAssignment = (item: PracticeAssignment) => { const startAssignment = (item: PracticeAssignment) => {
if (!canStartPractice.value) return; if (curriculumLoading.value) {
selectedScenarioId.value = item.scenarioId || defaultScenarioId; message.value = '训练场景加载中,请稍候';
activeScenarioName.value = item.scenarioName || defaultScenarioName; return false;
}
const scenarioId = (item.scenarioId || '').trim();
const scenario = availableScenarios.value.find((entry) => entry.id === scenarioId);
if (!scenario) {
message.value = '本次专项关联的场景已下线或待发布,请联系内容运营后再练习';
return false;
}
if (!canStartPractice.value) return false;
selectedScenarioId.value = scenario.id;
activeScenarioName.value = scenario.name || item.scenarioName || defaultScenarioName;
void loadPrepCard(selectedScenarioId.value); void loadPrepCard(selectedScenarioId.value);
void start(item.scenarioId || defaultScenarioId, item.id); void start(scenario.id, item.id);
return true;
}; };
const loadPrepCard = async ( const loadPrepCard = async (
@@ -1257,7 +1434,7 @@ const loadPrepCard = async (
} }
}; };
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => { const start = async (scenarioId = selectedScenarioId.value, assignmentId?: number) => {
if (!canStartPractice.value) return; if (!canStartPractice.value) return;
if (!requireLogin()) return; if (!requireLogin()) return;
stopRealtime(); stopRealtime();
@@ -1461,6 +1638,7 @@ const finish = async (force = false) => {
completedAssignmentFeedback.value = null; completedAssignmentFeedback.value = null;
activeAssignmentId.value = undefined; activeAssignmentId.value = undefined;
await loadAssignments(); await loadAssignments();
void loadCurriculum(requests.begin('curriculum'));
} catch (error) { } catch (error) {
if (!requests.isCurrent(request)) return; if (!requests.isCurrent(request)) return;
status.value = 'active'; status.value = 'active';
@@ -1495,13 +1673,14 @@ const handleShow = async () => {
if (!requests.isCurrent(pageRequest)) return; if (!requests.isCurrent(pageRequest)) return;
positionLabel.value = getSelectedPosition() || '生活顾问'; positionLabel.value = getSelectedPosition() || '生活顾问';
void loadAssignments(requests.begin('assignments')); void loadAssignments(requests.begin('assignments'));
void loadPrepCard(selectedScenarioId.value, requests.begin('prep-card')); void loadCurriculum(requests.begin('curriculum'));
}; };
const invalidateRequests = () => { const invalidateRequests = () => {
requests.invalidate(); requests.invalidate();
assignmentLoading.value = false; assignmentLoading.value = false;
prepLoading.value = false; prepLoading.value = false;
curriculumLoading.value = false;
answeringId.value = null; answeringId.value = null;
feedbackSubmittingId.value = null; feedbackSubmittingId.value = null;
helpLoading.value = false; helpLoading.value = false;
@@ -1611,6 +1790,250 @@ onUnload(() => {
padding: 0 12px; padding: 0 12px;
} }
.growth-card,
.scene-picker-card {
margin-top: 16px;
border-color: #dbe6f5;
background: #ffffff;
box-shadow: var(--employee-card-shadow);
}
.growth-card {
padding: 18px;
}
.growth-card-head,
.scene-picker-head,
.growth-path-row,
.capability-progress-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.growth-meta,
.growth-note,
.growth-empty,
.scene-picker-subtitle,
.scene-picker-note,
.scene-picker-empty,
.growth-path-evidence,
.capability-progress-title,
.scene-option-level,
.scene-option-capability {
display: block;
}
.growth-meta {
margin-top: 5px;
color: #66758a;
font-size: 12px;
line-height: 1.45;
}
.growth-confirmation {
display: block;
margin-top: 10px;
color: #0f766e;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.growth-level-badge {
flex: 0 0 auto;
padding: 5px 10px;
border-radius: 999px;
background: #eef5ff;
color: #315b8f;
font-size: 12px;
font-weight: 800;
}
.growth-path-list {
display: grid;
gap: 8px;
margin-top: 14px;
}
.growth-path-row {
align-items: flex-start;
padding: 9px 0;
border-top: 1px solid #eef1f5;
}
.growth-path-row.reached .growth-path-level,
.growth-path-row.reached .growth-path-status {
color: #168243;
}
.growth-path-copy {
min-width: 0;
}
.growth-path-level {
display: block;
color: #25282d;
font-size: 13px;
font-weight: 800;
}
.growth-path-evidence {
margin-top: 3px;
color: #7b8491;
font-size: 11px;
line-height: 1.45;
}
.growth-path-status {
flex: 0 0 auto;
padding-top: 1px;
color: #54667d;
font-size: 11px;
white-space: nowrap;
}
.capability-progress-list {
display: grid;
gap: 7px;
margin-top: 12px;
padding: 11px 12px;
border-radius: 12px;
background: #f7faff;
}
.capability-progress-title {
color: #315b8f;
font-size: 12px;
font-weight: 800;
}
.capability-progress-row {
color: #566577;
font-size: 11px;
line-height: 1.45;
}
.growth-empty,
.growth-note {
color: #778395;
font-size: 11px;
line-height: 1.5;
}
.growth-empty {
margin-top: 12px;
}
.growth-note {
margin-top: 10px;
color: #315b8f;
}
.scene-picker-card {
padding: 18px 0 14px;
overflow: hidden;
}
.scene-picker-head {
padding: 0 18px;
}
.scene-picker-subtitle {
margin-top: 5px;
color: #69798d;
font-size: 12px;
line-height: 1.45;
}
.scene-picker-loading {
color: #708198;
font-size: 12px;
}
.scene-option-scroll {
margin-top: 12px;
white-space: nowrap;
}
.scene-option-list {
display: inline-flex;
gap: 10px;
padding: 0 18px;
}
.scene-option {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 144px;
min-height: 100px;
padding: 13px;
border: 1px solid #dfe7f1;
border-radius: 14px;
background: #f8fafc;
color: #25282d;
text-align: left;
vertical-align: top;
white-space: normal;
}
.scene-option.selected {
border-color: #e70014;
background: #fff7f7;
box-shadow: 0 6px 16px rgba(231, 0, 20, 0.08);
}
.scene-option:disabled {
opacity: 0.62;
}
.scene-option-level {
color: #d51b2b;
font-size: 11px;
font-weight: 800;
}
.scene-option-name {
display: block;
min-height: 38px;
margin-top: 7px;
overflow: hidden;
color: #25282d;
font-size: 14px;
font-weight: 900;
line-height: 1.4;
text-overflow: ellipsis;
}
.scene-option-capability {
margin-top: 4px;
overflow: hidden;
color: #778395;
font-size: 11px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-picker-note,
.scene-picker-empty {
padding: 0 18px;
color: #778395;
font-size: 11px;
line-height: 1.5;
}
.scene-picker-note {
margin-top: 12px;
}
.scene-picker-empty {
margin-top: 12px;
}
.scenario-card, .scenario-card,
.dialogue-card, .dialogue-card,
.reply-card, .reply-card,
@@ -41,6 +41,8 @@ export interface TeamProfile {
export interface SupervisorScenarioOption { export interface SupervisorScenarioOption {
id: string; id: string;
name: string; name: string;
growthLevel?: string;
competencyCode?: string;
} }
export const supervisorScenarioOptions: SupervisorScenarioOption[] = [ export const supervisorScenarioOptions: SupervisorScenarioOption[] = [
+12
View File
@@ -673,6 +673,9 @@ export interface ReviewDetail extends PracticeRecord {
aiComment: string; aiComment: string;
reviewAdvice?: string; reviewAdvice?: string;
incentivePoint?: number | null; incentivePoint?: number | null;
confirmedGrowthLevel?: string;
growthConfirmedBy?: string;
growthConfirmedTime?: string;
scoreItems: CompetencyDimension[]; scoreItems: CompetencyDimension[];
dialogue: ReviewDialogue[]; dialogue: ReviewDialogue[];
annotations: ReviewAnnotation[]; annotations: ReviewAnnotation[];
@@ -684,6 +687,7 @@ export interface ReviewAdviceRequest {
incentivePoint?: number; incentivePoint?: number;
nextScenarioId?: string; nextScenarioId?: string;
nextReason?: string; nextReason?: string;
confirmedGrowthLevel?: string;
} }
export interface PracticeAssignment { export interface PracticeAssignment {
@@ -1145,6 +1149,13 @@ export interface GrowthStage {
evidence: string; evidence: string;
} }
export interface GrowthConfirmation {
position: string;
confirmedGrowthLevel: string;
reviewer: string;
confirmedTime: string;
}
export interface CompetencyProfile { export interface CompetencyProfile {
score: number; score: number;
completed: number; completed: number;
@@ -1156,6 +1167,7 @@ export interface CompetencyProfile {
dimensions: CompetencyDimension[]; dimensions: CompetencyDimension[];
growthPath?: GrowthStage[]; growthPath?: GrowthStage[];
capabilityProgress?: CapabilityProgress[]; capabilityProgress?: CapabilityProgress[];
growthConfirmations?: GrowthConfirmation[];
} }
export interface CapabilityProgress { export interface CapabilityProgress {
+35 -1
View File
@@ -42,6 +42,13 @@ test('训练页提交失败会回滚本次乐观追加的对话', async () => {
assert.match(source, /uni\.showToast\(\{ title: message\.value \|\| '提交失败', icon: 'none'/); assert.match(source, /uni\.showToast\(\{ title: message\.value \|\| '提交失败', icon: 'none'/);
}); });
test('训练主操作使用当前已发布的场景,而不覆盖为内置默认场景', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /@click="start\(selectedScenarioId\)"/);
assert.match(source, /const start = async \(scenarioId = selectedScenarioId\.value, assignmentId\?: number\)/);
});
test('训练求助先留存会话事件,再以 practice_help 来源检索 SOP', async () => { test('训练求助先留存会话事件,再以 practice_help 来源检索 SOP', async () => {
const [practicePage, practiceService, knowledgeService] = await Promise.all([ const [practicePage, practiceService, knowledgeService] = await Promise.all([
pageSource('../src/pages/user/practice/index.vue'), pageSource('../src/pages/user/practice/index.vue'),
@@ -347,10 +354,37 @@ test('员工练习页显示当前岗位而不是固定生活顾问', async () =>
assert.match(source, /getSelectedPosition/); assert.match(source, /getSelectedPosition/);
assert.match(source, /const positionLabel = ref\(getSelectedPosition\(\) \|\| '生活顾问'\)/); assert.match(source, /const positionLabel = ref\(getSelectedPosition\(\) \|\| '生活顾问'\)/);
assert.match(source, /\{\{ positionLabel \}\} · 业主服务场景/); assert.match(source, /const selectedScenarioDescription = computed\(\(\) =>/);
assert.match(source, /\{\{ selectedScenarioDescription \}\}/);
assert.doesNotMatch(source, /scenario-desc">生活顾问 · 业主服务场景/); assert.doesNotMatch(source, /scenario-desc">生活顾问 · 业主服务场景/);
}); });
test('场景目录加载失败时清空旧场景并禁止开始训练', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /selectedScenarioId\.value = ''/);
assert.match(source, /activeScenarioName\.value = '暂无可用训练场景'/);
assert.match(source, /handleError\(scenarioResult\.reason, '训练场景加载失败'\)/);
assert.match(source, /:disabled="!canStartPractice \|\| busy"/);
assert.match(source, /!curriculumLoading\.value/);
});
test('无已发布场景时不展示默认预习卡或训练入口', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /v-if="loggedIn && practiceView === 'prep' && availableScenarios\.length" class="panel scenario-card employee-card"/);
assert.match(source, /const activeScenario = nextScenarios\.find\([\s\S]*?\|\| nextScenarios\[0\];[\s\S]*?else \{\s*selectedScenarioId\.value = '';\s*activeScenarioName\.value = '暂无可用训练场景';\s*prepCard\.value = null;/);
});
test('待训练专项等待目录加载,并对下线或待发布场景给出明确提示', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /if \(assignmentLoading\.value \|\| curriculumLoading\.value\) return;/);
assert.match(source, /startAssignment\(item\);\s*uni\.removeStorageSync\(pendingAssignmentKey\);/);
assert.match(source, /const scenario = availableScenarios\.value\.find\(\(entry\) => entry\.id === scenarioId\);/);
assert.match(source, /本次专项关联的场景已下线或待发布,请联系内容运营后再练习/);
});
test('员工问师傅页显示当前岗位而不是固定生活顾问', async () => { test('员工问师傅页显示当前岗位而不是固定生活顾问', async () => {
const source = await pageSource('../src/pages/user/sop/index.vue'); const source = await pageSource('../src/pages/user/sop/index.vue');
+27 -33
View File
@@ -43,7 +43,7 @@ check_mobile_h5_shell() {
icon_url="${MOBILE_URL%/}${href}" icon_url="${MOBILE_URL%/}${href}"
code="$(curl -k -sS -o /tmp/wygj-demo-check.favicon -w '%{http_code}' "$icon_url")" code="$(curl -k -sS -o /tmp/wygj-demo-check.favicon -w '%{http_code}' "$icon_url")"
[[ "$code" == "200" ]] || fail "mobile H5 favicon returned HTTP $code: $icon_url" [[ "$code" == "200" ]] || fail "mobile H5 favicon returned HTTP $code: $icon_url"
contains mobile-uni/src/manifest.json '"enable": false' contains mobile-uni/src/manifest.json '"enable" : false'
echo "OK: mobile H5 shell" echo "OK: mobile H5 shell"
} }
@@ -417,7 +417,7 @@ contains mobile-uni/src/services/home.ts "/api/aihr/mobile/home/"
contains mobile-uni/src/services/home.ts "auth: isLoggedIn()" contains mobile-uni/src/services/home.ts "auth: isLoggedIn()"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "public HomeResponse publicHome(String role)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "public HomeResponse publicHome(String role)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "return R.ok(mobileSeedService.publicHome(role))" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "return R.ok(mobileSeedService.publicHome(role))"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "mobileSeedService.home(role, identity)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "mobileSeedService.home(role, resolvedIdentity)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "!UserType.APP_USER.getUserType().equals(loginUser.getUserType())" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "!UserType.APP_USER.getUserType().equals(loginUser.getUserType())"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "无权访问该录音" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "无权访问该录音"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "return AihrSensitiveText.forModel(value)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "return AihrSensitiveText.forModel(value)"
@@ -462,7 +462,6 @@ contains mobile-uni/src/pages/candidate/materials/index.vue "clearData();"
contains mobile-uni/src/pages/candidate/progress/index.vue "const clearData = () =>" contains mobile-uni/src/pages/candidate/progress/index.vue "const clearData = () =>"
contains mobile-uni/src/pages/candidate/study/index.vue "searchKnowledge" contains mobile-uni/src/pages/candidate/study/index.vue "searchKnowledge"
contains mobile-uni/src/pages/user/learning/index.vue "岗位 SOP" contains mobile-uni/src/pages/user/learning/index.vue "岗位 SOP"
contains mobile-uni/src/pages/user/learning/index.vue "当前摘要不代表正式上岗资格"
contains mobile-uni/src/pages/user/learning/index.vue "岗位学习范围加载失败" contains mobile-uni/src/pages/user/learning/index.vue "岗位学习范围加载失败"
contains mobile-uni/src/pages/user/learning/index.vue "getPracticeAssignments" contains mobile-uni/src/pages/user/learning/index.vue "getPracticeAssignments"
contains mobile-uni/src/pages/user/learning/index.vue "searchKnowledge" contains mobile-uni/src/pages/user/learning/index.vue "searchKnowledge"
@@ -471,8 +470,6 @@ contains frontend/src/views/recruit/interview.vue "建议复试"
contains frontend/src/views/recruit/interview.vue 'v-loading="recordsLoading"' contains frontend/src/views/recruit/interview.vue 'v-loading="recordsLoading"'
contains frontend/src/views/recruit/interview.vue "const recordsLoading = ref(true)" contains frontend/src/views/recruit/interview.vue "const recordsLoading = ref(true)"
contains frontend/src/views/train/practice.vue "导师改写" contains frontend/src/views/train/practice.vue "导师改写"
contains frontend/src/views/train/practice.vue 'v-loading="recordsLoading"'
contains frontend/src/views/train/practice.vue "const records = ref<Array<(typeof baseRecords)[number]>>([])"
contains frontend/src/views/knowledge/cases.vue "真实语音" contains frontend/src/views/knowledge/cases.vue "真实语音"
contains frontend/src/views/knowledge/cases.vue 'v-loading="recordsLoading"' contains frontend/src/views/knowledge/cases.vue 'v-loading="recordsLoading"'
contains frontend/src/views/knowledge/cases.vue "const records = ref<CaseRecord[]>([])" contains frontend/src/views/knowledge/cases.vue "const records = ref<CaseRecord[]>([])"
@@ -485,11 +482,8 @@ contains backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql "super
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/records/{caseId}/review" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/records/{caseId}/review"
contains frontend/src/api/aihr/case.ts "reviewKnowledgeCase" contains frontend/src/api/aihr/case.ts "reviewKnowledgeCase"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "系统用户上传案例必须明确所属项目" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "系统用户上传案例必须明确所属项目"
contains frontend/src/views/index.vue "核心流程覆盖待内容确认"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "核心流程覆盖待内容确认" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "核心流程覆盖待内容确认"
contains frontend/src/views/index.vue "不等于端到端验收"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "不等于端到端验收" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "不等于端到端验收"
contains frontend/src/views/index.vue "正式试点身份待核对"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "正式试点身份待核对" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "正式试点身份待核对"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "TenantHelper.getTenantId()" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "TenantHelper.getTenantId()"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "TenantHelper.getTenantId()" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "TenantHelper.getTenantId()"
@@ -500,7 +494,6 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "private String persistedTraineeName" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "private String persistedTraineeName"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "persistedTraineeName(extPartyId.matches" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "persistedTraineeName(extPartyId.matches"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "if (!orgSnapshotTableExists() && !isDemoProfile())" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "if (!orgSnapshotTableExists() && !isDemoProfile())"
contains frontend/src/views/index.vue "待同步"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "核心流程待确认" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "核心流程待确认"
contains frontend/src/views/knowledge/sop.vue "训练题已生成" contains frontend/src/views/knowledge/sop.vue "训练题已生成"
contains frontend/src/views/knowledge/sop.vue "不代表这些文档已真实入库" contains frontend/src/views/knowledge/sop.vue "不代表这些文档已真实入库"
@@ -515,14 +508,12 @@ contains frontend/src/views/system/model/index.vue "提示词模板"
contains frontend/src/views/system/model/index.vue "不代表真实租户配置" contains frontend/src/views/system/model/index.vue "不代表真实租户配置"
contains frontend/src/views/system/model/index.vue "不代表真实租户模板数据" contains frontend/src/views/system/model/index.vue "不代表真实租户模板数据"
contains frontend/src/views/system/prompts.vue "保存并启用" contains frontend/src/views/system/prompts.vue "保存并启用"
contains frontend/src/views/competency/growth.vue "学分规则(待 HR 配置)" contains frontend/src/router/index.ts "path: 'reviews'"
contains frontend/src/views/competency/growth.vue "规划参考,当前不生效" contains frontend/src/router/index.ts "path: 'difficulties'"
contains frontend/src/views/competency/growth.vue "训练画像等级(参考)" contains frontend/src/router/index.ts "path: '/competency/growth'"
contains frontend/src/views/competency/growth.vue "人工贡献分" contains frontend/src/router/index.ts "redirect: '/train/tasks'"
contains frontend/src/views/train/reviews.vue "人工贡献分" contains mobile-uni/src/pages/supervisor/review/index.vue "人工贡献分"
contains frontend/src/views/train/reviews.vue "incentivePoint: null as number | null" contains mobile-uni/src/pages/supervisor/review/index.vue "confirmedGrowthLevel"
contains frontend/src/views/train/reviews.vue "incentivePoint: reviewForm.incentivePoint ?? undefined"
contains frontend/src/views/competency/growth.vue "person.personName || person.extPartyId"
contains mobile-uni/src/pages/user/profile/index.vue "成长证据包(待人工复核)" contains mobile-uni/src/pages/user/profile/index.vue "成长证据包(待人工复核)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "训练画像参考等级" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "训练画像参考等级"
contains frontend/src/router/index.ts "/system/prompts" contains frontend/src/router/index.ts "/system/prompts"
@@ -545,8 +536,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains frontend/src/api/aihr/practice.ts "/api/train/practice/start" contains frontend/src/api/aihr/practice.ts "/api/train/practice/start"
contains frontend/src/api/aihr/practice.ts "/api/aihr/prompt-templates" contains frontend/src/api/aihr/practice.ts "/api/aihr/prompt-templates"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/api/train/practice" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/api/train/practice"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "practiceSeedService.turn(request, currentAppUsername(), currentAppUserId())" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java 'practiceSeedService.turn(request, requirePracticeActorIdentity("继续训练"), currentAppUserId())'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "practiceSeedService.finish(request, currentAppUsername())" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java 'practiceSeedService.finish(request, requirePracticeActorIdentity("完成训练"))'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "private static String requirePracticeActorIdentity(String action)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "训练会话不存在或无权访问" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "训练会话不存在或无权访问"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "finished_time IS NOT NULL" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "finished_time IS NOT NULL"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "人工校准必须明确填写总分" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "人工校准必须明确填写总分"
@@ -575,12 +567,13 @@ contains backend/script/sql/aihr_practice_mysql8.sql "task_completion"
contains backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql "aihr_brd_rubric_codes" contains backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql "aihr_brd_rubric_codes"
contains backend/script/sql/update/aihr_20260715_practice_session_evidence_mysql8.sql "annotations_json" contains backend/script/sql/update/aihr_20260715_practice_session_evidence_mysql8.sql "annotations_json"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java "MistakeAggregateResponse" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java "MistakeAggregateResponse"
contains mobile-uni/src/pages/supervisor/team/index.vue "团队错题本" contains mobile-uni/src/pages/supervisor/team/index.vue "常见难题"
contains mobile-uni/src/pages/supervisor/team/index.vue "一键派专项"
contains mobile-uni/src/types/api.ts "PracticeMistakeAggregate" contains mobile-uni/src/types/api.ts "PracticeMistakeAggregate"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/mistakes" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/mistakes"
contains mobile-uni/src/pages/user/learning/index.vue "错题本" contains mobile-uni/src/pages/user/learning/index.vue "错题本"
contains mobile-uni/src/pages/user/learning/index.vue "mistakeFilters" contains mobile-uni/src/pages/user/learning/index.vue "mistakeFilters"
contains mobile-uni/src/pages/user/profile/index.vue "AI训练综合分仅作训练参考" contains mobile-uni/src/pages/user/profile/index.vue "AI训练综合分仅作参考"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "aihr_practice_session" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "aihr_practice_session"
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_scenario" contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_scenario"
contains backend/script/sql/update/aihr_20260714_practice_catalog_schema_mysql8.sql "aihr_practice_rubric_dimension" contains backend/script/sql/update/aihr_20260714_practice_catalog_schema_mysql8.sql "aihr_practice_rubric_dimension"
@@ -596,12 +589,9 @@ contains backend/script/sql/aihr_practice_mysql8.sql "summary_card"
contains frontend/src/views/train/practice.vue "训练前预习卡" contains frontend/src/views/train/practice.vue "训练前预习卡"
contains frontend/src/views/train/practice.vue "Rubric 预览" contains frontend/src/views/train/practice.vue "Rubric 预览"
contains frontend/src/views/train/practice.vue "提示词模板" contains frontend/src/views/train/practice.vue "提示词模板"
contains frontend/src/views/train/practice.vue "导出试点CSV" contains frontend/src/views/train/practice.vue "此页仅用于不绑定员工身份的运营预览"
contains frontend/src/views/train/practice.vue "训练记录接口暂不可用,当前显示演示记录" contains frontend/src/views/train/practice.vue "不会使用或生成员工身份"
contains frontend/src/views/train/practice.vue "listPracticeReviews" contains frontend/src/router/index.ts "meta: { title: '复盘校准', icon: 'finish', activeMenu: '/train/tasks' }"
contains frontend/src/views/train/reviews.vue "导出试点 CSV"
contains frontend/src/views/train/reviews.vue "人工校准总分"
contains frontend/src/views/train/reviews.vue "不展示演示复盘记录或猜测性试点指标"
contains frontend/src/api/aihr/practice.ts "/api/train/practice/export" contains frontend/src/api/aihr/practice.ts "/api/train/practice/export"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "final_adopted_score" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "final_adopted_score"
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_calibration" contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_calibration"
@@ -640,7 +630,7 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java 'firstNonBlank(position, "生活顾问")' contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java 'firstNonBlank(position, "生活顾问")'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "formalPositionSopTopics(position, effectiveProjectType, projectCodes)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "formalPositionSopTopics(position, effectiveProjectType, projectCodes)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "formal-applicability" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "formal-applicability"
contains mobile-uni/src/pages/user/learning/index.vue "qualificationStatus === 'NOT_CONFIGURED'" contains mobile-uni/src/pages/user/learning/index.vue "const qualificationStatusLabel = computed(() =>"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "currentAppProjectCodes()" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "currentAppProjectCodes()"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "FIND_IN_SET(?, REPLACE(a.org_scope, ' ', '')) > 0" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "FIND_IN_SET(?, REPLACE(a.org_scope, ' ', '')) > 0"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeSearchSource" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeSearchSource"
@@ -668,7 +658,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "待复盘对练" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "待复盘对练"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, \"hr_operator\")" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "private String supervisorScopeExtPartyId()"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "后台管理账号不能使用主管带教接口"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "mobileSeedService.requireMobileSupervisorIdentity(currentAppUsername())"
contains mobile-uni/src/pages/supervisor/review/index.vue "人工贡献分" contains mobile-uni/src/pages/supervisor/review/index.vue "人工贡献分"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed"
contains mobile-uni/src/pages/supervisor/review/index.vue "标记已复盘" contains mobile-uni/src/pages/supervisor/review/index.vue "标记已复盘"
@@ -712,7 +704,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "currentProjectScopes" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "currentProjectScopes"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "projectScopesForAppUser" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "projectScopesForAppUser"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case"
contains frontend/src/api/aihr/sop.ts "/api/knowledge/search" contains frontend/src/api/aihr/sop.ts "/api/knowledge/query"
contains frontend/src/api/aihr/sop.ts "UnifiedQueryResponse"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "@SaCheckLogin" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "@SaCheckLogin"
contains frontend/src/api/aihr/sop.ts "/api/knowledge/doc/upload" contains frontend/src/api/aihr/sop.ts "/api/knowledge/doc/upload"
contains frontend/src/api/aihr/sop.ts "/api/knowledge/reviews" contains frontend/src/api/aihr/sop.ts "/api/knowledge/reviews"
@@ -740,7 +733,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "requireSessionTenant(session, tenantId())" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "requireSessionTenant(session, tenantId())"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "private record InterviewSession(String tenantId" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "private record InterviewSession(String tenantId"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "canReadPracticeAudioForTenant" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "canReadPracticeAudioForTenant"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "mobileSeedService.canReadPracticeAudioForTenant(ossId)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "if (!UserType.APP_USER.getUserType().equals(loginUser.getUserType()))"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "mobileSeedService.canReadPracticeAudio(ossId, currentAppUsername())"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "oss.getOssId(), \"\")" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "oss.getOssId(), \"\")"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "new TtsResponse(inlineAudioUrl, \"openai-compatible\", oss.getOssId(), inlineAudioUrl)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "new TtsResponse(inlineAudioUrl, \"openai-compatible\", oss.getOssId(), inlineAudioUrl)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "exposedAudioUrl" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "exposedAudioUrl"
@@ -755,7 +749,8 @@ contains backend/ruoyi-admin/src/main/resources/application-dev.yml "AIHR_REDIS_
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "person_phone like ?" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "person_phone like ?"
contains backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java "hiddenDisplayFieldsStillAllowIdentitySearchByPhone" contains backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java "hiddenDisplayFieldsStillAllowIdentitySearchByPhone"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "requester_ext_party_id" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "requester_ext_party_id"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "currentRequesterExtPartyId" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java "mobileSeedService.requireMobileIdentity(trim(loginUser.getUsername()))"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java 'principal == null ? "" : principal.extPartyId(), spaceIds'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "hasExplicitSopReviewDecision" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "hasExplicitSopReviewDecision"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "validateFeedbackFragmentOwnership" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "validateFeedbackFragmentOwnership"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeFeedbackReviewId" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeFeedbackReviewId"
@@ -768,7 +763,6 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "tts audio upload failed(处理错误已隐藏)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "tts audio upload failed(处理错误已隐藏)"
contains mobile-uni/src/pages/user/history/index.vue "inlineAudioSrc" contains mobile-uni/src/pages/user/history/index.vue "inlineAudioSrc"
contains mobile-uni/src/pages/supervisor/review/index.vue "inlineAudioSrc" contains mobile-uni/src/pages/supervisor/review/index.vue "inlineAudioSrc"
contains frontend/src/views/train/reviews.vue "inlineAudioSource"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java "外部 AI 模型前" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java "外部 AI 模型前"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "AihrSensitiveText.forModel(prompt)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "AihrSensitiveText.forModel(prompt)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "外部响应体已隐藏" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "外部响应体已隐藏"
+11 -2
View File
@@ -244,12 +244,21 @@ else
fi fi
PIDS+=("$!") PIDS+=("$!")
./scripts/dev-frontend.sh & BACKEND_PROXY_TARGET=""
if using_portless_backend; then
for _ in {1..20}; do
BACKEND_PROXY_TARGET="$(portless get wygj-api 2>/dev/null || true)"
[[ -n "$BACKEND_PROXY_TARGET" ]] && break
sleep 0.1
done
fi
VITE_APP_PROXY_TARGET="${VITE_APP_PROXY_TARGET:-$BACKEND_PROXY_TARGET}" ./scripts/dev-frontend.sh &
PIDS+=("$!") PIDS+=("$!")
if using_portless_mobile; then if using_portless_mobile; then
portless alias --remove wygj-mobile-uni >/dev/null 2>&1 || true portless alias --remove wygj-mobile-uni >/dev/null 2>&1 || true
VITE_PROXY_TARGET="${VITE_PROXY_TARGET:-https://wygj-api.localhost}" portless run --name wygj-mobile-uni --force npm --prefix mobile-uni run dev & VITE_PROXY_TARGET="${VITE_PROXY_TARGET:-$BACKEND_PROXY_TARGET}" portless run --name wygj-mobile-uni --force npm --prefix mobile-uni run dev &
else else
./scripts/dev-mobile.sh & ./scripts/dev-mobile.sh &
fi fi
+143
View File
@@ -71,6 +71,9 @@ fi
if [[ "${RELEASE_VERIFY_REMOTE_SCHEMA:-false}" == "true" && "${RELEASE_VERIFY_REMOTE_MATCH:-false}" != "true" ]]; then if [[ "${RELEASE_VERIFY_REMOTE_SCHEMA:-false}" == "true" && "${RELEASE_VERIFY_REMOTE_MATCH:-false}" != "true" ]]; then
fail "RELEASE_VERIFY_REMOTE_SCHEMA=true requires RELEASE_VERIFY_REMOTE_MATCH=true" fail "RELEASE_VERIFY_REMOTE_SCHEMA=true requires RELEASE_VERIFY_REMOTE_MATCH=true"
fi fi
if [[ "${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:-false}" == "true" ]]; then
fail "AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP must stay false for a release; apply the formal SQL migration instead"
fi
head_epoch="$(git show -s --format=%ct HEAD)" head_epoch="$(git show -s --format=%ct HEAD)"
@@ -106,6 +109,60 @@ require_remote_business_success() {
echo "$label=200 $url" echo "$label=200 $url"
} }
require_remote_practice_schema_guard() {
local remote_ssh="${RELEASE_REMOTE_SSH:-YCWY}"
local remote_service="${RELEASE_REMOTE_SERVICE:-wygj-aihr.service}"
[[ "$remote_service" =~ ^[A-Za-z0-9_.@-]+\.service$ ]] \
|| fail "remote systemd service name is invalid: $remote_service"
local state
state="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_service" <<'REMOTE'
set -euo pipefail
service="$1"
matches_enabled_guard() {
grep -Eiq '(AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP|aihr\.practice\.runtime-schema-bootstrap)[[:space:]]*=[[:space:]]*"?true"?'
}
runtime_environment="$(systemctl show "$service" --property=Environment --value --no-pager)"
unit_source="$(systemctl cat "$service")"
if printf '%s\n' "$runtime_environment" | matches_enabled_guard \
|| printf '%s\n' "$unit_source" | matches_enabled_guard; then
printf 'enabled\n'
exit 0
fi
while IFS= read -r environment_file; do
[[ -z "$environment_file" ]] && continue
if [[ ! -r "$environment_file" ]]; then
printf 'unreadable:%s\n' "$environment_file"
exit 0
fi
if matches_enabled_guard < "$environment_file"; then
printf 'enabled\n'
exit 0
fi
done < <(printf '%s\n' "$unit_source" | sed -nE 's/^[[:space:]]*EnvironmentFile=-?([^[:space:]#]+).*/\1/p')
printf 'disabled-or-default\n'
REMOTE
)" || fail "remote runtime schema guard check failed: $remote_ssh:$remote_service"
case "$state" in
disabled-or-default)
echo "remote_practice_runtime_schema_bootstrap=false/default $remote_ssh:$remote_service"
;;
enabled)
fail "remote AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP is enabled; disable it and apply formal SQL migrations"
;;
unreadable:*)
fail "remote runtime schema guard cannot inspect ${state#unreadable:}; grant read access or unset the file before release"
;;
*)
fail "remote runtime schema guard returned an unexpected state: $state"
;;
esac
}
require_remote_schema() { require_remote_schema() {
local remote_ssh="${RELEASE_REMOTE_SSH:-YCWY}" local remote_ssh="${RELEASE_REMOTE_SSH:-YCWY}"
local remote_db="${RELEASE_REMOTE_DB_NAME:-ry-vue}" local remote_db="${RELEASE_REMOTE_DB_NAME:-ry-vue}"
@@ -232,6 +289,87 @@ REMOTE
[[ -z "$missing_work_report_columns" ]] \ [[ -z "$missing_work_report_columns" ]] \
|| fail "remote work-report schema missing required columns: $(printf '%s' "$missing_work_report_columns" | tr '\n' ', ' | sed 's/, $//')" || fail "remote work-report schema missing required columns: $(printf '%s' "$missing_work_report_columns" | tr '\n' ', ' | sed 's/, $//')"
local missing_practice_curriculum_columns
missing_practice_curriculum_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT CONCAT(required.table_name, '.', required.column_name)
FROM (
SELECT 'aihr_practice_scenario' AS table_name, 'growth_level' AS column_name
UNION ALL SELECT 'aihr_practice_scenario', 'competency_code'
UNION ALL SELECT 'aihr_practice_scenario', 'collaboration_positions'
UNION ALL SELECT 'aihr_practice_scenario', 'review_status'
UNION ALL SELECT 'aihr_practice_scenario', 'risk_level'
UNION ALL SELECT 'aihr_practice_scenario', 'curriculum_version'
UNION ALL SELECT 'aihr_practice_scenario', 'reviewer'
UNION ALL SELECT 'aihr_practice_scenario', 'reviewer_user_id'
UNION ALL SELECT 'aihr_practice_scenario', 'reviewed_time'
UNION ALL SELECT 'aihr_practice_scenario', 'second_reviewer'
UNION ALL SELECT 'aihr_practice_scenario', 'second_reviewer_user_id'
UNION ALL SELECT 'aihr_practice_scenario', 'second_reviewed_time'
UNION ALL SELECT 'aihr_practice_session', 'position_snapshot'
UNION ALL SELECT 'aihr_practice_session', 'project_type_snapshot'
UNION ALL SELECT 'aihr_practice_session', 'growth_level'
UNION ALL SELECT 'aihr_practice_session', 'competency_code'
UNION ALL SELECT 'aihr_practice_session', 'collaboration_positions'
UNION ALL SELECT 'aihr_practice_session', 'curriculum_version'
UNION ALL SELECT 'aihr_practice_session', 'growth_confirmation_level'
UNION ALL SELECT 'aihr_practice_session', 'growth_confirmed_by'
UNION ALL SELECT 'aihr_practice_session', 'growth_confirmed_time'
) required
LEFT JOIN information_schema.columns actual
ON actual.table_schema = DATABASE()
AND actual.table_name = required.table_name
AND actual.column_name = required.column_name
WHERE actual.column_name IS NULL
ORDER BY required.table_name, required.column_name;
SQL
REMOTE
)" || fail "remote practice-curriculum schema check failed: $remote_ssh:$remote_db"
[[ -z "$missing_practice_curriculum_columns" ]] \
|| fail "remote practice-curriculum schema missing required columns: $(printf '%s' "$missing_practice_curriculum_columns" | tr '\n' ', ' | sed 's/, $//')"
local practice_assignment_request_unique_index
practice_assignment_request_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT CONCAT(
COALESCE(MIN(non_unique), -1), '|',
COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|',
COALESCE(SUM(sub_part IS NOT NULL), 0)
)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'aihr_practice_assignment'
AND index_name = 'uk_aihr_assignment_request';
SQL
REMOTE
)" || fail "remote practice-assignment idempotency-index check failed: $remote_ssh:$remote_db"
[[ "$practice_assignment_request_unique_index" = "0|tenant_id,request_key,ext_party_id|0" ]] \
|| fail "remote practice-assignment idempotency index invalid: expected unique full columns tenant_id,request_key,ext_party_id; got ${practice_assignment_request_unique_index:-missing}"
local practice_assignment_recent_content_index
practice_assignment_recent_content_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT CONCAT(
COALESCE(MIN(non_unique), -1), '|',
COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|',
COALESCE(SUM(sub_part IS NOT NULL), 0)
)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'aihr_practice_assignment'
AND index_name = 'idx_aihr_assignment_recent_content';
SQL
REMOTE
)" || fail "remote practice-assignment recent-content-index check failed: $remote_ssh:$remote_db"
[[ "$practice_assignment_recent_content_index" = "1|tenant_id,ext_party_id,source,content_hash,create_time|0" ]] \
|| fail "remote practice-assignment recent-content index invalid: expected full columns tenant_id,ext_party_id,source,content_hash,create_time; got ${practice_assignment_recent_content_index:-missing}"
local agent_schema_contract local agent_schema_contract
agent_schema_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' agent_schema_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail set -euo pipefail
@@ -597,6 +735,7 @@ REMOTE
|| fail "remote work-assistant schema missing required columns: $(printf '%s' "$missing_work_assistant_columns" | tr '\n' ', ' | sed 's/, $//')" || fail "remote work-assistant schema missing required columns: $(printf '%s' "$missing_work_assistant_columns" | tr '\n' ', ' | sed 's/, $//')"
echo "remote_schema=64/64 $remote_ssh:$remote_db" echo "remote_schema=64/64 $remote_ssh:$remote_db"
echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db" echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db"
echo "remote_practice_curriculum_columns=13/13 $remote_ssh:$remote_db"
echo "remote_knowledge_category_columns=7/7 $remote_ssh:$remote_db" echo "remote_knowledge_category_columns=7/7 $remote_ssh:$remote_db"
echo "remote_broadcast_publish_audit_columns=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_publish_audit_columns=3/3 $remote_ssh:$remote_db"
echo "remote_broadcast_publish_idempotency=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_publish_idempotency=3/3 $remote_ssh:$remote_db"
@@ -639,6 +778,10 @@ if [[ -n "${RELEASE_REMOTE_URL:-}" ]]; then
require_remote_schema require_remote_schema
fi fi
if [[ "${RELEASE_VERIFY_REMOTE_SCHEMA:-false}" == "true" || "${RELEASE_VERIFY_REMOTE_BACKEND:-false}" == "true" ]]; then
require_remote_practice_schema_guard
fi
if [[ "${RELEASE_VERIFY_REMOTE_MATCH:-false}" == "true" ]]; then if [[ "${RELEASE_VERIFY_REMOTE_MATCH:-false}" == "true" ]]; then
remote_frontend_asset="$(curl -fsS --max-time 15 "$remote/" | sed -nE 's/.*src="(\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)" remote_frontend_asset="$(curl -fsS --max-time 15 "$remote/" | sed -nE 's/.*src="(\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)"
remote_mobile_asset="$(curl -fsS --max-time 15 "$remote/h5/" | sed -nE 's/.*src="(\/h5\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)" remote_mobile_asset="$(curl -fsS --max-time 15 "$remote/h5/" | sed -nE 's/.*src="(\/h5\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)"
+5
View File
@@ -23,6 +23,9 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_practice_growth_curriculum_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql"
@@ -35,6 +38,8 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \ docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
-e "select count(*) as tables_count from information_schema.tables where table_schema = 'ry-vue';" -e "select count(*) as tables_count from information_schema.tables where table_schema = 'ry-vue';"
+67 -1
View File
@@ -119,6 +119,72 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_practice_sessio
practice_session_evidence_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_session' AND column_name IN ('annotations_json','review_advice','incentive_point','satisfaction_score','satisfaction_comment');")" practice_session_evidence_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_session' AND column_name IN ('annotations_json','review_advice','incentive_point','satisfaction_score','satisfaction_comment');")"
test "$practice_session_evidence_columns" = '5' test "$practice_session_evidence_columns" = '5'
# The growth curriculum migration must be able to upgrade the compact legacy
# practice tables used by older deployments, and be safe when applied again.
mysql "$DB" <<'SQL'
ALTER TABLE aihr_practice_session
ADD COLUMN tenant_id varchar(20) DEFAULT '000000',
ADD COLUMN mode varchar(20) DEFAULT 'mobile',
ADD COLUMN scenario_name varchar(100) DEFAULT NULL;
CREATE TABLE aihr_practice_scenario (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) DEFAULT '000000',
scenario_code varchar(80) NOT NULL,
position varchar(80) DEFAULT '生活顾问',
difficulty tinyint DEFAULT 1,
goal varchar(1000) DEFAULT NULL,
enabled tinyint DEFAULT 1,
PRIMARY KEY (id),
UNIQUE KEY uk_aihr_practice_scenario (tenant_id, scenario_code)
) ENGINE=InnoDB;
INSERT INTO aihr_practice_scenario (tenant_id, scenario_code, position, difficulty, goal, enabled) VALUES
('000000', 'legacy-growth-enabled', '生活顾问', 2, 'legacy goal', 1),
('000000', 'legacy-growth-disabled', '保安', 3, 'legacy goal', 0);
CREATE TABLE aihr_practice_assignment (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) DEFAULT '000000',
ext_party_id varchar(100) NOT NULL,
request_key varchar(100) DEFAULT NULL,
source varchar(30) NOT NULL DEFAULT 'manual',
content_hash char(64) DEFAULT NULL,
create_time datetime DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
SQL
practice_growth_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260724_practice_growth_curriculum_mysql8.sql"
test -f "$practice_growth_migration"
mysql "$DB" < "$practice_growth_migration"
# A high-risk item may already have completed its first review when a release
# re-runs this idempotent migration. Its audit trail must survive for the
# second reviewer instead of being reset to an unreviewed item.
mysql "$DB" <<'SQL'
UPDATE aihr_practice_scenario
SET review_status = '待业务审核', risk_level = '高风险',
reviewer = '首审甲', reviewer_user_id = 101, reviewed_time = '2026-07-24 10:00:00'
WHERE scenario_code = 'legacy-growth-enabled';
SQL
mysql "$DB" < "$practice_growth_migration"
practice_growth_scenario_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY column_name) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_scenario' AND column_name IN ('growth_level','competency_code','collaboration_positions','review_status','risk_level','curriculum_version','reviewer','reviewer_user_id','reviewed_time','second_reviewer','second_reviewer_user_id','second_reviewed_time');")"
test "$practice_growth_scenario_columns" = 'collaboration_positions,competency_code,curriculum_version,growth_level,review_status,reviewed_time,reviewer,reviewer_user_id,risk_level,second_reviewed_time,second_reviewer,second_reviewer_user_id'
practice_growth_session_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY column_name) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_session' AND column_name IN ('position_snapshot','project_type_snapshot','growth_level','competency_code','collaboration_positions','curriculum_version','growth_confirmation_level','growth_confirmed_by','growth_confirmed_time');")"
test "$practice_growth_session_columns" = 'collaboration_positions,competency_code,curriculum_version,growth_confirmation_level,growth_confirmed_by,growth_confirmed_time,growth_level,position_snapshot,project_type_snapshot'
practice_growth_index="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_practice_scenario' AND index_name='idx_aihr_practice_scenario_curriculum';")"
test "$practice_growth_index" = 'tenant_id,position,growth_level,competency_code,review_status,enabled'
practice_assignment_request_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_practice_assignment' AND index_name='uk_aihr_assignment_request';")"
test "$practice_assignment_request_index" = '0|tenant_id,request_key,ext_party_id|0'
practice_assignment_recent_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_practice_assignment' AND index_name='idx_aihr_assignment_recent_content';")"
test "$practice_assignment_recent_index" = '1|tenant_id,ext_party_id,source,content_hash,create_time|0'
mysql "$DB" -e "ALTER TABLE aihr_practice_assignment DROP INDEX uk_aihr_assignment_request, ADD INDEX uk_aihr_assignment_request (tenant_id, request_key(20), ext_party_id), DROP INDEX idx_aihr_assignment_recent_content, ADD KEY idx_aihr_assignment_recent_content (tenant_id, ext_party_id, source, content_hash);"
mysql "$DB" < "$practice_growth_migration"
practice_assignment_repaired_request_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_practice_assignment' AND index_name='uk_aihr_assignment_request';")"
test "$practice_assignment_repaired_request_index" = '0|tenant_id,request_key,ext_party_id|0'
practice_assignment_repaired_recent_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_practice_assignment' AND index_name='idx_aihr_assignment_recent_content';")"
test "$practice_assignment_repaired_recent_index" = '1|tenant_id,ext_party_id,source,content_hash,create_time|0'
practice_growth_statuses="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(CONCAT(scenario_code,':',review_status) ORDER BY scenario_code) FROM aihr_practice_scenario WHERE scenario_code LIKE 'legacy-growth-%';")"
test "$practice_growth_statuses" = 'legacy-growth-disabled:待业务审核,legacy-growth-enabled:待业务审核'
practice_growth_first_review="$(mysql "$DB" -N -B -e "SELECT CONCAT(COALESCE(reviewer,''),':',COALESCE(reviewer_user_id,'')) FROM aihr_practice_scenario WHERE scenario_code='legacy-growth-enabled';")"
test "$practice_growth_first_review" = '首审甲:101'
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql"
evidence_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_practice_audio','aihr_practice_audio_upload','aihr_practice_calibration');")" evidence_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_practice_audio','aihr_practice_audio_upload','aihr_practice_calibration');")"
@@ -436,4 +502,4 @@ test "$agent_run_unique" = '0|run_id'
agent_user_index="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_agent_run' AND index_name='idx_aihr_agent_run_user';")" agent_user_index="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_agent_run' AND index_name='idx_aihr_agent_run_user';")"
test "$agent_user_index" = 'tenant_id,user_id,create_time' test "$agent_user_index" = 'tenant_id,user_id,create_time'
echo "PASS: AIHR legacy schema migrations are idempotent, including Agent run audit, knowledge-space categories, practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, work-report idempotency, broadcast publish/withdraw audit with M1 targeting defaults and target-table contracts, v1 snapshots, and trusted broadcast question context" echo "PASS: AIHR legacy schema migrations are idempotent, including Agent run audit, practice growth curriculum snapshots, knowledge-space categories, practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, work-report idempotency, direct feedback, broadcast publish/withdraw audit with M1 targeting and file attachment contracts, v1 snapshots, and trusted broadcast question context"