feat(aihr): scope supervisor practice data
This commit is contained in:
@@ -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`。
|
||||
|
||||
## 发布口径
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 目录
|
||||
|
||||
|
||||
+6
-6
@@ -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")
|
||||
|
||||
+24
@@ -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, "");
|
||||
}
|
||||
|
||||
+192
@@ -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) {
|
||||
|
||||
+49
@@ -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
@@ -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"
|
||||
```
|
||||
|
||||
## 线上移动端静态发布
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"apps": {
|
||||
"frontend": {
|
||||
"name": "wygj-admin",
|
||||
"script": "dev"
|
||||
},
|
||||
"mobile-uni": {
|
||||
"name": "wygj-mobile-uni",
|
||||
"script": "dev"
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
+84
-3
@@ -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
|
||||
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
|
||||
|
||||
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."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user