feat(aihr): finalize practice growth and access boundaries
This commit is contained in:
+12
@@ -11,7 +11,9 @@ import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse;
|
||||
import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
|
||||
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
@@ -40,6 +42,9 @@ public class AihrCommunityService {
|
||||
private final AihrCommunityProperties properties;
|
||||
private final AihrIncentiveService incentiveService;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean tablesReady;
|
||||
|
||||
public List<QuestionResponse> publicQuestions(String status, boolean mine) {
|
||||
@@ -433,6 +438,13 @@ public class AihrCommunityService {
|
||||
if (tablesReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate,
|
||||
"aihr_community_question", "aihr_community_answer", "aihr_points_ledger", "aihr_incentive_rule");
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_points_ledger", "account_type");
|
||||
tablesReady = true;
|
||||
return;
|
||||
}
|
||||
for (String ddl : tableDdls()) {
|
||||
jdbcTemplate.execute(ddl);
|
||||
}
|
||||
|
||||
+11
@@ -2,6 +2,8 @@ package org.dromara.aihr.community;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
@@ -17,6 +19,10 @@ import java.util.Locale;
|
||||
public class AihrIncentiveService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public List<IncentiveRuleResponse> list(AihrKnowledgePrincipal principal, boolean includeDisabled) {
|
||||
@@ -133,6 +139,11 @@ public class AihrIncentiveService {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_incentive_rule");
|
||||
tableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_incentive_rule` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
|
||||
+11
-2
@@ -18,6 +18,7 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
|
||||
import org.dromara.aihr.service.AihrCaseService;
|
||||
import org.dromara.aihr.service.AihrMobileSeedService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -46,6 +47,7 @@ public class AihrCaseController {
|
||||
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
||||
|
||||
private final AihrCaseService caseService;
|
||||
private final AihrMobileSeedService mobileSeedService;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -125,7 +127,7 @@ public class AihrCaseController {
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return List.of();
|
||||
}
|
||||
return caseService.projectScopesForAppUser(loginUser.getUsername());
|
||||
return caseService.projectScopesForAppUser(currentAppMobilePhone(loginUser));
|
||||
}
|
||||
|
||||
private boolean canManageCases() {
|
||||
@@ -135,7 +137,7 @@ public class AihrCaseController {
|
||||
}
|
||||
return loginUser != null
|
||||
&& UserType.APP_USER.getUserType().equals(loginUser.getUserType())
|
||||
&& caseService.isCaseContributor(loginUser.getUsername());
|
||||
&& caseService.isCaseContributor(currentAppMobilePhone(loginUser));
|
||||
}
|
||||
|
||||
private boolean canViewCases() {
|
||||
@@ -145,4 +147,11 @@ public class AihrCaseController {
|
||||
}
|
||||
return loginUser != null && UserType.APP_USER.getUserType().equals(loginUser.getUserType());
|
||||
}
|
||||
|
||||
private String currentAppMobilePhone(LoginUser loginUser) {
|
||||
String username = loginUser == null || loginUser.getUsername() == null ? "" : loginUser.getUsername().trim();
|
||||
// Keep the collision checks shared with training and knowledge access;
|
||||
// only then may this phone reach the exact org-snapshot predicates.
|
||||
return mobileSeedService.requireMobileIdentity(username);
|
||||
}
|
||||
}
|
||||
|
||||
+37
-37
@@ -2,7 +2,6 @@ package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -52,7 +51,6 @@ import org.dromara.aihr.service.AihrSensitiveText;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
@@ -99,10 +97,13 @@ public class AihrMobileController {
|
||||
|| identity.isBlank()) {
|
||||
return R.ok(mobileSeedService.publicHome(role));
|
||||
}
|
||||
String resolvedIdentity = identity;
|
||||
if (supervisorRoleRequested(role)) {
|
||||
mobileSeedService.requireSupervisorIdentity(identity);
|
||||
resolvedIdentity = mobileSeedService.requireMobileSupervisorIdentity(identity);
|
||||
} else if (!"candidate".equalsIgnoreCase(role)) {
|
||||
mobileSeedService.requireMobileIdentity(identity);
|
||||
}
|
||||
return R.ok(mobileSeedService.home(role, identity));
|
||||
return R.ok(mobileSeedService.home(role, resolvedIdentity));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,11 +116,14 @@ public class AihrMobileController {
|
||||
if (phone.isBlank()) {
|
||||
return R.ok(new IdentityResponse("user", "员工端", "", "一线", List.of()));
|
||||
}
|
||||
// APP username is a phone number. Validate it before it reaches the
|
||||
// organization lookup so a phone-shaped external ID cannot borrow
|
||||
// another employee's role or project scope.
|
||||
mobileSeedService.requireMobileIdentity(phone);
|
||||
List<OrgPersonRow> people = List.of();
|
||||
var row = java.util.Optional.<OrgPersonRow>empty();
|
||||
try {
|
||||
var snapshot = orgSyncService.snapshot(phone, null, null, "active", 1, 500, 500);
|
||||
var snapshotPeople = snapshot.rows();
|
||||
var snapshotPeople = orgSyncService.activeByMobilePhone(phone);
|
||||
people = snapshotPeople;
|
||||
row = snapshotPeople.stream()
|
||||
.filter(person -> "主管".equals(person.positionLevel()) || "项目经理".equals(person.positionLevel()))
|
||||
@@ -204,19 +208,11 @@ public class AihrMobileController {
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
if (UserType.APP_USER.getUserType().equals(loginUser.getUserType())
|
||||
&& !mobileSeedService.canReadPracticeAudio(ossId, currentAppUsername())) {
|
||||
if (!UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音");
|
||||
return;
|
||||
}
|
||||
if (UserType.SYS_USER.getUserType().equals(loginUser.getUserType())
|
||||
&& (!StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator")
|
||||
|| !mobileSeedService.canReadPracticeAudioForTenant(ossId))) {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音");
|
||||
return;
|
||||
}
|
||||
if (!UserType.APP_USER.getUserType().equals(loginUser.getUserType())
|
||||
&& !UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
if (!mobileSeedService.canReadPracticeAudio(ossId, currentAppUsername())) {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音");
|
||||
return;
|
||||
}
|
||||
@@ -245,7 +241,7 @@ public class AihrMobileController {
|
||||
|
||||
@GetMapping("/practice/assignments")
|
||||
public R<List<PracticeAssignmentResponse>> practiceAssignments(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId), currentAppUsername()));
|
||||
return R.ok(mobileSeedService.practiceAssignments(ownMobileExtPartyId(extPartyId)));
|
||||
}
|
||||
|
||||
@GetMapping("/onboard/tasks")
|
||||
@@ -368,36 +364,30 @@ public class AihrMobileController {
|
||||
return response == null ? R.fail("满意度提交失败,请确认训练已完成且评分为1-5分") : R.ok(response);
|
||||
}
|
||||
|
||||
private static String ownMobileExtPartyId(String requested) {
|
||||
private String ownMobileExtPartyId(String ignoredRequested) {
|
||||
String username = currentAppUsername();
|
||||
if (!username.isBlank()) {
|
||||
return username;
|
||||
}
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())
|
||||
&& !StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator")) {
|
||||
if (username.isBlank()) {
|
||||
throw new ServiceException("无权访问员工个人数据");
|
||||
}
|
||||
return requested;
|
||||
return mobileSeedService.requireMobileIdentity(username);
|
||||
}
|
||||
|
||||
private static String scopedAssignmentExtPartyId(String requested) {
|
||||
return ownMobileExtPartyId(requested);
|
||||
private boolean isSystemOperator() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
return loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType());
|
||||
}
|
||||
|
||||
/**
|
||||
* APP supervisor tokens are org-scoped; management console sys_user keeps unscoped access
|
||||
* so admin review/growth pages continue to work against the same mobile APIs.
|
||||
* Supervisor coaching endpoints are APP-only and always scoped to the
|
||||
* authenticated supervisor's organization identity. Management preview
|
||||
* must use its own preview path; an unscoped sys_user must never become a
|
||||
* substitute supervisor for employee reviews or growth evidence.
|
||||
*/
|
||||
private String supervisorScopeExtPartyId() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
if (!StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator")) {
|
||||
throw new ServiceException("无主管权限,无法访问主管功能");
|
||||
}
|
||||
return "";
|
||||
if (isSystemOperator()) {
|
||||
throw new ServiceException("后台管理账号不能使用主管带教接口");
|
||||
}
|
||||
return mobileSeedService.requireSupervisorIdentity(currentAppUsername());
|
||||
return mobileSeedService.requireMobileSupervisorIdentity(currentAppUsername());
|
||||
}
|
||||
|
||||
private static String currentAppUsername() {
|
||||
@@ -423,7 +413,8 @@ public class AihrMobileController {
|
||||
request == null ? null : request.advice(),
|
||||
request == null ? null : request.incentivePoint(),
|
||||
request == null ? null : request.nextScenarioId(),
|
||||
request == null ? null : request.nextReason()
|
||||
request == null ? null : request.nextReason(),
|
||||
request == null ? null : request.confirmedGrowthLevel()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -452,6 +443,12 @@ public class AihrMobileController {
|
||||
data.put("time", detail.time());
|
||||
data.put("trainee", detail.trainee());
|
||||
data.put("scene", detail.scene());
|
||||
data.put("position", detail.position());
|
||||
data.put("projectType", detail.projectType());
|
||||
data.put("growthLevel", detail.growthLevel());
|
||||
data.put("competencyCode", detail.competencyCode());
|
||||
data.put("collaborationPositions", detail.collaborationPositions());
|
||||
data.put("curriculumVersion", detail.curriculumVersion());
|
||||
data.put("score", detail.score());
|
||||
data.put("status", detail.status());
|
||||
data.put("summary", maskSensitiveText(detail.summary()));
|
||||
@@ -459,6 +456,9 @@ public class AihrMobileController {
|
||||
data.put("aiComment", maskSensitiveText(detail.aiComment()));
|
||||
data.put("reviewAdvice", maskSensitiveText(detail.reviewAdvice()));
|
||||
data.put("incentivePoint", detail.incentivePoint());
|
||||
data.put("confirmedGrowthLevel", detail.confirmedGrowthLevel());
|
||||
data.put("growthConfirmedBy", detail.growthConfirmedBy());
|
||||
data.put("growthConfirmedTime", detail.growthConfirmedTime());
|
||||
data.put("extPartyId", detail.extPartyId());
|
||||
data.put("scoreItems", detail.scoreItems().stream().map(item -> Map.of(
|
||||
"label", item.label(),
|
||||
|
||||
+79
-9
@@ -11,6 +11,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.AssignmentCancelRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CurriculumCapabilityResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CurriculumMatrixResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse;
|
||||
@@ -29,6 +31,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionEnabledRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioReviewStatusRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioReviewStatusResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
|
||||
@@ -41,6 +45,7 @@ import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
@@ -81,7 +86,7 @@ public class AihrPracticeController {
|
||||
|
||||
@GetMapping("/scenarios/{id}")
|
||||
public R<ScenarioResponse> scenario(@PathVariable String id) {
|
||||
return R.ok(practiceSeedService.scenario(id));
|
||||
return R.ok(practiceSeedService.scenario(id, currentOperatorCanManageContent()));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@@ -96,6 +101,26 @@ public class AihrPracticeController {
|
||||
return practiceSeedService.updateScenarioEnabled(id, request) ? R.ok() : R.fail("训练场景不存在");
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PatchMapping("/scenarios/{id}/review-status")
|
||||
public R<ScenarioReviewStatusResponse> updateScenarioReviewStatus(@PathVariable String id, @RequestBody ScenarioReviewStatusRequest request) {
|
||||
return R.ok(practiceSeedService.updateScenarioReviewStatus(id, request,
|
||||
currentOperatorUserId(), currentOperatorUsername()));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@GetMapping("/curriculum/capabilities")
|
||||
public R<List<CurriculumCapabilityResponse>> curriculumCapabilities(@RequestParam(required = false) String position,
|
||||
@RequestParam(required = false) String growthLevel) {
|
||||
return R.ok(practiceSeedService.curriculumCapabilities(position, growthLevel));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@GetMapping("/curriculum/matrix")
|
||||
public R<List<CurriculumMatrixResponse>> curriculumMatrix(@RequestParam(required = false) String position) {
|
||||
return R.ok(practiceSeedService.curriculumMatrix(position));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@GetMapping("/questions")
|
||||
public R<List<PracticeQuestionResponse>> questions(@RequestParam(required = false) String position,
|
||||
@@ -193,7 +218,7 @@ public class AihrPracticeController {
|
||||
|
||||
@PostMapping("/start")
|
||||
public R<StartResponse> start(@RequestBody StartRequest request) {
|
||||
return R.ok(practiceSeedService.start(bindMobileStartIdentity(request)));
|
||||
return R.ok(practiceSeedService.start(bindPracticeStartIdentity(request)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,12 +235,12 @@ public class AihrPracticeController {
|
||||
|
||||
@PostMapping("/turn")
|
||||
public R<TurnResponse> turn(@RequestBody TurnRequest request) {
|
||||
return R.ok(practiceSeedService.turn(request, currentAppUsername(), currentAppUserId()));
|
||||
return R.ok(practiceSeedService.turn(request, requirePracticeActorIdentity("继续训练"), currentAppUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/finish")
|
||||
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
|
||||
return R.ok(practiceSeedService.finish(request, currentAppUsername()));
|
||||
return R.ok(practiceSeedService.finish(request, requirePracticeActorIdentity("完成训练")));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@@ -246,15 +271,52 @@ public class AihrPracticeController {
|
||||
);
|
||||
}
|
||||
|
||||
private static StartRequest bindMobileStartIdentity(StartRequest request) {
|
||||
if (request == null || !"mobile".equalsIgnoreCase(request.mode())) {
|
||||
return request;
|
||||
private static StartRequest bindPracticeStartIdentity(StartRequest request) {
|
||||
if (request == null) {
|
||||
throw new ServiceException("训练请求不能为空");
|
||||
}
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser != null && UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
String username = requireCurrentAppUsername("开始训练");
|
||||
// Employee endpoints never accept a client-selected identity or mode.
|
||||
return new StartRequest(username, request.scenarioId(), "mobile", request.assignmentId());
|
||||
}
|
||||
if (canUsePracticePreview(loginUser)) {
|
||||
// Operations may exercise the live model path, but never impersonate an employee or create mobile records.
|
||||
return new StartRequest(practicePreviewActor(loginUser), request.scenarioId(), "preview", null);
|
||||
}
|
||||
throw new ServiceException("请使用员工端账号或有权限的运营账号开始训练");
|
||||
}
|
||||
|
||||
private static String requireCurrentAppUsername(String action) {
|
||||
String username = currentAppUsername();
|
||||
if (username.isBlank()) {
|
||||
return request;
|
||||
throw new ServiceException("请使用员工端账号" + action);
|
||||
}
|
||||
return new StartRequest(username, request.scenarioId(), request.mode(), request.assignmentId());
|
||||
return username;
|
||||
}
|
||||
|
||||
private static String requirePracticeActorIdentity(String action) {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
String username = currentAppUsername();
|
||||
if (!username.isBlank()) {
|
||||
return username;
|
||||
}
|
||||
if (canUsePracticePreview(loginUser)) {
|
||||
return practicePreviewActor(loginUser);
|
||||
}
|
||||
throw new ServiceException("请使用员工端账号或有权限的运营账号" + action);
|
||||
}
|
||||
|
||||
private static boolean canUsePracticePreview(LoginUser loginUser) {
|
||||
return loginUser != null
|
||||
&& UserType.SYS_USER.getUserType().equals(loginUser.getUserType())
|
||||
&& currentOperatorCanManageContent()
|
||||
&& loginUser.getUserId() != null;
|
||||
}
|
||||
|
||||
private static String practicePreviewActor(LoginUser loginUser) {
|
||||
return "operator:" + loginUser.getUserId();
|
||||
}
|
||||
|
||||
private static String currentAppUsername() {
|
||||
@@ -282,6 +344,14 @@ public class AihrPracticeController {
|
||||
return loginUser.getUsername().trim();
|
||||
}
|
||||
|
||||
private static Long currentOperatorUserId() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || loginUser.getUserId() == null) {
|
||||
throw new ServiceException("无法识别当前内容审核人");
|
||||
}
|
||||
return loginUser.getUserId();
|
||||
}
|
||||
|
||||
private static boolean currentOperatorCanManageContent() {
|
||||
return StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE);
|
||||
}
|
||||
|
||||
+4
-20
@@ -25,9 +25,7 @@ import org.dromara.aihr.domain.AihrSopDto.UploadItemResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
|
||||
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
|
||||
import org.dromara.aihr.service.AihrSopSeedService;
|
||||
import org.dromara.aihr.service.AihrOrgSyncService;
|
||||
import org.dromara.aihr.service.AihrUploadQueueService;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
@@ -51,7 +49,6 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -67,7 +64,6 @@ public class AihrSopController {
|
||||
private static final String DEMO_VERIFY_SOURCE = "verify_demo_questions";
|
||||
|
||||
private final AihrSopSeedService sopSeedService;
|
||||
private final AihrOrgSyncService orgSyncService;
|
||||
private final AihrUploadQueueService uploadQueueService;
|
||||
private final AihrKnowledgeAccessService knowledgeAccessService;
|
||||
private final AihrKnowledgeAppService knowledgeAppService;
|
||||
@@ -111,14 +107,6 @@ public class AihrSopController {
|
||||
)).legacy());
|
||||
}
|
||||
|
||||
private String currentRequesterExtPartyId() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return "";
|
||||
}
|
||||
return loginUser.getUsername();
|
||||
}
|
||||
|
||||
private SearchRequest restrictDemoVerificationSource(SearchRequest request) {
|
||||
if (request == null || !DEMO_VERIFY_SOURCE.equals(request.source())
|
||||
|| StpUtil.hasRole(TenantConstants.SUPER_ADMIN_ROLE_KEY)) {
|
||||
@@ -139,15 +127,11 @@ public class AihrSopController {
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return null;
|
||||
}
|
||||
String identity = currentRequesterExtPartyId();
|
||||
if (identity.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
try {
|
||||
return orgSyncService.snapshot(identity, null, null, "active", 1, 500, 500).rows().stream()
|
||||
.map(OrgPersonRow::projectCode)
|
||||
.filter(code -> code != null && !code.isBlank())
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
// The resolver validates an APP phone against the active snapshot
|
||||
// before returning project scopes. Do not pass APP usernames to the
|
||||
// management-console fuzzy snapshot search here.
|
||||
return knowledgePrincipalResolver.current().projectCodes();
|
||||
} catch (RuntimeException ex) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
+106
-3
@@ -33,6 +33,19 @@ public final class AihrPracticeDto {
|
||||
String position,
|
||||
String scenarioType,
|
||||
String projectType,
|
||||
String growthLevel,
|
||||
String competencyCode,
|
||||
String collaborationPositions,
|
||||
String reviewStatus,
|
||||
String riskLevel,
|
||||
boolean requiresDualReview,
|
||||
String curriculumVersion,
|
||||
String reviewer,
|
||||
Long reviewerUserId,
|
||||
String reviewedTime,
|
||||
String secondReviewer,
|
||||
Long secondReviewerUserId,
|
||||
String secondReviewedTime,
|
||||
Integer difficulty,
|
||||
String customerPersona,
|
||||
String openingText,
|
||||
@@ -66,6 +79,11 @@ public final class AihrPracticeDto {
|
||||
String position,
|
||||
String scenarioType,
|
||||
String projectType,
|
||||
String growthLevel,
|
||||
String competencyCode,
|
||||
String collaborationPositions,
|
||||
String riskLevel,
|
||||
String curriculumVersion,
|
||||
Integer difficulty,
|
||||
String customerPersona,
|
||||
String openingText,
|
||||
@@ -85,6 +103,38 @@ public final class AihrPracticeDto {
|
||||
public record ScenarioEnabledRequest(Boolean enabled) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Content review is deliberately separate from editing a scenario. A
|
||||
* client may propose a status, but the server records the authenticated
|
||||
* operator as the reviewer.
|
||||
*/
|
||||
public record ScenarioReviewStatusRequest(String reviewStatus) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A high-risk scenario remains pending until two different operators have
|
||||
* reviewed the same content version. The response lets the console show
|
||||
* that first approval is recorded but is not publication yet.
|
||||
*/
|
||||
public record ScenarioReviewStatusResponse(String reviewStatus, boolean awaitingSecondReview, String message) {
|
||||
}
|
||||
|
||||
public record CurriculumCapabilityResponse(String position, String growthLevel, String code, String name) {
|
||||
}
|
||||
|
||||
public record CurriculumMatrixResponse(
|
||||
String position,
|
||||
String growthLevel,
|
||||
String competencyCode,
|
||||
String competencyName,
|
||||
Integer publishedCount,
|
||||
Integer pendingReviewCount,
|
||||
Integer draftCount,
|
||||
Integer offlineCount,
|
||||
String coverageStatus
|
||||
) {
|
||||
}
|
||||
|
||||
public record RubricResponse(String code, String name, String version, List<RubricDimensionResponse> dimensions) {
|
||||
}
|
||||
|
||||
@@ -269,7 +319,12 @@ public final class AihrPracticeDto {
|
||||
public record CalibrationResponse(Long id, String sessionId, Integer originalScore, Integer correctedScore, String reviewer, String reason, String status, String createTime) {
|
||||
}
|
||||
|
||||
public record ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId, String nextReason) {
|
||||
public record ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId,
|
||||
String nextReason, String confirmedGrowthLevel) {
|
||||
public ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId,
|
||||
String nextReason) {
|
||||
this(reviewer, advice, incentivePoint, nextScenarioId, nextReason, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReviewDetailResponse(
|
||||
@@ -278,6 +333,12 @@ public final class AihrPracticeDto {
|
||||
String time,
|
||||
String trainee,
|
||||
String scene,
|
||||
String position,
|
||||
String projectType,
|
||||
String growthLevel,
|
||||
String competencyCode,
|
||||
String collaborationPositions,
|
||||
String curriculumVersion,
|
||||
Integer score,
|
||||
String status,
|
||||
String summary,
|
||||
@@ -288,8 +349,22 @@ public final class AihrPracticeDto {
|
||||
List<DimensionResponse> scoreItems,
|
||||
List<DialogueResponse> dialogue,
|
||||
List<AnnotationResponse> annotations,
|
||||
String extPartyId
|
||||
String extPartyId,
|
||||
String confirmedGrowthLevel,
|
||||
String growthConfirmedBy,
|
||||
String growthConfirmedTime
|
||||
) {
|
||||
public ReviewDetailResponse(Long id, String sessionId, String time, String trainee, String scene,
|
||||
String position, String projectType, String growthLevel, String competencyCode,
|
||||
String collaborationPositions, String curriculumVersion, Integer score, String status,
|
||||
String summary, String mentorRewrite, String aiComment, String reviewAdvice,
|
||||
Integer incentivePoint, List<DimensionResponse> scoreItems,
|
||||
List<DialogueResponse> dialogue, List<AnnotationResponse> annotations,
|
||||
String extPartyId) {
|
||||
this(id, sessionId, time, trainee, scene, position, projectType, growthLevel, competencyCode,
|
||||
collaborationPositions, curriculumVersion, score, status, summary, mentorRewrite, aiComment,
|
||||
reviewAdvice, incentivePoint, scoreItems, dialogue, annotations, extPartyId, "", "", "");
|
||||
}
|
||||
}
|
||||
|
||||
public record CompetencyResponse(
|
||||
@@ -301,13 +376,41 @@ public final class AihrPracticeDto {
|
||||
Integer assessmentScore,
|
||||
String aiLevel,
|
||||
List<DimensionResponse> dimensions,
|
||||
List<GrowthStageResponse> growthPath
|
||||
List<GrowthStageResponse> growthPath,
|
||||
List<CapabilityProgressResponse> capabilityProgress,
|
||||
List<GrowthConfirmationResponse> growthConfirmations
|
||||
) {
|
||||
public CompetencyResponse(Integer score, Integer completed, Integer pendingReview, Integer trainingMinutes,
|
||||
Integer contribution, Integer assessmentScore, String aiLevel,
|
||||
List<DimensionResponse> dimensions, List<GrowthStageResponse> growthPath,
|
||||
List<CapabilityProgressResponse> capabilityProgress) {
|
||||
this(score, completed, pendingReview, trainingMinutes, contribution, assessmentScore, aiLevel,
|
||||
dimensions, growthPath, capabilityProgress, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
public record GrowthStageResponse(String level, Integer progress, String status, String evidence) {
|
||||
}
|
||||
|
||||
public record GrowthConfirmationResponse(String position, String confirmedGrowthLevel, String reviewer,
|
||||
String confirmedTime) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed-session aggregate keyed by the curriculum snapshot captured
|
||||
* when the employee started the practice. It deliberately reports
|
||||
* training evidence only; it is not a personnel-grade or promotion result.
|
||||
*/
|
||||
public record CapabilityProgressResponse(
|
||||
String position,
|
||||
String growthLevel,
|
||||
String competencyCode,
|
||||
Integer completed,
|
||||
Integer averageScore,
|
||||
String status
|
||||
) {
|
||||
}
|
||||
|
||||
public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) {
|
||||
}
|
||||
|
||||
|
||||
+9
-5
@@ -28,11 +28,15 @@ public class AihrKnowledgeDataToolService {
|
||||
if (principal == null || !hasText(principal.extPartyId())) {
|
||||
throw new ServiceException("当前登录身份不能使用训练数据工具", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
// In the APP knowledge flow this field carries the authenticated mobile
|
||||
// username. Validate it once at the tool boundary before any legacy
|
||||
// training query can resolve aliases from organization data.
|
||||
String mobilePhone = mobileSeedService.requireMobileIdentity(principal.extPartyId());
|
||||
String toolCode = rawToolCode == null ? "" : rawToolCode.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
return switch (toolCode) {
|
||||
case MY_PRACTICE_SUMMARY -> myPractice(principal.extPartyId());
|
||||
case TEAM_PRACTICE_SUMMARY -> teamPractice(principal);
|
||||
case MY_CURRENT_TASKS -> myCurrentTasks(principal.extPartyId());
|
||||
case MY_PRACTICE_SUMMARY -> myPractice(mobilePhone);
|
||||
case TEAM_PRACTICE_SUMMARY -> teamPractice(principal, mobilePhone);
|
||||
case MY_CURRENT_TASKS -> myCurrentTasks(mobilePhone);
|
||||
default -> throw new ServiceException("不支持的数据工具", HttpStatus.BAD_REQUEST);
|
||||
};
|
||||
}
|
||||
@@ -92,11 +96,11 @@ public class AihrKnowledgeDataToolService {
|
||||
return new ToolResult(MY_PRACTICE_SUMMARY, "我的训练概况", "RECENT_5", answer, data);
|
||||
}
|
||||
|
||||
private ToolResult teamPractice(AihrKnowledgePrincipal principal) {
|
||||
private ToolResult teamPractice(AihrKnowledgePrincipal principal, String mobilePhone) {
|
||||
if (!principal.roles().contains("supervisor")) {
|
||||
throw new ServiceException("只有主管可以查询团队训练概况", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
String supervisorIdentity = mobileSeedService.requireSupervisorIdentity(principal.extPartyId());
|
||||
String supervisorIdentity = mobileSeedService.requireMobileSupervisorIdentity(mobilePhone);
|
||||
PracticeTeamResponse snapshot = mobileSeedService.practiceTeam(supervisorIdentity);
|
||||
List<RecordResponse> records = snapshot.records() == null ? List.of() : snapshot.records();
|
||||
int memberCount = snapshot.members() == null ? 0 : snapshot.members().size();
|
||||
|
||||
+6
-4
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
import org.dromara.aihr.service.AihrMobileSeedService;
|
||||
import org.dromara.aihr.service.AihrOrgSyncService;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
@@ -22,6 +23,7 @@ import java.util.Set;
|
||||
public class AihrKnowledgePrincipalResolver {
|
||||
|
||||
private final AihrOrgSyncService orgSyncService;
|
||||
private final AihrMobileSeedService mobileSeedService;
|
||||
|
||||
public AihrKnowledgePrincipal current() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
@@ -41,7 +43,7 @@ public class AihrKnowledgePrincipalResolver {
|
||||
}
|
||||
if (UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
roles.add("employee");
|
||||
extPartyId = trim(loginUser.getUsername());
|
||||
extPartyId = mobileSeedService.requireMobileIdentity(trim(loginUser.getUsername()));
|
||||
List<OrgPersonRow> rows = orgRows(extPartyId);
|
||||
if (rows.stream().anyMatch(AihrKnowledgePrincipalResolver::isSupervisor)) {
|
||||
roles.add("supervisor");
|
||||
@@ -56,12 +58,12 @@ public class AihrKnowledgePrincipalResolver {
|
||||
roles, projectCodes, trim(loginUser.getClientKey()));
|
||||
}
|
||||
|
||||
private List<OrgPersonRow> orgRows(String extPartyId) {
|
||||
if (extPartyId.isBlank()) {
|
||||
private List<OrgPersonRow> orgRows(String mobilePhone) {
|
||||
if (mobilePhone.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return orgSyncService.snapshot(extPartyId, null, null, "active", 1, 500, 500).rows();
|
||||
return orgSyncService.activeByMobilePhone(mobilePhone);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("knowledge principal org lookup failed, keeping employee-only scope");
|
||||
return List.of();
|
||||
|
||||
+21
@@ -22,8 +22,10 @@ import org.dromara.aihr.learning.AihrLearningDto.ExamTargetDraft;
|
||||
import org.dromara.aihr.learning.AihrLearningDto.ExamTargetResponse;
|
||||
import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest;
|
||||
import org.dromara.aihr.service.AihrModelSeedService;
|
||||
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.tenant.helper.TenantHelper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
@@ -61,6 +63,9 @@ public class AihrExamService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AihrModelSeedService modelSeedService;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean schemaReady;
|
||||
|
||||
@PostConstruct
|
||||
@@ -792,6 +797,16 @@ public class AihrExamService {
|
||||
if (schemaReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate,
|
||||
"aihr_onboard_exam", "aihr_onboard_exam_question", "aihr_onboard_exam_target",
|
||||
"aihr_onboard_exam_attempt", "aihr_onboard_exam_answer");
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_exam", "publish_request_key");
|
||||
AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_onboard_exam",
|
||||
"uk_aihr_onboard_exam_publish_request");
|
||||
schemaReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS aihr_onboard_exam (
|
||||
id bigint NOT NULL AUTO_INCREMENT,
|
||||
@@ -899,6 +914,9 @@ public class AihrExamService {
|
||||
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?
|
||||
""", Integer.class, table, column);
|
||||
if (count == null || count == 0) {
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
throw new ServiceException("数据库结构缺失 " + table + "." + column + ",请先执行正式 SQL 迁移");
|
||||
}
|
||||
jdbcTemplate.execute(ddl);
|
||||
}
|
||||
}
|
||||
@@ -909,6 +927,9 @@ public class AihrExamService {
|
||||
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?
|
||||
""", Integer.class, table, index);
|
||||
if (count == null || count == 0) {
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
throw new ServiceException("数据库索引缺失 " + table + "." + index + ",请先执行正式 SQL 迁移");
|
||||
}
|
||||
jdbcTemplate.execute(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -12,9 +12,11 @@ import org.dromara.aihr.learning.AihrLearningDto.LearningMatrixResponse;
|
||||
import org.dromara.aihr.learning.AihrLearningDto.LearningQuestionResponse;
|
||||
import org.dromara.aihr.learning.AihrLearningDto.PositionMatrixResponse;
|
||||
import org.dromara.aihr.learning.AihrLearningDto.ScenarioMatrixResponse;
|
||||
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
|
||||
import org.dromara.aihr.service.AihrPracticeSeedService;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.tenant.helper.TenantHelper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -47,6 +49,9 @@ public class AihrLearningService {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AihrPracticeSeedService practiceSeedService;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean schemaReady;
|
||||
|
||||
@PostConstruct
|
||||
@@ -365,6 +370,14 @@ public class AihrLearningService {
|
||||
if (schemaReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate,
|
||||
"aihr_learning_question", "aihr_practice_question_feedback", "aihr_practice_assignment");
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_practice_assignment",
|
||||
"content_version", "question_snapshot", "reference_answer_snapshot", "content_hash");
|
||||
schemaReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS aihr_learning_question (
|
||||
id bigint NOT NULL AUTO_INCREMENT,
|
||||
@@ -416,6 +429,9 @@ public class AihrLearningService {
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_practice_assignment' AND COLUMN_NAME = ?
|
||||
""", Integer.class, column);
|
||||
if (exists == null || exists == 0) {
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
throw new ServiceException("数据库结构缺失 aihr_practice_assignment." + column + ",请先执行正式 SQL 迁移");
|
||||
}
|
||||
jdbcTemplate.execute(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -15,12 +15,14 @@ import org.dromara.aihr.report.AihrWorkReportDto.OrganizeResponse;
|
||||
import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse;
|
||||
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
|
||||
import org.dromara.aihr.service.AihrModelSeedService;
|
||||
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
|
||||
import org.dromara.aihr.service.AihrSensitiveText;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
@@ -89,6 +91,9 @@ public class AihrWorkReportService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public OrganizeResponse organize(OrganizeRequest request) {
|
||||
@@ -669,6 +674,12 @@ public class AihrWorkReportService {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_work_report");
|
||||
requireIdempotencySchema();
|
||||
tableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_work_report` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
|
||||
+10
@@ -7,6 +7,7 @@ import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
@@ -36,6 +37,9 @@ public class AihrCandidateMaterialService {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public MaterialResponse upload(MultipartFile file, String candidateId, String candidateName, String materialType) {
|
||||
@@ -161,6 +165,12 @@ public class AihrCandidateMaterialService {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_candidate_material");
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_candidate_material", "reviewer", "reviewed_time");
|
||||
tableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_candidate_material` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
|
||||
+28
-11
@@ -19,6 +19,7 @@ import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -52,6 +53,10 @@ public class AihrCaseService {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ISysOssService ossService;
|
||||
private final Map<String, CaseState> cases = new ConcurrentHashMap<>();
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public UploadResponse upload(MultipartFile file, String projectExtOrgId, List<String> projectScopes) {
|
||||
@@ -237,9 +242,14 @@ public class AihrCaseService {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public List<String> projectScopesForAppUser(String identity) {
|
||||
String username = clean(identity);
|
||||
if (username.isBlank()) {
|
||||
/**
|
||||
* APP callers are authenticated by mobile phone. This must remain an exact
|
||||
* phone predicate: treating the same value as an external ID lets a phone
|
||||
* collision borrow another employee's project scope.
|
||||
*/
|
||||
public List<String> projectScopesForAppUser(String mobilePhone) {
|
||||
String phone = clean(mobilePhone);
|
||||
if (phone.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
@@ -252,33 +262,33 @@ public class AihrCaseService {
|
||||
AND employment_status = 'active'
|
||||
AND project_code IS NOT NULL
|
||||
AND project_code <> ''
|
||||
AND (person_phone = ? OR ext_party_id = ?)
|
||||
AND person_phone = ?
|
||||
ORDER BY project_code
|
||||
""", (rs, rowNum) -> rs.getString("project_code"), tenantId(), username, username);
|
||||
""", (rs, rowNum) -> rs.getString("project_code"), tenantId(), phone);
|
||||
} catch (DataAccessException e) {
|
||||
log.warn("resolve app user project scope failed(处理错误已隐藏)");
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCaseContributor(String identity) {
|
||||
String username = clean(identity);
|
||||
if (username.isBlank()) {
|
||||
public boolean isCaseContributor(String mobilePhone) {
|
||||
String phone = clean(mobilePhone);
|
||||
if (phone.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM aihr_org_snapshot
|
||||
FROM aihr_org_snapshot
|
||||
WHERE tenant_id = ?
|
||||
AND employment_status = 'active'
|
||||
AND (person_phone = ? OR ext_party_id = ?)
|
||||
AND person_phone = ?
|
||||
AND (position_level IN ('主管', '项目经理')
|
||||
OR position_name LIKE '%主管%'
|
||||
OR position_name LIKE '%经理%'
|
||||
OR position_name LIKE '%负责人%'
|
||||
OR position_name LIKE '%组长%')
|
||||
""", Integer.class, tenantId(), username, username);
|
||||
""", Integer.class, tenantId(), phone);
|
||||
return count != null && count > 0;
|
||||
} catch (DataAccessException e) {
|
||||
log.warn("resolve case contributor failed(处理错误已隐藏)");
|
||||
@@ -331,6 +341,13 @@ public class AihrCaseService {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_case_record");
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_case_record",
|
||||
"media_oss_id", "media_url", "learning_points", "supervisor_comment");
|
||||
tableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_case_record` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
|
||||
+9
-4
@@ -392,14 +392,19 @@ public class AihrDashboardService {
|
||||
}
|
||||
|
||||
private CalibrationStats calibrationStats() {
|
||||
if (!tableExists("aihr_practice_calibration")) {
|
||||
if (!tableExists("aihr_practice_calibration")
|
||||
|| !tableExists("aihr_practice_session")
|
||||
|| !columnExists("aihr_practice_session", "mode")) {
|
||||
return CalibrationStats.unavailable();
|
||||
}
|
||||
try {
|
||||
CalibrationStats stats = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(DISTINCT session_id) sessions
|
||||
FROM aihr_practice_calibration
|
||||
WHERE tenant_id = ? AND original_score IS NOT NULL AND corrected_score IS NOT NULL
|
||||
SELECT COUNT(DISTINCT c.session_id) sessions
|
||||
FROM aihr_practice_calibration c
|
||||
JOIN aihr_practice_session s
|
||||
ON s.tenant_id = c.tenant_id AND s.session_id = c.session_id
|
||||
WHERE c.tenant_id = ? AND s.mode = 'mobile'
|
||||
AND c.original_score IS NOT NULL AND c.corrected_score IS NOT NULL
|
||||
""", (rs, rowNum) -> new CalibrationStats(rs.getInt("sessions"), true), tenantId());
|
||||
return stats == null ? CalibrationStats.unavailable() : stats;
|
||||
} catch (DataAccessException e) {
|
||||
|
||||
+17
@@ -20,6 +20,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -48,6 +49,10 @@ public class AihrInterviewService {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, CandidateProfile> candidates = buildCandidates();
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean tableReady;
|
||||
private volatile boolean candidateLinkTableReady;
|
||||
|
||||
@@ -376,6 +381,13 @@ public class AihrInterviewService {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_interview_result");
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_interview_result",
|
||||
"questions_json", "reviewed_score", "review_status", "reviewer", "review_note", "reviewed_time");
|
||||
tableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
@@ -446,6 +458,11 @@ public class AihrInterviewService {
|
||||
if (candidateLinkTableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_candidate_employee_link");
|
||||
candidateLinkTableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
|
||||
+36
@@ -86,10 +86,18 @@ public class AihrMobileSeedService {
|
||||
return practiceSeedService.mobileHistory(extPartyId, 5);
|
||||
}
|
||||
|
||||
public List<RecordResponse> organizationPracticeHistory(String organizationExtPartyId) {
|
||||
return practiceSeedService.organizationHistory(organizationExtPartyId, 5);
|
||||
}
|
||||
|
||||
public List<MistakeResponse> practiceMistakes(String extPartyId) {
|
||||
return practiceSeedService.mobileMistakes(extPartyId, 20);
|
||||
}
|
||||
|
||||
public List<MistakeResponse> organizationPracticeMistakes(String organizationExtPartyId) {
|
||||
return practiceSeedService.organizationMistakes(organizationExtPartyId, 20);
|
||||
}
|
||||
|
||||
public List<RecordResponse> practiceReviews() {
|
||||
return practiceSeedService.pendingReviewRecords(5);
|
||||
}
|
||||
@@ -114,6 +122,22 @@ public class AihrMobileSeedService {
|
||||
return practiceSeedService.requireSupervisorIdentity(authenticatedIdentity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an APP principal, whose authenticated username is always a
|
||||
* mobile phone number rather than an organization external ID.
|
||||
*/
|
||||
public String requireMobileIdentity(String mobilePhone) {
|
||||
return practiceSeedService.requireMobileIdentity(mobilePhone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an APP supervisor phone to the verified organization external
|
||||
* ID used by team-scope queries.
|
||||
*/
|
||||
public String requireMobileSupervisorIdentity(String mobilePhone) {
|
||||
return practiceSeedService.requireMobileSupervisorIdentity(mobilePhone);
|
||||
}
|
||||
|
||||
public ReviewDetailResponse practiceReview(Long id) {
|
||||
return practiceSeedService.reviewDetail(id);
|
||||
}
|
||||
@@ -154,10 +178,18 @@ public class AihrMobileSeedService {
|
||||
return practiceSeedService.competency(extPartyId);
|
||||
}
|
||||
|
||||
public CompetencyResponse organizationProfile(String organizationExtPartyId) {
|
||||
return practiceSeedService.organizationCompetency(organizationExtPartyId);
|
||||
}
|
||||
|
||||
public PromotionEvidenceResponse promotionEvidence(String extPartyId) {
|
||||
return practiceSeedService.promotionEvidence(extPartyId);
|
||||
}
|
||||
|
||||
public PromotionEvidenceResponse organizationPromotionEvidence(String organizationExtPartyId) {
|
||||
return practiceSeedService.organizationPromotionEvidence(organizationExtPartyId);
|
||||
}
|
||||
|
||||
public List<PracticeAlertResponse> practiceAlerts() {
|
||||
return practiceSeedService.alerts();
|
||||
}
|
||||
@@ -170,6 +202,10 @@ public class AihrMobileSeedService {
|
||||
return practiceSeedService.assignments(extPartyId, 10);
|
||||
}
|
||||
|
||||
public List<PracticeAssignmentResponse> organizationPracticeAssignments(String organizationExtPartyId) {
|
||||
return practiceSeedService.organizationAssignments(organizationExtPartyId, 10);
|
||||
}
|
||||
|
||||
public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId, String supervisorExtPartyId) {
|
||||
return practiceSeedService.assignments(extPartyId, supervisorExtPartyId, 10);
|
||||
}
|
||||
|
||||
+41
@@ -77,6 +77,9 @@ public class AihrOrgSyncService {
|
||||
@Value("${aihr.org-sync.position-map-json:${AIHR_ORG_POSITION_MAP_JSON:}}")
|
||||
private String configuredPositionMapJson;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
public SyncResponse sync(SyncRequest request) {
|
||||
SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request;
|
||||
if (req.dryRun() == null) {
|
||||
@@ -280,6 +283,38 @@ public class AihrOrgSyncService {
|
||||
return new OrgSnapshotResponse(total, active, departed, phoneLinked, suspectText, safePageNum, safePageSize, orgProjects(), orgLevels(), rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* APP identity resolution must not use the management-console fuzzy search:
|
||||
* an APP username is a phone number and only an exact active phone match is
|
||||
* eligible to contribute role or project scope.
|
||||
*/
|
||||
public List<OrgPersonRow> activeByMobilePhone(String mobilePhone) {
|
||||
requireSnapshotTable();
|
||||
String phone = clean(mobilePhone);
|
||||
if (phone.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return jdbcTemplate.query("""
|
||||
select id, project_code, project_name, dept_name, ext_party_id, person_name,
|
||||
position_name, position_level, employment_status,
|
||||
date_format(snapshot_date, '%Y-%m-%d') snapshot_date
|
||||
from aihr_org_snapshot
|
||||
where tenant_id = ? and person_phone = ? and employment_status = 'active'
|
||||
order by project_name, dept_name, position_level, person_name, id
|
||||
""", (rs, rowNum) -> new OrgPersonRow(
|
||||
rs.getLong("id"),
|
||||
rs.getString("project_code"),
|
||||
rs.getString("project_name"),
|
||||
storeDisplayFields ? rs.getString("dept_name") : "",
|
||||
rs.getString("ext_party_id"),
|
||||
storeDisplayFields ? rs.getString("person_name") : "",
|
||||
rs.getString("position_name"),
|
||||
rs.getString("position_level"),
|
||||
rs.getString("employment_status"),
|
||||
rs.getString("snapshot_date")
|
||||
), tenantId(), phone);
|
||||
}
|
||||
|
||||
private List<JsonNode> fetchOptional(String baseUrl, String token, SyncRequest req, String resourceType,
|
||||
int pageSize, int maxPages, List<String> warnings) {
|
||||
try {
|
||||
@@ -850,6 +885,12 @@ public class AihrOrgSyncService {
|
||||
}
|
||||
|
||||
private void requireSnapshotTable() {
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_org_snapshot");
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_org_snapshot", "person_phone", "hire_date");
|
||||
AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_org_snapshot", "idx_aihr_org_phone");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
|
||||
+37
-2
@@ -132,15 +132,41 @@ public class AihrPracticeLlmService {
|
||||
int defaultTrust,
|
||||
String defaultCoachHint,
|
||||
String styleInstruction
|
||||
) {
|
||||
return customerTurn(personaTraits, project, goal, dialogue, traineeText, nextBeat, defaultEmotion, defaultTrust,
|
||||
defaultCoachHint, styleInstruction, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The curriculum context is loaded from the server-side scenario snapshot.
|
||||
* It constrains the model but must never be exposed in the simulated
|
||||
* customer's own wording.
|
||||
*/
|
||||
public Optional<PracticeTurn> customerTurn(
|
||||
String personaTraits,
|
||||
String project,
|
||||
String goal,
|
||||
List<DialogueResponse> dialogue,
|
||||
String traineeText,
|
||||
String nextBeat,
|
||||
int defaultEmotion,
|
||||
int defaultTrust,
|
||||
String defaultCoachHint,
|
||||
String styleInstruction,
|
||||
String curriculumContext
|
||||
) {
|
||||
String styleRule = styleInstruction == null || styleInstruction.isBlank()
|
||||
? ""
|
||||
: "\n 6. 本次只调整 coachHint 风格:" + styleInstruction.trim();
|
||||
String curriculumRule = curriculumContext == null || curriculumContext.isBlank()
|
||||
? "未配置额外目录约束"
|
||||
: curriculumContext.trim();
|
||||
String system = """
|
||||
你只扮演物业小区业主,同时作为训练引擎给出结构化回合状态。
|
||||
人设特征:%s
|
||||
场景:%s
|
||||
隐性诉求:%s
|
||||
训练目录约束(仅约束角色和评分提示,绝不在客户话术中暴露):%s
|
||||
规则:
|
||||
1. customerText 是业主下一句话,一句以内,不超过%d个字,口语化。
|
||||
2. 禁止在 customerText 中提及系统、人设、评分、SOP、AI、训练。
|
||||
@@ -150,7 +176,7 @@ public class AihrPracticeLlmService {
|
||||
%s
|
||||
只输出 JSON,不要输出其他文字:
|
||||
{"customerText":"","emotion":%d,"trust":%d,"coachHint":"","redFlag":false}
|
||||
""".formatted(personaTraits, project, goal, MAX_CUSTOMER_CHARS, nextBeat, styleRule, defaultEmotion, defaultTrust);
|
||||
""".formatted(personaTraits, project, goal, curriculumRule, MAX_CUSTOMER_CHARS, nextBeat, styleRule, defaultEmotion, defaultTrust);
|
||||
String user = "对话记录:\n" + renderDialogue(dialogue)
|
||||
+ "\n员工刚才说:" + traineeText
|
||||
+ "\n默认教练提示:" + defaultCoachHint
|
||||
@@ -174,10 +200,18 @@ public class AihrPracticeLlmService {
|
||||
*/
|
||||
public Optional<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> dialogue,
|
||||
List<RubricDimensionResponse> rubric) {
|
||||
return score(scenarioName, goal, strategy, dialogue, rubric, "");
|
||||
}
|
||||
|
||||
public Optional<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> dialogue,
|
||||
List<RubricDimensionResponse> rubric, String curriculumContext) {
|
||||
if (dialogue.stream().noneMatch(turn -> "trainee".equals(turn.role()) && !turn.text().isBlank())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String rubricText = renderRubric(rubric);
|
||||
String curriculumRule = curriculumContext == null || curriculumContext.isBlank()
|
||||
? "未配置额外目录约束"
|
||||
: curriculumContext.trim();
|
||||
String system = """
|
||||
你是物业行业培训考官(prompt版本 %s)。根据对练记录给员工话术评分。
|
||||
评分锚点:60=及格线(有明显缺失);75=合格(覆盖主要要点);90=优秀(完整且超预期)。
|
||||
@@ -186,7 +220,8 @@ public class AihrPracticeLlmService {
|
||||
{"total":0-100,"taskCompletion":0-100,"compliance":0-100,"communication":0-100,"emotion":0-100,"responseTimeliness":0-100或-1,"marketing":0-100,"rewrite":"给员工的示范话术改写,80字内","summary":"一句话点评,60字内"}
|
||||
taskCompletion 评估问题是否真正被推进到可执行闭环;responseTimeliness 只有对话中存在明确响应时效证据时填写,否则填 -1。
|
||||
本场评分规则:%s
|
||||
""".formatted(PROMPT_VERSION, rubricText);
|
||||
训练目录约束(仅用于判断岗位边界和能力项,不得臆造员工未说过的动作):%s
|
||||
""".formatted(PROMPT_VERSION, rubricText, curriculumRule);
|
||||
String user = """
|
||||
训练场景:%s
|
||||
训练目标:%s
|
||||
|
||||
+2291
-424
File diff suppressed because it is too large
Load Diff
+105
@@ -0,0 +1,105 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Fails closed when a release migration has not created an AIHR table.
|
||||
* Runtime schema bootstrap remains an explicit development-only escape hatch.
|
||||
*/
|
||||
public final class AihrSchemaMigrationGuard {
|
||||
|
||||
private static final Pattern AIHR_TABLE = Pattern.compile("aihr_[a-z0-9_]+");
|
||||
private static final Pattern AIHR_IDENTIFIER = Pattern.compile("[a-z][a-z0-9_]*");
|
||||
|
||||
private AihrSchemaMigrationGuard() {
|
||||
}
|
||||
|
||||
public static void requireTables(JdbcTemplate jdbcTemplate, String... tableNames) {
|
||||
for (String tableName : tableNames) {
|
||||
requireAihrTableName(tableName);
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = ?
|
||||
""", Integer.class, tableName);
|
||||
if (count == null || count == 0) {
|
||||
throw new ServiceException("数据库结构缺失 " + tableName + ",请先执行正式 SQL 迁移");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void requireColumns(JdbcTemplate jdbcTemplate, String tableName, String... columnNames) {
|
||||
requireAihrTableName(tableName);
|
||||
for (String columnName : columnNames) {
|
||||
requireIdentifier(columnName, "列名");
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?
|
||||
""", Integer.class, tableName, columnName);
|
||||
if (count == null || count == 0) {
|
||||
throw new ServiceException("数据库结构缺失 " + tableName + "." + columnName + ",请先执行正式 SQL 迁移");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void requireIndexes(JdbcTemplate jdbcTemplate, String tableName, String... indexNames) {
|
||||
requireAihrTableName(tableName);
|
||||
for (String indexName : indexNames) {
|
||||
requireIdentifier(indexName, "索引名");
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?
|
||||
""", Integer.class, tableName, indexName);
|
||||
if (count == null || count == 0) {
|
||||
throw new ServiceException("数据库结构缺失 " + tableName + "." + indexName + " 索引,请先执行正式 SQL 迁移");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the exact index contract required for a correctness-sensitive path.
|
||||
* A same-named prefix or non-unique index is not equivalent to the formal migration.
|
||||
*/
|
||||
public static void requireIndexDefinition(JdbcTemplate jdbcTemplate, String tableName, String indexName,
|
||||
boolean unique, String... columnNames) {
|
||||
requireAihrTableName(tableName);
|
||||
requireIdentifier(indexName, "索引名");
|
||||
if (columnNames == null || columnNames.length == 0) {
|
||||
throw new IllegalArgumentException("索引至少需要一个列名");
|
||||
}
|
||||
for (String columnName : columnNames) {
|
||||
requireIdentifier(columnName, "列名");
|
||||
}
|
||||
String actual = jdbcTemplate.queryForObject("""
|
||||
SELECT CONCAT(
|
||||
COALESCE(MIN(non_unique), -1), '|',
|
||||
COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|',
|
||||
COALESCE(SUM(sub_part IS NOT NULL), 0)
|
||||
)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?
|
||||
""", String.class, tableName, indexName);
|
||||
String expected = (unique ? "0" : "1") + "|" + String.join(",", columnNames) + "|0";
|
||||
if (!expected.equals(actual)) {
|
||||
throw new ServiceException("数据库索引定义不匹配 " + tableName + "." + indexName
|
||||
+ ",请先执行正式 SQL 迁移");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireAihrTableName(String tableName) {
|
||||
if (tableName == null || !AIHR_TABLE.matcher(tableName).matches()) {
|
||||
throw new IllegalArgumentException("非法 AIHR 表名");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireIdentifier(String value, String label) {
|
||||
if (value == null || !AIHR_IDENTIFIER.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("非法 AIHR " + label);
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -137,6 +137,10 @@ public class AihrSopSeedService {
|
||||
private final AihrVideoService videoService;
|
||||
private final AihrModelSeedService modelService;
|
||||
private final TransactionTemplate requiresNewTransaction;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean knowledgeGapTableReady;
|
||||
private volatile boolean sopReviewTableReady;
|
||||
private volatile boolean answerFeedbackTableReady;
|
||||
@@ -2603,6 +2607,13 @@ public class AihrSopSeedService {
|
||||
if (sopReviewTableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_sop_answer_review");
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_sop_answer_review",
|
||||
"source", "requester_ext_party_id", "prompt_version");
|
||||
sopReviewTableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
@@ -2641,6 +2652,13 @@ public class AihrSopSeedService {
|
||||
if (answerFeedbackTableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_knowledge_answer_feedback");
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_knowledge_answer_feedback",
|
||||
"review_id", "feedback_reason_codes", "feedback_comment", "reviewer", "reviewed_time");
|
||||
answerFeedbackTableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
@@ -2683,6 +2701,11 @@ public class AihrSopSeedService {
|
||||
if (knowledgeGapTableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_knowledge_gap");
|
||||
knowledgeGapTableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
@@ -2708,6 +2731,9 @@ public class AihrSopSeedService {
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
|
||||
""", Integer.class, table, column);
|
||||
if (exists == null || exists == 0) {
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
throw new ServiceException("数据库结构缺失 " + table + "." + column + ",请先执行正式 SQL 迁移");
|
||||
}
|
||||
jdbcTemplate.execute(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -71,6 +71,10 @@ public class AihrUploadQueueService {
|
||||
private final ScheduledExecutorService scheduledExecutorService;
|
||||
private final Path stagingRoot;
|
||||
private final Semaphore workerPermits = new Semaphore(WORKER_PERMITS);
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public AihrUploadQueueService(AihrSopSeedService sopSeedService,
|
||||
@@ -660,6 +664,11 @@ public class AihrUploadQueueService {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_knowledge_upload_item");
|
||||
tableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_knowledge_upload_item` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
|
||||
+10
@@ -4,12 +4,14 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
|
||||
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
|
||||
import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest;
|
||||
import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse;
|
||||
import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.redisson.api.RateType;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -26,6 +28,9 @@ public class AihrWebAiService {
|
||||
private final AihrKnowledgePrincipalResolver principalResolver;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean auditTableReady;
|
||||
|
||||
public QueryResponse query(QueryRequest request) {
|
||||
@@ -136,6 +141,11 @@ public class AihrWebAiService {
|
||||
if (auditTableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_web_ai_query_audit");
|
||||
auditTableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_web_ai_query_audit` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
|
||||
+11
@@ -3,7 +3,9 @@ package org.dromara.aihr.webai;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
@@ -24,6 +26,10 @@ public class AihrWebSearchProviderService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AihrWebSecretCodec secretCodec;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public List<ProviderResponse> list(AihrKnowledgePrincipal principal) {
|
||||
@@ -220,6 +226,11 @@ public class AihrWebSearchProviderService {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_web_search_provider");
|
||||
tableReady = true;
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_web_search_provider` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL,
|
||||
|
||||
+18
-8
@@ -1,7 +1,9 @@
|
||||
package org.dromara.aihr.webai;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -22,6 +24,10 @@ class AihrWebSecretCodec {
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Value("${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}")
|
||||
private boolean runtimeSchemaBootstrap = true;
|
||||
|
||||
private volatile SecretKeySpec databaseKey;
|
||||
|
||||
String encrypt(String plaintext) {
|
||||
@@ -79,14 +85,18 @@ class AihrWebSecretCodec {
|
||||
if (databaseKey != null) {
|
||||
return databaseKey;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_web_ai_secret` (
|
||||
`config_key` varchar(64) NOT NULL,
|
||||
`config_value` varchar(255) NOT NULL,
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
""");
|
||||
if (!runtimeSchemaBootstrap) {
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_web_ai_secret");
|
||||
} else {
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_web_ai_secret` (
|
||||
`config_key` varchar(64) NOT NULL,
|
||||
`config_value` varchar(255) NOT NULL,
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
""");
|
||||
}
|
||||
byte[] random = new byte[32];
|
||||
RANDOM.nextBytes(random);
|
||||
jdbcTemplate.update("""
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ class AihrAgentMediaTest {
|
||||
return new AihrAgentOrchestrator(
|
||||
new AihrAgentPlanner(null, new ObjectMapper()),
|
||||
new AihrAgentPolicy(),
|
||||
new AihrKnowledgePrincipalResolver(null) {
|
||||
new AihrKnowledgePrincipalResolver(null, null) {
|
||||
@Override
|
||||
public AihrKnowledgePrincipal current() {
|
||||
return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ class AihrAgentOrchestratorTest {
|
||||
return new AihrAgentOrchestrator(
|
||||
new AihrAgentPlanner(null, new ObjectMapper()),
|
||||
new AihrAgentPolicy(),
|
||||
new AihrKnowledgePrincipalResolver(null) {
|
||||
new AihrKnowledgePrincipalResolver(null, null) {
|
||||
@Override
|
||||
public AihrKnowledgePrincipal current() {
|
||||
return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ class AihrAgentWebResearchTest {
|
||||
return new AihrAgentOrchestrator(
|
||||
new AihrAgentPlanner(null, new ObjectMapper()),
|
||||
new AihrAgentPolicy(),
|
||||
new AihrKnowledgePrincipalResolver(null) {
|
||||
new AihrKnowledgePrincipalResolver(null, null) {
|
||||
@Override
|
||||
public AihrKnowledgePrincipal current() {
|
||||
return principal;
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ class AihrBroadcastM1ServiceTest {
|
||||
assertTrue(jdbcTemplate.publishReplaySql.contains("unmatched_target_count"));
|
||||
}
|
||||
|
||||
private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null) {
|
||||
private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null, null) {
|
||||
@Override
|
||||
public AihrKnowledgePrincipal current() {
|
||||
return new AihrKnowledgePrincipal(
|
||||
|
||||
+2
-2
@@ -316,7 +316,7 @@ class AihrBroadcastServiceTest {
|
||||
Method publish = AihrBroadcastController.class.getMethod(
|
||||
"publish", AihrBroadcastDto.PublishRequest.class);
|
||||
Constructor<?> constructor = java.util.Arrays.stream(AihrBroadcastDto.PublishRequest.class.getDeclaredConstructors())
|
||||
.filter(candidate -> candidate.getParameterCount() == 6)
|
||||
.filter(candidate -> candidate.getParameterCount() == 7)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
@@ -347,7 +347,7 @@ class AihrBroadcastServiceTest {
|
||||
assertEquals(cn.dev33.satoken.annotation.SaMode.OR, role.mode());
|
||||
}
|
||||
|
||||
private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null) {
|
||||
private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null, null) {
|
||||
@Override
|
||||
public AihrKnowledgePrincipal current() {
|
||||
return new AihrKnowledgePrincipal(
|
||||
|
||||
+11
-2
@@ -30,12 +30,14 @@ class AihrKnowledgeDataToolServiceTest {
|
||||
@Test
|
||||
void myPracticeSummaryAlwaysUsesCurrentAppUserIdentity() {
|
||||
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
|
||||
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
|
||||
when(mobile.practiceHistory("employee-1")).thenReturn(List.of(
|
||||
new RecordResponse(9L, "session", "07-16 09:00", "张三", "投诉沟通", 88, "已完成", "表达清晰", "employee-1")));
|
||||
var service = new AihrKnowledgeDataToolService(mobile);
|
||||
|
||||
var result = service.execute(employee(), AihrKnowledgeDataToolService.MY_PRACTICE_SUMMARY);
|
||||
|
||||
verify(mobile).requireMobileIdentity("employee-1");
|
||||
verify(mobile).practiceHistory("employee-1");
|
||||
var data = (AihrKnowledgeDataToolService.MyPracticeSummary) result.data();
|
||||
assertEquals(1, data.practiceCount());
|
||||
@@ -45,7 +47,8 @@ class AihrKnowledgeDataToolServiceTest {
|
||||
@Test
|
||||
void teamPracticeSummaryUsesResolvedSupervisorScope() {
|
||||
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
|
||||
when(mobile.requireSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor");
|
||||
when(mobile.requireMobileIdentity("supervisor-1")).thenReturn("supervisor-1");
|
||||
when(mobile.requireMobileSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor");
|
||||
when(mobile.practiceTeam("canonical-supervisor"))
|
||||
.thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of()));
|
||||
var service = new AihrKnowledgeDataToolService(mobile);
|
||||
@@ -54,13 +57,15 @@ class AihrKnowledgeDataToolServiceTest {
|
||||
|
||||
service.execute(principal, AihrKnowledgeDataToolService.TEAM_PRACTICE_SUMMARY);
|
||||
|
||||
verify(mobile).requireSupervisorIdentity("supervisor-1");
|
||||
verify(mobile).requireMobileIdentity("supervisor-1");
|
||||
verify(mobile).requireMobileSupervisorIdentity("supervisor-1");
|
||||
verify(mobile).practiceTeam("canonical-supervisor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void employeeCannotCallTeamPracticeSummary() {
|
||||
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
|
||||
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
|
||||
var service = new AihrKnowledgeDataToolService(mobile);
|
||||
|
||||
assertThrows(ServiceException.class,
|
||||
@@ -71,6 +76,7 @@ class AihrKnowledgeDataToolServiceTest {
|
||||
@Test
|
||||
void currentTasksUsesOnlyCurrentIdentityAndReturnsNoTaskExplicitly() {
|
||||
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
|
||||
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
|
||||
when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(
|
||||
List.of(), List.of()));
|
||||
var service = new AihrKnowledgeDataToolService(mobile);
|
||||
@@ -87,6 +93,7 @@ class AihrKnowledgeDataToolServiceTest {
|
||||
@Test
|
||||
void currentTasksKeepsOnlyIncompleteOnboardingRows() {
|
||||
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
|
||||
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
|
||||
when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(List.of(
|
||||
new OnboardTaskResponse(1L, "P1", "客服", null, null, "岗前学习", "待完成", "主管", null,
|
||||
"2026-07-30", null, null),
|
||||
@@ -106,6 +113,7 @@ class AihrKnowledgeDataToolServiceTest {
|
||||
@Test
|
||||
void currentTasksNeverTurnsAnUnavailableSourceIntoNoTasks() {
|
||||
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
|
||||
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
|
||||
when(mobile.currentTasks("employee-1"))
|
||||
.thenThrow(new ServiceException("当前任务数据读取失败,暂时无法确认待办,请稍后再试", HttpStatus.ERROR));
|
||||
var service = new AihrKnowledgeDataToolService(mobile);
|
||||
@@ -121,6 +129,7 @@ class AihrKnowledgeDataToolServiceTest {
|
||||
@Test
|
||||
void unknownToolIsRejectedWithoutReadingTrainingData() {
|
||||
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
|
||||
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
|
||||
var service = new AihrKnowledgeDataToolService(mobile);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.execute(employee(), "RAW_SQL"));
|
||||
|
||||
+14
-4
@@ -174,11 +174,18 @@ class AihrKnowledgeQueryServiceTest {
|
||||
var access = mock(AihrKnowledgeAccessService.class);
|
||||
var sop = mock(AihrSopSeedService.class);
|
||||
var jdbc = mock(JdbcTemplate.class);
|
||||
var conversations = mock(AihrKnowledgeConversationService.class);
|
||||
var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", Set.of("employee"), Set.of(), "app");
|
||||
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
|
||||
var request = new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null,
|
||||
"conversation_123", 0L);
|
||||
var context = new ConversationContext("conversation_123", true, true, 0L, List.of(),
|
||||
"电梯这里该怎么办", "QA", null);
|
||||
when(resolver.current()).thenReturn(principal);
|
||||
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
|
||||
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
|
||||
when(conversations.prepare(principal, app, request)).thenReturn(context);
|
||||
when(conversations.append(any(), any(), any(), anyString(), anyString(), any())).thenReturn(1L);
|
||||
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop"));
|
||||
var media = new MockMultipartFile("file", "lift.jpg", "image/jpeg", new byte[]{1});
|
||||
when(sop.extractQuestionMedia(media, "电梯这里该怎么办"))
|
||||
@@ -188,17 +195,20 @@ class AihrKnowledgeQueryServiceTest {
|
||||
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
|
||||
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
|
||||
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
|
||||
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
|
||||
conversations, mock(AihrMemoryService.class));
|
||||
|
||||
var result = service.queryInternalMedia(
|
||||
new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null),
|
||||
media);
|
||||
var result = service.queryInternalMedia(request, media);
|
||||
|
||||
var requestCaptor = ArgumentCaptor.forClass(SearchRequest.class);
|
||||
verify(sop).searchAuthorized(requestCaptor.capture(), any(), any());
|
||||
assertEquals("电梯这里该怎么办", result.queryText());
|
||||
assertEquals(true, requestCaptor.getValue().queryText().contains("附件现场信息"));
|
||||
assertEquals(true, requestCaptor.getValue().queryText().contains("电梯门无法打开"));
|
||||
assertTrue(result.noEvidence());
|
||||
assertTrue(result.answer().contains("附件识别结果:画面显示电梯门无法打开,楼层指示灯闪烁"));
|
||||
var appendedAnswer = ArgumentCaptor.forClass(String.class);
|
||||
verify(conversations).append(any(), any(), any(), anyString(), appendedAnswer.capture(), any());
|
||||
assertTrue(appendedAnswer.getValue().contains("附件识别结果:画面显示电梯门无法打开,楼层指示灯闪烁"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+10
-11
@@ -1,7 +1,7 @@
|
||||
package org.dromara.aihr.knowledge.service;
|
||||
|
||||
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
|
||||
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse;
|
||||
import org.dromara.aihr.service.AihrMobileSeedService;
|
||||
import org.dromara.aihr.service.AihrOrgSyncService;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
@@ -16,8 +16,6 @@ import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -27,13 +25,14 @@ class AihrKnowledgePrincipalResolverTest {
|
||||
@Test
|
||||
void appSupervisorRoleAndProjectsComeFromOrgSnapshot() {
|
||||
AihrOrgSyncService orgService = mock(AihrOrgSyncService.class);
|
||||
when(orgService.snapshot(eq("party-1"), any(), any(), eq("active"), eq(1), eq(500), eq(500)))
|
||||
.thenReturn(new OrgSnapshotResponse(1, 1, 0, 1, 0, 1, 500, List.of(), List.of(), List.of(
|
||||
new OrgPersonRow(1L, "P01", "", "", "party-1", "", "项目经理", "项目经理", "active", "2026-07-16")
|
||||
)));
|
||||
LoginUser login = login(UserType.APP_USER.getUserType(), "party-1", Set.of());
|
||||
AihrMobileSeedService mobileSeedService = mock(AihrMobileSeedService.class);
|
||||
when(mobileSeedService.requireMobileIdentity("13900001111")).thenReturn("13900001111");
|
||||
when(orgService.activeByMobilePhone("13900001111")).thenReturn(List.of(
|
||||
new OrgPersonRow(1L, "P01", "", "", "party-1", "", "项目经理", "项目经理", "active", "2026-07-16")
|
||||
));
|
||||
LoginUser login = login(UserType.APP_USER.getUserType(), "13900001111", Set.of());
|
||||
|
||||
var principal = new AihrKnowledgePrincipalResolver(orgService).from(login);
|
||||
var principal = new AihrKnowledgePrincipalResolver(orgService, mobileSeedService).from(login);
|
||||
|
||||
assertEquals(Set.of("employee", "supervisor"), principal.roles());
|
||||
assertEquals(Set.of("P01"), principal.projectCodes());
|
||||
@@ -42,7 +41,7 @@ class AihrKnowledgePrincipalResolverTest {
|
||||
@Test
|
||||
void sysUserRolesComeFromLoginUser() {
|
||||
LoginUser login = login(UserType.SYS_USER.getUserType(), "admin", Set.of("hr_operator"));
|
||||
var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class)).from(login);
|
||||
var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class), mock(AihrMobileSeedService.class)).from(login);
|
||||
assertEquals("000000", principal.tenantId());
|
||||
assertTrue(principal.roles().contains("hr_operator"));
|
||||
assertTrue(principal.projectCodes().isEmpty());
|
||||
@@ -55,7 +54,7 @@ class AihrKnowledgePrincipalResolverTest {
|
||||
try (MockedStatic<TenantHelper> tenantHelper = Mockito.mockStatic(TenantHelper.class)) {
|
||||
tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-b");
|
||||
|
||||
var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class)).from(login);
|
||||
var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class), mock(AihrMobileSeedService.class)).from(login);
|
||||
|
||||
assertEquals("tenant-b", principal.tenantId());
|
||||
}
|
||||
|
||||
+41
@@ -234,6 +234,26 @@ class AihrCaseServiceTest {
|
||||
assertTrue(controllerSource.contains("HR_OPERATOR_ROLE = \"hr_operator\""));
|
||||
assertTrue(controllerSource.contains("private boolean canViewCases()"));
|
||||
assertTrue(controllerSource.contains("@GetMapping(\"/capabilities\")"));
|
||||
assertTrue(controllerSource.contains("mobileSeedService.requireMobileIdentity(username)"));
|
||||
assertTrue(controllerSource.contains("caseService.projectScopesForAppUser(currentAppMobilePhone(loginUser))"));
|
||||
assertTrue(controllerSource.contains("caseService.isCaseContributor(currentAppMobilePhone(loginUser))"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void appCaseScopeAndContributorQueriesUseOnlyExactMobilePhone() {
|
||||
CaseIdentityJdbcTemplate jdbcTemplate = new CaseIdentityJdbcTemplate();
|
||||
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
|
||||
|
||||
service.projectScopesForAppUser("13900001111");
|
||||
service.isCaseContributor("13900001111");
|
||||
|
||||
assertTrue(jdbcTemplate.scopeSql.contains("AND person_phone = ?"));
|
||||
assertFalse(jdbcTemplate.scopeSql.contains("OR ext_party_id"));
|
||||
assertEquals(List.of("000000", "13900001111"), List.of(jdbcTemplate.scopeArgs));
|
||||
assertTrue(jdbcTemplate.contributorSql.contains("AND person_phone = ?"));
|
||||
assertFalse(jdbcTemplate.contributorSql.contains("OR ext_party_id"));
|
||||
assertEquals(List.of("000000", "13900001111"), List.of(jdbcTemplate.contributorArgs));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -309,4 +329,25 @@ class AihrCaseServiceTest {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CaseIdentityJdbcTemplate extends CaseJdbcTemplate {
|
||||
private String scopeSql = "";
|
||||
private Object[] scopeArgs = new Object[0];
|
||||
private String contributorSql = "";
|
||||
private Object[] contributorArgs = new Object[0];
|
||||
|
||||
@Override
|
||||
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
|
||||
scopeSql = sql;
|
||||
scopeArgs = args;
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
|
||||
contributorSql = sql;
|
||||
contributorArgs = args;
|
||||
return requiredType.cast(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -41,5 +41,7 @@ class AihrDashboardServiceTest {
|
||||
assertTrue(code.contains("\"训练数据源未就绪\", \"info\", practice.available())"));
|
||||
assertTrue(code.contains("\"校准数据源未就绪\", \"info\", calibrations.available())"));
|
||||
assertTrue(code.contains("\"组织快照数据源未就绪\", \"info\", org.available())"));
|
||||
assertTrue(code.contains("JOIN aihr_practice_session s"));
|
||||
assertTrue(code.contains("s.mode = 'mobile'"));
|
||||
}
|
||||
}
|
||||
|
||||
+1050
-46
File diff suppressed because it is too large
Load Diff
+99
@@ -0,0 +1,99 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@Tag("dev")
|
||||
class AihrRuntimeSchemaBootstrapContractTest {
|
||||
|
||||
private static final String RUNTIME_SCHEMA_BOOTSTRAP_EXPRESSION =
|
||||
"${aihr.practice.runtime-schema-bootstrap:${AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP:false}}";
|
||||
|
||||
@Test
|
||||
void everyRuntimeDdlOwnerUsesTheSharedStaticSchemaGuard() throws Exception {
|
||||
Path sourceRoot = projectPath("src/main/java");
|
||||
List<Path> owners;
|
||||
try (Stream<Path> files = Files.walk(sourceRoot)) {
|
||||
owners = files.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> containsRuntimeDdl(path))
|
||||
.toList();
|
||||
}
|
||||
assertFalse(owners.isEmpty(), "至少应覆盖一个运行时 DDL 服务");
|
||||
|
||||
for (Path sourcePath : owners) {
|
||||
String sourceFile = sourceRoot.relativize(sourcePath).toString();
|
||||
String source = Files.readString(sourcePath);
|
||||
int firstDdl = firstDdlPosition(source);
|
||||
int guard = source.indexOf("if (!runtimeSchemaBootstrap)");
|
||||
|
||||
assertTrue(firstDdl >= 0, () -> sourceFile + " must contain runtime DDL");
|
||||
assertTrue(source.contains(RUNTIME_SCHEMA_BOOTSTRAP_EXPRESSION),
|
||||
() -> sourceFile + " must use the shared disabled-by-default runtime schema switch");
|
||||
assertTrue(guard >= 0 && guard < firstDdl,
|
||||
() -> sourceFile + " must return through the disabled-bootstrap guard before its DDL");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeSchemaBootstrapDefaultsToFalseInSpring() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(RuntimeSchemaBootstrapProbe.class);
|
||||
context.refresh();
|
||||
|
||||
assertFalse(context.getBean(RuntimeSchemaBootstrapProbe.class).enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeSchemaBootstrapCanOnlyBeEnabledExplicitlyInSpring() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test", Map.of(
|
||||
"aihr.practice.runtime-schema-bootstrap", "true"
|
||||
)));
|
||||
context.registerBean(RuntimeSchemaBootstrapProbe.class);
|
||||
context.refresh();
|
||||
|
||||
assertTrue(context.getBean(RuntimeSchemaBootstrapProbe.class).enabled);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RuntimeSchemaBootstrapProbe {
|
||||
@Value(RUNTIME_SCHEMA_BOOTSTRAP_EXPRESSION)
|
||||
private boolean enabled = true;
|
||||
}
|
||||
|
||||
private static boolean containsRuntimeDdl(Path sourcePath) {
|
||||
try {
|
||||
int ddlPosition = firstDdlPosition(Files.readString(sourcePath));
|
||||
return ddlPosition >= 0;
|
||||
} catch (Exception error) {
|
||||
throw new IllegalStateException("无法读取 " + sourcePath, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static int firstDdlPosition(String source) {
|
||||
int createTable = source.indexOf("CREATE TABLE IF NOT EXISTS");
|
||||
int alterTable = source.indexOf("ALTER TABLE");
|
||||
if (createTable < 0) {
|
||||
return alterTable;
|
||||
}
|
||||
return alterTable < 0 ? createTable : Math.min(createTable, alterTable);
|
||||
}
|
||||
|
||||
private static Path projectPath(String relative) {
|
||||
Path direct = Path.of(relative);
|
||||
return Files.exists(direct) ? direct : Path.of("ruoyi-modules/ruoyi-aihr").resolve(relative).normalize();
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@Tag("dev")
|
||||
class AihrSchemaMigrationGuardTest {
|
||||
|
||||
@Test
|
||||
void verifiesEveryRequestedAihrTable() {
|
||||
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
|
||||
|
||||
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_practice_session", "aihr_learning_question");
|
||||
|
||||
assertEquals(List.of("aihr_practice_session", "aihr_learning_question"), jdbcTemplate.checkedTables);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenTheFormalMigrationIsMissing() {
|
||||
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
|
||||
jdbcTemplate.missingTables.add("aihr_practice_session");
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_practice_session"));
|
||||
|
||||
assertTrue(error.getMessage().contains("aihr_practice_session"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifiesColumnsAndIndexesWithoutExecutingDdl() {
|
||||
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
|
||||
|
||||
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_practice_assignment", "content_hash");
|
||||
AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_practice_assignment", "idx_aihr_assignment_recent_content");
|
||||
|
||||
assertEquals(List.of("aihr_practice_assignment.content_hash"), jdbcTemplate.checkedColumns);
|
||||
assertEquals(List.of("aihr_practice_assignment.idx_aihr_assignment_recent_content"), jdbcTemplate.checkedIndexes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenARequiredColumnIsMissing() {
|
||||
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
|
||||
jdbcTemplate.missingColumns.add("aihr_practice_assignment.content_hash");
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_practice_assignment", "content_hash"));
|
||||
|
||||
assertTrue(error.getMessage().contains("aihr_practice_assignment.content_hash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenARequiredIndexIsMissing() {
|
||||
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
|
||||
jdbcTemplate.missingIndexes.add("aihr_practice_assignment.uk_aihr_assignment_request");
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_practice_assignment",
|
||||
"uk_aihr_assignment_request"));
|
||||
|
||||
assertTrue(error.getMessage().contains("aihr_practice_assignment.uk_aihr_assignment_request"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifiesACompleteIndexDefinition() {
|
||||
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
|
||||
jdbcTemplate.indexDefinitions.put("aihr_practice_assignment.uk_aihr_assignment_request",
|
||||
"0|tenant_id,request_key,ext_party_id|0");
|
||||
|
||||
AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_practice_assignment",
|
||||
"uk_aihr_assignment_request", true, "tenant_id", "request_key", "ext_party_id");
|
||||
|
||||
assertEquals(List.of("aihr_practice_assignment.uk_aihr_assignment_request"), jdbcTemplate.checkedIndexDefinitions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenAnIndexDefinitionIsWrong() {
|
||||
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
|
||||
jdbcTemplate.indexDefinitions.put("aihr_practice_assignment.uk_aihr_assignment_request",
|
||||
"1|tenant_id,request_key,ext_party_id|0");
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_practice_assignment",
|
||||
"uk_aihr_assignment_request", true, "tenant_id", "request_key", "ext_party_id"));
|
||||
|
||||
assertTrue(error.getMessage().contains("数据库索引定义不匹配"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonAihrTablesAndUnsafeIdentifiers() {
|
||||
SchemaJdbcTemplate jdbcTemplate = new SchemaJdbcTemplate();
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "sys_user"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_practice_assignment", "content-hash"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_practice_assignment", "idx;drop"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_practice_assignment",
|
||||
"uk_aihr_assignment_request", true, "request-key"));
|
||||
}
|
||||
|
||||
private static final class SchemaJdbcTemplate extends JdbcTemplate {
|
||||
private final List<String> checkedTables = new ArrayList<>();
|
||||
private final List<String> missingTables = new ArrayList<>();
|
||||
private final List<String> checkedColumns = new ArrayList<>();
|
||||
private final List<String> missingColumns = new ArrayList<>();
|
||||
private final List<String> checkedIndexes = new ArrayList<>();
|
||||
private final List<String> missingIndexes = new ArrayList<>();
|
||||
private final List<String> checkedIndexDefinitions = new ArrayList<>();
|
||||
private final Map<String, String> indexDefinitions = new java.util.HashMap<>();
|
||||
|
||||
@Override
|
||||
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
|
||||
if (sql.contains("GROUP_CONCAT(column_name ORDER BY seq_in_index)")) {
|
||||
String key = args[0] + "." + args[1];
|
||||
checkedIndexDefinitions.add(key);
|
||||
return requiredType.cast(indexDefinitions.getOrDefault(key, "-1||0"));
|
||||
}
|
||||
if (sql.contains("information_schema.columns")) {
|
||||
String key = args[0] + "." + args[1];
|
||||
checkedColumns.add(key);
|
||||
return requiredType.cast(missingColumns.contains(key) ? 0 : 1);
|
||||
}
|
||||
if (sql.contains("information_schema.statistics")) {
|
||||
String key = args[0] + "." + args[1];
|
||||
checkedIndexes.add(key);
|
||||
return requiredType.cast(missingIndexes.contains(key) ? 0 : 1);
|
||||
}
|
||||
String tableName = String.valueOf(args[0]);
|
||||
checkedTables.add(tableName);
|
||||
return requiredType.cast(missingTables.contains(tableName) ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -247,7 +247,8 @@ public class AihrSopSeedServiceTest {
|
||||
String serviceCode = Files.readString(service);
|
||||
|
||||
assertTrue(controllerCode.contains("UserType.APP_USER.getUserType()"));
|
||||
assertTrue(controllerCode.contains("currentRequesterExtPartyId()"));
|
||||
assertTrue(controllerCode.contains("knowledgePrincipalResolver.current().projectCodes()"));
|
||||
assertFalse(controllerCode.contains("orgSyncService.snapshot("));
|
||||
assertTrue(serviceCode.contains("requester_ext_party_id"));
|
||||
assertTrue(serviceCode.contains("NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL"));
|
||||
assertTrue(serviceCode.contains("FROM aihr_org_snapshot o"));
|
||||
@@ -308,8 +309,9 @@ public class AihrSopSeedServiceTest {
|
||||
String code = Files.readString(source);
|
||||
|
||||
assertTrue(code.contains("sopSeedService.positionSop(position, projectType, currentAppProjectCodes())"));
|
||||
assertTrue(code.contains("orgSyncService.snapshot(identity, null, null, \"active\", 1, 500, 500)"));
|
||||
assertTrue(code.contains("OrgPersonRow::projectCode"));
|
||||
assertTrue(code.contains("knowledgePrincipalResolver.current().projectCodes()"));
|
||||
assertFalse(code.contains("orgSyncService.snapshot(identity, null, null, \"active\", 1, 500, 500)"));
|
||||
assertFalse(code.contains("OrgPersonRow::projectCode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -32,6 +32,15 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
|
||||
`trainee_name` varchar(100) DEFAULT NULL COMMENT '学员名称',
|
||||
`scenario_id` varchar(80) DEFAULT NULL COMMENT '场景ID',
|
||||
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
|
||||
`position_snapshot` varchar(80) DEFAULT NULL COMMENT '训练开始时岗位快照',
|
||||
`project_type_snapshot` varchar(80) DEFAULT NULL COMMENT '训练开始时项目类型快照',
|
||||
`growth_level` varchar(20) DEFAULT NULL COMMENT '训练开始时成长层级快照',
|
||||
`competency_code` varchar(100) DEFAULT NULL COMMENT '训练开始时能力项快照',
|
||||
`collaboration_positions` varchar(500) DEFAULT NULL COMMENT '训练开始时协同岗位快照',
|
||||
`curriculum_version` varchar(30) DEFAULT NULL COMMENT '训练开始时成长目录版本快照',
|
||||
`growth_confirmation_level` varchar(20) DEFAULT NULL COMMENT '主管确认可进入的训练层级',
|
||||
`growth_confirmed_by` varchar(100) DEFAULT NULL COMMENT '训练层级确认主管',
|
||||
`growth_confirmed_time` datetime DEFAULT NULL COMMENT '训练层级确认时间',
|
||||
`mode` varchar(30) DEFAULT 'text' COMMENT '训练模式',
|
||||
`total_score` int DEFAULT NULL COMMENT '总分',
|
||||
`dim_task_completion` int DEFAULT NULL COMMENT '任务完成度分',
|
||||
@@ -181,6 +190,17 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` (
|
||||
`scenario_type` varchar(50) NOT NULL COMMENT '场景类型',
|
||||
`project_type` varchar(80) DEFAULT NULL COMMENT '项目类型',
|
||||
`difficulty` tinyint DEFAULT 1 COMMENT '难度',
|
||||
`growth_level` varchar(20) NOT NULL DEFAULT '初级' COMMENT '训练成长层级:初级/中级/高级',
|
||||
`competency_code` varchar(100) NOT NULL DEFAULT '待归类' COMMENT '岗位能力项编码;由内容运营归入固定矩阵',
|
||||
`collaboration_positions` varchar(500) NOT NULL DEFAULT '' COMMENT '协同岗位,使用中文顿号分隔',
|
||||
`review_status` varchar(20) NOT NULL DEFAULT '草稿' COMMENT '内容状态:草稿/待业务审核/已发布/已下线',
|
||||
`risk_level` varchar(20) NOT NULL DEFAULT '待评估' COMMENT '内容风险:待评估/常规/高风险',
|
||||
`reviewer` varchar(100) DEFAULT NULL COMMENT '首位内容审核操作人',
|
||||
`reviewer_user_id` bigint DEFAULT NULL COMMENT '首位内容审核人稳定用户ID',
|
||||
`reviewed_time` datetime DEFAULT NULL COMMENT '首位内容审核时间',
|
||||
`second_reviewer` varchar(100) DEFAULT NULL COMMENT '高风险内容第二位审核操作人',
|
||||
`second_reviewer_user_id` bigint DEFAULT NULL COMMENT '高风险内容第二位审核人稳定用户ID',
|
||||
`second_reviewed_time` datetime DEFAULT NULL COMMENT '高风险内容第二位审核时间',
|
||||
`customer_persona` varchar(500) DEFAULT NULL COMMENT '业主人设',
|
||||
`opening_text` varchar(500) DEFAULT NULL COMMENT '开场白',
|
||||
`explicit_need` varchar(300) DEFAULT NULL COMMENT '显性诉求',
|
||||
@@ -191,17 +211,21 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` (
|
||||
`success_criteria` varchar(500) DEFAULT NULL COMMENT '成功条件',
|
||||
`strategy` varchar(500) DEFAULT NULL COMMENT '训练策略',
|
||||
`goal` varchar(500) DEFAULT NULL COMMENT '训练目标',
|
||||
`curriculum_version` varchar(30) NOT NULL DEFAULT '住宅五岗-v1' COMMENT '成长目录版本',
|
||||
`trainee_name` varchar(100) DEFAULT '张三' COMMENT '默认学员',
|
||||
`trust_start` int DEFAULT 60 COMMENT '起始信任',
|
||||
`trust_end` int DEFAULT 80 COMMENT '目标信任',
|
||||
`rounds_json` text DEFAULT NULL COMMENT '回合seed JSON',
|
||||
`content_version` varchar(30) NOT NULL DEFAULT 'v1.0' COMMENT '内容版本',
|
||||
`content_hash` char(64) DEFAULT NULL COMMENT '内容快照哈希',
|
||||
`enabled` tinyint DEFAULT 1 COMMENT '是否启用',
|
||||
`sort_order` int DEFAULT 0 COMMENT '排序',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_scenario` (`tenant_id`, `scenario_code`),
|
||||
KEY `idx_aihr_practice_scenario_filter` (`tenant_id`, `position`, `scenario_type`, `enabled`, `sort_order`)
|
||||
KEY `idx_aihr_practice_scenario_filter` (`tenant_id`, `position`, `scenario_type`, `enabled`, `sort_order`),
|
||||
KEY `idx_aihr_practice_scenario_curriculum` (`tenant_id`, `position`, `growth_level`, `competency_code`, `review_status`, `enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练场景';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` (
|
||||
@@ -398,6 +422,34 @@ ON DUPLICATE KEY UPDATE
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`update_time` = VALUES(`update_time`);
|
||||
|
||||
-- 此文件仅用于全新开发库初始化;内置种子只是待审核素材,不能从 enabled 推断为正式发布。
|
||||
-- 新增/编辑内容必须走审核状态流转后才向员工可见。
|
||||
UPDATE `aihr_practice_scenario`
|
||||
SET `risk_level` = CASE
|
||||
WHEN CONCAT_WS('\n', `scenario_name`, `scenario_type`, `customer_persona`, `opening_text`, `explicit_need`, `hidden_need`,
|
||||
`irritants`, `delights`, `sop_refs`, `success_criteria`, `strategy`, `goal`, `rounds_json`)
|
||||
REGEXP '投诉|客诉|纠纷|收费|催费|费用|停车费|退款|减免|安全|事故|消防|燃气|漏电|电梯|高空|受伤|人身伤害|舆情' THEN '高风险'
|
||||
WHEN `risk_level` IS NULL OR `risk_level` = '' OR `risk_level` NOT IN ('待评估', '常规', '高风险') THEN '待评估'
|
||||
ELSE `risk_level`
|
||||
END,
|
||||
`review_status` = CASE
|
||||
WHEN `review_status` IS NULL OR `review_status` = '' OR `review_status` = '草稿' THEN '待业务审核'
|
||||
WHEN `review_status` = '已发布' AND (`competency_code` IS NULL OR `competency_code` = '' OR `competency_code` = '待归类'
|
||||
OR `risk_level` = '待评估'
|
||||
OR (`risk_level` = '高风险' AND (`reviewer_user_id` IS NULL OR `second_reviewer_user_id` IS NULL))) THEN '待业务审核'
|
||||
ELSE `review_status`
|
||||
END,
|
||||
-- 重跑初始化脚本时不得清空“待业务审核”中的首审记录;高风险场景可能正等待第二人复核。
|
||||
-- 草稿没有有效审核结论,才允许清除其历史审核字段。
|
||||
`reviewer` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `reviewer` END,
|
||||
`reviewer_user_id` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `reviewer_user_id` END,
|
||||
`reviewed_time` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `reviewed_time` END,
|
||||
`second_reviewer` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `second_reviewer` END,
|
||||
`second_reviewer_user_id` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `second_reviewer_user_id` END,
|
||||
`second_reviewed_time` = CASE WHEN `review_status` = '草稿' THEN NULL ELSE `second_reviewed_time` END,
|
||||
`curriculum_version` = COALESCE(NULLIF(`curriculum_version`, ''), '住宅五岗-v1')
|
||||
WHERE `tenant_id` = '000000';
|
||||
|
||||
INSERT INTO `aihr_practice_rubric`
|
||||
(`tenant_id`, `rubric_code`, `scenario_code`, `rubric_name`, `version`, `enabled`, `description`, `create_time`, `update_time`)
|
||||
SELECT
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
-- 阶段一陪练成长目录:场景的岗位/层级/能力项/审核语义及新会话快照。
|
||||
-- 不创建第二套课程引擎;只扩展 aihr_practice_scenario 与 aihr_practice_session。
|
||||
-- 可重复执行。历史会话不回填推测出的课程归属,避免改写历史训练事实。
|
||||
|
||||
SET @aihr_schema = DATABASE();
|
||||
|
||||
SET @growth_level_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'growth_level'
|
||||
);
|
||||
SET @growth_level_ddl = IF(@growth_level_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN growth_level varchar(20) NOT NULL DEFAULT ''初级'' COMMENT ''训练成长层级:初级/中级/高级'' AFTER difficulty',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_growth_level_stmt FROM @growth_level_ddl;
|
||||
EXECUTE aihr_growth_level_stmt;
|
||||
DEALLOCATE PREPARE aihr_growth_level_stmt;
|
||||
|
||||
SET @competency_code_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'competency_code'
|
||||
);
|
||||
SET @competency_code_ddl = IF(@competency_code_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN competency_code varchar(100) NOT NULL DEFAULT ''待归类'' COMMENT ''岗位能力项编码;由内容运营归入固定矩阵'' AFTER growth_level',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_competency_code_stmt FROM @competency_code_ddl;
|
||||
EXECUTE aihr_competency_code_stmt;
|
||||
DEALLOCATE PREPARE aihr_competency_code_stmt;
|
||||
|
||||
SET @collaboration_positions_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'collaboration_positions'
|
||||
);
|
||||
SET @collaboration_positions_ddl = IF(@collaboration_positions_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN collaboration_positions varchar(500) NOT NULL DEFAULT '''' COMMENT ''协同岗位,使用中文顿号分隔'' AFTER competency_code',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_collaboration_positions_stmt FROM @collaboration_positions_ddl;
|
||||
EXECUTE aihr_collaboration_positions_stmt;
|
||||
DEALLOCATE PREPARE aihr_collaboration_positions_stmt;
|
||||
|
||||
SET @review_status_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'review_status'
|
||||
);
|
||||
SET @review_status_ddl = IF(@review_status_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN review_status varchar(20) NOT NULL DEFAULT ''草稿'' COMMENT ''内容状态:草稿/待业务审核/已发布/已下线'' AFTER collaboration_positions',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_review_status_stmt FROM @review_status_ddl;
|
||||
EXECUTE aihr_review_status_stmt;
|
||||
DEALLOCATE PREPARE aihr_review_status_stmt;
|
||||
|
||||
SET @risk_level_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'risk_level'
|
||||
);
|
||||
SET @risk_level_ddl = IF(@risk_level_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN risk_level varchar(20) NOT NULL DEFAULT ''待评估'' COMMENT ''内容风险:待评估/常规/高风险'' AFTER review_status',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_risk_level_stmt FROM @risk_level_ddl;
|
||||
EXECUTE aihr_risk_level_stmt;
|
||||
DEALLOCATE PREPARE aihr_risk_level_stmt;
|
||||
|
||||
SET @curriculum_version_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'curriculum_version'
|
||||
);
|
||||
SET @curriculum_version_ddl = IF(@curriculum_version_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN curriculum_version varchar(30) NOT NULL DEFAULT ''住宅五岗-v1'' COMMENT ''成长目录版本'' AFTER goal',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_curriculum_version_stmt FROM @curriculum_version_ddl;
|
||||
EXECUTE aihr_curriculum_version_stmt;
|
||||
DEALLOCATE PREPARE aihr_curriculum_version_stmt;
|
||||
|
||||
SET @reviewer_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'reviewer'
|
||||
);
|
||||
SET @reviewer_ddl = IF(@reviewer_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN reviewer varchar(100) DEFAULT NULL COMMENT ''首位内容审核操作人'' AFTER risk_level',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_reviewer_stmt FROM @reviewer_ddl;
|
||||
EXECUTE aihr_reviewer_stmt;
|
||||
DEALLOCATE PREPARE aihr_reviewer_stmt;
|
||||
|
||||
SET @reviewer_user_id_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'reviewer_user_id'
|
||||
);
|
||||
SET @reviewer_user_id_ddl = IF(@reviewer_user_id_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN reviewer_user_id bigint DEFAULT NULL COMMENT ''首位内容审核人稳定用户ID'' AFTER reviewer',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_reviewer_user_id_stmt FROM @reviewer_user_id_ddl;
|
||||
EXECUTE aihr_reviewer_user_id_stmt;
|
||||
DEALLOCATE PREPARE aihr_reviewer_user_id_stmt;
|
||||
|
||||
SET @reviewed_time_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'reviewed_time'
|
||||
);
|
||||
SET @reviewed_time_ddl = IF(@reviewed_time_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT ''首位内容审核时间'' AFTER reviewer_user_id',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_reviewed_time_stmt FROM @reviewed_time_ddl;
|
||||
EXECUTE aihr_reviewed_time_stmt;
|
||||
DEALLOCATE PREPARE aihr_reviewed_time_stmt;
|
||||
|
||||
SET @second_reviewer_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'second_reviewer'
|
||||
);
|
||||
SET @second_reviewer_ddl = IF(@second_reviewer_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN second_reviewer varchar(100) DEFAULT NULL COMMENT ''高风险内容第二位审核操作人'' AFTER reviewed_time',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_second_reviewer_stmt FROM @second_reviewer_ddl;
|
||||
EXECUTE aihr_second_reviewer_stmt;
|
||||
DEALLOCATE PREPARE aihr_second_reviewer_stmt;
|
||||
|
||||
SET @second_reviewer_user_id_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'second_reviewer_user_id'
|
||||
);
|
||||
SET @second_reviewer_user_id_ddl = IF(@second_reviewer_user_id_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN second_reviewer_user_id bigint DEFAULT NULL COMMENT ''高风险内容第二位审核人稳定用户ID'' AFTER second_reviewer',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_second_reviewer_user_id_stmt FROM @second_reviewer_user_id_ddl;
|
||||
EXECUTE aihr_second_reviewer_user_id_stmt;
|
||||
DEALLOCATE PREPARE aihr_second_reviewer_user_id_stmt;
|
||||
|
||||
SET @second_reviewed_time_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario' AND column_name = 'second_reviewed_time'
|
||||
);
|
||||
SET @second_reviewed_time_ddl = IF(@second_reviewed_time_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD COLUMN second_reviewed_time datetime DEFAULT NULL COMMENT ''高风险内容第二位审核时间'' AFTER second_reviewer_user_id',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_second_reviewed_time_stmt FROM @second_reviewed_time_ddl;
|
||||
EXECUTE aihr_second_reviewed_time_stmt;
|
||||
DEALLOCATE PREPARE aihr_second_reviewed_time_stmt;
|
||||
|
||||
-- 既有场景必须重新进入业务审核,不能从旧的 enabled 状态推断“已发布”。
|
||||
-- 同时收口曾由早期脚本自动标记为已发布、但仍未归类能力项的场景。
|
||||
-- 注意:这份 migration 可以重复执行。不得清空已完成首审、正在等待第二人复核的待审记录。
|
||||
-- 对于本次新增稳定审核人 ID 前已发布的旧内容,一律退回待业务审核,重新建立可审计链路。
|
||||
UPDATE aihr_practice_scenario
|
||||
SET growth_level = CASE
|
||||
WHEN @growth_level_exists = 0 OR growth_level IS NULL OR growth_level = '' THEN CASE
|
||||
WHEN difficulty >= 3 THEN '高级'
|
||||
WHEN difficulty = 2 THEN '中级'
|
||||
ELSE '初级'
|
||||
END
|
||||
ELSE growth_level
|
||||
END,
|
||||
competency_code = CASE WHEN competency_code IS NULL OR competency_code = '' THEN '待归类' ELSE competency_code END,
|
||||
collaboration_positions = COALESCE(collaboration_positions, ''),
|
||||
risk_level = CASE
|
||||
-- 旧表刚新增风险字段时,没有可信的既有分类,必须等待运营重新评估。
|
||||
WHEN @risk_level_exists = 0 THEN '待评估'
|
||||
-- 兼容早期紧凑表:只依赖其已存在的编码和目标字段;完整内容由服务端保存时逐字段复核。
|
||||
WHEN CONCAT_WS('\n', scenario_code, goal)
|
||||
REGEXP '投诉|客诉|纠纷|收费|催费|费用|停车费|退款|减免|安全|事故|消防|燃气|漏电|电梯|高空|受伤|人身伤害|舆情' THEN '高风险'
|
||||
WHEN risk_level IS NULL OR risk_level = '' OR risk_level NOT IN ('待评估', '常规', '高风险') THEN '待评估'
|
||||
ELSE risk_level
|
||||
END,
|
||||
review_status = CASE
|
||||
WHEN @review_status_exists = 0 THEN '待业务审核'
|
||||
WHEN review_status IS NULL OR review_status = '' THEN '待业务审核'
|
||||
WHEN @risk_level_exists = 0 AND review_status = '已发布' THEN '待业务审核'
|
||||
WHEN @reviewer_user_id_exists = 0 AND review_status = '已发布' THEN '待业务审核'
|
||||
WHEN review_status = '已发布' AND (competency_code IS NULL OR competency_code = '' OR competency_code = '待归类') THEN '待业务审核'
|
||||
WHEN review_status = '已发布' AND risk_level = '待评估' THEN '待业务审核'
|
||||
WHEN review_status = '已发布' AND risk_level = '高风险'
|
||||
AND (reviewer_user_id IS NULL OR second_reviewer_user_id IS NULL) THEN '待业务审核'
|
||||
ELSE review_status
|
||||
END,
|
||||
curriculum_version = CASE
|
||||
WHEN @curriculum_version_exists = 0 OR curriculum_version IS NULL OR curriculum_version = '' THEN '住宅五岗-v1'
|
||||
ELSE curriculum_version
|
||||
END;
|
||||
|
||||
SET @position_snapshot_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'position_snapshot'
|
||||
);
|
||||
SET @position_snapshot_ddl = IF(@position_snapshot_exists = 0,
|
||||
'ALTER TABLE aihr_practice_session ADD COLUMN position_snapshot varchar(80) DEFAULT NULL COMMENT ''训练开始时岗位快照'' AFTER scenario_name',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_position_snapshot_stmt FROM @position_snapshot_ddl;
|
||||
EXECUTE aihr_position_snapshot_stmt;
|
||||
DEALLOCATE PREPARE aihr_position_snapshot_stmt;
|
||||
|
||||
SET @project_type_snapshot_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'project_type_snapshot'
|
||||
);
|
||||
SET @project_type_snapshot_ddl = IF(@project_type_snapshot_exists = 0,
|
||||
'ALTER TABLE aihr_practice_session ADD COLUMN project_type_snapshot varchar(80) DEFAULT NULL COMMENT ''训练开始时项目类型快照'' AFTER position_snapshot',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_project_type_snapshot_stmt FROM @project_type_snapshot_ddl;
|
||||
EXECUTE aihr_project_type_snapshot_stmt;
|
||||
DEALLOCATE PREPARE aihr_project_type_snapshot_stmt;
|
||||
|
||||
SET @session_growth_level_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'growth_level'
|
||||
);
|
||||
SET @session_growth_level_ddl = IF(@session_growth_level_exists = 0,
|
||||
'ALTER TABLE aihr_practice_session ADD COLUMN growth_level varchar(20) DEFAULT NULL COMMENT ''训练开始时成长层级快照'' AFTER project_type_snapshot',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_session_growth_level_stmt FROM @session_growth_level_ddl;
|
||||
EXECUTE aihr_session_growth_level_stmt;
|
||||
DEALLOCATE PREPARE aihr_session_growth_level_stmt;
|
||||
|
||||
SET @session_competency_code_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'competency_code'
|
||||
);
|
||||
SET @session_competency_code_ddl = IF(@session_competency_code_exists = 0,
|
||||
'ALTER TABLE aihr_practice_session ADD COLUMN competency_code varchar(100) DEFAULT NULL COMMENT ''训练开始时能力项快照'' AFTER growth_level',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_session_competency_code_stmt FROM @session_competency_code_ddl;
|
||||
EXECUTE aihr_session_competency_code_stmt;
|
||||
DEALLOCATE PREPARE aihr_session_competency_code_stmt;
|
||||
|
||||
SET @session_collaboration_positions_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'collaboration_positions'
|
||||
);
|
||||
SET @session_collaboration_positions_ddl = IF(@session_collaboration_positions_exists = 0,
|
||||
'ALTER TABLE aihr_practice_session ADD COLUMN collaboration_positions varchar(500) DEFAULT NULL COMMENT ''训练开始时协同岗位快照'' AFTER competency_code',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_session_collaboration_positions_stmt FROM @session_collaboration_positions_ddl;
|
||||
EXECUTE aihr_session_collaboration_positions_stmt;
|
||||
DEALLOCATE PREPARE aihr_session_collaboration_positions_stmt;
|
||||
|
||||
SET @session_curriculum_version_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'curriculum_version'
|
||||
);
|
||||
SET @session_curriculum_version_ddl = IF(@session_curriculum_version_exists = 0,
|
||||
'ALTER TABLE aihr_practice_session ADD COLUMN curriculum_version varchar(30) DEFAULT NULL COMMENT ''训练开始时成长目录版本快照'' AFTER collaboration_positions',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_session_curriculum_version_stmt FROM @session_curriculum_version_ddl;
|
||||
EXECUTE aihr_session_curriculum_version_stmt;
|
||||
DEALLOCATE PREPARE aihr_session_curriculum_version_stmt;
|
||||
|
||||
-- 主管只可在复盘时确认“可进入下一训练层级”。确认附着于原训练会话,
|
||||
-- 不改变人事职级、薪酬、上岗资格或历史训练快照。
|
||||
SET @growth_confirmation_level_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'growth_confirmation_level'
|
||||
);
|
||||
SET @growth_confirmation_level_ddl = IF(@growth_confirmation_level_exists = 0,
|
||||
'ALTER TABLE aihr_practice_session ADD COLUMN growth_confirmation_level varchar(20) DEFAULT NULL COMMENT ''主管确认可进入的训练层级'' AFTER curriculum_version',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_growth_confirmation_level_stmt FROM @growth_confirmation_level_ddl;
|
||||
EXECUTE aihr_growth_confirmation_level_stmt;
|
||||
DEALLOCATE PREPARE aihr_growth_confirmation_level_stmt;
|
||||
|
||||
SET @growth_confirmed_by_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'growth_confirmed_by'
|
||||
);
|
||||
SET @growth_confirmed_by_ddl = IF(@growth_confirmed_by_exists = 0,
|
||||
'ALTER TABLE aihr_practice_session ADD COLUMN growth_confirmed_by varchar(100) DEFAULT NULL COMMENT ''训练层级确认主管'' AFTER growth_confirmation_level',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_growth_confirmed_by_stmt FROM @growth_confirmed_by_ddl;
|
||||
EXECUTE aihr_growth_confirmed_by_stmt;
|
||||
DEALLOCATE PREPARE aihr_growth_confirmed_by_stmt;
|
||||
|
||||
SET @growth_confirmed_time_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'growth_confirmed_time'
|
||||
);
|
||||
SET @growth_confirmed_time_ddl = IF(@growth_confirmed_time_exists = 0,
|
||||
'ALTER TABLE aihr_practice_session ADD COLUMN growth_confirmed_time datetime DEFAULT NULL COMMENT ''训练层级确认时间'' AFTER growth_confirmed_by',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_growth_confirmed_time_stmt FROM @growth_confirmed_time_ddl;
|
||||
EXECUTE aihr_growth_confirmed_time_stmt;
|
||||
DEALLOCATE PREPARE aihr_growth_confirmed_time_stmt;
|
||||
|
||||
SET @scenario_curriculum_index_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_scenario'
|
||||
AND index_name = 'idx_aihr_practice_scenario_curriculum'
|
||||
);
|
||||
SET @scenario_curriculum_index_ddl = IF(@scenario_curriculum_index_exists = 0,
|
||||
'ALTER TABLE aihr_practice_scenario ADD KEY idx_aihr_practice_scenario_curriculum (tenant_id, position, growth_level, competency_code, review_status, enabled)',
|
||||
'SELECT 1');
|
||||
PREPARE aihr_scenario_curriculum_index_stmt FROM @scenario_curriculum_index_ddl;
|
||||
EXECUTE aihr_scenario_curriculum_index_stmt;
|
||||
DEALLOCATE PREPARE aihr_scenario_curriculum_index_stmt;
|
||||
|
||||
-- 派发请求的重放依赖稳定唯一键;最近内容过滤依赖其复合索引。
|
||||
-- 这两项由正式迁移维护,运行环境不能依赖服务启动时补 DDL。
|
||||
SET @assignment_request_index_count = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
|
||||
AND index_name = 'uk_aihr_assignment_request'
|
||||
);
|
||||
SET @assignment_request_index_non_unique = (
|
||||
SELECT COALESCE(MIN(non_unique), 1) FROM information_schema.statistics
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
|
||||
AND index_name = 'uk_aihr_assignment_request'
|
||||
);
|
||||
SET @assignment_request_index_columns = (
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
|
||||
AND index_name = 'uk_aihr_assignment_request'
|
||||
);
|
||||
SET @assignment_request_index_prefixes = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
|
||||
AND index_name = 'uk_aihr_assignment_request' AND sub_part IS NOT NULL
|
||||
);
|
||||
SET @assignment_request_index_ddl = IF(
|
||||
@assignment_request_index_count = 3
|
||||
AND @assignment_request_index_non_unique = 0
|
||||
AND @assignment_request_index_columns = 'tenant_id,request_key,ext_party_id'
|
||||
AND @assignment_request_index_prefixes = 0,
|
||||
'SELECT 1',
|
||||
IF(@assignment_request_index_count = 0,
|
||||
'ALTER TABLE aihr_practice_assignment ADD UNIQUE KEY uk_aihr_assignment_request (tenant_id, request_key, ext_party_id)',
|
||||
'ALTER TABLE aihr_practice_assignment DROP INDEX uk_aihr_assignment_request, ADD UNIQUE KEY uk_aihr_assignment_request (tenant_id, request_key, ext_party_id)'
|
||||
)
|
||||
);
|
||||
PREPARE aihr_assignment_request_index_stmt FROM @assignment_request_index_ddl;
|
||||
EXECUTE aihr_assignment_request_index_stmt;
|
||||
DEALLOCATE PREPARE aihr_assignment_request_index_stmt;
|
||||
|
||||
SET @assignment_recent_index_count = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
|
||||
AND index_name = 'idx_aihr_assignment_recent_content'
|
||||
);
|
||||
SET @assignment_recent_index_non_unique = (
|
||||
SELECT COALESCE(MIN(non_unique), 1) FROM information_schema.statistics
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
|
||||
AND index_name = 'idx_aihr_assignment_recent_content'
|
||||
);
|
||||
SET @assignment_recent_index_columns = (
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
|
||||
AND index_name = 'idx_aihr_assignment_recent_content'
|
||||
);
|
||||
SET @assignment_recent_index_prefixes = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment'
|
||||
AND index_name = 'idx_aihr_assignment_recent_content' AND sub_part IS NOT NULL
|
||||
);
|
||||
SET @assignment_recent_index_ddl = IF(
|
||||
@assignment_recent_index_count = 5
|
||||
AND @assignment_recent_index_non_unique = 1
|
||||
AND @assignment_recent_index_columns = 'tenant_id,ext_party_id,source,content_hash,create_time'
|
||||
AND @assignment_recent_index_prefixes = 0,
|
||||
'SELECT 1',
|
||||
IF(@assignment_recent_index_count = 0,
|
||||
'ALTER TABLE aihr_practice_assignment ADD KEY idx_aihr_assignment_recent_content (tenant_id, ext_party_id, source, content_hash, create_time)',
|
||||
'ALTER TABLE aihr_practice_assignment DROP INDEX idx_aihr_assignment_recent_content, ADD KEY idx_aihr_assignment_recent_content (tenant_id, ext_party_id, source, content_hash, create_time)'
|
||||
)
|
||||
);
|
||||
PREPARE aihr_assignment_recent_index_stmt FROM @assignment_recent_index_ddl;
|
||||
EXECUTE aihr_assignment_recent_index_stmt;
|
||||
DEALLOCATE PREPARE aihr_assignment_recent_index_stmt;
|
||||
Reference in New Issue
Block a user