diff --git a/AGENTS.md b/AGENTS.md index e7f8477f..82058274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ - 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`。 +- 后端:默认通过 portless 启动,入口 `https://wygj-api.localhost/`;命令为 `portless run --name wygj-api ./scripts/dev-backend.sh` 或根目录 `./scripts/dev.sh`,显式 `PORTLESS=0` 时才回退端口 `8080`。 +- 管理端前端:`./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`。 @@ -30,10 +30,11 @@ ## 验证口径 -- 前端首页:`http://127.0.0.1:5173/` +- 前端首页:`https://wygj-admin.localhost/` - 用户侧首页:`https://wygj-mobile-uni.localhost/h5/` -- 租户接口:`http://127.0.0.1:5173/dev-api/auth/tenant/list` -- 验证码接口:`http://127.0.0.1:5173/dev-api/auth/code` +- 后端接口:`https://wygj-api.localhost/` +- 租户接口:`https://wygj-admin.localhost/dev-api/auth/tenant/list` +- 验证码接口:`https://wygj-admin.localhost/dev-api/auth/code` - 默认登录:租户 `000000`,管理员 `admin / admin123`。 ## 发布口径 diff --git a/README.md b/README.md index 5fe69854..a67275ac 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,14 @@ ## 快速启动 ```bash +# 终端 1 ./scripts/dev.sh -portless + +# 终端 2 +cd mobile-uni && portless run --name wygj-mobile-uni npm run dev ``` -首次启动或需要重建本地 MVP 数据库时执行 `./scripts/dev.sh --reset`。`./scripts/dev.sh` 启动后端、管理端和旧移动端兜底;`portless` 启动当前 `mobile-uni` 用户侧。详细端口、默认账号和验证项见 [本地运行手册](docs/DEV_SETUP.md)。 +首次启动或需要重建本地 MVP 数据库时执行 `./scripts/dev.sh --reset`。`./scripts/dev.sh` 启动后端、管理端和旧移动端兜底;后端与管理端默认分别通过 `https://wygj-api.localhost/`、`https://wygj-admin.localhost/` 访问,`portless` 启动当前 `mobile-uni` 用户侧。详细端口、默认账号和验证项见 [本地运行手册](docs/DEV_SETUP.md)。 ## 目录 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 68358243..01d2c5d3 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 @@ -54,18 +54,18 @@ public class AihrMobileController { @GetMapping("/practice/reviews") public R> practiceReviews() { - return R.ok(mobileSeedService.practiceReviews()); + return R.ok(mobileSeedService.practiceReviews(currentAppUsername())); } @GetMapping("/practice/reviews/{id}") public R> practiceReview(@PathVariable Long id) { - ReviewDetailResponse detail = mobileSeedService.practiceReview(id); + ReviewDetailResponse detail = mobileSeedService.practiceReview(id, currentAppUsername()); return detail == null ? R.fail("复盘记录不存在") : R.ok(reviewDetailPayload(detail)); } @PostMapping("/practice/reviews/{id}/reviewed") public R markReviewed(@PathVariable Long id, @RequestBody(required = false) ReviewAdviceRequest request) { - return mobileSeedService.markReviewed(id, request) ? R.ok() : R.fail("复盘记录不存在"); + return mobileSeedService.markReviewed(id, request, currentAppUsername()) ? R.ok() : R.fail("复盘记录不存在"); } @GetMapping("/profile") @@ -80,17 +80,17 @@ public class AihrMobileController { @GetMapping("/practice/alerts") public R> practiceAlerts() { - return R.ok(mobileSeedService.practiceAlerts()); + return R.ok(mobileSeedService.practiceAlerts(currentAppUsername())); } @GetMapping("/practice/assignments") public R> practiceAssignments(@RequestParam(required = false) String extPartyId) { - return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId))); + return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId), currentAppUsername())); } @PostMapping("/practice/assignments") public R createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) { - return R.ok(mobileSeedService.createPracticeAssignment(request)); + return R.ok(mobileSeedService.createPracticeAssignment(request, currentAppUsername())); } @PostMapping("/practice/assignments/{id}/answer") diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java index df620a8b..2e9f937a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java @@ -53,10 +53,18 @@ public class AihrMobileSeedService { return practiceSeedService.pendingReviewRecords(5); } + public List practiceReviews(String supervisorExtPartyId) { + return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, 5); + } + public ReviewDetailResponse practiceReview(Long id) { return practiceSeedService.reviewDetail(id); } + public ReviewDetailResponse practiceReview(Long id, String supervisorExtPartyId) { + return practiceSeedService.reviewDetail(id, supervisorExtPartyId); + } + public boolean markReviewed(Long id) { return markReviewed(id, null); } @@ -65,6 +73,10 @@ public class AihrMobileSeedService { return practiceSeedService.markReviewed(id, request); } + public boolean markReviewed(Long id, ReviewAdviceRequest request, String supervisorExtPartyId) { + return practiceSeedService.markReviewed(id, request, supervisorExtPartyId); + } + public CompetencyResponse profile(String extPartyId) { return practiceSeedService.competency(extPartyId); } @@ -77,14 +89,26 @@ public class AihrMobileSeedService { return practiceSeedService.alerts(); } + public List practiceAlerts(String supervisorExtPartyId) { + return practiceSeedService.alerts(supervisorExtPartyId); + } + public List practiceAssignments(String extPartyId) { return practiceSeedService.assignments(extPartyId, 10); } + public List practiceAssignments(String extPartyId, String supervisorExtPartyId) { + return practiceSeedService.assignments(extPartyId, supervisorExtPartyId, 10); + } + public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request) { return practiceSeedService.createAssignment(request); } + public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) { + return practiceSeedService.createAssignment(request, supervisorExtPartyId); + } + public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) { return answerDailyDrill(id, request, ""); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 9ac58123..29036d4b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -488,6 +488,29 @@ public class AihrPracticeSeedService { """, this::mapRecord, TENANT_ID, normalizeLimit(limit)); } + public List pendingReviewRecords(String supervisorExtPartyId, int limit) { + TeamScope scope = teamScope(supervisorExtPartyId); + if (!scope.scoped()) { + return pendingReviewRecords(limit); + } + if (scope.extPartyIds().isEmpty()) { + return List.of(); + } + ensurePracticeTable(); + List args = new ArrayList<>(); + args.add(TENANT_ID); + args.addAll(scope.extPartyIds()); + args.add(normalizeLimit(limit)); + return jdbcTemplate.query(""" + SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘' + AND ext_party_id IN (%s) + ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC + LIMIT ? + """.formatted(inClause(scope.extPartyIds().size())), this::mapRecord, args.toArray()); + } + public String exportPilotCsv() { ensurePracticeTable(); ensureAssignmentTable(); @@ -564,6 +587,11 @@ public class AihrPracticeSeedService { return rows.isEmpty() ? null : rows.get(0); } + public ReviewDetailResponse reviewDetail(Long id, String supervisorExtPartyId) { + ReviewDetailResponse detail = reviewDetail(id); + return detail == null || !inTeamScope(supervisorExtPartyId, reviewExtPartyId(id)) ? null : detail; + } + public boolean markReviewed(Long id) { return markReviewed(id, null); } @@ -604,6 +632,13 @@ public class AihrPracticeSeedService { return updated > 0; } + public boolean markReviewed(Long id, ReviewAdviceRequest request, String supervisorExtPartyId) { + if (reviewDetail(id, supervisorExtPartyId) == null) { + return false; + } + return markReviewed(id, request); + } + public List saveAnnotations(String sessionId, AnnotationSaveRequest request) { ensurePracticeTable(); if (isBlank(sessionId)) { @@ -820,6 +855,27 @@ public class AihrPracticeSeedService { return result; } + public List alerts(String supervisorExtPartyId) { + TeamScope scope = teamScope(supervisorExtPartyId); + if (!scope.scoped()) { + return alerts(); + } + ensurePracticeTable(); + int pending = countPendingReview(scope); + int lowScore = countLowScore(scope); + List result = new ArrayList<>(); + if (pending > 0) { + result.add(new PracticeAlertResponse("pending_review", "待复盘积压", pending + " 条对练等待主管复盘", "medium", "")); + } + if (lowScore > 0) { + result.add(new PracticeAlertResponse("low_score", "低分训练预警", lowScore + " 条训练低于 80 分,建议派发定向再练", "high", "")); + } + if (result.isEmpty()) { + result.add(new PracticeAlertResponse("stable", "暂无高风险预警", "团队训练记录稳定,可继续按今日安排推进", "low", "")); + } + return result; + } + public List assignments(String extPartyId, int limit) { ensureAssignmentTable(); String party = isBlank(extPartyId) ? "" : extPartyId.trim(); @@ -840,6 +896,39 @@ public class AihrPracticeSeedService { """, this::mapAssignment, TENANT_ID, party, party, normalizeLimit(limit)); } + public List assignments(String extPartyId, String supervisorExtPartyId, int limit) { + String party = isBlank(extPartyId) ? "" : extPartyId.trim(); + if (!isBlank(party)) { + return inTeamScope(supervisorExtPartyId, party) ? assignments(party, limit) : List.of(); + } + TeamScope scope = teamScope(supervisorExtPartyId); + if (!scope.scoped()) { + return assignments("", limit); + } + if (scope.extPartyIds().isEmpty()) { + return List.of(); + } + ensureAssignmentTable(); + List args = new ArrayList<>(); + args.add(TENANT_ID); + args.addAll(scope.extPartyIds()); + args.add(normalizeLimit(limit)); + return jdbcTemplate.query(""" + SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, + answer_text, feedback, correct + FROM aihr_practice_assignment + WHERE tenant_id = ? + AND ext_party_id IN (%s) + AND (status = '待训练' OR (source = 'daily' AND DATE(create_time) = CURRENT_DATE())) + ORDER BY + CASE WHEN source = 'daily' THEN 1 ELSE 0 END, + CASE WHEN source = 'daily' THEN id ELSE NULL END ASC, + create_time DESC, + id DESC + LIMIT ? + """.formatted(inClause(scope.extPartyIds().size())), this::mapAssignment, args.toArray()); + } + private void ensureDailyDrills(String extPartyId) { if (isBlank(extPartyId) || countMobileRecords(extPartyId) >= 3) { return; @@ -904,6 +993,14 @@ public class AihrPracticeSeedService { return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null) : rows.get(0); } + public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) { + String extPartyId = request == null ? "" : firstNonBlank(request.extPartyId(), ""); + if (!inTeamScope(supervisorExtPartyId, extPartyId)) { + throw new ServiceException("只能派发给当前项目范围内员工"); + } + return createAssignment(request); + } + private void markAssignmentCompleted(Long requestAssignmentId, ActiveSession activeSession) { Long assignmentId = requestAssignmentId == null && activeSession != null ? activeSession.assignmentId() : requestAssignmentId; if (assignmentId == null || activeSession == null || isBlank(activeSession.extPartyId())) { @@ -1208,6 +1305,88 @@ public class AihrPracticeSeedService { """, TENANT_ID, extPartyId.trim()); } + private int countPendingReview(TeamScope scope) { + if (scope.extPartyIds().isEmpty()) { + return 0; + } + List args = new ArrayList<>(); + args.add(TENANT_ID); + args.addAll(scope.extPartyIds()); + return count(""" + SELECT COUNT(*) + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘' + AND ext_party_id IN (%s) + """.formatted(inClause(scope.extPartyIds().size())), args.toArray()); + } + + private int countLowScore(TeamScope scope) { + if (scope.extPartyIds().isEmpty()) { + return 0; + } + List args = new ArrayList<>(); + args.add(TENANT_ID); + args.addAll(scope.extPartyIds()); + return count(""" + SELECT COUNT(*) + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile' AND total_score < 80 + AND ext_party_id IN (%s) + """.formatted(inClause(scope.extPartyIds().size())), args.toArray()); + } + + private boolean inTeamScope(String supervisorExtPartyId, String extPartyId) { + if (isBlank(extPartyId)) { + return false; + } + TeamScope scope = teamScope(supervisorExtPartyId); + return !scope.scoped() || scope.extPartyIds().contains(extPartyId.trim()); + } + + private TeamScope teamScope(String supervisorExtPartyId) { + String owner = isBlank(supervisorExtPartyId) ? "" : supervisorExtPartyId.trim(); + if (owner.isEmpty() || !orgSnapshotTableExists()) { + return TeamScope.unscoped(); + } + List owners = jdbcTemplate.query(""" + SELECT project_code, position_level + FROM aihr_org_snapshot + WHERE tenant_id = ? AND ext_party_id = ? AND employment_status = 'active' + LIMIT 1 + """, (rs, rowNum) -> new OrgScopeRow(rs.getString("project_code"), rs.getString("position_level")), TENANT_ID, owner); + if (owners.isEmpty()) { + return TeamScope.unscoped(); + } + OrgScopeRow row = owners.get(0); + if (!canSeeProject(row.positionLevel())) { + return TeamScope.scoped(List.of(owner)); + } + List ids = jdbcTemplate.query(""" + SELECT ext_party_id + FROM aihr_org_snapshot + WHERE tenant_id = ? AND project_code = ? AND employment_status = 'active' + ORDER BY position_level, person_name, ext_party_id + """, (rs, rowNum) -> rs.getString("ext_party_id"), TENANT_ID, row.projectCode()); + return TeamScope.scoped(ids); + } + + private boolean orgSnapshotTableExists() { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_org_snapshot' + """, Integer.class); + return count != null && count > 0; + } + + private static boolean canSeeProject(String positionLevel) { + return "主管".equals(positionLevel) || "项目经理".equals(positionLevel); + } + + private static String inClause(int size) { + return String.join(",", java.util.Collections.nCopies(size, "?")); + } + private int count(String sql, Object... args) { Integer value = jdbcTemplate.queryForObject(sql, Integer.class, args); return value == null ? 0 : value; @@ -2567,6 +2746,19 @@ public class AihrPracticeSeedService { private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) { } + private record OrgScopeRow(String projectCode, String positionLevel) { + } + + private record TeamScope(boolean scoped, List extPartyIds) { + static TeamScope unscoped() { + return new TeamScope(false, List.of()); + } + + static TeamScope scoped(List extPartyIds) { + return new TeamScope(true, extPartyIds); + } + } + private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt, List traineeReplies, List traineeAudioUrls, List traineeAudioOssIds, List customerLines, List turnEvidence) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 832c7731..f7223100 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -4,9 +4,23 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.common.core.exception.ServiceException; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.invocation.InvocationOnMock; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class AihrPracticeSeedServiceTest { @@ -24,4 +38,39 @@ public class AihrPracticeSeedServiceTest { ))); assertEquals("缺少员工ID,无法派发专项训练", error.getMessage()); } + + @Test + public void createAssignmentRejectsOutOfProjectAssignee() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.queryForObject(ArgumentMatchers.anyString(), eq(Integer.class))).thenReturn(1); + when(jdbcTemplate.query(contains("SELECT project_code"), any(RowMapper.class), eq("000000"), eq("SUP-1"))) + .thenAnswer(invocation -> mapOne(invocation, Map.of( + "project_code", "P1", + "position_level", "主管" + ))); + when(jdbcTemplate.query(contains("SELECT ext_party_id"), any(RowMapper.class), eq("000000"), eq("P1"))) + .thenAnswer(invocation -> mapOne(invocation, Map.of("ext_party_id", "EMP-1"))); + + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + ServiceException error = assertThrows(ServiceException.class, () -> service.createAssignment(new PracticeAssignmentRequest( + "EMP-2", + "越权员工", + "fee-parking", + null, + "manual", + "主管派发专项训练" + ), "SUP-1")); + assertEquals("只能派发给当前项目范围内员工", error.getMessage()); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static List mapOne(InvocationOnMock invocation, Map values) throws SQLException { + RowMapper mapper = invocation.getArgument(1); + ResultSet resultSet = mock(ResultSet.class); + for (Map.Entry entry : values.entrySet()) { + when(resultSet.getString(entry.getKey())).thenReturn(entry.getValue()); + } + return List.of((T) mapper.mapRow(resultSet, 0)); + } } diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 88aeadc8..a3749835 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -8,10 +8,10 @@ ## 本地端口 -- 管理端前端:`http://127.0.0.1:5173/` +- 管理端前端:`https://wygj-admin.localhost/` - 旧移动端前端:`http://127.0.0.1:5174/h5/user` - uni-app 用户侧:`https://wygj-mobile-uni.localhost/h5/` -- 后端:`http://127.0.0.1:8080/` +- 后端:`https://wygj-api.localhost/`,显式 `PORTLESS=0` 时回退 `http://127.0.0.1:8080/` - MySQL:`127.0.0.1:13306`,数据库 `ry-vue`,账号 `root/root` - Redis:`127.0.0.1:16379`,密码 `ruoyi123` - MinIO API:`http://127.0.0.1:9000`,Console:`http://127.0.0.1:9001`,账号 `ruoyi / ruoyi123`,默认 bucket `ruoyi` @@ -23,22 +23,30 @@ 本机开发测试优先用 portless 访问稳定域名,避免继续记固定端口: - uni-app 用户侧:`https://wygj-mobile-uni.localhost/h5/` +- 管理端:`https://wygj-admin.localhost/` - 后端 API:`https://wygj-api.localhost` -启动 uni-app 用户侧: +从根目录一次性启动 portless 配置中的管理端和 uni-app 用户侧: ```bash portless ``` -portless 会按根目录 `portless.json` 执行 `mobile-uni` 的 `npm run dev`。旧 `./scripts/dev.sh` 仍会拉起 `mobile/` 作为演示兜底;新用户侧开发测试优先看 `wygj-mobile-uni.localhost`。 - -若后端已经由 `./scripts/dev-backend.sh` 启动在 `8080`,注册一次静态别名: +portless 会按根目录 `portless.json` 执行 `frontend` 和 `mobile-uni` 的 `npm run dev`。后端由 `./scripts/dev.sh` 默认通过 `portless run --name wygj-api ./scripts/dev-backend.sh` 启动;如需单独启动后端,可执行: ```bash -portless alias wygj-api 8080 +portless run --name wygj-api ./scripts/dev-backend.sh ``` +若已经通过 `./scripts/dev.sh` 启动管理端,只需要单独启动当前用户侧: + +```bash +cd mobile-uni +portless run --name wygj-mobile-uni npm run dev +``` + +旧 `./scripts/dev.sh` 仍会拉起 `mobile/` 作为演示兜底;后端开发测试优先看 `wygj-api.localhost`,管理端开发测试优先看 `wygj-admin.localhost`,新用户侧开发测试优先看 `wygj-mobile-uni.localhost`。 + Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。组织人员同步可用 `AIHR_ORG_SYNC_BASE_URL` 指向外部开放平台 `/api/open/v1` 前缀,并配置 `AIHR_ORG_SYNC_ACCESS_TOKEN` 或 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`;业务请求会用 client secret 生成 HMAC-SHA256 hex 签名。移动端手机号登录的短信模板 ID、阿里云 AccessKey、Secret 和短信签名都通过环境变量注入;本地放根目录 `.env.local`,`scripts/dev-backend.sh` 会自动加载。若开放平台凭证放在 `backend/.env`,启动脚本也会加载该文件,并把 `client_id`/`client_secret` 映射为组织同步实际读取的 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`。 本地 `.env.local` 示例: @@ -71,11 +79,14 @@ AIHR_AI_SPEECH_ENABLED=true 在项目根目录执行: ```bash +# 终端 1 ./scripts/dev.sh -portless + +# 终端 2 +cd mobile-uni && portless run --name wygj-mobile-uni npm run dev ``` -`./scripts/dev.sh` 启动后端、管理端和旧移动端兜底;`portless` 启动当前 `mobile-uni` 用户侧。 +`./scripts/dev.sh` 启动后端、管理端和旧移动端兜底;后端与管理端默认通过 portless 暴露为 `https://wygj-api.localhost/` 和 `https://wygj-admin.localhost/`,`portless` 启动当前 `mobile-uni` 用户侧。若要显式使用旧端口调试,可执行 `PORTLESS=0 ./scripts/dev.sh`,后端入口为 `http://127.0.0.1:8080/`,管理端入口为 `http://127.0.0.1:5173/`。 首次启动或需要重建本地 `ry-vue` MVP 数据库时执行: @@ -110,8 +121,8 @@ portless - 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`) - 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。 - 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。 -- 后端 `ruoyi-admin` 已完成 Maven 打包并启动在 `8080` -- 前端依赖已安装,Vite 已启动在 `5173` +- 后端 `ruoyi-admin` 已完成 Maven 打包,并通过 portless 暴露为 `https://wygj-api.localhost/` +- 前端依赖已安装,Vite 已通过 portless 暴露为 `https://wygj-admin.localhost/` - 旧移动端兜底依赖已安装,Vite 可启动在 `5174`;当前用户侧由 portless 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/` - 移动端手机号登录页已接 `/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`;完成后主管端完训人数、“待复盘对练”计数、复盘列表、复盘详情、员工训练历史和能力画像会变化 @@ -129,7 +140,7 @@ curl -fsS http://127.0.0.1:6333/ ```bash TOKEN=<登录后 access_token> -curl -fsS http://127.0.0.1:8080/api/knowledge/doc/vector-index-status -H "Authorization: Bearer $TOKEN" +curl -fsS https://wygj-api.localhost/api/knowledge/doc/vector-index-status -H "Authorization: Bearer $TOKEN" ``` ## 线上移动端静态发布 diff --git a/portless.json b/portless.json index 8a1455eb..e7a22b81 100644 --- a/portless.json +++ b/portless.json @@ -1,5 +1,9 @@ { "apps": { + "frontend": { + "name": "wygj-admin", + "script": "dev" + }, "mobile-uni": { "name": "wygj-mobile-uni", "script": "dev" diff --git a/scripts/dev-backend.sh b/scripts/dev-backend.sh index 50795934..6acbf842 100755 --- a/scripts/dev-backend.sh +++ b/scripts/dev-backend.sh @@ -31,6 +31,7 @@ mkdir -p "$ROOT_DIR/.data/import" JAR="ruoyi-admin/target/ruoyi-admin.jar" NEEDS_BUILD=false +BACKEND_PORT="${PORT:-8080}" if [[ ! -f "$JAR" ]]; then NEEDS_BUILD=true @@ -43,6 +44,7 @@ if [[ "$NEEDS_BUILD" == true ]]; then fi exec java -jar "$JAR" \ + "--server.port=$BACKEND_PORT" \ "--spring.datasource.dynamic.datasource.master.url=jdbc:mysql://localhost:13306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true" \ "--spring.data.redis.port=16379" \ "--spring.boot.admin.client.enabled=false" \ diff --git a/scripts/dev-frontend.sh b/scripts/dev-frontend.sh index 9c3cbce7..cb70c224 100755 --- a/scripts/dev-frontend.sh +++ b/scripts/dev-frontend.sh @@ -9,4 +9,12 @@ if [[ ! -d node_modules ]]; then npm install --registry=https://registry.npmmirror.com fi +if [[ "${PORTLESS:-1}" != "0" ]] && command -v portless >/dev/null 2>&1; then + exec portless run --name wygj-admin bash -lc 'npm run dev -- --host "${HOST:-127.0.0.1}" --port "$PORT" --strictPort' +fi + +if [[ "${PORTLESS:-1}" != "0" ]]; then + echo "portless is not available; falling back to http://127.0.0.1:5173/." >&2 +fi + exec npm run dev -- --host 127.0.0.1 --port 5173 --strictPort diff --git a/scripts/dev.sh b/scripts/dev.sh index b5892889..a7cf427b 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -75,8 +75,77 @@ stop_port() { kill -9 $pids >/dev/null 2>&1 || true } +stop_portless_route() { + local name="$1" + local pids + if ! command -v portless >/dev/null 2>&1; then + return + fi + + pids="$(portless list 2>/dev/null | sed -nE "s#.*https://${name}[.]localhost.*[(]pid ([0-9]+)[)].*#\\1#p" || true)" + if [[ -z "$pids" ]]; then + echo "Portless route $name is not running." + return + fi + + echo "Stopping portless route $name: $pids" + kill $pids >/dev/null 2>&1 || true + for _ in {1..20}; do + if ! portless list 2>/dev/null | grep -q "https://${name}[.]localhost"; then + return + fi + sleep 0.2 + done + kill -9 $pids >/dev/null 2>&1 || true +} + +stop_launchctl_job() { + local label="$1" + local domain="gui/$(id -u)/$label" + if ! command -v launchctl >/dev/null 2>&1; then + return + fi + if ! launchctl print "$domain" >/dev/null 2>&1; then + return + fi + + echo "Stopping launchctl job $label" + launchctl bootout "$domain" >/dev/null 2>&1 || true +} + +using_portless() { + [[ "${PORTLESS:-1}" != "0" ]] && command -v portless >/dev/null 2>&1 +} + +using_portless_backend() { + using_portless +} + +using_portless_admin() { + using_portless +} + +backend_url() { + if using_portless_backend; then + echo "https://wygj-api.localhost/" + else + echo "http://127.0.0.1:8080/" + fi +} + +admin_url() { + if using_portless_admin; then + echo "https://wygj-admin.localhost/" + else + echo "http://127.0.0.1:5173/" + fi +} + stop_dev() { + stop_launchctl_job local.wygj.backend.dev + stop_portless_route wygj-api stop_port 8080 + stop_portless_route wygj-admin stop_port 5173 stop_port 5174 } @@ -86,6 +155,9 @@ status() { lsof -nP -iTCP:8080 -sTCP:LISTEN || true lsof -nP -iTCP:5173 -sTCP:LISTEN || true lsof -nP -iTCP:5174 -sTCP:LISTEN || true + if command -v portless >/dev/null 2>&1; then + portless list || true + fi } if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then @@ -125,8 +197,12 @@ esac cd "$ROOT_DIR" ensure_docker -ensure_port_free 8080 -ensure_port_free 5173 +if ! using_portless_backend; then + ensure_port_free 8080 +fi +if ! using_portless_admin; then + ensure_port_free 5173 +fi ensure_port_free 5174 if [[ "$RESET" == true ]]; then @@ -137,7 +213,12 @@ fi trap cleanup INT TERM EXIT -./scripts/dev-backend.sh & +if using_portless_backend; then + portless alias --remove wygj-api >/dev/null 2>&1 || true + portless run --name wygj-api ./scripts/dev-backend.sh & +else + ./scripts/dev-backend.sh & +fi PIDS+=("$!") ./scripts/dev-frontend.sh & @@ -146,10 +227,10 @@ PIDS+=("$!") ./scripts/dev-mobile.sh & PIDS+=("$!") -echo "Backend: http://127.0.0.1:8080/" -echo "Admin: http://127.0.0.1:5173/" +echo "Backend: $(backend_url)" +echo "Admin: $(admin_url)" echo "Legacy mobile fallback: http://127.0.0.1:5174/h5/user" -echo "Current user side: https://wygj-mobile-uni.localhost/h5/ (run: portless)" +echo "Current user side: https://wygj-mobile-uni.localhost/h5/ (run: cd mobile-uni && portless run --name wygj-mobile-uni npm run dev)" echo "Qdrant: http://127.0.0.1:6333/" echo "Press Ctrl+C to stop backend, admin frontend and legacy mobile frontend."