feat: restore learning feedback and knowledge capabilities
This commit is contained in:
+76
@@ -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)));
|
||||
}
|
||||
}
|
||||
+87
@@ -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) {
|
||||
}
|
||||
}
|
||||
+43
@@ -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");
|
||||
};
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
+591
@@ -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) {
|
||||
}
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+205
@@ -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) {
|
||||
}
|
||||
}
|
||||
+57
@@ -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));
|
||||
}
|
||||
}
|
||||
+99
@@ -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));
|
||||
|
||||
+42
-5
@@ -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)
|
||||
|
||||
+100
-2
@@ -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,
|
||||
|
||||
+46
@@ -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));
|
||||
}
|
||||
}
|
||||
+112
@@ -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));
|
||||
}
|
||||
}
|
||||
+30
@@ -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));
|
||||
}
|
||||
}
|
||||
+45
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+62
@@ -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) {
|
||||
}
|
||||
}
|
||||
+88
@@ -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) {
|
||||
}
|
||||
}
|
||||
+47
@@ -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 "[]";
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -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();
|
||||
}
|
||||
}
|
||||
+490
@@ -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();
|
||||
}
|
||||
}
|
||||
+138
@@ -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) {
|
||||
}
|
||||
}
|
||||
+763
@@ -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) {
|
||||
}
|
||||
}
|
||||
+178
@@ -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) {
|
||||
}
|
||||
}
|
||||
+485
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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, "");
|
||||
}
|
||||
|
||||
+195
-14
@@ -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(
|
||||
|
||||
+633
-93
@@ -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());
|
||||
|
||||
+635
-78
File diff suppressed because it is too large
Load Diff
+192
-39
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+52
-13
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -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));
|
||||
}
|
||||
}
|
||||
+29
@@ -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) {
|
||||
}
|
||||
}
|
||||
+68
@@ -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));
|
||||
}
|
||||
}
|
||||
+137
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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:[已脱敏]");
|
||||
}
|
||||
}
|
||||
+195
@@ -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) {
|
||||
}
|
||||
}
|
||||
+84
@@ -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) {
|
||||
}
|
||||
}
|
||||
+233
@@ -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) {
|
||||
}
|
||||
}
|
||||
+79
@@ -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");
|
||||
}
|
||||
}
|
||||
+99
@@ -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");
|
||||
}
|
||||
}
|
||||
+79
@@ -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");
|
||||
}
|
||||
}
|
||||
+173
@@ -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));
|
||||
}
|
||||
}
|
||||
+95
@@ -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);
|
||||
}
|
||||
}
|
||||
+97
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -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));
|
||||
}
|
||||
}
|
||||
+177
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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;
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -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"));
|
||||
|
||||
+272
-1
@@ -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;
|
||||
|
||||
+194
-2
@@ -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);
|
||||
}
|
||||
|
||||
+81
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+185
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user