diff --git a/.gitignore b/.gitignore
index e91e8ab4..927a49c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,7 @@ frontend/dist/
frontend/.vite/
mobile/node_modules/
mobile/dist/
+mobile-uni/node_modules/
+mobile-uni/dist/
.tmp/
+.worktrees/
diff --git a/AGENTS.md b/AGENTS.md
index 2cea1902..b6c7379e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,7 @@
- `backend/` 是 `dromara/RuoYi-Vue-Plus` 的 `5.X` 分支检出。
- `frontend/` 是 `CrazyLionCat/plus-ui` 的 `5.X` 分支检出,只承载管理端。
-- `mobile/` 是独立移动端工程,承载员工端、候选人端、主管端;当前是 Vue/Vite H5,三端首页按 `docs/prototypes/*端-首页.png` 实现,后续确定小程序技术栈后再迁移到 uni-app/Taro。
+- `mobile-uni/` 是当前用户侧工程,承载员工端、候选人端、主管端,使用 uni-app Vue3 H5,后续便于转换 H5/APP/小程序;旧 `mobile/` 仅保留为 MVP 演示兜底,不再承载新增功能。
- 本项目自己的本地编排放在根目录:`docker-compose.dev.yml`、`scripts/`;运行说明在 `docs/DEV_SETUP.md`。
- 除非确实要接业务模块,不要改上游框架默认配置;本地端口和开关优先放根目录脚本里覆盖。
@@ -20,25 +20,27 @@
- 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` 覆盖。
- 本地私密配置:根目录 `.env.local` 已被 `.gitignore` 忽略,`scripts/dev-backend.sh` 会自动加载;阿里云短信密钥只放这里或外部环境变量,不写入 `application-*.yml`。
-- 后端:`./scripts/dev-backend.sh`,端口 `8080`。
-- 管理端前端:`./scripts/dev-frontend.sh`,端口 `5173`。
-- 移动端前端:`./scripts/dev-mobile.sh`,端口 `5174`。
+- 后端:默认通过 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` 指向该入口。
+- 管理端前端:`./scripts/dev-frontend.sh`,默认走 portless,入口 `https://wygj-admin.localhost/`;显式 `PORTLESS=0` 时回退端口 `5173`。
+- 当前用户侧前端:`portless` 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/`。
+- 旧移动端兜底:`./scripts/dev-mobile.sh`,端口 `5174`,只用于回看 MVP 演示壳。
- 重置数据:`./scripts/reset-dev-db.sh`,会删除并重建本地 `ry-vue`。
导入 SQL 必须带 `--default-character-set=utf8mb4`,否则中文昵称可能按错误字符集导入并触发字段长度问题。
## 验证口径
-- 前端首页:`http://127.0.0.1:5173/`
-- 移动端首页:`http://127.0.0.1:5174/h5/user`
-- 租户接口:`http://127.0.0.1:5173/dev-api/auth/tenant/list`
-- 验证码接口:`http://127.0.0.1:5173/dev-api/auth/code`
+- 前端首页:`https://wygj-admin.localhost/`
+- 用户侧首页:`https://wygj-mobile-uni.localhost/h5/`
+- 后端接口:`https://wygj-api.localhost/`
+- 租户接口:`https://wygj-admin.localhost/dev-api/auth/tenant/list`
+- 验证码接口:`https://wygj-admin.localhost/dev-api/auth/code`
- 默认登录:租户 `000000`,管理员 `admin / admin123`。
## 发布口径
-- 线上移动端 H5:`https://peilian.njzhmj.top/h5/user`,Caddy 服务 `/opt/wygj/www/h5`。
-- 只发布移动端静态资源时,用 `VITE_BASE=/h5/ npm --prefix mobile run build` 后备份远端 `/opt/wygj/www/h5`,再 `rsync -az --delete mobile/dist/ YCWY:/opt/wygj/www/h5/`;前端静态修复不需要重启后端。
+- 线上移动端 H5:`https://peilian.njzhmj.top/h5/`,Caddy 服务 `/opt/wygj/www/h5`。
+- 只发布移动端静态资源时,用 `npm --prefix mobile-uni run build:h5` 后备份远端 `/opt/wygj/www/h5`,再 `rsync -az --delete mobile-uni/dist/build/h5/ YCWY:/opt/wygj/www/h5/`;前端静态修复不需要重启后端。
## 品牌入口
@@ -55,10 +57,11 @@
- 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`。
- 模型配置:`frontend/src/views/system/model/index.vue`,路由 `/system/model`。
- MVP 阶段侧栏只保留“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”。过滤逻辑在 `frontend/src/layout/components/Sidebar/index.vue`,不要在未明确要求时恢复若依默认全量菜单。
-- 移动端三端首页:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,分别对应用户/员工端、候选人端、主管端;兼容 `/h5/employee`、`/h5/admin`,但移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换(isHomeTab + activeUserPanel/activeSupervisorPanel 条件),**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 全屏层(sopPanel)调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。
+- 移动端三端首页:`mobile-uni/src/pages/*`,员工端 `/h5/#/pages/user/today/index`、候选人端 `/h5/#/pages/candidate/index/index`、主管端 `/h5/#/pages/supervisor/index/index`;`/h5/user`、`/h5/candidate`、`/h5/supervisor`、`/h5/employee`、`/h5/admin` 仅作为旧链接兼容重定向,移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);员工端训练页是 `/h5/#/pages/user/practice/index`,首屏必须先给「场景模拟/开始练习/最近练过」,每日三题后置;点击「开始练习」进入训练对话区域,不能在旧 `mobile/src/App.vue` 上继续堆锚点。师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换,**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 页面调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。
- 移动端首页接口:`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`。dev 兜底:`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod profile 代码级强制失效,不要移除该闸门。
- 移动端员工训练闭环:员工端首页“开始训练”复用 `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`。
+- 正式试点 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`。
- 当前业务页已跑通可演示闭环;后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 已接真 LLM(`AihrPracticeLlmService`,评分 temperature=0 结构化输出),seed 剧本是剧情锚点与兜底,改对练逻辑时必须保留"未配置/失败回退 seed"的降级链,不要让演示依赖外部 API。
- 对练语音走 `POST /api/ai/asr`(≤5MB multipart)和 `POST /api/ai/tts`(文本≤300字,返回 base64 dataURL);模型走 `aihr_model_config` 的 `asr`/`tts` 类目(`category` 全集:chat/vector/rerank/asr/tts/vision)。移动端 `getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用隐藏 `audio/*` file input 选择/录制音频后继续走同一 ASR 接口,不要退回只有文字输入。multipart 头部的 filename/contentType 已做 CRLF 清洗,新增外发 HTTP 时同样注意。
@@ -82,6 +85,7 @@
- 阶段交付归档:`docs/archive/2026-07-mvp-delivery/`
- 分阶段总纲:`docs/银城员工端APP分阶段实施总纲.md`
- AI 陪练二期推进(阶段一执行):`docs/AI陪练二期开发推进计划.md`
+- 数字师傅产品定案(2026-07-08 学练问报/问题榜/学分):`docs/20260708/数字师傅学练问报整合方案.md`
- AI 陪练缺口修订:`docs/AI陪练功能优化与缺口分析(修订).md`
- 后端 API 对接:`docs/API_INTEGRATION.md`
- 对外会议版口径:`docs/AI人力资源系统项目规划方案与AI接口说明.md`
diff --git a/README.md b/README.md
index 11ad6c5c..9e49eb91 100644
--- a/README.md
+++ b/README.md
@@ -6,15 +6,16 @@
- 后端:`backend`,来自 `dromara/RuoYi-Vue-Plus` 的 `5.X` 分支。
- 管理端前端:`frontend`,来自 `CrazyLionCat/plus-ui` 的 `5.X` 分支。
-- 移动端前端:`mobile`,独立 Vue/Vite 工程,承接员工端、候选人端、主管端。
+- 用户侧前端:`mobile-uni`,uni-app Vue3 H5 工程,承接员工端、候选人端、主管端;旧 `mobile` 保留为 MVP 演示兜底。
- 本地依赖:MySQL + Redis + MinIO + Qdrant 由 `docker-compose.dev.yml` 启动。
- 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。
- 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。
- 管理端 MVP:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。
-- 移动端 MVP:三端首页已拆到独立工程 `mobile/`;路由为 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,并兼容 `/h5/employee`、`/h5/admin`;已接手机号短信登录、`GET /api/aihr/mobile/home/{role}` 首页 API。员工端首页为「数字师傅」形态(师傅头像四状态+按住说话/打字问+快捷chips→今日安排→我的地图四色块→成长摘要,视觉基准 `docs/feedback/2026070423/design/`),底部 tab 今日/练/问/我整页切换;“开始训练”可完成两轮三角色对练,记录落 `aihr_practice_session` 并刷新主管端待复盘列表、复盘详情、标记已复盘状态和员工能力画像;「查 SOP」文本/语音提问返回结构化概要+引用片段,可按需生成信息图总结卡(听一遍/开始跟读);候选人端“开始面试/面试练习”复用 AI 面试 API 拉题、作答、评分,“补充资料”走 OSS + `aihr_candidate_material` 入库,HR 可在 AI 面试页审核通过/驳回;前端保留本地 fallback。
+- 用户侧 Alpha:三端已在 `mobile-uni/` 按真实页面栈重建,portless 本地入口为 `https://wygj-mobile-uni.localhost/h5/`;员工端路由为 `/h5/#/pages/user/today/index`、`/practice/index`、`/sop/index`、`/profile/index`,候选人端为 `/h5/#/pages/candidate/index/index`,主管端为 `/h5/#/pages/supervisor/index/index`。已接手机号短信登录、问师傅检索/反馈/总结卡、员工训练闭环、案例素材入库、候选人面试/资料、主管复盘/派专项;旧 `mobile/` 不再作为新增功能承载面。
- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述);模型配置页可查看向量库维度/点数并一键重建。
- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。
-- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。
+- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。
+- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。
- 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。
- 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。
@@ -24,7 +25,7 @@
./scripts/dev.sh
```
-首次启动或需要重建本地 MVP 数据库时执行 `./scripts/dev.sh --reset`。详细端口、默认账号和验证项见 [本地运行手册](docs/DEV_SETUP.md)。
+首次启动或需要重建本地 MVP 数据库时执行 `./scripts/dev.sh --reset`。`./scripts/dev.sh` 默认通过 portless 启动后端、管理端和当前 `mobile-uni` 用户侧,对应入口为 `https://wygj-api.localhost/`、`https://wygj-admin.localhost/`、`https://wygj-mobile-uni.localhost/h5/`。只有显式 `PORTLESS=0 ./scripts/dev.sh` 时才回退固定端口和旧移动端兜底。详细端口、默认账号和验证项见 [本地运行手册](docs/DEV_SETUP.md)。
## 目录
@@ -32,7 +33,8 @@
|---|---|
| `backend/` | RuoYi-Vue-Plus 后端基座 |
| `frontend/` | plus-ui 管理端前端基座 |
-| `mobile/` | 员工端、候选人端、主管端独立移动端工程 |
+| `mobile-uni/` | 当前用户侧 uni-app H5 工程 |
+| `mobile/` | 旧移动端 H5,保留为 MVP 演示兜底 |
| `scripts/` | 本地 MVP 启动与数据重置脚本 |
| `docker-compose.dev.yml` | 本地 MySQL / Redis / MinIO / Qdrant |
| `docs/` | 项目文档、旧方案参考、Word 原件 |
@@ -44,17 +46,17 @@
| [文档目录](docs/README.md) | docs 区完整索引 |
| [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 |
| [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 |
-| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、接口接入顺序、移动端训练记录、候选资料上传/审核和 SOP 上传解析接口 |
+| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 |
| [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 |
| [项目规划方案与 AI 接口说明](docs/AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位和 AI 接口规划 |
| [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用 |
| [银城员工端APP分阶段实施总纲](docs/银城员工端APP分阶段实施总纲.md) | 2026-07-07 立项会后的三阶段总纲与阶段二/三边界 |
+| [数字师傅学练问报整合方案](docs/20260708/数字师傅学练问报整合方案.md) | 2026-07-08 产品讨论定案:学练问报四模块、开放问题榜、学分晋升、发布会「生活顾问的一天」 |
| [AI 陪练缺口修订版](docs/AI陪练功能优化与缺口分析(修订).md) | 24 项陪练缺口的代码实况校对和二期排期依据 |
-| [阶段交付归档](docs/archive/2026-07-mvp-delivery/) | 2026-07 MVP 作战清单、演示 PPT 和阶段交付材料 |
+| [阶段交付归档](docs/archive/2026-07-mvp-delivery/) | 2026-07 MVP 作战清单、演示 PPT、设计 QA 记录和阶段交付材料 |
| [业务需求 BRD](docs/物业AI人力资源系统业务需求文档BRD.md) | 业务范围、角色、边界和验收 |
| [开发规格 TechSpec](docs/物业AI人力资源系统开发规格TechSpec.md) | 模块、数据模型、路由、API 和实现切分 |
-| [移动端设计 QA](design-qa.md) | 三端首页高保真实现的截图对照记录 |
## 下一步
-现役演示验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。2026-07-07 立项会后按 [银城员工端APP分阶段实施总纲](docs/银城员工端APP分阶段实施总纲.md) 三阶段推进:阶段一即 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)(时间锚点 2026-07-10 Alpha → 半年会发布 → 9 月收费季前试点可用);大喇叭信息平台、知识学习平台、北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。
+现役验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。阶段一的工程闭环已经就绪,当前唯一主线是补齐正式组织身份数据,完成 1–2 个项目、至少 20 名员工的真实训练、校准和 SOP 业务评审后重跑 M5 严格门禁;详见 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)。大喇叭信息平台、知识学习平台和北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。
diff --git a/backend/ruoyi-admin/src/main/java/org/dromara/web/listener/UserActionListener.java b/backend/ruoyi-admin/src/main/java/org/dromara/web/listener/UserActionListener.java
index a45be585..9ee0d1a1 100644
--- a/backend/ruoyi-admin/src/main/java/org/dromara/web/listener/UserActionListener.java
+++ b/backend/ruoyi-admin/src/main/java/org/dromara/web/listener/UserActionListener.java
@@ -47,8 +47,8 @@ public class UserActionListener implements SaTokenListener {
UserOnlineDTO dto = new UserOnlineDTO();
dto.setIpaddr(ip);
dto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
- dto.setBrowser(userAgent.getBrowser().getName());
- dto.setOs(userAgent.getOs().getName());
+ dto.setBrowser(userAgent == null ? "Unknown" : userAgent.getBrowser().getName());
+ dto.setOs(userAgent == null ? "Unknown" : userAgent.getOs().getName());
dto.setLoginTime(System.currentTimeMillis());
dto.setTokenId(tokenValue);
String username = (String) loginParameter.getExtra(LoginHelper.USER_NAME_KEY);
diff --git a/backend/ruoyi-modules/ruoyi-aihr/pom.xml b/backend/ruoyi-modules/ruoyi-aihr/pom.xml
index 6883e0fc..a5cf11e1 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/pom.xml
+++ b/backend/ruoyi-modules/ruoyi-aihr/pom.xml
@@ -53,5 +53,11 @@
tika-parser-microsoft-module
3.2.2
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java
index a44531ab..0751a6f6 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java
@@ -1,11 +1,17 @@
package org.dromara.aihr.controller;
+import cn.dev33.satoken.annotation.SaCheckRole;
+import cn.dev33.satoken.annotation.SaMode;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.service.AihrCandidateMaterialService;
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialResponse;
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialReviewResponse;
import org.dromara.aihr.service.AihrCandidateMaterialService.ReviewRequest;
+import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
+import org.dromara.common.core.domain.model.LoginUser;
+import org.dromara.common.core.enums.UserType;
+import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -22,6 +28,8 @@ import java.util.List;
@RestController
public class AihrCandidateMaterialController {
+ private static final String HR_OPERATOR_ROLE = "hr_operator";
+
private final AihrCandidateMaterialService materialService;
@PostMapping(value = "/api/aihr/mobile/candidate/materials", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -29,21 +37,40 @@ public class AihrCandidateMaterialController {
@RequestParam(value = "candidateId", required = false) String candidateId,
@RequestParam(value = "candidateName", required = false) String candidateName,
@RequestParam(value = "materialType", required = false) String materialType) {
- return R.ok(materialService.upload(file, candidateId, candidateName, materialType));
+ String appUsername = currentAppUsername();
+ String boundCandidateId = appUsername.isBlank() ? candidateId : appUsername;
+ String boundCandidateName = appUsername.isBlank() ? candidateName : appCandidateName(appUsername);
+ return R.ok(materialService.upload(file, boundCandidateId, boundCandidateName, materialType));
}
@GetMapping("/api/aihr/mobile/candidate/materials")
public R> list(@RequestParam(value = "candidateId", required = false) String candidateId) {
- return R.ok(materialService.list(candidateId));
+ String appUsername = currentAppUsername();
+ return R.ok(materialService.list(appUsername.isBlank() ? candidateId : appUsername));
}
+ @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/api/aihr/hr/candidate/materials")
public R> reviewList(@RequestParam(value = "status", required = false) String status) {
return R.ok(materialService.reviewList(status));
}
+ @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/api/aihr/hr/candidate/materials/{id}/review")
public R review(@PathVariable Long id, @RequestBody ReviewRequest request) {
return R.ok(materialService.review(id, request));
}
+
+ private static String currentAppUsername() {
+ LoginUser loginUser = LoginHelper.getLoginUser();
+ if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
+ return "";
+ }
+ String username = loginUser.getUsername();
+ return username == null ? "" : username.trim();
+ }
+
+ private static String appCandidateName(String username) {
+ return username.matches("1[3-9]\\d{9}") ? "候选人" + username.substring(username.length() - 4) : username;
+ }
}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java
index 3e72bac6..4f6cdb1f 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java
@@ -5,10 +5,12 @@ import org.dromara.aihr.domain.AihrCaseDto.CurateRequest;
import org.dromara.aihr.domain.AihrCaseDto.CurateResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
+import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.service.AihrCaseService;
import org.dromara.common.core.domain.R;
import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -17,6 +19,8 @@ import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
+import java.util.List;
+
/**
* 案例语音整理 API。
*/
@@ -41,4 +45,9 @@ public class AihrCaseController {
public R curate(@RequestBody CurateRequest request) {
return R.ok(caseService.curate(request));
}
+
+ @GetMapping("/records")
+ public R> records(@RequestParam(value = "limit", required = false) Integer limit) {
+ return R.ok(caseService.records(limit));
+ }
}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java
index 047f0351..5544099b 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java
@@ -10,6 +10,9 @@ import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.dromara.aihr.service.AihrInterviewService;
import org.dromara.common.core.domain.R;
+import org.dromara.common.core.domain.model.LoginUser;
+import org.dromara.common.core.enums.UserType;
+import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -31,7 +34,7 @@ public class AihrInterviewController {
@PostMapping("/start")
public R start(@RequestBody StartRequest request) {
- return R.ok(interviewService.start(request));
+ return R.ok(interviewService.start(bindAppCandidate(request)));
}
@PostMapping("/answer")
@@ -45,7 +48,32 @@ public class AihrInterviewController {
}
@GetMapping("/records")
- public R> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit) {
- return R.ok(interviewService.records(limit));
+ public R> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit,
+ @RequestParam(value = "candidateId", required = false) String candidateId) {
+ String appUsername = currentAppUsername();
+ return R.ok(interviewService.records(limit, appUsername.isBlank() ? candidateId : appUsername));
+ }
+
+ private static StartRequest bindAppCandidate(StartRequest request) {
+ String appUsername = currentAppUsername();
+ if (appUsername.isBlank()) {
+ return request;
+ }
+ String positionCode = request == null ? null : request.positionCode();
+ String mode = request == null ? null : request.mode();
+ return new StartRequest(appUsername, appCandidateName(appUsername), positionCode, mode);
+ }
+
+ private static String currentAppUsername() {
+ LoginUser loginUser = LoginHelper.getLoginUser();
+ if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
+ return "";
+ }
+ String username = loginUser.getUsername();
+ return username == null ? "" : username.trim();
+ }
+
+ private static String appCandidateName(String username) {
+ return username.matches("1[3-9]\\d{9}") ? "候选人" + username.substring(username.length() - 4) : username;
}
}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java
index 52618a22..56493b4c 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java
@@ -1,20 +1,36 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaIgnore;
+import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
+import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
+import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest;
+import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
+import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
+import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
+import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
+import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
+import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
+import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.common.core.domain.R;
+import org.dromara.common.core.domain.model.LoginUser;
+import org.dromara.common.core.enums.UserType;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.system.service.ISysOssService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -22,14 +38,15 @@ import java.util.Map;
/**
* Mobile H5 home APIs.
*/
-@SaIgnore
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/aihr/mobile")
public class AihrMobileController {
private final AihrMobileSeedService mobileSeedService;
+ private final ISysOssService ossService;
+ @SaIgnore
@GetMapping("/home/{role}")
public R home(@PathVariable String role) {
return R.ok(mobileSeedService.home(role));
@@ -37,28 +54,99 @@ public class AihrMobileController {
@GetMapping("/practice/history")
public R> practiceHistory(@RequestParam(required = false) String extPartyId) {
- return R.ok(mobileSeedService.practiceHistory(extPartyId));
+ return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId)));
}
@GetMapping("/practice/reviews")
- public R> practiceReviews() {
- return R.ok(mobileSeedService.practiceReviews());
+ public R> practiceReviews(@RequestParam(required = false) String projectCode,
+ @RequestParam(required = false) String extPartyId,
+ @RequestParam(required = false) Integer limit) {
+ return R.ok(mobileSeedService.practiceReviews(supervisorScopeExtPartyId(), projectCode, extPartyId, limit));
+ }
+
+ @GetMapping("/practice/team")
+ public R practiceTeam() {
+ return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId()));
}
@GetMapping("/practice/reviews/{id}")
public R