feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
@@ -0,0 +1,76 @@
package org.dromara.aihr.community;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerRequest;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest;
import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest;
import org.dromara.aihr.community.AihrCommunityDto.IncentiveOverviewResponse;
import org.dromara.aihr.community.AihrCommunityDto.PointsResponse;
import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
import java.util.List;
@RestController
@RequiredArgsConstructor
@SaCheckLogin
@RequestMapping("/api/aihr/community")
public class AihrCommunityController {
private final AihrCommunityService communityService;
private final AihrIncentiveService incentiveService;
@GetMapping("/questions")
public R<List<QuestionResponse>> questions(@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "mine", defaultValue = "false") boolean mine) {
return R.ok(communityService.publicQuestions(status, mine));
}
@PostMapping("/questions")
public R<QuestionResponse> createQuestion(@RequestBody CreateQuestionRequest request) {
return R.ok(communityService.createQuestion(request, false));
}
@GetMapping("/questions/{questionId}")
public R<QuestionResponse> question(@PathVariable Long questionId) {
return R.ok(communityService.publicQuestion(questionId));
}
@PostMapping("/questions/{questionId}/answers")
public R<AnswerResponse> answer(@PathVariable Long questionId, @RequestBody CreateAnswerRequest request) {
return R.ok(communityService.createAnswer(questionId, request));
}
@PostMapping("/questions/{questionId}/best-answer")
public R<BestAnswerResponse> chooseBest(@PathVariable Long questionId,
@RequestBody BestAnswerRequest request) {
if (request == null || request.answerId() == null) {
return R.fail("请选择最佳回答");
}
return R.ok(communityService.chooseBest(questionId, request.answerId(), false));
}
@GetMapping("/points/me")
public R<PointsResponse> myPoints() {
return R.ok(communityService.myPoints());
}
@GetMapping("/incentives")
public R<IncentiveOverviewResponse> incentives() {
AihrKnowledgePrincipal principal = communityService.currentPrincipal();
PointsResponse points = communityService.myPoints();
return R.ok(new IncentiveOverviewResponse(
points.pointsBalance(), points.studyCreditBalance(), incentiveService.list(principal, false)));
}
}
@@ -0,0 +1,87 @@
package org.dromara.aihr.community;
import java.util.List;
public final class AihrCommunityDto {
private AihrCommunityDto() {
}
public record CreateQuestionRequest(String title,
String content,
String category,
Integer rewardPoints,
String deadline,
String ruleCode) {
}
public record CreateAnswerRequest(String content) {
}
public record ReviewRequest(String decision, String note) {
}
public record BestAnswerRequest(Long answerId) {
}
public record AnswerResponse(Long id,
Long questionId,
String authorName,
String content,
String status,
boolean best,
boolean mine,
String createdAt) {
}
public record QuestionResponse(Long id,
String type,
String category,
String title,
String content,
String authorName,
int rewardPoints,
String rewardAccountType,
String ruleCode,
String deadline,
String status,
Long bestAnswerId,
int answerCount,
boolean mine,
String createdAt,
List<AnswerResponse> answers) {
}
public record BestAnswerResponse(Long questionId,
Long answerId,
int awardedPoints,
boolean newlyAwarded,
int pointsBalance,
String accountType,
int awardedValue,
int studyCreditBalance) {
}
/**
* {@code balance} is kept as a compatibility alias for the POINTS balance.
*/
public record PointsResponse(int balance,
int pointsBalance,
int studyCreditBalance,
List<PointsEntryResponse> entries) {
}
public record PointsEntryResponse(Long id,
String type,
Long businessId,
String accountType,
int points,
String reason,
String createdAt) {
}
public record IncentiveOverviewResponse(int pointsBalance,
int studyCreditBalance,
List<AihrIncentiveService.IncentiveRuleResponse> rules) {
}
}
@@ -0,0 +1,43 @@
package org.dromara.aihr.community;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import java.util.Locale;
import java.util.Set;
public final class AihrCommunityPolicy {
private static final Set<String> MODERATOR_ROLES = Set.of(
"supervisor", "manager", "project_manager", "admin", "superadmin", "hr_operator"
);
private AihrCommunityPolicy() {
}
public static int rewardPoints(Integer requested, AihrCommunityProperties properties) {
int value = requested == null ? properties.getDefaultRewardPoints() : requested;
if (value < 0) {
throw new IllegalArgumentException("奖励积分不能小于0");
}
int maximum = Math.max(0, properties.getMaxRewardPoints());
return Math.min(value, maximum);
}
public static boolean canModerate(AihrKnowledgePrincipal principal) {
if (principal == null) {
return false;
}
return principal.roles().stream()
.map(role -> role == null ? "" : role.trim().toLowerCase(Locale.ROOT))
.anyMatch(MODERATOR_ROLES::contains);
}
public static String moderatedStatus(String decision) {
String normalized = decision == null ? "" : decision.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "approve", "approved", "publish", "published", "通过", "发布" -> "PUBLISHED";
case "reject", "rejected", "驳回", "拒绝" -> "REJECTED";
default -> throw new IllegalArgumentException("审核结论只能为 approve 或 reject");
};
}
}
@@ -0,0 +1,28 @@
package org.dromara.aihr.community;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "aihr.community")
public class AihrCommunityProperties {
private int defaultRewardPoints = 10;
private int maxRewardPoints = 500;
public int getDefaultRewardPoints() {
return defaultRewardPoints;
}
public void setDefaultRewardPoints(int defaultRewardPoints) {
this.defaultRewardPoints = defaultRewardPoints;
}
public int getMaxRewardPoints() {
return maxRewardPoints;
}
public void setMaxRewardPoints(int maxRewardPoints) {
this.maxRewardPoints = maxRewardPoints;
}
}
@@ -0,0 +1,591 @@
package org.dromara.aihr.community;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest;
import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest;
import org.dromara.aihr.community.AihrCommunityDto.PointsEntryResponse;
import org.dromara.aihr.community.AihrCommunityDto.PointsResponse;
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.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Service
@RequiredArgsConstructor
public class AihrCommunityService {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgePrincipalResolver principalResolver;
private final AihrCommunityProperties properties;
private final AihrIncentiveService incentiveService;
private volatile boolean tablesReady;
public List<QuestionResponse> publicQuestions(String status, boolean mine) {
AihrKnowledgePrincipal principal = principalResolver.current();
ensureTables();
String normalized = publicStatus(status);
List<QuestionRow> rows = jdbcTemplate.query("""
select q.id, q.author_user_id, q.author_name, q.question_type, q.category, q.title, q.content,
q.reward_points, q.reward_rule_code,
coalesce((select r.account_type from aihr_incentive_rule r
where r.tenant_id = q.tenant_id and r.rule_code = q.reward_rule_code
order by r.enabled desc, r.id desc limit 1), 'POINTS') reward_account_type,
q.deadline, q.question_status,
q.best_answer_id, q.create_time,
(select count(*) from aihr_community_answer a
where a.tenant_id = q.tenant_id and a.question_id = q.id and a.answer_status = 'PUBLISHED') answer_count
from aihr_community_question q
where q.tenant_id = ?
and ((? = 1 and q.author_user_id = ?) or (? = 0 and q.question_status in ('PUBLISHED', 'CLOSED')))
and (? = '' or q.question_status = ?)
order by q.id desc
limit 100
""", this::questionRow, principal.tenantId(), mine ? 1 : 0, principal.userId(), mine ? 1 : 0,
normalized, normalized);
return rows.stream().map(row -> response(principal, row, false)).toList();
}
public List<QuestionResponse> adminQuestions(String status) {
AihrKnowledgePrincipal principal = requireModerator();
ensureTables();
String normalized = adminStatus(status);
List<QuestionRow> rows = jdbcTemplate.query("""
select q.id, q.author_user_id, q.author_name, q.question_type, q.category, q.title, q.content,
q.reward_points, q.reward_rule_code,
coalesce((select r.account_type from aihr_incentive_rule r
where r.tenant_id = q.tenant_id and r.rule_code = q.reward_rule_code
order by r.enabled desc, r.id desc limit 1), 'POINTS') reward_account_type,
q.deadline, q.question_status,
q.best_answer_id, q.create_time,
(select count(*) from aihr_community_answer a
where a.tenant_id = q.tenant_id and a.question_id = q.id and a.answer_status = 'PUBLISHED') answer_count
from aihr_community_question q
where q.tenant_id = ? and (? = '' or q.question_status = ?)
order by q.id desc
limit 100
""", this::questionRow, principal.tenantId(), normalized, normalized);
return rows.stream().map(row -> response(principal, row, true)).toList();
}
public QuestionResponse createQuestion(CreateQuestionRequest request, boolean adminBounty) {
AihrKnowledgePrincipal principal = adminBounty ? requireModerator() : principalResolver.current();
ensureTables();
if (request == null) {
throw new IllegalArgumentException("问题参数不能为空");
}
String title = required(request.title(), "问题标题", 160);
String content = required(request.content(), "问题内容", 4000);
String category = normalizeCategory(request.category());
String ruleCode = normalizeRuleCode(request.ruleCode());
AihrIncentiveService.IncentiveRuleResponse rule = incentiveService.activeRule(principal, ruleCode);
Integer requestedPoints = request.rewardPoints() == null && rule != null
? rule.value() : request.rewardPoints();
int points = AihrCommunityPolicy.rewardPoints(requestedPoints, properties);
LocalDateTime deadline = parseDeadline(request.deadline(), adminBounty);
String type = adminBounty ? "BOUNTY" : "OPEN";
String status = adminBounty ? "PUBLISHED" : "PENDING";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_community_question
(tenant_id, author_user_id, author_ext_party_id, author_name, title, content,
question_type, category, reward_rule_code, reward_points, deadline, question_status, create_time, update_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, principal.tenantId());
statement.setLong(2, principal.userId());
statement.setString(3, clean(principal.extPartyId()));
statement.setString(4, displayName(principal));
statement.setString(5, title);
statement.setString(6, content);
statement.setString(7, type);
statement.setString(8, category);
statement.setString(9, ruleCode);
statement.setInt(10, points);
statement.setTimestamp(11, deadline == null ? null : Timestamp.valueOf(deadline));
statement.setString(12, status);
return statement;
}, keyHolder);
Number key = keyHolder.getKey();
return response(principal, findQuestion(principal, key == null ? 0L : key.longValue()), adminBounty);
}
public AnswerResponse createAnswer(Long questionId, CreateAnswerRequest request) {
AihrKnowledgePrincipal principal = principalResolver.current();
ensureTables();
QuestionRow question = findQuestion(principal, questionId);
if (!"PUBLISHED".equals(question.status())) {
throw new IllegalArgumentException("当前问题暂不可回答");
}
if (question.deadline() != null && question.deadline().isBefore(LocalDateTime.now())) {
throw new IllegalArgumentException("该问题已过截止时间");
}
if (question.authorUserId().equals(principal.userId())) {
throw new IllegalArgumentException("不能回答自己发布的问题");
}
String content = required(request == null ? null : request.content(), "回答内容", 5000);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_community_answer
(tenant_id, question_id, author_user_id, author_ext_party_id, author_name, content,
answer_status, is_best, create_time, update_time)
values (?, ?, ?, ?, ?, ?, 'PENDING', 0, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, principal.tenantId());
statement.setLong(2, questionId);
statement.setLong(3, principal.userId());
statement.setString(4, clean(principal.extPartyId()));
statement.setString(5, displayName(principal));
statement.setString(6, content);
return statement;
}, keyHolder);
Number key = keyHolder.getKey();
return findAnswer(principal, key == null ? 0L : key.longValue());
}
public QuestionResponse publicQuestion(Long questionId) {
AihrKnowledgePrincipal principal = principalResolver.current();
ensureTables();
QuestionRow row = findQuestion(principal, questionId);
if (!row.authorUserId().equals(principal.userId())
&& !("PUBLISHED".equals(row.status()) || "CLOSED".equals(row.status()))) {
throw new ServiceException("问题尚未发布", 403);
}
return response(principal, row, false);
}
public QuestionResponse reviewQuestion(Long questionId, ReviewRequest request) {
AihrKnowledgePrincipal principal = requireModerator();
ensureTables();
String status = AihrCommunityPolicy.moderatedStatus(request == null ? null : request.decision());
int updated = jdbcTemplate.update("""
update aihr_community_question
set question_status = ?, moderator_user_id = ?, moderation_note = ?, moderated_time = now(), update_time = now()
where tenant_id = ? and id = ? and question_status = 'PENDING'
""", status, principal.userId(), truncate(clean(request.note()), 300), principal.tenantId(), questionId);
if (updated == 0) {
throw new IllegalArgumentException("待审核问题不存在或已处理");
}
return response(principal, findQuestion(principal, questionId), true);
}
public AnswerResponse reviewAnswer(Long answerId, ReviewRequest request) {
AihrKnowledgePrincipal principal = requireModerator();
ensureTables();
String status = AihrCommunityPolicy.moderatedStatus(request == null ? null : request.decision());
int updated = jdbcTemplate.update("""
update aihr_community_answer
set answer_status = ?, moderator_user_id = ?, moderation_note = ?, moderated_time = now(), update_time = now()
where tenant_id = ? and id = ? and answer_status = 'PENDING'
""", status, principal.userId(), truncate(clean(request.note()), 300), principal.tenantId(), answerId);
if (updated == 0) {
throw new IllegalArgumentException("待审核回答不存在或已处理");
}
return findAnswer(principal, answerId);
}
@Transactional(rollbackFor = Exception.class)
public BestAnswerResponse chooseBest(Long questionId, Long answerId, boolean admin) {
AihrKnowledgePrincipal principal = admin ? requireModerator() : principalResolver.current();
ensureTables();
QuestionRow question = findQuestionForUpdate(principal, questionId);
if (!admin && !question.authorUserId().equals(principal.userId())) {
throw new ServiceException("只有提问人或管理员可以选择最佳回答", 403);
}
if (!("PUBLISHED".equals(question.status()) || "CLOSED".equals(question.status()))) {
throw new IllegalArgumentException("当前问题不能选择最佳回答");
}
AnswerRow answer = findAnswerRow(principal, answerId);
if (!questionId.equals(answer.questionId()) || !"PUBLISHED".equals(answer.status())) {
throw new IllegalArgumentException("回答不存在或尚未通过审核");
}
if (question.authorUserId().equals(answer.authorUserId())) {
throw new IllegalArgumentException("不能把自己的回答设为最佳回答");
}
if (question.bestAnswerId() != null && !question.bestAnswerId().equals(answerId)) {
throw new IllegalArgumentException("该问题已经选择了其他最佳回答");
}
if (question.bestAnswerId() == null) {
int updated = jdbcTemplate.update("""
update aihr_community_question
set best_answer_id = ?, question_status = 'CLOSED', update_time = now()
where tenant_id = ? and id = ? and best_answer_id is null
""", answerId, principal.tenantId(), questionId);
if (updated == 0) {
throw new IllegalStateException("最佳回答已被其他操作更新,请刷新后重试");
}
jdbcTemplate.update("""
update aihr_community_answer set is_best = 1, update_time = now()
where tenant_id = ? and id = ?
""", principal.tenantId(), answerId);
}
Award award = awardableReward(principal, question, answer);
int inserted = award.value() <= 0 ? 0 : jdbcTemplate.update("""
insert ignore into aihr_points_ledger
(tenant_id, user_id, ext_party_id, business_type, business_id, account_type, points, reason, create_time)
values (?, ?, ?, 'BEST_ANSWER', ?, ?, ?, ?, now())
""", principal.tenantId(), answer.authorUserId(), answer.authorExtPartyId(), answerId,
award.accountType(), award.value(),
"开放问题榜最佳回答");
String awardedAccount = inserted > 0 ? award.accountType()
: existingAwardAccount(principal.tenantId(), answer.authorUserId(), answerId, award.accountType());
int awardedValue = inserted > 0 ? award.value() : 0;
return new BestAnswerResponse(questionId, answerId,
"POINTS".equals(awardedAccount) ? awardedValue : 0,
inserted > 0,
accountBalance(principal.tenantId(), answer.authorUserId(), "POINTS"),
awardedAccount,
awardedValue,
accountBalance(principal.tenantId(), answer.authorUserId(), "STUDY_CREDIT"));
}
public PointsResponse myPoints() {
AihrKnowledgePrincipal principal = principalResolver.current();
ensureTables();
List<PointsEntryResponse> entries = jdbcTemplate.query("""
select id, business_type, business_id, account_type, points, reason, create_time
from aihr_points_ledger
where tenant_id = ? and user_id = ?
order by id desc
limit 50
""", (rs, rowNum) -> new PointsEntryResponse(
rs.getLong("id"), rs.getString("business_type"), rs.getLong("business_id"),
rs.getString("account_type"), rs.getInt("points"), rs.getString("reason"),
time(rs.getTimestamp("create_time"))
), principal.tenantId(), principal.userId());
int pointsBalance = accountBalance(principal.tenantId(), principal.userId(), "POINTS");
int studyCreditBalance = accountBalance(principal.tenantId(), principal.userId(), "STUDY_CREDIT");
return new PointsResponse(pointsBalance, pointsBalance, studyCreditBalance, entries);
}
public AihrKnowledgePrincipal currentPrincipal() {
return principalResolver.current();
}
private QuestionResponse response(AihrKnowledgePrincipal principal, QuestionRow row, boolean includeModeration) {
List<AnswerResponse> answers = answers(principal, row.id(), includeModeration);
return new QuestionResponse(row.id(), row.type(), row.category(), row.title(), row.content(), row.authorName(),
row.rewardPoints(), row.rewardAccountType(), row.ruleCode(),
row.deadline() == null ? "" : row.deadline().format(TIME_FORMAT),
row.status(), row.bestAnswerId(), row.answerCount(), row.authorUserId().equals(principal.userId()),
row.createdAt().format(TIME_FORMAT), answers);
}
private List<AnswerResponse> answers(AihrKnowledgePrincipal principal, Long questionId, boolean includeModeration) {
return jdbcTemplate.query("""
select id, question_id, author_user_id, author_ext_party_id, author_name, content,
answer_status, is_best, create_time
from aihr_community_answer
where tenant_id = ? and question_id = ?
and (? = 1 or answer_status = 'PUBLISHED' or author_user_id = ?)
order by is_best desc, id desc
""", (rs, rowNum) -> new AnswerResponse(
rs.getLong("id"), rs.getLong("question_id"), rs.getString("author_name"),
rs.getString("content"), rs.getString("answer_status"), rs.getBoolean("is_best"),
rs.getLong("author_user_id") == principal.userId(),
time(rs.getTimestamp("create_time"))
), principal.tenantId(), questionId, includeModeration ? 1 : 0, principal.userId());
}
private QuestionRow findQuestion(AihrKnowledgePrincipal principal, Long id) {
List<QuestionRow> rows = jdbcTemplate.query("""
select q.id, q.author_user_id, q.author_name, q.question_type, q.category, q.title, q.content,
q.reward_points, q.reward_rule_code,
coalesce((select r.account_type from aihr_incentive_rule r
where r.tenant_id = q.tenant_id and r.rule_code = q.reward_rule_code
order by r.enabled desc, r.id desc limit 1), 'POINTS') reward_account_type,
q.deadline, q.question_status,
q.best_answer_id, q.create_time,
(select count(*) from aihr_community_answer a
where a.tenant_id = q.tenant_id and a.question_id = q.id and a.answer_status = 'PUBLISHED') answer_count
from aihr_community_question q where q.tenant_id = ? and q.id = ?
""", this::questionRow, principal.tenantId(), id);
if (rows.isEmpty()) {
throw new IllegalArgumentException("问题不存在");
}
return rows.get(0);
}
private QuestionRow findQuestionForUpdate(AihrKnowledgePrincipal principal, Long id) {
List<QuestionRow> rows = jdbcTemplate.query("""
select q.id, q.author_user_id, q.author_name, q.question_type, q.category, q.title, q.content,
q.reward_points, q.reward_rule_code,
coalesce((select r.account_type from aihr_incentive_rule r
where r.tenant_id = q.tenant_id and r.rule_code = q.reward_rule_code
order by r.enabled desc, r.id desc limit 1), 'POINTS') reward_account_type,
q.deadline, q.question_status,
q.best_answer_id, q.create_time, 0 answer_count
from aihr_community_question q where q.tenant_id = ? and q.id = ? for update
""", this::questionRow, principal.tenantId(), id);
if (rows.isEmpty()) {
throw new IllegalArgumentException("问题不存在");
}
return rows.get(0);
}
private AnswerResponse findAnswer(AihrKnowledgePrincipal principal, Long id) {
AnswerRow row = findAnswerRow(principal, id);
return new AnswerResponse(row.id(), row.questionId(), row.authorName(), row.content(), row.status(),
row.best(), row.authorUserId().equals(principal.userId()), row.createdAt().format(TIME_FORMAT));
}
private AnswerRow findAnswerRow(AihrKnowledgePrincipal principal, Long id) {
List<AnswerRow> rows = jdbcTemplate.query("""
select id, question_id, author_user_id, author_ext_party_id, author_name, content,
answer_status, is_best, create_time
from aihr_community_answer where tenant_id = ? and id = ?
""", (rs, rowNum) -> new AnswerRow(
rs.getLong("id"), rs.getLong("question_id"), rs.getLong("author_user_id"),
rs.getString("author_ext_party_id"), rs.getString("author_name"), rs.getString("content"),
rs.getString("answer_status"), rs.getBoolean("is_best"),
rs.getTimestamp("create_time").toLocalDateTime()
), principal.tenantId(), id);
if (rows.isEmpty()) {
throw new IllegalArgumentException("回答不存在");
}
return rows.get(0);
}
private QuestionRow questionRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
Timestamp deadline = rs.getTimestamp("deadline");
Number bestId = (Number) rs.getObject("best_answer_id");
return new QuestionRow(
rs.getLong("id"), rs.getLong("author_user_id"), rs.getString("author_name"),
rs.getString("question_type"), rs.getString("category"), rs.getString("title"), rs.getString("content"),
rs.getInt("reward_points"), rs.getString("reward_rule_code"), rs.getString("reward_account_type"),
deadline == null ? null : deadline.toLocalDateTime(), rs.getString("question_status"),
bestId == null ? null : bestId.longValue(), rs.getInt("answer_count"),
rs.getTimestamp("create_time").toLocalDateTime()
);
}
private Award awardableReward(AihrKnowledgePrincipal principal, QuestionRow question, AnswerRow answer) {
AihrIncentiveService.IncentiveRuleResponse rule = incentiveService.activeRule(principal, question.ruleCode());
if (rule == null) {
return new Award("POINTS", 0);
}
String accountType = AihrIncentiveService.normalizeAccountType(rule.accountType());
int desired = Math.max(0, question.rewardPoints());
if (rule.dailyCap() <= 0) {
return new Award(accountType, desired);
}
Integer awardedToday = jdbcTemplate.queryForObject("""
select coalesce(sum(points), 0) from aihr_points_ledger
where tenant_id = ? and user_id = ? and account_type = ? and create_time >= current_date()
""", Integer.class, principal.tenantId(), answer.authorUserId(), accountType);
return new Award(accountType,
Math.min(desired, Math.max(0, rule.dailyCap() - (awardedToday == null ? 0 : awardedToday))));
}
private int accountBalance(String tenantId, Long userId, String accountType) {
Integer balance = jdbcTemplate.queryForObject("""
select coalesce(sum(points), 0) from aihr_points_ledger
where tenant_id = ? and user_id = ? and account_type = ?
""", Integer.class, tenantId, userId, accountType);
return balance == null ? 0 : balance;
}
private String existingAwardAccount(String tenantId, Long userId, Long answerId, String fallback) {
List<String> rows = jdbcTemplate.query("""
select account_type from aihr_points_ledger
where tenant_id = ? and user_id = ? and business_type = 'BEST_ANSWER' and business_id = ?
limit 1
""", (rs, rowNum) -> rs.getString("account_type"), tenantId, userId, answerId);
return rows.isEmpty() ? fallback : rows.get(0);
}
private AihrKnowledgePrincipal requireModerator() {
AihrKnowledgePrincipal principal = principalResolver.current();
if (!AihrCommunityPolicy.canModerate(principal)) {
throw new ServiceException("仅主管或管理员可执行该操作", 403);
}
return principal;
}
private void ensureTables() {
if (tablesReady) {
return;
}
synchronized (this) {
if (tablesReady) {
return;
}
for (String ddl : tableDdls()) {
jdbcTemplate.execute(ddl);
}
Integer accountTypeColumnCount = jdbcTemplate.queryForObject("""
select count(*) from information_schema.columns
where table_schema = database()
and table_name = 'aihr_points_ledger'
and column_name = 'account_type'
""", Integer.class);
if (accountTypeColumnCount == null || accountTypeColumnCount == 0) {
jdbcTemplate.execute("""
ALTER TABLE `aihr_points_ledger`
ADD COLUMN `account_type` varchar(20) NOT NULL DEFAULT 'POINTS'
COMMENT 'POINTS/STUDY_CREDIT' AFTER `business_id`
""");
}
tablesReady = true;
}
}
private static List<String> tableDdls() {
List<String> ddls = new ArrayList<>();
ddls.add("""
CREATE TABLE IF NOT EXISTS `aihr_community_question` (
`id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL,
`author_user_id` bigint NOT NULL, `author_ext_party_id` varchar(80) DEFAULT NULL,
`author_name` varchar(100) NOT NULL, `title` varchar(160) NOT NULL, `content` text NOT NULL,
`question_type` varchar(24) NOT NULL DEFAULT 'OPEN',
`category` varchar(40) NOT NULL DEFAULT '工作求助',
`reward_rule_code` varchar(60) NOT NULL DEFAULT 'BEST_ANSWER',
`reward_points` int NOT NULL DEFAULT 10, `deadline` datetime DEFAULT NULL,
`question_status` varchar(24) NOT NULL DEFAULT 'PENDING', `best_answer_id` bigint DEFAULT NULL,
`moderator_user_id` bigint DEFAULT NULL, `moderation_note` varchar(300) DEFAULT NULL,
`moderated_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), KEY `idx_aihr_community_question_board` (`tenant_id`, `question_status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
ddls.add("""
CREATE TABLE IF NOT EXISTS `aihr_community_answer` (
`id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL,
`question_id` bigint NOT NULL, `author_user_id` bigint NOT NULL,
`author_ext_party_id` varchar(80) DEFAULT NULL, `author_name` varchar(100) NOT NULL,
`content` text NOT NULL, `answer_status` varchar(24) NOT NULL DEFAULT 'PENDING',
`is_best` tinyint NOT NULL DEFAULT 0, `moderator_user_id` bigint DEFAULT NULL,
`moderation_note` varchar(300) DEFAULT NULL, `moderated_time` datetime DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), KEY `idx_aihr_community_answer_question` (`tenant_id`, `question_id`, `answer_status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
ddls.add("""
CREATE TABLE IF NOT EXISTS `aihr_points_ledger` (
`id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL,
`user_id` bigint NOT NULL, `ext_party_id` varchar(80) DEFAULT NULL,
`business_type` varchar(40) NOT NULL, `business_id` bigint NOT NULL,
`account_type` varchar(20) NOT NULL DEFAULT 'POINTS',
`points` int NOT NULL, `reason` varchar(200) NOT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_points_business` (`tenant_id`, `user_id`, `business_type`, `business_id`),
KEY `idx_aihr_points_user` (`tenant_id`, `user_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
return ddls;
}
private static String publicStatus(String status) {
String value = clean(status).toUpperCase(Locale.ROOT);
return ("PUBLISHED".equals(value) || "CLOSED".equals(value)) ? value : "";
}
private static String adminStatus(String status) {
String value = clean(status).toUpperCase(Locale.ROOT);
return List.of("PENDING", "PUBLISHED", "REJECTED", "CLOSED").contains(value) ? value : "";
}
private static String normalizeRuleCode(String ruleCode) {
String value = clean(ruleCode).toUpperCase(Locale.ROOT);
return value.isBlank() ? "BEST_ANSWER" : value;
}
private static String normalizeCategory(String category) {
String value = clean(category);
return value.isBlank() ? "工作求助" : truncate(value, 40);
}
private static LocalDateTime parseDeadline(String value, boolean required) {
String text = clean(value);
if (text.isBlank()) {
if (required) {
throw new IllegalArgumentException("悬赏问题必须设置截止时间");
}
return null;
}
try {
LocalDateTime parsed;
if (text.matches("\\d{4}-\\d{2}-\\d{2}")) {
parsed = LocalDate.parse(text).atTime(23, 59, 59);
} else if (text.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}")) {
parsed = LocalDateTime.parse(text, TIME_FORMAT);
} else {
parsed = LocalDateTime.parse(text);
}
if (parsed.isBefore(LocalDateTime.now())) {
throw new IllegalArgumentException("截止时间不能早于当前时间");
}
return parsed;
} catch (DateTimeParseException error) {
throw new IllegalArgumentException("截止时间格式不正确");
}
}
private static String required(String value, String label, int max) {
String cleaned = clean(value);
if (cleaned.isBlank()) {
throw new IllegalArgumentException(label + "不能为空");
}
if (cleaned.length() > max) {
throw new IllegalArgumentException(label + "不能超过" + max + "字");
}
return cleaned;
}
private static String displayName(AihrKnowledgePrincipal principal) {
String identity = clean(principal.extPartyId());
if (identity.isBlank()) {
return "管理员" + principal.userId();
}
return identity.length() <= 4 ? identity : "员工" + identity.substring(identity.length() - 4);
}
private static String time(Timestamp value) {
return value == null ? "" : value.toLocalDateTime().format(TIME_FORMAT);
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
private static String truncate(String value, int max) {
return value.length() <= max ? value : value.substring(0, max);
}
private record QuestionRow(Long id, Long authorUserId, String authorName, String type, String category, String title,
String content, int rewardPoints, String ruleCode, String rewardAccountType, LocalDateTime deadline,
String status, Long bestAnswerId, int answerCount, LocalDateTime createdAt) {
}
private record AnswerRow(Long id, Long questionId, Long authorUserId, String authorExtPartyId,
String authorName, String content, String status, boolean best,
LocalDateTime createdAt) {
}
private record Award(String accountType, int value) {
}
}
@@ -0,0 +1,52 @@
package org.dromara.aihr.community;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.community.AihrIncentiveService.IncentiveRuleRequest;
import org.dromara.aihr.community.AihrIncentiveService.IncentiveRuleResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@SaCheckLogin
@RequestMapping("/api/aihr/incentive-admin")
public class AihrIncentiveAdminController {
private final AihrIncentiveService incentiveService;
private final AihrKnowledgePrincipalResolver principalResolver;
@GetMapping("/rules")
public R<List<IncentiveRuleResponse>> rules() {
AihrKnowledgePrincipal principal = requireModerator();
return R.ok(incentiveService.list(principal, true));
}
@PostMapping("/rules")
public R<IncentiveRuleResponse> create(@RequestBody IncentiveRuleRequest request) {
return R.ok(incentiveService.create(requireModerator(), request));
}
@PutMapping("/rules")
public R<IncentiveRuleResponse> update(@RequestBody IncentiveRuleRequest request) {
return R.ok(incentiveService.update(requireModerator(), request));
}
private AihrKnowledgePrincipal requireModerator() {
AihrKnowledgePrincipal principal = principalResolver.current();
if (!AihrCommunityPolicy.canModerate(principal)) {
throw new ServiceException("仅主管或管理员可管理激励规则", 403);
}
return principal;
}
}
@@ -0,0 +1,205 @@
package org.dromara.aihr.community;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.List;
import java.util.Locale;
@Service
@RequiredArgsConstructor
public class AihrIncentiveService {
private final JdbcTemplate jdbcTemplate;
private volatile boolean tableReady;
public List<IncentiveRuleResponse> list(AihrKnowledgePrincipal principal, boolean includeDisabled) {
ensureTable();
ensureDefaultRule(principal.tenantId());
return jdbcTemplate.query("""
select id, rule_code, display_name, account_type, rule_value, daily_cap, enabled
from aihr_incentive_rule
where tenant_id = ? and (? = 1 or enabled = 1)
order by id
""", (rs, rowNum) -> new IncentiveRuleResponse(
rs.getLong("id"), rs.getString("rule_code"), rs.getString("display_name"),
rs.getString("account_type"), rs.getInt("rule_value"), rs.getInt("daily_cap"),
rs.getBoolean("enabled")
), principal.tenantId(), includeDisabled ? 1 : 0);
}
public IncentiveRuleResponse create(AihrKnowledgePrincipal principal, IncentiveRuleRequest request) {
ensureTable();
NormalizedRule rule = normalize(request, false);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_incentive_rule
(tenant_id, rule_code, display_name, account_type, rule_value, daily_cap, enabled, create_time, update_time)
values (?, ?, ?, ?, ?, ?, ?, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, principal.tenantId());
statement.setString(2, rule.ruleCode());
statement.setString(3, rule.displayName());
statement.setString(4, rule.accountType());
statement.setInt(5, rule.value());
statement.setInt(6, rule.dailyCap());
statement.setBoolean(7, rule.enabled());
return statement;
}, keyHolder);
Number id = keyHolder.getKey();
return find(principal, id == null ? 0L : id.longValue());
}
public IncentiveRuleResponse update(AihrKnowledgePrincipal principal, IncentiveRuleRequest request) {
ensureTable();
NormalizedRule rule = normalize(request, true);
int updated = jdbcTemplate.update("""
update aihr_incentive_rule
set rule_code = ?, display_name = ?, account_type = ?, rule_value = ?, daily_cap = ?, enabled = ?, update_time = now()
where tenant_id = ? and id = ?
""", rule.ruleCode(), rule.displayName(), rule.accountType(), rule.value(), rule.dailyCap(),
rule.enabled(), principal.tenantId(), request.id());
if (updated == 0) {
throw new IllegalArgumentException("激励规则不存在");
}
return find(principal, request.id());
}
IncentiveRuleResponse activeRule(AihrKnowledgePrincipal principal, String ruleCode) {
ensureTable();
ensureDefaultRule(principal.tenantId());
List<IncentiveRuleResponse> rules = jdbcTemplate.query("""
select id, rule_code, display_name, account_type, rule_value, daily_cap, enabled
from aihr_incentive_rule
where tenant_id = ? and rule_code = ? and enabled = 1
limit 1
""", (rs, rowNum) -> new IncentiveRuleResponse(
rs.getLong("id"), rs.getString("rule_code"), rs.getString("display_name"),
rs.getString("account_type"), rs.getInt("rule_value"), rs.getInt("daily_cap"), true
), principal.tenantId(), normalizeCode(ruleCode));
return rules.isEmpty() ? null : rules.get(0);
}
private IncentiveRuleResponse find(AihrKnowledgePrincipal principal, Long id) {
List<IncentiveRuleResponse> rows = jdbcTemplate.query("""
select id, rule_code, display_name, account_type, rule_value, daily_cap, enabled
from aihr_incentive_rule
where tenant_id = ? and id = ?
""", (rs, rowNum) -> new IncentiveRuleResponse(
rs.getLong("id"), rs.getString("rule_code"), rs.getString("display_name"),
rs.getString("account_type"), rs.getInt("rule_value"), rs.getInt("daily_cap"),
rs.getBoolean("enabled")
), principal.tenantId(), id);
if (rows.isEmpty()) {
throw new IllegalArgumentException("激励规则不存在");
}
return rows.get(0);
}
private static NormalizedRule normalize(IncentiveRuleRequest request, boolean idRequired) {
if (request == null || (idRequired && request.id() == null)) {
throw new IllegalArgumentException("激励规则参数不完整");
}
String code = normalizeCode(request.ruleCode());
String name = clean(request.displayName());
if (!code.matches("[A-Z0-9_]{2,60}")) {
throw new IllegalArgumentException("规则编码仅支持大写字母、数字和下划线");
}
if (name.isBlank() || name.length() > 100) {
throw new IllegalArgumentException("规则名称不能为空且不能超过100字");
}
String accountType = normalizeAccountType(request.accountType());
int value = request.value() == null ? 0 : request.value();
int dailyCap = request.dailyCap() == null ? 0 : request.dailyCap();
if (value < 0 || value > 100000 || dailyCap < 0 || dailyCap > 1000000) {
throw new IllegalArgumentException("激励数值超出允许范围");
}
return new NormalizedRule(code, name, accountType, value, dailyCap,
request.enabled() == null || request.enabled());
}
private void ensureTable() {
if (tableReady) {
return;
}
synchronized (this) {
if (tableReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_incentive_rule` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`rule_code` varchar(60) NOT NULL,
`display_name` varchar(100) NOT NULL,
`account_type` varchar(20) NOT NULL DEFAULT 'POINTS',
`rule_value` int NOT NULL DEFAULT 0,
`daily_cap` int NOT NULL DEFAULT 0,
`enabled` tinyint NOT NULL DEFAULT 1,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_incentive_rule` (`tenant_id`, `rule_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
tableReady = true;
}
}
private void ensureDefaultRule(String tenantId) {
jdbcTemplate.update("""
insert ignore into aihr_incentive_rule
(tenant_id, rule_code, display_name, account_type, rule_value, daily_cap, enabled)
values (?, 'BEST_ANSWER', '最佳回答积分', 'POINTS', 10, 100, 1)
""", tenantId);
}
private static String normalizeCode(String value) {
return clean(value).toUpperCase(Locale.ROOT);
}
static String normalizeAccountType(String value) {
String accountType = clean(value).toUpperCase(Locale.ROOT);
if (!("POINTS".equals(accountType) || "STUDY_CREDIT".equals(accountType))) {
throw new IllegalArgumentException("账户类型仅支持 POINTS 或 STUDY_CREDIT");
}
return accountType;
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
public record IncentiveRuleRequest(Long id,
String ruleCode,
String displayName,
String accountType,
Integer value,
Integer dailyCap,
Boolean enabled) {
}
public record IncentiveRuleResponse(Long id,
String ruleCode,
String displayName,
String accountType,
int value,
int dailyCap,
boolean enabled) {
}
private record NormalizedRule(String ruleCode,
String displayName,
String accountType,
int value,
int dailyCap,
boolean enabled) {
}
}
@@ -0,0 +1,57 @@
package org.dromara.aihr.community;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerRequest;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest;
import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse;
import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
import java.util.List;
@RestController
@RequiredArgsConstructor
@SaCheckLogin
@RequestMapping("/api/aihr/question-admin")
public class AihrQuestionAdminController {
private final AihrCommunityService communityService;
@GetMapping("/questions")
public R<List<QuestionResponse>> questions(@RequestParam(value = "status", required = false) String status) {
return R.ok(communityService.adminQuestions(status));
}
@PostMapping("/questions")
public R<QuestionResponse> createBounty(@RequestBody CreateQuestionRequest request) {
return R.ok(communityService.createQuestion(request, true));
}
@PostMapping("/questions/{id}/review")
public R<QuestionResponse> reviewQuestion(@PathVariable Long id, @RequestBody ReviewRequest request) {
return R.ok(communityService.reviewQuestion(id, request));
}
@PostMapping("/answers/{id}/review")
public R<AnswerResponse> reviewAnswer(@PathVariable Long id, @RequestBody ReviewRequest request) {
return R.ok(communityService.reviewAnswer(id, request));
}
@PostMapping("/questions/{id}/best-answer")
public R<BestAnswerResponse> chooseBest(@PathVariable Long id, @RequestBody BestAnswerRequest request) {
if (request == null || request.answerId() == null) {
return R.fail("请选择最佳回答");
}
return R.ok(communityService.chooseBest(id, request.answerId(), true));
}
}
@@ -11,6 +11,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest;
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
@@ -27,6 +29,20 @@ import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionResponse;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.IdentityResponse;
import org.dromara.aihr.learning.AihrExamService;
import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackRequest;
import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackResponse;
import org.dromara.aihr.learning.AihrLearningDto.DifficultyAssignRequest;
import org.dromara.aihr.learning.AihrLearningDto.DifficultyResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamSummaryResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest;
import org.dromara.aihr.learning.AihrLearningDto.LearningMatrixResponse;
import org.dromara.aihr.learning.AihrLearningService;
import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.aihr.service.AihrSensitiveText;
@@ -44,6 +60,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;
import java.io.IOException;
import java.util.LinkedHashMap;
@@ -63,6 +80,8 @@ public class AihrMobileController {
private final AihrMobileSeedService mobileSeedService;
private final AihrOrgSyncService orgSyncService;
private final ISysOssService ossService;
private final AihrLearningService learningService;
private final AihrExamService examService;
@SaIgnore
@GetMapping("/home/{role}")
@@ -224,12 +243,92 @@ public class AihrMobileController {
return R.ok(mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId()));
}
@PostMapping("/practice/assignments/batch")
public R<PracticeAssignmentBatchResponse> createPracticeAssignments(@RequestBody PracticeAssignmentBatchRequest request) {
return R.ok(mobileSeedService.createPracticeAssignments(request, supervisorScopeExtPartyId()));
}
@PostMapping("/practice/assignments/{id}/answer")
public R<DailyDrillAnswerResponse> answerDailyDrill(@PathVariable Long id, @RequestBody(required = false) DailyDrillAnswerRequest request) {
DailyDrillAnswerResponse response = mobileSeedService.answerDailyDrill(id, request, ownMobileExtPartyId(null), currentAppUserId());
return response == null ? R.fail("每日三题不存在或已完成") : R.ok(response);
}
@PostMapping("/practice/assignments/{id}/feedback")
public R<AssignmentFeedbackResponse> assignmentFeedback(@PathVariable Long id,
@RequestBody AssignmentFeedbackRequest request) {
return R.ok(learningService.submitAssignmentFeedback(id, request, ownMobileExtPartyId(null)));
}
@GetMapping("/practice/difficulties")
public R<List<DifficultyResponse>> practiceDifficulties(
@RequestParam(required = false, defaultValue = "20") Integer limit,
@RequestParam(required = false, defaultValue = "30") Integer windowDays,
@RequestParam(required = false) String position) {
return R.ok(learningService.difficulties(
supervisorScopeExtPartyId(), limit == null ? 20 : limit,
windowDays == null ? 30 : windowDays, position));
}
@PostMapping("/practice/difficulties/assign")
public R<PracticeAssignmentBatchResponse> assignPracticeDifficulty(@RequestBody DifficultyAssignRequest request) {
return R.ok(learningService.assignDifficulty(request, supervisorScopeExtPartyId()));
}
@GetMapping("/learning/matrix")
public R<LearningMatrixResponse> learningMatrix(@RequestParam(required = false) String positionCode) {
supervisorScopeExtPartyId();
return R.ok(learningService.matrix(positionCode));
}
@GetMapping("/exams")
public R<List<ExamSummaryResponse>> exams() {
return R.ok(examService.employeeExams(ownMobileExtPartyId(null)));
}
@GetMapping("/exams/{id}")
public R<ExamDetailResponse> exam(@PathVariable Long id) {
return R.ok(examService.employeeExam(id, ownMobileExtPartyId(null)));
}
@PostMapping("/exams/{id}/submit")
public R<ExamSubmitResponse> submitExam(@PathVariable Long id, @RequestBody ExamSubmitRequest request) {
return R.ok(examService.submit(id, request, ownMobileExtPartyId(null)));
}
@GetMapping("/exams/supervisor")
public R<List<ExamSummaryResponse>> supervisorExams() {
return R.ok(examService.supervisorExams(supervisorScopeExtPartyId()));
}
@PostMapping("/exams/supervisor")
public R<ExamDetailResponse> createSupervisorExam(@RequestBody ExamUpsertRequest request) {
String supervisor = supervisorScopeExtPartyId();
return R.ok(examService.create(request, supervisor));
}
@GetMapping("/exams/supervisor/{id}")
public R<ExamDetailResponse> supervisorExam(@PathVariable Long id) {
return R.ok(examService.supervisorExam(id, supervisorScopeExtPartyId()));
}
@PutMapping("/exams/supervisor/{id}")
public R<ExamDetailResponse> updateSupervisorExam(@PathVariable Long id, @RequestBody ExamUpsertRequest request) {
String supervisor = supervisorScopeExtPartyId();
return R.ok(examService.update(id, request, supervisor));
}
@PostMapping("/exams/supervisor/{id}/publish")
public R<ExamDetailResponse> publishSupervisorExam(@PathVariable Long id,
@RequestBody(required = false) ExamPublishRequest request) {
return R.ok(examService.publish(id, request, supervisorScopeExtPartyId()));
}
@GetMapping("/exams/supervisor/{id}/results")
public R<List<ExamResultResponse>> supervisorExamResults(@PathVariable Long id) {
return R.ok(examService.results(id, supervisorScopeExtPartyId()));
}
@PostMapping("/practice/satisfaction")
public R<SatisfactionResponse> submitSatisfaction(@RequestBody SatisfactionRequest request) {
SatisfactionResponse response = mobileSeedService.submitSatisfaction(request, ownMobileExtPartyId(null));
@@ -29,6 +29,13 @@ 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;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
@@ -62,13 +69,22 @@ public class AihrSopController {
private final AihrSopSeedService sopSeedService;
private final AihrOrgSyncService orgSyncService;
private final AihrUploadQueueService uploadQueueService;
private final AihrKnowledgeAccessService knowledgeAccessService;
private final AihrKnowledgeAppService knowledgeAppService;
private final AihrKnowledgePrincipalResolver knowledgePrincipalResolver;
private final AihrKnowledgeQueryService knowledgeQueryService;
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/doc/upload-async")
public R<UploadEnqueueResponse> uploadAsync(@RequestPart("file") MultipartFile file,
@RequestParam(value = "category", required = false) String category,
@RequestParam(value = "spaceCodes", required = false) List<String> spaceCodes,
@RequestParam(value = "batchId", required = false) String batchId) {
return R.ok(uploadQueueService.enqueue(file, category, batchId));
if (spaceCodes == null || spaceCodes.isEmpty()) {
return R.ok(uploadQueueService.enqueue(file, category, batchId));
}
requireManageSpaces(spaceCodes);
return R.ok(uploadQueueService.enqueue(file, spaceCodes, category, batchId));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@@ -87,7 +103,12 @@ public class AihrSopController {
@SaCheckLogin
@PostMapping("/search")
public R<SearchResponse> search(@RequestBody SearchRequest request) {
return R.ok(sopSeedService.search(restrictDemoVerificationSource(request), currentRequesterExtPartyId()));
SearchRequest compatible = restrictDemoVerificationSource(request);
return R.ok(knowledgeQueryService.queryInternal(new QueryRequest(
compatible == null ? null : compatible.queryText(), List.of(),
compatible == null ? null : compatible.category(), compatible == null ? null : compatible.position(),
compatible == null ? null : compatible.source(), compatible == null ? null : compatible.limit(), null
)).legacy());
}
private String currentRequesterExtPartyId() {
@@ -166,13 +187,29 @@ public class AihrSopController {
@SaCheckLogin
@PostMapping("/summary-card")
public R<SummaryCardResponse> summaryCard(@RequestBody SummaryCardRequest request) {
return R.ok(sopSeedService.summaryCard(request));
AihrKnowledgePrincipal principal = knowledgePrincipalResolver.current();
AuthenticatedApp app = knowledgeAppService.requireSessionApp(principal.tenantId(), principal.clientKey());
Set<Long> allowedKnowledgeIds = knowledgeAccessService.resolveInternalSpaceIds(
principal, app, List.of(), "READ");
return R.ok(sopSeedService.summaryCardAuthorized(request, allowedKnowledgeIds));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/doc/upload")
public R<UploadResponse> uploadDoc(@RequestPart("file") MultipartFile file, @RequestParam("category") String category) {
return R.ok(sopSeedService.uploadDoc(file, category));
public R<UploadResponse> uploadDoc(@RequestPart("file") MultipartFile file,
@RequestParam(value = "category", required = false) String category,
@RequestParam(value = "spaceCodes", required = false) List<String> spaceCodes) {
if (spaceCodes == null || spaceCodes.isEmpty()) {
return R.ok(sopSeedService.uploadDoc(file, category));
}
requireManageSpaces(spaceCodes);
return R.ok(sopSeedService.uploadDoc(file, spaceCodes));
}
private void requireManageSpaces(List<String> spaceCodes) {
AihrKnowledgePrincipal principal = knowledgePrincipalResolver.current();
AuthenticatedApp app = knowledgeAppService.requireSessionApp(principal.tenantId(), principal.clientKey());
knowledgeAccessService.resolveInternalSpaceIds(principal, app, spaceCodes, "MANAGE");
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@@ -252,7 +252,51 @@ public final class AihrPracticeDto {
public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) {
}
public record PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId, String scenarioName, String source, String reason) {
public record PracticeAssignmentRequest(
String extPartyId,
String traineeName,
String scenarioId,
String scenarioName,
String source,
String reason,
String dueDate,
String requestId
) {
public PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId,
String scenarioName, String source, String reason) {
this(extPartyId, traineeName, scenarioId, scenarioName, source, reason, null, null);
}
}
public record PracticeAssignmentTarget(String extPartyId, String traineeName) {
}
public record PracticeAssignmentBatchRequest(
String requestId,
List<PracticeAssignmentTarget> targets,
String scenarioId,
String reason,
String dueDate
) {
}
public record PracticeAssignmentBatchItemResponse(
String extPartyId,
String traineeName,
String status,
String message,
PracticeAssignmentResponse assignment
) {
}
public record PracticeAssignmentBatchResponse(
String requestId,
Integer total,
Integer created,
Integer duplicate,
Integer failed,
List<PracticeAssignmentBatchItemResponse> results
) {
}
public record PracticeAssignmentResponse(
@@ -273,8 +317,62 @@ public final class AihrPracticeDto {
Integer score,
String scoreMode,
String audioUrl,
Long audioOssId
Long audioOssId,
String dueDate,
String contentVersion,
String contentHash,
String feedbackVerdict
) {
public PracticeAssignmentResponse(
Long id,
String extPartyId,
String traineeName,
String scenarioId,
String scenarioName,
String source,
String reason,
String status,
String createTime,
String question,
String referenceAnswer,
String userAnswer,
String feedback,
Boolean correct,
Integer score,
String scoreMode,
String audioUrl,
Long audioOssId
) {
this(id, extPartyId, traineeName, scenarioId, scenarioName, source, reason, status, createTime,
question, referenceAnswer, userAnswer, feedback, correct, score, scoreMode, audioUrl, audioOssId,
null, null, null, null);
}
public PracticeAssignmentResponse(
Long id,
String extPartyId,
String traineeName,
String scenarioId,
String scenarioName,
String source,
String reason,
String status,
String createTime,
String question,
String referenceAnswer,
String userAnswer,
String feedback,
Boolean correct,
Integer score,
String scoreMode,
String audioUrl,
Long audioOssId,
String dueDate
) {
this(id, extPartyId, traineeName, scenarioId, scenarioName, source, reason, status, createTime,
question, referenceAnswer, userAnswer, feedback, correct, score, scoreMode, audioUrl, audioOssId,
dueDate, null, null, null);
}
}
public record OnboardTaskResponse(
@@ -194,6 +194,7 @@ public final class AihrSopDto {
String batchId,
String fileName,
String category,
List<String> spaceCodes,
Integer status,
String statusLabel,
String error,
@@ -0,0 +1,46 @@
package org.dromara.aihr.knowledge.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.common.core.domain.R;
import org.springframework.http.MediaType;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/knowledge")
public class AihrKnowledgeQueryController {
private final AihrKnowledgeQueryService queryService;
@SaCheckLogin
@PostMapping("/query")
public R<QueryResponse> query(@RequestBody QueryRequest request) {
return R.ok(queryService.queryInternal(request));
}
@SaCheckLogin
@PostMapping(value = "/query-media", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<QueryResponse> queryMedia(@RequestPart("file") MultipartFile file,
@RequestParam(value = "queryText", required = false) String queryText,
@RequestParam(value = "position", required = false) String position) {
String question = queryText == null || queryText.isBlank()
? "请根据附件现场情况,告诉我应该按什么 SOP 处理"
: queryText.trim();
QueryRequest request = new QueryRequest(
question, List.of(), "sop", position, "mobile_uni_media", 5, null
);
return R.ok(queryService.queryInternalMedia(request, file));
}
}
@@ -0,0 +1,112 @@
package org.dromara.aihr.knowledge.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import cn.dev33.satoken.stp.StpUtil;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.TokenIssuedResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AdminContext;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AppView;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateAppRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateAppResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateSpaceRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.DocumentView;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.GrantItem;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.ReplaceAppSpacesRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.ReplaceGrantsRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.RotateTokenRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.SpaceView;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UnbindDocumentResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateAppRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateSpaceRequest;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.knowledge.service.AihrKnowledgeSpaceAdminService;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/knowledge/admin")
@RequiredArgsConstructor
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator"}, mode = SaMode.OR)
public class AihrKnowledgeSpaceController {
private final AihrKnowledgeSpaceAdminService adminService;
private final AihrKnowledgePrincipalResolver principalResolver;
@GetMapping("/spaces")
public R<List<SpaceView>> spaces() {
return R.ok(adminService.spaces(context()));
}
@PostMapping("/spaces")
public R<SpaceView> createSpace(@RequestBody CreateSpaceRequest request) {
return R.ok(adminService.createSpace(context(), request));
}
@PutMapping("/spaces/{id}")
public R<SpaceView> updateSpace(@PathVariable Long id, @RequestBody UpdateSpaceRequest request) {
return R.ok(adminService.updateSpace(context(), id, request));
}
@GetMapping("/spaces/{id}/documents")
public R<List<DocumentView>> documents(@PathVariable Long id) {
return R.ok(adminService.documents(context(), id));
}
@DeleteMapping("/spaces/{id}/documents/{attachId}")
public R<UnbindDocumentResponse> unbindDocument(@PathVariable Long id, @PathVariable Long attachId) {
return R.ok(adminService.unbindDocument(context(), id, attachId));
}
@GetMapping("/spaces/{id}/grants")
public R<List<GrantItem>> grants(@PathVariable Long id) {
return R.ok(adminService.grants(context(), id));
}
@PutMapping("/spaces/{id}/grants")
public R<List<GrantItem>> replaceGrants(@PathVariable Long id, @RequestBody ReplaceGrantsRequest request) {
return R.ok(adminService.replaceGrants(context(), id, request == null ? List.of() : request.grants()));
}
@GetMapping("/apps")
public R<List<AppView>> apps() {
return R.ok(adminService.apps(context()));
}
@PostMapping("/apps")
public R<CreateAppResponse> createApp(@RequestBody CreateAppRequest request) {
return R.ok(adminService.createApp(context(), request));
}
@PutMapping("/apps/{id}")
public R<AppView> updateApp(@PathVariable Long id, @RequestBody UpdateAppRequest request) {
return R.ok(adminService.updateApp(context(), id, request));
}
@PutMapping("/apps/{id}/spaces")
public R<AppView> replaceAppSpaces(@PathVariable Long id, @RequestBody ReplaceAppSpacesRequest request) {
return R.ok(adminService.replaceAppSpaces(context(), id, request == null ? List.of() : request.spaceCodes()));
}
@PostMapping("/apps/{id}/rotate-token")
public R<TokenIssuedResponse> rotateToken(@PathVariable Long id, @RequestBody(required = false) RotateTokenRequest request) {
return R.ok(adminService.rotateToken(context(), id, request == null ? null : request.expiresTime()));
}
private AdminContext context() {
AihrKnowledgePrincipal principal = principalResolver.current();
return new AdminContext(principal.tenantId(), principal.userId(), principal.roles(),
StpUtil.hasRole(TenantConstants.SUPER_ADMIN_ROLE_KEY));
}
}
@@ -0,0 +1,30 @@
package org.dromara.aihr.knowledge.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/open/knowledge")
public class AihrOpenKnowledgeController {
private final AihrKnowledgeAppService appService;
private final AihrKnowledgeQueryService queryService;
@SaIgnore
@PostMapping("/query")
public R<QueryResponse> query(@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestBody QueryRequest request) {
return R.ok(queryService.queryExternal(appService.authenticateBearer(authorization), request));
}
}
@@ -0,0 +1,45 @@
package org.dromara.aihr.knowledge.domain;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import java.util.List;
public final class AihrKnowledgeQueryDto {
private AihrKnowledgeQueryDto() {
}
public record QueryRequest(
String queryText,
List<String> spaceCodes,
String category,
String position,
String source,
Integer limit,
String toolCode
) {
}
public record Citation(
String spaceCode,
String sourceType,
String docId,
String title,
String snippet,
Long fragmentId
) {
}
public record QueryResponse(
String requestId,
String queryText,
String answer,
List<Citation> citations,
List<String> usedSpaceCodes,
boolean noEvidence,
String promptVersion,
SearchResponse legacy,
Object data
) {
}
}
@@ -0,0 +1,62 @@
package org.dromara.aihr.knowledge.domain;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
public final class AihrKnowledgeSpaceDto {
private AihrKnowledgeSpaceDto() {
}
public record AdminContext(String tenantId, Long operatorId, Set<String> roles, boolean superAdmin) {
}
public record SpaceView(Long id, String code, String name, String description, String spaceType,
String sensitivityLevel, String status, int documentCount, int fragmentCount) {
}
public record DocumentView(Long attachId, String docId, String name, String type, String status,
Long ossId, int fragmentCount, LocalDateTime updateTime) {
}
public record UnbindDocumentResponse(Long attachId, String spaceCode, Long ossId, boolean ossDeleted) {
}
public record CreateSpaceRequest(String code, String name, String description, String spaceType,
String sensitivityLevel, String status) {
}
public record UpdateSpaceRequest(String code, String name, String description, String spaceType,
String sensitivityLevel, String status) {
}
public record GrantItem(String principalType, String principalValue, String permission, String status) {
}
public record ReplaceGrantsRequest(List<GrantItem> grants) {
}
public record AppView(Long id, String appCode, String appName, String authType, String internalClientKey,
String status, Integer rateLimitPerMinute, LocalDateTime expiresTime,
LocalDateTime lastUsedTime, List<String> spaceCodes) {
}
public record CreateAppRequest(String appCode, String appName, String authType, String internalClientKey,
String status, Integer rateLimitPerMinute, LocalDateTime expiresTime,
List<String> spaceCodes) {
}
public record CreateAppResponse(AppView app, String token) {
}
public record UpdateAppRequest(String appName, String status, Integer rateLimitPerMinute,
LocalDateTime expiresTime) {
}
public record ReplaceAppSpacesRequest(List<String> spaceCodes) {
}
public record RotateTokenRequest(LocalDateTime expiresTime) {
}
}
@@ -0,0 +1,88 @@
package org.dromara.aihr.knowledge.service;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeDataToolService {
public static final String MY_PRACTICE_SUMMARY = "MY_PRACTICE_SUMMARY";
public static final String TEAM_PRACTICE_SUMMARY = "TEAM_PRACTICE_SUMMARY";
private final AihrMobileSeedService mobileSeedService;
public ToolResult execute(AihrKnowledgePrincipal principal, String rawToolCode) {
if (principal == null || !hasText(principal.extPartyId())) {
throw new ServiceException("当前登录身份不能使用训练数据工具", HttpStatus.FORBIDDEN);
}
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);
default -> throw new ServiceException("不支持的数据工具", HttpStatus.BAD_REQUEST);
};
}
private ToolResult myPractice(String extPartyId) {
List<RecordResponse> records = mobileSeedService.practiceHistory(extPartyId);
List<PracticeRecord> recent = records.stream().map(row -> new PracticeRecord(
row.time(), row.scene(), row.score(), row.status(), row.summary())).toList();
Integer average = averageScore(records);
MyPracticeSummary data = new MyPracticeSummary(records.size(), average,
records.isEmpty() ? "" : records.get(0).time(), recent);
String answer = records.isEmpty()
? "当前没有可查询的本人训练记录。"
: "最近训练 " + records.size() + " 次,平均分 " + average + " 分,最近一次为 " + data.latestAt() + "。";
return new ToolResult(MY_PRACTICE_SUMMARY, "我的训练概况", "RECENT_5", answer, data);
}
private ToolResult teamPractice(AihrKnowledgePrincipal principal) {
if (!principal.roles().contains("supervisor")) {
throw new ServiceException("只有主管可以查询团队训练概况", HttpStatus.FORBIDDEN);
}
String supervisorIdentity = mobileSeedService.requireSupervisorIdentity(principal.extPartyId());
PracticeTeamResponse snapshot = mobileSeedService.practiceTeam(supervisorIdentity);
List<RecordResponse> records = snapshot.records() == null ? List.of() : snapshot.records();
int memberCount = snapshot.members() == null ? 0 : snapshot.members().size();
int pendingAssignments = snapshot.assignments() == null ? 0 : snapshot.assignments().size();
int lowScoreCount = snapshot.mistakes() == null ? 0 : snapshot.mistakes().size();
long pendingReviews = records.stream().filter(row -> "待复盘".equals(row.status())).count();
TeamPracticeSummary data = new TeamPracticeSummary(
memberCount, records.size(), (int) pendingReviews, pendingAssignments, lowScoreCount, averageScore(records));
String answer = "当前管理范围共 " + memberCount + " 人,训练记录 " + records.size()
+ " 条,待复盘 " + pendingReviews + " 条,平均分 " + data.averageScore() + " 分。";
return new ToolResult(TEAM_PRACTICE_SUMMARY, "团队训练概况", "CURRENT_SUPERVISOR_SCOPE", answer, data);
}
private static int averageScore(List<RecordResponse> records) {
return (int) Math.round(records.stream().filter(row -> row.score() != null)
.mapToInt(RecordResponse::score).average().orElse(0));
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
public record ToolResult(String toolCode, String title, String window, String answer, Object data) {
}
public record PracticeRecord(String time, String scene, Integer score, String status, String summary) {
}
public record MyPracticeSummary(int practiceCount, int averageScore, String latestAt,
List<PracticeRecord> recentRecords) {
}
public record TeamPracticeSummary(int memberCount, int practiceCount, int pendingReviewCount,
int pendingAssignmentCount, int lowScoreCount, int averageScore) {
}
}
@@ -0,0 +1,47 @@
package org.dromara.aihr.knowledge.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrKnowledgeQueryAuditService {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public void record(String requestId, AihrKnowledgePrincipal principal, AuthenticatedApp app, String question,
List<String> spaceCodes, List<String> sourceTypes, String status, long latencyMs,
String promptVersion) {
try {
jdbcTemplate.update("""
insert into aihr_knowledge_query_log
(request_id, tenant_id, app_id, user_id, question_hash, used_space_codes_json,
source_types_json, status, latency_ms, prompt_version, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
""", requestId, app.tenantId(), app.id(), principal == null ? null : principal.userId(),
AihrKnowledgeAppService.sha256(question == null ? "" : question), json(spaceCodes),
json(sourceTypes), status, latencyMs, promptVersion);
} catch (DataAccessException ex) {
log.warn("knowledge query audit write failed for request {}", requestId);
}
}
private String json(List<String> values) {
try {
return objectMapper.writeValueAsString(values == null ? List.of() : values);
} catch (JsonProcessingException ex) {
return "[]";
}
}
}
@@ -0,0 +1,215 @@
package org.dromara.aihr.knowledge.service;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeQueryService {
private final AihrKnowledgePrincipalResolver principalResolver;
private final AihrKnowledgeAppService appService;
private final AihrKnowledgeAccessService accessService;
private final AihrSopSeedService sopService;
private final AihrKnowledgeQueryAuditService auditService;
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgeDataToolService dataToolService;
public QueryResponse queryInternal(QueryRequest rawRequest) {
QueryRequest request = validate(rawRequest);
AihrKnowledgePrincipal principal = principalResolver.current();
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
if (hasText(request.toolCode())) {
return queryDataTool(principal, app, request);
}
Set<Long> spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
return queryDocuments(principal, app, spaceIds, request);
}
public QueryResponse queryInternalMedia(QueryRequest rawRequest, MultipartFile file) {
QueryRequest request = validate(rawRequest);
if (hasText(request.toolCode())) {
throw new ServiceException("媒体提问不能调用内部数据工具", HttpStatus.BAD_REQUEST);
}
AihrKnowledgePrincipal principal = principalResolver.current();
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
Set<Long> spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
String extracted = sopService.extractQuestionMedia(file, request.queryText());
String context = extracted == null ? "" : extracted.trim();
if (context.isBlank()) {
throw new ServiceException("附件中没有可用于查询的现场信息", HttpStatus.BAD_REQUEST);
}
String combinedQuery = truncate(request.queryText(), 400)
+ "\n附件现场信息:" + truncate(context, 520);
QueryResponse response = queryDocuments(principal, app, spaceIds, new QueryRequest(
combinedQuery, request.spaceCodes(), request.category(), request.position(),
request.source(), request.limit(), null
));
return new QueryResponse(
response.requestId(), request.queryText(), response.answer(), response.citations(),
response.usedSpaceCodes(), response.noEvidence(), response.promptVersion(), response.legacy(), response.data()
);
}
public QueryResponse queryExternal(AuthenticatedApp app, QueryRequest rawRequest) {
QueryRequest request = validate(rawRequest);
if (hasText(request.toolCode())) {
throw new ServiceException("外部调用应用不能使用内部数据工具", HttpStatus.FORBIDDEN);
}
return TenantHelper.dynamic(app.tenantId(), () -> {
Set<Long> spaceIds = accessService.resolveExternalSpaceIds(app, request.spaceCodes());
return queryDocuments(null, app, spaceIds, request);
});
}
private QueryResponse queryDocuments(AihrKnowledgePrincipal principal, AuthenticatedApp app,
Set<Long> spaceIds, QueryRequest request) {
String requestId = UUID.randomUUID().toString().replace("-", "");
long started = System.nanoTime();
List<String> scopeCodes = spaceCodes(app.tenantId(), spaceIds);
try {
SearchResponse legacy = sopService.searchAuthorized(new SearchRequest(
request.queryText(), request.category(), request.position(), request.source(), request.limit()),
principal == null ? "" : principal.extPartyId(), spaceIds);
List<Citation> citations = citations(app.tenantId(), spaceIds, legacy.snippets());
boolean noEvidence = citations.isEmpty();
String status = noEvidence ? "NO_EVIDENCE" : "SUCCESS";
long latency = elapsedMillis(started);
auditService.record(requestId, principal, app, request.queryText(), scopeCodes,
citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, legacy.promptVersion());
return new QueryResponse(requestId, request.queryText(), legacy.answer(), citations, scopeCodes,
noEvidence, legacy.promptVersion(), legacy, null);
} catch (RuntimeException ex) {
auditService.record(requestId, principal, app, request.queryText(), scopeCodes, List.of(),
ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500
? "REJECTED" : "FAILED",
elapsedMillis(started), null);
throw ex;
}
}
private QueryResponse queryDataTool(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request) {
String requestId = UUID.randomUUID().toString().replace("-", "");
long started = System.nanoTime();
try {
ToolResult result = dataToolService.execute(principal, request.toolCode());
Citation citation = new Citation(null, "DATA_TOOL", result.toolCode(), result.title(), result.window(), null);
auditService.record(requestId, principal, app, request.queryText(), List.of(),
List.of("DATA_TOOL:" + result.toolCode()), "SUCCESS", elapsedMillis(started), "data-tool-v1");
return new QueryResponse(requestId, request.queryText(), result.answer(), List.of(citation), List.of(),
false, "data-tool-v1", null, result.data());
} catch (RuntimeException ex) {
auditService.record(requestId, principal, app, request.queryText(), List.of(),
List.of("DATA_TOOL:" + request.toolCode()),
ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500
? "REJECTED" : "FAILED",
elapsedMillis(started), "data-tool-v1");
throw ex;
}
}
private List<Citation> citations(String tenantId, Set<Long> allowedSpaceIds, List<SnippetResponse> snippets) {
if (snippets == null || snippets.isEmpty()) {
return List.of();
}
List<Long> fragmentIds = snippets.stream().map(SnippetResponse::fragmentId)
.filter(id -> id != null && id > 0).distinct().toList();
if (fragmentIds.isEmpty()) {
return List.of();
}
List<Object> args = new ArrayList<>();
args.add(tenantId);
args.addAll(allowedSpaceIds);
args.addAll(fragmentIds);
List<Citation> rows = jdbcTemplate.query("""
select k.code as space_code, f.doc_id, coalesce(a.name, k.name) as title,
f.content, f.id as fragment_id
from aihr_knowledge_fragment f
join aihr_knowledge_info k on k.id = f.knowledge_id and k.tenant_id = f.tenant_id
left join aihr_knowledge_attach a
on a.tenant_id = f.tenant_id and a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id
where f.tenant_id = ?
and f.knowledge_id in (%s)
and f.id in (%s)
""".formatted(placeholders(allowedSpaceIds.size()), placeholders(fragmentIds.size())),
(rs, rowNum) -> new Citation(
rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"),
truncate(rs.getString("content"), 500), rs.getLong("fragment_id")
), args.toArray());
Map<Long, Citation> byId = new LinkedHashMap<>();
rows.forEach(row -> byId.put(row.fragmentId(), row));
return fragmentIds.stream().map(byId::get).filter(java.util.Objects::nonNull).toList();
}
private List<String> spaceCodes(String tenantId, Set<Long> spaceIds) {
if (spaceIds.isEmpty()) {
return List.of();
}
List<Object> args = new ArrayList<>();
args.add(tenantId);
args.addAll(spaceIds);
return jdbcTemplate.queryForList("""
select code from aihr_knowledge_info
where tenant_id = ? and id in (%s) and status = 'ACTIVE'
order by id
""".formatted(placeholders(spaceIds.size())), String.class, args.toArray());
}
private static QueryRequest validate(QueryRequest request) {
String query = request == null || request.queryText() == null ? "" : request.queryText().trim();
if (query.isBlank() || query.length() > 1000) {
throw new ServiceException("问题长度必须为 1-1000 字", HttpStatus.BAD_REQUEST);
}
int limit = request.limit() == null ? 5 : request.limit();
if (limit < 1 || limit > 10) {
throw new ServiceException("limit 仅支持 1-10", HttpStatus.BAD_REQUEST);
}
List<String> codes = request.spaceCodes() == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(request.spaceCodes()));
if (codes.size() > 20) {
throw new ServiceException("单次最多选择 20 个知识空间", HttpStatus.BAD_REQUEST);
}
return new QueryRequest(query, codes, request.category(), request.position(), request.source(), limit, request.toolCode());
}
private static long elapsedMillis(long started) {
return Math.max(0, (System.nanoTime() - started) / 1_000_000);
}
private static String placeholders(int size) {
return String.join(",", java.util.Collections.nCopies(size, "?"));
}
private static String truncate(String value, int max) {
String text = value == null ? "" : value;
return text.length() <= max ? text : text.substring(0, max);
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
@@ -0,0 +1,490 @@
package org.dromara.aihr.knowledge.service;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.TokenIssuedResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AdminContext;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AppView;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateAppRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateAppResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.CreateSpaceRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.DocumentView;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.GrantItem;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.SpaceView;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UnbindDocumentResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateAppRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateSpaceRequest;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeSpaceAdminService {
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgeAppService appService;
private final AihrSopSeedService sopService;
public List<SpaceView> spaces(AdminContext context) {
requireContext(context);
List<Object> args = new ArrayList<>();
args.add(context.tenantId());
String scope = "";
if (!context.superAdmin()) {
scope = """
and exists (select 1 from aihr_knowledge_space_grant g
where g.tenant_id = k.tenant_id and g.knowledge_id = k.id
and g.permission = 'MANAGE' and g.status = 'ACTIVE'
and ((g.principal_type = 'USER' and g.principal_value = ?)%s))
""".formatted(roleScope(context.roles(), args));
args.add(1, String.valueOf(context.operatorId()));
}
return jdbcTemplate.query("""
select k.id, k.code, k.name, coalesce(k.description, '') description, k.space_type,
k.sensitivity_level, k.status,
count(distinct a.id) document_count, count(distinct f.id) fragment_count
from aihr_knowledge_info k
left join aihr_knowledge_attach a on a.tenant_id = k.tenant_id and a.knowledge_id = k.id
left join aihr_knowledge_fragment f on f.tenant_id = k.tenant_id and f.knowledge_id = k.id
where k.tenant_id = ? %s
group by k.id, k.code, k.name, k.description, k.space_type, k.sensitivity_level, k.status
order by k.id
""".formatted(scope), (rs, rowNum) -> new SpaceView(
rs.getLong("id"), rs.getString("code"), rs.getString("name"), rs.getString("description"),
rs.getString("space_type"), rs.getString("sensitivity_level"), rs.getString("status"),
rs.getInt("document_count"), rs.getInt("fragment_count")), args.toArray());
}
public List<DocumentView> documents(AdminContext context, Long spaceId) {
requireSpace(context, spaceId, true);
return jdbcTemplate.query("""
select a.id, a.doc_id, a.name, a.type,
case a.status when 0 then '等待解析' when 1 then '解析中' when 2 then '已解析' else '解析失败' end as status,
a.oss_id, count(f.id) fragment_count, a.update_time
from aihr_knowledge_attach a
left join aihr_knowledge_fragment f
on f.tenant_id = a.tenant_id and f.knowledge_id = a.knowledge_id and f.doc_id = a.doc_id
where a.tenant_id = ? and a.knowledge_id = ?
group by a.id, a.doc_id, a.name, a.type, a.status, a.oss_id, a.update_time
order by a.id desc
""", (rs, rowNum) -> new DocumentView(
rs.getLong("id"), rs.getString("doc_id"), rs.getString("name"), rs.getString("type"),
rs.getString("status"), rs.getObject("oss_id", Long.class), rs.getInt("fragment_count"),
rs.getTimestamp("update_time") == null ? null : rs.getTimestamp("update_time").toLocalDateTime()),
context.tenantId(), spaceId);
}
@Transactional
public UnbindDocumentResponse unbindDocument(AdminContext context, Long spaceId, Long attachId) {
SpaceView space = requireSpace(context, spaceId, true);
UnbindDocumentResponse response = sopService.unbindDocumentMembership(space.code(), attachId);
audit(context, "UNBIND", "DOCUMENT", attachId,
"space=" + space.code() + ",ossId=" + response.ossId(),
"membership=removed,ossDeleted=" + response.ossDeleted());
return response;
}
@Transactional
public SpaceView createSpace(AdminContext context, CreateSpaceRequest request) {
requireContext(context);
String code = code(request == null ? null : request.code());
String name = required(request == null ? null : request.name(), "空间名称");
String type = option(request.spaceType(), Set.of("PUBLIC", "BUSINESS", "MANAGEMENT", "EXTERNAL"), "BUSINESS");
String sensitivity = option(request.sensitivityLevel(), Set.of("PUBLIC", "INTERNAL", "CONFIDENTIAL"), "INTERNAL");
String status = option(request.status(), Set.of("DRAFT", "ACTIVE", "DISABLED"), "DRAFT");
KeyHolder keys = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_knowledge_info
(tenant_id, name, code, description, space_type, sensitivity_level, status, `separator`, overlap_char,
retrieve_limit, similarity_threshold, text_block_size, enable_hybrid, hybrid_alpha, system_prompt,
create_by, create_time, update_by, update_time, remark)
values (?, ?, ?, ?, ?, ?, ?, '\n\n', 120, 5, 0.5, 800, 1, 1.0, ?, ?, now(), ?, now(), 'knowledge-space-admin')
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, context.tenantId());
statement.setString(2, name);
statement.setString(3, code);
statement.setString(4, text(request.description(), 500));
statement.setString(5, type);
statement.setString(6, sensitivity);
statement.setString(7, status);
statement.setString(8, "只能基于当前知识空间已授权片段回答。");
statement.setObject(9, context.operatorId());
statement.setObject(10, context.operatorId());
return statement;
}, keys);
long id = keys.getKey().longValue();
jdbcTemplate.update("""
insert into aihr_knowledge_space_grant
(tenant_id, knowledge_id, principal_type, principal_value, permission, status, create_by, create_time, update_by, update_time)
values (?, ?, 'USER', ?, 'MANAGE', 'ACTIVE', ?, now(), ?, now())
""", context.tenantId(), id, String.valueOf(context.operatorId()), context.operatorId(), context.operatorId());
audit(context, "CREATE", "SPACE", id, "", summary(code, name, type, sensitivity, status));
return requireSpace(context, id, false);
}
@Transactional
public SpaceView updateSpace(AdminContext context, Long id, UpdateSpaceRequest request) {
SpaceView before = requireSpace(context, id, true);
if (request != null && hasText(request.code()) && !before.code().equals(request.code().trim())) {
throw new ServiceException("空间编码创建后不可修改", HttpStatus.BAD_REQUEST);
}
String name = required(request == null ? null : request.name(), "空间名称");
String type = option(request.spaceType(), Set.of("PUBLIC", "BUSINESS", "MANAGEMENT", "EXTERNAL"), before.spaceType());
String sensitivity = option(request.sensitivityLevel(), Set.of("PUBLIC", "INTERNAL", "CONFIDENTIAL"), before.sensitivityLevel());
String status = option(request.status(), Set.of("DRAFT", "ACTIVE", "DISABLED"), before.status());
jdbcTemplate.update("""
update aihr_knowledge_info set name = ?, description = ?, space_type = ?, sensitivity_level = ?, status = ?,
update_by = ?, update_time = now()
where tenant_id = ? and id = ?
""", name, text(request.description(), 500), type, sensitivity, status,
context.operatorId(), context.tenantId(), id);
SpaceView after = requireSpace(context, id, false);
audit(context, "UPDATE", "SPACE", id, spaceSummary(before), spaceSummary(after));
return after;
}
public List<GrantItem> grants(AdminContext context, Long spaceId) {
requireSpace(context, spaceId, true);
return jdbcTemplate.query("""
select principal_type, principal_value, permission, status
from aihr_knowledge_space_grant
where tenant_id = ? and knowledge_id = ? order by id
""", (rs, rowNum) -> new GrantItem(rs.getString(1), rs.getString(2), rs.getString(3), rs.getString(4)),
context.tenantId(), spaceId);
}
@Transactional
public List<GrantItem> replaceGrants(AdminContext context, Long spaceId, List<GrantItem> rawGrants) {
requireSpace(context, spaceId, true);
List<GrantItem> before = grants(context, spaceId);
List<GrantItem> grants = rawGrants == null ? List.of() : rawGrants.stream()
.map(AihrKnowledgeSpaceAdminService::validateGrant).distinct().toList();
jdbcTemplate.update("delete from aihr_knowledge_space_grant where tenant_id = ? and knowledge_id = ?",
context.tenantId(), spaceId);
for (GrantItem grant : grants) {
jdbcTemplate.update("""
insert into aihr_knowledge_space_grant
(tenant_id, knowledge_id, principal_type, principal_value, permission, status, create_by, create_time, update_by, update_time)
values (?, ?, ?, ?, ?, ?, ?, now(), ?, now())
""", context.tenantId(), spaceId, grant.principalType(), grant.principalValue(), grant.permission(),
grant.status(), context.operatorId(), context.operatorId());
}
audit(context, "REPLACE", "GRANT", spaceId, before.toString(), grants.toString());
return grants;
}
public List<AppView> apps(AdminContext context) {
requireTenantAdmin(context);
return jdbcTemplate.query("""
select a.id, a.app_code, a.app_name, a.auth_type, a.internal_client_key, a.status,
a.rate_limit_per_minute, a.expires_time, a.last_used_time,
group_concat(k.code order by k.id separator ',') as space_codes
from aihr_knowledge_app a
left join aihr_knowledge_app_space s on s.tenant_id = a.tenant_id and s.app_id = a.id
left join aihr_knowledge_info k on k.tenant_id = s.tenant_id and k.id = s.knowledge_id
where a.tenant_id = ?
group by a.id, a.app_code, a.app_name, a.auth_type, a.internal_client_key, a.status,
a.rate_limit_per_minute, a.expires_time, a.last_used_time
order by a.id
""", (rs, rowNum) -> new AppView(
rs.getLong("id"), rs.getString("app_code"), rs.getString("app_name"), rs.getString("auth_type"),
rs.getString("internal_client_key"), rs.getString("status"), rs.getInt("rate_limit_per_minute"),
rs.getTimestamp("expires_time") == null ? null : rs.getTimestamp("expires_time").toLocalDateTime(),
rs.getTimestamp("last_used_time") == null ? null : rs.getTimestamp("last_used_time").toLocalDateTime(),
csv(rs.getString("space_codes"))), context.tenantId());
}
@Transactional
public CreateAppResponse createApp(AdminContext context, CreateAppRequest request) {
requireTenantAdmin(context);
String appCode = appCode(request == null ? null : request.appCode());
String appName = required(request == null ? null : request.appName(), "应用名称");
String authType = option(request.authType(), Set.of("SESSION", "API_TOKEN"), "SESSION");
String clientKey = "SESSION".equals(authType) ? required(request.internalClientKey(), "内部客户端标识") : null;
String status = option(request.status(), Set.of("ACTIVE", "DISABLED"), "ACTIVE");
int rate = rate(request.rateLimitPerMinute());
String token = "API_TOKEN".equals(authType) ? AihrKnowledgeAppService.generateToken(appCode) : null;
KeyHolder keys = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_knowledge_app
(tenant_id, app_code, app_name, auth_type, internal_client_key, token_hash, status,
rate_limit_per_minute, expires_time, create_by, create_time, update_by, update_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), ?, now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, context.tenantId());
statement.setString(2, appCode);
statement.setString(3, appName);
statement.setString(4, authType);
statement.setString(5, clientKey);
statement.setString(6, token == null ? null : AihrKnowledgeAppService.sha256(token));
statement.setString(7, status);
statement.setInt(8, rate);
statement.setObject(9, request.expiresTime());
statement.setObject(10, context.operatorId());
statement.setObject(11, context.operatorId());
return statement;
}, keys);
long id = keys.getKey().longValue();
replaceAppSpacesInternal(context, id, request.spaceCodes());
audit(context, "CREATE", "APP", id, "", "code=" + appCode + ",authType=" + authType + ",status=" + status);
return new CreateAppResponse(app(context, id), token);
}
@Transactional
public AppView updateApp(AdminContext context, Long id, UpdateAppRequest request) {
AppView before = app(context, id);
jdbcTemplate.update("""
update aihr_knowledge_app set app_name = ?, status = ?, rate_limit_per_minute = ?, expires_time = ?,
update_by = ?, update_time = now()
where tenant_id = ? and id = ?
""", required(request.appName(), "应用名称"), option(request.status(), Set.of("ACTIVE", "DISABLED"), before.status()),
rate(request.rateLimitPerMinute()), request.expiresTime(), context.operatorId(), context.tenantId(), id);
AppView after = app(context, id);
audit(context, "UPDATE", "APP", id, appSummary(before), appSummary(after));
return after;
}
@Transactional
public AppView replaceAppSpaces(AdminContext context, Long appId, List<String> spaceCodes) {
AppView before = app(context, appId);
replaceAppSpacesInternal(context, appId, spaceCodes);
AppView after = app(context, appId);
audit(context, "REPLACE", "APP_SPACE", appId, before.spaceCodes().toString(), after.spaceCodes().toString());
return after;
}
public TokenIssuedResponse rotateToken(AdminContext context, Long appId, LocalDateTime expiresTime) {
AppView app = app(context, appId);
if (!"API_TOKEN".equals(app.authType())) {
throw new ServiceException("内部会话应用不使用访问令牌", HttpStatus.BAD_REQUEST);
}
TokenIssuedResponse response = appService.rotateToken(context.tenantId(), appId, expiresTime);
audit(context, "ROTATE", "TOKEN", appId, "token=hidden", "token=rotated,expires=" + expiresTime);
return response;
}
private void replaceAppSpacesInternal(AdminContext context, Long appId, List<String> rawCodes) {
requireAppTenant(context, appId);
List<String> codes = cleanCodes(rawCodes);
List<Long> ids = spaceIds(context, codes, true);
jdbcTemplate.update("delete from aihr_knowledge_app_space where tenant_id = ? and app_id = ?", context.tenantId(), appId);
for (Long spaceId : ids) {
jdbcTemplate.update("""
insert into aihr_knowledge_app_space (tenant_id, app_id, knowledge_id, create_by, create_time)
values (?, ?, ?, ?, now())
""", context.tenantId(), appId, spaceId, context.operatorId());
}
}
private SpaceView requireSpace(AdminContext context, Long id, boolean requireManage) {
requireContext(context);
List<SpaceView> rows = jdbcTemplate.query("""
select k.id, k.code, k.name, coalesce(k.description, ''), k.space_type, k.sensitivity_level, k.status,
(select count(*) from aihr_knowledge_attach a where a.tenant_id = k.tenant_id and a.knowledge_id = k.id),
(select count(*) from aihr_knowledge_fragment f where f.tenant_id = k.tenant_id and f.knowledge_id = k.id)
from aihr_knowledge_info k where k.tenant_id = ? and k.id = ?
""", (rs, rowNum) -> new SpaceView(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4),
rs.getString(5), rs.getString(6), rs.getString(7), rs.getInt(8), rs.getInt(9)), context.tenantId(), id);
if (rows.isEmpty()) {
throw new ServiceException("知识空间不存在", HttpStatus.NOT_FOUND);
}
if (requireManage && !context.superAdmin() && !canManage(context, id)) {
throw new ServiceException("没有该知识空间的管理权限", HttpStatus.FORBIDDEN);
}
return rows.get(0);
}
private AppView app(AdminContext context, Long id) {
requireTenantAdmin(context);
return apps(context).stream().filter(row -> row.id().equals(id)).findFirst()
.orElseThrow(() -> new ServiceException("调用应用不存在", HttpStatus.NOT_FOUND));
}
private void requireAppTenant(AdminContext context, Long appId) {
Integer count = jdbcTemplate.queryForObject("select count(*) from aihr_knowledge_app where tenant_id = ? and id = ?",
Integer.class, context.tenantId(), appId);
if (count == null || count == 0) {
throw new ServiceException("调用应用不存在", HttpStatus.NOT_FOUND);
}
}
private List<Long> spaceIds(AdminContext context, List<String> codes, boolean requireManage) {
if (codes.isEmpty()) {
return List.of();
}
List<Object> args = new ArrayList<>();
args.add(context.tenantId());
args.addAll(codes);
List<Long> ids = jdbcTemplate.queryForList("select id from aihr_knowledge_info where tenant_id = ? and code in (%s)"
.formatted(placeholders(codes.size())), Long.class, args.toArray());
if (ids.size() != codes.size()) {
throw new ServiceException("应用绑定包含其他租户或不存在的空间", HttpStatus.BAD_REQUEST);
}
if (requireManage && !context.superAdmin() && ids.stream().anyMatch(id -> !canManage(context, id))) {
throw new ServiceException("应用绑定包含无管理权限的空间", HttpStatus.FORBIDDEN);
}
return ids;
}
private boolean canManage(AdminContext context, Long spaceId) {
List<Object> args = new ArrayList<>();
args.add(context.tenantId());
args.add(spaceId);
args.add(String.valueOf(context.operatorId()));
String roles = roleScope(context.roles(), args);
Integer count = jdbcTemplate.queryForObject("""
select count(*) from aihr_knowledge_space_grant g
where g.tenant_id = ? and g.knowledge_id = ? and g.permission = 'MANAGE' and g.status = 'ACTIVE'
and ((g.principal_type = 'USER' and g.principal_value = ?)%s)
""".formatted(roles), Integer.class, args.toArray());
return count != null && count > 0;
}
private void requireTenantAdmin(AdminContext context) {
requireContext(context);
if (context.superAdmin()) {
return;
}
List<Object> args = new ArrayList<>();
args.add(context.tenantId());
args.add(String.valueOf(context.operatorId()));
String roles = roleScope(context.roles(), args);
Integer count = jdbcTemplate.queryForObject("""
select count(*) from aihr_knowledge_space_grant g
where g.tenant_id = ? and g.permission = 'MANAGE' and g.status = 'ACTIVE'
and ((g.principal_type = 'USER' and g.principal_value = ?)%s)
""".formatted(roles), Integer.class, args.toArray());
if (count == null || count == 0) {
throw new ServiceException("没有知识平台管理权限", HttpStatus.FORBIDDEN);
}
}
private void audit(AdminContext context, String action, String type, Long objectId, String before, String after) {
jdbcTemplate.update("""
insert into aihr_knowledge_admin_audit
(tenant_id, operator_id, action, object_type, object_id, before_summary, after_summary, create_time)
values (?, ?, ?, ?, ?, ?, ?, now())
""", context.tenantId(), context.operatorId(), action, type, objectId, text(before, 1000), text(after, 1000));
}
private static String roleScope(Set<String> roles, List<Object> args) {
if (roles == null || roles.isEmpty()) {
return "";
}
args.addAll(roles);
return " or (g.principal_type = 'ROLE' and g.principal_value in (" + placeholders(roles.size()) + "))";
}
private static GrantItem validateGrant(GrantItem grant) {
if (grant == null) {
throw new ServiceException("授权项不能为空", HttpStatus.BAD_REQUEST);
}
String type = option(grant.principalType(), Set.of("ROLE", "USER"), null);
String value = required(grant.principalValue(), "授权主体");
String permission = option(grant.permission(), Set.of("READ", "MANAGE"), null);
String status = option(grant.status(), Set.of("ACTIVE", "DISABLED"), "ACTIVE");
return new GrantItem(type, value, permission, status);
}
private static String code(String value) {
String result = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
if (!result.matches("[a-z0-9][a-z0-9_]{2,99}")) {
throw new ServiceException("空间编码仅支持 3-100 位小写字母、数字和下划线", HttpStatus.BAD_REQUEST);
}
return result;
}
private static String appCode(String value) {
String result = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
if (!result.matches("[a-z0-9_]{3,64}")) {
throw new ServiceException("应用编码仅支持 3-64 位小写字母、数字和下划线", HttpStatus.BAD_REQUEST);
}
return result;
}
private static String option(String value, Set<String> allowed, String fallback) {
String result = hasText(value) ? value.trim().toUpperCase(Locale.ROOT) : fallback;
if (result == null || !allowed.contains(result)) {
throw new ServiceException("参数取值不正确", HttpStatus.BAD_REQUEST);
}
return result;
}
private static int rate(Integer value) {
int rate = value == null ? 60 : value;
if (rate < 1 || rate > 10000) {
throw new ServiceException("每分钟限流必须为 1-10000", HttpStatus.BAD_REQUEST);
}
return rate;
}
private static List<String> cleanCodes(List<String> values) {
LinkedHashSet<String> result = new LinkedHashSet<>();
if (values != null) {
values.stream().filter(AihrKnowledgeSpaceAdminService::hasText).map(String::trim).forEach(result::add);
}
if (result.size() > 20) {
throw new ServiceException("单个应用最多绑定 20 个知识空间", HttpStatus.BAD_REQUEST);
}
return List.copyOf(result);
}
private static List<String> csv(String value) {
return hasText(value) ? List.of(value.split(",")) : List.of();
}
private static String required(String value, String label) {
if (!hasText(value)) {
throw new ServiceException(label + "不能为空", HttpStatus.BAD_REQUEST);
}
return text(value.trim(), 100);
}
private static void requireContext(AdminContext context) {
if (context == null || !hasText(context.tenantId()) || context.operatorId() == null) {
throw new ServiceException("管理登录身份缺失", HttpStatus.UNAUTHORIZED);
}
}
private static String placeholders(int count) {
return String.join(",", java.util.Collections.nCopies(count, "?"));
}
private static String spaceSummary(SpaceView value) {
return summary(value.code(), value.name(), value.spaceType(), value.sensitivityLevel(), value.status());
}
private static String summary(String code, String name, String type, String sensitivity, String status) {
return "code=" + code + ",name=" + name + ",type=" + type + ",sensitivity=" + sensitivity + ",status=" + status;
}
private static String appSummary(AppView value) {
return "code=" + value.appCode() + ",name=" + value.appName() + ",status=" + value.status()
+ ",rate=" + value.rateLimitPerMinute() + ",expires=" + value.expiresTime();
}
private static String text(String value, int max) {
String result = value == null ? "" : value;
return result.length() <= max ? result : result.substring(0, max);
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
@@ -0,0 +1,138 @@
package org.dromara.aihr.learning;
import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult;
import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDraft;
import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest;
import org.dromara.common.core.exception.ServiceException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** Pure validation and scoring rules so exam submissions stay deterministic and testable. */
final class AihrExamScoring {
private static final Set<String> QUESTION_TYPES = Set.of("single", "multiple", "judge", "short");
private AihrExamScoring() {
}
static void validate(ExamUpsertRequest request) {
if (request == null || clean(request.title()).isEmpty()) {
throw new ServiceException("请填写考试名称");
}
int passScore = request.passScore() == null ? 80 : request.passScore();
if (passScore < 1 || passScore > 100) {
throw new ServiceException("及格分必须在1-100之间");
}
List<ExamQuestionDraft> questions = request.questions() == null ? List.of() : request.questions();
if (questions.isEmpty()) {
throw new ServiceException("请至少添加一道考试题");
}
if (questions.size() > 100) {
throw new ServiceException("单份试卷最多100题");
}
validateTargets(request.targets());
int total = 0;
for (ExamQuestionDraft question : questions) {
validateQuestion(question);
total += question.score();
}
if (total != 100) {
throw new ServiceException("试卷题目分值合计必须为100分");
}
}
static void validateTargets(List<AihrLearningDto.ExamTargetDraft> targets) {
if (targets == null || targets.isEmpty()) {
return;
}
if (targets.size() > 100) {
throw new ServiceException("单次考试最多选择100名员工");
}
Set<String> identities = new LinkedHashSet<>();
for (AihrLearningDto.ExamTargetDraft target : targets) {
String identity = target == null ? "" : clean(target.extPartyId());
if (identity.isEmpty()) {
throw new ServiceException("考试人员身份不能为空");
}
if (!identities.add(identity)) {
throw new ServiceException("考试人员不能重复选择");
}
}
}
private static void validateQuestion(ExamQuestionDraft question) {
if (question == null || clean(question.stem()).isEmpty()) {
throw new ServiceException("考试题干不能为空");
}
String type = clean(question.questionType()).toLowerCase(Locale.ROOT);
if (!QUESTION_TYPES.contains(type)) {
throw new ServiceException("不支持的考试题型");
}
if (question.score() == null || question.score() < 1 || question.score() > 100) {
throw new ServiceException("单题分值必须在1-100之间");
}
List<String> correct = normalized(question.correctAnswers());
if (correct.isEmpty()) {
throw new ServiceException("请设置考试题参考答案");
}
if (!"short".equals(type)) {
List<String> options = normalized(question.options());
if (options.size() < 2 || !options.containsAll(correct)) {
throw new ServiceException("客观题选项或答案配置不正确");
}
}
}
static ScoreResult score(List<ExamQuestionDraft> questions, List<ExamAnswerRequest> answers) {
Map<Long, List<String>> answerMap = new LinkedHashMap<>();
for (ExamAnswerRequest answer : answers == null ? List.<ExamAnswerRequest>of() : answers) {
if (answer != null && answer.questionId() != null) {
answerMap.put(answer.questionId(), normalized(answer.answers()));
}
}
int earned = 0;
List<ExamAnswerResult> results = new ArrayList<>();
for (ExamQuestionDraft question : questions == null ? List.<ExamQuestionDraft>of() : questions) {
List<String> actual = answerMap.getOrDefault(question.id(), List.of());
boolean correct = isCorrect(question.questionType(), normalized(question.correctAnswers()), actual);
int itemScore = correct ? question.score() : 0;
earned += itemScore;
results.add(new ExamAnswerResult(question.id(), correct, itemScore, question.score(),
correct ? "回答正确" : clean(question.explanation())));
}
return new ScoreResult(Math.max(0, Math.min(100, earned)), List.copyOf(results));
}
private static boolean isCorrect(String type, List<String> expected, List<String> actual) {
if ("short".equalsIgnoreCase(clean(type))) {
String text = String.join(" ", actual).toLowerCase(Locale.ROOT);
return !expected.isEmpty() && expected.stream().allMatch(text::contains);
}
return new LinkedHashSet<>(expected).equals(new LinkedHashSet<>(actual));
}
static List<String> normalized(List<String> values) {
LinkedHashSet<String> result = new LinkedHashSet<>();
for (String value : values == null ? List.<String>of() : values) {
String normalized = clean(value).toLowerCase(Locale.ROOT);
if (!normalized.isEmpty()) {
result.add(normalized);
}
}
return List.copyOf(result);
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
record ScoreResult(int score, List<ExamAnswerResult> results) {
}
}
@@ -0,0 +1,763 @@
package org.dromara.aihr.learning;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult;
import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDraft;
import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamSummaryResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamTargetDraft;
import org.dromara.aihr.learning.AihrLearningDto.ExamTargetResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** Supervisor-authored onboarding exams and employee submission flow. */
@Service
@RequiredArgsConstructor
public class AihrExamService {
private static final String DEFAULT_TENANT = "000000";
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final TypeReference<List<String>> STRING_LIST = new TypeReference<>() { };
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
private volatile boolean schemaReady;
@PostConstruct
void initializeSchema() {
ensureSchema();
}
public List<ExamSummaryResponse> employeeExams(String extPartyId) {
ensureSchema();
String identity = canonicalIdentity(requireIdentity(extPartyId));
return jdbcTemplate.query("""
SELECT e.id, e.title, e.project_code, e.position_code, e.status, e.total_score, e.pass_score,
e.due_date, COUNT(DISTINCT q.id) AS question_count,
COUNT(DISTINCT all_target.ext_party_id) AS target_count,
COUNT(DISTINCT a.id) AS submission_count, MAX(a.score) AS best_score,
MAX(CASE WHEN a.passed = 1 THEN 1 ELSE 0 END) AS passed
FROM aihr_onboard_exam e
JOIN aihr_onboard_exam_target own_target
ON own_target.tenant_id = e.tenant_id AND own_target.exam_id = e.id AND own_target.ext_party_id = ?
LEFT JOIN aihr_onboard_exam_target all_target
ON all_target.tenant_id = e.tenant_id AND all_target.exam_id = e.id
LEFT JOIN aihr_onboard_exam_question q ON q.tenant_id = e.tenant_id AND q.exam_id = e.id
LEFT JOIN aihr_onboard_exam_attempt a ON a.tenant_id = e.tenant_id AND a.exam_id = e.id AND a.ext_party_id = ?
WHERE e.tenant_id = ? AND e.status = 'PUBLISHED'
AND (e.due_date IS NULL OR e.due_date >= CURRENT_DATE())
GROUP BY e.id, e.title, e.project_code, e.position_code, e.status, e.total_score, e.pass_score, e.due_date
ORDER BY CASE WHEN submission_count = 0 THEN 0 ELSE 1 END, COALESCE(e.due_date, '9999-12-31'), e.id DESC
""", (rs, rowNum) -> mapSummary(rs), identity, identity, tenantId());
}
public ExamDetailResponse employeeExam(Long examId, String extPartyId) {
ensureSchema();
String identity = canonicalIdentity(requireIdentity(extPartyId));
ExamHeader header = requireEmployeeExam(examId, identity);
return detail(header, false, identity);
}
@Transactional(rollbackFor = Exception.class)
public ExamSubmitResponse submit(Long examId, ExamSubmitRequest request, String extPartyId) {
ensureSchema();
String identity = canonicalIdentity(requireIdentity(extPartyId));
ExamHeader header = requireEmployeeExam(examId, identity);
String requestId = normalizeRequestId(request == null ? null : request.requestId());
ExamSubmitResponse existing = findAttempt(examId, identity, requestId);
if (existing != null) {
return existing;
}
List<ExamQuestionDraft> questions = loadQuestionDrafts(examId);
AihrExamScoring.ScoreResult score = AihrExamScoring.score(questions,
request == null ? List.of() : request.answers());
boolean passed = score.score() >= header.passScore();
// MySQL DATETIME has second precision here. Normalize before insert so the first
// response and an idempotent replay cannot differ because JDBC rounds nanos.
LocalDateTime submittedAt = databaseSecondPrecision(LocalDateTime.now());
long attemptId;
try {
attemptId = insertAttempt(examId, identity, requestId, score.score(), passed, submittedAt);
} catch (DuplicateKeyException duplicate) {
ExamSubmitResponse raced = findAttempt(examId, identity, requestId);
if (raced != null) {
return raced;
}
throw duplicate;
}
List<ExamAnswerRequest> answers = request == null || request.answers() == null ? List.of() : request.answers();
for (var result : score.results()) {
List<String> answer = answers.stream()
.filter(item -> item != null && result.questionId().equals(item.questionId()))
.findFirst().map(ExamAnswerRequest::answers).orElse(List.of());
jdbcTemplate.update("""
INSERT INTO aihr_onboard_exam_answer
(tenant_id, attempt_id, question_id, answer_json, correct, earned_score, create_time)
VALUES (?, ?, ?, ?, ?, ?, now())
""", tenantId(), attemptId, result.questionId(), json(answer), result.correct(), result.earnedScore());
}
return new ExamSubmitResponse(attemptId, "SUBMITTED", score.score(), passed,
submittedAt.format(TIME_FORMAT), score.results());
}
public List<ExamSummaryResponse> supervisorExams(String supervisorExtPartyId) {
ensureSchema();
String owner = clean(supervisorExtPartyId);
return jdbcTemplate.query("""
SELECT e.id, e.title, e.project_code, e.position_code, e.status, e.total_score, e.pass_score,
e.due_date, COUNT(DISTINCT q.id) AS question_count,
COUNT(DISTINCT t.ext_party_id) AS target_count,
COUNT(DISTINCT a.id) AS submission_count, MAX(a.score) AS best_score,
MAX(CASE WHEN a.passed = 1 THEN 1 ELSE 0 END) AS passed
FROM aihr_onboard_exam e
LEFT JOIN aihr_onboard_exam_question q ON q.tenant_id = e.tenant_id AND q.exam_id = e.id
LEFT JOIN aihr_onboard_exam_target t ON t.tenant_id = e.tenant_id AND t.exam_id = e.id
LEFT JOIN aihr_onboard_exam_attempt a ON a.tenant_id = e.tenant_id AND a.exam_id = e.id
WHERE e.tenant_id = ? AND (? = '' OR e.created_by = ?)
GROUP BY e.id, e.title, e.project_code, e.position_code, e.status, e.total_score, e.pass_score, e.due_date
ORDER BY e.id DESC
""", (rs, rowNum) -> mapSummary(rs), tenantId(), owner, owner);
}
@Transactional(rollbackFor = Exception.class)
public ExamDetailResponse create(ExamUpsertRequest request, String supervisorExtPartyId) {
ensureSchema();
AihrExamScoring.validate(request);
String owner = fallback(supervisorExtPartyId, "system");
LocalDate dueDate = parseDueDate(request.dueDate());
List<ResolvedTarget> targets = resolveTargets(request.targets(), supervisorExtPartyId);
long examId = insertExam(request, owner, dueDate);
saveQuestions(examId, request.questions());
saveTargets(examId, targets);
return supervisorExam(examId, supervisorExtPartyId);
}
public ExamDetailResponse supervisorExam(Long examId, String supervisorExtPartyId) {
ensureSchema();
return detail(requireSupervisorExam(examId, supervisorExtPartyId), true, null);
}
@Transactional(rollbackFor = Exception.class)
public ExamDetailResponse update(Long examId, ExamUpsertRequest request, String supervisorExtPartyId) {
ensureSchema();
AihrExamScoring.validate(request);
ExamHeader existing = requireSupervisorExam(examId, supervisorExtPartyId);
if (!"DRAFT".equals(existing.status())) {
throw new ServiceException("仅草稿考试可以修改");
}
LocalDate dueDate = parseDueDate(request.dueDate());
List<ResolvedTarget> targets = resolveTargets(request.targets(), supervisorExtPartyId);
int updated = jdbcTemplate.update("""
UPDATE aihr_onboard_exam
SET title = ?, project_code = ?, position_code = ?, pass_score = ?, due_date = ?, update_time = now()
WHERE tenant_id = ? AND id = ? AND status = 'DRAFT'
""", clean(request.title()), clean(request.projectCode()), clean(request.positionCode()),
request.passScore() == null ? 80 : request.passScore(), dueDate == null ? null : Date.valueOf(dueDate),
tenantId(), examId);
if (updated == 0) {
throw new ServiceException("考试不存在或状态已变化");
}
jdbcTemplate.update("DELETE FROM aihr_onboard_exam_question WHERE tenant_id = ? AND exam_id = ?", tenantId(), examId);
saveQuestions(examId, request.questions());
jdbcTemplate.update("DELETE FROM aihr_onboard_exam_target WHERE tenant_id = ? AND exam_id = ?", tenantId(), examId);
saveTargets(examId, targets);
return supervisorExam(examId, supervisorExtPartyId);
}
@Transactional(rollbackFor = Exception.class)
public ExamDetailResponse publish(Long examId, ExamPublishRequest request, String supervisorExtPartyId) {
ensureSchema();
ExamHeader header = requireSupervisorExam(examId, supervisorExtPartyId);
String requestId = normalizeOptionalRequestId(request == null ? null : request.requestId());
assertPublishRequestAvailable(examId, header.createdBy(), requestId);
if (!"DRAFT".equals(header.status())) {
return detail(header, true, null);
}
Integer score = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(score), 0) FROM aihr_onboard_exam_question WHERE tenant_id = ? AND exam_id = ?
""", Integer.class, tenantId(), examId);
if (score == null || score != 100) {
throw new ServiceException("试卷题目分值合计必须为100分");
}
Integer targetCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM aihr_onboard_exam_target WHERE tenant_id = ? AND exam_id = ?
""", Integer.class, tenantId(), examId);
if (targetCount == null || targetCount < 1 || targetCount > 100) {
throw new ServiceException("发布考试前请选择1-100名真实员工");
}
List<ExamTargetDraft> storedTargets = jdbcTemplate.query("""
SELECT ext_party_id, trainee_name FROM aihr_onboard_exam_target
WHERE tenant_id = ? AND exam_id = ? ORDER BY id
""", (rs, rowNum) -> new ExamTargetDraft(rs.getString("ext_party_id"), rs.getString("trainee_name")),
tenantId(), examId);
List<ResolvedTarget> currentTargets = resolveTargets(storedTargets, supervisorExtPartyId);
jdbcTemplate.update("DELETE FROM aihr_onboard_exam_target WHERE tenant_id = ? AND exam_id = ?",
tenantId(), examId);
saveTargets(examId, currentTargets);
try {
jdbcTemplate.update("""
UPDATE aihr_onboard_exam
SET status = 'PUBLISHED', publish_request_key = ?, published_time = now(), update_time = now()
WHERE tenant_id = ? AND id = ? AND status = 'DRAFT'
""", requestId.isEmpty() ? null : requestId, tenantId(), examId);
} catch (DuplicateKeyException duplicate) {
throw new ServiceException("发布请求ID已用于其他考试");
}
return supervisorExam(examId, supervisorExtPartyId);
}
public ExamDetailResponse publish(Long examId, String supervisorExtPartyId) {
return publish(examId, null, supervisorExtPartyId);
}
public List<ExamResultResponse> results(Long examId, String supervisorExtPartyId) {
ensureSchema();
requireSupervisorExam(examId, supervisorExtPartyId);
return jdbcTemplate.query("""
SELECT a.id, t.ext_party_id, t.trainee_name,
a.score, a.passed, a.submitted_time
FROM aihr_onboard_exam_target t
LEFT JOIN aihr_onboard_exam_attempt a
ON a.tenant_id = t.tenant_id AND a.exam_id = t.exam_id AND a.ext_party_id = t.ext_party_id
AND a.id = (SELECT a2.id FROM aihr_onboard_exam_attempt a2
WHERE a2.tenant_id = t.tenant_id AND a2.exam_id = t.exam_id
AND a2.ext_party_id = t.ext_party_id
ORDER BY a2.score DESC, a2.submitted_time DESC, a2.id DESC LIMIT 1)
WHERE t.tenant_id = ? AND t.exam_id = ?
ORDER BY CASE WHEN a.id IS NULL THEN 1 ELSE 0 END, a.score DESC, t.trainee_name, t.ext_party_id
""", (rs, rowNum) -> new ExamResultResponse(
(Long) rs.getObject("id"), rs.getString("ext_party_id"), rs.getString("trainee_name"),
(Integer) rs.getObject("score"), rs.getObject("passed") == null ? null : rs.getBoolean("passed"),
format(rs.getTimestamp("submitted_time")), rs.getObject("id") == null ? "PENDING" : "SUBMITTED"
), tenantId(), examId);
}
private ExamDetailResponse detail(ExamHeader header, boolean includeAnswers, String employeeIdentity) {
List<ExamQuestionResponse> questions = loadQuestionDrafts(header.id()).stream().map(question ->
new ExamQuestionResponse(question.id(), question.questionType(), question.stem(), question.options(),
includeAnswers ? question.correctAnswers() : List.of(), question.score(),
includeAnswers ? question.explanation() : "", question.sortOrder())).toList();
List<ExamTargetResponse> targets = loadTargets(header.id(), includeAnswers ? null : employeeIdentity);
return new ExamDetailResponse(header.id(), header.title(), header.projectCode(), header.positionCode(),
header.status(), 100, header.passScore(), header.dueDate(), questions, targets);
}
private ExamHeader requireEmployeeExam(Long examId, String extPartyId) {
String identity = canonicalIdentity(requireIdentity(extPartyId));
List<ExamHeader> rows = jdbcTemplate.query("""
SELECT e.id, e.title, e.project_code, e.position_code, e.status, e.pass_score, e.due_date,
e.created_by, e.publish_request_key
FROM aihr_onboard_exam e
JOIN aihr_onboard_exam_target t
ON t.tenant_id = e.tenant_id AND t.exam_id = e.id AND t.ext_party_id = ?
WHERE e.tenant_id = ? AND e.id = ? AND e.status = 'PUBLISHED'
AND (e.due_date IS NULL OR e.due_date >= CURRENT_DATE())
""", (rs, rowNum) -> mapHeader(rs), identity, tenantId(), examId);
if (rows.isEmpty()) {
throw new ServiceException("考试不存在、已截止或不适用于当前岗位");
}
return rows.get(0);
}
private ExamHeader requireSupervisorExam(Long examId, String supervisorExtPartyId) {
if (examId == null || examId <= 0) {
throw new ServiceException("考试不存在");
}
String owner = clean(supervisorExtPartyId);
List<ExamHeader> rows = jdbcTemplate.query("""
SELECT id, title, project_code, position_code, status, pass_score, due_date, created_by,
publish_request_key
FROM aihr_onboard_exam WHERE tenant_id = ? AND id = ? AND (? = '' OR created_by = ?)
""", (rs, rowNum) -> mapHeader(rs), tenantId(), examId, owner, owner);
if (rows.isEmpty()) {
throw new ServiceException("考试不存在或无权操作");
}
return rows.get(0);
}
private List<ExamQuestionDraft> loadQuestionDrafts(Long examId) {
return jdbcTemplate.query("""
SELECT id, question_type, stem, options_json, correct_answers_json, score, explanation, sort_order
FROM aihr_onboard_exam_question WHERE tenant_id = ? AND exam_id = ? ORDER BY sort_order, id
""", (rs, rowNum) -> new ExamQuestionDraft(
rs.getLong("id"), rs.getString("question_type"), rs.getString("stem"),
stringList(rs.getString("options_json")), stringList(rs.getString("correct_answers_json")),
rs.getInt("score"), rs.getString("explanation"), rs.getInt("sort_order")
), tenantId(), examId);
}
private long insertExam(ExamUpsertRequest request, String owner, LocalDate dueDate) {
KeyHolder key = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
INSERT INTO aihr_onboard_exam
(tenant_id, title, project_code, position_code, status, total_score, pass_score, due_date,
created_by, create_time, update_time)
VALUES (?, ?, ?, ?, 'DRAFT', 100, ?, ?, ?, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId());
statement.setString(2, clean(request.title()));
statement.setString(3, clean(request.projectCode()));
statement.setString(4, clean(request.positionCode()));
statement.setInt(5, request.passScore() == null ? 80 : request.passScore());
statement.setDate(6, dueDate == null ? null : Date.valueOf(dueDate));
statement.setString(7, owner);
return statement;
}, key);
Number id = key.getKey();
if (id == null) {
throw new ServiceException("创建考试失败");
}
return id.longValue();
}
private long insertAttempt(Long examId, String identity, String requestId, int score, boolean passed,
LocalDateTime submittedAt) {
KeyHolder key = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
INSERT INTO aihr_onboard_exam_attempt
(tenant_id, exam_id, ext_party_id, request_key, status, score, passed, submitted_time, create_time, update_time)
VALUES (?, ?, ?, ?, 'SUBMITTED', ?, ?, ?, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId());
statement.setLong(2, examId);
statement.setString(3, identity);
statement.setString(4, requestId);
statement.setInt(5, score);
statement.setBoolean(6, passed);
statement.setTimestamp(7, Timestamp.valueOf(submittedAt));
return statement;
}, key);
Number id = key.getKey();
if (id == null) {
throw new ServiceException("提交考试失败");
}
return id.longValue();
}
private void saveQuestions(Long examId, List<ExamQuestionDraft> questions) {
int index = 0;
for (ExamQuestionDraft question : questions) {
index++;
jdbcTemplate.update("""
INSERT INTO aihr_onboard_exam_question
(tenant_id, exam_id, question_type, stem, options_json, correct_answers_json, score,
explanation, sort_order, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
""", tenantId(), examId, clean(question.questionType()).toLowerCase(Locale.ROOT),
clean(question.stem()), json(question.options()), json(question.correctAnswers()), question.score(),
truncate(question.explanation(), 1000), question.sortOrder() == null ? index : question.sortOrder());
}
}
private void saveTargets(Long examId, List<ResolvedTarget> targets) {
for (ResolvedTarget target : targets) {
jdbcTemplate.update("""
INSERT INTO aihr_onboard_exam_target
(tenant_id, exam_id, ext_party_id, trainee_name, project_code, position_name, create_time)
VALUES (?, ?, ?, ?, ?, ?, now())
""", tenantId(), examId, target.extPartyId(), target.traineeName(), target.projectCode(),
target.positionName());
}
}
private List<ExamTargetResponse> loadTargets(Long examId, String employeeIdentity) {
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.add(examId);
String employeeSql = "";
if (!clean(employeeIdentity).isEmpty()) {
employeeSql = " AND ext_party_id = ?";
args.add(clean(employeeIdentity));
}
return jdbcTemplate.query("""
SELECT ext_party_id, trainee_name, project_code, position_name
FROM aihr_onboard_exam_target
WHERE tenant_id = ? AND exam_id = ?%s
ORDER BY trainee_name, ext_party_id
""".formatted(employeeSql), (rs, rowNum) -> new ExamTargetResponse(
rs.getString("ext_party_id"), rs.getString("trainee_name"), rs.getString("project_code"),
rs.getString("position_name")
), args.toArray());
}
private List<ResolvedTarget> resolveTargets(List<ExamTargetDraft> requested, String supervisorExtPartyId) {
AihrExamScoring.validateTargets(requested);
if (requested == null || requested.isEmpty()) {
return List.of();
}
Map<String, ExamTargetDraft> requestedById = new LinkedHashMap<>();
for (ExamTargetDraft target : requested) {
requestedById.put(clean(target.extPartyId()), target);
}
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.addAll(requestedById.keySet());
String scopeSql = "";
String supervisor = clean(supervisorExtPartyId);
if (!supervisor.isEmpty()) {
List<String> projects = supervisorProjects(supervisor);
if (projects.isEmpty()) {
throw new ServiceException("当前账号没有可指派的在职员工范围");
}
scopeSql = " AND project_code IN (" + placeholders(projects.size()) + ")";
args.addAll(projects);
}
List<ResolvedTarget> rows = jdbcTemplate.query("""
SELECT ext_party_id, person_name, project_code, position_name
FROM aihr_org_snapshot
WHERE tenant_id = ? AND employment_status = 'active'
AND ext_party_id IN (%s)%s
""".formatted(placeholders(requestedById.size()), scopeSql), (rs, rowNum) -> new ResolvedTarget(
rs.getString("ext_party_id"), fallback(rs.getString("person_name"), "员工"),
clean(rs.getString("project_code")), clean(rs.getString("position_name"))
), args.toArray());
Map<String, ResolvedTarget> resolved = new LinkedHashMap<>();
rows.forEach(row -> resolved.put(row.extPartyId(), row));
List<String> invalid = requestedById.keySet().stream().filter(id -> !resolved.containsKey(id)).toList();
if (!invalid.isEmpty()) {
throw new ServiceException("所选人员不存在、已离职或超出主管范围: " + String.join(",", invalid));
}
return requestedById.keySet().stream().map(resolved::get).toList();
}
private List<String> supervisorProjects(String supervisorIdentity) {
return jdbcTemplate.query("""
SELECT DISTINCT project_code
FROM aihr_org_snapshot
WHERE tenant_id = ? AND employment_status = 'active'
AND (ext_party_id = ? OR person_phone = ?)
AND position_level IN ('主管', '项目经理')
ORDER BY project_code
""", (rs, rowNum) -> rs.getString("project_code"), tenantId(), supervisorIdentity, supervisorIdentity);
}
private void assertPublishRequestAvailable(Long examId, String owner, String requestId) {
if (requestId.isEmpty()) {
return;
}
List<Long> rows = jdbcTemplate.query("""
SELECT id FROM aihr_onboard_exam
WHERE tenant_id = ? AND created_by = ? AND publish_request_key = ? LIMIT 1
""", (rs, rowNum) -> rs.getLong("id"), tenantId(), owner, requestId);
if (!rows.isEmpty() && !rows.get(0).equals(examId)) {
throw new ServiceException("发布请求ID已用于其他考试");
}
}
private ExamSubmitResponse findAttempt(Long examId, String identity, String requestId) {
List<AttemptRow> rows = jdbcTemplate.query("""
SELECT id, status, score, passed, submitted_time FROM aihr_onboard_exam_attempt
WHERE tenant_id = ? AND exam_id = ? AND ext_party_id = ? AND request_key = ? LIMIT 1
""", (rs, rowNum) -> new AttemptRow(rs.getLong("id"), rs.getString("status"), rs.getInt("score"),
rs.getBoolean("passed"), format(rs.getTimestamp("submitted_time"))),
tenantId(), examId, identity, requestId);
if (rows.isEmpty()) {
return null;
}
AttemptRow row = rows.get(0);
List<ExamAnswerResult> results = jdbcTemplate.query("""
SELECT answer.question_id, answer.correct, answer.earned_score,
question.score AS max_score,
CASE WHEN answer.correct = 1 THEN '回答正确'
ELSE COALESCE(question.explanation, '') END AS explanation
FROM aihr_onboard_exam_answer answer
JOIN aihr_onboard_exam_question question
ON question.tenant_id = answer.tenant_id
AND question.id = answer.question_id
AND question.exam_id = ?
WHERE answer.tenant_id = ? AND answer.attempt_id = ?
ORDER BY question.sort_order, question.id
""", (rs, rowNum) -> new ExamAnswerResult(
rs.getLong("question_id"), rs.getBoolean("correct"), rs.getInt("earned_score"),
rs.getInt("max_score"), clean(rs.getString("explanation"))
), examId, tenantId(), row.id());
return new ExamSubmitResponse(row.id(), row.status(), row.score(), row.passed(), row.submittedTime(), results);
}
private String canonicalIdentity(String identity) {
List<String> rows = jdbcTemplate.query("""
SELECT ext_party_id FROM aihr_org_snapshot
WHERE tenant_id = ? AND employment_status = 'active' AND (ext_party_id = ? OR person_phone = ?)
ORDER BY snapshot_date DESC, id DESC LIMIT 1
""", (rs, rowNum) -> rs.getString("ext_party_id"), tenantId(), identity, identity);
return rows.isEmpty() ? identity : rows.get(0);
}
private static ExamHeader mapHeader(java.sql.ResultSet rs) throws java.sql.SQLException {
return new ExamHeader(rs.getLong("id"), rs.getString("title"), rs.getString("project_code"),
rs.getString("position_code"), rs.getString("status"), rs.getInt("pass_score"),
rs.getDate("due_date") == null ? null : rs.getDate("due_date").toLocalDate().toString(),
rs.getString("created_by"), rs.getString("publish_request_key"));
}
private static ExamSummaryResponse mapSummary(java.sql.ResultSet rs) throws java.sql.SQLException {
Object passed = rs.getObject("passed");
Object bestScore = rs.getObject("best_score");
return new ExamSummaryResponse(rs.getLong("id"), rs.getString("title"), rs.getString("project_code"),
rs.getString("position_code"), rs.getString("status"), rs.getInt("total_score"), rs.getInt("pass_score"),
rs.getDate("due_date") == null ? null : rs.getDate("due_date").toLocalDate().toString(),
rs.getInt("question_count"), rs.getInt("target_count"), rs.getInt("submission_count"),
bestScore == null ? null : rs.getInt("best_score"),
passed == null ? null : rs.getBoolean("passed"));
}
private LocalDate parseDueDate(String value) {
if (clean(value).isEmpty()) {
return null;
}
try {
LocalDate date = LocalDate.parse(clean(value));
if (date.isBefore(LocalDate.now())) {
throw new ServiceException("考试截止日期不能早于今天");
}
return date;
} catch (DateTimeParseException error) {
throw new ServiceException("考试截止日期格式应为YYYY-MM-DD");
}
}
private String normalizeRequestId(String value) {
String requestId = clean(value);
if (requestId.isEmpty() || requestId.length() > 100 || !requestId.matches("[A-Za-z0-9._:-]+")) {
throw new ServiceException("缺少有效的考试提交请求ID");
}
return requestId;
}
private String normalizeOptionalRequestId(String value) {
String requestId = clean(value);
if (!requestId.isEmpty() && (requestId.length() > 100 || !requestId.matches("[A-Za-z0-9._:-]+"))) {
throw new ServiceException("考试发布请求ID格式不正确");
}
return requestId;
}
private String json(List<String> values) {
try {
return objectMapper.writeValueAsString(values == null ? List.of() : values);
} catch (JsonProcessingException error) {
throw new ServiceException("考试答案序列化失败");
}
}
private List<String> stringList(String json) {
if (clean(json).isEmpty()) {
return List.of();
}
try {
return objectMapper.readValue(json, STRING_LIST);
} catch (JsonProcessingException error) {
throw new ServiceException("考试题数据损坏");
}
}
private void ensureSchema() {
if (schemaReady) {
return;
}
synchronized (this) {
if (schemaReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_onboard_exam (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) NOT NULL DEFAULT '000000',
title varchar(200) NOT NULL,
project_code varchar(100) NOT NULL DEFAULT '',
position_code varchar(80) NOT NULL DEFAULT '',
status varchar(20) NOT NULL DEFAULT 'DRAFT',
total_score int NOT NULL DEFAULT 100,
pass_score int NOT NULL DEFAULT 80,
due_date date DEFAULT NULL,
created_by varchar(100) NOT NULL,
publish_request_key varchar(100) DEFAULT NULL,
published_time datetime DEFAULT NULL,
create_time datetime DEFAULT NULL,
update_time datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_aihr_onboard_exam_publish_request (tenant_id, created_by, publish_request_key),
KEY idx_aihr_onboard_exam_scope (tenant_id, status, project_code, position_code, due_date),
KEY idx_aihr_onboard_exam_owner (tenant_id, created_by, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
ensureColumn("aihr_onboard_exam", "publish_request_key", """
ALTER TABLE aihr_onboard_exam
ADD COLUMN publish_request_key varchar(100) DEFAULT NULL AFTER created_by
""");
ensureIndex("aihr_onboard_exam", "uk_aihr_onboard_exam_publish_request", """
ALTER TABLE aihr_onboard_exam
ADD UNIQUE KEY uk_aihr_onboard_exam_publish_request (tenant_id, created_by, publish_request_key)
""");
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_onboard_exam_question (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) NOT NULL DEFAULT '000000',
exam_id bigint NOT NULL,
question_type varchar(20) NOT NULL,
stem varchar(1000) NOT NULL,
options_json text,
correct_answers_json text NOT NULL,
score int NOT NULL,
explanation varchar(1000) DEFAULT NULL,
sort_order int NOT NULL DEFAULT 0,
create_time datetime DEFAULT NULL,
update_time datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_aihr_onboard_exam_question (tenant_id, exam_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_onboard_exam_target (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) NOT NULL DEFAULT '000000',
exam_id bigint NOT NULL,
ext_party_id varchar(100) NOT NULL,
trainee_name varchar(100) NOT NULL,
project_code varchar(100) NOT NULL DEFAULT '',
position_name varchar(100) NOT NULL DEFAULT '',
create_time datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_aihr_onboard_exam_target (tenant_id, exam_id, ext_party_id),
KEY idx_aihr_onboard_exam_target_party (tenant_id, ext_party_id, exam_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_onboard_exam_attempt (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) NOT NULL DEFAULT '000000',
exam_id bigint NOT NULL,
ext_party_id varchar(100) NOT NULL,
request_key varchar(100) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'SUBMITTED',
score int NOT NULL DEFAULT 0,
passed tinyint NOT NULL DEFAULT 0,
submitted_time datetime NOT NULL,
create_time datetime DEFAULT NULL,
update_time datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_aihr_onboard_exam_attempt_request (tenant_id, exam_id, ext_party_id, request_key),
KEY idx_aihr_onboard_exam_attempt_result (tenant_id, exam_id, score, submitted_time),
KEY idx_aihr_onboard_exam_attempt_party (tenant_id, ext_party_id, submitted_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_onboard_exam_answer (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) NOT NULL DEFAULT '000000',
attempt_id bigint NOT NULL,
question_id bigint NOT NULL,
answer_json text,
correct tinyint NOT NULL DEFAULT 0,
earned_score int NOT NULL DEFAULT 0,
create_time datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_aihr_onboard_exam_answer (tenant_id, attempt_id, question_id),
KEY idx_aihr_onboard_exam_answer_question (tenant_id, question_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
schemaReady = true;
}
}
private void ensureColumn(String table, String column, String ddl) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?
""", Integer.class, table, column);
if (count == null || count == 0) {
jdbcTemplate.execute(ddl);
}
}
private void ensureIndex(String table, String index, String ddl) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?
""", Integer.class, table, index);
if (count == null || count == 0) {
jdbcTemplate.execute(ddl);
}
}
private String tenantId() {
return fallback(TenantHelper.getTenantId(), DEFAULT_TENANT);
}
private static String requireIdentity(String value) {
String identity = clean(value);
if (identity.isEmpty()) {
throw new ServiceException("缺少员工身份");
}
return identity;
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
private static String fallback(String value, String fallback) {
return clean(value).isEmpty() ? fallback : clean(value);
}
private static String truncate(String value, int maxLength) {
String text = clean(value);
return text.length() <= maxLength ? text : text.substring(0, maxLength);
}
private static String placeholders(int count) {
return String.join(",", java.util.Collections.nCopies(Math.max(1, count), "?"));
}
private static String format(Timestamp timestamp) {
return timestamp == null ? "" : timestamp.toLocalDateTime().format(TIME_FORMAT);
}
static LocalDateTime databaseSecondPrecision(LocalDateTime value) {
return value.withNano(0);
}
private record ExamHeader(Long id, String title, String projectCode, String positionCode, String status,
Integer passScore, String dueDate, String createdBy, String publishRequestKey) {
}
private record AttemptRow(Long id, String status, Integer score, Boolean passed, String submittedTime) {
}
private record ResolvedTarget(String extPartyId, String traineeName, String projectCode, String positionName) {
}
}
@@ -0,0 +1,178 @@
package org.dromara.aihr.learning;
import java.util.List;
/**
* Contracts for the mobile learning, exam, feedback, and content-matrix APIs.
*/
public final class AihrLearningDto {
private AihrLearningDto() {
}
public record AssignmentFeedbackRequest(String verdict, String comment) {
}
public record AssignmentFeedbackResponse(Long assignmentId, String verdict, String comment, String updateTime) {
}
public record DifficultyResponse(
String scenarioId,
String scenarioName,
String position,
Integer attempts,
Integer mistakeCount,
Integer affectedPeople,
Integer averageScore,
Integer difficultyScore,
String latestTime
) {
}
public record DifficultyAssignRequest(
String scenarioId,
String cause,
String dueDate,
String requestId,
Integer windowDays,
String position,
List<org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget> targets
) {
}
public record LearningQuestionResponse(
String questionCode,
String questionType,
String question,
String referenceAnswer,
String contentVersion,
String contentHash
) {
}
public record ScenarioMatrixResponse(
String scenarioCode,
String scenarioName,
String scenarioType,
Integer difficulty,
String sopRefs,
List<LearningQuestionResponse> questions
) {
}
public record PositionMatrixResponse(
String positionCode,
String positionName,
Integer scenarioCount,
Integer questionCount,
List<ScenarioMatrixResponse> scenarios
) {
}
public record LearningMatrixResponse(Integer positionCount, Integer scenarioCount, Integer questionCount,
List<PositionMatrixResponse> positions) {
}
public record ExamQuestionDraft(
Long id,
String questionType,
String stem,
List<String> options,
List<String> correctAnswers,
Integer score,
String explanation,
Integer sortOrder
) {
}
/** A selected employee. traineeName is display-only; the server resolves the authoritative org name. */
public record ExamTargetDraft(String extPartyId, String traineeName) {
}
public record ExamTargetResponse(
String extPartyId,
String traineeName,
String projectCode,
String positionName
) {
}
public record ExamUpsertRequest(
String title,
String projectCode,
String positionCode,
Integer passScore,
String dueDate,
List<ExamQuestionDraft> questions,
List<ExamTargetDraft> targets
) {
public ExamUpsertRequest(String title, String projectCode, String positionCode, Integer passScore,
String dueDate, List<ExamQuestionDraft> questions) {
this(title, projectCode, positionCode, passScore, dueDate, questions, List.of());
}
}
public record ExamPublishRequest(String requestId) {
}
public record ExamSummaryResponse(
Long id,
String title,
String projectCode,
String positionCode,
String status,
Integer totalScore,
Integer passScore,
String dueDate,
Integer questionCount,
Integer targetCount,
Integer submissionCount,
Integer bestScore,
Boolean passed
) {
}
public record ExamQuestionResponse(
Long id,
String questionType,
String stem,
List<String> options,
List<String> correctAnswers,
Integer score,
String explanation,
Integer sortOrder
) {
}
public record ExamDetailResponse(
Long id,
String title,
String projectCode,
String positionCode,
String status,
Integer totalScore,
Integer passScore,
String dueDate,
List<ExamQuestionResponse> questions,
List<ExamTargetResponse> targets
) {
}
public record ExamAnswerRequest(Long questionId, List<String> answers) {
}
public record ExamSubmitRequest(String requestId, List<ExamAnswerRequest> answers) {
}
public record ExamAnswerResult(Long questionId, Boolean correct, Integer earnedScore, Integer maxScore,
String explanation) {
}
public record ExamSubmitResponse(Long attemptId, String status, Integer score, Boolean passed,
String submittedTime, List<ExamAnswerResult> results) {
}
public record ExamResultResponse(Long attemptId, String extPartyId, String traineeName, Integer score,
Boolean passed, String submittedTime, String status) {
}
}
@@ -0,0 +1,485 @@
package org.dromara.aihr.learning;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse;
import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackRequest;
import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackResponse;
import org.dromara.aihr.learning.AihrLearningDto.DifficultyAssignRequest;
import org.dromara.aihr.learning.AihrLearningDto.DifficultyResponse;
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.AihrPracticeSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Learning content matrix, assignment usefulness feedback and team difficulty aggregation.
*/
@Service
@RequiredArgsConstructor
public class AihrLearningService {
private static final String DEFAULT_TENANT = "000000";
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final Set<String> VERDICTS = Set.of("useful", "not_useful");
private final JdbcTemplate jdbcTemplate;
private final AihrPracticeSeedService practiceSeedService;
private volatile boolean schemaReady;
@PostConstruct
void initializeSchema() {
ensureSchema();
}
public AssignmentFeedbackResponse submitAssignmentFeedback(Long assignmentId, AssignmentFeedbackRequest request,
String extPartyId) {
ensureSchema();
if (assignmentId == null || assignmentId <= 0 || clean(extPartyId).isEmpty()) {
throw new ServiceException("训练题不存在或无权反馈");
}
String verdict = normalizeVerdict(request == null ? null : request.verdict());
String comment = truncate(request == null ? null : request.comment(), 500);
if (!ownsAssignment(assignmentId, extPartyId)) {
throw new ServiceException("训练题不存在或无权反馈");
}
jdbcTemplate.update("""
INSERT INTO aihr_practice_question_feedback
(tenant_id, assignment_id, ext_party_id, verdict, comment, create_time, update_time)
VALUES (?, ?, ?, ?, ?, now(), now())
ON DUPLICATE KEY UPDATE verdict = VALUES(verdict), comment = VALUES(comment), update_time = now()
""", tenantId(), assignmentId, clean(extPartyId), verdict, emptyToNull(comment));
return new AssignmentFeedbackResponse(assignmentId, verdict, comment, LocalDateTime.now().format(TIME_FORMAT));
}
public List<DifficultyResponse> difficulties(String supervisorExtPartyId, int limit, int windowDays,
String positionCode) {
ensureSchema();
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100));
int safeWindow = validateDifficultyWindow(windowDays);
String position = clean(positionCode);
List<String> team = teamIdentities(supervisorExtPartyId);
if (supervisorExtPartyId != null && !supervisorExtPartyId.isBlank() && team.isEmpty()) {
return List.of();
}
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.add(safeWindow);
String assignmentScope = scopeSql("a.ext_party_id", team, args);
args.add(tenantId());
args.add(safeWindow);
String sessionScope = scopeSql("s.ext_party_id", team, args);
args.add(tenantId());
args.add(position);
args.add(position);
args.add(safeLimit);
return jdbcTemplate.query("""
SELECT evidence.scenario_id,
MAX(evidence.scenario_name) AS scenario_name,
COALESCE(MAX(scenario.position), '') AS position,
COUNT(*) AS attempts,
SUM(is_mistake) AS mistake_count,
COUNT(DISTINCT CASE WHEN is_mistake = 1 THEN ext_party_id END) AS affected_people,
ROUND(AVG(score)) AS average_score,
ROUND((SUM(is_mistake) / COUNT(*)) * 70 + (100 - AVG(score)) * 0.30) AS difficulty_score,
MAX(event_time) AS latest_time
FROM (
SELECT a.scenario_id, a.scenario_name, a.ext_party_id, COALESCE(a.score, 0) AS score,
CASE WHEN a.correct = 0 OR COALESCE(a.score, 0) < 70 THEN 1 ELSE 0 END AS is_mistake,
COALESCE(a.finished_time, a.update_time, a.create_time) AS event_time
FROM aihr_practice_assignment a
WHERE a.tenant_id = ? AND a.status = '已完成'
AND COALESCE(a.finished_time, a.update_time, a.create_time) >= TIMESTAMPADD(DAY, -?, NOW())%s
UNION ALL
SELECT s.scenario_id, s.scenario_name, s.ext_party_id, COALESCE(s.total_score, 0) AS score,
CASE WHEN COALESCE(s.total_score, 0) < 80 OR s.dialogue_json LIKE '%%\"redFlag\":true%%' THEN 1 ELSE 0 END,
COALESCE(s.finished_time, s.update_time, s.create_time)
FROM aihr_practice_session s
WHERE s.tenant_id = ? AND s.mode = 'mobile' AND s.finished_time IS NOT NULL
AND COALESCE(s.finished_time, s.update_time, s.create_time) >= TIMESTAMPADD(DAY, -?, NOW())%s
) evidence
JOIN aihr_practice_scenario scenario
ON scenario.tenant_id = ? AND scenario.scenario_code = evidence.scenario_id
WHERE (? = '' OR scenario.position = ?)
GROUP BY evidence.scenario_id
HAVING mistake_count > 0
ORDER BY difficulty_score DESC, mistake_count DESC, latest_time DESC
LIMIT ?
""".formatted(assignmentScope, sessionScope), (rs, rowNum) -> new DifficultyResponse(
rs.getString("scenario_id"),
fallback(rs.getString("scenario_name"), "场景训练"),
rs.getString("position"),
rs.getInt("attempts"),
rs.getInt("mistake_count"),
rs.getInt("affected_people"),
rs.getInt("average_score"),
rs.getInt("difficulty_score"),
format(rs.getTimestamp("latest_time"))
), args.toArray());
}
public PracticeAssignmentBatchResponse assignDifficulty(DifficultyAssignRequest request, String supervisorExtPartyId) {
if (request == null) {
throw new ServiceException("缺少专项派发参数");
}
int windowDays = validateDifficultyWindow(request.windowDays());
List<org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget> eligibleTargets =
difficultyTargets(request.scenarioId(), windowDays, request.position(), supervisorExtPartyId);
List<org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget> targets = eligibleTargets;
if (request.targets() != null && !request.targets().isEmpty()) {
Set<String> requestedIdentities = request.targets().stream()
.map(org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget::extPartyId)
.map(AihrLearningService::clean)
.filter(value -> !value.isEmpty())
.collect(java.util.stream.Collectors.toSet());
targets = eligibleTargets.stream()
.filter(target -> requestedIdentities.contains(clean(target.extPartyId())))
.toList();
}
if (targets.isEmpty()) {
throw new ServiceException("当前项目暂无该场景的低分员工");
}
PracticeAssignmentBatchRequest batch = new PracticeAssignmentBatchRequest(
request.requestId(), targets, request.scenarioId(),
fallback(request.cause(), "常见难题一键专项"), request.dueDate());
return practiceSeedService.createAssignments(batch, supervisorExtPartyId);
}
private List<org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget> difficultyTargets(
String scenarioId, int windowDays, String positionCode, String supervisorExtPartyId) {
String scenario = clean(scenarioId);
if (scenario.isEmpty()) {
throw new ServiceException("缺少难题场景ID");
}
String position = clean(positionCode);
List<String> team = teamIdentities(supervisorExtPartyId);
if (team != null && team.isEmpty()) {
return List.of();
}
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.add(scenario);
args.add(windowDays);
String assignmentScope = scopeSql("a.ext_party_id", team, args);
args.add(tenantId());
args.add(scenario);
args.add(windowDays);
String sessionScope = scopeSql("s.ext_party_id", team, args);
args.add(tenantId());
args.add(scenario);
args.add(position);
args.add(position);
return jdbcTemplate.query("""
SELECT evidence.ext_party_id,
COALESCE((SELECT NULLIF(o.person_name, '') FROM aihr_org_snapshot o
WHERE o.tenant_id = ? AND o.employment_status = 'active'
AND (o.ext_party_id = evidence.ext_party_id OR o.person_phone = evidence.ext_party_id)
ORDER BY o.snapshot_date DESC, o.id DESC LIMIT 1), '员工') AS trainee_name
FROM (
SELECT a.ext_party_id FROM aihr_practice_assignment a
WHERE a.tenant_id = ? AND a.scenario_id = ? AND a.status = '已完成'
AND COALESCE(a.finished_time, a.update_time, a.create_time) >= TIMESTAMPADD(DAY, -?, NOW())
AND (a.correct = 0 OR COALESCE(a.score, 0) < 70)%s
UNION
SELECT s.ext_party_id FROM aihr_practice_session s
WHERE s.tenant_id = ? AND s.scenario_id = ? AND s.mode = 'mobile' AND s.finished_time IS NOT NULL
AND COALESCE(s.finished_time, s.update_time, s.create_time) >= TIMESTAMPADD(DAY, -?, NOW())
AND (COALESCE(s.total_score, 0) < 80 OR s.dialogue_json LIKE '%%\"redFlag\":true%%')%s
) evidence
JOIN aihr_practice_scenario scenario
ON scenario.tenant_id = ? AND scenario.scenario_code = ?
AND (? = '' OR scenario.position = ?)
WHERE evidence.ext_party_id IS NOT NULL AND evidence.ext_party_id <> ''
ORDER BY evidence.ext_party_id
LIMIT 100
""".formatted(assignmentScope, sessionScope), (rs, rowNum) ->
new org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget(
rs.getString("ext_party_id"), rs.getString("trainee_name")),
prependTenant(args));
}
static int validateDifficultyWindow(Integer windowDays) {
int value = windowDays == null ? 30 : windowDays;
if (value < 1 || value > 365) {
throw new ServiceException("统计窗口须为1至365天");
}
return value;
}
private Object[] prependTenant(List<Object> args) {
List<Object> all = new ArrayList<>();
all.add(tenantId());
all.addAll(args);
return all.toArray();
}
public LearningMatrixResponse matrix(String positionCode) {
ensureSchema();
String position = clean(positionCode);
List<MatrixRow> rows = jdbcTemplate.query("""
SELECT s.position, s.scenario_code, s.scenario_name, s.scenario_type, s.difficulty, s.sop_refs,
q.question_code, q.question_type, q.question_text, q.reference_answer,
q.content_version, q.content_hash
FROM aihr_practice_scenario s
LEFT JOIN aihr_learning_question q
ON q.tenant_id = s.tenant_id AND q.scenario_code = s.scenario_code AND q.enabled = 1
WHERE s.tenant_id = ? AND s.enabled = 1 AND (? = '' OR s.position = ?)
ORDER BY s.position, s.sort_order, s.scenario_code, q.sort_order, q.id
""", (rs, rowNum) -> new MatrixRow(
rs.getString("position"), rs.getString("scenario_code"), rs.getString("scenario_name"),
rs.getString("scenario_type"), rs.getInt("difficulty"), rs.getString("sop_refs"),
rs.getString("question_code"), rs.getString("question_type"), rs.getString("question_text"),
rs.getString("reference_answer"), rs.getString("content_version"), rs.getString("content_hash")
), tenantId(), position, position);
Map<String, PositionAccumulator> positions = new LinkedHashMap<>();
for (MatrixRow row : rows) {
PositionAccumulator positionItem = positions.computeIfAbsent(row.position(), PositionAccumulator::new);
ScenarioAccumulator scenario = positionItem.scenarios.computeIfAbsent(row.scenarioCode(),
ignored -> new ScenarioAccumulator(row));
if (row.questionCode() != null) {
scenario.questions.add(new LearningQuestionResponse(
row.questionCode(), fallback(row.questionType(), "daily"), row.questionText(), row.referenceAnswer(),
fallback(row.contentVersion(), "v1"), row.contentHash()));
}
}
List<PositionMatrixResponse> result = positions.values().stream().map(PositionAccumulator::response).toList();
int scenarios = result.stream().mapToInt(PositionMatrixResponse::scenarioCount).sum();
int questions = result.stream().mapToInt(PositionMatrixResponse::questionCount).sum();
return new LearningMatrixResponse(result.size(), scenarios, questions, result);
}
static String normalizeVerdict(String value) {
String verdict = clean(value).toLowerCase(java.util.Locale.ROOT);
if (!VERDICTS.contains(verdict)) {
throw new ServiceException("反馈类型只能是useful或not_useful");
}
return verdict;
}
public static String contentHash(String contentVersion, String question, String referenceAnswer) {
String canonical = clean(contentVersion) + "\n" + clean(question) + "\n" + clean(referenceAnswer);
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException error) {
throw new IllegalStateException("SHA-256 unavailable", error);
}
}
private boolean ownsAssignment(Long assignmentId, String identity) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM aihr_practice_assignment a
WHERE a.tenant_id = ? AND a.id = ?
AND (a.ext_party_id = ? OR a.ext_party_id IN (
SELECT o.ext_party_id FROM aihr_org_snapshot o
WHERE o.tenant_id = ? AND o.employment_status = 'active'
AND (o.ext_party_id = ? OR o.person_phone = ?)
))
""", Integer.class, tenantId(), assignmentId, clean(identity), tenantId(), clean(identity), clean(identity));
return count != null && count > 0;
}
/** null means unscoped system operator; empty means fail-closed scoped operator. */
private List<String> teamIdentities(String supervisorExtPartyId) {
String owner = clean(supervisorExtPartyId);
if (owner.isEmpty()) {
return null;
}
try {
List<String> projects = jdbcTemplate.query("""
SELECT DISTINCT project_code
FROM aihr_org_snapshot
WHERE tenant_id = ? AND employment_status = 'active'
AND (ext_party_id = ? OR person_phone = ?)
AND position_level IN ('主管', '项目经理')
""", (rs, rowNum) -> rs.getString("project_code"), tenantId(), owner, owner);
if (projects.isEmpty()) {
return List.of();
}
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.addAll(projects);
return jdbcTemplate.query("""
SELECT identity FROM (
SELECT ext_party_id AS identity FROM aihr_org_snapshot
WHERE tenant_id = ? AND employment_status = 'active' AND project_code IN (%s)
UNION
SELECT person_phone AS identity FROM aihr_org_snapshot
WHERE tenant_id = ? AND employment_status = 'active' AND project_code IN (%s)
AND person_phone IS NOT NULL AND person_phone <> ''
) team ORDER BY identity
""".formatted(placeholders(projects.size()), placeholders(projects.size())), (rs, rowNum) -> rs.getString("identity"),
combinedArgs(args, tenantId(), projects));
} catch (DataAccessException error) {
return List.of();
}
}
private static Object[] combinedArgs(List<Object> first, String tenantId, List<String> projects) {
List<Object> args = new ArrayList<>(first);
args.add(tenantId);
args.addAll(projects);
return args.toArray();
}
private static String scopeSql(String column, List<String> team, List<Object> args) {
if (team == null) {
return "";
}
if (team.isEmpty()) {
return " AND 1 = 0";
}
args.addAll(team);
return " AND " + column + " IN (" + placeholders(team.size()) + ")";
}
private void ensureSchema() {
if (schemaReady) {
return;
}
synchronized (this) {
if (schemaReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_learning_question (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) NOT NULL DEFAULT '000000',
question_code varchar(120) NOT NULL,
scenario_code varchar(80) NOT NULL,
position_code varchar(80) NOT NULL,
question_type varchar(20) NOT NULL DEFAULT 'daily',
question_text varchar(1000) NOT NULL,
reference_answer varchar(2000) NOT NULL,
content_version varchar(30) NOT NULL DEFAULT 'v1',
content_hash char(64) NOT NULL,
enabled tinyint NOT NULL DEFAULT 1,
sort_order int NOT NULL DEFAULT 0,
create_time datetime DEFAULT NULL,
update_time datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_aihr_learning_question (tenant_id, question_code),
KEY idx_aihr_learning_question_matrix (tenant_id, position_code, question_type, enabled, sort_order),
KEY idx_aihr_learning_question_hash (tenant_id, content_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_practice_question_feedback (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) NOT NULL DEFAULT '000000',
assignment_id bigint NOT NULL,
ext_party_id varchar(100) NOT NULL,
verdict varchar(20) NOT NULL,
comment varchar(500) DEFAULT NULL,
create_time datetime DEFAULT NULL,
update_time datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_aihr_question_feedback (tenant_id, assignment_id, ext_party_id),
KEY idx_aihr_question_feedback_verdict (tenant_id, verdict, update_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
ensureAssignmentColumn("content_version", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT 'v1' AFTER request_key");
ensureAssignmentColumn("question_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN question_snapshot varchar(1000) DEFAULT NULL AFTER content_version");
ensureAssignmentColumn("reference_answer_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN reference_answer_snapshot varchar(2000) DEFAULT NULL AFTER question_snapshot");
ensureAssignmentColumn("content_hash", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_hash char(64) DEFAULT NULL AFTER reference_answer_snapshot");
schemaReady = true;
}
}
private void ensureAssignmentColumn(String column, String ddl) {
Integer exists = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_practice_assignment' AND COLUMN_NAME = ?
""", Integer.class, column);
if (exists == null || exists == 0) {
jdbcTemplate.execute(ddl);
}
}
private String tenantId() {
return fallback(TenantHelper.getTenantId(), DEFAULT_TENANT);
}
private static String placeholders(int size) {
return String.join(",", java.util.Collections.nCopies(Math.max(1, size), "?"));
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
private static String fallback(String value, String fallback) {
return clean(value).isEmpty() ? fallback : clean(value);
}
private static String truncate(String value, int max) {
String text = clean(value);
return text.length() <= max ? text : text.substring(0, max);
}
private static String emptyToNull(String value) {
return clean(value).isEmpty() ? null : clean(value);
}
private static String format(Timestamp timestamp) {
return timestamp == null ? "" : timestamp.toLocalDateTime().format(TIME_FORMAT);
}
private record MatrixRow(String position, String scenarioCode, String scenarioName, String scenarioType,
Integer difficulty, String sopRefs, String questionCode, String questionType,
String questionText, String referenceAnswer, String contentVersion, String contentHash) {
}
private static final class ScenarioAccumulator {
private final MatrixRow row;
private final List<LearningQuestionResponse> questions = new ArrayList<>();
private ScenarioAccumulator(MatrixRow row) {
this.row = row;
}
private ScenarioMatrixResponse response() {
return new ScenarioMatrixResponse(row.scenarioCode(), row.scenarioName(), row.scenarioType(),
row.difficulty(), row.sopRefs(), List.copyOf(questions));
}
}
private static final class PositionAccumulator {
private final String position;
private final Map<String, ScenarioAccumulator> scenarios = new LinkedHashMap<>();
private PositionAccumulator(String position) {
this.position = position;
}
private PositionMatrixResponse response() {
List<ScenarioMatrixResponse> items = scenarios.values().stream().map(ScenarioAccumulator::response).toList();
int questionCount = items.stream().mapToInt(item -> item.questions().size()).sum();
return new PositionMatrixResponse(position, position, items.size(), questionCount, items);
}
}
}
@@ -4,6 +4,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest;
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
@@ -188,6 +190,10 @@ public class AihrMobileSeedService {
return practiceSeedService.createAssignment(request, supervisorExtPartyId);
}
public PracticeAssignmentBatchResponse createPracticeAssignments(PracticeAssignmentBatchRequest request, String supervisorExtPartyId) {
return practiceSeedService.createAssignments(request, supervisorExtPartyId);
}
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) {
return answerDailyDrill(id, request, "");
}
@@ -95,15 +95,19 @@ public class AihrOrgSyncService {
List<JsonNode> companyItems = fetchOptional(baseUrl, token, req, "company", pageSize, maxPages, warnings);
List<JsonNode> departmentItems = fetchOptional(baseUrl, token, req, "department", pageSize, maxPages, warnings);
List<JsonNode> projectItems = fetchOptional(baseUrl, token, req, "project", pageSize, maxPages, warnings);
List<JsonNode> assignmentItems = fetchOptional(baseUrl, token, req, "employee_project_assignment", pageSize, maxPages, warnings);
List<JsonNode> employeeItems = fetchSnapshot(baseUrl, token, req, "employee", pageSize, maxPages);
Map<String, CompanyInfo> companies = companyMap(companyItems);
Map<String, DepartmentInfo> departments = departmentMap(departmentItems);
Map<String, ProjectInfo> projects = projectMap(projectItems);
Map<String, AssignmentInfo> assignments = assignmentMap(assignmentItems);
List<OrgRow> rows = new ArrayList<>();
int skipped = 0;
int maskedPhone = 0;
for (JsonNode employee : employeeItems) {
OrgRow row = orgRow(employee, companies, departments);
OrgRow row = orgRow(employee, companies, departments, projects, assignments);
if (row == null) {
skipped++;
} else {
@@ -375,33 +379,69 @@ public class AihrOrgSyncService {
});
}
private OrgRow orgRow(JsonNode employee, Map<String, CompanyInfo> companies, Map<String, DepartmentInfo> departments) {
String extPartyId = firstNonBlank(text(employee, "employee_number", "employeeNo", "employee_id", "employeeId", "id", "user_id"));
private OrgRow orgRow(JsonNode employee, Map<String, CompanyInfo> companies, Map<String, DepartmentInfo> departments,
Map<String, ProjectInfo> projects, Map<String, AssignmentInfo> assignments) {
String employeeNumber = firstNonBlank(text(employee, "employee_number", "employeeNumber", "employeeNo"));
String employeeId = firstNonBlank(text(employee, "employee_id", "employeeId", "id", "user_id", "userId"));
String extPartyId = firstNonBlank(employeeNumber, employeeId);
if (extPartyId.isBlank()) {
return null;
}
String departmentId = firstNonBlank(text(employee, "department_id", "departmentId", "dept_id", "deptId"));
AssignmentInfo assignment = assignment(assignments, employeeId, employeeNumber, extPartyId);
String departmentId = firstNonBlank(
text(employee, "department_id", "departmentId", "dept_id", "deptId"),
assignment == null ? "" : assignment.employeeDepartmentId()
);
DepartmentInfo dept = departments.get(departmentId);
String companyId = firstNonBlank(text(employee, "company_id", "companyId"), dept == null ? "" : dept.companyId());
String projectId = firstNonBlank(
assignment == null ? "" : assignment.projectId(),
text(employee, "project_id", "projectId"),
dept == null ? "" : dept.projectId()
);
ProjectInfo project = projects.get(projectId);
String companyId = firstNonBlank(
text(employee, "company_id", "companyId"),
project == null ? "" : project.companyId(),
dept == null ? "" : dept.companyId()
);
CompanyInfo company = companies.get(companyId);
String deptName = firstNonBlank(text(employee, "department_name", "departmentName", "dept_name", "deptName"), dept == null ? "" : dept.name());
String deptName = firstNonBlank(
text(employee, "department_name", "departmentName", "dept_name", "deptName"),
assignment == null ? "" : assignment.employeeDepartmentName(),
dept == null ? "" : dept.name(),
assignment == null ? "" : assignment.projectDepartmentName()
);
String projectCode = firstNonBlank(
text(employee, "project_code", "projectCode", "company_code", "companyCode"),
assignment == null ? "" : assignment.projectCode(),
text(employee, "project_code", "projectCode"),
project == null ? "" : project.code(),
dept == null ? "" : dept.projectCode(),
text(employee, "company_code", "companyCode"),
company == null ? "" : company.code(),
companyId,
dept == null ? "" : dept.code(),
"ORG"
);
String projectName = firstNonBlank(
text(employee, "project_name", "projectName", "company_name", "companyName"),
assignment == null ? "" : assignment.projectName(),
text(employee, "project_name", "projectName"),
project == null ? "" : project.name(),
dept == null ? "" : dept.projectName(),
text(employee, "company_name", "companyName"),
company == null ? "" : company.name(),
storeDisplayFields ? deptName : "",
"组织架构"
);
String personName = firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-");
String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle",
String positionName = firstNonBlank(
assignment == null ? "" : assignment.positionName(),
text(employee, "project_position_name", "projectPositionName", "position_name", "positionName", "job_title", "jobTitle",
"post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工");
String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName));
String positionLevel = firstNonBlank(
assignment == null ? "" : assignment.positionLevel(),
text(employee, "project_position_level", "projectPositionLevel", "position_level", "positionLevel", "job_level", "jobLevel"),
level(positionName)
);
LocalDate hireDate = parseDate(firstNonBlank(
text(employee, "hire_date", "hireDate", "entry_date", "entryDate", "employment_date", "employmentDate")));
boolean suspectText = hasQuestionMark(projectName)
@@ -443,16 +483,98 @@ public class AihrOrgSyncService {
for (JsonNode item : items) {
String id = firstNonBlank(text(item, "id", "department_id", "departmentId", "dept_id", "deptId"));
if (!id.isBlank()) {
map.put(id, new DepartmentInfo(
DepartmentInfo info = new DepartmentInfo(
firstNonBlank(text(item, "code", "department_code", "departmentCode", "dept_code", "deptCode"), id),
firstNonBlank(text(item, "name", "department_name", "departmentName", "dept_name", "deptName"), id),
firstNonBlank(text(item, "company_id", "companyId"), "")
));
firstNonBlank(text(item, "company_id", "companyId"), ""),
firstNonBlank(text(item, "project_id", "projectId"), ""),
firstNonBlank(text(item, "project_code", "projectCode"), ""),
firstNonBlank(text(item, "project_name", "projectName"), "")
);
map.put(id, info);
putIfNotBlank(map, info.code(), info);
}
}
return map;
}
private Map<String, ProjectInfo> projectMap(List<JsonNode> items) {
Map<String, ProjectInfo> map = new TreeMap<>();
for (JsonNode item : items) {
String id = firstNonBlank(text(item, "id", "project_id", "projectId"));
if (!id.isBlank()) {
ProjectInfo info = new ProjectInfo(
firstNonBlank(text(item, "code", "project_code", "projectCode"), id),
firstNonBlank(text(item, "project_name", "projectName", "name"), id),
firstNonBlank(text(item, "company_id", "companyId"), ""),
firstNonBlank(text(item, "project_department_id", "projectDepartmentId"), "")
);
map.put(id, info);
putIfNotBlank(map, info.code(), info);
}
}
return map;
}
private Map<String, AssignmentInfo> assignmentMap(List<JsonNode> items) {
Map<String, AssignmentInfo> map = new TreeMap<>();
for (JsonNode item : items) {
if ("departed".equals(status(text(item, "status")))) {
continue;
}
String employeeId = firstNonBlank(text(item, "employee_id", "employeeId"));
String employeeNumber = firstNonBlank(text(item, "employee_number", "employeeNumber"));
AssignmentInfo info = new AssignmentInfo(
employeeId,
employeeNumber,
firstNonBlank(text(item, "employee_department_id", "employeeDepartmentId"), ""),
firstNonBlank(text(item, "employee_department_name", "employeeDepartmentName"), ""),
firstNonBlank(text(item, "project_id", "projectId"), ""),
firstNonBlank(text(item, "project_code", "projectCode"), ""),
firstNonBlank(text(item, "project_name", "projectName"), ""),
firstNonBlank(text(item, "project_department_id", "projectDepartmentId"), ""),
firstNonBlank(text(item, "project_department_name", "projectDepartmentName"), ""),
firstNonBlank(text(item, "project_position_name", "projectPositionName"), ""),
firstNonBlank(text(item, "project_position_level", "projectPositionLevel"), ""),
bool(item, "is_primary", "isPrimary") || "primary".equalsIgnoreCase(text(item, "assignment_type", "assignmentType"))
);
putAssignment(map, employeeId, info);
putAssignment(map, employeeNumber, info);
}
return map;
}
private static AssignmentInfo assignment(Map<String, AssignmentInfo> assignments, String... keys) {
for (String key : keys) {
AssignmentInfo info = assignments.get(clean(key));
if (info != null) {
return info;
}
}
return null;
}
private static void putAssignment(Map<String, AssignmentInfo> map, String key, AssignmentInfo info) {
String cleanKey = clean(key);
if (cleanKey.isBlank()) {
return;
}
AssignmentInfo current = map.get(cleanKey);
if (current == null || preferAssignment(info, current)) {
map.put(cleanKey, info);
}
}
private static boolean preferAssignment(AssignmentInfo candidate, AssignmentInfo current) {
if (candidate.primary() != current.primary()) {
return candidate.primary();
}
if (!candidate.positionLevel().isBlank() && current.positionLevel().isBlank()) {
return true;
}
return !candidate.projectCode().isBlank() && current.projectCode().isBlank();
}
private String orgSnapshotWhere(String keyword, String projectCode, String positionLevel, String status, List<Object> args) {
StringBuilder where = new StringBuilder(" where tenant_id = ?");
args.add(tenantId());
@@ -596,9 +718,25 @@ public class AihrOrgSyncService {
private static String positionLevel(String rawLevel, String positionName) {
String inferred = level(positionName);
String level = clean(rawLevel);
String normalized = level.toLowerCase();
if (normalized.equals("manager") || normalized.equals("project_manager") || normalized.equals("project-manager")
|| normalized.equals("pm")) {
return "项目经理";
}
if (normalized.equals("foreman") || normalized.equals("supervisor") || normalized.equals("leader")
|| normalized.equals("team_leader") || normalized.equals("team-leader")) {
return "主管";
}
if (normalized.equals("worker") || normalized.equals("staff") || normalized.equals("employee")
|| normalized.equals("frontline") || normalized.equals("front-line")) {
return "一线".equals(inferred) ? "一线" : inferred;
}
if (level.isBlank() || (("一线".equals(level) || "员工".equals(level)) && !"一线".equals(inferred))) {
return inferred;
}
if (level.matches("\\d+")) {
return inferred;
}
return level;
}
@@ -654,6 +792,29 @@ public class AihrOrgSyncService {
}
}
private static <T> void putIfNotBlank(Map<String, T> map, String key, T value) {
String clean = clean(key);
if (!clean.isBlank()) {
map.put(clean, value);
}
}
private static boolean bool(JsonNode node, String... fields) {
for (String field : fields) {
JsonNode child = node.path(field);
if (child.isBoolean()) {
return child.asBoolean();
}
if (child.isValueNode() && !child.isNull()) {
String value = clean(child.asText()).toLowerCase();
if (value.equals("true") || value.equals("1") || value.equals("yes")) {
return true;
}
}
}
return false;
}
private static int clamp(Integer value, int fallback, int min, int max) {
int result = value == null ? fallback : value;
return Math.max(min, Math.min(max, result));
@@ -737,7 +898,27 @@ public class AihrOrgSyncService {
private record CompanyInfo(String code, String name) {
}
private record DepartmentInfo(String code, String name, String companyId) {
private record DepartmentInfo(String code, String name, String companyId, String projectId, String projectCode,
String projectName) {
}
private record ProjectInfo(String code, String name, String companyId, String departmentId) {
}
private record AssignmentInfo(
String employeeId,
String employeeNumber,
String employeeDepartmentId,
String employeeDepartmentName,
String projectId,
String projectCode,
String projectName,
String projectDepartmentId,
String projectDepartmentName,
String positionName,
String positionLevel,
boolean primary
) {
}
private record OrgRow(
@@ -20,8 +20,12 @@ import org.dromara.aihr.domain.AihrPracticeDto.GrowthStageResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchItemResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
@@ -48,6 +52,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
import org.dromara.aihr.learning.AihrLearningService;
import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn;
import org.dromara.aihr.service.AihrPracticeLlmService.DailyDrillAssessment;
@@ -57,6 +62,7 @@ import org.dromara.system.service.ISysOssService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -69,6 +75,7 @@ import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -81,6 +88,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
/**
* 三角色对练编排器。seed 剧本承担剧情锚点与兜底;配置了 chat 模型时,
@@ -100,6 +108,7 @@ public class AihrPracticeSeedService {
"professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。"
);
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
// 阶段一只统计“员工收到业主话术后到提交下一轮”的陪练响应时长;正式首响/工单处理时效仍需业务系统数据。
private static final long RESPONSE_FAST_MS = 5_000L;
private static final long RESPONSE_ACCEPTABLE_MS = 10_000L;
@@ -248,6 +257,7 @@ public class AihrPracticeSeedService {
private volatile boolean promptTemplateTableReady;
private volatile boolean annotationTableReady;
private volatile boolean audioTableReady;
private final TransactionTemplate assignmentTransaction;
@Autowired
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
@@ -273,6 +283,7 @@ public class AihrPracticeSeedService {
this.objectMapper = objectMapper;
this.jdbcTemplate = jdbcTemplate;
this.practiceLlmService = practiceLlmService;
this.assignmentTransaction = configureAssignmentTransaction(pilotExportTransaction);
this.sopSeedService = sopSeedService;
this.pilotExportTransaction = configurePilotExportTransaction(pilotExportTransaction);
this.ossService = ossService;
@@ -1106,10 +1117,14 @@ public class AihrPracticeSeedService {
String identityExpression = hasOrgSnapshot
? teamCanonicalIdentityExpression("a", hasPhone)
: "a.ext_party_id";
String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name";
String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
String sql = """
SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode, due_date,
content_version, question_snapshot, reference_answer_snapshot, content_hash,
(SELECT f.verdict FROM aihr_practice_question_feedback f
WHERE f.tenant_id = a.tenant_id AND f.assignment_id = a.id AND f.ext_party_id = a.ext_party_id
LIMIT 1) AS feedback_verdict
FROM aihr_practice_assignment a
%s
ORDER BY a.create_time DESC, a.id DESC
@@ -1386,6 +1401,18 @@ public class AihrPracticeSeedService {
return transactionTemplate;
}
private static TransactionTemplate configureAssignmentTransaction(TransactionTemplate transactionTemplate) {
if (transactionTemplate == null || transactionTemplate.getTransactionManager() == null) {
return null;
}
TransactionTemplate result = new TransactionTemplate(transactionTemplate.getTransactionManager());
// A batch may legitimately contain one invalid/stale employee. Each employee is
// therefore committed independently so one failure never rolls back another's task.
result.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
result.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
return result;
}
private int countCompletedPilotPeople() {
ensurePracticeTable();
return count("""
@@ -1464,7 +1491,7 @@ public class AihrPracticeSeedService {
if (id == null) {
return null;
}
String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name";
List<ReviewDetailResponse> rows = jdbcTemplate.query("""
SELECT id, session_id, finished_time, %s AS trainee_name, scenario_id, scenario_name, total_score, status,
summary, mentor_rewrite, ai_comment, review_advice, incentive_point,
@@ -1607,14 +1634,20 @@ public class AihrPracticeSeedService {
if (isBlank(ownerIdentity) || ownerUserId == null || ownerUserId <= 0) {
throw new ServiceException("录音归属校验失败,请重新录音后提交");
}
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM sys_oss
WHERE tenant_id = ? AND oss_id = ? AND create_by = ?
""", Integer.class, tenantId(), ossId, ownerUserId);
if (count == null || count == 0) {
throw new ServiceException("录音不属于当前账号,请重新录音后提交");
try {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM aihr_practice_audio_upload
WHERE tenant_id = ? AND oss_id = ? AND owner_user_id = ? AND owner_identity = ?
AND status IN ('staged', 'bound')
""", Integer.class, tenantId(), ossId, ownerUserId, ownerIdentity.trim());
if (count != null && count > 0) {
return;
}
} catch (DataAccessException ignored) {
// Keep the ownership failure generic; do not leak database details.
}
throw new ServiceException("录音不属于当前账号,请重新录音后提交");
}
@Transactional(rollbackFor = Exception.class)
@@ -2023,20 +2056,29 @@ public class AihrPracticeSeedService {
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
ensureDailyDrills(party);
String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
return jdbcTemplate.query("""
int resultLimit = normalizeLimit(limit);
List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
SELECT id, ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode, due_date,
content_version, question_snapshot, reference_answer_snapshot, content_hash,
(SELECT f.verdict FROM aihr_practice_question_feedback f
WHERE f.tenant_id = aihr_practice_assignment.tenant_id
AND f.assignment_id = aihr_practice_assignment.id
AND f.ext_party_id = aihr_practice_assignment.ext_party_id
LIMIT 1) AS feedback_verdict
FROM aihr_practice_assignment
WHERE tenant_id = ?
AND (? = '' OR ext_party_id = ?)
AND ((source <> 'daily' AND status = '待训练') OR (source = 'daily' AND DATE(create_time) = CURRENT_DATE()))
ORDER BY
CASE WHEN source = 'daily' THEN 1 ELSE 0 END,
CASE WHEN source = 'daily' AND request_key LIKE 'daily:%%' THEN 0 ELSE 1 END,
CASE WHEN source = 'daily' THEN id ELSE NULL END ASC,
create_time DESC,
id DESC
LIMIT ?
""".formatted(nameExpression), this::mapAssignment, tenantId(), party, party, normalizeLimit(limit));
""".formatted(nameExpression), this::mapAssignment, tenantId(), party, party, assignmentQueryLimit(resultLimit));
return visibleAssignments(rows, resultLimit);
}
/**
@@ -2182,68 +2224,145 @@ public class AihrPracticeSeedService {
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.addAll(scope.extPartyIds());
args.add(normalizeLimit(limit));
int resultLimit = normalizeLimit(limit);
args.add(assignmentQueryLimit(resultLimit));
boolean hasOrgSnapshot = orgSnapshotTableExists();
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
String identityExpression = hasOrgSnapshot
? teamCanonicalIdentityExpression("a", hasPhone)
: "a.ext_party_id";
String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name";
return jdbcTemplate.query("""
List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode, due_date,
content_version, question_snapshot, reference_answer_snapshot, content_hash,
(SELECT f.verdict FROM aihr_practice_question_feedback f
WHERE f.tenant_id = a.tenant_id AND f.assignment_id = a.id AND f.ext_party_id = a.ext_party_id
LIMIT 1) AS feedback_verdict
FROM aihr_practice_assignment a
WHERE a.tenant_id = ?
AND a.ext_party_id IN (%s)
AND ((a.source <> 'daily' AND a.status = '待训练') OR (a.source = 'daily' AND DATE(a.create_time) = CURRENT_DATE()))
ORDER BY
CASE WHEN a.source = 'daily' THEN 1 ELSE 0 END,
CASE WHEN a.source = 'daily' AND a.request_key LIKE 'daily:%%' THEN 0 ELSE 1 END,
CASE WHEN a.source = 'daily' THEN a.id ELSE NULL END ASC,
a.create_time DESC,
a.id DESC
LIMIT ?
""".formatted(identityExpression, nameExpression, inClause(scope.extPartyIds().size())), this::mapAssignment, args.toArray());
return visibleAssignments(rows, resultLimit);
}
private void ensureDailyDrills(String extPartyId) {
if (isBlank(extPartyId) || !dailyDrillEligible(extPartyId)) {
return;
}
int existingToday = count("""
SELECT COUNT(*)
Set<String> existingQuestions = new LinkedHashSet<>(jdbcTemplate.query("""
SELECT scenario_id, reason
FROM aihr_practice_assignment
WHERE tenant_id = ? AND ext_party_id = ? AND source = 'daily' AND DATE(create_time) = CURRENT_DATE()
""", tenantId(), extPartyId);
if (existingToday >= 3) {
return;
}
List<DailyDrillSeed> drills = dailyDrills();
int start = Math.max(0, existingToday);
for (int i = start; i < drills.size(); i++) {
ORDER BY id
""", (rs, rowNum) -> dailyDrillParts(rs.getString("reason"), rs.getString("scenario_id")).question(),
tenantId(), extPartyId));
LocalDate today = businessToday();
List<DailyDrillSeed> drills = dailyDrills(today, dailyDrillPosition(extPartyId), extPartyId);
for (int i : missingDailyDrillSlots(drills, existingQuestions)) {
DailyDrillSeed drill = drills.get(i);
ScenarioSeed scenario = resolveScenario(drill.scenarioId(), null);
LocalDateTime now = LocalDateTime.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 (?, ?, ?, ?, ?, 'daily', ?, '待训练', ?, ?)
""",
tenantId(),
extPartyId,
persistedTraineeName(extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId),
scenario.id(),
scenario.name(),
drill.question(),
Timestamp.valueOf(now),
Timestamp.valueOf(now)
);
String contentVersion = "daily-v1";
String contentHash = AihrLearningService.contentHash(contentVersion, drill.question(), drill.referenceAnswer());
if (dailyQuestionRecentlyUsed(extPartyId, contentHash)) {
continue;
}
LocalDateTime now = businessNow();
try {
jdbcTemplate.update("""
INSERT INTO aihr_practice_assignment
(tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status,
request_key, content_version, question_snapshot, reference_answer_snapshot, content_hash,
create_time, update_time)
VALUES (?, ?, ?, ?, ?, 'daily', ?, '待训练', ?, ?, ?, ?, ?, ?, ?)
""",
tenantId(),
extPartyId,
persistedTraineeName(extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId),
scenario.id(),
scenario.name(),
dailyDrillReason(drill),
dailyRequestKey(today, i),
contentVersion,
drill.question(),
drill.referenceAnswer(),
contentHash,
Timestamp.valueOf(now),
Timestamp.valueOf(now)
);
} catch (DuplicateKeyException ignored) {
// Concurrent page loads may race after the initial count. The daily
// request key makes each employee/date/slot exactly-once.
}
existingQuestions.add(drill.question());
}
}
private boolean dailyQuestionRecentlyUsed(String extPartyId, String contentHash) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM aihr_practice_assignment
WHERE tenant_id = ? AND ext_party_id = ? AND source = 'daily' AND content_hash = ?
AND create_time >= DATE_SUB(CURRENT_DATE(), INTERVAL 6 DAY)
""", Integer.class, tenantId(), extPartyId, contentHash);
return count != null && count > 0;
}
static List<Integer> missingDailyDrillSlots(List<DailyDrillSeed> drills, Set<String> existingQuestions) {
Set<String> existing = Optional.ofNullable(existingQuestions).orElse(Set.of());
List<Integer> missing = new ArrayList<>();
List<DailyDrillSeed> safeDrills = Optional.ofNullable(drills).orElse(List.of());
for (int index = 0; index < safeDrills.size(); index++) {
if (!existing.contains(safeDrills.get(index).question())) {
missing.add(index);
}
}
return List.copyOf(missing);
}
private String dailyDrillPosition(String extPartyId) {
if (isBlank(extPartyId) || !orgSnapshotTableExists() || !orgSnapshotColumnExists("position_name")) {
return "生活顾问";
}
try {
List<String> positions = jdbcTemplate.query("""
SELECT position_name
FROM aihr_org_snapshot
WHERE tenant_id = ? AND employment_status = 'active'
AND (ext_party_id = ? OR person_phone = ?)
AND position_name IS NOT NULL AND position_name <> ''
ORDER BY snapshot_date DESC, id DESC
LIMIT 1
""", (rs, rowNum) -> rs.getString("position_name"), tenantId(), extPartyId, extPartyId);
return positions.stream().filter(position -> !isBlank(position)).findFirst().orElse("生活顾问");
} catch (DataAccessException ignored) {
return "生活顾问";
}
}
private String scenarioContentVersion(String scenarioId) {
try {
List<String> versions = jdbcTemplate.query("""
SELECT content_version FROM aihr_practice_scenario
WHERE tenant_id = ? AND scenario_code = ? AND enabled = 1 LIMIT 1
""", (rs, rowNum) -> rs.getString("content_version"), tenantId(), scenarioId);
return versions.stream().filter(version -> !isBlank(version)).findFirst().orElse("v1");
} catch (DataAccessException ignored) {
return "v1";
}
}
private boolean dailyDrillEligible(String extPartyId) {
if (orgSnapshotTableExists() && orgSnapshotColumnExists("hire_date")) {
return findHireDate(extPartyId)
.map(hireDate -> isWithinNewHireWindow(hireDate, LocalDate.now()))
.map(hireDate -> isWithinNewHireWindow(hireDate, businessToday()))
.orElseGet(() -> isLegacyDailyDrillEligible(
isLegacyDailyDrillFallbackEnabled(allowLegacyDailyDrillFallback, activeProfiles),
extPartyId,
@@ -2298,44 +2417,184 @@ public class AihrPracticeSeedService {
}
public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request) {
return createAssignmentInternal(request).assignment();
}
public PracticeAssignmentBatchResponse createAssignments(PracticeAssignmentBatchRequest request, String supervisorExtPartyId) {
String requestId = normalizeBatchRequestId(request == null ? null : request.requestId());
List<PracticeAssignmentTarget> targets = request == null || request.targets() == null
? List.of()
: request.targets();
if (targets.isEmpty()) {
throw new ServiceException("请至少选择一名员工");
}
if (targets.size() > 100) {
throw new ServiceException("单次最多派发100名员工");
}
String scenarioId = firstNonBlank(request.scenarioId(), "fee-parking");
ensureScenarioEnabledForStart(scenarioId);
ScenarioSeed batchScenario = resolveScenario(scenarioId, null);
LocalDate dueDate = normalizeAssignmentDueDate(request.dueDate(), businessToday());
String reason = firstNonBlank(request.reason(), "主管派发专项训练");
// Runtime schema fallback may execute DDL (and MySQL DDL commits implicitly), so
// prepare it once before the per-employee REQUIRES_NEW transactions begin.
ensureAssignmentTable();
TeamScope scope = teamScope(supervisorExtPartyId);
Set<String> scopedExtPartyIds = new LinkedHashSet<>(scope.extPartyIds());
List<String> requestedExtPartyIds = targets.stream()
.map(target -> firstNonBlank(target == null ? null : target.extPartyId(), ""))
.filter(extPartyId -> !isBlank(extPartyId))
.distinct()
.toList();
AssignmentStorageIdentityResolution storageResolution = assignmentStorageIdentities(requestedExtPartyIds);
Map<String, String> storageIdentities = storageResolution.identities();
Set<String> seen = new LinkedHashSet<>();
List<PracticeAssignmentBatchItemResponse> results = new ArrayList<>();
int created = 0;
int duplicate = 0;
int failed = 0;
for (PracticeAssignmentTarget target : targets) {
String extPartyId = firstNonBlank(target == null ? null : target.extPartyId(), "");
String traineeName = firstNonBlank(target == null ? null : target.traineeName(), extPartyId);
if (isBlank(extPartyId)) {
results.add(new PracticeAssignmentBatchItemResponse("", traineeName, "FAILED", "缺少员工ID", null));
failed++;
continue;
}
if (!seen.add(extPartyId)) {
results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "DUPLICATE", "本次名单中已包含该员工", null));
duplicate++;
continue;
}
if (scope.scoped() && !scopedExtPartyIds.contains(extPartyId.trim())) {
results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED", "不在当前项目的在职名单中", null));
failed++;
continue;
}
if (storageResolution.ambiguousExtPartyIds().contains(extPartyId.trim())) {
results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED",
"该手机号对应多名在职员工,请先修正组织通讯录后再派发", null));
failed++;
continue;
}
PracticeAssignmentRequest itemRequest = new PracticeAssignmentRequest(
extPartyId,
traineeName,
scenarioId,
null,
"manual",
reason,
dueDate.toString(),
requestId
);
try {
String storedExtPartyId = storageIdentities.getOrDefault(extPartyId, extPartyId);
AssignmentCreation outcome = inAssignmentTransaction(
() -> createAssignmentInternal(itemRequest, batchScenario, storedExtPartyId));
String status = outcome.duplicate() ? "DUPLICATE" : "CREATED";
String message = outcome.duplicate() ? "该批次已派发,无需重复创建" : "派发成功";
results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, status, message, outcome.assignment()));
if (outcome.duplicate()) {
duplicate++;
} else {
created++;
}
} catch (ServiceException ex) {
results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED", ex.getMessage(), null));
failed++;
} catch (RuntimeException ex) {
log.warn("batch practice assignment failed for request {} target {}", requestId, extPartyId, ex);
results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED", "派发失败,请稍后重试", null));
failed++;
}
}
return new PracticeAssignmentBatchResponse(requestId, results.size(), created, duplicate, failed, results);
}
private AssignmentCreation createAssignmentInternal(PracticeAssignmentRequest request) {
return createAssignmentInternal(request, null, null);
}
private AssignmentCreation createAssignmentInternal(PracticeAssignmentRequest request, ScenarioSeed preResolvedScenario,
String preResolvedStorageIdentity) {
String extPartyId = request == null ? "" : firstNonBlank(request.extPartyId(), "");
if (isBlank(extPartyId)) {
throw new ServiceException("缺少员工ID,无法派发专项训练");
}
String storedExtPartyId = assignmentStorageIdentity(extPartyId);
String storedExtPartyId = isBlank(preResolvedStorageIdentity)
? assignmentStorageIdentity(extPartyId)
: preResolvedStorageIdentity;
ensureAssignmentTable();
LocalDateTime now = LocalDateTime.now();
LocalDateTime now = businessNow();
String scenarioId = firstNonBlank(request == null ? null : request.scenarioId(), "fee-parking");
ensureScenarioEnabledForStart(scenarioId);
ScenarioSeed scenario = resolveScenario(scenarioId, null);
ScenarioSeed scenario = preResolvedScenario;
if (scenario == null || !scenarioId.equals(scenario.id())) {
ensureScenarioEnabledForStart(scenarioId);
scenario = resolveScenario(scenarioId, null);
}
String traineeName = firstNonBlank(request == null ? null : request.traineeName(), extPartyId);
String storedTraineeName = storeDisplayFields ? traineeName : "";
// Assignment source is a server-owned classification. Client supplied values
// must not turn a manual supervisor assignment into a daily/retry/camp record.
String 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 (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?)
""", tenantId(), storedExtPartyId, storedTraineeName, scenario.id(), 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, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
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, tenantId(), storedExtPartyId, scenario.id(), source, reason);
if (rows.isEmpty()) {
return new PracticeAssignmentResponse(null, extPartyId, storeDisplayFields ? traineeName : "员工",
scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null, null, null);
LocalDate dueDate = normalizeAssignmentDueDate(request == null ? null : request.dueDate(), businessToday());
String requestId = normalizeOptionalRequestId(request == null ? null : request.requestId());
String contentVersion = scenarioContentVersion(scenario.id());
String questionSnapshot = scenario.rounds().isEmpty() ? scenario.goal() : scenario.rounds().get(0).customer();
String referenceAnswerSnapshot = scenario.rewrite();
String contentHash = AihrLearningService.contentHash(contentVersion, questionSnapshot, referenceAnswerSnapshot);
if (requestId != null) {
PracticeAssignmentResponse existing = findAssignmentByRequest(requestId, storedExtPartyId, extPartyId);
if (existing != null) {
return new AssignmentCreation(existing, true);
}
PracticeAssignmentResponse row = rows.get(0);
return new PracticeAssignmentResponse(row.id(), extPartyId, storeDisplayFields ? row.traineeName() : "员工",
row.scenarioId(), row.scenarioName(), row.source(), row.reason(), row.status(), row.createTime(), row.question(),
row.referenceAnswer(), row.userAnswer(), row.feedback(), row.correct(), row.score(), row.scoreMode(), row.audioUrl(), row.audioOssId());
}
Timestamp createdAt = Timestamp.valueOf(now);
try {
jdbcTemplate.update("""
INSERT INTO aihr_practice_assignment
(tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status,
due_date, request_key, content_version, question_snapshot, reference_answer_snapshot, content_hash,
create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?, ?, ?, ?, ?, ?, ?)
""", tenantId(), storedExtPartyId, storedTraineeName, scenario.id(), scenario.name(), source, reason,
java.sql.Date.valueOf(dueDate), requestId, contentVersion, questionSnapshot, referenceAnswerSnapshot,
contentHash, createdAt, createdAt);
} catch (DuplicateKeyException ex) {
PracticeAssignmentResponse existing = findAssignmentByRequest(requestId, storedExtPartyId, extPartyId);
if (existing != null) {
return new AssignmentCreation(existing, true);
}
throw ex;
}
List<PracticeAssignmentResponse> rows;
if (requestId == null) {
rows = jdbcTemplate.query("""
SELECT a.id, a.ext_party_id, a.trainee_name, a.scenario_id, a.scenario_name, a.source, a.reason, a.status, a.create_time,
a.answer_text, a.answer_audio_url, a.answer_audio_oss_id, a.feedback, a.correct, a.score, a.score_mode, a.due_date,
a.content_version, a.question_snapshot, a.reference_answer_snapshot, a.content_hash,
(SELECT f.verdict FROM aihr_practice_question_feedback f
WHERE f.tenant_id = a.tenant_id AND f.assignment_id = a.id AND f.ext_party_id = a.ext_party_id
LIMIT 1) AS feedback_verdict
FROM aihr_practice_assignment a
WHERE a.tenant_id = ? AND a.ext_party_id = ? AND a.scenario_id = ? AND a.source = ? AND a.reason = ?
ORDER BY a.id DESC
LIMIT 1
""", this::mapAssignment, tenantId(), storedExtPartyId, scenario.id(), source, reason);
} else {
PracticeAssignmentResponse requested = findAssignmentByRequest(requestId, storedExtPartyId, extPartyId);
rows = requested == null ? List.of() : List.of(requested);
}
PracticeAssignmentResponse row = rows.stream().filter(java.util.Objects::nonNull).findFirst().orElse(null);
if (row == null) {
row = new PracticeAssignmentResponse(null, extPartyId, storeDisplayFields ? traineeName : "员工",
scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null,
null, null, null, null, null, null, dueDate.toString());
} else {
row = assignmentWithRequestedIdentity(row, extPartyId);
}
return new AssignmentCreation(row, false);
}
public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) {
@@ -2346,20 +2605,138 @@ public class AihrPracticeSeedService {
return createAssignment(request);
}
private String assignmentStorageIdentity(String extPartyId) {
if (isBlank(extPartyId) || jdbcTemplate == null || !orgSnapshotTableExists()
|| !orgSnapshotColumnExists("person_phone")) {
return extPartyId;
private PracticeAssignmentResponse findAssignmentByRequest(String requestId, String storedExtPartyId,
String requestedExtPartyId) {
if (isBlank(requestId)) {
return null;
}
List<String> phones = jdbcTemplate.query("""
SELECT person_phone
FROM aihr_org_snapshot
WHERE tenant_id = ? AND ext_party_id = ? AND employment_status = 'active'
AND person_phone IS NOT NULL AND person_phone <> ''
ORDER BY id
List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
SELECT a.id, a.ext_party_id, a.trainee_name, a.scenario_id, a.scenario_name, a.source, a.reason, a.status, a.create_time,
a.answer_text, a.answer_audio_url, a.answer_audio_oss_id, a.feedback, a.correct, a.score, a.score_mode, a.due_date,
a.content_version, a.question_snapshot, a.reference_answer_snapshot, a.content_hash,
(SELECT f.verdict FROM aihr_practice_question_feedback f
WHERE f.tenant_id = a.tenant_id AND f.assignment_id = a.id AND f.ext_party_id = a.ext_party_id
LIMIT 1) AS feedback_verdict
FROM aihr_practice_assignment a
WHERE a.tenant_id = ? AND a.request_key = ? AND a.ext_party_id = ?
ORDER BY a.id DESC
LIMIT 1
""", (rs, rowNum) -> rs.getString("person_phone"), tenantId(), extPartyId.trim());
return phones.isEmpty() ? extPartyId.trim() : phones.get(0).trim();
""", this::mapAssignment, tenantId(), requestId, storedExtPartyId);
return rows.isEmpty() ? null : assignmentWithRequestedIdentity(rows.get(0), requestedExtPartyId);
}
private PracticeAssignmentResponse assignmentWithRequestedIdentity(PracticeAssignmentResponse row,
String extPartyId) {
return new PracticeAssignmentResponse(row.id(), extPartyId, storeDisplayFields ? row.traineeName() : "员工",
row.scenarioId(), row.scenarioName(), row.source(), row.reason(), row.status(), row.createTime(), row.question(),
row.referenceAnswer(), row.userAnswer(), row.feedback(), row.correct(), row.score(), row.scoreMode(), row.audioUrl(),
row.audioOssId(), row.dueDate(), row.contentVersion(), row.contentHash(), row.feedbackVerdict());
}
private <T> T inAssignmentTransaction(Supplier<T> work) {
if (assignmentTransaction == null) {
return work.get();
}
return assignmentTransaction.execute(status -> work.get());
}
static LocalDate normalizeAssignmentDueDate(String value, LocalDate today) {
LocalDate base = today == null ? businessToday() : today;
if (value == null || value.isBlank()) {
return base.plusDays(1);
}
try {
LocalDate dueDate = LocalDate.parse(value.trim());
if (dueDate.isBefore(base)) {
throw new ServiceException("截止日期不能早于今天");
}
return dueDate;
} catch (java.time.format.DateTimeParseException ex) {
throw new ServiceException("截止日期格式应为YYYY-MM-DD");
}
}
private static String normalizeBatchRequestId(String value) {
String requestId = normalizeOptionalRequestId(value);
if (requestId == null) {
throw new ServiceException("缺少批次请求ID");
}
return requestId;
}
private static String normalizeOptionalRequestId(String value) {
if (value == null || value.isBlank()) {
return null;
}
String requestId = value.trim();
if (requestId.length() > 100 || !requestId.matches("[A-Za-z0-9._:-]+")) {
throw new ServiceException("批次请求ID格式不正确");
}
if (requestId.regionMatches(true, 0, "daily:", 0, "daily:".length())) {
throw new ServiceException("批次请求ID不能使用系统保留前缀");
}
return requestId;
}
private String assignmentStorageIdentity(String extPartyId) {
AssignmentStorageIdentityResolution resolution = assignmentStorageIdentities(List.of(extPartyId));
String normalized = isBlank(extPartyId) ? "" : extPartyId.trim();
if (resolution.ambiguousExtPartyIds().contains(normalized)) {
throw new ServiceException("该手机号对应多名在职员工,请先修正组织通讯录后再派发");
}
return resolution.identities().getOrDefault(normalized, normalized);
}
private AssignmentStorageIdentityResolution assignmentStorageIdentities(List<String> extPartyIds) {
Map<String, String> identities = new LinkedHashMap<>();
extPartyIds.stream()
.filter(extPartyId -> !isBlank(extPartyId))
.map(String::trim)
.distinct()
.forEach(extPartyId -> identities.put(extPartyId, extPartyId));
if (identities.isEmpty() || jdbcTemplate == null || !orgSnapshotTableExists()
|| !orgSnapshotColumnExists("person_phone")) {
return new AssignmentStorageIdentityResolution(Map.copyOf(identities), Set.of());
}
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.addAll(identities.keySet());
args.addAll(identities.keySet());
List<OrgIdentityRow> rows = jdbcTemplate.query("""
SELECT o.ext_party_id, o.person_phone,
(SELECT COUNT(DISTINCT x.ext_party_id)
FROM aihr_org_snapshot x
WHERE x.tenant_id = o.tenant_id AND x.employment_status = 'active'
AND x.person_phone = o.person_phone) AS phone_owner_count
FROM aihr_org_snapshot o
WHERE o.tenant_id = ?
AND (o.ext_party_id IN (%s) OR o.person_phone IN (%s))
AND o.employment_status = 'active'
AND o.person_phone IS NOT NULL AND o.person_phone <> ''
ORDER BY o.id
""".formatted(inClause(identities.size()), inClause(identities.size())),
(rs, rowNum) -> new OrgIdentityRow(rs.getString("ext_party_id"), rs.getString("person_phone"),
rs.getInt("phone_owner_count")),
args.toArray());
Set<String> ambiguousExtPartyIds = new LinkedHashSet<>();
for (OrgIdentityRow row : rows) {
if (!isBlank(row.extPartyId()) && !isBlank(row.personPhone())) {
String normalizedExtPartyId = row.extPartyId().trim();
if (row.phoneOwnerCount() > 1) {
if (identities.containsKey(normalizedExtPartyId)) {
ambiguousExtPartyIds.add(normalizedExtPartyId);
}
String normalizedPhone = row.personPhone().trim();
if (identities.containsKey(normalizedPhone)) {
ambiguousExtPartyIds.add(normalizedPhone);
}
} else {
identities.computeIfPresent(normalizedExtPartyId, (ignored, current) ->
current.equals(normalizedExtPartyId) ? row.personPhone().trim() : current);
}
}
}
return new AssignmentStorageIdentityResolution(Map.copyOf(identities), Set.copyOf(ambiguousExtPartyIds));
}
private void markAssignmentCompleted(Long requestAssignmentId, ActiveSession activeSession) {
@@ -2402,7 +2779,7 @@ public class AihrPracticeSeedService {
}
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, reason, source, status, scenario_id
SELECT id, reason, source, status, scenario_id, question_snapshot, reference_answer_snapshot
FROM aihr_practice_assignment
WHERE tenant_id = ? AND id = ? AND source = 'daily'
AND DATE(create_time) = CURRENT_DATE()
@@ -2413,7 +2790,13 @@ public class AihrPracticeSeedService {
if (rows.isEmpty()) {
return null;
}
DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id"));
DailyDrillParts legacyParts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id"));
String questionSnapshot = (String) rows.get(0).get("question_snapshot");
String referenceSnapshot = (String) rows.get(0).get("reference_answer_snapshot");
DailyDrillParts parts = new DailyDrillParts(
firstNonBlank(questionSnapshot, legacyParts.question()),
firstNonBlank(referenceSnapshot, legacyParts.referenceAnswer())
);
String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), ""));
String audioUrl = exposedAudioUrl(request == null ? null : request.audioUrl());
Long audioOssId = request == null ? null : request.audioOssId();
@@ -2713,10 +3096,15 @@ public class AihrPracticeSeedService {
return;
}
LocalDateTime now = LocalDateTime.now();
String contentVersion = scenarioContentVersion(scenario.id());
String questionSnapshot = scenario.rounds().isEmpty() ? scenario.goal() : scenario.rounds().get(0).customer();
String referenceAnswerSnapshot = scenario.rewrite();
String contentHash = AihrLearningService.contentHash(contentVersion, questionSnapshot, referenceAnswerSnapshot);
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 (?, ?, ?, ?, ?, 'retry', ?, '待训练', ?, ?)
(tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status,
content_version, question_snapshot, reference_answer_snapshot, content_hash, create_time, update_time)
VALUES (?, ?, ?, ?, ?, 'retry', ?, '待训练', ?, ?, ?, ?, ?, ?)
""",
tenantId(),
extPartyId,
@@ -2724,6 +3112,10 @@ public class AihrPracticeSeedService {
scenario.id(),
scenario.name(),
"本场得分 " + result.total() + ",自动生成错题重练",
contentVersion,
questionSnapshot,
referenceAnswerSnapshot,
contentHash,
Timestamp.valueOf(now),
Timestamp.valueOf(now)
);
@@ -3040,6 +3432,8 @@ public class AihrPracticeSeedService {
String reason = rs.getString("reason");
String status = rs.getString("status");
DailyDrillParts daily = dailyDrillParts(reason, rs.getString("scenario_id"));
String questionSnapshot = firstNonBlank(rs.getString("question_snapshot"), daily.question());
String referenceSnapshot = firstNonBlank(rs.getString("reference_answer_snapshot"), daily.referenceAnswer());
Object correctValue = rs.getObject("correct");
return new PracticeAssignmentResponse(
rs.getLong("id"),
@@ -3051,33 +3445,141 @@ public class AihrPracticeSeedService {
"daily".equals(source) ? daily.question() : reason,
status,
createTime == null ? "" : createTime.toLocalDateTime().format(TIME_FORMATTER),
"daily".equals(source) ? daily.question() : null,
"daily".equals(source) && "已完成".equals(status) ? daily.referenceAnswer() : null,
"daily".equals(source) ? questionSnapshot : rs.getString("question_snapshot"),
"已完成".equals(status) ? referenceSnapshot : null,
rs.getString("answer_text"),
"daily".equals(source) ? stripReferenceAnswer(rs.getString("feedback")) : rs.getString("feedback"),
correctValue == null ? null : rs.getBoolean("correct"),
rs.getObject("score") == null ? null : rs.getInt("score"),
firstNonBlank(rs.getString("score_mode"), "RULE_FALLBACK"),
exposedAudioUrl(rs.getString("answer_audio_url")),
rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id")
rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id"),
rs.getDate("due_date") == null ? null : rs.getDate("due_date").toLocalDate().toString(),
firstNonBlank(rs.getString("content_version"), "v1"),
rs.getString("content_hash"),
rs.getString("feedback_verdict")
);
}
private List<DailyDrillSeed> dailyDrills() {
static String dailyRequestKey(LocalDate date, int slotIndex) {
LocalDate safeDate = date == null ? LocalDate.ofEpochDay(0) : date;
return "daily:" + safeDate + ":" + (Math.max(0, slotIndex) + 1);
}
static List<PracticeAssignmentResponse> visibleAssignments(List<PracticeAssignmentResponse> rows, int limit) {
int resultLimit = normalizeLimit(limit);
Map<String, Set<String>> dailyQuestionsByParty = new LinkedHashMap<>();
List<PracticeAssignmentResponse> visible = new ArrayList<>();
for (PracticeAssignmentResponse row : Optional.ofNullable(rows).orElse(List.of())) {
if (row == null) {
continue;
}
if ("daily".equals(row.source())) {
String party = firstNonBlankStatic(row.extPartyId(), "");
Set<String> questions = dailyQuestionsByParty.computeIfAbsent(party, ignored -> new LinkedHashSet<>());
String question = firstNonBlankStatic(row.question(), row.reason());
if (questions.size() >= 3 || !questions.add(question)) {
continue;
}
}
visible.add(row);
if (visible.size() >= resultLimit) {
break;
}
}
return List.copyOf(visible);
}
private static int assignmentQueryLimit(int resultLimit) {
return Math.min(300, Math.max(3, normalizeLimit(resultLimit) * 3));
}
private static LocalDate businessToday() {
return LocalDate.now(BUSINESS_ZONE);
}
private static LocalDateTime businessNow() {
return LocalDateTime.now(BUSINESS_ZONE);
}
static List<DailyDrillSeed> dailyDrills(LocalDate date, String position, String extPartyId) {
LocalDate safeDate = date == null ? LocalDate.ofEpochDay(0) : date;
String scope = firstNonBlankStatic(position, "生活顾问") + "|" + firstNonBlankStatic(extPartyId, "anonymous");
List<List<DailyDrillSeed>> packs = dailyDrillPacks();
int packIndex = (int) Math.floorMod(safeDate.toEpochDay() + scope.hashCode(), packs.size());
List<DailyDrillSeed> pack = packs.get(packIndex);
List<DailyDrillSeed> numbered = new ArrayList<>(pack.size());
for (int i = 0; i < pack.size(); i++) {
DailyDrillSeed drill = pack.get(i);
numbered.add(new DailyDrillSeed(
drill.scenarioId(),
"每日三题 " + (i + 1) + ":" + drill.question(),
drill.referenceAnswer()
));
}
return List.copyOf(numbered);
}
private static List<List<DailyDrillSeed>> dailyDrillPacks() {
return List.of(
new DailyDrillSeed("complaint-water", "每日三题 1:业主投诉车库积水,第一句话先说什么?", "先承接情绪,再确认点位、责任人和首次反馈时间。"),
new DailyDrillSeed("fee-collection", "每日三题 2:业主拒缴物业费并质疑服务,如何拆分问题?", "把服务整改和费用沟通分开,说明复核、整改跟进和缴费节点。"),
new DailyDrillSeed("repair-leak", "每日三题 3:报修跟进超时,如何给下一步承诺?", "说明已核实的信息、责任人、到场或反馈时间,并主动同步变化。")
List.of(
new DailyDrillSeed("complaint-water", "业主投诉车库积水,第一句话先说什么?", "先承接情绪,再确认点位、责任人和首次反馈时间。"),
new DailyDrillSeed("fee-collection", "业主拒缴物业费并质疑服务,如何拆分问题?", "把服务整改和费用沟通分开,说明复核、整改跟进和缴费节点。"),
new DailyDrillSeed("repair-leak", "报修跟进超时,如何给下一步承诺?", "说明已核实的信息、责任人、到场或反馈时间,并主动同步变化。")
),
List.of(
new DailyDrillSeed("complaint-water", "电梯故障时业主连续追问进度,你先确认哪三项信息?", "确认受影响楼栋和人员、维保责任人、预计恢复或下一次反馈时间。"),
new DailyDrillSeed("fee-collection", "业主认为收费项目看不懂,如何解释又不激化争议?", "先确认具体收费项,提供依据和明细,并约定复核责任人与反馈时间。"),
new DailyDrillSeed("complaint-water", "夜间噪声投诉暂时无法定位来源,如何回复?", "承接休息受影响的情绪,说明核查范围、现场责任人和阶段反馈时间。")
),
List.of(
new DailyDrillSeed("complaint-water", "同一报修问题第二次复发,怎样避免只说“再看看”?", "说明历史处理情况,升级责任人,给出复查动作、完成节点和回访安排。"),
new DailyDrillSeed("fee-collection", "业主同意补缴但要求先看整改结果,如何推进?", "分别确认整改验收和缴费节点,记录双方约定并按时同步进展。"),
new DailyDrillSeed("complaint-water", "公共区域异味投诉多人同时反馈,如何组织首次回应?", "统一承接口径,确认点位和影响范围,明确排查责任人及首次通报时间。")
),
List.of(
new DailyDrillSeed("complaint-water", "临时停水通知晚了,业主质疑物业失职,如何回应?", "先道歉并承接影响,说明已采取的供水保障、责任人和恢复或更新节点。"),
new DailyDrillSeed("fee-collection", "业主拿其他小区价格质疑物业费,如何避免争辩?", "确认对比口径,解释本项目收费依据和服务范围,并提供复核渠道与时间。"),
new DailyDrillSeed("complaint-water", "装修施工影响邻居休息,双方情绪都很高,先做什么?", "分别安抚并核实事实,说明管理规则,明确现场协调人和后续反馈节点。")
),
List.of(
new DailyDrillSeed("complaint-water", "访客登记引发业主不满,怎样兼顾安全和体验?", "先解释安全目的,确认业主实际困难,提供合规替代方式并协助完成登记。"),
new DailyDrillSeed("fee-collection", "业主只愿意口头承诺缴费,如何形成可跟进的节点?", "确认金额和时间,复述双方约定,记录跟进责任人并提前提醒。"),
new DailyDrillSeed("complaint-water", "园区车辆被剐蹭但监控存在盲区,如何避免过度承诺?", "说明已核实范围和证据边界,协助报案或调取记录,明确后续联系人和反馈时间。")
),
List.of(
new DailyDrillSeed("complaint-water", "独居老人多次未应门,邻居担心安全,如何处理?", "先核实紧急程度和可用联系人,按应急流程联动物业人员,并持续记录反馈。"),
new DailyDrillSeed("fee-collection", "业主投诉服务后要求立即减免物业费,如何回应?", "先登记和整改服务问题,再说明减免需按制度复核,明确受理人与答复时间。"),
new DailyDrillSeed("complaint-water", "儿童游乐设施被反映松动,现场还没有维修人员,怎么回复?", "立即隔离风险区域,通知维修责任人,说明到场时间并主动同步处理结果。")
),
List.of(
new DailyDrillSeed("complaint-water", "暴雨预警后业主询问地下车库是否安全,怎样回答?", "说明巡检和防汛措施、重点点位责任人以及异常情况的通知和升级方式。"),
new DailyDrillSeed("fee-collection", "业主对历史欠费金额有异议,当前无法现场核清,怎么办?", "记录争议明细,暂停武断结论,约定账务复核责任人和书面反馈时间。"),
new DailyDrillSeed("complaint-water", "投诉处理完成后业主仍不满意,回访时要确认什么?", "确认问题是否真正解决、剩余影响和期望,说明补救动作、负责人及再次回访时间。")
)
);
}
private static String firstNonBlankStatic(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
private static String dailyDrillReason(DailyDrillSeed drill) {
return drill.question() + " 参考答案:" + drill.referenceAnswer();
}
private static List<DailyDrillSeed> dailyDrillBank() {
return dailyDrillPacks().stream().flatMap(List::stream).toList();
}
private DailyDrillParts dailyDrillParts(String reason, String scenarioId) {
String safeReason = firstNonBlank(reason, "");
String marker = "参考答案:";
int markerIndex = safeReason.indexOf(marker);
if (markerIndex < 0) {
String reference = dailyDrills().stream()
.filter(drill -> drill.question().equals(safeReason) || drill.scenarioId().equals(scenarioId))
String unnumberedReason = safeReason.replaceFirst("^每日三题\\s+\\d+:", "");
String reference = dailyDrillBank().stream()
.filter(drill -> drill.question().equals(unnumberedReason))
.map(DailyDrillSeed::referenceAnswer)
.findFirst()
.orElse("");
@@ -3890,7 +4392,7 @@ public class AihrPracticeSeedService {
return value == null ? fallback : value;
}
private int normalizeLimit(int limit) {
private static int normalizeLimit(int limit) {
if (limit <= 0) {
return 5;
}
@@ -4021,6 +4523,12 @@ public class AihrPracticeSeedService {
`source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual',
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
`due_date` date DEFAULT NULL COMMENT '训练截止日期',
`request_key` varchar(100) DEFAULT NULL COMMENT '客户端幂等请求ID',
`content_version` varchar(30) DEFAULT 'v1' COMMENT '派发内容版本',
`question_snapshot` varchar(1000) DEFAULT NULL COMMENT '派发时题目快照',
`reference_answer_snapshot` varchar(2000) DEFAULT NULL COMMENT '派发时参考答案快照',
`content_hash` char(64) DEFAULT NULL COMMENT '版本化内容哈希',
`answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答',
`answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址',
`answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号',
@@ -4032,6 +4540,7 @@ public class AihrPracticeSeedService {
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_assignment_request` (`tenant_id`, `request_key`, `ext_party_id`),
KEY `idx_aihr_assignment_party` (`tenant_id`, `ext_party_id`, `status`, `create_time`),
KEY `idx_aihr_assignment_source` (`tenant_id`, `source`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务';
@@ -4044,6 +4553,16 @@ public class AihrPracticeSeedService {
ensureColumn("aihr_practice_assignment", "score", "ALTER TABLE aihr_practice_assignment ADD COLUMN `score` int DEFAULT NULL COMMENT '每日三题得分' AFTER `correct`");
ensureColumn("aihr_practice_assignment", "score_mode", "ALTER TABLE aihr_practice_assignment ADD COLUMN `score_mode` varchar(30) DEFAULT NULL COMMENT '评分模式 LLM/RULE_FALLBACK' AFTER `score`");
ensureColumn("aihr_practice_assignment", "finished_time", "ALTER TABLE aihr_practice_assignment ADD COLUMN `finished_time` datetime DEFAULT NULL COMMENT '完成时间' AFTER `score_mode`");
ensureColumn("aihr_practice_assignment", "due_date", "ALTER TABLE aihr_practice_assignment ADD COLUMN `due_date` date DEFAULT NULL COMMENT '训练截止日期' AFTER `status`");
ensureColumn("aihr_practice_assignment", "request_key", "ALTER TABLE aihr_practice_assignment ADD COLUMN `request_key` varchar(100) DEFAULT NULL COMMENT '客户端幂等请求ID' AFTER `due_date`");
ensureColumn("aihr_practice_assignment", "content_version", "ALTER TABLE aihr_practice_assignment ADD COLUMN `content_version` varchar(30) DEFAULT 'v1' COMMENT '派发内容版本' AFTER `request_key`");
ensureColumn("aihr_practice_assignment", "question_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN `question_snapshot` varchar(1000) DEFAULT NULL COMMENT '派发时题目快照' AFTER `content_version`");
ensureColumn("aihr_practice_assignment", "reference_answer_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN `reference_answer_snapshot` varchar(2000) DEFAULT NULL COMMENT '派发时参考答案快照' AFTER `question_snapshot`");
ensureColumn("aihr_practice_assignment", "content_hash", "ALTER TABLE aihr_practice_assignment ADD COLUMN `content_hash` char(64) DEFAULT NULL COMMENT '版本化内容哈希' AFTER `reference_answer_snapshot`");
ensureIndex("aihr_practice_assignment", "uk_aihr_assignment_request",
"ALTER TABLE aihr_practice_assignment ADD UNIQUE KEY `uk_aihr_assignment_request` (`tenant_id`, `request_key`, `ext_party_id`)");
ensureIndex("aihr_practice_assignment", "idx_aihr_assignment_recent_content",
"ALTER TABLE aihr_practice_assignment ADD KEY `idx_aihr_assignment_recent_content` (`tenant_id`, `ext_party_id`, `source`, `content_hash`, `create_time`)");
assignmentTableReady = true;
}
}
@@ -4282,6 +4801,17 @@ public class AihrPracticeSeedService {
}
}
private void ensureIndex(String table, String index, String ddl) {
Integer exists = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
""", Integer.class, table, index);
if (exists == null || exists == 0) {
jdbcTemplate.execute(ddl);
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
@@ -4585,7 +5115,7 @@ public class AihrPracticeSeedService {
private record ResolvedTurn(String customerText, Integer emotion, Integer trust, String coachHint, boolean redFlag) {
}
private record DailyDrillSeed(String scenarioId, String question, String referenceAnswer) {
record DailyDrillSeed(String scenarioId, String question, String referenceAnswer) {
}
private record DailyDrillParts(String question, String referenceAnswer) {
@@ -4594,12 +5124,22 @@ public class AihrPracticeSeedService {
private record DailyDrillScore(boolean correct, int score, String feedback, String mode) {
}
private record AssignmentCreation(PracticeAssignmentResponse assignment, boolean duplicate) {
}
private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
}
private record OrgScopeRow(String projectCode, String positionLevel) {
}
private record OrgIdentityRow(String extPartyId, String personPhone, int phoneOwnerCount) {
}
private record AssignmentStorageIdentityResolution(Map<String, String> identities,
Set<String> ambiguousExtPartyIds) {
}
private record TeamScope(boolean scoped, List<String> extPartyIds) {
static TeamScope unscoped() {
return new TeamScope(false, List.of());
@@ -1,5 +1,7 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse;
@@ -14,6 +16,8 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@@ -21,7 +25,10 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
@@ -47,6 +54,8 @@ public class AihrUploadQueueService {
private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024;
private static final int MAX_ARCHIVE_ENTRIES = 1000;
private static final int MAX_ERROR_CHARS = 480;
// ponytail: UTF-8 + GBK covers the current Chinese sources; add an explicit charset option only if another legacy encoding appears.
private static final Charset ZIP_FALLBACK_CHARSET = Charset.forName("GBK");
/** 加工并发上限:链路含 LLM 归类与 embedding 外呼,并发过高会互相争抢配额并拖慢单文件耗时。 */
private static final int WORKER_PERMITS = 2;
/** 处理中超过该时长视为孤儿(服务重启/线程异常丢失),由清扫任务重置回待处理。需大于最慢单文件加工时长。 */
@@ -55,6 +64,7 @@ public class AihrUploadQueueService {
private static final int VIDEO_STUCK_MINUTES = 120;
/** 失败条目的暂存文件保留时长,超时由清扫回收磁盘(之后重试会提示重新上传)。 */
private static final int STAGING_RETENTION_HOURS = 72;
private static final ObjectMapper JSON = new ObjectMapper();
private final AihrSopSeedService sopSeedService;
private final JdbcTemplate jdbcTemplate;
@@ -79,6 +89,18 @@ public class AihrUploadQueueService {
}
public UploadEnqueueResponse enqueue(MultipartFile file, String category, String batchId) {
return enqueueInternal(file, List.of(), category, batchId);
}
public UploadEnqueueResponse enqueue(MultipartFile file, List<String> spaceCodes, String category, String batchId) {
List<String> normalized = normalizeSpaceCodes(spaceCodes);
if (normalized.isEmpty()) {
throw new ServiceException("请至少选择一个知识空间");
}
return enqueueInternal(file, normalized, category, batchId);
}
private UploadEnqueueResponse enqueueInternal(MultipartFile file, List<String> spaceCodes, String category, String batchId) {
if (file == null || file.isEmpty()) {
throw new ServiceException("上传文件不能为空");
}
@@ -107,9 +129,10 @@ public class AihrUploadQueueService {
}
jdbcTemplate.update("""
insert into aihr_knowledge_upload_item
(tenant_id, batch_id, file_name, category, staging_path, status, create_time, update_time)
values (?, ?, ?, ?, ?, 0, now(), now())
""", currentTenantId(), batch, fileName, category == null ? "" : category.trim(), staged.toString());
(tenant_id, batch_id, file_name, category, space_codes_json, staging_path, status, create_time, update_time)
values (?, ?, ?, ?, ?, ?, 0, now(), now())
""", currentTenantId(), batch, fileName, category == null ? "" : category.trim(),
writeSpaceCodes(spaceCodes), staged.toString());
// staging_path 含 UUID 全局唯一,用它反查主键,避免 last_insert_id 的跨连接问题
Long itemId = jdbcTemplate.query("""
select id from aihr_knowledge_upload_item where tenant_id = ? and staging_path = ?
@@ -124,7 +147,7 @@ public class AihrUploadQueueService {
String batch = sanitizeBatchId(batchId);
if (batchId == null || batchId.isBlank()) {
return jdbcTemplate.query("""
select id, batch_id, file_name, category, status, error, doc_id, fragment_count, update_time
select id, batch_id, file_name, category, space_codes_json, status, error, doc_id, fragment_count, update_time
from aihr_knowledge_upload_item
where tenant_id = ?
order by id desc
@@ -132,7 +155,7 @@ public class AihrUploadQueueService {
""".formatted(safeLimit), this::mapItem, currentTenantId());
}
return jdbcTemplate.query("""
select id, batch_id, file_name, category, status, error, doc_id, fragment_count, update_time
select id, batch_id, file_name, category, space_codes_json, status, error, doc_id, fragment_count, update_time
from aihr_knowledge_upload_item
where tenant_id = ? and batch_id = ?
order by id asc
@@ -147,11 +170,12 @@ public class AihrUploadQueueService {
}
String tenantId = currentTenantId();
ItemRow row = jdbcTemplate.query("""
select tenant_id, id, batch_id, file_name, category, staging_path, status
select tenant_id, id, batch_id, file_name, category, space_codes_json, staging_path, status
from aihr_knowledge_upload_item
where tenant_id = ? and id = ?
""", rs -> rs.next()
? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"),
rs.getString("category"), readSpaceCodes(rs.getString("space_codes_json")), rs.getString("staging_path"), rs.getInt("status"))
: null, tenantId, id);
if (row == null) {
throw new ServiceException("重试条目不存在");
@@ -242,11 +266,12 @@ public class AihrUploadQueueService {
String tenantId = claim.tenantId();
Long id = claim.id();
ItemRow row = jdbcTemplate.query("""
select tenant_id, id, batch_id, file_name, category, staging_path, status
select tenant_id, id, batch_id, file_name, category, space_codes_json, staging_path, status
from aihr_knowledge_upload_item
where tenant_id = ? and id = ?
""", rs -> rs.next()
? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"),
rs.getString("category"), readSpaceCodes(rs.getString("space_codes_json")), rs.getString("staging_path"), rs.getInt("status"))
: null, tenantId, id);
if (row == null) {
return;
@@ -269,8 +294,9 @@ public class AihrUploadQueueService {
}
return;
}
UploadResponse result = TenantHelper.dynamic(tenantId,
() -> sopSeedService.processStagedDocument(row.fileName(), row.category(), staged));
UploadResponse result = TenantHelper.dynamic(tenantId, () -> row.spaceCodes().isEmpty()
? sopSeedService.processStagedDocument(row.fileName(), row.category(), staged)
: sopSeedService.processStagedDocument(row.fileName(), row.spaceCodes(), staged));
// 0 片段的完成态(如图片待 OCR)把说明写进 error 列,面板可见原因
String note = result.fragments() != null && result.fragments() == 0 ? truncateError(result.summary()) : null;
// CAS:仅当仍是本工人持有的「处理中」才写完成,防止清扫重置后被后来的工人覆盖状态
@@ -284,22 +310,71 @@ public class AihrUploadQueueService {
} else {
log.info("upload item {} finished but row was re-claimed, skip status write", id);
}
} catch (ArchiveException e) {
log.warn("upload item {} archive rejected: {}", id, e.getMessage(), e);
markFailed(tenantId, id, e.getMessage());
} catch (Exception e) {
log.warn("upload item {} process failed(处理错误已隐藏)", id);
log.warn("upload item {} process failed", id, e);
markFailed(tenantId, id, "资料加工失败,请重试或联系管理员");
}
}
private int expandArchive(ItemRow archive) throws IOException {
List<ExtractedFile> files;
try {
files = extractArchiveWithEncodingFallback(archive.stagingPath());
} catch (ArchiveException e) {
throw e;
} catch (IOException | IllegalArgumentException e) {
throw new ArchiveException("ZIP解压失败,请使用未加密的 UTF-8 或 GBK ZIP 文件后重试", e);
}
try {
enqueueExtractedFiles(archive, files);
} catch (RuntimeException e) {
deleteExtractedFiles(files);
throw new ArchiveException("ZIP文件入队失败,请重试或联系管理员", e);
}
return files.size();
}
private List<ExtractedFile> extractArchiveWithEncodingFallback(String stagingPath) throws IOException {
try {
return extractArchive(stagingPath, StandardCharsets.UTF_8);
} catch (IOException | IllegalArgumentException e) {
if (!zipNameDecodeFailure(e)) {
throw e;
}
log.info("ZIP filenames are not UTF-8, retrying with GBK");
return extractArchive(stagingPath, ZIP_FALLBACK_CHARSET);
}
}
private static boolean zipNameDecodeFailure(Throwable error) {
Throwable current = error;
while (current != null) {
if (current instanceof IllegalArgumentException) {
return true;
}
String message = current.getMessage();
if (message != null && (message.contains("bad entry name") || message.contains("malformed input"))) {
return true;
}
current = current.getCause();
}
return false;
}
private List<ExtractedFile> extractArchive(String stagingPath, Charset charset) throws IOException {
List<ExtractedFile> files = new ArrayList<>();
long unpackedBytes = 0;
int entries = 0;
try (var input = Files.newInputStream(Path.of(archive.stagingPath()));
var zip = new ZipInputStream(input)) {
Set<String> usedFileNames = new HashSet<>();
try (var input = Files.newInputStream(Path.of(stagingPath));
var zip = new ZipInputStream(input, charset)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (++entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("ZIP文件数量不能超过 1000 个");
throw new ArchiveException("ZIP文件数量不能超过 1000 个");
}
if (entry.isDirectory()) {
continue;
@@ -307,11 +382,11 @@ public class AihrUploadQueueService {
String entryName = entry.getName() == null ? "" : entry.getName().replace('\\', '/');
Path entryPath = Path.of(entryName).normalize();
if (entryName.isBlank() || entryPath.isAbsolute() || entryPath.startsWith("..")) {
throw new IOException("ZIP包含不安全的文件路径");
throw new ArchiveException("ZIP包含不安全的文件路径");
}
String fileName = sanitizeFileName(entryPath.getFileName().toString());
String fileName = uniqueFileName(sanitizeFileName(entryPath.getFileName().toString()), usedFileNames);
if (isZip(fileName)) {
throw new IOException("不支持嵌套 ZIP 文件");
throw new ArchiveException("不支持嵌套 ZIP 文件");
}
if (!AihrSopSeedService.supportedFile(fileName)) {
continue;
@@ -326,37 +401,69 @@ public class AihrUploadQueueService {
entryBytes += read;
unpackedBytes += read;
if (entryBytes > maxBytes || unpackedBytes > MAX_ARCHIVE_UNPACKED_BYTES) {
throw new IOException("ZIP解压后文件超过大小限制");
throw new ArchiveException("ZIP解压后文件超过大小限制");
}
output.write(buffer, 0, read);
}
} catch (IOException | RuntimeException e) {
deleteQuietly(extracted);
throw e;
}
files.add(new ExtractedFile(fileName, extracted));
}
} catch (IOException e) {
files.forEach(file -> deleteQuietly(file.stagedPath()));
} catch (IOException | RuntimeException e) {
deleteExtractedFiles(files);
throw e;
}
if (files.isEmpty()) {
throw new IOException("ZIP内没有支持的资料文件");
throw new ArchiveException("ZIP内没有支持的资料文件");
}
try {
for (ExtractedFile file : files) {
jdbcTemplate.update("""
insert into aihr_knowledge_upload_item
(tenant_id, batch_id, file_name, category, staging_path, status, create_time, update_time)
values (?, ?, ?, ?, ?, 0, now(), now())
""", archive.tenantId(), archive.batchId(), file.fileName(), archive.category(), file.stagedPath().toString());
return files;
}
private void enqueueExtractedFiles(ItemRow archive, List<ExtractedFile> files) {
StringBuilder sql = new StringBuilder("""
insert into aihr_knowledge_upload_item
(tenant_id, batch_id, file_name, category, space_codes_json, staging_path, status, create_time, update_time)
values
""");
List<Object> parameters = new ArrayList<>(files.size() * 6);
for (int index = 0; index < files.size(); index++) {
if (index > 0) {
sql.append(',');
}
} catch (RuntimeException e) {
for (ExtractedFile file : files) {
jdbcTemplate.update("delete from aihr_knowledge_upload_item where tenant_id = ? and staging_path = ?",
archive.tenantId(), file.stagedPath().toString());
deleteQuietly(file.stagedPath());
}
throw new IOException("ZIP文件入队失败", e);
sql.append(" (?, ?, ?, ?, ?, ?, 0, now(), now())");
ExtractedFile file = files.get(index);
parameters.add(archive.tenantId());
parameters.add(archive.batchId());
parameters.add(file.fileName());
parameters.add(archive.category());
parameters.add(writeSpaceCodes(archive.spaceCodes()));
parameters.add(file.stagedPath().toString());
}
return files.size();
jdbcTemplate.update(sql.toString(), parameters.toArray());
}
private static void deleteExtractedFiles(List<ExtractedFile> files) {
files.forEach(file -> deleteQuietly(file.stagedPath()));
}
private static String uniqueFileName(String fileName, Set<String> usedFileNames) {
String candidate = fileName;
int suffix = 2;
while (!usedFileNames.add(candidate.toLowerCase(Locale.ROOT))) {
candidate = fileNameWithSuffix(fileName, suffix++);
}
return candidate;
}
private static String fileNameWithSuffix(String fileName, int suffix) {
int extensionStart = fileName.lastIndexOf('.');
String extension = extensionStart > 0 ? fileName.substring(extensionStart) : "";
String stem = extensionStart > 0 ? fileName.substring(0, extensionStart) : fileName;
String marker = "(" + suffix + ")";
int maxStemLength = Math.max(1, 200 - extension.length() - marker.length());
return stem.substring(0, Math.min(stem.length(), maxStemLength)) + marker + extension;
}
private static boolean isZip(String fileName) {
@@ -366,6 +473,16 @@ public class AihrUploadQueueService {
private record ExtractedFile(String fileName, Path stagedPath) {
}
private static final class ArchiveException extends IOException {
private ArchiveException(String message) {
super(message);
}
private ArchiveException(String message, Throwable cause) {
super(message, cause);
}
}
/** 清扫卡在「处理中」的孤儿条目(重启/异常导致),重置回待处理后再触发一轮加工;顺带回收过期暂存文件。 */
private void sweepAndTrigger() {
try {
@@ -430,7 +547,7 @@ public class AihrUploadQueueService {
private UploadItemResponse item(Long id) {
List<UploadItemResponse> rows = jdbcTemplate.query("""
select id, batch_id, file_name, category, status, error, doc_id, fragment_count, update_time
select id, batch_id, file_name, category, space_codes_json, status, error, doc_id, fragment_count, update_time
from aihr_knowledge_upload_item
where tenant_id = ? and id = ?
""", this::mapItem, currentTenantId(), id);
@@ -445,6 +562,7 @@ public class AihrUploadQueueService {
rs.getString("batch_id"),
rs.getString("file_name"),
rs.getString("category"),
readSpaceCodes(rs.getString("space_codes_json")),
status,
statusLabel(status),
rs.getString("error"),
@@ -488,6 +606,39 @@ public class AihrUploadQueueService {
return cleaned.isEmpty() || cleaned.length() > 64 ? "b-" + UUID.randomUUID() : cleaned;
}
private static List<String> normalizeSpaceCodes(List<String> values) {
Set<String> codes = new java.util.LinkedHashSet<>();
if (values != null) {
values.stream().filter(java.util.Objects::nonNull).map(String::trim).filter(value -> !value.isBlank()).forEach(codes::add);
}
if (codes.size() > 20) {
throw new ServiceException("单次最多选择 20 个知识空间");
}
if (codes.stream().anyMatch(code -> !code.matches("[A-Za-z0-9][A-Za-z0-9_-]{1,63}"))) {
throw new ServiceException("知识空间编码格式不正确");
}
return List.copyOf(codes);
}
private static String writeSpaceCodes(List<String> values) {
try {
return JSON.writeValueAsString(values == null ? List.of() : values);
} catch (Exception ex) {
throw new ServiceException("知识空间参数无法保存");
}
}
private static List<String> readSpaceCodes(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return normalizeSpaceCodes(JSON.readValue(json, new TypeReference<List<String>>() { }));
} catch (Exception ex) {
throw new ServiceException("上传任务的知识空间参数无效");
}
}
private static void deleteQuietly(Path path) {
try {
Files.deleteIfExists(path);
@@ -516,6 +667,7 @@ public class AihrUploadQueueService {
`batch_id` varchar(64) NOT NULL COMMENT '上传批次',
`file_name` varchar(255) NOT NULL COMMENT '文件名',
`category` varchar(100) DEFAULT '' COMMENT '目标分类',
`space_codes_json` json DEFAULT NULL COMMENT '目标知识空间编码',
`staging_path` varchar(500) NOT NULL COMMENT '暂存文件路径',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1处理中 2完成 3失败',
`error` varchar(500) DEFAULT NULL COMMENT '失败原因',
@@ -532,6 +684,7 @@ public class AihrUploadQueueService {
}
}
private record ItemRow(String tenantId, Long id, String batchId, String fileName, String category, String stagingPath, int status) {
private record ItemRow(String tenantId, Long id, String batchId, String fileName, String category,
List<String> spaceCodes, String stagingPath, int status) {
}
}
@@ -148,17 +148,22 @@ public class AihrVideoService {
}
private long probeDurationSeconds(Path video) {
Process process = null;
try {
Process process = new ProcessBuilder(
process = new ProcessBuilder(
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", video.toString()
).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip();
if (!process.waitFor(30, TimeUnit.SECONDS)) {
process.destroyForcibly();
terminateProcess(process);
throw new ServiceException("视频时长探测超时");
}
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip();
return (long) Double.parseDouble(output.lines().findFirst().orElse("0"));
} catch (InterruptedException e) {
terminateProcess(process);
Thread.currentThread().interrupt();
throw new ServiceException("视频时长探测已中断");
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
@@ -167,12 +172,19 @@ public class AihrVideoService {
}
private int runFfmpeg(List<String> command) throws IOException, InterruptedException {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
// 消费输出防止缓冲区塞满导致进程挂起
byte[] discarded = process.getInputStream().readAllBytes();
if (!process.waitFor(FFMPEG_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
process.destroyForcibly();
throw new ServiceException("ffmpeg 处理超时");
Process process = new ProcessBuilder(command)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start();
try {
if (!process.waitFor(FFMPEG_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
terminateProcess(process);
throw new ServiceException("ffmpeg 处理超时");
}
} catch (InterruptedException e) {
terminateProcess(process);
Thread.currentThread().interrupt();
throw e;
}
int exit = process.exitValue();
if (exit != 0) {
@@ -182,12 +194,39 @@ public class AihrVideoService {
}
private static boolean commandAvailable(String command) {
Process process = null;
try {
Process process = new ProcessBuilder(command, "-version").redirectErrorStream(true).start();
process.getInputStream().readAllBytes();
return process.waitFor(10, TimeUnit.SECONDS) && process.exitValue() == 0;
} catch (Exception e) {
process = new ProcessBuilder(command, "-version")
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start();
if (!process.waitFor(10, TimeUnit.SECONDS)) {
terminateProcess(process);
return false;
}
return process.exitValue() == 0;
} catch (InterruptedException e) {
terminateProcess(process);
Thread.currentThread().interrupt();
return false;
} catch (IOException e) {
return false;
}
}
private static void terminateProcess(Process process) {
if (process == null || !process.isAlive()) {
return;
}
process.destroy();
try {
if (!process.waitFor(2, TimeUnit.SECONDS)) {
process.destroyForcibly();
process.waitFor(5, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
process.destroyForcibly();
Thread.currentThread().interrupt();
}
}
@@ -0,0 +1,32 @@
package org.dromara.aihr.webai;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
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.domain.R;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@SaCheckLogin
@RequestMapping("/api/aihr/web-ai")
public class AihrWebAiController {
private final AihrWebAiService webAiService;
@GetMapping("/capabilities")
public R<CapabilitiesResponse> capabilities() {
return R.ok(webAiService.capabilities());
}
@PostMapping("/query")
public R<QueryResponse> query(@RequestBody QueryRequest request) {
return R.ok(webAiService.query(request));
}
}
@@ -0,0 +1,29 @@
package org.dromara.aihr.webai;
import java.util.List;
public final class AihrWebAiDto {
private AihrWebAiDto() {
}
public record QueryRequest(String question, Boolean consent) {
}
public record QueryResponse(String mode,
String status,
String answer,
List<AihrWebSearchClient.WebSource> sources,
String provider,
String notice,
boolean redacted,
boolean requiresConsent) {
}
public record CapabilitiesResponse(boolean enabled,
String provider,
boolean requiresConsent,
int maxQuestionLength,
String notice) {
}
}
@@ -0,0 +1,68 @@
package org.dromara.aihr.webai;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "aihr.web-ai")
public class AihrWebAiProperties {
private boolean enabled;
private String provider = "tavily";
private String endpoint = "https://api.tavily.com/search";
private String apiKey = "";
private String secretKey = "";
private int maxResults = 5;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
this.provider = provider;
}
public String getEndpoint() {
return endpoint;
}
public void setEndpoint(String endpoint) {
this.endpoint = endpoint;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String secretKey) {
this.secretKey = secretKey;
}
public int getMaxResults() {
return maxResults;
}
public void setMaxResults(int maxResults) {
this.maxResults = maxResults;
}
int boundedMaxResults() {
return Math.max(1, Math.min(maxResults, 8));
}
}
@@ -0,0 +1,137 @@
package org.dromara.aihr.webai;
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.webai.AihrWebAiDto.QueryRequest;
import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse;
import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrWebAiService {
private final AihrWebAiProperties properties;
private final AihrWebSearchClient searchClient;
private final AihrKnowledgePrincipalResolver principalResolver;
private final JdbcTemplate jdbcTemplate;
private volatile boolean auditTableReady;
public QueryResponse query(QueryRequest request) {
AihrKnowledgePrincipal principal = principalResolver.current();
String rawQuestion = normalizeQuestion(request == null ? null : request.question());
String question = AihrWebPrivacy.forExternalSearch(rawQuestion);
boolean redacted = !question.equals(rawQuestion);
AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal);
String provider = runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name();
if (request == null || !Boolean.TRUE.equals(request.consent())) {
audit(principal, question, provider, "CONSENT_REQUIRED", 0);
return new QueryResponse("web", "CONSENT_REQUIRED", "", List.of(), provider,
"请确认同意将脱敏后的问题发送到外部检索服务", redacted, true);
}
if (runtime == null) {
audit(principal, question, provider, "DISABLED", 0);
return new QueryResponse("web", "DISABLED", "", List.of(), provider,
"全网检索默认关闭,请由管理员配置合规的外部检索服务后启用", redacted, true);
}
try {
AihrWebSearchClient.SearchResult result = searchClient.search(runtime, question);
if (result.sources().isEmpty()) {
audit(principal, question, provider, "NO_SOURCES", 0);
return new QueryResponse("web", "NO_SOURCES", "", List.of(), provider,
"没有找到可公开核验的来源,本次不生成答案", redacted, true);
}
if (result.answer() == null || result.answer().isBlank()) {
audit(principal, question, provider, "NO_ANSWER", result.sources().size());
return new QueryResponse("web", "NO_ANSWER", "", result.sources(), provider,
"已找到公开来源,但外部服务未返回可核验答案;请直接查看来源", redacted, true);
}
audit(principal, question, provider, "OK", result.sources().size());
return new QueryResponse("web", "OK", result.answer(), result.sources(), provider,
"回答来自全网公开资料,不代表企业内部 SOP;请核对下方来源", redacted, true);
} catch (RuntimeException error) {
log.warn("web AI query failed provider={} (details hidden)", provider);
audit(principal, question, provider, "FAILED", 0);
return new QueryResponse("web", "FAILED", "", List.of(), provider,
"全网检索暂时不可用,本次未生成答案", redacted, true);
}
}
public CapabilitiesResponse capabilities() {
AihrKnowledgePrincipal principal = principalResolver.current();
AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal);
return new CapabilitiesResponse(runtime != null,
runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name(), true, 500,
runtime == null
? "全网检索未配置,企业内部问师傅不受影响"
: "问题会先脱敏,再发送到外部公开检索服务;答案必须带可核验来源");
}
static String normalizeQuestion(String value) {
String question = value == null ? "" : value.trim();
if (question.length() < 2) {
throw new IllegalArgumentException("请输入至少2个字的问题");
}
if (question.length() > 500) {
throw new IllegalArgumentException("问题不能超过500字");
}
return question;
}
private void audit(AihrKnowledgePrincipal principal, String question, String provider, String status, int sourceCount) {
try {
ensureAuditTable();
jdbcTemplate.update("""
insert into aihr_web_ai_query_audit
(tenant_id, user_id, ext_party_id, question, provider, query_status, source_count, create_time)
values (?, ?, ?, ?, ?, ?, ?, now())
""", principal.tenantId(), principal.userId(), principal.extPartyId(), question,
auditProvider(provider), status, sourceCount);
} catch (RuntimeException error) {
log.warn("web AI audit write failed (details hidden)");
}
}
private void ensureAuditTable() {
if (auditTableReady) {
return;
}
synchronized (this) {
if (auditTableReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_web_ai_query_audit` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`user_id` bigint NOT NULL,
`ext_party_id` varchar(80) DEFAULT NULL,
`question` varchar(600) NOT NULL,
`provider` varchar(40) NOT NULL,
`query_status` varchar(24) NOT NULL,
`source_count` int NOT NULL DEFAULT 0,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_aihr_web_ai_user` (`tenant_id`, `user_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
auditTableReady = true;
}
}
private static String clean(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
static String auditProvider(String value) {
String provider = clean(value, "unknown");
return provider.length() <= 40 ? provider : provider.substring(0, 40);
}
}
@@ -0,0 +1,22 @@
package org.dromara.aihr.webai;
import org.dromara.aihr.service.AihrSensitiveText;
import java.util.regex.Pattern;
final class AihrWebPrivacy {
private static final Pattern ID_CARD = Pattern.compile("(?<!\\d)(?:\\d{15}|\\d{17}[0-9Xx])(?!\\d)");
private static final Pattern ACCESS_SECRET = Pattern.compile(
"(?i)(门禁码|门禁密码|密码|验证码|开门码)\\s*[::]?\\s*[A-Za-z0-9_-]{4,20}"
);
private AihrWebPrivacy() {
}
static String forExternalSearch(String text) {
String safe = AihrSensitiveText.forModel(text);
safe = ID_CARD.matcher(safe).replaceAll("[身份证已脱敏]");
return ACCESS_SECRET.matcher(safe).replaceAll("$1:[已脱敏]");
}
}
@@ -0,0 +1,195 @@
package org.dromara.aihr.webai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.stereotype.Service;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Service
@RequiredArgsConstructor
public class AihrWebSearchClient {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(12);
private final AihrWebAiProperties properties;
private final AihrWebSearchProviderService providerService;
private final ObjectMapper objectMapper;
public SearchResult search(AihrKnowledgePrincipal principal, String question) {
RuntimeProvider runtime = runtime(principal);
if (runtime == null) {
throw new ServiceException("全网检索尚未启用");
}
return search(runtime, question);
}
SearchResult search(RuntimeProvider runtime, String question) {
if (!"tavily".equalsIgnoreCase(clean(runtime.code()))) {
throw new ServiceException("暂不支持当前全网检索提供方");
}
String endpoint = clean(runtime.endpoint());
String apiKey = clean(runtime.apiKey());
if (!isPublicHttpsUrl(endpoint)) {
throw new ServiceException("全网检索地址未配置或不安全");
}
if (apiKey.isBlank()) {
throw new ServiceException("全网检索密钥未配置");
}
try {
ObjectNode body = objectMapper.createObjectNode();
body.put("query", question);
body.put("search_depth", "basic");
body.put("include_answer", "basic");
body.put("include_raw_content", false);
body.put("max_results", properties.boundedMaxResults());
HttpRequest request = HttpRequest.newBuilder(URI.create(endpoint))
.timeout(REQUEST_TIMEOUT)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ServiceException("全网检索服务暂时不可用");
}
return parseTavilyResponse(objectMapper, response.body(), properties.boundedMaxResults());
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
throw new ServiceException("全网检索已中断");
} catch (ServiceException error) {
throw error;
} catch (Exception error) {
throw new ServiceException("全网检索服务暂时不可用");
}
}
RuntimeProvider runtime(AihrKnowledgePrincipal principal) {
RuntimeProvider configured = providerService.active(principal);
if (configured != null) {
return configured;
}
if (!properties.isEnabled()) {
return null;
}
if (!isPublicHttpsUrl(properties.getEndpoint()) || clean(properties.getApiKey()).isBlank()) {
return null;
}
return new RuntimeProvider(-1L, clean(properties.getProvider()), clean(properties.getProvider()),
clean(properties.getEndpoint()), clean(properties.getApiKey()), true, true, null);
}
static SearchResult parseTavilyResponse(ObjectMapper mapper, String body, int limit) throws Exception {
JsonNode root = mapper.readTree(body == null ? "{}" : body);
List<WebSource> sources = new ArrayList<>();
JsonNode results = root.path("results");
if (results.isArray()) {
for (JsonNode item : results) {
if (sources.size() >= Math.max(1, Math.min(limit, 8))) {
break;
}
String url = clean(item.path("url").asText(""));
if (!isPublicHttpUrl(url)) {
continue;
}
sources.add(new WebSource(
clean(item.path("title").asText("公开来源")),
url,
truncate(clean(item.path("content").asText("")), 600),
item.path("score").asDouble(0D)
));
}
}
String answer = sources.isEmpty() ? "" : truncate(clean(root.path("answer").asText("")), 4000);
return new SearchResult(answer, List.copyOf(sources));
}
static boolean isPublicHttpUrl(String value) {
try {
URI uri = URI.create(clean(value));
String scheme = clean(uri.getScheme()).toLowerCase(Locale.ROOT);
if (!("http".equals(scheme) || "https".equals(scheme)) || uri.getUserInfo() != null) {
return false;
}
String host = clean(uri.getHost()).toLowerCase(Locale.ROOT);
if (host.isBlank() || host.equals("localhost") || host.endsWith(".localhost")
|| host.endsWith(".local") || host.endsWith(".internal")) {
return false;
}
InetAddress[] addresses = InetAddress.getAllByName(host);
if (addresses.length == 0) {
return false;
}
for (InetAddress address : addresses) {
if (!isPublicAddress(address)) {
return false;
}
}
return true;
} catch (Exception error) {
return false;
}
}
static boolean isPublicHttpsUrl(String value) {
try {
URI uri = URI.create(clean(value));
return "https".equalsIgnoreCase(clean(uri.getScheme())) && isPublicHttpUrl(value);
} catch (RuntimeException error) {
return false;
}
}
private static boolean isPublicAddress(InetAddress address) {
if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isLinkLocalAddress()
|| address.isSiteLocalAddress() || address.isMulticastAddress()) {
return false;
}
byte[] bytes = address.getAddress();
if (address instanceof Inet4Address && bytes.length == 4) {
int first = Byte.toUnsignedInt(bytes[0]);
int second = Byte.toUnsignedInt(bytes[1]);
return first != 0 && first != 127 && first < 224
&& !(first == 100 && second >= 64 && second <= 127);
}
if (address instanceof Inet6Address && bytes.length == 16) {
int first = Byte.toUnsignedInt(bytes[0]);
return (first & 0xfe) != 0xfc;
}
return false;
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
private static String truncate(String value, int max) {
return value.length() <= max ? value : value.substring(0, max);
}
public record WebSource(String title, String url, String snippet, double score) {
}
public record SearchResult(String answer, List<WebSource> sources) {
}
}
@@ -0,0 +1,84 @@
package org.dromara.aihr.webai;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.community.AihrCommunityPolicy;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderRequest;
import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderResponse;
import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@SaCheckLogin
@RequestMapping("/api/aihr/web-search")
public class AihrWebSearchProviderAdminController {
private final AihrWebSearchProviderService providerService;
private final AihrWebSearchClient searchClient;
private final AihrKnowledgePrincipalResolver principalResolver;
@GetMapping("/providers")
public R<List<ProviderResponse>> providers() {
AihrKnowledgePrincipal principal = requireModerator();
return R.ok(providerService.list(principal));
}
@PostMapping("/providers")
public R<ProviderResponse> create(@RequestBody ProviderRequest request) {
return R.ok(providerService.create(requireModerator(), request));
}
@PutMapping("/providers")
public R<ProviderResponse> update(@RequestBody ProviderRequest request) {
return R.ok(providerService.update(requireModerator(), request));
}
@PatchMapping("/providers/{id}/enabled")
public R<ProviderResponse> enabled(@PathVariable Long id, @RequestBody EnabledRequest request) {
return R.ok(providerService.setEnabled(requireModerator(), id, request != null && request.enabled()));
}
@PostMapping("/providers/{id}/test")
public R<TestResponse> test(@PathVariable Long id) {
AihrKnowledgePrincipal principal = requireModerator();
RuntimeProvider provider = providerService.provider(principal, id);
try {
AihrWebSearchClient.SearchResult result = searchClient.search(provider, "物业服务行业最新公开信息");
boolean ok = !result.sources().isEmpty();
providerService.recordTest(principal, id, ok);
return R.ok(new TestResponse(ok, result.sources().size(),
ok ? "连接正常" : "连接成功,但未检索到可公开核验的来源"));
} catch (RuntimeException error) {
providerService.recordTest(principal, id, false);
return R.ok(new TestResponse(false, 0, "连接测试失败,请检查 HTTPS 地址和访问密钥"));
}
}
private AihrKnowledgePrincipal requireModerator() {
AihrKnowledgePrincipal principal = principalResolver.current();
if (!AihrCommunityPolicy.canModerate(principal)) {
throw new ServiceException("仅主管或管理员可管理全网检索提供方", 403);
}
return principal;
}
public record EnabledRequest(boolean enabled) {
}
public record TestResponse(boolean ok, int sourceCount, String message) {
}
}
@@ -0,0 +1,233 @@
package org.dromara.aihr.webai;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
@Service
@RequiredArgsConstructor
public class AihrWebSearchProviderService {
private final JdbcTemplate jdbcTemplate;
private final AihrWebSecretCodec secretCodec;
private volatile boolean tableReady;
public List<ProviderResponse> list(AihrKnowledgePrincipal principal) {
ensureTable();
return jdbcTemplate.query("""
select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time
from aihr_web_search_provider where tenant_id = ? order by id
""", (rs, rowNum) -> new ProviderResponse(
rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"),
rs.getString("endpoint"), hasText(rs.getString("api_key")), rs.getBoolean("enabled"),
rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class)
), principal.tenantId());
}
public ProviderResponse create(AihrKnowledgePrincipal principal, ProviderRequest request) {
ensureTable();
ProviderData data = normalize(request, false);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_web_search_provider
(tenant_id, provider_name, provider_code, endpoint, api_key, enabled, create_time, update_time)
values (?, ?, ?, ?, ?, 0, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, principal.tenantId());
statement.setString(2, data.name());
statement.setString(3, data.code());
statement.setString(4, data.endpoint());
statement.setString(5, secretCodec.encrypt(data.apiKey()));
return statement;
}, keyHolder);
Number key = keyHolder.getKey();
return responseById(principal, key == null ? 0L : key.longValue());
}
public ProviderResponse update(AihrKnowledgePrincipal principal, ProviderRequest request) {
ensureTable();
ProviderData data = normalize(request, true);
String apiKey = hasText(request.apiKey()) ? secretCodec.encrypt(request.apiKey().trim()) : null;
int updated = jdbcTemplate.update("""
update aihr_web_search_provider
set provider_name = ?, provider_code = ?, endpoint = ?,
api_key = case when ? is null then api_key else ? end,
enabled = 0, last_test_ok = 0, last_test_time = null,
update_time = now()
where tenant_id = ? and id = ?
""", data.name(), data.code(), data.endpoint(), apiKey, apiKey, principal.tenantId(), request.id());
if (updated == 0) {
throw new IllegalArgumentException("全网检索提供方不存在");
}
return responseById(principal, request.id());
}
@Transactional(rollbackFor = Exception.class)
public ProviderResponse setEnabled(AihrKnowledgePrincipal principal, Long id, boolean enabled) {
ensureTable();
if (enabled) {
RuntimeProvider selected = provider(principal, id);
if (!AihrWebSearchClient.isPublicHttpsUrl(selected.endpoint()) || !hasText(selected.apiKey())) {
throw new IllegalArgumentException("请先配置安全的公开接口和访问密钥");
}
if (!selected.lastTestOk() || selected.lastTestTime() == null) {
throw new IllegalArgumentException("当前配置尚未通过连接测试,请先测试后再启用");
}
jdbcTemplate.update("""
update aihr_web_search_provider set enabled = 0, update_time = now() where tenant_id = ?
""", principal.tenantId());
}
jdbcTemplate.update("""
update aihr_web_search_provider set enabled = ?, update_time = now() where tenant_id = ? and id = ?
""", enabled ? 1 : 0, principal.tenantId(), id);
return responseById(principal, id);
}
public ProviderResponse recordTest(AihrKnowledgePrincipal principal, Long id, boolean ok) {
ensureTable();
int updated = jdbcTemplate.update("""
update aihr_web_search_provider
set last_test_ok = ?, last_test_time = now(), update_time = now()
where tenant_id = ? and id = ?
""", ok ? 1 : 0, principal.tenantId(), id);
if (updated == 0) {
throw new IllegalArgumentException("全网检索提供方不存在");
}
return responseById(principal, id);
}
RuntimeProvider active(AihrKnowledgePrincipal principal) {
ensureTable();
try {
List<RuntimeProvider> rows = jdbcTemplate.query("""
select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time
from aihr_web_search_provider
where tenant_id = ? and enabled = 1
order by id desc limit 1
""", (rs, rowNum) -> providerRow(rs), principal.tenantId());
return rows.isEmpty() ? null : rows.get(0);
} catch (ServiceException error) {
return null;
}
}
RuntimeProvider provider(AihrKnowledgePrincipal principal, Long id) {
ensureTable();
List<RuntimeProvider> rows = jdbcTemplate.query("""
select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time
from aihr_web_search_provider where tenant_id = ? and id = ?
""", (rs, rowNum) -> providerRow(rs), principal.tenantId(), id);
if (rows.isEmpty()) {
throw new IllegalArgumentException("全网检索提供方不存在");
}
return rows.get(0);
}
private RuntimeProvider providerRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new RuntimeProvider(rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"),
rs.getString("endpoint"), secretCodec.decrypt(rs.getString("api_key")), rs.getBoolean("enabled"),
rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class));
}
private ProviderResponse responseById(AihrKnowledgePrincipal principal, Long id) {
List<ProviderResponse> rows = jdbcTemplate.query("""
select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time
from aihr_web_search_provider where tenant_id = ? and id = ?
""", (rs, rowNum) -> new ProviderResponse(
rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"),
rs.getString("endpoint"), hasText(rs.getString("api_key")), rs.getBoolean("enabled"),
rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class)
), principal.tenantId(), id);
if (rows.isEmpty()) {
throw new IllegalArgumentException("全网检索提供方不存在");
}
return rows.get(0);
}
private static ProviderData normalize(ProviderRequest request, boolean idRequired) {
if (request == null || (idRequired && request.id() == null)) {
throw new IllegalArgumentException("提供方参数不完整");
}
String name = clean(request.name());
String code = clean(request.providerCode()).toLowerCase(Locale.ROOT);
String endpoint = clean(request.endpoint());
if (name.isBlank() || name.length() > 100) {
throw new IllegalArgumentException("提供方名称不能为空且不能超过100字");
}
if (!"tavily".equals(code)) {
throw new IllegalArgumentException("当前仅支持 tavily");
}
if (!AihrWebSearchClient.isPublicHttpsUrl(endpoint)) {
throw new IllegalArgumentException("检索地址必须是可公开访问的 HTTPS 地址");
}
String apiKey = clean(request.apiKey());
if (!idRequired && apiKey.isBlank()) {
throw new IllegalArgumentException("访问密钥不能为空");
}
if (apiKey.length() > 1000) {
throw new IllegalArgumentException("访问密钥长度超限");
}
return new ProviderData(name, code, endpoint, apiKey);
}
private void ensureTable() {
if (tableReady) {
return;
}
synchronized (this) {
if (tableReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_web_search_provider` (
`id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL,
`provider_name` varchar(100) NOT NULL, `provider_code` varchar(40) NOT NULL DEFAULT 'tavily',
`endpoint` varchar(500) NOT NULL, `api_key` varchar(1000) DEFAULT NULL,
`enabled` tinyint NOT NULL DEFAULT 0,
`last_test_ok` tinyint NOT NULL DEFAULT 0,
`last_test_time` datetime DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `uk_aihr_web_search_provider` (`tenant_id`, `provider_name`),
KEY `idx_aihr_web_search_enabled` (`tenant_id`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
tableReady = true;
}
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
public record ProviderRequest(Long id, String name, String providerCode, String endpoint, String apiKey) {
}
public record ProviderResponse(Long id, String name, String providerCode, String endpoint,
boolean configured, boolean enabled, boolean lastTestOk,
LocalDateTime lastTestTime) {
}
record RuntimeProvider(Long id, String name, String code, String endpoint, String apiKey, boolean enabled,
boolean lastTestOk, LocalDateTime lastTestTime) {
}
private record ProviderData(String name, String code, String endpoint, String apiKey) {
}
}
@@ -0,0 +1,79 @@
package org.dromara.aihr.webai;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
@Component
@RequiredArgsConstructor
class AihrWebSecretCodec {
private static final String PREFIX = "enc:v1:";
private static final SecureRandom RANDOM = new SecureRandom();
private final AihrWebAiProperties properties;
String encrypt(String plaintext) {
if (plaintext == null || plaintext.isBlank()) {
return "";
}
try {
byte[] iv = new byte[12];
RANDOM.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key(), new GCMParameterSpec(128, iv));
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] payload = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, payload, 0, iv.length);
System.arraycopy(encrypted, 0, payload, iv.length, encrypted.length);
return PREFIX + Base64.getEncoder().encodeToString(payload);
} catch (ServiceException error) {
throw error;
} catch (Exception error) {
throw new ServiceException("全网检索密钥加密失败");
}
}
String decrypt(String encoded) {
if (encoded == null || encoded.isBlank()) {
return "";
}
if (!encoded.startsWith(PREFIX)) {
throw new ServiceException("全网检索密钥格式不安全,请重新保存");
}
try {
byte[] payload = Base64.getDecoder().decode(encoded.substring(PREFIX.length()));
if (payload.length <= 28) {
throw new IllegalArgumentException("ciphertext too short");
}
byte[] iv = new byte[12];
byte[] ciphertext = new byte[payload.length - iv.length];
System.arraycopy(payload, 0, iv, 0, iv.length);
System.arraycopy(payload, iv.length, ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, iv));
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
} catch (ServiceException error) {
throw error;
} catch (Exception error) {
throw new ServiceException("全网检索密钥解密失败,请重新保存");
}
}
private SecretKeySpec key() throws Exception {
String secret = properties.getSecretKey() == null ? "" : properties.getSecretKey().trim();
if (secret.length() < 16) {
throw new ServiceException("请先配置至少16位的 AIHR_WEB_AI_SECRET_KEY");
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8));
return new SecretKeySpec(digest, "AES");
}
}
@@ -0,0 +1,99 @@
package org.dromara.aihr.community;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.common.core.enums.UserType;
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.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AihrCommunityPolicyTest {
@Test
@Tag("dev")
void rewardUsesConfigurablePointsAndNeverRepresentsCash() {
AihrCommunityProperties properties = new AihrCommunityProperties();
properties.setDefaultRewardPoints(12);
properties.setMaxRewardPoints(100);
assertEquals(12, AihrCommunityPolicy.rewardPoints(null, properties));
assertEquals(0, AihrCommunityPolicy.rewardPoints(0, properties));
assertEquals(100, AihrCommunityPolicy.rewardPoints(999, properties));
assertThrows(IllegalArgumentException.class, () -> AihrCommunityPolicy.rewardPoints(-1, properties));
}
@Test
@Tag("dev")
void moderationAuthorityComesFromServerPrincipal() {
AihrKnowledgePrincipal employee = principal(UserType.APP_USER.getUserType(), Set.of("employee"));
AihrKnowledgePrincipal supervisor = principal(UserType.APP_USER.getUserType(), Set.of("employee", "supervisor"));
AihrKnowledgePrincipal hr = principal(UserType.SYS_USER.getUserType(), Set.of("hr_operator"));
assertFalse(AihrCommunityPolicy.canModerate(employee));
assertTrue(AihrCommunityPolicy.canModerate(supervisor));
assertTrue(AihrCommunityPolicy.canModerate(hr));
}
@Test
@Tag("dev")
void moderationDecisionIsExplicit() {
assertEquals("PUBLISHED", AihrCommunityPolicy.moderatedStatus("approve"));
assertEquals("REJECTED", AihrCommunityPolicy.moderatedStatus("reject"));
assertThrows(IllegalArgumentException.class, () -> AihrCommunityPolicy.moderatedStatus("pending"));
}
@Test
@Tag("dev")
void rewardLedgerMigrationHasAnIdempotencyConstraintAndNoCashColumn() throws Exception {
Path migration = Path.of("../../script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql");
if (!Files.exists(migration)) {
migration = Path.of("script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql");
}
String sql = Files.readString(migration).toLowerCase();
assertTrue(sql.contains("uk_aihr_points_business"));
assertTrue(sql.contains("account_type"));
assertTrue(sql.contains("study_credit"));
assertTrue(sql.contains("reward_points"));
assertFalse(sql.contains("cash_amount"));
assertFalse(sql.contains("payment"));
}
@Test
@Tag("dev")
void incentiveAccountsAreRestrictedToRealPointsAndStudyCreditLedgers() {
assertEquals("POINTS", AihrIncentiveService.normalizeAccountType("points"));
assertEquals("STUDY_CREDIT", AihrIncentiveService.normalizeAccountType(" study_credit "));
assertThrows(IllegalArgumentException.class,
() -> AihrIncentiveService.normalizeAccountType("PRIZE"));
assertThrows(IllegalArgumentException.class,
() -> AihrIncentiveService.normalizeAccountType("CASH"));
}
@Test
@Tag("dev")
void bestAnswerWriteIsTransactionalAndUsesTheUniqueLedgerInsert() throws Exception {
Path source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java");
if (!Files.exists(source)) {
source = Path.of("src/main/java/org/dromara/aihr/community/AihrCommunityService.java");
}
String code = Files.readString(source);
assertTrue(code.contains("@Transactional(rollbackFor = Exception.class)"));
assertTrue(code.contains("insert ignore into aihr_points_ledger"));
assertTrue(code.contains("account_type = ? and create_time >= current_date()"));
assertTrue(code.contains("accountBalance(principal.tenantId(), answer.authorUserId(), \"STUDY_CREDIT\")"));
assertTrue(code.contains("best_answer_id is null"));
}
private static AihrKnowledgePrincipal principal(String userType, Set<String> roles) {
return new AihrKnowledgePrincipal("000000", 7L, userType, "staff-7", roles, Set.of("P1"), "mobile");
}
}
@@ -0,0 +1,79 @@
package org.dromara.aihr.knowledge;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService;
import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrKnowledgeDataToolServiceTest {
@Test
void myPracticeSummaryAlwaysUsesCurrentAppUserIdentity() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
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).practiceHistory("employee-1");
var data = (AihrKnowledgeDataToolService.MyPracticeSummary) result.data();
assertEquals(1, data.practiceCount());
assertEquals("投诉沟通", data.recentRecords().get(0).scene());
}
@Test
void teamPracticeSummaryUsesResolvedSupervisorScope() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor");
when(mobile.practiceTeam("canonical-supervisor"))
.thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of()));
var service = new AihrKnowledgeDataToolService(mobile);
var principal = new AihrKnowledgePrincipal("000000", 2L, "app_user", "supervisor-1",
Set.of("employee", "supervisor"), Set.of("P1"), "app");
service.execute(principal, AihrKnowledgeDataToolService.TEAM_PRACTICE_SUMMARY);
verify(mobile).requireSupervisorIdentity("supervisor-1");
verify(mobile).practiceTeam("canonical-supervisor");
}
@Test
void employeeCannotCallTeamPracticeSummary() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
var service = new AihrKnowledgeDataToolService(mobile);
assertThrows(ServiceException.class,
() -> service.execute(employee(), AihrKnowledgeDataToolService.TEAM_PRACTICE_SUMMARY));
verify(mobile, never()).practiceTeam(org.mockito.ArgumentMatchers.anyString());
}
@Test
void unknownToolIsRejectedWithoutReadingTrainingData() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
var service = new AihrKnowledgeDataToolService(mobile);
assertThrows(ServiceException.class, () -> service.execute(employee(), "RAW_SQL"));
verify(mobile, never()).practiceHistory(org.mockito.ArgumentMatchers.anyString());
}
private static AihrKnowledgePrincipal employee() {
return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
Set.of("employee"), Set.of("P1"), "app");
}
}
@@ -0,0 +1,173 @@
package org.dromara.aihr.knowledge;
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryAuditService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockMultipartFile;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrKnowledgeQueryServiceTest {
@Test
void emptyEffectiveScopeStopsBeforeRag() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var principal = new AihrKnowledgePrincipal("000000", 1L, "sys_user", "", Set.of("employee"), Set.of(), "pc");
var app = new AuthenticatedApp(1L, "000000", "yc_admin", "管理端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "pc")).thenReturn(app);
when(access.resolveInternalSpaceIds(any(), any(), any(), any()))
.thenThrow(new ServiceException("no scope", 403));
var service = service(resolver, appService, access, sop);
assertThrows(ServiceException.class, () -> service.queryInternal(
new QueryRequest("制度是什么", List.of(), "sop", null, "test", 5, null)));
verify(sop, never()).searchAuthorized(any(), any(), any());
}
@Test
void externalApplicationCannotCallDataTools() {
var sop = mock(AihrSopSeedService.class);
var service = service(mock(AihrKnowledgePrincipalResolver.class), mock(AihrKnowledgeAppService.class),
mock(AihrKnowledgeAccessService.class), sop);
var app = new AuthenticatedApp(2L, "100001", "mt_card_miniapp", "名片小程序", "API_TOKEN", 60, null);
assertThrows(ServiceException.class, () -> service.queryExternal(app,
new QueryRequest("我的训练", List.of(), null, null, "miniapp", 5, "MY_PRACTICE_SUMMARY")));
verify(sop, never()).searchAuthorized(any(), any(), any());
}
@Test
void mediaAnalysisStartsOnlyAfterKnowledgeScopeAuthorization() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.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);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ"))
.thenThrow(new ServiceException("no scope", 403));
var service = service(resolver, appService, access, sop);
var media = new MockMultipartFile("file", "lift.jpg", "image/jpeg", new byte[]{1});
assertThrows(ServiceException.class, () -> service.queryInternalMedia(
new QueryRequest("电梯怎么处理", List.of(), "sop", "保安", "mobile_uni_media", 5, null), media));
verify(sop, never()).extractQuestionMedia(any(), any());
verify(sop, never()).searchAuthorized(any(), any(), any());
}
@Test
void externalDocumentQueryRunsInsideAuthenticatedApplicationTenant() {
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class);
var app = new AuthenticatedApp(2L, "100001", "mt_card_miniapp", "名片小程序", "API_TOKEN", 60, null);
when(access.resolveExternalSpaceIds(app, List.of("mt_customer_service"))).thenReturn(Set.of(9L));
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class)))
.thenReturn(List.of("mt_customer_service"));
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("公开服务", "sop", "仅返回公开内容", "", List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(mock(AihrKnowledgePrincipalResolver.class),
mock(AihrKnowledgeAppService.class), access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc,
mock(AihrKnowledgeDataToolService.class));
var result = service.queryExternal(app,
new QueryRequest("公开服务", List.of("mt_customer_service"), null, null, "miniapp", 5, null));
assertEquals(List.of("mt_customer_service"), result.usedSpaceCodes());
}
@Test
void internalToolFailureNeverFallsBackToRag() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var sop = mock(AihrSopSeedService.class);
var tools = mock(AihrKnowledgeDataToolService.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);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(tools.execute(principal, "MY_PRACTICE_SUMMARY")).thenThrow(new ServiceException("tool failed", 500));
var service = new AihrKnowledgeQueryService(resolver, appService, mock(AihrKnowledgeAccessService.class), sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), tools);
assertThrows(ServiceException.class, () -> service.queryInternal(
new QueryRequest("我的训练", List.of(), null, null, "mobile", 5, "MY_PRACTICE_SUMMARY")));
verify(sop, never()).searchAuthorized(any(), any(), any());
}
@Test
void mediaContextParticipatesInRetrievalWithoutReplacingDisplayedQuestion() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.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);
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(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, "电梯这里该怎么办"))
.thenReturn("画面显示电梯门无法打开,楼层指示灯闪烁");
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("现场问题", "sop", "按电梯困人流程处理", "", List.of(), List.of(),
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));
var result = service.queryInternalMedia(
new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null),
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("电梯门无法打开"));
}
private static AihrKnowledgeQueryService service(
AihrKnowledgePrincipalResolver resolver,
AihrKnowledgeAppService appService,
AihrKnowledgeAccessService access,
AihrSopSeedService sop
) {
return new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class));
}
}
@@ -0,0 +1,95 @@
package org.dromara.aihr.knowledge;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.AdminContext;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.SpaceView;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UpdateSpaceRequest;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeSpaceAdminService;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrKnowledgeSpaceAdminServiceTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void listReturnsOnlyCurrentTenantSpaces() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of());
var service = new AihrKnowledgeSpaceAdminService(jdbc, mock(AihrKnowledgeAppService.class), mock(AihrSopSeedService.class));
service.spaces(admin());
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object[]> args = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).query(sql.capture(), any(RowMapper.class), args.capture());
assertTrue(sql.getValue().contains("where k.tenant_id = ?"));
assertTrue(java.util.Arrays.asList(args.getValue()).contains("tenant-a"));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void codeCannotChangeAfterSpaceCreation() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of(
new SpaceView(1L, "fixed_code", "旧名称", "", "BUSINESS", "INTERNAL", "ACTIVE", 0, 0)));
var service = new AihrKnowledgeSpaceAdminService(jdbc, mock(AihrKnowledgeAppService.class), mock(AihrSopSeedService.class));
assertThrows(ServiceException.class, () -> service.updateSpace(admin(), 1L,
new UpdateSpaceRequest("changed_code", "新名称", "", "BUSINESS", "INTERNAL", "ACTIVE")));
}
@Test
void tokenHashNeverAppearsInAdminDtoOrSelect() throws Exception {
Path dto = Path.of("src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeSpaceDto.java");
Path service = Path.of("src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java");
if (!Files.exists(dto)) {
dto = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeSpaceDto.java");
service = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java");
}
assertFalse(Files.readString(dto).contains("tokenHash"));
String code = Files.readString(service);
String listMethod = code.substring(code.indexOf("public List<AppView> apps"), code.indexOf("public CreateAppResponse createApp"));
assertFalse(listMethod.contains("token_hash"));
assertTrue(code.contains("token=hidden"));
}
@Test
void adminServiceKeepsTenantChecksOnBindingsAndManageChecks() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java");
}
String code = Files.readString(source);
assertTrue(code.contains("where tenant_id = ? and code in"));
assertTrue(code.contains("应用绑定包含其他租户或不存在的空间"));
assertTrue(code.contains("g.permission = 'MANAGE'"));
assertTrue(code.contains("String roles = roleScope(context.roles(), args)"));
assertTrue(code.contains("aihr_knowledge_admin_audit"));
assertTrue(code.contains("where a.tenant_id = ? and a.knowledge_id = ?"));
assertTrue(code.contains("sopService.unbindDocumentMembership(space.code(), attachId)"));
}
private static AdminContext admin() {
return new AdminContext("tenant-a", 7L, Set.of("admin"), true);
}
}
@@ -0,0 +1,97 @@
package org.dromara.aihr.learning;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult;
import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitResponse;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrExamIdempotencyTest {
@Test
void submissionTimestampUsesTheSamePrecisionAsTheDatabase() {
LocalDateTime raw = LocalDateTime.of(2026, 7, 18, 0, 5, 27, 900_000_000);
assertEquals(LocalDateTime.of(2026, 7, 18, 0, 5, 27),
AihrExamService.databaseSecondPrecision(raw));
}
@Test
void retryWithSameRequestIdRestoresTheCompleteOriginalPerQuestionResult() throws Exception {
AihrExamService service = new AihrExamService(new ReplayJdbcTemplate(), new ObjectMapper());
ExamSubmitResponse expected = new ExamSubmitResponse(
42L, "SUBMITTED", 40, false, "2026-07-17 10:11:12",
List.of(
new ExamAnswerResult(101L, true, 40, 40, "回答正确"),
new ExamAnswerResult(102L, false, 0, 60, "应先确认现场风险并约定反馈时间")
)
);
ExamSubmitResponse firstRetry = findAttempt(service);
ExamSubmitResponse secondRetry = findAttempt(service);
assertEquals(expected, firstRetry);
assertEquals(firstRetry, secondRetry);
assertEquals(expected.results(), secondRetry.results());
}
private static ExamSubmitResponse findAttempt(AihrExamService service) throws Exception {
var method = AihrExamService.class.getDeclaredMethod(
"findAttempt", Long.class, String.class, String.class);
method.setAccessible(true);
return (ExamSubmitResponse) method.invoke(service, 7L, "EMP-1", "submit-001");
}
private static final class ReplayJdbcTemplate extends JdbcTemplate {
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
try {
if (sql.contains("FROM aihr_onboard_exam_attempt")) {
ResultSet attempt = mock(ResultSet.class);
when(attempt.getLong("id")).thenReturn(42L);
when(attempt.getString("status")).thenReturn("SUBMITTED");
when(attempt.getInt("score")).thenReturn(40);
when(attempt.getBoolean("passed")).thenReturn(false);
when(attempt.getTimestamp("submitted_time"))
.thenReturn(Timestamp.valueOf("2026-07-17 10:11:12"));
return List.of(rowMapper.mapRow(attempt, 0));
}
if (sql.contains("FROM aihr_onboard_exam_answer")) {
List<T> rows = new ArrayList<>();
rows.add(rowMapper.mapRow(answerRow(101L, true, 40, 40, "回答正确"), 0));
rows.add(rowMapper.mapRow(answerRow(102L, false, 0, 60,
"应先确认现场风险并约定反馈时间"), 1));
return rows;
}
return List.of();
} catch (java.sql.SQLException error) {
throw new IllegalStateException(error);
}
}
private static ResultSet answerRow(long questionId, boolean correct, int earnedScore, int maxScore,
String explanation) throws java.sql.SQLException {
ResultSet row = mock(ResultSet.class);
when(row.getLong("question_id")).thenReturn(questionId);
when(row.getBoolean("correct")).thenReturn(correct);
when(row.getInt("earned_score")).thenReturn(earnedScore);
when(row.getInt("max_score")).thenReturn(maxScore);
when(row.getString("explanation")).thenReturn(explanation);
return row;
}
}
}
@@ -0,0 +1,84 @@
package org.dromara.aihr.learning;
import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDraft;
import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamTargetDraft;
import org.dromara.common.core.exception.ServiceException;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrExamScoringTest {
@Test
void objectiveAnswersUseExactSetComparisonAndShortAnswersRequireAllKeywords() {
var questions = List.of(
new ExamQuestionDraft(11L, "single", "第一步?", List.of("A", "B"), List.of("A"), 30, "", 1),
new ExamQuestionDraft(12L, "multiple", "哪些需要记录?", List.of("A", "B", "C"), List.of("A", "C"), 40, "", 2),
new ExamQuestionDraft(13L, "short", "闭环要素", List.of(), List.of("责任人", "反馈时间"), 30, "", 3)
);
var score = AihrExamScoring.score(questions, List.of(
new ExamAnswerRequest(11L, List.of("A")),
new ExamAnswerRequest(12L, List.of("C", "A")),
new ExamAnswerRequest(13L, List.of("先明确责任人,再约定反馈时间"))
));
assertEquals(100, score.score());
assertTrue(score.results().stream().allMatch(result -> result.correct()));
var partial = AihrExamScoring.score(questions, List.of(
new ExamAnswerRequest(11L, List.of("B")),
new ExamAnswerRequest(12L, List.of("A")),
new ExamAnswerRequest(13L, List.of("只说明责任人"))
));
assertEquals(0, partial.score());
assertFalse(partial.results().stream().anyMatch(result -> result.correct()));
}
@Test
void examDraftRequiresQuestionsWhoseScoresTotalOneHundred() {
ExamUpsertRequest invalid = new ExamUpsertRequest(
"岗前测验", "P100", "生活顾问", 80, null,
List.of(new ExamQuestionDraft(null, "single", "第一步?", List.of("A", "B"), List.of("A"), 60, "", 1))
);
ServiceException error = assertThrows(ServiceException.class, () -> AihrExamScoring.validate(invalid));
assertEquals("试卷题目分值合计必须为100分", error.getMessage());
}
@Test
void examTargetsAreUniqueAndLimitedToOneHundred() {
assertThrows(ServiceException.class, () -> AihrExamScoring.validateTargets(List.of(
new ExamTargetDraft("EMP-1", "张三"), new ExamTargetDraft("EMP-1", "张三")
)));
List<ExamTargetDraft> tooMany = java.util.stream.IntStream.rangeClosed(1, 101)
.mapToObj(index -> new ExamTargetDraft("EMP-" + index, "员工" + index)).toList();
ServiceException error = assertThrows(ServiceException.class,
() -> AihrExamScoring.validateTargets(tooMany));
assertEquals("单次考试最多选择100名员工", error.getMessage());
}
@Test
void feedbackAndContentHashAreCanonicalAndStrict() {
assertEquals("useful", AihrLearningService.normalizeVerdict(" useful "));
assertEquals("not_useful", AihrLearningService.normalizeVerdict("not_useful"));
assertThrows(ServiceException.class, () -> AihrLearningService.normalizeVerdict("like"));
String first = AihrLearningService.contentHash("v1", " 如何处理? ", "先承接情绪");
String same = AihrLearningService.contentHash("v1", "如何处理?", "先承接情绪");
String changed = AihrLearningService.contentHash("v2", "如何处理?", "先承接情绪");
assertEquals(64, first.length());
assertEquals(first, same);
assertFalse(first.equals(changed));
}
}
@@ -0,0 +1,177 @@
package org.dromara.aihr.learning;
import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackRequest;
import org.dromara.aihr.learning.AihrLearningDto.DifficultyAssignRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.lang.reflect.ParameterizedType;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
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 AihrLearningContractTest {
@Test
void assignmentFeedbackIsAnOwnedIdempotentUpsert() {
FeedbackJdbcTemplate jdbcTemplate = new FeedbackJdbcTemplate();
AihrLearningService service = new AihrLearningService(jdbcTemplate, null);
var response = service.submitAssignmentFeedback(9L,
new AssignmentFeedbackRequest("not_useful", "题目与现场不符"), "EMP-1");
assertEquals(9L, response.assignmentId());
assertEquals("not_useful", response.verdict());
assertTrue(jdbcTemplate.feedbackSql.contains("ON DUPLICATE KEY UPDATE"));
assertEquals("EMP-1", jdbcTemplate.feedbackArgs[2]);
}
@Test
void mobileControllerPublishesTheAgreedLearningAndExamRoutes() throws Exception {
String controller = Files.readString(projectPath(
"src/main/java/org/dromara/aihr/controller/AihrMobileController.java"));
assertTrue(controller.contains("@PostMapping(\"/practice/assignments/{id}/feedback\")"));
assertTrue(controller.contains("@GetMapping(\"/practice/difficulties\")"));
assertTrue(controller.contains("@PostMapping(\"/practice/difficulties/assign\")"));
assertTrue(controller.contains("@GetMapping(\"/learning/matrix\")"));
assertTrue(controller.contains("@GetMapping(\"/exams\")"));
assertTrue(controller.contains("@PostMapping(\"/exams/{id}/submit\")"));
assertTrue(controller.contains("@PostMapping(\"/exams/supervisor/{id}/publish\")"));
assertTrue(controller.contains("@GetMapping(\"/exams/supervisor/{id}/results\")"));
}
@Test
void migrationKeepsVersionedSnapshotsFeedbackAndExamAttempts() throws Exception {
String migration = Files.readString(projectPath(
"../../script/sql/update/aihr_20260717_learning_closure_mysql8.sql"));
assertTrue(migration.contains("question_snapshot"));
assertTrue(migration.contains("reference_answer_snapshot"));
assertTrue(migration.contains("idx_aihr_assignment_recent_content"));
assertTrue(migration.contains("aihr_practice_question_feedback"));
assertTrue(migration.contains("aihr_onboard_exam_attempt"));
assertTrue(migration.contains("uk_aihr_onboard_exam_attempt_request"));
assertTrue(migration.contains("aihr_onboard_exam_target"));
assertTrue(migration.contains("publish_request_key"));
assertTrue(migration.contains("uk_aihr_onboard_exam_target"));
assertTrue(migration.contains("uk_aihr_onboard_exam_publish_request"));
}
@Test
void employeeExamVisibilityIsTargetBasedAndPublishRequiresOneToOneHundredTargets() throws Exception {
String service = Files.readString(projectPath(
"src/main/java/org/dromara/aihr/learning/AihrExamService.java"));
assertTrue(service.contains("JOIN aihr_onboard_exam_target own_target"));
assertTrue(service.contains("own_target.ext_party_id = ?"));
assertTrue(service.contains("JOIN aihr_onboard_exam_target t"));
assertTrue(service.contains("t.ext_party_id = ?"));
assertTrue(service.contains("targetCount < 1 || targetCount > 100"));
assertTrue(service.contains("assertPublishRequestAvailable"));
assertTrue(service.contains("publish_request_key = ?"));
}
@Test
void submitContractUsesAnExplicitAnswerListRatherThanAnAmbiguousRecordMap() {
var answers = java.util.Arrays.stream(ExamSubmitRequest.class.getRecordComponents())
.filter(component -> "answers".equals(component.getName()))
.findFirst().orElseThrow();
assertEquals(List.class, answers.getType());
ParameterizedType generic = (ParameterizedType) answers.getGenericType();
assertEquals(AihrLearningDto.ExamAnswerRequest.class, generic.getActualTypeArguments()[0]);
}
@Test
void commonDifficultyAssignmentUsesTheDisplayedWindowAndPositionScope() throws Exception {
assertEquals(30, AihrLearningService.validateDifficultyWindow(null));
assertEquals(7, AihrLearningService.validateDifficultyWindow(7));
assertThrows(org.dromara.common.core.exception.ServiceException.class,
() -> AihrLearningService.validateDifficultyWindow(0));
assertThrows(org.dromara.common.core.exception.ServiceException.class,
() -> AihrLearningService.validateDifficultyWindow(366));
String service = Files.readString(projectPath(
"src/main/java/org/dromara/aihr/learning/AihrLearningService.java"));
assertTrue(service.contains("difficultyTargets(request.scenarioId(), windowDays, request.position()"));
assertTrue(service.contains("TIMESTAMPADD(DAY, -?, NOW())"));
assertTrue(service.contains("scenario.position = ?"));
assertTrue(service.contains("requestedIdentities.contains"));
DifficultyJdbcTemplate jdbcTemplate = new DifficultyJdbcTemplate();
AihrLearningService learningService = new AihrLearningService(jdbcTemplate, null);
assertThrows(org.dromara.common.core.exception.ServiceException.class, () ->
learningService.assignDifficulty(new DifficultyAssignRequest(
"SCENE-1", "近期低分", "2026-07-18", "difficulty:1", 7, "保洁", List.of()), ""));
assertTrue(jdbcTemplate.difficultySql.contains("TIMESTAMPADD(DAY, -?, NOW())"));
assertTrue(jdbcTemplate.difficultySql.contains("scenario.position = ?"));
assertEquals(7, jdbcTemplate.difficultyArgs[3]);
assertEquals(7, jdbcTemplate.difficultyArgs[6]);
assertEquals("保洁", jdbcTemplate.difficultyArgs[9]);
assertEquals("保洁", jdbcTemplate.difficultyArgs[10]);
}
private static Path projectPath(String relative) {
Path direct = Path.of(relative);
if (Files.exists(direct)) {
return direct;
}
return Path.of("ruoyi-modules/ruoyi-aihr").resolve(relative).normalize();
}
private static final class FeedbackJdbcTemplate extends JdbcTemplate {
private String feedbackSql = "";
private Object[] feedbackArgs = new Object[0];
@Override
public void execute(String sql) {
// DDL is covered by the migration contract test.
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
return requiredType.cast(1);
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("aihr_practice_question_feedback")) {
feedbackSql = sql;
feedbackArgs = args;
}
return 1;
}
}
private static final class DifficultyJdbcTemplate extends JdbcTemplate {
private String difficultySql = "";
private Object[] difficultyArgs = new Object[0];
@Override
public void execute(String sql) {
// DDL is covered by the migration contract test.
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
return requiredType.cast(1);
}
@Override
public <T> List<T> query(String sql, org.springframework.jdbc.core.RowMapper<T> rowMapper, Object... args) {
if (sql.contains("FROM aihr_practice_assignment a") && sql.contains("TIMESTAMPADD")) {
difficultySql = sql;
difficultyArgs = args;
}
return List.of();
}
}
}
@@ -0,0 +1,92 @@
package org.dromara.aihr.service;
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.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrFivePositionCatalogMigrationTest {
private static final Pattern CATALOG_ROW = Pattern.compile(
"(?m)^\\('(?:生活顾问|保安|保洁|保修|客服)', '([A-Za-z0-9-]+)', '([^']+)', '[^']+', '(SOP-[A-Z]{2}-\\d{3})',");
@Test
void migrationSeedsTwentyScenariosForEachPositionWithUniqueSopCodes() throws Exception {
String sql = migrationSql();
Matcher matcher = CATALOG_ROW.matcher(sql);
Set<String> scenarioCodes = new HashSet<>();
Set<String> sopCodes = new HashSet<>();
int rows = 0;
while (matcher.find()) {
rows++;
scenarioCodes.add(matcher.group(1));
sopCodes.add(matcher.group(3));
}
assertEquals(100, rows);
assertEquals(100, scenarioCodes.size());
assertEquals(100, sopCodes.size());
for (String position : Set.of("生活顾问", "保安", "保洁", "保修", "客服")) {
assertEquals(20, countRows(sql, position));
}
}
@Test
void migrationBuildsDailyAndSpecialVersionedQuestionsForEveryScenario() throws Exception {
String sql = migrationSql();
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS `aihr_learning_question`"));
assertTrue(sql.contains("`question_type` varchar(20) NOT NULL DEFAULT 'daily'"));
assertTrue(sql.contains("SELECT 'daily' AS `question_type`"));
assertTrue(sql.contains("UNION ALL SELECT 'special'"));
assertTrue(sql.contains("CONCAT(c.`scenario_code`, '-', q.`question_type`, '-v1')"));
assertTrue(sql.contains("SHA2(CONCAT_WS('|', c.`scenario_code`, q.`question_type`"));
assertTrue(sql.contains("`content_version` = VALUES(`content_version`)"));
assertTrue(sql.contains("`content_hash` = VALUES(`content_hash`)"));
}
@Test
void migrationIsRepeatableAndKeepsUnreviewedHighRiskContentDisabled() throws Exception {
String sql = migrationSql();
assertTrue(sql.contains("ON DUPLICATE KEY UPDATE"));
assertTrue(sql.contains("information_schema.columns"));
assertTrue(sql.contains("COALESCE(MAX(`id`), 0) + 1"));
assertTrue(sql.contains("ALTER TABLE aihr_practice_scenario AUTO_INCREMENT ="));
assertTrue(sql.contains("'BA-FIRE-ALARM', '消防报警首响与升级'"));
assertTrue(sql.contains("'BA-ELEVATOR-TRAPPED', '电梯困人信息收集与升级'"));
assertTrue(sql.contains("'BX-LIVE-ELECTRICAL', '带电故障处置边界'"));
assertTrue(sql.contains("'BJ-HIGH-RISE-EXTERIOR', '高空外墙保洁边界'"));
assertTrue(sql.contains("c.`enabled`,\n CASE q.`question_type`"));
}
private static long countRows(String sql, String position) {
return Pattern.compile("(?m)^\\('" + Pattern.quote(position) + "', '").matcher(sql).results().count();
}
private static String migrationSql() throws Exception {
return Files.readString(repoRoot().resolve(
"backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql"));
}
private static Path repoRoot() {
Path current = Path.of("").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("backend/script/sql"))) {
current = current.getParent();
}
if (current == null) {
throw new IllegalStateException("repository root not found");
}
return current;
}
}
@@ -79,18 +79,61 @@ public class AihrOrgSyncServiceTest {
""");
ReflectionTestUtils.setField(service, "storeDisplayFields", false);
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of());
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of());
assertEquals("", ReflectionTestUtils.getField(masked, "personName"));
assertEquals("", ReflectionTestUtils.getField(masked, "deptName"));
assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(masked, "hireDate"));
ReflectionTestUtils.setField(service, "storeDisplayFields", true);
Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of());
Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of());
assertEquals("测试员工", ReflectionTestUtils.getField(demo, "personName"));
assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName"));
assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(demo, "hireDate"));
}
@Test
public void projectAssignmentDeterminesProjectScopeAndPermissionLevel() throws Exception {
ObjectMapper mapper = new ObjectMapper();
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class));
ReflectionTestUtils.setField(service, "storeDisplayFields", true);
var employee = mapper.readTree("""
{"id":"EMP-1","employee_number":"YC001","name":"测试主管","department_id":"D-1","position_name":"员工","position_level":3,"phone":"13900001111"}
""");
List<?> departments = List.of(mapper.readTree("""
{"id":"D-1","name":"生活服务部","code":"D001","company_id":"C-1"}
"""));
List<?> projects = List.of(mapper.readTree("""
{"id":"P-1","project_name":"翡翠湾","code":"FW001","company_id":"C-1"}
"""));
List<?> assignments = List.of(mapper.readTree("""
{"employee_id":"EMP-1","employee_number":"YC001","project_id":"P-1","project_code":"FW001","project_name":"翡翠湾","employee_department_id":"D-1","employee_department_name":"生活服务部","project_position_name":"管家主管","project_position_level":"foreman","is_primary":true,"status":"active"}
"""));
Object row = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(),
ReflectionTestUtils.invokeMethod(service, "departmentMap", departments),
ReflectionTestUtils.invokeMethod(service, "projectMap", projects),
ReflectionTestUtils.invokeMethod(service, "assignmentMap", assignments));
assertEquals("FW001", ReflectionTestUtils.getField(row, "projectCode"));
assertEquals("翡翠湾", ReflectionTestUtils.getField(row, "projectName"));
assertEquals("生活服务部", ReflectionTestUtils.getField(row, "deptName"));
assertEquals("管家主管", ReflectionTestUtils.getField(row, "positionName"));
assertEquals("主管", ReflectionTestUtils.getField(row, "positionLevel"));
}
@Test
public void numericRawPositionLevelFallsBackToPositionName() throws Exception {
ObjectMapper mapper = new ObjectMapper();
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class));
var employee = mapper.readTree("""
{"id":"EMP-1","project_name":"翡翠湾","position_name":"项目经理","position_level":2,"phone":"13900001111"}
""");
Object row = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of());
assertEquals("项目经理", ReflectionTestUtils.getField(row, "positionLevel"));
}
@Test
public void suspectTextIsCheckedBeforeDisplayFieldsAreMasked() throws Exception {
ObjectMapper mapper = new ObjectMapper();
@@ -100,7 +143,7 @@ public class AihrOrgSyncServiceTest {
""");
ReflectionTestUtils.setField(service, "storeDisplayFields", false);
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of());
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of());
assertEquals("", ReflectionTestUtils.getField(masked, "personName"));
assertEquals("", ReflectionTestUtils.getField(masked, "deptName"));
@@ -5,6 +5,9 @@ import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.DimensionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
@@ -33,8 +36,10 @@ import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -113,6 +118,101 @@ public class AihrPracticeSeedServiceTest {
assertEquals("缺少员工ID,无法派发专项训练", error.getMessage());
}
@Test
public void batchAssignmentRequiresStableRequestIdAndAtLeastOneTarget() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
ServiceException missingRequest = assertThrows(ServiceException.class, () -> service.createAssignments(
new PracticeAssignmentBatchRequest("", List.of(new PracticeAssignmentTarget("EMP-1", "员工甲")),
"fee-parking", null, "2026-07-18"), "SUP-1"));
assertEquals("缺少批次请求ID", missingRequest.getMessage());
ServiceException missingTarget = assertThrows(ServiceException.class, () -> service.createAssignments(
new PracticeAssignmentBatchRequest("assign-20260717-001", List.of(),
"fee-parking", null, "2026-07-18"), "SUP-1"));
assertEquals("请至少选择一名员工", missingTarget.getMessage());
}
@Test
public void batchAssignmentDueDateDefaultsToTomorrowAndRejectsPastDate() {
LocalDate today = LocalDate.of(2026, 7, 17);
assertEquals(LocalDate.of(2026, 7, 18), AihrPracticeSeedService.normalizeAssignmentDueDate(null, today));
assertEquals(today, AihrPracticeSeedService.normalizeAssignmentDueDate("2026-07-17", today));
ServiceException past = assertThrows(ServiceException.class,
() -> AihrPracticeSeedService.normalizeAssignmentDueDate("2026-07-16", today));
assertEquals("截止日期不能早于今天", past.getMessage());
ServiceException malformed = assertThrows(ServiceException.class,
() -> AihrPracticeSeedService.normalizeAssignmentDueDate("2026/07/18", today));
assertEquals("截止日期格式应为YYYY-MM-DD", malformed.getMessage());
}
@Test
public void batchAssignmentUsesPerEmployeeTransactionsAndDatabaseIdempotency() throws Exception {
String serviceSource = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
String controllerSource = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrMobileController.java"));
assertTrue(serviceSource.contains("TransactionDefinition.PROPAGATION_REQUIRES_NEW"));
assertTrue(serviceSource.contains("() -> createAssignmentInternal(itemRequest, batchScenario, storedExtPartyId)"));
assertTrue(serviceSource.contains("TeamScope scope = teamScope(supervisorExtPartyId)"));
assertTrue(serviceSource.contains("AssignmentStorageIdentityResolution storageResolution = assignmentStorageIdentities(requestedExtPartyIds)"));
assertTrue(serviceSource.contains("phone_owner_count"));
assertTrue(serviceSource.contains("ambiguousExtPartyIds().contains"));
assertTrue(serviceSource.contains("uk_aihr_assignment_request"));
assertTrue(serviceSource.contains("new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, \"FAILED\""));
assertTrue(controllerSource.contains("@PostMapping(\"/practice/assignments/batch\")"));
}
@Test
public void batchAssignmentCreatesIndependentRowsAndRetryIsIdempotent() {
BatchAssignmentJdbcTemplate jdbcTemplate = new BatchAssignmentJdbcTemplate();
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
PracticeAssignmentBatchRequest request = new PracticeAssignmentBatchRequest(
"assign-20260717-002",
List.of(
new PracticeAssignmentTarget("EMP-1", "员工甲"),
new PracticeAssignmentTarget("EMP-2", "员工乙")
),
"complaint-water",
"投诉沟通巩固",
"2099-07-20"
);
var first = service.createAssignments(request, "");
var retry = service.createAssignments(request, "");
assertEquals(2, first.created());
assertEquals(0, first.failed());
assertEquals(List.of("CREATED", "CREATED"), first.results().stream().map(item -> item.status()).toList());
assertEquals(0, retry.created());
assertEquals(2, retry.duplicate());
assertEquals(List.of("DUPLICATE", "DUPLICATE"), retry.results().stream().map(item -> item.status()).toList());
assertEquals(2, jdbcTemplate.insertCount);
}
@Test
public void batchAssignmentKeepsSuccessfulEmployeesWhenAnotherInsertFails() {
BatchAssignmentJdbcTemplate jdbcTemplate = new BatchAssignmentJdbcTemplate("EMP-FAIL");
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
var result = service.createAssignments(new PracticeAssignmentBatchRequest(
"assign-20260717-003",
List.of(
new PracticeAssignmentTarget("EMP-1", "员工甲"),
new PracticeAssignmentTarget("EMP-FAIL", "员工失败")
),
"complaint-water",
null,
"2099-07-20"
), "");
assertEquals(1, result.created());
assertEquals(1, result.failed());
assertEquals("CREATED", result.results().get(0).status());
assertEquals("FAILED", result.results().get(1).status());
assertEquals(1, jdbcTemplate.insertCount);
}
@Test
public void historyAndReviewDetailUseDisplayFieldPrivacyContract() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java");
@@ -227,6 +327,99 @@ public class AihrPracticeSeedServiceTest {
assertFalse(AihrPracticeSeedService.isLegacyDailyDrillFallbackEnabled(false, "dev"));
}
@Test
public void dailyDrillsRotateByDatePositionAndEmployeeWithoutConsecutiveRepeats() {
LocalDate date = LocalDate.of(2026, 7, 17);
List<AihrPracticeSeedService.DailyDrillSeed> today =
AihrPracticeSeedService.dailyDrills(date, "物业管家", "EMP-1003");
List<AihrPracticeSeedService.DailyDrillSeed> sameScope =
AihrPracticeSeedService.dailyDrills(date, "物业管家", "EMP-1003");
List<AihrPracticeSeedService.DailyDrillSeed> tomorrow =
AihrPracticeSeedService.dailyDrills(date.plusDays(1), "物业管家", "EMP-1003");
List<AihrPracticeSeedService.DailyDrillSeed> anotherEmployee =
AihrPracticeSeedService.dailyDrills(date, "物业管家", "EMP-1004");
List<AihrPracticeSeedService.DailyDrillSeed> anotherPosition =
AihrPracticeSeedService.dailyDrills(date, "客服专员", "EMP-1003");
assertEquals(today, sameScope);
assertEquals(3, today.size());
assertEquals(List.of("每日三题 1:", "每日三题 2:", "每日三题 3:"),
today.stream().map(drill -> drill.question().substring(0, 7)).toList());
assertTrue(java.util.Collections.disjoint(
today.stream().map(AihrPracticeSeedService.DailyDrillSeed::question).toList(),
tomorrow.stream().map(AihrPracticeSeedService.DailyDrillSeed::question).toList()
));
assertFalse(today.equals(anotherEmployee));
assertFalse(today.equals(anotherPosition));
}
@Test
public void dailyDrillQuestionCarriesItsReferenceSnapshot() throws Exception {
String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
assertTrue(source.contains("dailyDrillReason(drill)"));
assertTrue(source.contains("return drill.question() + \" 参考答案:\" + drill.referenceAnswer();"));
assertTrue(source.contains("dailyDrills(today, dailyDrillPosition(extPartyId), extPartyId)"));
assertTrue(source.contains("dailyRequestKey(today, i)"));
assertTrue(source.contains("catch (DuplicateKeyException ignored)"));
}
@Test
public void dailyRequestKeysAreStablePerDateAndDistinctPerSlot() {
LocalDate date = LocalDate.of(2026, 7, 17);
assertEquals("daily:2026-07-17:1", AihrPracticeSeedService.dailyRequestKey(date, 0));
assertEquals("daily:2026-07-17:2", AihrPracticeSeedService.dailyRequestKey(date, 1));
assertEquals("daily:2026-07-18:1", AihrPracticeSeedService.dailyRequestKey(date.plusDays(1), 0));
}
@Test
public void assignmentVisibilityDeduplicatesLegacyDailyRowsAndCapsThreePerEmployee() {
List<PracticeAssignmentResponse> rows = new ArrayList<>();
rows.add(assignment(1L, "EMP-1", "daily", "问题一"));
rows.add(assignment(2L, "EMP-1", "daily", "问题一"));
rows.add(assignment(3L, "EMP-1", "daily", "问题二"));
rows.add(assignment(4L, "EMP-1", "daily", "问题二"));
rows.add(assignment(5L, "EMP-1", "daily", "问题三"));
rows.add(assignment(6L, "EMP-1", "daily", "问题三"));
rows.add(assignment(7L, "EMP-1", "daily", "问题四"));
List<PracticeAssignmentResponse> visible = AihrPracticeSeedService.visibleAssignments(rows, 10);
assertEquals(List.of("问题一", "问题二", "问题三"),
visible.stream().map(PracticeAssignmentResponse::question).toList());
}
@Test
public void legacyDuplicateDailyRowsStillLeaveMissingSlotsToGenerate() {
List<AihrPracticeSeedService.DailyDrillSeed> drills = List.of(
new AihrPracticeSeedService.DailyDrillSeed("scene-1", "问题一", "答案一"),
new AihrPracticeSeedService.DailyDrillSeed("scene-2", "问题二", "答案二"),
new AihrPracticeSeedService.DailyDrillSeed("scene-3", "问题三", "答案三")
);
assertEquals(List.of(1, 2), AihrPracticeSeedService.missingDailyDrillSlots(drills, Set.of("问题一")));
assertEquals(List.of(), AihrPracticeSeedService.missingDailyDrillSlots(drills, Set.of("问题一", "问题二", "问题三")));
}
@Test
public void batchRequestIdRejectsReservedDailyNamespace() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
ServiceException error = assertThrows(ServiceException.class, () -> service.createAssignments(
new PracticeAssignmentBatchRequest("daily:2026-07-17:1", List.of(
new PracticeAssignmentTarget("EMP-1", "员工甲")
), "complaint-water", null, "2099-07-20"), ""));
assertEquals("批次请求ID不能使用系统保留前缀", error.getMessage());
}
private static PracticeAssignmentResponse assignment(Long id, String extPartyId, String source, String question) {
return new PracticeAssignmentResponse(id, extPartyId, "员工", "scene", "场景", source, question,
"待训练", "", question, null, null, null, null, null, null, null, null, null);
}
@Test
public void dailyDrillAnswerDoesNotOverwriteCompletedAttempt() throws Exception {
String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
@@ -311,7 +504,10 @@ public class AihrPracticeSeedServiceTest {
String controller = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrPracticeController.java"));
String mobileController = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrMobileController.java"));
assertTrue(service.contains("FROM sys_oss\n WHERE tenant_id = ? AND oss_id = ? AND create_by = ?"));
assertTrue(service.contains("FROM aihr_practice_audio_upload"));
assertTrue(service.contains("owner_user_id = ? AND owner_identity = ?"));
assertTrue(service.contains("status IN ('staged', 'bound')"));
assertFalse(service.contains("FROM sys_oss\n WHERE tenant_id = ? AND oss_id = ? AND create_by = ?"));
assertTrue(service.contains("validateAudioOwnership(request == null ? null : request.traineeAudioOssId(), ownerIdentity, ownerUserId)"));
assertTrue(service.contains("validateAudioOwnership(audioOssId, extPartyId, ownerUserId)"));
assertTrue(service.contains("d.answer_audio_oss_id = ?"));
@@ -1044,6 +1240,11 @@ public class AihrPracticeSeedServiceTest {
// Table creation is irrelevant for query assembly tests.
}
@Override
public int update(String sql, Object... args) {
return 1;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
try {
@@ -1070,6 +1271,76 @@ public class AihrPracticeSeedServiceTest {
}
}
private static final class BatchAssignmentJdbcTemplate extends JdbcTemplate {
private final Map<String, Map<String, String>> rows = new LinkedHashMap<>();
private final String failingExtPartyId;
private int insertCount;
private BatchAssignmentJdbcTemplate() {
this("");
}
private BatchAssignmentJdbcTemplate(String failingExtPartyId) {
this.failingExtPartyId = failingExtPartyId;
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast(sql.contains("aihr_org_snapshot") ? 0 : 1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
return requiredType.cast(1);
}
@Override
public void execute(String sql) {
// Schema DDL is outside this behavior test.
}
@Override
public int update(String sql, Object... args) {
if (!sql.contains("INSERT INTO aihr_practice_assignment")) {
return 0;
}
String extPartyId = String.valueOf(args[1]);
if (extPartyId.equals(failingExtPartyId)) {
throw new org.springframework.dao.DataAccessResourceFailureException("simulated target insert failure");
}
String requestId = String.valueOf(args[8]);
insertCount++;
rows.put(requestId + "|" + extPartyId, Map.ofEntries(
Map.entry("id", String.valueOf(insertCount)),
Map.entry("ext_party_id", extPartyId),
Map.entry("trainee_name", ""),
Map.entry("scenario_id", String.valueOf(args[3])),
Map.entry("scenario_name", String.valueOf(args[4])),
Map.entry("source", "manual"),
Map.entry("reason", String.valueOf(args[6])),
Map.entry("status", "待训练")
));
return 1;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.contains("request_key = ?")) {
Map<String, String> row = rows.get(String.valueOf(args[1]) + "|" + String.valueOf(args[2]));
if (row == null) {
return List.of();
}
try {
return mapRows(rowMapper, List.of(row));
} catch (SQLException ex) {
throw new IllegalStateException(ex);
}
}
// No database scenario override: keep the built-in phase-one seed.
return List.of();
}
}
private static final class PracticeReviewJdbcTemplate extends JdbcTemplate {
private final String ownerLevel;
private final List<String> scopeIds;
@@ -2,9 +2,11 @@ package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrSopDto;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@@ -18,6 +20,7 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
@@ -175,6 +178,32 @@ public class AihrSopSeedServiceTest {
assertTrue(code.contains("private void deleteUploadedOssQuietly(SysOssVo oss)"));
}
@Test
@Tag("dev")
public void unbindingOneSpaceKeepsSharedOssUntilEveryMembershipIsGone() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
}
String code = Files.readString(source);
String method = code.substring(code.indexOf("public UnbindDocumentResponse unbindDocumentMembership"),
code.indexOf("public VectorizeResponse vectorizeMissing"));
assertTrue(method.contains("where a.tenant_id = ? and k.code = ? and a.id = ?"));
assertTrue(method.contains("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?"));
assertTrue(method.contains("delete from aihr_knowledge_attach where tenant_id = ? and knowledge_id = ? and id = ?"));
assertTrue(method.contains("afterCommitOrNow(() -> deleteOrphanedOss"));
assertTrue(code.contains("select oss_id from sys_oss where oss_id = ? for update"));
assertTrue(code.contains("select count(*) from aihr_knowledge_attach where oss_id = ?"));
assertTrue(code.indexOf("references != 0") < code.indexOf("ossService.deleteWithValidByIds"));
assertTrue(code.contains("PROPAGATION_REQUIRES_NEW"));
assertTrue(code.contains("lockOssForReference(ossId)"));
assertTrue(code.contains("@Transactional(rollbackFor = Exception.class)\n public UploadResponse uploadDoc(MultipartFile file, String category)"));
assertTrue(code.contains("@Transactional(rollbackFor = Exception.class)\n public UploadResponse processStagedDocument(String fileName, String category, Path stagedFile)"));
assertTrue(code.contains("!TransactionSynchronizationManager.isActualTransactionActive() && requiresNewTransaction != null"));
assertTrue(code.contains("TenantHelper.ignore"));
}
@Test
@Tag("dev")
public void disabledVisionGateStopsBeforeDatabaseLookup() throws Exception {
@@ -349,13 +378,27 @@ public class AihrSopSeedServiceTest {
}
String code = Files.readString(source);
assertTrue(code.contains("join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id"));
assertTrue(code.contains("left join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id"));
assertTrue(code.contains("join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id"));
assertTrue(code.contains("left join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id"));
assertTrue(code.contains("left join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id"));
assertTrue(code.contains("where tenant_id = ? and oss_id = ? limit 1"));
assertTrue(code.contains("where tenant_id = ? and oss_id = ?\", objectMapper.writeValueAsString(node), tenantId(), ossId"));
}
@Test
@Tag("dev")
@SuppressWarnings({"rawtypes", "unchecked"})
public void processingOverviewPropagatesDatabaseFailures() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenThrow(new DataAccessResourceFailureException("collation mismatch"));
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.visionAllowed()).thenReturn(false);
AihrSopSeedService service = new AihrSopSeedService(new ObjectMapper(), jdbcTemplate, null, "", null, null, modelService);
assertThrows(DataAccessResourceFailureException.class, service::processingOverview);
}
@Test
@Tag("dev")
public void qdrantRebuildKeepsOtherTenantsVectors() throws Exception {
@@ -372,6 +415,155 @@ public class AihrSopSeedServiceTest {
assertFalse(code.contains("qdrantRequest(\"DELETE\", \"/collections/\" + qdrantCollection()"));
}
@Test
@Tag("dev")
public void authorizedSearchScopesMysqlAndQdrantToKnowledgeIds() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
}
String code = Files.readString(source);
assertTrue(code.contains("searchAuthorized(SearchRequest request, String requesterExtPartyId, Set<Long> allowedKnowledgeIds)"));
assertTrue(code.contains(".knowledge_id in ("));
assertTrue(code.contains("qdrantMatchAny(\"knowledge_id\", knowledgeIds)"));
AihrSopSeedService service = new AihrSopSeedService(
new ObjectMapper(), mock(JdbcTemplate.class), null, "", null, null, mock(AihrModelSeedService.class));
assertThrows(org.dromara.common.core.exception.ServiceException.class,
() -> service.searchAuthorized(new AihrSopDto.SearchRequest("问题", "sop", null, null, 5), "", Set.of()));
}
@Test
@Tag("dev")
public void legacySearchControllerDelegatesToAuthorizedQueryService() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/controller/AihrSopController.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java");
}
String code = Files.readString(source);
assertTrue(code.contains("knowledgeQueryService.queryInternal(new QueryRequest"));
assertFalse(code.contains("return R.ok(sopSeedService.search(restrictDemoVerificationSource"));
}
@Test
@Tag("dev")
public void duplicateDocumentsNeverMoveOrDeleteOtherSpaceMemberships() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
}
String code = Files.readString(source);
String method = code.substring(code.indexOf("private String reuseDuplicateAttach"),
code.indexOf("private void updateOssInsight"));
assertFalse(method.contains("set knowledge_id = ?"));
assertFalse(method.contains("delete from aihr_knowledge_fragment"));
assertTrue(code.contains("saveDocumentToSpaces"));
assertTrue(code.contains("generateEmbeddings(fragments)"));
}
@Test
@Tag("dev")
public void multiSpaceUploadsRollbackDatabaseMembershipsAsOneUnit() throws Exception {
assertTrue(AihrSopSeedService.class
.getMethod("uploadDoc", org.springframework.web.multipart.MultipartFile.class, List.class)
.getAnnotation(org.springframework.transaction.annotation.Transactional.class) != null);
assertTrue(AihrSopSeedService.class
.getMethod("processStagedDocument", String.class, List.class, Path.class)
.getAnnotation(org.springframework.transaction.annotation.Transactional.class) != null);
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
String code = Files.readString(source);
assertTrue(code.contains("TransactionSynchronizationManager.registerSynchronization"));
assertTrue(code.contains("public void afterCommit()"));
assertTrue(code.contains("afterCommitOrNow(() -> deleteQdrantDocNow"));
assertTrue(code.contains("afterCommitOrNow(() -> {\n try {\n upsertQdrant"));
}
@Test
@Tag("dev")
public void videoProcessTimeoutsWaitBeforeReadingProcessOutput() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrVideoService.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java");
}
String code = Files.readString(source);
int probe = code.indexOf("private long probeDurationSeconds");
int run = code.indexOf("private int runFfmpeg", probe);
String probeMethod = code.substring(probe, run);
assertTrue(probeMethod.indexOf("waitFor(30") < probeMethod.indexOf("readAllBytes()"));
assertTrue(code.contains("redirectOutput(ProcessBuilder.Redirect.DISCARD)"));
assertTrue(code.contains("terminateProcess(process)"));
assertTrue(code.contains("process.destroyForcibly();\n process.waitFor(5"));
}
@Test
@Tag("dev")
public void summaryCardFallsBackToRetrievedEvidenceWithoutInventingScripts() {
List<AihrSopSeedService.KnowledgeHit> hits = List.of(
new AihrSopSeedService.KnowledgeHit(
1L,
"投诉处理 SOP.pdf",
"sop",
"",
"doc-1",
"先记录业主诉求、房号和联系方式。30分钟内反馈首个处理节点,并保留工单记录。",
1,
1.0
),
new AihrSopSeedService.KnowledgeHit(
2L,
"报修跟进 SOP.pdf",
"sop",
"",
"doc-2",
"派单后确认责任人和完成时限;复查通过后再关闭工单。",
1,
0.9
)
);
AihrSopDto.SummaryCardResponse response = AihrSopSeedService.evidenceSummaryCard(
"业主投诉漏水,第一步怎么处理?",
hits
);
assertFalse(response.title().isBlank());
assertTrue(response.steps().size() >= 2);
assertTrue(response.steps().stream().anyMatch(step -> step.desc().contains("记录业主诉求")));
assertFalse(response.steps().stream().anyMatch(step -> step.desc().contains("确认责任人")));
assertTrue(response.scripts().isEmpty());
assertTrue(response.objections().isEmpty());
assertEquals(List.of("来源:投诉处理 SOP.pdf"), response.reminders());
}
@Test
@Tag("dev")
public void summaryCardRejectsUnrelatedEvidenceInsteadOfPackagingFirstHit() {
List<AihrSopSeedService.KnowledgeHit> hits = List.of(
new AihrSopSeedService.KnowledgeHit(1L, "信息安全制度.pdf", "sop", "", "doc-1",
"员工电脑必须启用屏幕锁定并定期更新密码。", 1, 1.0)
);
ServiceException error = assertThrows(ServiceException.class,
() -> AihrSopSeedService.evidenceSummaryCard("停车费催缴怎么沟通?", hits));
assertTrue(error.getMessage().contains("相关度不足"));
}
@Test
@Tag("dev")
public void summaryCardControllerCarriesTheSameKnowledgeSpaceReadScope() throws Exception {
Path controller = Path.of("src/main/java/org/dromara/aihr/controller/AihrSopController.java");
if (!Files.exists(controller)) {
controller = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java");
}
String code = Files.readString(controller);
assertTrue(code.contains("resolveInternalSpaceIds"));
assertTrue(code.contains("principal, app, List.of(), \"READ\""));
assertTrue(code.contains("summaryCardAuthorized(request, allowedKnowledgeIds)"));
}
private static AihrSopSeedService.KnowledgeHit hit(Long fragmentId, String title) {
return new AihrSopSeedService.KnowledgeHit(fragmentId, title, "sop", "", "doc-" + fragmentId, "片段内容", 1, 1.0);
}
@@ -2,16 +2,67 @@ package org.dromara.aihr.service;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.jdbc.core.JdbcTemplate;
import org.mockito.ArgumentCaptor;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrUploadQueueServiceTest {
@TempDir
Path tempDir;
@Test
void expandsLegacyGbkZipAtomicallyAndKeepsDuplicateEntries() throws Exception {
Path staging = Files.createDirectories(tempDir.resolve("staging"));
Path archive = tempDir.resolve("legacy.zip");
writeZip(archive, Charset.forName("GBK"), "目录一/附件.docx", "目录二/附件.docx");
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.update(anyString(), any(Object[].class))).thenReturn(1);
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
try {
AihrUploadQueueService service = new AihrUploadQueueService(null, jdbcTemplate, executor, staging.toString());
int extracted = assertDoesNotThrow(() -> expandArchive(service, itemRow(archive)));
assertEquals(2, extracted);
ArgumentCaptor<Object[]> parameters = ArgumentCaptor.forClass(Object[].class);
verify(jdbcTemplate, times(1)).update(anyString(), parameters.capture());
Object[] values = parameters.getValue();
assertEquals(12, values.length);
assertEquals("附件.docx", values[2]);
assertEquals("[]", values[4]);
assertEquals("附件(2).docx", values[8]);
assertTrue(Files.isRegularFile(Path.of(values[5].toString())));
assertTrue(Files.isRegularFile(Path.of(values[11].toString())));
} finally {
executor.shutdownNow();
}
}
@Test
void queueKeepsTenantWhenWorkerLeavesLoginThread() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java");
@@ -31,4 +82,34 @@ class AihrUploadQueueServiceTest {
assertFalse(code.contains("\"暂存文件写入失败:\" + e.getMessage()"));
assertFalse(code.contains("where tenant_id = ? and status = 1\n and ("));
}
private static void writeZip(Path archive, Charset charset, String... entries) throws IOException {
try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(archive), charset)) {
for (String entry : entries) {
output.putNextEntry(new ZipEntry(entry));
output.write("fixture".getBytes());
output.closeEntry();
}
}
}
private static Object itemRow(Path archive) throws ReflectiveOperationException {
Class<?> rowType = Class.forName("org.dromara.aihr.service.AihrUploadQueueService$ItemRow");
Constructor<?> constructor = rowType.getDeclaredConstructors()[0];
constructor.setAccessible(true);
return constructor.newInstance("000000", 1L, "batch", "legacy.zip", "__auto__", java.util.List.of(), archive.toString(), 1);
}
private static int expandArchive(AihrUploadQueueService service, Object itemRow) throws Exception {
Method method = service.getClass().getDeclaredMethod("expandArchive", itemRow.getClass());
method.setAccessible(true);
try {
return (int) method.invoke(service, itemRow);
} catch (InvocationTargetException e) {
if (e.getCause() instanceof Exception cause) {
throw cause;
}
throw e;
}
}
}
@@ -0,0 +1,185 @@
package org.dromara.aihr.webai;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest;
import org.dromara.aihr.webai.AihrWebSearchClient.SearchResult;
import org.dromara.aihr.webai.AihrWebSearchClient.WebSource;
import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider;
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.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AihrWebAiSecurityTest {
@Test
@Tag("dev")
void webSearchIsDisabledByDefault() {
AihrWebAiProperties properties = new AihrWebAiProperties();
assertFalse(properties.isEnabled());
assertEquals("tavily", properties.getProvider());
}
@Test
@Tag("dev")
void searchEndpointRejectsLocalAndPrivateNetworks() {
assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://localhost/search"));
assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://127.0.0.1/search"));
assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://10.0.0.8/search"));
assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://169.254.169.254/latest/meta-data"));
assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://192.168.1.8/search"));
assertFalse(AihrWebSearchClient.isPublicHttpUrl("http://[::1]/search"));
assertFalse(AihrWebSearchClient.isPublicHttpUrl("file:///etc/passwd"));
assertFalse(AihrWebSearchClient.isPublicHttpUrl("https://user:pass@8.8.8.8/search"));
assertTrue(AihrWebSearchClient.isPublicHttpUrl("https://8.8.8.8/search"));
assertFalse(AihrWebSearchClient.isPublicHttpsUrl("http://8.8.8.8/search"));
assertTrue(AihrWebSearchClient.isPublicHttpsUrl("https://8.8.8.8/search"));
}
@Test
@Tag("dev")
void providerAnswerIsDiscardedWhenThereAreNoPublicSources() throws Exception {
String body = """
{
"answer": "不能在没有可核验来源时返回这段答案",
"results": [
{"title":"内网","url":"http://127.0.0.1/admin","content":"secret","score":0.99}
]
}
""";
AihrWebSearchClient.SearchResult result = AihrWebSearchClient.parseTavilyResponse(
new ObjectMapper(), body, 5
);
assertTrue(result.sources().isEmpty());
assertEquals("", result.answer());
}
@Test
@Tag("dev")
void providerAnswerKeepsOnlyPublicCitedSources() throws Exception {
String body = """
{
"answer": "这是基于公开来源生成的答案",
"results": [
{"title":"公开资料","url":"https://8.8.8.8/article","content":"公开摘要","score":0.91},
{"title":"内网","url":"http://10.0.0.1/secret","content":"secret","score":0.99}
]
}
""";
AihrWebSearchClient.SearchResult result = AihrWebSearchClient.parseTavilyResponse(
new ObjectMapper(), body, 5
);
assertEquals("这是基于公开来源生成的答案", result.answer());
assertEquals(1, result.sources().size());
assertEquals("https://8.8.8.8/article", result.sources().get(0).url());
}
@Test
@Tag("dev")
void providerRequestsRequireHttpsEvenForPublicHosts() {
AihrWebSearchClient client = new AihrWebSearchClient(
new AihrWebAiProperties(), mock(AihrWebSearchProviderService.class), new ObjectMapper()
);
RuntimeProvider provider = new RuntimeProvider(1L, "test", "tavily",
"http://8.8.8.8/search", "secret", true, true, LocalDateTime.now());
assertThrows(ServiceException.class, () -> client.search(provider, "test question"));
}
@Test
@Tag("dev")
void sourcedButEmptyProviderAnswerIsNotReportedAsOk() {
AihrWebAiProperties properties = new AihrWebAiProperties();
AihrWebSearchClient searchClient = mock(AihrWebSearchClient.class);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
AihrKnowledgePrincipal principal = new AihrKnowledgePrincipal(
"000000", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile"
);
RuntimeProvider provider = new RuntimeProvider(1L, "Tavily", "tavily",
"https://8.8.8.8/search", "secret", true, true, LocalDateTime.now());
when(resolver.current()).thenReturn(principal);
when(searchClient.runtime(principal)).thenReturn(provider);
when(searchClient.search(provider, "物业行业新规"))
.thenReturn(new SearchResult("", List.of(
new WebSource("公开来源", "https://8.8.8.8/article", "公开摘要", 0.9)
)));
AihrWebAiService service = new AihrWebAiService(properties, searchClient, resolver, jdbcTemplate);
var response = service.query(new QueryRequest("物业行业新规", true));
assertEquals("NO_ANSWER", response.status());
assertEquals("", response.answer());
assertEquals(1, response.sources().size());
}
@Test
@Tag("dev")
void providerAuditNameIsBoundedToDatabaseColumn() {
assertEquals(40, AihrWebAiService.auditProvider("x".repeat(100)).length());
assertEquals("unknown", AihrWebAiService.auditProvider(" "));
}
@Test
@Tag("dev")
void providerTestStateMigrationIsIdempotentAndDefaultsToUntested() throws Exception {
Path migration = Path.of("../../script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql");
if (!Files.exists(migration)) {
migration = Path.of("script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql");
}
String sql = Files.readString(migration).toLowerCase();
assertTrue(sql.contains("last_test_ok"));
assertTrue(sql.contains("last_test_time"));
assertTrue(sql.contains("information_schema.columns"));
assertTrue(sql.contains("default 0"));
}
@Test
@Tag("dev")
void outboundQuestionRedactsKnownPersonalAndAccessData() {
String raw = "张女士住3栋1201,手机13812345678,身份证320311199001011234,门禁码 667788";
String safe = AihrWebPrivacy.forExternalSearch(raw);
assertFalse(safe.contains("13812345678"));
assertFalse(safe.contains("320311199001011234"));
assertFalse(safe.contains("1201"));
assertFalse(safe.contains("667788"));
assertTrue(safe.contains("已脱敏"));
}
@Test
@Tag("dev")
void databaseProviderSecretUsesAuthenticatedEncryption() {
AihrWebAiProperties properties = new AihrWebAiProperties();
properties.setSecretKey("unit-test-secret-key-32-bytes-long");
AihrWebSecretCodec codec = new AihrWebSecretCodec(properties);
String encrypted = codec.encrypt("tvly-test-secret");
assertTrue(encrypted.startsWith("enc:v1:"));
assertFalse(encrypted.contains("tvly-test-secret"));
assertEquals("tvly-test-secret", codec.decrypt(encrypted));
}
}