feat(aihr): complete uni-app role workflows

This commit is contained in:
2026-07-09 10:42:20 +08:00
parent e4070c8b8c
commit 678543e8a5
73 changed files with 15696 additions and 134 deletions
@@ -47,8 +47,8 @@ public class UserActionListener implements SaTokenListener {
UserOnlineDTO dto = new UserOnlineDTO();
dto.setIpaddr(ip);
dto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
dto.setBrowser(userAgent.getBrowser().getName());
dto.setOs(userAgent.getOs().getName());
dto.setBrowser(userAgent == null ? "Unknown" : userAgent.getBrowser().getName());
dto.setOs(userAgent == null ? "Unknown" : userAgent.getOs().getName());
dto.setLoginTime(System.currentTimeMillis());
dto.setTokenId(tokenValue);
String username = (String) loginParameter.getExtra(LoginHelper.USER_NAME_KEY);
+6
View File
@@ -53,5 +53,11 @@
<artifactId>tika-parser-microsoft-module</artifactId>
<version>3.2.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1,11 +1,17 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.service.AihrCandidateMaterialService;
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialResponse;
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialReviewResponse;
import org.dromara.aihr.service.AihrCandidateMaterialService.ReviewRequest;
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.satoken.utils.LoginHelper;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -22,6 +28,8 @@ import java.util.List;
@RestController
public class AihrCandidateMaterialController {
private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrCandidateMaterialService materialService;
@PostMapping(value = "/api/aihr/mobile/candidate/materials", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -29,21 +37,40 @@ public class AihrCandidateMaterialController {
@RequestParam(value = "candidateId", required = false) String candidateId,
@RequestParam(value = "candidateName", required = false) String candidateName,
@RequestParam(value = "materialType", required = false) String materialType) {
return R.ok(materialService.upload(file, candidateId, candidateName, materialType));
String appUsername = currentAppUsername();
String boundCandidateId = appUsername.isBlank() ? candidateId : appUsername;
String boundCandidateName = appUsername.isBlank() ? candidateName : appCandidateName(appUsername);
return R.ok(materialService.upload(file, boundCandidateId, boundCandidateName, materialType));
}
@GetMapping("/api/aihr/mobile/candidate/materials")
public R<List<MaterialResponse>> list(@RequestParam(value = "candidateId", required = false) String candidateId) {
return R.ok(materialService.list(candidateId));
String appUsername = currentAppUsername();
return R.ok(materialService.list(appUsername.isBlank() ? candidateId : appUsername));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/api/aihr/hr/candidate/materials")
public R<List<MaterialReviewResponse>> reviewList(@RequestParam(value = "status", required = false) String status) {
return R.ok(materialService.reviewList(status));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/api/aihr/hr/candidate/materials/{id}/review")
public R<MaterialReviewResponse> review(@PathVariable Long id, @RequestBody ReviewRequest request) {
return R.ok(materialService.review(id, request));
}
private static String currentAppUsername() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
String username = loginUser.getUsername();
return username == null ? "" : username.trim();
}
private static String appCandidateName(String username) {
return username.matches("1[3-9]\\d{9}") ? "候选人" + username.substring(username.length() - 4) : username;
}
}
@@ -5,10 +5,12 @@ import org.dromara.aihr.domain.AihrCaseDto.CurateRequest;
import org.dromara.aihr.domain.AihrCaseDto.CurateResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.service.AihrCaseService;
import org.dromara.common.core.domain.R;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -17,6 +19,8 @@ import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 案例语音整理 API。
*/
@@ -41,4 +45,9 @@ public class AihrCaseController {
public R<CurateResponse> curate(@RequestBody CurateRequest request) {
return R.ok(caseService.curate(request));
}
@GetMapping("/records")
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false) Integer limit) {
return R.ok(caseService.records(limit));
}
}
@@ -10,6 +10,9 @@ import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.dromara.aihr.service.AihrInterviewService;
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.satoken.utils.LoginHelper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -31,7 +34,7 @@ public class AihrInterviewController {
@PostMapping("/start")
public R<StartResponse> start(@RequestBody StartRequest request) {
return R.ok(interviewService.start(request));
return R.ok(interviewService.start(bindAppCandidate(request)));
}
@PostMapping("/answer")
@@ -45,7 +48,32 @@ public class AihrInterviewController {
}
@GetMapping("/records")
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit) {
return R.ok(interviewService.records(limit));
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit,
@RequestParam(value = "candidateId", required = false) String candidateId) {
String appUsername = currentAppUsername();
return R.ok(interviewService.records(limit, appUsername.isBlank() ? candidateId : appUsername));
}
private static StartRequest bindAppCandidate(StartRequest request) {
String appUsername = currentAppUsername();
if (appUsername.isBlank()) {
return request;
}
String positionCode = request == null ? null : request.positionCode();
String mode = request == null ? null : request.mode();
return new StartRequest(appUsername, appCandidateName(appUsername), positionCode, mode);
}
private static String currentAppUsername() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
String username = loginUser.getUsername();
return username == null ? "" : username.trim();
}
private static String appCandidateName(String username) {
return username.matches("1[3-9]\\d{9}") ? "候选人" + username.substring(username.length() - 4) : username;
}
}
@@ -1,13 +1,16 @@
package org.dromara.aihr.controller;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse;
import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@@ -21,4 +24,13 @@ public class AihrOrgSyncController {
public R<SyncResponse> sync(@RequestBody(required = false) SyncRequest request) {
return R.ok(orgSyncService.sync(request));
}
@GetMapping("/snapshot")
public R<OrgSnapshotResponse> snapshot(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String projectCode,
@RequestParam(required = false) String positionLevel,
@RequestParam(required = false) String status,
@RequestParam(required = false, defaultValue = "200") Integer limit) {
return R.ok(orgSyncService.snapshot(keyword, projectCode, positionLevel, status, limit));
}
}
@@ -128,7 +128,7 @@ public class AihrPracticeController {
if (username.isBlank()) {
return request;
}
return new StartRequest(username, request.scenarioId(), request.mode());
return new StartRequest(username, request.scenarioId(), request.mode(), request.assignmentId());
}
private static String currentAppUsername() {
@@ -8,7 +8,7 @@ public final class AihrInterviewDto {
private AihrInterviewDto() {
}
public record StartRequest(String candidateId, String positionCode, String mode) {
public record StartRequest(String candidateId, String candidateName, String positionCode, String mode) {
}
public record StartResponse(String sessionId, String candidateId, String candidateName, String positionCode, List<QuestionResponse> questions, String source) {
@@ -30,4 +30,34 @@ public final class AihrOrgSyncDto {
List<String> warnings
) {
}
public record OrgSnapshotResponse(
int total,
int active,
int departed,
List<OrgProjectOption> projects,
List<String> levels,
List<OrgPersonRow> rows
) {
}
public record OrgProjectOption(
String projectCode,
String projectName
) {
}
public record OrgPersonRow(
Long id,
String projectCode,
String projectName,
String deptName,
String extPartyId,
String personName,
String positionName,
String positionLevel,
String employmentStatus,
String snapshotDate
) {
}
}
@@ -7,7 +7,7 @@ public final class AihrPracticeDto {
private AihrPracticeDto() {
}
public record StartRequest(String extPartyId, String scenarioId, String mode) {
public record StartRequest(String extPartyId, String scenarioId, String mode, Long assignmentId) {
}
public record StartResponse(
@@ -97,7 +97,7 @@ public final class AihrPracticeDto {
) {
}
public record FinishRequest(String sessionId) {
public record FinishRequest(String sessionId, Long assignmentId) {
}
public record FinishResponse(
@@ -113,7 +113,7 @@ public final class AihrPracticeDto {
public record DimensionResponse(String label, Object value, String note) {
}
public record RecordResponse(Long id, String sessionId, String time, String trainee, String scene, Integer score, String status, String summary) {
public record RecordResponse(Long id, String sessionId, String time, String trainee, String scene, Integer score, String status, String summary, String extPartyId) {
}
public record DialogueResponse(String role, String label, String text, String audioUrl, Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
@@ -83,7 +83,26 @@ public class AihrCaseService {
summary.stream().map(item -> item.label() + ":" + item.text()).findFirst().orElse(state.transcript())
);
saveCase(state, "已入库", record.title(), record.summary());
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,视频仍使用预渲染样片。");
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,已进入案例库。");
}
public List<RecordResponse> records(Integer limit) {
ensureCaseTable();
int safeLimit = limit == null ? 10 : Math.max(1, Math.min(20, limit));
return jdbcTemplate.query("""
SELECT title, type, status, update_time, owner, summary
FROM aihr_case_record
WHERE tenant_id = '000000'
ORDER BY update_time DESC, id DESC
LIMIT ?
""", (rs, rowNum) -> new RecordResponse(
firstNonBlank(rs.getString("title"), "现场案例"),
firstNonBlank(rs.getString("type"), "现场案例"),
firstNonBlank(rs.getString("status"), "已入库"),
rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT),
firstNonBlank(rs.getString("owner"), "培训组"),
firstNonBlank(rs.getString("summary"), "")
), safeLimit);
}
private void saveCase(CaseState state, String status, String title, String summary) {
@@ -44,7 +44,12 @@ public class AihrInterviewService {
private volatile boolean tableReady;
public StartResponse start(StartRequest request) {
CandidateProfile candidate = resolveCandidate(request == null ? null : request.candidateId(), null);
CandidateProfile candidate = resolveCandidate(
request == null ? null : request.candidateId(),
request == null ? null : request.candidateName(),
request == null ? null : request.positionCode(),
null
);
GeneratedQuestions generated = generateQuestions(candidate).orElseGet(() -> new GeneratedQuestions(candidate.questions(), "local-rubric"));
String sessionId = "iv-" + UUID.randomUUID();
sessions.put(sessionId, new InterviewSession(candidate, generated.questions(), new ConcurrentHashMap<>(), generated.source()));
@@ -70,7 +75,7 @@ public class AihrInterviewService {
InterviewSession session = sessions.computeIfAbsent(
sessionKey,
key -> {
CandidateProfile candidate = resolveCandidate(null, key);
CandidateProfile candidate = resolveCandidate(null, null, null, key);
return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric");
}
);
@@ -98,15 +103,27 @@ public class AihrInterviewService {
}
public List<RecordResponse> records(int limit) {
return records(limit, null);
}
public List<RecordResponse> records(int limit, String candidateId) {
ensureResultTable();
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100));
return jdbcTemplate.query("""
StringBuilder sql = new StringBuilder("""
SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary
FROM aihr_interview_result
WHERE tenant_id = '000000'
""");
List<Object> args = new ArrayList<>();
if (!isBlank(candidateId)) {
sql.append(" AND candidate_id = ?");
args.add(clean(candidateId));
}
sql.append("""
ORDER BY finished_time DESC, id DESC
LIMIT %d
""".formatted(safeLimit), this::mapRecord);
""".formatted(safeLimit));
return jdbcTemplate.query(sql.toString(), this::mapRecord, args.toArray());
}
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
@@ -330,13 +347,40 @@ public class AihrInterviewService {
return builder.toString();
}
private CandidateProfile resolveCandidate(String candidateId, String sessionId) {
private CandidateProfile resolveCandidate(String candidateId, String candidateName, String positionCode, String sessionId) {
if (!isBlank(candidateId) && candidates.containsKey(candidateId)) {
return candidates.get(candidateId);
}
if (!isBlank(candidateId)) {
return dynamicCandidate(candidateId, candidateName, positionCode);
}
return candidates.get("zhang-ming");
}
private CandidateProfile dynamicCandidate(String candidateId, String candidateName, String positionCode) {
CandidateProfile template = candidates.get("zhang-ming");
String cleanId = clean(candidateId);
String name = firstNonBlank(candidateName, cleanId.matches("1[3-9]\\d{9}") ? "候选人" + cleanId.substring(cleanId.length() - 4) : cleanId);
return new CandidateProfile(
cleanId,
name,
positionName(positionCode),
"待分配项目",
"候选人补充资料待审核",
"需通过结构化面试确认服务场景能力",
template.capabilities(),
template.questions()
);
}
private static String positionName(String positionCode) {
String code = clean(positionCode);
if (code.isBlank() || "customer-service".equals(code) || "life-consultant".equals(code)) {
return "生活顾问";
}
return code;
}
private static Map<String, CandidateProfile> buildCandidates() {
Map<String, CandidateProfile> result = new LinkedHashMap<>();
result.put("zhang-ming", new CandidateProfile(
@@ -167,7 +167,7 @@ public class AihrMobileSeedService {
new PrimaryTaskResponse(
"重点训练",
"投诉接待三角色对练",
"3分钟语音练习 · 完成后自动评分",
"3分钟情景练习 · 完成后自动评分",
"1/1",
100,
"开始训练",
@@ -4,6 +4,9 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgProjectOption;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse;
import org.springframework.beans.factory.annotation.Value;
@@ -23,9 +26,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDate;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
@@ -65,7 +66,7 @@ public class AihrOrgSyncService {
SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null) : request;
String baseUrl = normalizeBaseUrl(configuredBaseUrl);
if (baseUrl.isBlank()) {
throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /open/v1 前缀");
throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀");
}
requireSnapshotTable();
@@ -112,6 +113,39 @@ public class AihrOrgSyncService {
);
}
public OrgSnapshotResponse snapshot(String keyword, String projectCode, String positionLevel, String status, Integer limit) {
requireSnapshotTable();
int safeLimit = clamp(limit, 200, 1, MAX_PAGE_SIZE);
List<Object> args = new ArrayList<>();
String where = orgSnapshotWhere(keyword, projectCode, positionLevel, status, args);
int total = countOrgRows("select count(*) from aihr_org_snapshot" + where, args);
int active = countOrgRows("select count(*) from aihr_org_snapshot" + where + " and employment_status = 'active'", args);
int departed = countOrgRows("select count(*) from aihr_org_snapshot" + where + " and employment_status = 'departed'", args);
List<Object> rowArgs = new ArrayList<>(args);
rowArgs.add(safeLimit);
List<OrgPersonRow> rows = 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 + """
order by project_name, dept_name, position_level, person_name
limit ?
""", (rs, rowNum) -> new OrgPersonRow(
rs.getLong("id"),
rs.getString("project_code"),
rs.getString("project_name"),
rs.getString("dept_name"),
rs.getString("ext_party_id"),
rs.getString("person_name"),
rs.getString("position_name"),
rs.getString("position_level"),
rs.getString("employment_status"),
rs.getString("snapshot_date")
), rowArgs.toArray());
return new OrgSnapshotResponse(total, active, departed, orgProjects(), orgLevels(), rows);
}
private List<JsonNode> fetchOptional(String baseUrl, String token, SyncRequest req, String resourceType,
int pageSize, int maxPages, List<String> warnings) {
try {
@@ -131,7 +165,7 @@ public class AihrOrgSyncService {
Map<String, String> query = new TreeMap<>();
query.put("resource_type", resourceType);
query.put("page", String.valueOf(page));
query.put("page_size", String.valueOf(pageSize));
query.put("limit", String.valueOf(pageSize));
putIfNotBlank(query, "group_id", req.groupId());
putIfNotBlank(query, "company_id", req.companyId());
putIfNotBlank(query, "department_id", req.departmentId());
@@ -190,16 +224,16 @@ public class AihrOrgSyncService {
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.timeout(HTTP_TIMEOUT)
.header("Accept", "application/json");
String requestBody = body == null ? "" : body;
if (!clean(token).isBlank()) {
builder.header("Authorization", bearer(token));
sign(builder, method, uri.getRawPath(), queryString, requestBody);
}
String requestBody = body == null ? "" : body;
if ("POST".equals(method)) {
builder.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(requestBody));
} else {
builder.GET();
}
sign(builder, method, uri.getRawPath(), queryString, requestBody);
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(HTTP_TIMEOUT)
.build()
@@ -218,20 +252,21 @@ public class AihrOrgSyncService {
}
private void sign(HttpRequest.Builder builder, String method, String path, String queryString, String body) throws Exception {
String signingSecret = clean(configuredSigningSecret);
String signingSecret = firstNonBlank(configuredSigningSecret, configuredClientSecret);
String clientId = clean(configuredClientId);
if (signingSecret.isBlank() || clientId.isBlank()) {
return;
}
String timestamp = Instant.now().toString();
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString();
String plain = method + "\n" + path + "\n" + queryString + "\n" + sha256(body) + "\n" + timestamp + "\n" + nonce;
String plain = method + "\n" + path + "\n" + queryString + "\n" + sha256(signatureBody(body)) + "\n" + timestamp + "\n" + nonce;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
builder.header("X-Client-Id", clientId)
.header("X-Client-Secret", clean(configuredClientSecret))
.header("X-Timestamp", timestamp)
.header("X-Nonce", nonce)
.header("X-Signature", Base64.getEncoder().encodeToString(mac.doFinal(plain.getBytes(StandardCharsets.UTF_8))));
.header("X-Signature", hex(mac.doFinal(plain.getBytes(StandardCharsets.UTF_8))));
}
private void saveRows(List<OrgRow> rows, boolean replaceExisting) {
@@ -325,6 +360,69 @@ public class AihrOrgSyncService {
return map;
}
private String orgSnapshotWhere(String keyword, String projectCode, String positionLevel, String status, List<Object> args) {
StringBuilder where = new StringBuilder(" where tenant_id = ?");
args.add(TENANT_ID);
String keywordValue = clean(keyword);
if (!keywordValue.isBlank()) {
String like = "%" + keywordValue + "%";
where.append("""
and (person_name like ? or ext_party_id like ? or position_name like ?
or dept_name like ? or project_name like ?)
""");
args.add(like);
args.add(like);
args.add(like);
args.add(like);
args.add(like);
}
String project = clean(projectCode);
if (!project.isBlank()) {
where.append(" and project_code = ?");
args.add(project);
}
String levelValue = clean(positionLevel);
if (!levelValue.isBlank()) {
where.append(" and position_level = ?");
args.add(levelValue);
}
String statusValue = clean(status);
if (!statusValue.isBlank()) {
where.append(" and employment_status = ?");
args.add(statusValue);
}
return where.toString();
}
private int countOrgRows(String sql, List<Object> args) {
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, args.toArray());
return count == null ? 0 : count;
}
private List<OrgProjectOption> orgProjects() {
return jdbcTemplate.query("""
select project_code, max(project_name) project_name
from aihr_org_snapshot
where tenant_id = ?
group by project_code
order by project_name
limit 200
""", (rs, rowNum) -> new OrgProjectOption(
rs.getString("project_code"),
rs.getString("project_name")
), TENANT_ID);
}
private List<String> orgLevels() {
return jdbcTemplate.query("""
select distinct position_level
from aihr_org_snapshot
where tenant_id = ? and position_level is not null and position_level <> ''
order by position_level
limit 20
""", (rs, rowNum) -> rs.getString("position_level"), TENANT_ID);
}
private void requireSnapshotTable() {
try {
Integer count = jdbcTemplate.queryForObject("""
@@ -367,7 +465,7 @@ public class AihrOrgSyncService {
private static String text(JsonNode node, String... fields) {
for (String field : fields) {
JsonNode child = node.path(field);
if (child.isValueNode()) {
if (child.isValueNode() && !child.isNull()) {
String value = clean(child.asText());
if (!value.isBlank()) {
return value;
@@ -417,6 +515,14 @@ public class AihrOrgSyncService {
private static String sha256(String value) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
return hex(bytes);
}
private static String signatureBody(String value) {
return clean(value).isBlank() ? "{}" : value;
}
private static String hex(byte[] bytes) {
StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
hex.append(String.format("%02x", b));
@@ -255,7 +255,7 @@ public class AihrPracticeSeedService {
String trainee = resolveTrainee(request, scenario);
List<String> customerLines = new ArrayList<>();
customerLines.add(firstRound.customer());
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), isMobile(request), LocalDateTime.now(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>()));
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(), isMobile(request), LocalDateTime.now(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>()));
return new StartResponse(
sessionId,
scenario.id(),
@@ -411,8 +411,10 @@ public class AihrPracticeSeedService {
ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId);
String trainee = activeSession == null ? scenario.trainee() : activeSession.trainee();
PracticeResult result = evaluate(activeSession, scenario);
RecordResponse record = new RecordResponse(null, sessionId, formatNow(), trainee, scenario.name(), result.total(), "待复盘", result.summary());
String extPartyId = activeSession == null ? trainee : activeSession.extPartyId();
RecordResponse record = new RecordResponse(null, sessionId, formatNow(), trainee, scenario.name(), result.total(), "待复盘", result.summary(), extPartyId);
savePracticeRecord(sessionId, activeSession, scenario, record, result);
markAssignmentCompleted(request == null ? null : request.assignmentId(), activeSession);
return new FinishResponse(
result.total(),
result.scores(),
@@ -459,7 +461,7 @@ public class AihrPracticeSeedService {
int safeLimit = normalizeLimit(limit);
if (isBlank(extPartyId)) {
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
ORDER BY finished_time DESC, id DESC
@@ -467,7 +469,7 @@ public class AihrPracticeSeedService {
""", this::mapRecord, TENANT_ID, safeLimit);
}
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND ext_party_id = ?
ORDER BY finished_time DESC, id DESC
@@ -478,10 +480,10 @@ public class AihrPracticeSeedService {
public List<RecordResponse> pendingReviewRecords(int limit) {
ensurePracticeTable();
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'
ORDER BY finished_time DESC, id DESC
ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC
LIMIT ?
""", this::mapRecord, TENANT_ID, normalizeLimit(limit));
}
@@ -729,7 +731,9 @@ public class AihrPracticeSeedService {
), TENANT_ID, party, party);
CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 82, 0, 82, 82, 82, 0) : rows.get(0);
int trainingMinutes = snapshot.completed() * 8;
int contribution = Math.min(100, snapshot.incentivePoints() + snapshot.completed() * 8 + Math.max(0, 100 - snapshot.pendingReview() * 10) / 5);
int contribution = snapshot.completed() == 0 && snapshot.incentivePoints() == 0
? 0
: Math.min(100, snapshot.incentivePoints() + snapshot.completed() * 8 + Math.max(0, 100 - snapshot.pendingReview() * 10) / 5);
int assessmentScore = snapshot.score();
String aiLevel = aiLevel(snapshot.completed(), assessmentScore);
return new CompetencyResponse(
@@ -827,7 +831,11 @@ public class AihrPracticeSeedService {
WHERE tenant_id = ?
AND (? = '' OR ext_party_id = ?)
AND (status = '待训练' OR (source = 'daily' AND DATE(create_time) = CURRENT_DATE()))
ORDER BY create_time DESC, id DESC
ORDER BY
CASE WHEN source = 'daily' THEN 1 ELSE 0 END,
CASE WHEN source = 'daily' THEN id ELSE NULL END ASC,
create_time DESC,
id DESC
LIMIT ?
""", this::mapAssignment, TENANT_ID, party, party, normalizeLimit(limit));
}
@@ -844,11 +852,7 @@ public class AihrPracticeSeedService {
if (existingToday >= 3) {
return;
}
List<DailyDrillSeed> drills = List.of(
new DailyDrillSeed("complaint-water", "每日三题 1:业主投诉车库积水,第一句话先说什么?", "先承接情绪,再确认点位、责任人和首次反馈时间。"),
new DailyDrillSeed("fee-collection", "每日三题 2:业主拒缴物业费并质疑服务,如何拆分问题?", "把服务整改和费用沟通分开,说明复核、整改跟进和缴费节点。"),
new DailyDrillSeed("repair-leak", "每日三题 3:报修跟进超时,如何给下一步承诺?", "说明已核实的信息、责任人、到场或反馈时间,并主动同步变化。")
);
List<DailyDrillSeed> drills = dailyDrills();
int start = Math.max(0, existingToday);
for (int i = start; i < drills.size(); i++) {
DailyDrillSeed drill = drills.get(i);
@@ -864,7 +868,7 @@ public class AihrPracticeSeedService {
extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId,
scenario.id(),
scenario.name(),
drill.question() + " 参考答案:" + drill.referenceAnswer(),
drill.question(),
Timestamp.valueOf(now),
Timestamp.valueOf(now)
);
@@ -880,15 +884,48 @@ public class AihrPracticeSeedService {
String traineeName = firstNonBlank(request == null ? null : request.traineeName(), extPartyId);
String source = firstNonBlank(request == null ? null : request.source(), "manual");
String reason = firstNonBlank(request == null ? null : request.reason(), "主管派发专项训练");
Timestamp createdAt = Timestamp.valueOf(now);
jdbcTemplate.update("""
INSERT INTO aihr_practice_assignment
(tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?)
""", TENANT_ID, extPartyId, traineeName, scenario.id(), firstNonBlank(request == null ? null : request.scenarioName(), scenario.name()), source, reason, Timestamp.valueOf(now), Timestamp.valueOf(now));
List<PracticeAssignmentResponse> rows = assignments(extPartyId, 1);
""", TENANT_ID, extPartyId, traineeName, scenario.id(), firstNonBlank(request == null ? null : request.scenarioName(), scenario.name()), source, reason, createdAt, createdAt);
List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, feedback, correct
FROM aihr_practice_assignment
WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ?
ORDER BY id DESC
LIMIT 1
""", this::mapAssignment, TENANT_ID, extPartyId, scenario.id(), source, reason);
return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null) : rows.get(0);
}
private void markAssignmentCompleted(Long requestAssignmentId, ActiveSession activeSession) {
Long assignmentId = requestAssignmentId == null && activeSession != null ? activeSession.assignmentId() : requestAssignmentId;
if (assignmentId == null || activeSession == null || isBlank(activeSession.extPartyId())) {
return;
}
ensureAssignmentTable();
jdbcTemplate.update("""
UPDATE aihr_practice_assignment
SET status = '已完成',
finished_time = ?,
update_time = ?
WHERE tenant_id = ?
AND id = ?
AND ext_party_id = ?
AND source <> 'daily'
AND status = '待训练'
""",
Timestamp.valueOf(LocalDateTime.now()),
Timestamp.valueOf(LocalDateTime.now()),
TENANT_ID,
assignmentId,
activeSession.extPartyId()
);
}
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) {
return answerDailyDrill(id, request, "");
}
@@ -900,7 +937,7 @@ public class AihrPracticeSeedService {
}
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, reason, source, status
SELECT id, reason, source, status, scenario_id
FROM aihr_practice_assignment
WHERE tenant_id = ? AND id = ? AND source = 'daily'
AND (? = '' OR ext_party_id = ?)
@@ -909,7 +946,7 @@ public class AihrPracticeSeedService {
if (rows.isEmpty()) {
return null;
}
DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"));
DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id"));
String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), ""));
DailyDrillScore score = scoreDailyDrill(answer, parts.referenceAnswer());
LocalDateTime now = LocalDateTime.now();
@@ -1233,7 +1270,8 @@ public class AihrPracticeSeedService {
rs.getString("scenario_name"),
rs.getInt("total_score"),
rs.getString("status"),
rs.getString("summary")
rs.getString("summary"),
rs.getString("ext_party_id")
);
}
@@ -1241,7 +1279,8 @@ public class AihrPracticeSeedService {
Timestamp createTime = rs.getTimestamp("create_time");
String source = rs.getString("source");
String reason = rs.getString("reason");
DailyDrillParts daily = dailyDrillParts(reason);
String status = rs.getString("status");
DailyDrillParts daily = dailyDrillParts(reason, rs.getString("scenario_id"));
Object correctValue = rs.getObject("correct");
return new PracticeAssignmentResponse(
rs.getLong("id"),
@@ -1250,23 +1289,36 @@ public class AihrPracticeSeedService {
rs.getString("scenario_id"),
rs.getString("scenario_name"),
source,
reason,
rs.getString("status"),
"daily".equals(source) ? daily.question() : reason,
status,
createTime == null ? "" : createTime.toLocalDateTime().format(TIME_FORMATTER),
"daily".equals(source) ? daily.question() : null,
"daily".equals(source) ? daily.referenceAnswer() : null,
"daily".equals(source) && "已完成".equals(status) ? daily.referenceAnswer() : null,
rs.getString("answer_text"),
rs.getString("feedback"),
"daily".equals(source) ? stripReferenceAnswer(rs.getString("feedback")) : rs.getString("feedback"),
correctValue == null ? null : rs.getBoolean("correct")
);
}
private DailyDrillParts dailyDrillParts(String reason) {
private List<DailyDrillSeed> dailyDrills() {
return List.of(
new DailyDrillSeed("complaint-water", "每日三题 1:业主投诉车库积水,第一句话先说什么?", "先承接情绪,再确认点位、责任人和首次反馈时间。"),
new DailyDrillSeed("fee-collection", "每日三题 2:业主拒缴物业费并质疑服务,如何拆分问题?", "把服务整改和费用沟通分开,说明复核、整改跟进和缴费节点。"),
new DailyDrillSeed("repair-leak", "每日三题 3:报修跟进超时,如何给下一步承诺?", "说明已核实的信息、责任人、到场或反馈时间,并主动同步变化。")
);
}
private DailyDrillParts dailyDrillParts(String reason, String scenarioId) {
String safeReason = firstNonBlank(reason, "");
String marker = "参考答案:";
int markerIndex = safeReason.indexOf(marker);
if (markerIndex < 0) {
return new DailyDrillParts(safeReason, "");
String reference = dailyDrills().stream()
.filter(drill -> drill.question().equals(safeReason) || drill.scenarioId().equals(scenarioId))
.map(DailyDrillSeed::referenceAnswer)
.findFirst()
.orElse("");
return new DailyDrillParts(safeReason, reference);
}
String question = safeReason.substring(0, markerIndex).trim();
String reference = safeReason.substring(markerIndex + marker.length()).trim();
@@ -1295,11 +1347,16 @@ public class AihrPracticeSeedService {
boolean correct = score >= 70;
String feedback = (correct ? "回答方向正确。" : "还缺关键动作。")
+ (hit.isEmpty() ? "" : "已命中:" + String.join("、", hit) + "。")
+ (missing.isEmpty() ? "" : "建议补充:" + String.join("、", missing) + "。")
+ (isBlank(reference) ? "" : "参考答案:" + reference);
+ (missing.isEmpty() ? "" : "建议补充:" + String.join("、", missing) + "。");
return new DailyDrillScore(correct, score, feedback);
}
private String stripReferenceAnswer(String feedback) {
String text = firstNonBlank(feedback, "");
int index = text.indexOf("参考答案:");
return index < 0 ? text : text.substring(0, index).trim();
}
private List<String> dailyDrillKeywords(String reference) {
List<String> keywords = List.of("承接情绪", "确认", "点位", "责任人", "反馈时间", "服务整改", "费用沟通", "节点", "核实信息", "到场", "同步变化");
List<String> result = new ArrayList<>();
@@ -2507,7 +2564,7 @@ public class AihrPracticeSeedService {
private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
}
private record ActiveSession(String scenarioId, String trainee, String extPartyId, boolean mobile, LocalDateTime startedAt,
private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt,
List<String> traineeReplies, List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
List<String> customerLines, List<TurnEvidence> turnEvidence) {
}
@@ -2216,7 +2216,7 @@ public class AihrSopSeedService {
}
SearchDigest digest = searchDigest(queryText, hits);
if (digest == null) {
return noEvidenceResponse(queryText, responseCategory, extraCautions);
return rawHitResponse(responseCategory, queryText, List.copyOf(docs.values()), snippets, extraCautions);
}
if (isNoEvidenceAnswer(digest.answer())) {
return noEvidenceResponse(queryText, responseCategory, appendCautions(digest.cautions(), extraCautions));
@@ -2236,6 +2236,25 @@ public class AihrSopSeedService {
);
}
static SearchResponse rawHitResponse(String category, String queryText, List<DocResponse> docs, List<SnippetResponse> snippets, List<String> extraCautions) {
if (snippets == null || snippets.isEmpty()) {
return noEvidenceResponse(queryText, category, extraCautions);
}
return new SearchResponse(
queryText,
firstNonBlank(category, "sop"),
"已找到相关资料:" + snippetPreview(snippets.get(0).text()),
"引用 " + (docs == null ? 0 : docs.size()) + " 份文档 / " + snippets.size() + " 个片段",
docs == null ? List.of() : docs,
snippets,
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
records(),
List.of(),
appendCautions(List.of("未生成结构化概要,已返回命中原文片段。"), extraCautions),
null
);
}
/**
* RAG 生成环节:把命中片段交给 chat 模型压成「直接回答+要点+注意事项」结构化概要。
* 模型未配置、超时或解析失败返回 null,由调用方回退片段原文拼接——检索永不因概要失败而报错。
@@ -2509,6 +2528,13 @@ public class AihrSopSeedService {
return score <= 0 ? "片段" : String.format("%.2f", score);
}
private static String snippetPreview(String text) {
String cleaned = Optional.ofNullable(text).orElse("")
.replaceAll("\\s+", " ")
.trim();
return truncate(firstNonBlank(cleaned, "请参考下方命中片段。"), 180);
}
private static boolean isNoEvidenceAnswer(String answer) {
return NO_CLEAR_SOP_EVIDENCE.equals(Optional.ofNullable(answer).orElse("").trim());
}
@@ -0,0 +1,29 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrSopDto;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class AihrSopSeedServiceTest {
@Test
@Tag("dev")
public void rawHitResponseKeepsSnippetsWhenDigestUnavailable() {
List<AihrSopDto.DocResponse> docs = List.of(new AihrSopDto.DocResponse("印章管理办法.pdf", "制度文件", "命中 3.00", "pill-success"));
List<AihrSopDto.SnippetResponse> snippets = List.of(new AihrSopDto.SnippetResponse("第 1 段:印章管理办法.pdf", "印章使用前必须取得审批,并保留用印记录。", 100310L));
AihrSopDto.SearchResponse response = AihrSopSeedService.rawHitResponse("sop", "印章怎么使用?", docs, snippets, List.of());
assertFalse(response.docs().isEmpty());
assertEquals(100310L, response.snippets().get(0).fragmentId());
assertTrue(response.answer().contains("印章使用前必须取得审批"));
assertTrue(response.reference().contains("1 份文档 / 1 个片段"));
assertTrue(response.cautions().contains("未生成结构化概要,已返回命中原文片段。"));
}
}
@@ -75,9 +75,9 @@ public class SysLogininforServiceImpl implements ISysLogininforService {
// 打印信息到日志
log.info(s.toString(), logininforEvent.getArgs());
// 获取客户端操作系统
String os = userAgent.getOs().getName();
String os = userAgent == null ? "Unknown" : userAgent.getOs().getName();
// 获取客户端浏览器
String browser = userAgent.getBrowser().getName();
String browser = userAgent == null ? "Unknown" : userAgent.getBrowser().getName();
// 封装对象
SysLogininforBo logininfor = new SysLogininforBo();
logininfor.setTenantId(logininforEvent.getTenantId());