feat(aihr): scope supervisor practice data

This commit is contained in:
2026-07-09 11:37:33 +08:00
parent 53698e7832
commit a83fb53ecd
11 changed files with 406 additions and 31 deletions
+6 -5
View File
@@ -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`。
## 发布口径
+5 -2
View File
@@ -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)。
## 目录
@@ -54,18 +54,18 @@ public class AihrMobileController {
@GetMapping("/practice/reviews")
public R<List<RecordResponse>> practiceReviews() {
return R.ok(mobileSeedService.practiceReviews());
return R.ok(mobileSeedService.practiceReviews(currentAppUsername()));
}
@GetMapping("/practice/reviews/{id}")
public R<Map<String, Object>> 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<Void> 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<List<PracticeAlertResponse>> practiceAlerts() {
return R.ok(mobileSeedService.practiceAlerts());
return R.ok(mobileSeedService.practiceAlerts(currentAppUsername()));
}
@GetMapping("/practice/assignments")
public R<List<PracticeAssignmentResponse>> 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<PracticeAssignmentResponse> createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) {
return R.ok(mobileSeedService.createPracticeAssignment(request));
return R.ok(mobileSeedService.createPracticeAssignment(request, currentAppUsername()));
}
@PostMapping("/practice/assignments/{id}/answer")
@@ -53,10 +53,18 @@ public class AihrMobileSeedService {
return practiceSeedService.pendingReviewRecords(5);
}
public List<RecordResponse> 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<PracticeAlertResponse> practiceAlerts(String supervisorExtPartyId) {
return practiceSeedService.alerts(supervisorExtPartyId);
}
public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId) {
return practiceSeedService.assignments(extPartyId, 10);
}
public List<PracticeAssignmentResponse> 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, "");
}
@@ -488,6 +488,29 @@ public class AihrPracticeSeedService {
""", this::mapRecord, TENANT_ID, normalizeLimit(limit));
}
public List<RecordResponse> pendingReviewRecords(String supervisorExtPartyId, int limit) {
TeamScope scope = teamScope(supervisorExtPartyId);
if (!scope.scoped()) {
return pendingReviewRecords(limit);
}
if (scope.extPartyIds().isEmpty()) {
return List.of();
}
ensurePracticeTable();
List<Object> 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<AnnotationResponse> saveAnnotations(String sessionId, AnnotationSaveRequest request) {
ensurePracticeTable();
if (isBlank(sessionId)) {
@@ -820,6 +855,27 @@ public class AihrPracticeSeedService {
return result;
}
public List<PracticeAlertResponse> alerts(String supervisorExtPartyId) {
TeamScope scope = teamScope(supervisorExtPartyId);
if (!scope.scoped()) {
return alerts();
}
ensurePracticeTable();
int pending = countPendingReview(scope);
int lowScore = countLowScore(scope);
List<PracticeAlertResponse> 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<PracticeAssignmentResponse> 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<PracticeAssignmentResponse> 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<Object> 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<Object> 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<Object> 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<OrgScopeRow> 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<String> 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<String> extPartyIds) {
static TeamScope unscoped() {
return new TeamScope(false, List.of());
}
static TeamScope scoped(List<String> extPartyIds) {
return new TeamScope(true, extPartyIds);
}
}
private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt,
List<String> traineeReplies, List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
List<String> customerLines, List<TurnEvidence> turnEvidence) {
@@ -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 <T> List<T> mapOne(InvocationOnMock invocation, Map<String, String> values) throws SQLException {
RowMapper mapper = invocation.getArgument(1);
ResultSet resultSet = mock(ResultSet.class);
for (Map.Entry<String, String> entry : values.entrySet()) {
when(resultSet.getString(entry.getKey())).thenReturn(entry.getValue());
}
return List.of((T) mapper.mapRow(resultSet, 0));
}
}
+23 -12
View File
@@ -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"
```
## 线上移动端静态发布
+4
View File
@@ -1,5 +1,9 @@
{
"apps": {
"frontend": {
"name": "wygj-admin",
"script": "dev"
},
"mobile-uni": {
"name": "wygj-mobile-uni",
"script": "dev"
+2
View File
@@ -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" \
+8
View File
@@ -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
+87 -6
View File
@@ -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."