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));
}
}
+15 -1
View File
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_info` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`name` varchar(100) NOT NULL COMMENT '知识库名称',
`code` varchar(100) NOT NULL COMMENT '租户内稳定空间编码',
`code` varchar(100) DEFAULT NULL COMMENT '租户内稳定空间编码;平台迁移会回填 legacy 编码并改为必填',
`space_type` varchar(30) NOT NULL DEFAULT 'BUSINESS' COMMENT 'PUBLIC/BUSINESS/MANAGEMENT/EXTERNAL',
`sensitivity_level` varchar(30) NOT NULL DEFAULT 'INTERNAL' COMMENT 'PUBLIC/INTERNAL/CONFIDENTIAL',
`status` varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'DRAFT/ACTIVE/DISABLED',
@@ -230,3 +230,17 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_query_log` (
KEY `idx_aihr_query_tenant_app` (`tenant_id`, `app_id`, `create_time`),
KEY `idx_aihr_query_status` (`tenant_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识问答最小化审计日志';
CREATE TABLE IF NOT EXISTS `aihr_knowledge_admin_audit` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`operator_id` bigint DEFAULT NULL COMMENT '操作人',
`action` varchar(50) NOT NULL COMMENT '操作动作',
`object_type` varchar(30) NOT NULL COMMENT 'SPACE/GRANT/APP/APP_SPACE/TOKEN',
`object_id` bigint DEFAULT NULL COMMENT '对象ID',
`before_summary` varchar(1000) DEFAULT NULL COMMENT '变更前摘要',
`after_summary` varchar(1000) DEFAULT NULL COMMENT '变更后摘要',
`create_time` datetime NOT NULL COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_admin_audit_tenant` (`tenant_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识平台管理变更审计';
@@ -119,6 +119,8 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` (
`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',
`answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答',
`answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址',
`answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号',
@@ -130,6 +132,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` (
`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 对练派发任务';
@@ -1,15 +1,50 @@
-- AIHR BRD 住宅 SOP 补充迁移(MySQL 8.x)
-- 补齐服务推介与日常服务两类可检索 SOP;不写入正式评审数据,可重复执行。
INSERT INTO `aihr_knowledge_info`
(`id`, `tenant_id`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark`)
CREATE TEMPORARY TABLE IF NOT EXISTS `_aihr_brd_sop_space_seed` (
`id` bigint NOT NULL,
`tenant_id` varchar(20) NOT NULL,
`code` varchar(100) NOT NULL,
`name` varchar(100) NOT NULL,
`description` varchar(500) DEFAULT NULL,
`separator` varchar(20) DEFAULT NULL,
`overlap_char` int DEFAULT NULL,
`retrieve_limit` int DEFAULT NULL,
`similarity_threshold` decimal(6,4) DEFAULT NULL,
`text_block_size` int DEFAULT NULL,
`vector_model` varchar(100) DEFAULT NULL,
`embedding_model` varchar(100) DEFAULT NULL,
`enable_hybrid` tinyint DEFAULT NULL,
`hybrid_alpha` decimal(6,4) DEFAULT NULL,
`system_prompt` text,
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`remark` varchar(500) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
DELETE FROM `_aihr_brd_sop_space_seed`;
INSERT INTO `_aihr_brd_sop_space_seed`
(`id`, `tenant_id`, `code`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark`)
VALUES
(1201, '000000', '增值服务需求识别 SOP', '基于业主真实需求介绍增值服务,明确适用范围、价格和取消规则。', '\n\n', 120, 5, 0.5, 800, NULL, NULL, 1, 1.0, '只能基于已确认的服务目录和价目口径回答,不得编造优惠或效果承诺。', NOW(), NOW(), 'seed:sop:brd-supplement'),
(1202, '000000', '日常服务回访 SOP', '服务完成后的满意度确认、遗留问题记录、责任人和回访节点。', '\n\n', 120, 5, 0.5, 800, NULL, NULL, 1, 1.0, '已完成不等于已闭环,回答必须包含满意度确认、责任人和下一次回访节点。', NOW(), NOW(), 'seed:sop:brd-supplement')
ON DUPLICATE KEY UPDATE
`description` = VALUES(`description`),
`system_prompt` = VALUES(`system_prompt`),
`update_time` = NOW();
(1201, '000000', 'sop_service_promotion', '增值服务需求识别 SOP', '基于业主真实需求介绍增值服务,明确适用范围、价格和取消规则。', '\n\n', 120, 5, 0.5, 800, NULL, NULL, 1, 1.0, '只能基于已确认的服务目录和价目口径回答,不得编造优惠或效果承诺。', NOW(), NOW(), 'seed:sop:brd-supplement'),
(1202, '000000', 'sop_daily_service', '日常服务回访 SOP', '服务完成后的满意度确认、遗留问题记录、责任人和回访节点。', '\n\n', 120, 5, 0.5, 800, NULL, NULL, 1, 1.0, '已完成不等于已闭环,回答必须包含满意度确认、责任人和下一次回访节点。', NOW(), NOW(), 'seed:sop:brd-supplement');
SET @aihr_space_code_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_knowledge_info' AND COLUMN_NAME = 'code'
);
SET @aihr_brd_sop_space_upsert := IF(
@aihr_space_code_exists = 1,
'INSERT INTO `aihr_knowledge_info` (`id`, `tenant_id`, `code`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark`) SELECT `id`, `tenant_id`, `code`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark` FROM `_aihr_brd_sop_space_seed` ON DUPLICATE KEY UPDATE `code` = VALUES(`code`), `description` = VALUES(`description`), `system_prompt` = VALUES(`system_prompt`), `update_time` = NOW()',
'INSERT INTO `aihr_knowledge_info` (`id`, `tenant_id`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark`) SELECT `id`, `tenant_id`, `name`, `description`, `separator`, `overlap_char`, `retrieve_limit`, `similarity_threshold`, `text_block_size`, `vector_model`, `embedding_model`, `enable_hybrid`, `hybrid_alpha`, `system_prompt`, `create_time`, `update_time`, `remark` FROM `_aihr_brd_sop_space_seed` ON DUPLICATE KEY UPDATE `description` = VALUES(`description`), `system_prompt` = VALUES(`system_prompt`), `update_time` = NOW()'
);
PREPARE aihr_brd_sop_space_stmt FROM @aihr_brd_sop_space_upsert;
EXECUTE aihr_brd_sop_space_stmt;
DEALLOCATE PREPARE aihr_brd_sop_space_stmt;
DROP TEMPORARY TABLE IF EXISTS `_aihr_brd_sop_space_seed`;
INSERT INTO `aihr_knowledge_attach`
(`id`, `tenant_id`, `knowledge_id`, `doc_id`, `name`, `type`, `status`, `create_time`, `update_time`, `remark`)
@@ -68,7 +68,10 @@ INSERT INTO aihr_brd_rubric_codes (tenant_id, rubric_code)
SELECT r.tenant_id, r.rubric_code
FROM aihr_practice_rubric r
JOIN aihr_practice_scenario s
ON s.tenant_id = r.tenant_id AND s.scenario_code = r.scenario_code
ON CONVERT(s.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
= CONVERT(r.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
AND CONVERT(s.scenario_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
= CONVERT(r.scenario_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
JOIN (
SELECT tenant_id, rubric_code
FROM aihr_practice_rubric_dimension
@@ -76,14 +79,20 @@ JOIN (
GROUP BY tenant_id, rubric_code
HAVING COUNT(*) = 4
) old_dimensions
ON old_dimensions.tenant_id = r.tenant_id AND old_dimensions.rubric_code = r.rubric_code
ON CONVERT(old_dimensions.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
= CONVERT(r.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
AND CONVERT(old_dimensions.rubric_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
= CONVERT(r.rubric_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
WHERE r.tenant_id = '000000'
AND s.position = '生活顾问';
DELETE d
FROM aihr_practice_rubric_dimension d
JOIN aihr_brd_rubric_codes r
ON r.tenant_id = d.tenant_id AND r.rubric_code = d.rubric_code;
ON CONVERT(r.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
= CONVERT(d.tenant_id USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
AND CONVERT(r.rubric_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
= CONVERT(d.rubric_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci;
INSERT INTO aihr_practice_rubric_dimension
(tenant_id, rubric_code, dimension_code, dimension_name, weight, description, sort_order, create_time, update_time)
@@ -152,3 +152,17 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_query_log` (
KEY `idx_aihr_query_tenant_app` (`tenant_id`, `app_id`, `create_time`),
KEY `idx_aihr_query_status` (`tenant_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识问答最小化审计日志';
CREATE TABLE IF NOT EXISTS `aihr_knowledge_admin_audit` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) NOT NULL,
`operator_id` bigint DEFAULT NULL,
`action` varchar(50) NOT NULL,
`object_type` varchar(30) NOT NULL,
`object_id` bigint DEFAULT NULL,
`before_summary` varchar(1000) DEFAULT NULL,
`after_summary` varchar(1000) DEFAULT NULL,
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_aihr_admin_audit_tenant` (`tenant_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识平台管理变更审计';
@@ -0,0 +1,201 @@
-- 学习闭环:训练题快照/反馈、岗位题库、入职考试(MySQL 8.x)。
-- 可重复执行;先于 aihr_20260717_practice_five_position_catalog_mysql8.sql 执行。
SET @aihr_schema = DATABASE();
SET @content_version_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND column_name = 'content_version'
);
SET @content_version_ddl = IF(@content_version_exists = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT ''v1'' COMMENT ''派发内容版本'' AFTER request_key',
'SELECT 1');
PREPARE aihr_content_version_stmt FROM @content_version_ddl;
EXECUTE aihr_content_version_stmt;
DEALLOCATE PREPARE aihr_content_version_stmt;
SET @question_snapshot_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND column_name = 'question_snapshot'
);
SET @question_snapshot_ddl = IF(@question_snapshot_exists = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN question_snapshot varchar(1000) DEFAULT NULL COMMENT ''派发时题目快照'' AFTER content_version',
'SELECT 1');
PREPARE aihr_question_snapshot_stmt FROM @question_snapshot_ddl;
EXECUTE aihr_question_snapshot_stmt;
DEALLOCATE PREPARE aihr_question_snapshot_stmt;
SET @reference_snapshot_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND column_name = 'reference_answer_snapshot'
);
SET @reference_snapshot_ddl = IF(@reference_snapshot_exists = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN reference_answer_snapshot varchar(2000) DEFAULT NULL COMMENT ''派发时参考答案快照'' AFTER question_snapshot',
'SELECT 1');
PREPARE aihr_reference_snapshot_stmt FROM @reference_snapshot_ddl;
EXECUTE aihr_reference_snapshot_stmt;
DEALLOCATE PREPARE aihr_reference_snapshot_stmt;
SET @content_hash_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND column_name = 'content_hash'
);
SET @content_hash_ddl = IF(@content_hash_exists = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN content_hash char(64) DEFAULT NULL COMMENT ''版本化内容哈希'' AFTER reference_answer_snapshot',
'SELECT 1');
PREPARE aihr_content_hash_stmt FROM @content_hash_ddl;
EXECUTE aihr_content_hash_stmt;
DEALLOCATE PREPARE aihr_content_hash_stmt;
SET @content_hash_index_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND index_name = 'idx_aihr_assignment_recent_content'
);
SET @content_hash_index_ddl = IF(@content_hash_index_exists = 0,
'ALTER TABLE aihr_practice_assignment ADD KEY idx_aihr_assignment_recent_content (tenant_id, ext_party_id, source, content_hash, create_time)',
'SELECT 1');
PREPARE aihr_content_hash_index_stmt FROM @content_hash_index_ddl;
EXECUTE aihr_content_hash_index_stmt;
DEALLOCATE PREPARE aihr_content_hash_index_stmt;
CREATE TABLE IF NOT EXISTS `aihr_learning_question` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
`question_code` varchar(120) NOT NULL COMMENT '题目编码',
`scenario_code` varchar(80) NOT NULL COMMENT '场景编码',
`position_code` varchar(80) NOT NULL COMMENT '岗位编码',
`question_type` varchar(20) NOT NULL DEFAULT 'daily' COMMENT 'daily/special',
`question_text` varchar(1000) NOT NULL COMMENT '题干',
`reference_answer` varchar(2000) NOT NULL COMMENT '参考答案',
`content_version` varchar(30) NOT NULL DEFAULT 'v1' COMMENT '内容版本',
`content_hash` char(64) NOT NULL COMMENT '题目内容哈希',
`enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用',
`sort_order` int NOT NULL DEFAULT 0 COMMENT '排序',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
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 COMMENT='岗位场景学习题库';
CREATE TABLE IF NOT EXISTS `aihr_practice_question_feedback` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
`assignment_id` bigint NOT NULL COMMENT '训练题派发ID',
`ext_party_id` varchar(100) NOT NULL COMMENT '员工身份',
`verdict` varchar(20) NOT NULL COMMENT 'useful/not_useful',
`comment` varchar(500) DEFAULT NULL COMMENT '反馈说明',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
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 COMMENT='每日题和专项题有用性反馈';
CREATE TABLE IF NOT EXISTS `aihr_onboard_exam` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
`title` varchar(200) NOT NULL COMMENT '考试名称',
`project_code` varchar(100) NOT NULL DEFAULT '' COMMENT '适用项目,空为全部',
`position_code` varchar(80) NOT NULL DEFAULT '' COMMENT '适用岗位,空为全部',
`status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/PUBLISHED',
`total_score` int NOT NULL DEFAULT 100 COMMENT '总分',
`pass_score` int NOT NULL DEFAULT 80 COMMENT '及格分',
`due_date` date DEFAULT NULL COMMENT '截止日期',
`created_by` varchar(100) NOT NULL COMMENT '创建人身份',
`publish_request_key` varchar(100) DEFAULT NULL COMMENT '发布幂等键',
`published_time` datetime DEFAULT NULL COMMENT '发布时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
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 COMMENT='主管创建的岗前/入职考试';
SET @publish_request_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_onboard_exam' AND column_name = 'publish_request_key'
);
SET @publish_request_ddl = IF(@publish_request_exists = 0,
'ALTER TABLE aihr_onboard_exam ADD COLUMN publish_request_key varchar(100) DEFAULT NULL COMMENT ''发布幂等键'' AFTER created_by',
'SELECT 1');
PREPARE aihr_publish_request_stmt FROM @publish_request_ddl;
EXECUTE aihr_publish_request_stmt;
DEALLOCATE PREPARE aihr_publish_request_stmt;
SET @publish_request_index_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @aihr_schema AND table_name = 'aihr_onboard_exam'
AND index_name = 'uk_aihr_onboard_exam_publish_request'
);
SET @publish_request_index_ddl = IF(@publish_request_index_exists = 0,
'ALTER TABLE aihr_onboard_exam ADD UNIQUE KEY uk_aihr_onboard_exam_publish_request (tenant_id, created_by, publish_request_key)',
'SELECT 1');
PREPARE aihr_publish_request_index_stmt FROM @publish_request_index_ddl;
EXECUTE aihr_publish_request_index_stmt;
DEALLOCATE PREPARE aihr_publish_request_index_stmt;
CREATE TABLE IF NOT EXISTS `aihr_onboard_exam_target` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
`exam_id` bigint NOT NULL COMMENT '考试ID',
`ext_party_id` varchar(100) NOT NULL COMMENT '真实员工外部身份',
`trainee_name` varchar(100) NOT NULL COMMENT '派发时员工姓名快照',
`project_code` varchar(100) NOT NULL DEFAULT '' COMMENT '派发时项目快照',
`position_name` varchar(100) NOT NULL DEFAULT '' COMMENT '派发时岗位快照',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
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 COMMENT='入职考试定向人员(最多100人)';
CREATE TABLE IF NOT EXISTS `aihr_onboard_exam_question` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
`exam_id` bigint NOT NULL COMMENT '考试ID',
`question_type` varchar(20) NOT NULL COMMENT 'single/multiple/judge/short',
`stem` varchar(1000) NOT NULL COMMENT '题干',
`options_json` text COMMENT '选项JSON',
`correct_answers_json` text NOT NULL COMMENT '正确答案或关键词JSON',
`score` int NOT NULL COMMENT '分值',
`explanation` varchar(1000) DEFAULT NULL COMMENT '解析',
`sort_order` int NOT NULL DEFAULT 0 COMMENT '排序',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
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 COMMENT='入职考试题目';
CREATE TABLE IF NOT EXISTS `aihr_onboard_exam_attempt` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
`exam_id` bigint NOT NULL COMMENT '考试ID',
`ext_party_id` varchar(100) NOT NULL COMMENT '员工身份',
`request_key` varchar(100) NOT NULL COMMENT '提交幂等键',
`status` varchar(20) NOT NULL DEFAULT 'SUBMITTED' COMMENT '提交状态',
`score` int NOT NULL DEFAULT 0 COMMENT '得分',
`passed` tinyint NOT NULL DEFAULT 0 COMMENT '是否通过',
`submitted_time` datetime NOT NULL COMMENT '提交时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
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 COMMENT='入职考试作答记录';
CREATE TABLE IF NOT EXISTS `aihr_onboard_exam_answer` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
`attempt_id` bigint NOT NULL COMMENT '作答记录ID',
`question_id` bigint NOT NULL COMMENT '题目ID',
`answer_json` text COMMENT '员工答案JSON',
`correct` tinyint NOT NULL DEFAULT 0 COMMENT '是否正确',
`earned_score` int NOT NULL DEFAULT 0 COMMENT '本题得分',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
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 COMMENT='入职考试逐题答案';
@@ -0,0 +1,42 @@
-- 主管专项批量派发:截止日期与客户端幂等键。
-- request_key 对历史/单人旧调用保持 NULL;MySQL 唯一索引允许多行 NULL。
SET @has_due_date := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_assignment' AND column_name = 'due_date'
);
SET @ddl_due_date := IF(
@has_due_date = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN due_date date DEFAULT NULL COMMENT ''训练截止日期'' AFTER status',
'SELECT 1'
);
PREPARE stmt_due_date FROM @ddl_due_date;
EXECUTE stmt_due_date;
DEALLOCATE PREPARE stmt_due_date;
SET @has_request_key := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_assignment' AND column_name = 'request_key'
);
SET @ddl_request_key := IF(
@has_request_key = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN request_key varchar(100) DEFAULT NULL COMMENT ''客户端幂等请求ID'' AFTER due_date',
'SELECT 1'
);
PREPARE stmt_request_key FROM @ddl_request_key;
EXECUTE stmt_request_key;
DEALLOCATE PREPARE stmt_request_key;
SET @has_request_index := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_assignment'
AND index_name = 'uk_aihr_assignment_request'
);
SET @ddl_request_index := IF(
@has_request_index = 0,
'ALTER TABLE aihr_practice_assignment ADD UNIQUE KEY uk_aihr_assignment_request (tenant_id, request_key, ext_party_id)',
'SELECT 1'
);
PREPARE stmt_request_index FROM @ddl_request_index;
EXECUTE stmt_request_index;
DEALLOCATE PREPARE stmt_request_index;
@@ -0,0 +1,277 @@
-- 五岗位训练内容目录(MySQL 8.x)。
-- 生活顾问/保安/保洁/保修/客服各 20 个场景,并为每个场景生成 daily、special 两类版本化题目。
-- 本脚本是内置内容,不代表高风险作业已获得业务、安全或法务审核;未完成正式审核的高风险行保持 enabled=0。
-- 依赖:先执行 aihr_20260714_practice_catalog_schema_mysql8.sql。脚本可重复执行。
SET @db_name = DATABASE();
SET @scenario_version_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @db_name AND table_name = 'aihr_practice_scenario' AND column_name = 'content_version'
);
SET @scenario_version_ddl = IF(
@scenario_version_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN content_version varchar(30) NOT NULL DEFAULT ''v1.0'' COMMENT ''内容版本'' AFTER rounds_json',
'SELECT 1'
);
PREPARE scenario_version_stmt FROM @scenario_version_ddl;
EXECUTE scenario_version_stmt;
DEALLOCATE PREPARE scenario_version_stmt;
SET @scenario_hash_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @db_name AND table_name = 'aihr_practice_scenario' AND column_name = 'content_hash'
);
SET @scenario_hash_ddl = IF(
@scenario_hash_exists = 0,
'ALTER TABLE aihr_practice_scenario ADD COLUMN content_hash char(64) DEFAULT NULL COMMENT ''内容快照哈希'' AFTER content_version',
'SELECT 1'
);
PREPARE scenario_hash_stmt FROM @scenario_hash_ddl;
EXECUTE scenario_hash_stmt;
DEALLOCATE PREPARE scenario_hash_stmt;
CREATE TABLE IF NOT EXISTS `aihr_learning_question` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
`question_code` varchar(120) NOT NULL COMMENT '题目编码',
`scenario_code` varchar(80) NOT NULL COMMENT '场景编码',
`position_code` varchar(80) NOT NULL COMMENT '岗位编码',
`question_type` varchar(20) NOT NULL DEFAULT 'daily' COMMENT 'daily/special',
`question_text` varchar(1000) NOT NULL COMMENT '题干',
`reference_answer` varchar(2000) NOT NULL COMMENT '参考答案',
`content_version` varchar(30) NOT NULL DEFAULT 'v1.0' COMMENT '内容版本',
`content_hash` char(64) NOT NULL COMMENT '题目内容哈希',
`enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用',
`sort_order` int NOT NULL DEFAULT 0 COMMENT '排序',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_learning_question` (`tenant_id`, `question_code`),
KEY `idx_aihr_learning_question_hash` (`tenant_id`, `content_hash`),
KEY `idx_aihr_learning_question_pick` (`tenant_id`, `position_code`, `question_type`, `enabled`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='岗位学习题库';
SET @question_type_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @db_name AND table_name = 'aihr_learning_question' AND column_name = 'question_type'
);
SET @question_type_ddl = IF(
@question_type_exists = 0,
'ALTER TABLE aihr_learning_question ADD COLUMN question_type varchar(20) NOT NULL DEFAULT ''daily'' COMMENT ''daily/special'' AFTER position_code',
'SELECT 1'
);
PREPARE question_type_stmt FROM @question_type_ddl;
EXECUTE question_type_stmt;
DEALLOCATE PREPARE question_type_stmt;
DROP TEMPORARY TABLE IF EXISTS `_aihr_five_position_catalog`;
CREATE TEMPORARY TABLE `_aihr_five_position_catalog` (
`position` varchar(80) NOT NULL,
`scenario_code` varchar(80) NOT NULL,
`scenario_name` varchar(100) NOT NULL,
`scenario_type` varchar(50) NOT NULL,
`sop_code` varchar(80) NOT NULL,
`opening_text` varchar(500) NOT NULL,
`difficulty` tinyint NOT NULL,
`enabled` tinyint NOT NULL,
PRIMARY KEY (`scenario_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `_aihr_five_position_catalog`
(`position`, `scenario_code`, `scenario_name`, `scenario_type`, `sop_code`, `opening_text`, `difficulty`, `enabled`)
VALUES
('生活顾问', 'complaint-water', '客服应对投诉', '投诉处理', 'SOP-LG-001', '你们说今天处理,到现在都没人联系我,这个车库还怎么停?', 2, 1),
('生活顾问', 'complaint-noise', '楼上噪音投诉', '投诉处理', 'SOP-LG-002', '楼上每天晚上拖椅子,你们物业到底管不管?', 2, 1),
('生活顾问', 'complaint-attitude', '服务态度投诉', '投诉处理', 'SOP-LG-003', '你们前台刚才那是什么态度?我要投诉。', 1, 1),
('生活顾问', 'fee-collection', '催缴沟通技巧', '催费/停车费', 'SOP-LG-004', '服务做成这样还让我缴费?你先把问题解决了再说。', 2, 1),
('生活顾问', 'fee-parking', '停车费催缴', '催费/停车费', 'SOP-LG-005', '停车费又催,我车位入口问题一直没解决,凭什么交?', 2, 1),
('生活顾问', 'fee-prepay-discount', '预交折扣沟通', '催费/停车费', 'SOP-LG-006', '你们说预交有折扣,那我以前欠的怎么算?', 2, 1),
('生活顾问', 'repair-leak-followup', '漏水报修跟进', '报修跟进', 'SOP-LG-007', '我家天花还在滴水,报修两天了怎么没人来?', 2, 1),
('生活顾问', 'repair-elevator-delay', '电梯故障跟进', '报修跟进', 'SOP-LG-008', '电梯坏了一上午,老人怎么下楼?你们给个准话。', 3, 1),
('生活顾问', 'repair-public-light', '公区照明维修', '报修跟进', 'SOP-LG-009', '楼道灯坏了三天,晚上黑漆漆的。', 1, 1),
('生活顾问', 'emergency-water-outage', '突发停水求助', '突发求助', 'SOP-LG-010', '家里突然没水了,孩子还要洗澡,怎么回事?', 2, 1),
('生活顾问', 'emergency-elder-fall', '老人摔倒求助', '突发求助', 'SOP-LG-011', '我爸在楼下摔倒了,你们快来帮忙!', 3, 1),
('生活顾问', 'emergency-security', '陌生人尾随求助', '突发求助', 'SOP-LG-012', '我刚进小区感觉有人跟着我,你们保安在哪里?', 3, 1),
('生活顾问', 'service-promotion-needs', '增值服务需求识别与推介', '服务推介/增值', 'SOP-LG-013', '家里老人要长期出门,社区有没有适合的生活服务?', 2, 1),
('生活顾问', 'daily-service-followup', '日常服务主动回访', '日常服务', 'SOP-LG-014', '上次问题处理完了,后续还会有人联系我吗?', 1, 1),
('生活顾问', 'LG-RENOVATION-REGISTER', '装修登记引导', '装修服务', 'SOP-LG-015', '我家准备装修,需要先办哪些手续?', 2, 1),
('生活顾问', 'LG-MOVE-IN', '入住服务办理', '入住服务', 'SOP-LG-016', '我明天搬进来,钥匙和门禁怎么办理?', 1, 1),
('生活顾问', 'LG-ACTIVITY-CONSULT', '社区活动咨询', '社区运营', 'SOP-LG-017', '本周社区活动怎么报名,老人可以参加吗?', 1, 1),
('生活顾问', 'LG-PARKING-CONSULT', '车位使用咨询', '停车服务', 'SOP-LG-018', '临时车辆怎么收费,车位可以共享吗?', 1, 1),
('生活顾问', 'LG-INFO-CHANGE', '业主信息变更', '资料维护', 'SOP-LG-019', '我的联系电话换了,在哪里更新业主资料?', 1, 1),
('生活顾问', 'LG-VISITOR-REGISTER', '访客登记协同', '客户接待', 'SOP-LG-020', '家里今天有客人来,门岗说还需要登记,我该怎么办?', 1, 1),
('保安', 'BA-VEHICLE-ENTRY', '临时车辆入场核验', '门岗管理', 'SOP-BA-001', '我是来访车辆,业主已经同意了,为什么还不能进?', 1, 1),
('保安', 'BA-VISITOR-ENTRY', '访客入场核验', '门岗管理', 'SOP-BA-002', '我来找朋友但联系不上他,可以先让我进去吗?', 2, 1),
('保安', 'BA-DELIVERY-ENTRY', '配送人员入场引导', '门岗管理', 'SOP-BA-003', '我要给业主送货,东西很多,需要从哪里进入?', 1, 1),
('保安', 'BA-NIGHT-PATROL', '夜间巡逻异常记录', '巡逻管理', 'SOP-BA-004', '巡逻时发现单元门长时间敞开且附近无人。', 2, 1),
('保安', 'BA-SUSPICIOUS-PERSON', '可疑人员信息核查', '异常处置', 'SOP-BA-005', '有陌生人在楼栋附近反复徘徊,我有点担心。', 2, 1),
('保安', 'BA-LOST-CHILD', '走失儿童协查', '人员协查', 'SOP-BA-006', '孩子在小区里走丢了,已经十分钟没找到。', 3, 1),
('保安', 'BA-DRUNK-PERSON', '醉酒人员现场协同', '异常处置', 'SOP-BA-007', '有人醉倒在单元门口,还在大声吵闹。', 3, 1),
('保安', 'BA-PARKING-CONFLICT', '停车纠纷降级沟通', '停车秩序', 'SOP-BA-008', '对方占了我的车位,我们已经吵起来了。', 3, 1),
('保安', 'BA-ILLEGAL-PARKING', '消防通道违停劝离', '停车秩序', 'SOP-BA-009', '有车辆堵住通道,怎么联系车主处理?', 2, 1),
('保安', 'BA-GATE-FAILURE', '门岗设备故障登记', '设备异常', 'SOP-BA-010', '闸机无法识别车辆,后面已经排起长队。', 2, 1),
('保安', 'BA-CCTV-REQUEST', '监控查看申请引导', '信息合规', 'SOP-BA-011', '我的东西丢了,能不能直接把监控录像给我?', 2, 1),
('保安', 'BA-KEYS-FOUND', '拾获钥匙登记', '失物招领', 'SOP-BA-012', '我在地库捡到一串钥匙,应该交给谁?', 1, 1),
('保安', 'BA-LARGE-ITEM-EXIT', '大件物品出门核验', '门岗管理', 'SOP-BA-013', '搬家公司要把家具运出去,需要什么凭证?', 2, 1),
('保安', 'BA-EVENT-CROWD', '活动人流秩序引导', '活动保障', 'SOP-BA-014', '活动结束后门口人员集中,请安排疏导。', 2, 1),
('保安', 'BA-EMERGENCY-ACCESS', '救援车辆通道保障', '应急协同', 'SOP-BA-015', '救护车马上到,请确认最快的进入路线。', 3, 1),
('保安', 'BA-FIRE-ALARM', '消防报警首响与升级', '高风险应急', 'SOP-BA-016', '消防报警器持续响起并闻到焦糊味。', 3, 0),
('保安', 'BA-ELEVATOR-TRAPPED', '电梯困人信息收集与升级', '高风险应急', 'SOP-BA-017', '有人被困在电梯里,情绪很紧张。', 3, 0),
('保安', 'BA-VIOLENT-CONFLICT', '暴力冲突报警与隔离边界', '高风险应急', 'SOP-BA-018', '两个人正在发生肢体冲突,现场有人围观。', 3, 0),
('保安', 'BA-FLOOD-CONTROL', '地库积水应急边界', '高风险应急', 'SOP-BA-019', '地库入口积水上涨,车辆仍在进入。', 3, 0),
('保安', 'BA-HAZARDOUS-ITEM', '疑似危险物品隔离与上报', '高风险应急', 'SOP-BA-020', '公共区域发现来源不明且有刺激气味的容器。', 3, 0),
('保洁', 'BJ-LOBBY-CLEANING', '大堂日常保洁', '日常保洁', 'SOP-BJ-001', '大堂地面有脚印和灰尘,请尽快恢复整洁。', 1, 1),
('保洁', 'BJ-CORRIDOR-CLEANING', '楼道日常保洁', '日常保洁', 'SOP-BJ-002', '楼道角落积灰比较明显,今天能处理吗?', 1, 1),
('保洁', 'BJ-ELEVATOR-CAR', '电梯轿厢日常保洁', '日常保洁', 'SOP-BJ-003', '电梯轿厢有污渍和异味,请安排清洁。', 1, 1),
('保洁', 'BJ-WASTE-CLASSIFY', '生活垃圾分类引导', '垃圾管理', 'SOP-BJ-004', '这个垃圾应该投放在哪一类垃圾桶?', 1, 1),
('保洁', 'BJ-BIN-OVERFLOW', '垃圾桶满溢处理', '垃圾管理', 'SOP-BJ-005', '垃圾桶已经满了,周围也有散落垃圾。', 2, 1),
('保洁', 'BJ-PET-WASTE', '宠物排泄物清理', '专项清洁', 'SOP-BJ-006', '草坪边有宠物排泄物,影响大家通行。', 1, 1),
('保洁', 'BJ-SPILL-SLIP', '地面泼洒防滑处置', '专项清洁', 'SOP-BJ-007', '走廊地面有水,已经有人差点滑倒。', 2, 1),
('保洁', 'BJ-ODOR-COMPLAINT', '公共区域异味排查', '专项清洁', 'SOP-BJ-008', '楼道一直有异味,但找不到来源。', 2, 1),
('保洁', 'BJ-WASHROOM', '公共卫生间保洁', '日常保洁', 'SOP-BJ-009', '公共卫生间需要补充耗材并清洁。', 1, 1),
('保洁', 'BJ-GLASS-DOOR', '低位玻璃门清洁', '日常保洁', 'SOP-BJ-010', '一楼玻璃门有很多手印,影响观感。', 1, 1),
('保洁', 'BJ-PUBLIC-DISINFECTION', '公共接触面消洁', '消洁作业', 'SOP-BJ-011', '请按计划完成门把手和按钮的消洁记录。', 2, 1),
('保洁', 'BJ-EVENT-CLEANUP', '社区活动后保洁', '活动保障', 'SOP-BJ-012', '活动刚结束,场地有纸屑和桌椅需要恢复。', 2, 1),
('保洁', 'BJ-RENOVATION-DUST', '装修扬尘清洁协同', '专项清洁', 'SOP-BJ-013', '装修搬运后楼道都是灰,请协调清理。', 2, 1),
('保洁', 'BJ-RAINY-ENTRANCE', '雨天入口防滑保障', '天气保障', 'SOP-BJ-014', '外面下大雨,大堂入口地面很湿。', 2, 1),
('保洁', 'BJ-LEAF-CLEANUP', '落叶集中清理', '室外保洁', 'SOP-BJ-015', '园区步道落叶较多,影响通行和观感。', 1, 1),
('保洁', 'BJ-PEST-SIGHTING', '虫害线索登记上报', '虫害协同', 'SOP-BJ-016', '垃圾房附近发现较多虫害,请记录并报处理。', 2, 1),
('保洁', 'BJ-MEDICAL-WASTE', '疑似医疗废弃物隔离边界', '高风险作业', 'SOP-BJ-017', '垃圾桶旁发现疑似使用过的医疗针具。', 3, 0),
('保洁', 'BJ-CHEMICAL-SPILL', '不明化学品泼洒处置边界', '高风险作业', 'SOP-BJ-018', '储物间有不明液体泄漏并伴随刺激气味。', 3, 0),
('保洁', 'BJ-HIGH-RISE-EXTERIOR', '高空外墙保洁边界', '高风险作业', 'SOP-BJ-019', '需要安排高空外墙玻璃清洗,请确认作业条件。', 3, 0),
('保洁', 'BJ-SHARP-OBJECT', '锐器垃圾隔离处置边界', '高风险作业', 'SOP-BJ-020', '垃圾袋内有裸露碎玻璃,存在割伤风险。', 3, 0),
('保修', 'BX-WATER-LEAK', '室内漏水报修初判', '给排水维修', 'SOP-BX-001', '天花板一直滴水,不知道是不是楼上漏下来的。', 2, 1),
('保修', 'BX-DRAIN-BLOCK', '排水堵塞报修', '给排水维修', 'SOP-BX-002', '厨房下水很慢,还有返水现象。', 2, 1),
('保修', 'BX-DOOR-LOCK', '公共门锁故障处理', '门窗维修', 'SOP-BX-003', '单元门锁不上,晚上让人不放心。', 2, 1),
('保修', 'BX-LAMP-REPLACE', '公共照明更换', '电气维修', 'SOP-BX-004', '楼道灯坏了,晚上比较暗。', 1, 1),
('保修', 'BX-PUBLIC-LIGHT', '园区照明异常排查', '电气维修', 'SOP-BX-005', '园区有一排路灯都不亮,请安排检查。', 2, 1),
('保修', 'BX-ACCESS-CONTROL', '门禁设备故障报修', '弱电维修', 'SOP-BX-006', '门禁刷卡没有反应,居民进出不方便。', 2, 1),
('保修', 'BX-INTERCOM', '楼宇对讲故障报修', '弱电维修', 'SOP-BX-007', '家里的可视对讲听不到声音。', 2, 1),
('保修', 'BX-PARKING-GATE', '停车道闸故障协同', '设备维修', 'SOP-BX-008', '出口道闸不抬杆,车辆已经排队。', 2, 1),
('保修', 'BX-PUMP-NOISE', '水泵异响工单处理', '设备维修', 'SOP-BX-009', '设备房附近晚上持续有明显异响。', 2, 1),
('保修', 'BX-COMMON-AC', '公共空调异常报修', '暖通维修', 'SOP-BX-010', '会所公共区域空调不制冷。', 2, 1),
('保修', 'BX-WALL-CRACK', '墙面裂缝登记勘查', '土建维修', 'SOP-BX-011', '公共走廊墙面出现裂缝,是否有安全问题?', 2, 1),
('保修', 'BX-WATERPROOF', '渗水返修跟进', '土建维修', 'SOP-BX-012', '上次做过防水,现在又开始渗水了。', 2, 1),
('保修', 'BX-APPLIANCE-BOUNDARY', '业主家电维修边界说明', '服务边界', 'SOP-BX-013', '家里的冰箱坏了,物业能不能上门维修?', 1, 1),
('保修', 'BX-METER-QUERY', '水电表读数疑问协查', '计量协查', 'SOP-BX-014', '这个月用量异常,能否帮我核对表计?', 2, 1),
('保修', 'BX-SAFE-SHUTDOWN', '设备异常停用与围挡', '故障隔离', 'SOP-BX-015', '公共设施出现异响,是否需要先停用并围挡?', 3, 1),
('保修', 'BX-LIVE-ELECTRICAL', '带电故障处置边界', '高风险作业', 'SOP-BX-016', '配电箱出现火花,现场是否可以直接检查?', 3, 0),
('保修', 'BX-GAS-LEAK', '疑似燃气泄漏升级边界', '高风险作业', 'SOP-BX-017', '家中闻到明显燃气味,请问应该怎么处理?', 3, 0),
('保修', 'BX-ELEVATOR-REPAIR', '电梯故障维修边界', '高风险作业', 'SOP-BX-018', '电梯运行时抖动,能否马上打开设备检查?', 3, 0),
('保修', 'BX-FIRE-SYSTEM', '消防设施故障维修边界', '高风险作业', 'SOP-BX-019', '消防泵控制柜报故障,需要立即检修。', 3, 0),
('保修', 'BX-CONFINED-SPACE', '有限空间作业边界', '高风险作业', 'SOP-BX-020', '污水井需要下井检查,请确认作业准备。', 3, 0),
('客服', 'KF-PHONE-FIRST', '电话咨询首次响应', '电话服务', 'SOP-KF-001', '我第一次打电话咨询,请告诉我该找哪个部门。', 1, 1),
('客服', 'KF-ONLINE-FIRST', '在线咨询首次响应', '在线服务', 'SOP-KF-002', '我在小程序留言了,能否现在帮我查询?', 1, 1),
('客服', 'KF-COMPLAINT-REGISTER', '投诉信息完整登记', '投诉受理', 'SOP-KF-003', '我要正式投诉,请把我的诉求完整记录下来。', 2, 1),
('客服', 'KF-REPAIR-DISPATCH', '报修信息登记派单', '工单受理', 'SOP-KF-004', '家里漏水,请尽快安排师傅上门。', 2, 1),
('客服', 'KF-PROGRESS-QUERY', '工单进度查询', '工单受理', 'SOP-KF-005', '我的工单已经两天了,现在处理到哪一步?', 2, 1),
('客服', 'KF-RETURN-CALL', '未接来电回呼', '电话服务', 'SOP-KF-006', '刚才你们打电话我没接到,请问是什么事情?', 1, 1),
('客服', 'KF-SATISFACTION', '服务满意度回访', '回访服务', 'SOP-KF-007', '问题已经处理了,你们还要了解哪些情况?', 1, 1),
('客服', 'KF-FEE-QUERY', '费用明细查询受理', '费用咨询', 'SOP-KF-008', '我对账单有疑问,请帮我核对收费项目。', 2, 1),
('客服', 'KF-INVOICE', '发票申请引导', '费用服务', 'SOP-KF-009', '物业费发票在哪里申请,需要提供什么信息?', 1, 1),
('客服', 'KF-ACCESS-CARD', '门禁卡业务受理', '便民服务', 'SOP-KF-010', '门禁卡丢了,怎么挂失和补办?', 1, 1),
('客服', 'KF-PARKING-RENEW', '停车服务续费咨询', '停车服务', 'SOP-KF-011', '停车服务快到期了,如何续费?', 1, 1),
('客服', 'KF-INFO-CHANGE', '客户资料变更受理', '资料维护', 'SOP-KF-012', '我需要修改联系电话,请告诉我验证流程。', 1, 1),
('客服', 'KF-OUTAGE-INQUIRY', '停水停电信息查询', '突发咨询', 'SOP-KF-013', '现在停水了,预计什么时候恢复?', 2, 1),
('客服', 'KF-NOISE-COMPLAINT', '噪音投诉受理', '投诉受理', 'SOP-KF-014', '隔壁夜间噪音很大,请帮我登记处理。', 2, 1),
('客服', 'KF-LOST-FOUND', '失物招领查询', '便民服务', 'SOP-KF-015', '我可能把证件落在园区里了,怎么查询?', 1, 1),
('客服', 'KF-ACTIVITY-REGISTER', '社区活动报名受理', '社区运营', 'SOP-KF-016', '我想报名周末活动,还有名额吗?', 1, 1),
('客服', 'KF-SERVICE-BOOKING', '增值服务预约受理', '服务预约', 'SOP-KF-017', '我想预约上门服务,请说明时间和费用。', 2, 1),
('客服', 'KF-PRIVACY-REQUEST', '个人信息查询申请引导', '信息合规', 'SOP-KF-018', '我想查看物业保存了我的哪些个人信息。', 2, 1),
('客服', 'KF-ABUSIVE-CALLER', '辱骂来电情绪降级', '困难沟通', 'SOP-KF-019', '客户持续辱骂并拒绝提供工单信息。', 3, 1),
('客服', 'KF-EMERGENCY-TRANSFER', '紧急来电识别与转接', '紧急协同', 'SOP-KF-020', '现场有人受伤,请马上告诉我应该联系谁。', 3, 1);
-- 兼容历史库以显式 id 导入 seed、但 AUTO_INCREMENT 仍停留在 1 的情况。
-- 不先推进游标时,INSERT ... SELECT 的前若干行可能撞主键并错误进入 ON DUPLICATE 分支。
SET @next_scenario_id = (SELECT COALESCE(MAX(`id`), 0) + 1 FROM `aihr_practice_scenario`);
SET @scenario_auto_increment_ddl = CONCAT(
'ALTER TABLE aihr_practice_scenario AUTO_INCREMENT = ', @next_scenario_id
);
PREPARE scenario_auto_increment_stmt FROM @scenario_auto_increment_ddl;
EXECUTE scenario_auto_increment_stmt;
DEALLOCATE PREPARE scenario_auto_increment_stmt;
INSERT INTO `aihr_practice_scenario`
(`tenant_id`, `scenario_code`, `scenario_name`, `position`, `scenario_type`, `project_type`, `difficulty`,
`customer_persona`, `opening_text`, `explicit_need`, `hidden_need`, `irritants`, `delights`, `sop_refs`,
`success_criteria`, `strategy`, `goal`, `trainee_name`, `trust_start`, `trust_end`, `rounds_json`,
`content_version`, `content_hash`, `enabled`, `sort_order`, `create_time`, `update_time`)
SELECT
'000000', c.`scenario_code`, c.`scenario_name`, c.`position`, c.`scenario_type`, '住宅', c.`difficulty`,
'关注响应速度、事实准确和处理闭环的业主或现场相关人员', c.`opening_text`,
CONCAT('依据 ', c.`sop_code`, ' 完成信息确认、边界说明和后续动作'),
'希望得到明确责任人、处理节点和可追溯记录',
'未经核实直接承诺结果、遗漏记录、越权处置或使用刺激性表达',
'先确认事实和风险,再说明权限边界、责任人、时限和回访方式',
c.`sop_code`,
CONCAT('正确引用 ', c.`sop_code`, ',形成受理、处理、升级、回访闭环'),
'保持承接情绪,优先识别安全风险;不确定时停止越权操作并升级',
CONCAT('训练', c.`position`, '在“', c.`scenario_name`, '”场景中按SOP完成服务闭环。'),
'试点学员', 60, 82,
JSON_ARRAY(
JSON_OBJECT('customer', c.`opening_text`, 'coachHint', CONCAT('先确认事实,再按 ', c.`sop_code`, ' 说明下一步。'), 'emotion', 68, 'trust', 68),
JSON_OBJECT('customer', '如果现在还不能解决,请明确谁负责、什么时候反馈。', 'coachHint', '说明权限边界、升级对象、时间节点和记录方式。', 'emotion', 58, 'trust', 82)
),
'v1.0',
SHA2(CONCAT_WS('|', c.`position`, c.`scenario_code`, c.`scenario_name`, c.`scenario_type`, c.`sop_code`, c.`opening_text`, 'v1.0'), 256),
c.`enabled`,
CASE c.`position` WHEN '生活顾问' THEN 100 WHEN '保安' THEN 200 WHEN '保洁' THEN 300 WHEN '保修' THEN 400 ELSE 500 END
+ CAST(RIGHT(c.`sop_code`, 3) AS UNSIGNED),
NOW(), NOW()
FROM `_aihr_five_position_catalog` c
ON DUPLICATE KEY UPDATE
`sop_refs` = VALUES(`sop_refs`),
`content_version` = VALUES(`content_version`),
`content_hash` = VALUES(`content_hash`),
`enabled` = VALUES(`enabled`),
`sort_order` = VALUES(`sort_order`),
`update_time` = VALUES(`update_time`);
INSERT INTO `aihr_learning_question`
(`tenant_id`, `question_code`, `scenario_code`, `position_code`, `question_type`, `question_text`,
`reference_answer`, `content_version`, `content_hash`, `enabled`, `sort_order`, `create_time`, `update_time`)
SELECT
'000000',
CONCAT(c.`scenario_code`, '-', q.`question_type`, '-v1'),
c.`scenario_code`,
c.`position`,
q.`question_type`,
CASE q.`question_type`
WHEN 'daily' THEN CONCAT('面对“', c.`scenario_name`, '”场景,收到“', c.`opening_text`, '”后,你的第一轮回应和后续闭环动作是什么?')
ELSE CONCAT('专项题:在“', c.`scenario_name`, '”场景中,如果对方情绪升级且你无法当场解决,如何依据 ', c.`sop_code`, ' 控制风险、升级并留痕?')
END,
CASE q.`question_type`
WHEN 'daily' THEN CONCAT('先复述并确认事实与诉求,检查是否存在安全风险;依据 ', c.`sop_code`, ' 说明权限边界、责任人和处理时限,记录工单并约定回访。不得编造承诺或越权操作。')
ELSE CONCAT('保持承接情绪,明确当前已知与未知信息;按 ', c.`sop_code`, ' 停止越权处置,隔离或提醒风险,升级到授权责任人,记录时间、对象、证据和反馈节点,并持续回访直至闭环。')
END,
'v1.0',
SHA2(CONCAT_WS('|', c.`scenario_code`, q.`question_type`, c.`scenario_name`, c.`opening_text`, c.`sop_code`, 'v1.0'), 256),
c.`enabled`,
CASE q.`question_type` WHEN 'daily' THEN 1000 ELSE 2000 END
+ CASE c.`position` WHEN '生活顾问' THEN 100 WHEN '保安' THEN 200 WHEN '保洁' THEN 300 WHEN '保修' THEN 400 ELSE 500 END
+ CAST(RIGHT(c.`sop_code`, 3) AS UNSIGNED),
NOW(), NOW()
FROM `_aihr_five_position_catalog` c
CROSS JOIN (
SELECT 'daily' AS `question_type`
UNION ALL SELECT 'special'
) q
WHERE 1 = 1
ON DUPLICATE KEY UPDATE
`scenario_code` = VALUES(`scenario_code`),
`position_code` = VALUES(`position_code`),
`question_type` = VALUES(`question_type`),
`question_text` = VALUES(`question_text`),
`reference_answer` = VALUES(`reference_answer`),
`content_version` = VALUES(`content_version`),
`content_hash` = VALUES(`content_hash`),
`enabled` = VALUES(`enabled`),
`sort_order` = VALUES(`sort_order`),
`update_time` = VALUES(`update_time`);
DROP TEMPORARY TABLE IF EXISTS `_aihr_five_position_catalog`;
@@ -0,0 +1,150 @@
-- 问 AI(全网)查询审计:只记录来源数量和状态,不写入密钥。
CREATE TABLE IF NOT EXISTS `aihr_web_search_provider` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`provider_name` varchar(100) NOT NULL COMMENT '提供方名称',
`provider_code` varchar(40) NOT NULL DEFAULT 'tavily' COMMENT '提供方编码',
`endpoint` varchar(500) NOT NULL COMMENT '公开检索接口',
`api_key` varchar(1000) DEFAULT NULL COMMENT '加密访问密钥,接口永不回传',
`enabled` tinyint NOT NULL DEFAULT 0 COMMENT '是否启用',
`last_test_ok` tinyint NOT NULL DEFAULT 0 COMMENT '当前配置最近一次连接测试是否成功',
`last_test_time` datetime DEFAULT NULL COMMENT '当前配置最近一次连接测试时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
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 COMMENT='全网检索提供方';
-- 兼容已执行过旧版脚本的环境;新增字段默认未测试,避免未经测试的配置被启用。
SET @aihr_schema = DATABASE();
SET @web_last_test_ok_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_web_search_provider' AND column_name = 'last_test_ok'
);
SET @web_last_test_ok_ddl = IF(@web_last_test_ok_exists = 0,
'ALTER TABLE aihr_web_search_provider ADD COLUMN last_test_ok tinyint NOT NULL DEFAULT 0 COMMENT ''当前配置最近一次连接测试是否成功'' AFTER enabled',
'SELECT 1');
PREPARE aihr_web_last_test_ok_stmt FROM @web_last_test_ok_ddl;
EXECUTE aihr_web_last_test_ok_stmt;
DEALLOCATE PREPARE aihr_web_last_test_ok_stmt;
SET @web_last_test_time_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_web_search_provider' AND column_name = 'last_test_time'
);
SET @web_last_test_time_ddl = IF(@web_last_test_time_exists = 0,
'ALTER TABLE aihr_web_search_provider ADD COLUMN last_test_time datetime DEFAULT NULL COMMENT ''当前配置最近一次连接测试时间'' AFTER last_test_ok',
'SELECT 1');
PREPARE aihr_web_last_test_time_stmt FROM @web_last_test_time_ddl;
EXECUTE aihr_web_last_test_time_stmt;
DEALLOCATE PREPARE aihr_web_last_test_time_stmt;
CREATE TABLE IF NOT EXISTS `aihr_web_ai_query_audit` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`user_id` bigint NOT NULL COMMENT '服务端登录用户ID',
`ext_party_id` varchar(80) DEFAULT NULL COMMENT '组织人员外部ID',
`question` varchar(600) NOT NULL COMMENT '查询内容',
`provider` varchar(40) NOT NULL COMMENT '外部检索提供方',
`query_status` varchar(24) NOT NULL COMMENT 'DISABLED/OK/NO_SOURCES/NO_ANSWER/FAILED',
`source_count` int NOT NULL DEFAULT 0 COMMENT '可公开访问来源数',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
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 COMMENT='全网AI查询审计';
-- 开放问题榜。奖励只进入系统积分或学习学分账户,最终值由服务端配置约束。
CREATE TABLE IF NOT EXISTS `aihr_community_question` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`author_user_id` bigint NOT NULL COMMENT '提问用户ID',
`author_ext_party_id` varchar(80) DEFAULT NULL COMMENT '组织人员外部ID',
`author_name` varchar(100) NOT NULL COMMENT '展示名',
`title` varchar(160) NOT NULL COMMENT '标题',
`content` text NOT NULL COMMENT '问题内容',
`question_type` varchar(24) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/BOUNTY',
`category` varchar(40) NOT NULL DEFAULT '工作求助' COMMENT '问题分类',
`reward_rule_code` varchar(60) NOT NULL DEFAULT 'BEST_ANSWER' COMMENT '奖励规则编码',
`reward_points` int NOT NULL DEFAULT 10 COMMENT '最佳回答积分',
`deadline` datetime DEFAULT NULL COMMENT '截止时间',
`question_status` varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PUBLISHED/REJECTED/CLOSED',
`best_answer_id` bigint DEFAULT NULL COMMENT '最佳回答ID',
`moderator_user_id` bigint DEFAULT NULL COMMENT '审核人ID',
`moderation_note` varchar(300) DEFAULT NULL COMMENT '审核说明',
`moderated_time` datetime DEFAULT NULL COMMENT '审核时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_community_question_board` (`tenant_id`, `question_status`, `create_time`),
KEY `idx_aihr_community_question_author` (`tenant_id`, `author_user_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开放问题榜';
CREATE TABLE IF NOT EXISTS `aihr_community_answer` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`question_id` bigint NOT NULL COMMENT '问题ID',
`author_user_id` bigint NOT NULL COMMENT '回答用户ID',
`author_ext_party_id` varchar(80) DEFAULT NULL COMMENT '组织人员外部ID',
`author_name` varchar(100) NOT NULL COMMENT '展示名',
`content` text NOT NULL COMMENT '回答内容',
`answer_status` varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PUBLISHED/REJECTED',
`is_best` tinyint NOT NULL DEFAULT 0 COMMENT '是否最佳回答',
`moderator_user_id` bigint DEFAULT NULL COMMENT '审核人ID',
`moderation_note` varchar(300) DEFAULT NULL COMMENT '审核说明',
`moderated_time` datetime DEFAULT NULL COMMENT '审核时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_community_answer_question` (`tenant_id`, `question_id`, `answer_status`, `create_time`),
KEY `idx_aihr_community_answer_author` (`tenant_id`, `author_user_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开放问题回答';
CREATE TABLE IF NOT EXISTS `aihr_incentive_rule` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`rule_code` varchar(60) NOT NULL COMMENT '规则编码',
`display_name` varchar(100) NOT NULL COMMENT '规则名称',
`account_type` varchar(20) NOT NULL DEFAULT 'POINTS' COMMENT 'POINTS/STUDY_CREDIT',
`rule_value` int NOT NULL DEFAULT 0 COMMENT '奖励数值',
`daily_cap` int NOT NULL DEFAULT 0 COMMENT '单人每日上限,0为不限制',
`enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_incentive_rule` (`tenant_id`, `rule_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='积分和奖品激励规则';
INSERT IGNORE INTO `aihr_incentive_rule`
(`tenant_id`, `rule_code`, `display_name`, `account_type`, `rule_value`, `daily_cap`, `enabled`)
VALUES
('000000', 'BEST_ANSWER', '最佳回答积分', 'POINTS', 10, 100, 1);
CREATE TABLE IF NOT EXISTS `aihr_points_ledger` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`user_id` bigint NOT NULL COMMENT '积分用户ID',
`ext_party_id` varchar(80) DEFAULT NULL COMMENT '组织人员外部ID',
`business_type` varchar(40) NOT NULL COMMENT '业务类型',
`business_id` bigint NOT NULL COMMENT '业务记录ID',
`account_type` varchar(20) NOT NULL DEFAULT 'POINTS' COMMENT 'POINTS/STUDY_CREDIT',
`points` int NOT NULL COMMENT '账户数值变动',
`reason` varchar(200) NOT NULL COMMENT '变动原因',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
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 COMMENT='员工奖励账户流水';
-- 兼容已经执行过旧版脚本的环境:旧流水全部归入 POINTS,保持原积分余额不变。
SET @aihr_schema = DATABASE();
SET @account_type_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_points_ledger' AND column_name = 'account_type'
);
SET @account_type_ddl = IF(@account_type_exists = 0,
'ALTER TABLE aihr_points_ledger ADD COLUMN account_type varchar(20) NOT NULL DEFAULT ''POINTS'' COMMENT ''POINTS/STUDY_CREDIT'' AFTER business_id',
'SELECT 1');
PREPARE aihr_account_type_stmt FROM @account_type_ddl;
EXECUTE aihr_account_type_stmt;
DEALLOCATE PREPARE aihr_account_type_stmt;