feat(aihr): finalize practice growth and access boundaries

This commit is contained in:
2026-07-25 00:42:19 +08:00
parent a90f40e4d8
commit c9d2012ec1
87 changed files with 6804 additions and 2538 deletions
@@ -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);
}
@@ -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,
@@ -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);
}
}
@@ -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(),
@@ -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);
}
@@ -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();
}
@@ -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) {
}
@@ -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();
@@ -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();
@@ -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);
}
}
@@ -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);
}
}
@@ -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 '主键',
@@ -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 '主键',
@@ -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 '主键',
@@ -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) {
@@ -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 '主键',
@@ -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);
}
@@ -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(*)
@@ -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
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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 '主键',
@@ -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,
@@ -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,
@@ -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("""
@@ -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",
@@ -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",
@@ -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;
@@ -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(
@@ -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(
@@ -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"));
@@ -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
@@ -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());
}
@@ -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);
}
}
}
@@ -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'"));
}
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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
+53 -1
View File
@@ -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;