feat(aihr): close functional checklist gaps

This commit is contained in:
key
2026-08-02 12:15:45 +08:00
parent 699cc08050
commit 6ad8a017a4
80 changed files with 4930 additions and 430 deletions
@@ -4,6 +4,11 @@ spring.servlet.multipart.location: /ruoyi/server/temp
spring.servlet.multipart.max-file-size: 500MB
spring.servlet.multipart.max-request-size: 520MB
# RC-007: expose server request percentiles for the separately approved load model.
# Error rate remains queryable from the same metric by status/outcome tags.
management.metrics.distribution.percentiles-histogram.http.server.requests: true
management.metrics.distribution.percentiles.http.server.requests: 0.5,0.95,0.99
--- # 监控中心配置
spring.boot.admin.client:
# 增加客户端开关
@@ -4,6 +4,10 @@ import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentViewResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTopicTag;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTopicTagRequest;
import org.dromara.aihr.broadcast.AihrBroadcastDto.UpdateBroadcastTopicTagsRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
@@ -22,11 +26,13 @@ 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 org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@@ -42,9 +48,20 @@ import java.util.concurrent.atomic.AtomicBoolean;
public class AihrBroadcastAttachmentService {
private static final long MAX_FILE_BYTES = 100L * 1024 * 1024;
private static final int MAX_VIEW_CHARACTERS = 200_000;
private static final Set<String> SUPPORTED_SUFFIXES = Set.of(
"txt", "md", "markdown", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"
);
private static final List<TopicRule> TOPIC_RULES = List.of(
new TopicRule("保洁", List.of("保洁", "清洁", "消杀", "垃圾分类")),
new TopicRule("安保", List.of("安保", "保安", "秩序", "门岗", "巡逻")),
new TopicRule("收费", List.of("收费", "物业费", "催缴", "欠费")),
new TopicRule("工程维修", List.of("工程维修", "维修", "报修", "设施设备")),
new TopicRule("消防", List.of("消防", "灭火器", "消防通道")),
new TopicRule("客户服务", List.of("客户服务", "生活顾问", "管家", "投诉")),
new TopicRule("安全", List.of("安全生产", "安全隐患", "应急预案")),
new TopicRule("人力", List.of("人力资源", "员工培训", "考勤"))
);
private final JdbcTemplate jdbcTemplate;
private final ISysOssService ossService;
@@ -115,7 +132,7 @@ public class AihrBroadcastAttachmentService {
insight_status, insights_json
from aihr_broadcast_attachment
where tenant_id = ? and id = ?
""", (rs, rowNum) -> response(
""", (rs, rowNum) -> response(tenantId,
rs.getLong("id"), rs.getString("file_name"), rs.getLong("file_size"),
rs.getString("content_type"), rs.getString("status"), rs.getString("summary"),
rs.getString("error_message"), rs.getString("insight_status"), rs.getString("insights_json")
@@ -127,21 +144,117 @@ public class AihrBroadcastAttachmentService {
}
public Long authorizedDownloadOssId(Long rawAttachmentId) {
AttachmentAccess access = authorizedAccess(rawAttachmentId, true);
return access == null ? null : access.ossId();
}
public BroadcastAttachmentViewResponse authorizedView(Long rawAttachmentId) {
long attachmentId = attachmentId(rawAttachmentId);
AihrKnowledgePrincipal principal = principalResolver.current();
if (!UserType.APP_USER.getUserType().equals(principal.userType())) {
throw new ServiceException("仅在职员工可在线阅读公司文件", HttpStatus.FORBIDDEN);
}
List<AttachmentView> rows = jdbcTemplate.query("""
select a.message_id, a.file_name, a.extracted_text
from aihr_broadcast_attachment a
join aihr_broadcast_message m
on m.tenant_id = a.tenant_id and m.id = a.message_id and m.status = 'PUBLISHED'
where a.tenant_id = ? and a.id = ? and a.status = 'READY'
""", (rs, rowNum) -> new AttachmentView(
rs.getLong("message_id"), rs.getString("file_name"), rs.getString("extracted_text")
), principal.tenantId(), attachmentId);
if (rows.isEmpty()) {
throw new ServiceException("公司文件不存在或无权访问", HttpStatus.NOT_FOUND);
}
AttachmentView view = rows.get(0);
broadcastService.message(view.messageId());
jdbcTemplate.update("""
insert into aihr_broadcast_attachment_access_audit
(tenant_id, message_id, attachment_id, user_id, access_type, access_time)
values (?, ?, ?, ?, 'VIEW', now())
""", principal.tenantId(), view.messageId(), attachmentId, principal.userId());
String source = view.content() == null ? "" : view.content();
boolean truncated = source.length() > MAX_VIEW_CHARACTERS;
String content = truncated ? source.substring(0, MAX_VIEW_CHARACTERS) : source;
return new BroadcastAttachmentViewResponse(attachmentId, view.fileName(), content, truncated,
watermarkText(principal), topicTags(principal.tenantId(), attachmentId));
}
private AttachmentAccess authorizedAccess(Long rawAttachmentId, boolean download) {
long attachmentId = attachmentId(rawAttachmentId);
AihrKnowledgePrincipal principal = principalResolver.current();
if (UserType.APP_USER.getUserType().equals(principal.userType())) {
List<Long> messageIds = jdbcTemplate.query("""
select message_id from aihr_broadcast_attachment
where tenant_id = ? and id = ? and status = 'READY' and message_id is not null
""", (rs, rowNum) -> rs.getLong("message_id"), principal.tenantId(), attachmentId);
if (messageIds.isEmpty()) {
List<AttachmentAccess> rows = jdbcTemplate.query("""
select a.oss_id, a.message_id,
case when m.allow_download = 1 then 1 else 0 end as allow_download
from aihr_broadcast_attachment a
join aihr_broadcast_message m
on m.tenant_id = a.tenant_id and m.id = a.message_id and m.status = 'PUBLISHED'
where a.tenant_id = ? and a.id = ? and a.status = 'READY'
""", (rs, rowNum) -> new AttachmentAccess(
rs.getLong("oss_id"), rs.getLong("message_id"), rs.getBoolean("allow_download")
), principal.tenantId(), attachmentId);
if (rows.isEmpty()) {
return null;
}
broadcastService.message(messageIds.get(0));
return ossId(principal.tenantId(), attachmentId);
AttachmentAccess access = rows.get(0);
broadcastService.message(access.messageId());
if (download && !access.allowDownload()) {
throw new ServiceException("该公司文件仅允许在线阅读", HttpStatus.FORBIDDEN);
}
jdbcTemplate.update("""
insert into aihr_broadcast_attachment_access_audit
(tenant_id, message_id, attachment_id, user_id, access_type, access_time)
values (?, ?, ?, ?, ?, now())
""", principal.tenantId(), access.messageId(), attachmentId, principal.userId(),
download ? "DOWNLOAD" : "VIEW");
return access;
}
requireBroadcastAdmin(principal);
return ossId(adminTenant(principal), attachmentId);
Long ossId = ossId(adminTenant(principal), attachmentId);
return ossId == null ? null : new AttachmentAccess(ossId, 0, true);
}
@Transactional(rollbackFor = Exception.class)
public BroadcastAttachmentResponse updateTopicTags(Long rawAttachmentId, UpdateBroadcastTopicTagsRequest request) {
AihrKnowledgePrincipal principal = principalResolver.current();
String tenantId = adminTenant(principal);
requireExpectedTenant(request == null ? null : request.expectedTenantId(), tenantId);
long attachmentId = attachmentId(rawAttachmentId);
List<String> sourceRows = jdbcTemplate.query("""
select extracted_text from aihr_broadcast_attachment
where tenant_id = ? and id = ? and uploaded_by = ?
and status = 'READY' and message_id is null
for update
""", (rs, rowNum) -> rs.getString("extracted_text"), tenantId, attachmentId, principal.userId());
if (sourceRows.isEmpty()) {
throw new ServiceException("公司文件不存在、已发布或无权修正标签", HttpStatus.CONFLICT);
}
List<BroadcastTopicTagRequest> tags = request == null ? List.of() : request.topicTags();
String source = sourceRows.get(0) == null ? "" : sourceRows.get(0);
Set<String> names = new java.util.LinkedHashSet<>();
List<BroadcastTopicTagRequest> normalizedTags = new ArrayList<>();
for (BroadcastTopicTagRequest rawTag : tags) {
String name = clean(rawTag == null ? null : rawTag.name(), 30, "主题标签");
String evidence = clean(rawTag == null ? null : rawTag.evidenceQuote(), 200, "主题标签原文依据");
if (!source.contains(evidence)) {
throw new ServiceException("主题标签“" + name + "”的依据不是文件原文", HttpStatus.BAD_REQUEST);
}
if (!names.add(name)) {
throw new ServiceException("主题标签不能重复", HttpStatus.BAD_REQUEST);
}
normalizedTags.add(new BroadcastTopicTagRequest(name, evidence));
}
jdbcTemplate.update("delete from aihr_broadcast_attachment_topic_tag where tenant_id = ? and attachment_id = ?",
tenantId, attachmentId);
for (BroadcastTopicTagRequest tag : normalizedTags) {
jdbcTemplate.update("""
insert into aihr_broadcast_attachment_topic_tag
(tenant_id, attachment_id, tag_name, source_type, evidence_quote, created_by, create_time, update_time)
values (?, ?, ?, 'MANUAL', ?, ?, now(), now())
""", tenantId, attachmentId, tag.name(), tag.evidenceQuote(), principal.userId());
}
return adminAttachment(attachmentId);
}
private Long ossId(String tenantId, long attachmentId) {
@@ -279,6 +392,8 @@ public class AihrBroadcastAttachmentService {
""", text, summary, work.tenantId(), work.id());
if (updated != 1) {
log.info("broadcast attachment {} completed after its state changed", work.id());
} else {
persistAutoTopicTags(work.tenantId(), work.id(), text);
}
} catch (Exception error) {
log.warn("broadcast attachment {} processing failed", work.id());
@@ -334,6 +449,7 @@ public class AihrBroadcastAttachmentService {
}
private BroadcastAttachmentResponse response(
String tenantId,
long id,
String fileName,
long fileSize,
@@ -349,11 +465,67 @@ public class AihrBroadcastAttachmentService {
List<String> labels = AihrBroadcastInsightService.readPerspectives(insightsJson).stream()
.map(AihrBroadcastDto.BroadcastPerspective::label)
.toList();
String contentUrl = "READY".equals(status) ? "/api/aihr/broadcast/attachments/" + id + "/content" : null;
return new BroadcastAttachmentResponse(id, fileName, fileSize, contentType, status, summary,
errorMessage, "READY".equals(status) ? "/api/aihr/broadcast/attachments/" + id + "/content" : null,
errorMessage, contentUrl, contentUrl, true, null, topicTags(tenantId, id),
insightStatus, null, labels, List.of());
}
private List<BroadcastTopicTag> topicTags(String tenantId, long attachmentId) {
return jdbcTemplate.query("""
select tag_name, source_type, evidence_quote
from aihr_broadcast_attachment_topic_tag
where tenant_id = ? and attachment_id = ?
order by source_type desc, tag_name
""", (rs, rowNum) -> new BroadcastTopicTag(
rs.getString("tag_name"), rs.getString("source_type"), rs.getString("evidence_quote")
), tenantId, attachmentId);
}
private void persistAutoTopicTags(String tenantId, long attachmentId, String source) {
Long manualCount = jdbcTemplate.queryForObject("""
select count(*) from aihr_broadcast_attachment_topic_tag
where tenant_id = ? and attachment_id = ? and source_type = 'MANUAL'
""", Long.class, tenantId, attachmentId);
if (manualCount != null && manualCount > 0) {
return;
}
jdbcTemplate.update("delete from aihr_broadcast_attachment_topic_tag where tenant_id = ? and attachment_id = ?",
tenantId, attachmentId);
for (BroadcastTopicTag tag : autoTopicTags(source)) {
jdbcTemplate.update("""
insert into aihr_broadcast_attachment_topic_tag
(tenant_id, attachment_id, tag_name, source_type, evidence_quote, created_by, create_time, update_time)
values (?, ?, ?, 'AUTO', ?, null, now(), now())
""", tenantId, attachmentId, tag.name(), tag.evidenceQuote());
}
}
static List<BroadcastTopicTag> autoTopicTags(String rawSource) {
String source = rawSource == null ? "" : rawSource.replace("\r\n", "\n").replace('\r', '\n').trim();
if (source.isEmpty()) return List.of();
List<BroadcastTopicTag> tags = new ArrayList<>();
for (TopicRule rule : TOPIC_RULES) {
int match = -1;
for (String keyword : rule.keywords()) {
int candidate = source.indexOf(keyword);
if (candidate >= 0 && (match < 0 || candidate < match)) match = candidate;
}
if (match < 0) continue;
int start = Math.max(0, source.lastIndexOf('\n', match) + 1);
int lineEnd = source.indexOf('\n', match);
int end = lineEnd < 0 ? source.length() : lineEnd;
String evidence = source.substring(start, end).trim();
if (evidence.length() > 200) {
int excerptStart = Math.max(start, match - 60);
int excerptEnd = Math.min(source.length(), excerptStart + 200);
evidence = source.substring(excerptStart, excerptEnd).trim();
}
if (!evidence.isEmpty()) tags.add(new BroadcastTopicTag(rule.name(), "AUTO", evidence));
}
return List.copyOf(tags);
}
private static void requireBroadcastAdmin(AihrKnowledgePrincipal principal) {
if (UserType.APP_USER.getUserType().equals(principal.userType())
|| (!principal.roles().contains(TenantConstants.SUPER_ADMIN_ROLE_KEY)
@@ -395,6 +567,32 @@ public class AihrBroadcastAttachmentService {
return normalized.isEmpty() ? "application/octet-stream" : normalized.substring(0, Math.min(100, normalized.length()));
}
private static String clean(String value, int maximumLength, String label) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
throw new ServiceException(label + "不能为空", HttpStatus.BAD_REQUEST);
}
if (normalized.length() > maximumLength) {
throw new ServiceException(label + "不能超过 " + maximumLength + " 个字符", HttpStatus.BAD_REQUEST);
}
return normalized;
}
private static String watermarkText(AihrKnowledgePrincipal principal) {
return "帮道内部资料 · 用户#" + principal.userId() + " · "
+ java.time.LocalDateTime.now().withSecond(0).withNano(0)
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
}
private record TopicRule(String name, List<String> keywords) {
}
private record AttachmentAccess(long ossId, long messageId, boolean allowDownload) {
}
private record AttachmentView(long messageId, String fileName, String content) {
}
private record AttachmentWork(
long id,
String tenantId,
@@ -11,15 +11,18 @@ import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastDetailResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOptions;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentViewResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest;
import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.UnreadCountResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.UpdateBroadcastTopicTagsRequest;
import org.dromara.aihr.broadcast.AihrBroadcastDto.WithdrawRequest;
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.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.RequestParam;
@@ -88,6 +91,15 @@ public class AihrBroadcastController {
return R.ok(attachmentService.adminAttachment(id));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PutMapping("/admin/attachments/{id}/topic-tags")
public R<BroadcastAttachmentResponse> updateAttachmentTopicTags(
@PathVariable Long id,
@Valid @RequestBody UpdateBroadcastTopicTagsRequest request
) {
return R.ok(attachmentService.updateTopicTags(id, request));
}
@GetMapping("/messages/{id}")
public R<BroadcastDetailResponse> message(@PathVariable Long id) {
return R.ok(broadcastService.message(id));
@@ -109,6 +121,11 @@ public class AihrBroadcastController {
ossService.download(ossId, response);
}
@GetMapping("/attachments/{id}/view")
public R<BroadcastAttachmentViewResponse> attachmentView(@PathVariable Long id) {
return R.ok(attachmentService.authorizedView(id));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/messages")
public R<PublishResponse> publish(@Valid @RequestBody PublishRequest request) {
@@ -30,10 +30,11 @@ public final class AihrBroadcastDto {
String publishedAt,
boolean requiredRead,
boolean targeted,
String targetReason
String targetReason,
String visibilityMode
) {
public BroadcastListItem(Long id, String title, boolean read, String publishedAt) {
this(id, title, read, publishedAt, false, false, null);
this(id, title, read, publishedAt, false, false, null, "ALL");
}
}
@@ -46,10 +47,31 @@ public final class AihrBroadcastDto {
boolean requiredRead,
boolean targeted,
String targetReason,
String visibilityMode,
BroadcastAttachmentResponse attachment
) {
public BroadcastDetailResponse(Long id, String title, String content, boolean read, String publishedAt) {
this(id, title, content, read, publishedAt, false, false, null, null);
this(id, title, content, read, publishedAt, false, false, null, "ALL", null);
}
}
public record BroadcastTopicTag(
String name,
String sourceType,
String evidenceQuote
) {
}
public record BroadcastAttachmentViewResponse(
Long id,
String fileName,
String content,
boolean truncated,
String watermarkText,
List<BroadcastTopicTag> topicTags
) {
public BroadcastAttachmentViewResponse {
topicTags = topicTags == null ? List.of() : List.copyOf(topicTags);
}
}
@@ -61,13 +83,18 @@ public final class AihrBroadcastDto {
String status,
String summary,
String errorMessage,
String viewUrl,
String downloadUrl,
boolean allowDownload,
String watermarkText,
List<BroadcastTopicTag> topicTags,
String insightStatus,
String defaultPerspectiveCode,
List<String> perspectiveLabels,
List<BroadcastPerspective> perspectives
) {
public BroadcastAttachmentResponse {
topicTags = topicTags == null ? List.of() : List.copyOf(topicTags);
perspectiveLabels = perspectiveLabels == null ? List.of() : List.copyOf(perspectiveLabels);
perspectives = perspectives == null ? List.of() : List.copyOf(perspectives);
}
@@ -83,7 +110,7 @@ public final class AihrBroadcastDto {
String downloadUrl
) {
this(id, fileName, fileSize, contentType, status, summary, errorMessage, downloadUrl,
"NOT_PROCESSED", null, List.of(), List.of());
downloadUrl, true, null, List.of(), "NOT_PROCESSED", null, List.of(), List.of());
}
}
@@ -130,6 +157,8 @@ public final class AihrBroadcastDto {
String withdrawnAt,
String withdrawReason,
boolean requiredRead,
String visibilityMode,
boolean allowDownload,
long targetRuleCount,
long matchedRecipientCount,
long unmatchedTargetCount
@@ -147,7 +176,7 @@ public final class AihrBroadcastDto {
String withdrawReason
) {
this(id, title, content, status, currentVersion, publishedBy, publishedAt,
withdrawnBy, withdrawnAt, withdrawReason, false, 0, 0, 0);
withdrawnBy, withdrawnAt, withdrawReason, false, "ALL", true, 0, 0, 0);
}
}
@@ -195,11 +224,15 @@ public final class AihrBroadcastDto {
@Size(max = 64, message = "当前租户不能超过 64 个字符")
String expectedTenantId,
Boolean requiredRead,
@Pattern(regexp = "ALL|TARGET_ONLY", message = "发布模式只支持 ALL 或 TARGET_ONLY")
String visibilityMode,
Boolean allowDownload,
@Valid BroadcastTargetRequest targets,
Long attachmentId
) {
public PublishRequest(String requestId, String title, String content, String expectedTenantId) {
this(requestId, title, content, expectedTenantId, false, BroadcastTargetRequest.empty(), null);
this(requestId, title, content, expectedTenantId, false, "ALL", true,
BroadcastTargetRequest.empty(), null);
}
public PublishRequest(
@@ -210,7 +243,43 @@ public final class AihrBroadcastDto {
Boolean requiredRead,
BroadcastTargetRequest targets
) {
this(requestId, title, content, expectedTenantId, requiredRead, targets, null);
this(requestId, title, content, expectedTenantId, requiredRead, "ALL", true,
targets, null);
}
public PublishRequest(
String requestId,
String title,
String content,
String expectedTenantId,
Boolean requiredRead,
BroadcastTargetRequest targets,
Long attachmentId
) {
this(requestId, title, content, expectedTenantId, requiredRead, "ALL", true,
targets, attachmentId);
}
}
public record BroadcastTopicTagRequest(
@NotBlank(message = "主题标签不能为空")
@Size(max = 30, message = "主题标签不能超过 30 个字符")
String name,
@NotBlank(message = "主题标签必须提供原文依据")
@Size(max = 200, message = "主题标签原文依据不能超过 200 个字符")
String evidenceQuote
) {
}
public record UpdateBroadcastTopicTagsRequest(
@NotBlank(message = "当前租户不能为空")
@Size(max = 64, message = "当前租户不能超过 64 个字符")
String expectedTenantId,
@Size(max = 8, message = "主题标签不能超过 8 个")
List<@Valid BroadcastTopicTagRequest> topicTags
) {
public UpdateBroadcastTopicTagsRequest {
topicTags = topicTags == null ? List.of() : List.copyOf(topicTags);
}
}
@@ -10,6 +10,7 @@ import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOption;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOptions;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetRequest;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTopicTag;
import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest;
import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.UnreadCountResponse;
@@ -32,6 +33,7 @@ import java.security.NoSuchAlgorithmException;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
@@ -62,13 +64,20 @@ public class AihrBroadcastService {
from aihr_broadcast_message m
where m.tenant_id = ?
and m.status = 'PUBLISHED'
and (m.visibility_mode = 'ALL' or exists (
select 1 from aihr_broadcast_target_recipient visible_recipient
where visible_recipient.tenant_id = m.tenant_id
and visible_recipient.message_id = m.id
and visible_recipient.user_id = ?
and visible_recipient.target_status = 'MATCHED'
))
and not exists (
select 1 from aihr_broadcast_read r
where r.tenant_id = m.tenant_id
and r.message_id = m.id
and r.user_id = ?
)
""", Long.class, principal.tenantId(), principal.userId());
""", Long.class, principal.tenantId(), principal.userId(), principal.userId());
return new UnreadCountResponse(unreadCount == null ? 0 : unreadCount);
}
@@ -92,7 +101,7 @@ public class AihrBroadcastService {
case when r.id is null then 0 else 1 end as read_flag,
case when m.required_read = 1 then 1 else 0 end as required_read,
case when tr.user_id is null then 0 else 1 end as targeted_flag,
tr.target_reason
tr.target_reason, m.visibility_mode
""" + employeeMessages.fromWhere() + """
order by case
when m.required_read = 1
@@ -110,7 +119,7 @@ public class AihrBroadcastService {
""", (rs, rowNum) -> new BroadcastListItem(
rs.getLong("id"), rs.getString("title"), rs.getBoolean("read_flag"),
format(rs.getTimestamp("published_time")), rs.getBoolean("required_read"),
rs.getBoolean("targeted_flag"), rs.getString("target_reason")
rs.getBoolean("targeted_flag"), rs.getString("target_reason"), rs.getString("visibility_mode")
), parameters.toArray());
return new BroadcastListResponse(total == null ? 0 : total, page.pageNum(), page.pageSize(), rows);
}
@@ -136,6 +145,7 @@ public class AihrBroadcastService {
select m.id, m.title, m.content, m.status, 1 as current_version,
m.published_by, m.published_time, m.withdrawn_by, m.withdrawn_time, m.withdraw_reason,
case when m.required_read = 1 then 1 else 0 end as required_read,
m.visibility_mode, case when m.allow_download = 1 then 1 else 0 end as allow_download,
(select count(*) from aihr_broadcast_target_rule target_rule
where target_rule.tenant_id = m.tenant_id and target_rule.message_id = m.id) as target_rule_count,
(select count(distinct recipient.user_id) from aihr_broadcast_target_recipient recipient
@@ -152,7 +162,8 @@ public class AihrBroadcastService {
rs.getLong("id"), rs.getString("title"), rs.getString("content"), rs.getString("status"),
rs.getInt("current_version"), rs.getLong("published_by"), format(rs.getTimestamp("published_time")),
rs.getObject("withdrawn_by", Long.class), format(rs.getTimestamp("withdrawn_time")),
rs.getString("withdraw_reason"), rs.getBoolean("required_read"), rs.getLong("target_rule_count"),
rs.getString("withdraw_reason"), rs.getBoolean("required_read"),
rs.getString("visibility_mode"), rs.getBoolean("allow_download"), rs.getLong("target_rule_count"),
rs.getLong("matched_recipient_count"), rs.getLong("unmatched_target_count")
), parameters.toArray());
return new AdminBroadcastListResponse(tenantId, total == null ? 0 : total, page.pageNum(), page.pageSize(), rows);
@@ -206,11 +217,12 @@ public class AihrBroadcastService {
String defaultPerspectiveCode = AihrBroadcastInsightService.defaultPerspectiveCode(
currentPositionNames(principal));
List<BroadcastDetailResponse> rows = jdbcTemplate.query("""
select m.id, m.title, m.content, m.published_time,
select m.id, m.tenant_id, m.title, m.content, m.published_time,
case when r.id is null then 0 else 1 end as read_flag,
case when m.required_read = 1 then 1 else 0 end as required_read,
case when tr.user_id is null then 0 else 1 end as targeted_flag,
tr.target_reason,
tr.target_reason, m.visibility_mode,
case when m.allow_download = 1 then 1 else 0 end as allow_download,
a.id as attachment_id, a.file_name as attachment_file_name,
a.file_size as attachment_file_size, a.content_type as attachment_content_type,
a.status as attachment_status, a.summary as attachment_summary,
@@ -231,12 +243,14 @@ public class AihrBroadcastService {
left join aihr_broadcast_attachment a
on a.tenant_id = m.tenant_id and a.id = m.attachment_id and a.status = 'READY'
where m.tenant_id = ? and m.id = ? and m.status = 'PUBLISHED'
and (m.visibility_mode = 'ALL' or tr.user_id is not null)
limit 1
""", (rs, rowNum) -> new BroadcastDetailResponse(
rs.getLong("id"), rs.getString("title"), rs.getString("content"), rs.getBoolean("read_flag"),
format(rs.getTimestamp("published_time")), rs.getBoolean("required_read"),
rs.getBoolean("targeted_flag"), rs.getString("target_reason"),
attachment(rs, defaultPerspectiveCode)
rs.getString("visibility_mode"), attachment(rs, defaultPerspectiveCode,
rs.getBoolean("allow_download"), watermarkText(principal))
), principal.userId(), principal.tenantId(), principal.userId(), principal.tenantId(), messageId);
if (rows.isEmpty()) {
throw unavailable();
@@ -261,13 +275,22 @@ public class AihrBroadcastService {
from aihr_broadcast_message m
left join aihr_broadcast_attachment a
on a.tenant_id = m.tenant_id and a.id = m.attachment_id and a.status = 'READY'
left join (
select recipient.tenant_id, recipient.message_id, recipient.user_id
from aihr_broadcast_target_recipient recipient
where recipient.tenant_id = ? and recipient.user_id = ?
and recipient.target_status = 'MATCHED'
group by recipient.tenant_id, recipient.message_id, recipient.user_id
) visible_recipient on visible_recipient.tenant_id = m.tenant_id
and visible_recipient.message_id = m.id
where m.tenant_id = ? and m.id = ? and m.status = 'PUBLISHED'
and (m.visibility_mode = 'ALL' or visible_recipient.user_id is not null)
limit 1
""", (rs, rowNum) -> new BroadcastQuestionContext(
rs.getLong("id"), rs.getString("title"), questionContent(
rs.getString("content"), rs.getString("extracted_text")),
format(rs.getTimestamp("published_time"))
), principal.tenantId(), messageId);
), principal.tenantId(), principal.userId(), principal.tenantId(), messageId);
if (rows.isEmpty()) {
throw unavailable();
}
@@ -282,8 +305,14 @@ public class AihrBroadcastService {
List<Long> messages = jdbcTemplate.query("""
select id from aihr_broadcast_message
where tenant_id = ? and id = ? and status = 'PUBLISHED'
and (visibility_mode = 'ALL' or exists (
select 1 from aihr_broadcast_target_recipient recipient
where recipient.tenant_id = aihr_broadcast_message.tenant_id
and recipient.message_id = aihr_broadcast_message.id
and recipient.user_id = ? and recipient.target_status = 'MATCHED'
))
for update
""", (rs, rowNum) -> rs.getLong("id"), principal.tenantId(), messageId);
""", (rs, rowNum) -> rs.getLong("id"), principal.tenantId(), messageId, principal.userId());
if (messages.isEmpty()) {
throw unavailable();
}
@@ -304,13 +333,20 @@ public class AihrBroadcastService {
String title = required(request == null ? null : request.title(), "消息标题", 200);
String content = required(request == null ? null : request.content(), "消息正文", 10_000);
boolean requiredRead = request != null && Boolean.TRUE.equals(request.requiredRead());
String visibilityMode = visibilityMode(request == null ? null : request.visibilityMode());
boolean allowDownload = request == null || request.allowDownload() == null || Boolean.TRUE.equals(request.allowDownload());
TargetSelection targetSelection = targetSelection(request == null ? null : request.targets());
if ("TARGET_ONLY".equals(visibilityMode) && !targetSelection.hasTargets()) {
throw new ServiceException("仅目标可见模式必须选择至少一个定向范围", HttpStatus.BAD_REQUEST);
}
Long attachmentId = optionalAttachmentId(request == null ? null : request.attachmentId());
String requestHash = requestHash(title, content);
String targetPayloadHash = targetPayloadHash(requiredRead, targetSelection, attachmentId);
String targetPayloadHash = targetPayloadHash(requiredRead, visibilityMode, allowDownload, targetSelection, attachmentId);
String legacyTargetPayloadHash = targetPayloadHashLegacy(requiredRead, targetSelection, attachmentId);
PublishReplay existing = findPublishReplay(tenantId, principal.userId(), requestKey);
if (existing != null) {
return replayOrConflict(existing, title, content, requiredRead, targetSelection, targetPayloadHash);
return replayOrConflict(existing, title, content, requiredRead, targetSelection,
targetPayloadHash, legacyTargetPayloadHash, "ALL".equals(visibilityMode) && allowDownload);
}
requirePublishableAttachment(tenantId, principal.userId(), attachmentId);
KeyHolder keyHolder = new GeneratedKeyHolder();
@@ -318,9 +354,9 @@ public class AihrBroadcastService {
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_broadcast_message
(tenant_id, title, content, attachment_id, required_read, status, published_by, publish_request_key, publish_request_hash,
(tenant_id, title, content, attachment_id, required_read, visibility_mode, allow_download, status, published_by, publish_request_key, publish_request_hash,
target_payload_hash, published_time, create_time, update_time)
values (?, ?, ?, ?, ?, 'PUBLISHED', ?, ?, ?, ?, now(), now(), now())
values (?, ?, ?, ?, ?, ?, ?, 'PUBLISHED', ?, ?, ?, ?, now(), now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, tenantId);
statement.setString(2, title);
@@ -328,10 +364,12 @@ public class AihrBroadcastService {
if (attachmentId == null) statement.setNull(4, java.sql.Types.BIGINT);
else statement.setLong(4, attachmentId);
statement.setBoolean(5, requiredRead);
statement.setLong(6, principal.userId());
statement.setString(7, requestKey);
statement.setString(8, requestHash);
statement.setString(9, targetPayloadHash);
statement.setString(6, visibilityMode);
statement.setBoolean(7, allowDownload);
statement.setLong(8, principal.userId());
statement.setString(9, requestKey);
statement.setString(10, requestHash);
statement.setString(11, targetPayloadHash);
return statement;
}, keyHolder);
} catch (DuplicateKeyException duplicate) {
@@ -340,7 +378,8 @@ public class AihrBroadcastService {
// concurrent request that just won the unique-key race.
PublishReplay raced = findPublishReplayForUpdate(tenantId, principal.userId(), requestKey);
if (raced != null) {
return replayOrConflict(raced, title, content, requiredRead, targetSelection, targetPayloadHash);
return replayOrConflict(raced, title, content, requiredRead, targetSelection,
targetPayloadHash, legacyTargetPayloadHash, "ALL".equals(visibilityMode) && allowDownload);
}
throw duplicate;
}
@@ -430,6 +469,7 @@ public class AihrBroadcastService {
group by recipient.tenant_id, recipient.message_id, recipient.user_id
) tr on tr.tenant_id = m.tenant_id and tr.message_id = m.id
where m.tenant_id = ? and m.status = 'PUBLISHED'
and (m.visibility_mode = 'ALL' or tr.user_id is not null)
""" + scopeFilter, parameters);
}
@@ -492,7 +532,9 @@ public class AihrBroadcastService {
String content,
boolean requiredRead,
TargetSelection targetSelection,
String targetPayloadHash
String targetPayloadHash,
String legacyTargetPayloadHash,
boolean allowLegacyContract
) {
// The target payload has an independent length-delimited digest, so a retry cannot
// reuse a request ID while silently changing mandatory-read or target dimensions.
@@ -500,7 +542,9 @@ public class AihrBroadcastService {
boolean legacyM0TargetPayload = existing.targetPayloadHash() == null
&& !requiredRead && !targetSelection.hasTargets();
if (!title.equals(existing.title()) || !content.equals(existing.content())
|| (!legacyM0TargetPayload && !targetPayloadHash.equals(existing.targetPayloadHash()))) {
|| (!legacyM0TargetPayload
&& !targetPayloadHash.equals(existing.targetPayloadHash())
&& !(allowLegacyContract && legacyTargetPayloadHash.equals(existing.targetPayloadHash())))) {
throw new ServiceException("该请求ID已用于不同的公司消息", 409);
}
return new PublishResponse(existing.id(), 1,
@@ -620,10 +664,18 @@ public class AihrBroadcastService {
return List.copyOf(sorted);
}
private static String targetPayloadHash(boolean requiredRead, TargetSelection selection, Long attachmentId) {
private static String targetPayloadHash(
boolean requiredRead,
String visibilityMode,
boolean allowDownload,
TargetSelection selection,
Long attachmentId
) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
updateDigestField(digest, requiredRead ? "1" : "0");
updateDigestField(digest, visibilityMode);
updateDigestField(digest, allowDownload ? "1" : "0");
updateDigestField(digest, "PROJECT");
selection.projectCodes().forEach(value -> updateDigestField(digest, value));
updateDigestField(digest, "POSITION");
@@ -774,9 +826,11 @@ public class AihrBroadcastService {
}
}
private static BroadcastAttachmentResponse attachment(
private BroadcastAttachmentResponse attachment(
java.sql.ResultSet rs,
String requestedDefaultPerspectiveCode
String requestedDefaultPerspectiveCode,
boolean allowDownload,
String watermarkText
) throws java.sql.SQLException {
Long id = rs.getObject("attachment_id", Long.class);
if (id == null) return null;
@@ -792,7 +846,9 @@ public class AihrBroadcastService {
id, rs.getString("attachment_file_name"), rs.getLong("attachment_file_size"),
rs.getString("attachment_content_type"), rs.getString("attachment_status"),
rs.getString("attachment_summary"), rs.getString("attachment_error_message"),
"/api/aihr/broadcast/attachments/" + id + "/content",
"/api/aihr/broadcast/attachments/" + id + "/view",
allowDownload ? "/api/aihr/broadcast/attachments/" + id + "/content" : null,
allowDownload, watermarkText, topicTags(rs.getString("tenant_id"), id),
insightStatus,
defaultPerspectiveCode,
perspectives.stream().map(AihrBroadcastDto.BroadcastPerspective::label).toList(),
@@ -800,6 +856,41 @@ public class AihrBroadcastService {
);
}
private static String targetPayloadHashLegacy(
boolean requiredRead,
TargetSelection selection,
Long attachmentId
) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
updateDigestField(digest, requiredRead ? "1" : "0");
updateDigestField(digest, "PROJECT");
selection.projectCodes().forEach(value -> updateDigestField(digest, value));
updateDigestField(digest, "POSITION");
selection.positionNames().forEach(value -> updateDigestField(digest, value));
updateDigestField(digest, "LEVEL");
selection.positionLevels().forEach(value -> updateDigestField(digest, value));
if (attachmentId != null) {
updateDigestField(digest, "ATTACHMENT");
updateDigestField(digest, attachmentId.toString());
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
}
private List<BroadcastTopicTag> topicTags(String tenantId, long attachmentId) {
return jdbcTemplate.query("""
select tag_name, source_type, evidence_quote
from aihr_broadcast_attachment_topic_tag
where tenant_id = ? and attachment_id = ?
order by source_type desc, tag_name
""", (rs, rowNum) -> new BroadcastTopicTag(
rs.getString("tag_name"), rs.getString("source_type"), rs.getString("evidence_quote")
), tenantId, attachmentId);
}
private static String questionContent(String messageContent, String attachmentText) {
if (attachmentText == null || attachmentText.isBlank()) {
return messageContent;
@@ -818,6 +909,22 @@ public class AihrBroadcastService {
throw new ServiceException("status 只支持 PUBLISHED、WITHDRAWN 或 all", HttpStatus.BAD_REQUEST);
}
private static String visibilityMode(String rawMode) {
String mode = rawMode == null ? "ALL" : rawMode.trim().toUpperCase(Locale.ROOT);
if (mode.isEmpty() || "ALL".equals(mode)) {
return "ALL";
}
if ("TARGET_ONLY".equals(mode)) {
return mode;
}
throw new ServiceException("发布模式只支持 ALL 或 TARGET_ONLY", HttpStatus.BAD_REQUEST);
}
private static String watermarkText(AihrKnowledgePrincipal principal) {
return "帮道内部资料 · 用户#" + principal.userId() + " · "
+ LocalDateTime.now().withSecond(0).withNano(0).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
}
private static String requestKey(String value) {
String normalized = required(value, "请求ID", 100);
if (!REQUEST_ID_PATTERN.matcher(normalized).matches()) {
@@ -3,6 +3,7 @@ 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.AnswerFeedbackQuestionRequest;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerRequest;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest;
@@ -42,6 +43,12 @@ public class AihrCommunityController {
return R.ok(communityService.createQuestion(request, false));
}
@PostMapping("/questions/from-answer-feedback")
public R<QuestionResponse> createQuestionFromAnswerFeedback(
@RequestBody AnswerFeedbackQuestionRequest request) {
return R.ok(communityService.createQuestionFromAnswerFeedback(request));
}
@GetMapping("/questions/{questionId}")
public R<QuestionResponse> question(@PathVariable Long questionId) {
return R.ok(communityService.publicQuestion(questionId));
@@ -15,6 +15,9 @@ public final class AihrCommunityDto {
String ruleCode) {
}
public record AnswerFeedbackQuestionRequest(String requestId, String idempotencyKey) {
}
public record CreateAnswerRequest(String content) {
}
@@ -2,6 +2,7 @@ package org.dromara.aihr.community;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.AnswerFeedbackQuestionRequest;
import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse;
import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest;
import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest;
@@ -17,6 +18,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -137,6 +139,57 @@ public class AihrCommunityService {
return response(principal, findQuestion(principal, key == null ? 0L : key.longValue()), adminBounty);
}
@Transactional(rollbackFor = Exception.class)
public QuestionResponse createQuestionFromAnswerFeedback(AnswerFeedbackQuestionRequest request) {
AihrKnowledgePrincipal principal = principalResolver.current();
ensureTables();
String requestId = requiredToken(request == null ? null : request.requestId(), "知识查询请求ID", 64);
String idempotencyKey = requiredToken(
request == null ? null : request.idempotencyKey(), "提交幂等键", 80);
String knowledgeRequestId = resolveOwnedKnowledgeRequestId(principal, requestId);
LinkedQuestion idempotent = linkedQuestionByIdempotencyKey(principal, idempotencyKey);
if (idempotent != null) {
if (!knowledgeRequestId.equals(idempotent.sourceRequestId())) {
throw new ServiceException("提交幂等键已用于其他问题", 409);
}
return response(principal, findQuestion(principal, idempotent.questionId()), false);
}
LinkedQuestion existing = linkedQuestionBySource(principal, knowledgeRequestId);
if (existing != null) {
return response(principal, findQuestion(principal, existing.questionId()), false);
}
FeedbackSource source = ownedNegativeFeedback(principal, knowledgeRequestId);
String content = required(source.queryText(), "问题内容", 4000);
String title = truncate(content.replaceAll("\\s+", " "), 80);
String category = normalizeCategory(source.category());
AihrIncentiveService.IncentiveRuleResponse rule = incentiveService.activeRule(principal, "BEST_ANSWER");
int rewardPoints = AihrCommunityPolicy.rewardPoints(rule == null ? 0 : rule.value(), properties);
try {
jdbcTemplate.update("""
insert into aihr_community_question
(tenant_id, author_user_id, author_ext_party_id, author_name, title, content,
question_type, source_type, source_request_id, submit_idempotency_key, category,
reward_rule_code, reward_points, question_status, create_time, update_time)
values (?, ?, ?, ?, ?, ?, 'OPEN', 'ANSWER_FEEDBACK', ?, ?, ?,
'BEST_ANSWER', ?, 'PENDING', now(), now())
""", principal.tenantId(), principal.userId(), clean(principal.extPartyId()),
displayName(principal), title, content, knowledgeRequestId, idempotencyKey, category, rewardPoints);
} catch (DuplicateKeyException ignored) {
LinkedQuestion replay = linkedQuestionBySource(principal, knowledgeRequestId);
if (replay == null) {
throw new ServiceException("提交幂等键已用于其他问题", 409);
}
}
LinkedQuestion created = linkedQuestionBySource(principal, knowledgeRequestId);
if (created == null) {
throw new IllegalStateException("问题提交失败,请稍后重试");
}
return response(principal, findQuestion(principal, created.questionId()), false);
}
public AnswerResponse createAnswer(Long questionId, CreateAnswerRequest request) {
AihrKnowledgePrincipal principal = principalResolver.current();
ensureTables();
@@ -351,6 +404,69 @@ public class AihrCommunityService {
return rows.get(0);
}
private String resolveOwnedKnowledgeRequestId(AihrKnowledgePrincipal principal, String requestId) {
if (!requestId.startsWith("agent_run_")) {
return requireOwnedKnowledgeQuery(principal, requestId);
}
List<String> resultRefs = jdbcTemplate.query("""
select result_ref from aihr_agent_run
where tenant_id = ? and user_id = ? and run_id = ?
order by id desc limit 1
""", (rs, rowNum) -> rs.getString("result_ref"),
principal.tenantId(), principal.userId(), requestId);
if (resultRefs.isEmpty() || resultRefs.get(0) == null
|| !resultRefs.get(0).startsWith("KNOWLEDGE:")) {
throw new ServiceException("未找到本人本次知识查询", 404);
}
return requireOwnedKnowledgeQuery(
principal, requiredToken(resultRefs.get(0).substring("KNOWLEDGE:".length()), "知识查询请求ID", 64));
}
private String requireOwnedKnowledgeQuery(AihrKnowledgePrincipal principal, String requestId) {
Integer count = jdbcTemplate.queryForObject("""
select count(*) from aihr_knowledge_query_log
where tenant_id = ? and user_id = ? and request_id = ?
""", Integer.class, principal.tenantId(), principal.userId(), requestId);
if (count == null || count == 0) {
throw new ServiceException("未找到本人本次知识查询", 404);
}
return requestId;
}
private FeedbackSource ownedNegativeFeedback(AihrKnowledgePrincipal principal, String requestId) {
List<FeedbackSource> rows = jdbcTemplate.query("""
select f.query_text, f.category
from aihr_knowledge_answer_feedback f
where f.tenant_id = ? and f.submitter_user_id = ? and f.request_id = ? and f.verdict = 'down'
order by f.id asc limit 1
""", (rs, rowNum) -> new FeedbackSource(
rs.getString("query_text"), rs.getString("category")),
principal.tenantId(), principal.userId(), requestId);
if (rows.isEmpty()) {
throw new ServiceException("仅本人已提交的未解决反馈可转人工求助", 409);
}
return rows.get(0);
}
private LinkedQuestion linkedQuestionBySource(AihrKnowledgePrincipal principal, String requestId) {
return linkedQuestion(principal, "source_type = 'ANSWER_FEEDBACK' and source_request_id = ?", requestId);
}
private LinkedQuestion linkedQuestionByIdempotencyKey(AihrKnowledgePrincipal principal, String key) {
return linkedQuestion(principal, "submit_idempotency_key = ?", key);
}
private LinkedQuestion linkedQuestion(AihrKnowledgePrincipal principal, String condition, String value) {
List<LinkedQuestion> rows = jdbcTemplate.query("""
select id, source_request_id from aihr_community_question
where tenant_id = ? and author_user_id = ? and %s
limit 1
""".formatted(condition), (rs, rowNum) -> new LinkedQuestion(
rs.getLong("id"), rs.getString("source_request_id")),
principal.tenantId(), principal.userId(), value);
return rows.isEmpty() ? null : 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(),
@@ -440,7 +556,16 @@ public class AihrCommunityService {
}
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate,
"aihr_community_question", "aihr_community_answer", "aihr_points_ledger", "aihr_incentive_rule");
"aihr_community_question", "aihr_community_answer", "aihr_points_ledger",
"aihr_incentive_rule", "aihr_knowledge_answer_feedback", "aihr_knowledge_query_log");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_community_question",
"source_type", "source_request_id", "submit_idempotency_key");
AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_community_question",
"uk_aihr_community_question_source", true,
"tenant_id", "author_user_id", "source_type", "source_request_id");
AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_community_question",
"uk_aihr_community_question_submit_key", true,
"tenant_id", "author_user_id", "submit_idempotency_key");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_points_ledger", "account_type");
tablesReady = true;
return;
@@ -461,6 +586,7 @@ public class AihrCommunityService {
COMMENT 'POINTS/STUDY_CREDIT' AFTER `business_id`
""");
}
ensureQuestionBridgeSchema();
tablesReady = true;
}
}
@@ -473,6 +599,8 @@ public class AihrCommunityService {
`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',
`source_type` varchar(32) DEFAULT NULL, `source_request_id` varchar(64) DEFAULT NULL,
`submit_idempotency_key` varchar(80) DEFAULT NULL,
`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,
@@ -480,7 +608,9 @@ public class AihrCommunityService {
`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`)
PRIMARY KEY (`id`), KEY `idx_aihr_community_question_board` (`tenant_id`, `question_status`, `create_time`),
UNIQUE KEY `uk_aihr_community_question_source` (`tenant_id`, `author_user_id`, `source_type`, `source_request_id`),
UNIQUE KEY `uk_aihr_community_question_submit_key` (`tenant_id`, `author_user_id`, `submit_idempotency_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
ddls.add("""
@@ -568,6 +698,47 @@ public class AihrCommunityService {
return cleaned;
}
private static String requiredToken(String value, String label, int max) {
String token = required(value, label, max);
if (!token.matches("[A-Za-z0-9:_-]+")) {
throw new IllegalArgumentException(label + "格式不正确");
}
return token;
}
private void ensureQuestionBridgeSchema() {
ensureQuestionColumn("source_type",
"ALTER TABLE aihr_community_question ADD COLUMN source_type varchar(32) DEFAULT NULL AFTER question_type");
ensureQuestionColumn("source_request_id",
"ALTER TABLE aihr_community_question ADD COLUMN source_request_id varchar(64) DEFAULT NULL AFTER source_type");
ensureQuestionColumn("submit_idempotency_key",
"ALTER TABLE aihr_community_question ADD COLUMN submit_idempotency_key varchar(80) DEFAULT NULL AFTER source_request_id");
ensureQuestionIndex("uk_aihr_community_question_source",
"ALTER TABLE aihr_community_question ADD UNIQUE KEY uk_aihr_community_question_source (tenant_id, author_user_id, source_type, source_request_id)");
ensureQuestionIndex("uk_aihr_community_question_submit_key",
"ALTER TABLE aihr_community_question ADD UNIQUE KEY uk_aihr_community_question_submit_key (tenant_id, author_user_id, submit_idempotency_key)");
}
private void ensureQuestionColumn(String column, String ddl) {
Integer count = jdbcTemplate.queryForObject("""
select count(*) from information_schema.columns
where table_schema = database() and table_name = 'aihr_community_question' and column_name = ?
""", Integer.class, column);
if (count == null || count == 0) {
jdbcTemplate.execute(ddl);
}
}
private void ensureQuestionIndex(String index, String ddl) {
Integer count = jdbcTemplate.queryForObject("""
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'aihr_community_question' and index_name = ?
""", Integer.class, index);
if (count == null || count == 0) {
jdbcTemplate.execute(ddl);
}
}
private static String displayName(AihrKnowledgePrincipal principal) {
String identity = clean(principal.extPartyId());
if (identity.isBlank()) {
@@ -600,4 +771,10 @@ public class AihrCommunityService {
private record Award(String accountType, int value) {
}
private record FeedbackSource(String queryText, String category) {
}
private record LinkedQuestion(Long questionId, String sourceRequestId) {
}
}
@@ -14,7 +14,9 @@ import org.dromara.aihr.domain.AihrCaseDto.CapabilitiesResponse;
import org.dromara.aihr.domain.AihrCaseDto.DetailResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
import org.dromara.aihr.domain.AihrCaseDto.PublishRequest;
import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.RetireRequest;
import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.service.AihrCaseService;
@@ -114,6 +116,24 @@ public class AihrCaseController {
return R.ok();
}
@PostMapping("/records/{caseId}/publish")
public R<Void> publish(@PathVariable String caseId, @RequestBody PublishRequest request) {
if (!canReviewCases()) {
return R.fail("仅企业知识运营人员可正式发布经验");
}
caseService.publish(caseId, request, currentProjectScopes());
return R.ok();
}
@PostMapping("/records/{caseId}/retire")
public R<Void> retire(@PathVariable String caseId, @RequestBody RetireRequest request) {
if (!canReviewCases()) {
return R.fail("仅企业知识运营人员可下线经验");
}
caseService.retire(caseId, request, currentProjectScopes());
return R.ok();
}
@GetMapping("/capabilities")
public R<CapabilitiesResponse> capabilities() {
return R.ok(new CapabilitiesResponse(canManageCases(), canViewCases()));
@@ -17,6 +17,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskCompleteRequest;
import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskProgressRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
@@ -37,6 +38,8 @@ import org.dromara.aihr.learning.AihrLearningDto.DifficultyResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamAiGenerateRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamAiGenerateResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDrawRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDrawResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest;
@@ -261,6 +264,14 @@ public class AihrMobileController {
return mobileSeedService.completeOnboardTask(id, identity, evidenceRef) ? R.ok() : R.fail("任务不存在、已完成或无权操作");
}
@PostMapping("/onboard/tasks/{id}/progress")
public R<Void> updateOnboardTaskProgress(@PathVariable Long id,
@RequestBody OnboardTaskProgressRequest request) {
String identity = ownMobileExtPartyId(null);
return mobileSeedService.updateOnboardTaskProgress(id, identity, request)
? R.ok() : R.fail("任务不存在、已完成或无权操作");
}
@GetMapping("/qualification")
public R<QualificationResponse> qualification() {
return R.ok(mobileSeedService.qualification(ownMobileExtPartyId(null)));
@@ -311,17 +322,17 @@ public class AihrMobileController {
@GetMapping("/exams")
public R<List<ExamSummaryResponse>> exams() {
return R.ok(examService.employeeExams(ownMobileExtPartyId(null)));
return R.ok(examService.employeeExams(ownMobileOrganizationExtPartyId()));
}
@GetMapping("/exams/{id}")
public R<ExamDetailResponse> exam(@PathVariable Long id) {
return R.ok(examService.employeeExam(id, ownMobileExtPartyId(null)));
return R.ok(examService.employeeExam(id, ownMobileOrganizationExtPartyId()));
}
@PostMapping("/exams/{id}/submit")
public R<ExamSubmitResponse> submitExam(@PathVariable Long id, @RequestBody ExamSubmitRequest request) {
return R.ok(examService.submit(id, request, ownMobileExtPartyId(null)));
return R.ok(examService.submit(id, request, ownMobileOrganizationExtPartyId()));
}
@GetMapping("/exams/supervisor")
@@ -357,6 +368,11 @@ public class AihrMobileController {
return R.ok(examService.generateQuestions(request, supervisorScopeExtPartyId()));
}
@PostMapping("/exams/supervisor/question-bank/draw")
public R<ExamQuestionDrawResponse> drawSupervisorExamQuestions(@RequestBody ExamQuestionDrawRequest request) {
return R.ok(examService.drawQuestions(request, supervisorScopeExtPartyId()));
}
@GetMapping("/exams/supervisor/{id}/results")
public R<List<ExamResultResponse>> supervisorExamResults(@PathVariable Long id) {
return R.ok(examService.results(id, supervisorScopeExtPartyId()));
@@ -376,6 +392,14 @@ public class AihrMobileController {
return mobileSeedService.requireMobileIdentity(username);
}
private String ownMobileOrganizationExtPartyId() {
String username = currentAppUsername();
if (username.isBlank()) {
throw new ServiceException("无权访问员工个人数据");
}
return mobileSeedService.requireMobileOrganizationIdentity(username);
}
private boolean isSystemOperator() {
LoginUser loginUser = LoginHelper.getLoginUser();
return loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType());
@@ -34,6 +34,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionReviewRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioReviewStatusRequest;
@@ -45,6 +46,12 @@ 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.service.AihrPracticeSeedService;
import org.dromara.aihr.learning.AihrLearningTaskService;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningExamOption;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningMaterialOption;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskBatchResponse;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskDispatchRequest;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskManageResponse;
import org.dromara.aihr.service.AihrRealtimePracticeService;
import org.dromara.aihr.service.AihrRealtimeSessionService;
import org.dromara.aihr.service.AihrRealtimeToolService;
@@ -82,6 +89,7 @@ public class AihrPracticeController {
private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrPracticeSeedService practiceSeedService;
private final AihrLearningTaskService learningTaskService;
private final AihrRealtimePracticeService realtimePracticeService;
private final AihrRealtimeSessionService realtimeSessionService;
private final AihrRealtimeToolService realtimeToolService;
@@ -150,6 +158,13 @@ public class AihrPracticeController {
return practiceSeedService.updateQuestionEnabled(code, request) ? R.ok() : R.fail("训练题目不存在");
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PatchMapping("/questions/{code}/review")
public R<PracticeQuestionResponse> reviewQuestion(@PathVariable String code,
@RequestBody PracticeQuestionReviewRequest request) {
return R.ok(practiceSeedService.reviewQuestion(code, request, currentOperatorUsername()));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/assignments/manage")
public R<List<PracticeAssignmentResponse>> manageAssignments(@RequestParam(required = false) String position,
@@ -166,6 +181,32 @@ public class AihrPracticeController {
return R.ok(practiceSeedService.createAssignments(request, ""));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/onboard-tasks/manage")
public R<List<LearningTaskManageResponse>> manageLearningTasks(@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword,
@RequestParam(required = false, defaultValue = "200") Integer limit) {
return R.ok(learningTaskService.manageTasks(status, keyword, limit == null ? 200 : limit));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/onboard-tasks/manage/materials")
public R<List<LearningMaterialOption>> learningMaterials() {
return R.ok(learningTaskService.materials());
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/onboard-tasks/manage/exams")
public R<List<LearningExamOption>> learningExams() {
return R.ok(learningTaskService.publishedExams());
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/onboard-tasks/manage/batch")
public R<LearningTaskBatchResponse> dispatchLearningTasks(@RequestBody LearningTaskDispatchRequest request) {
return R.ok(learningTaskService.dispatch(request, currentOperatorUsername()));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PatchMapping("/assignments/manage/{id}/cancel")
public R<Void> cancelAssignment(@PathVariable Long id, @RequestBody(required = false) AssignmentCancelRequest request) {
@@ -147,7 +147,7 @@ public class AihrSopController {
@SaCheckLogin
@PostMapping("/answer-feedback")
public R<AnswerFeedbackResponse> answerFeedback(@RequestBody AnswerFeedbackRequest request) {
return R.ok(sopSeedService.answerFeedback(request));
return R.ok(sopSeedService.answerFeedback(request, knowledgePrincipalResolver.current().userId()));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@@ -27,6 +27,26 @@ public final class AihrCaseDto {
public record ReviewRequest(String comment) {
}
/**
* Explicit second-stage release metadata. A reviewed case remains a
* candidate until a content owner confirms these fields.
*/
public record PublishRequest(
String sourceType,
String applicablePositions,
String contentVersion,
String releaseOwner,
String releaseNote,
Boolean sourceAuthorized,
Boolean deidentified,
String effectiveFrom,
String effectiveTo
) {
}
public record RetireRequest(String reason) {
}
public record CurateResponse(List<String> selectedCaseIds, List<RecordResponse> records, String sampleHint) {
}
@@ -39,15 +59,29 @@ public final class AihrCaseDto {
public record TagResponse(String text, String type) {
}
public record RecordResponse(String caseId, String title, String type, String status, String time, String owner, String summary) {
public record RecordResponse(String caseId, String title, String type, String status, String time, String owner, String summary,
String knowledgeStatus, String sourceType, String applicablePositions, String contentVersion) {
public RecordResponse(String caseId, String title, String type, String status, String time, String owner, String summary) {
this(caseId, title, type, status, time, owner, summary, "CANDIDATE", "", "", "");
}
public RecordResponse(String title, String type, String status, String time, String owner, String summary) {
this("", title, type, status, time, owner, summary);
this("", title, type, status, time, owner, summary, "CANDIDATE", "", "", "");
}
}
public record DetailResponse(String caseId, String fileName, String title, String type, String status,
String time, String owner, String transcript, String summary,
String mediaUrl, Long mediaOssId, List<String> learningPoints,
String supervisorComment) {
String supervisorComment, String knowledgeStatus, String sourceType,
String applicablePositions, String contentVersion, String effectiveFrom,
String effectiveTo, String releaseOwner, String releaseNote, String retireReason) {
public DetailResponse(String caseId, String fileName, String title, String type, String status,
String time, String owner, String transcript, String summary,
String mediaUrl, Long mediaOssId, List<String> learningPoints,
String supervisorComment) {
this(caseId, fileName, title, type, status, time, owner, transcript, summary, mediaUrl, mediaOssId,
learningPoints, supervisorComment, "CANDIDATE", "", "", "", "", "", "", "", "");
}
}
}
@@ -182,7 +182,15 @@ public final class AihrPracticeDto {
String contentVersion,
String contentHash,
Boolean enabled,
Integer sortOrder
Integer sortOrder,
String examFormat,
List<String> examOptions,
List<String> examAnswers,
String examExplanation,
Boolean examEnabled,
String reviewStatus,
String reviewedBy,
String reviewedTime
) {
}
@@ -194,13 +202,26 @@ public final class AihrPracticeDto {
String question,
String referenceAnswer,
Boolean enabled,
Integer sortOrder
Integer sortOrder,
String examFormat,
List<String> examOptions,
List<String> examAnswers,
String examExplanation,
Boolean examEnabled
) {
public PracticeQuestionSaveRequest(String code, String scenarioCode, String positionCode, String type,
String question, String referenceAnswer, Boolean enabled, Integer sortOrder) {
this(code, scenarioCode, positionCode, type, question, referenceAnswer, enabled, sortOrder,
null, List.of(), List.of(), null, false);
}
}
public record PracticeQuestionEnabledRequest(Boolean enabled) {
}
public record PracticeQuestionReviewRequest(String decision, String note) {
}
public record AssignmentCancelRequest(String reason) {
}
@@ -590,13 +611,41 @@ public final class AihrPracticeDto {
String assignedTime,
String dueTime,
String completedTime,
String evidenceRef
String evidenceRef,
String materialType,
Long materialAttachmentId,
String materialTitle,
String materialVersion,
Integer progressPercent,
String completionRule,
Long examId,
String recurrenceType,
String startedTime,
String lastViewedTime,
Integer lastPositionSeconds,
Integer mediaDurationSeconds
) {
public OnboardTaskResponse(Long id, String projectCode, String positionCode, Long sopId, Long courseId,
String taskType, String status, String assignBy, String assignedTime, String dueTime,
String completedTime, String evidenceRef) {
this(id, projectCode, positionCode, sopId, courseId, taskType, status, assignBy, assignedTime, dueTime,
completedTime, evidenceRef, null, null, null, null, 0, null, null, null,
null, null, 0, null);
}
}
public record OnboardTaskCompleteRequest(String evidenceRef) {
}
public record OnboardTaskProgressRequest(
Integer progressPercent,
Integer positionSeconds,
Integer durationSeconds,
String materialVersion,
Boolean completed
) {
}
public record QualificationGateResponse(
Long id,
String projectCode,
@@ -28,10 +28,9 @@ public class AihrKnowledgeDataToolService {
if (principal == null || !hasText(principal.extPartyId())) {
throw new ServiceException("当前登录身份不能使用训练数据工具", HttpStatus.FORBIDDEN);
}
// In the APP knowledge flow this field carries the authenticated mobile
// username. Validate it once at the tool boundary before any legacy
// training query can resolve aliases from organization data.
String mobilePhone = mobileSeedService.requireMobileIdentity(principal.extPartyId());
// The knowledge principal stores the verified organization identity.
// Resolve its unique APP phone before entering legacy phone-keyed reads.
String mobilePhone = mobileSeedService.requireMobilePhoneForOrganizationIdentity(principal.extPartyId());
String toolCode = rawToolCode == null ? "" : rawToolCode.trim().toUpperCase(java.util.Locale.ROOT);
return switch (toolCode) {
case MY_PRACTICE_SUMMARY -> myPractice(mobilePhone);
@@ -43,8 +43,9 @@ public class AihrKnowledgePrincipalResolver {
}
if (UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
roles.add("employee");
extPartyId = mobileSeedService.requireMobileIdentity(trim(loginUser.getUsername()));
List<OrgPersonRow> rows = orgRows(extPartyId);
String mobilePhone = mobileSeedService.requireMobileIdentity(trim(loginUser.getUsername()));
extPartyId = mobileSeedService.requireMobileOrganizationIdentity(mobilePhone);
List<OrgPersonRow> rows = orgRows(mobilePhone);
if (rows.stream().anyMatch(AihrKnowledgePrincipalResolver::isSupervisor)) {
roles.add("supervisor");
}
@@ -22,6 +22,7 @@ import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.aihr.service.AihrModelSeedService;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -304,14 +305,13 @@ public class AihrKnowledgeQueryService {
AihrKnowledgePrincipal principal = principalResolver.current();
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
Set<Long> spaceIds = accessService.resolveInternalSpaceIds(principal, app, List.of(), "READ");
if (spaceIds.isEmpty()) {
return null;
}
List<Object> args = new ArrayList<>();
args.add(principal.tenantId());
args.add(attachmentId);
args.addAll(spaceIds);
List<Long> rows = jdbcTemplate.queryForList("""
List<Long> rows = List.of();
if (!spaceIds.isEmpty()) {
List<Object> args = new ArrayList<>();
args.add(principal.tenantId());
args.add(attachmentId);
args.addAll(spaceIds);
rows = jdbcTemplate.queryForList("""
select a.oss_id
from aihr_knowledge_attach a
join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id
@@ -334,8 +334,34 @@ public class AihrKnowledgeQueryService {
and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date())
and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date())
)
limit 1
limit 1
""".formatted(placeholders(spaceIds.size())), Long.class, args.toArray());
}
if (!rows.isEmpty()) {
return rows.get(0);
}
return authorizedLearningTaskResource(principal, attachmentId);
}
private Long authorizedLearningTaskResource(AihrKnowledgePrincipal principal, Long attachmentId) {
if (principal == null || !UserType.APP_USER.getUserType().equals(principal.userType()) || !hasText(principal.extPartyId())) {
return null;
}
List<Long> rows = jdbcTemplate.queryForList("""
select a.oss_id
from aihr_knowledge_attach a
where a.tenant_id = ? and a.id = ? and a.oss_id is not null and a.status = 2
and exists (
select 1
from aihr_onboard_task task
where task.tenant_id = a.tenant_id
and task.material_attachment_id = a.id
and task.status in ('待完成', '进行中', '已完成')
and (task.assigned_time is null or task.assigned_time <= now())
and task.ext_party_id = ?
)
limit 1
""", Long.class, principal.tenantId(), attachmentId, principal.extPartyId());
return rows.isEmpty() ? null : rows.get(0);
}
@@ -13,6 +13,8 @@ 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.ExamQuestionDrawRequest;
import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDrawResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse;
import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest;
@@ -57,6 +59,7 @@ 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 static final TypeReference<List<ExamQuestionDraft>> QUESTION_DRAFT_LIST = new TypeReference<>() { };
private static final Set<String> AI_QUESTION_FORMATS = Set.of("single", "multiple", "judge", "short");
private final JdbcTemplate jdbcTemplate;
@@ -141,6 +144,15 @@ public class AihrExamService {
VALUES (?, ?, ?, ?, ?, ?, now())
""", tenantId(), attemptId, result.questionId(), json(answer), result.correct(), result.earnedScore());
}
if (passed) {
jdbcTemplate.update("""
UPDATE aihr_onboard_task
SET status = '已完成', progress_percent = 100, completed_time = ?,
evidence_ref = CONCAT('exam_attempt:', ?), update_time = now()
WHERE tenant_id = ? AND exam_id = ? AND ext_party_id = ?
AND completion_rule = 'EXAM_PASS' AND status IN ('待完成', '进行中')
""", Timestamp.valueOf(submittedAt), attemptId, tenantId(), examId, identity);
}
return new ExamSubmitResponse(attemptId, "SUBMITTED", score.score(), passed,
submittedAt.format(TIME_FORMAT), score.results());
}
@@ -167,12 +179,13 @@ public class AihrExamService {
@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());
ExamUpsertRequest resolved = resolveDrawQuestions(request, owner);
AihrExamScoring.validate(resolved);
LocalDate dueDate = parseDueDate(resolved.dueDate());
List<ResolvedTarget> targets = resolveTargets(resolved.targets(), supervisorExtPartyId);
long examId = insertExam(resolved, owner, dueDate);
saveQuestions(examId, resolved.questions());
saveTargets(examId, targets);
return supervisorExam(examId, supervisorExtPartyId);
}
@@ -185,25 +198,26 @@ public class AihrExamService {
@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);
ExamUpsertRequest resolved = resolveDrawQuestions(request, existing.createdBy());
AihrExamScoring.validate(resolved);
LocalDate dueDate = parseDueDate(resolved.dueDate());
List<ResolvedTarget> targets = resolveTargets(resolved.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),
""", clean(resolved.title()), clean(resolved.projectCode()), clean(resolved.positionCode()),
resolved.passScore() == null ? 80 : resolved.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());
saveQuestions(examId, resolved.questions());
jdbcTemplate.update("DELETE FROM aihr_onboard_exam_target WHERE tenant_id = ? AND exam_id = ?", tenantId(), examId);
saveTargets(examId, targets);
return supervisorExam(examId, supervisorExtPartyId);
@@ -255,6 +269,66 @@ public class AihrExamService {
return publish(examId, null, supervisorExtPartyId);
}
@Transactional(rollbackFor = Exception.class)
public ExamQuestionDrawResponse drawQuestions(ExamQuestionDrawRequest request, String supervisorExtPartyId) {
ensureSchema();
String owner = fallback(supervisorExtPartyId, "system");
String requestId = normalizeRequestId(request == null ? null : request.requestId());
String position = clean(request == null ? null : request.positionCode());
int count = request == null || request.count() == null ? 10 : request.count();
if (position.isEmpty()) {
throw new ServiceException("请选择组卷岗位");
}
if (count < 1 || count > 50) {
throw new ServiceException("随机组卷题数必须为 1-50");
}
List<String> formats = normalizeDrawFormats(request == null ? null : request.formats());
String configHash = AihrLearningService.contentHash("exam-draw-v1", position + "\n" + count, json(formats));
ExamQuestionDrawResponse existing = findQuestionDraw(owner, requestId, configHash);
if (existing != null) {
return existing;
}
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.add(position);
args.addAll(formats);
args.add(requestId);
args.add(count);
List<ExamQuestionDraft> selected = jdbcTemplate.query("""
SELECT id, question_code, exam_format, question_text, exam_options_json, exam_answers_json,
exam_explanation, content_version, content_hash
FROM aihr_learning_question
WHERE tenant_id = ? AND position_code = ? AND enabled = 1 AND exam_enabled = 1
AND review_status = 'APPROVED' AND exam_format IN (%s)
ORDER BY SHA2(CONCAT(id, ':' , ?), 256), id
LIMIT ?
""".formatted(placeholders(formats.size())), (rs, rowNum) -> new ExamQuestionDraft(
null, rs.getString("exam_format"), rs.getString("question_text"),
stringList(rs.getString("exam_options_json")), stringList(rs.getString("exam_answers_json")),
0, rs.getString("exam_explanation"), rowNum + 1, rs.getLong("id"),
rs.getString("question_code"), rs.getString("content_version"), rs.getString("content_hash")
), args.toArray());
if (selected.size() < count) {
throw new ServiceException("当前岗位已审核考试题不足 " + count + " 道,请先补充并审核题库");
}
List<ExamQuestionDraft> scored = rebalanceQuestionScores(selected);
String snapshotJson = objectJson(scored);
try {
jdbcTemplate.update("""
INSERT INTO aihr_exam_question_draw
(tenant_id, owner_ext_party_id, request_key, config_hash, question_snapshot_json, create_time)
VALUES (?, ?, ?, ?, ?, now())
""", tenantId(), owner, requestId, configHash, snapshotJson);
} catch (DuplicateKeyException duplicate) {
ExamQuestionDrawResponse raced = findQuestionDraw(owner, requestId, configHash);
if (raced != null) {
return raced;
}
throw duplicate;
}
return new ExamQuestionDrawResponse(requestId, scored);
}
public List<ExamResultResponse> results(Long examId, String supervisorExtPartyId) {
ensureSchema();
requireSupervisorExam(examId, supervisorExtPartyId);
@@ -281,7 +355,8 @@ public class AihrExamService {
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();
includeAnswers ? question.explanation() : "", question.sortOrder(),
question.sourceQuestionCode(), question.sourceContentVersion())).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);
@@ -297,7 +372,16 @@ public class AihrExamService {
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);
AND NOT EXISTS (
SELECT 1 FROM aihr_onboard_task learning_task
WHERE learning_task.tenant_id = e.tenant_id
AND learning_task.exam_id = e.id
AND learning_task.ext_party_id = ?
AND learning_task.completion_rule = 'EXAM_PASS'
AND learning_task.status IN ('待完成', '进行中')
AND COALESCE(learning_task.progress_percent, 0) < 100
)
""", (rs, rowNum) -> mapHeader(rs), identity, tenantId(), examId, identity);
if (rows.isEmpty()) {
throw new ServiceException("考试不存在、已截止或不适用于当前岗位");
}
@@ -322,12 +406,15 @@ public class AihrExamService {
private List<ExamQuestionDraft> loadQuestionDrafts(Long examId) {
return jdbcTemplate.query("""
SELECT id, question_type, stem, options_json, correct_answers_json, score, explanation, sort_order
SELECT id, question_type, stem, options_json, correct_answers_json, score, explanation, sort_order,
source_question_id, source_question_code, source_content_version, source_content_hash
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")
rs.getInt("score"), rs.getString("explanation"), rs.getInt("sort_order"),
(Long) rs.getObject("source_question_id"), rs.getString("source_question_code"),
rs.getString("source_content_version"), rs.getString("source_content_hash")
), tenantId(), examId);
}
@@ -388,14 +475,79 @@ public class AihrExamService {
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())
explanation, sort_order, source_question_id, source_question_code, source_content_version,
source_content_hash, 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());
truncate(question.explanation(), 1000), question.sortOrder() == null ? index : question.sortOrder(),
question.sourceQuestionId(), nullable(question.sourceQuestionCode()),
nullable(question.sourceContentVersion()), nullable(question.sourceContentHash()));
}
}
private ExamUpsertRequest resolveDrawQuestions(ExamUpsertRequest request, String owner) {
if (request == null) {
throw new ServiceException("缺少考试配置");
}
String drawRequestId = normalizeOptionalRequestId(request.questionDrawRequestId());
if (drawRequestId.isEmpty()) {
return request;
}
ExamQuestionDrawResponse draw = findQuestionDraw(owner, drawRequestId, null);
if (draw == null) {
throw new ServiceException("随机组卷快照不存在或不属于当前主管,请重新组卷");
}
return new ExamUpsertRequest(request.title(), request.projectCode(), request.positionCode(),
request.passScore(), request.dueDate(), draw.questions(), request.targets(), drawRequestId);
}
private ExamQuestionDrawResponse findQuestionDraw(String owner, String requestId, String expectedConfigHash) {
List<QuestionDrawRow> rows = jdbcTemplate.query("""
SELECT config_hash, question_snapshot_json
FROM aihr_exam_question_draw
WHERE tenant_id = ? AND owner_ext_party_id = ? AND request_key = ?
LIMIT 1
""", (rs, rowNum) -> new QuestionDrawRow(
rs.getString("config_hash"), rs.getString("question_snapshot_json")
), tenantId(), owner, requestId);
if (rows.isEmpty()) {
return null;
}
QuestionDrawRow row = rows.get(0);
if (expectedConfigHash != null && !expectedConfigHash.equals(row.configHash())) {
throw new ServiceException("该随机组卷请求ID已用于其他岗位或题数");
}
try {
return new ExamQuestionDrawResponse(requestId,
objectMapper.readValue(row.questionSnapshotJson(), QUESTION_DRAFT_LIST));
} catch (JsonProcessingException error) {
throw new ServiceException("随机组卷快照数据损坏");
}
}
private static List<String> normalizeDrawFormats(List<String> requested) {
List<String> formats = requested == null ? List.of() : requested.stream()
.map(AihrExamService::clean).map(value -> value.toLowerCase(Locale.ROOT))
.filter(AI_QUESTION_FORMATS::contains).distinct().toList();
return formats.isEmpty() ? List.of("single", "multiple", "judge", "short") : formats;
}
private static List<ExamQuestionDraft> rebalanceQuestionScores(List<ExamQuestionDraft> questions) {
int base = 100 / questions.size();
int remainder = 100 - base * questions.size();
List<ExamQuestionDraft> result = new ArrayList<>();
for (int index = 0; index < questions.size(); index++) {
ExamQuestionDraft question = questions.get(index);
int score = base + (index < remainder ? 1 : 0);
result.add(new ExamQuestionDraft(question.id(), question.questionType(), question.stem(),
question.options(), question.correctAnswers(), score, question.explanation(), index + 1,
question.sourceQuestionId(), question.sourceQuestionCode(), question.sourceContentVersion(),
question.sourceContentHash()));
}
return List.copyOf(result);
}
private void saveTargets(Long examId, List<ResolvedTarget> targets) {
for (ResolvedTarget target : targets) {
jdbcTemplate.update("""
@@ -736,11 +888,14 @@ public class AihrExamService {
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);
SELECT DISTINCT ext_party_id FROM aihr_org_snapshot
WHERE tenant_id = ? AND employment_status = 'active' AND ext_party_id = ?
ORDER BY ext_party_id
""", (rs, rowNum) -> rs.getString("ext_party_id"), tenantId(), identity);
if (rows.size() != 1 || clean(rows.get(0)).isEmpty()) {
throw new ServiceException("当前手机号未唯一匹配在职员工,暂不能访问考试");
}
return rows.get(0).trim();
}
private static ExamHeader mapHeader(java.sql.ResultSet rs) throws java.sql.SQLException {
@@ -822,8 +977,17 @@ public class AihrExamService {
if (!runtimeSchemaBootstrap) {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate,
"aihr_onboard_exam", "aihr_onboard_exam_question", "aihr_onboard_exam_target",
"aihr_onboard_exam_attempt", "aihr_onboard_exam_answer");
"aihr_onboard_exam_attempt", "aihr_onboard_exam_answer", "aihr_exam_question_draw",
"aihr_onboard_task");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_exam", "publish_request_key");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_exam_question",
"source_question_id", "source_question_code", "source_content_version",
"source_content_hash");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_learning_question",
"exam_format", "exam_options_json", "exam_answers_json", "exam_explanation",
"exam_enabled", "review_status");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_task",
"completion_rule", "progress_percent");
AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_onboard_exam",
"uk_aihr_onboard_exam_publish_request");
schemaReady = true;
@@ -871,12 +1035,37 @@ public class AihrExamService {
score int NOT NULL,
explanation varchar(1000) DEFAULT NULL,
sort_order int NOT NULL DEFAULT 0,
source_question_id bigint DEFAULT NULL,
source_question_code varchar(120) DEFAULT NULL,
source_content_version varchar(30) DEFAULT NULL,
source_content_hash char(64) DEFAULT NULL,
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
""");
ensureColumn("aihr_onboard_exam_question", "source_question_id",
"ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_question_id bigint DEFAULT NULL AFTER sort_order");
ensureColumn("aihr_onboard_exam_question", "source_question_code",
"ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_question_code varchar(120) DEFAULT NULL AFTER source_question_id");
ensureColumn("aihr_onboard_exam_question", "source_content_version",
"ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_content_version varchar(30) DEFAULT NULL AFTER source_question_code");
ensureColumn("aihr_onboard_exam_question", "source_content_hash",
"ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_content_hash char(64) DEFAULT NULL AFTER source_content_version");
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS aihr_exam_question_draw (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) NOT NULL DEFAULT '000000',
owner_ext_party_id varchar(100) NOT NULL,
request_key varchar(100) NOT NULL,
config_hash char(64) NOT NULL,
question_snapshot_json longtext NOT NULL,
create_time datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_aihr_exam_question_draw_request (tenant_id, owner_ext_party_id, request_key)
) 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,
@@ -997,6 +1186,22 @@ public class AihrExamService {
Integer passScore, String dueDate, String createdBy, String publishRequestKey) {
}
private String objectJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException error) {
throw new ServiceException("考试快照序列化失败");
}
}
private static String nullable(String value) {
String normalized = clean(value);
return normalized.isEmpty() ? null : normalized;
}
private record QuestionDrawRow(String configHash, String questionSnapshotJson) {
}
private record AttemptRow(Long id, String status, Integer score, Boolean passed, String submittedTime) {
}
@@ -81,8 +81,28 @@ public final class AihrLearningDto {
List<String> correctAnswers,
Integer score,
String explanation,
Integer sortOrder
Integer sortOrder,
Long sourceQuestionId,
String sourceQuestionCode,
String sourceContentVersion,
String sourceContentHash
) {
public ExamQuestionDraft(Long id, String questionType, String stem, List<String> options,
List<String> correctAnswers, Integer score, String explanation, Integer sortOrder) {
this(id, questionType, stem, options, correctAnswers, score, explanation, sortOrder,
null, null, null, null);
}
}
public record ExamQuestionDrawRequest(
String requestId,
String positionCode,
Integer count,
List<String> formats
) {
}
public record ExamQuestionDrawResponse(String requestId, List<ExamQuestionDraft> questions) {
}
/** A selected employee. traineeName is display-only; the server resolves the authoritative org name. */
@@ -104,11 +124,17 @@ public final class AihrLearningDto {
Integer passScore,
String dueDate,
List<ExamQuestionDraft> questions,
List<ExamTargetDraft> targets
List<ExamTargetDraft> targets,
String questionDrawRequestId
) {
public ExamUpsertRequest(String title, String projectCode, String positionCode, Integer passScore,
String dueDate, List<ExamQuestionDraft> questions) {
this(title, projectCode, positionCode, passScore, dueDate, questions, List.of());
this(title, projectCode, positionCode, passScore, dueDate, questions, List.of(), null);
}
public ExamUpsertRequest(String title, String projectCode, String positionCode, Integer passScore,
String dueDate, List<ExamQuestionDraft> questions, List<ExamTargetDraft> targets) {
this(title, projectCode, positionCode, passScore, dueDate, questions, targets, null);
}
}
@@ -146,7 +172,9 @@ public final class AihrLearningDto {
List<String> correctAnswers,
Integer score,
String explanation,
Integer sortOrder
Integer sortOrder,
String sourceQuestionCode,
String sourceContentVersion
) {
}
@@ -0,0 +1,90 @@
package org.dromara.aihr.learning;
import java.util.List;
public final class AihrLearningTaskDto {
private AihrLearningTaskDto() {
}
public record LearningTaskTarget(String extPartyId, String traineeName) {
}
public record LearningTaskDispatchRequest(
String requestId,
List<LearningTaskTarget> targets,
String projectCode,
String positionCode,
String taskType,
String materialType,
Long materialAttachmentId,
String completionRule,
Long examId,
String recurrenceType,
String startTime,
String dueTime
) {
}
public record LearningTaskManageResponse(
Long id,
String extPartyId,
String traineeName,
String projectCode,
String positionCode,
String taskType,
String status,
String materialType,
Long materialAttachmentId,
String materialTitle,
String materialVersion,
Integer progressPercent,
String completionRule,
Long examId,
String examTitle,
String recurrenceType,
String assignedTime,
String dueTime,
String completedTime,
String assignBy
) {
}
public record LearningTaskBatchItemResponse(
String extPartyId,
String traineeName,
String status,
String message,
LearningTaskManageResponse task
) {
}
public record LearningTaskBatchResponse(
String requestId,
int total,
int created,
int duplicate,
int failed,
List<LearningTaskBatchItemResponse> items
) {
}
public record LearningMaterialOption(
Long attachmentId,
String title,
String fileType,
String materialVersion,
String updatedTime
) {
}
public record LearningExamOption(
Long examId,
String title,
String projectCode,
String positionCode,
String dueDate,
Integer targetCount
) {
}
}
@@ -0,0 +1,433 @@
package org.dromara.aihr.learning;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningExamOption;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningMaterialOption;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskBatchItemResponse;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskBatchResponse;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskDispatchRequest;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskManageResponse;
import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskTarget;
import org.dromara.aihr.service.AihrSchemaMigrationGuard;
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.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
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.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrLearningTaskService {
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> MATERIAL_TYPES = Set.of("DOCUMENT", "VIDEO", "COURSE", "SOP");
private static final Set<String> COMPLETION_RULES = Set.of("MANUAL_CONFIRM", "EXAM_PASS");
private static final Set<String> RECURRENCES = Set.of("ONCE", "DAILY", "WEEKLY", "MONTHLY");
private final JdbcTemplate jdbcTemplate;
private volatile boolean schemaReady;
public List<LearningTaskManageResponse> manageTasks(String status, String keyword, int limit) {
ensureSchema();
String normalizedStatus = clean(status);
String normalizedKeyword = clean(keyword);
int safeLimit = Math.max(1, Math.min(limit, 500));
List<Object> args = new ArrayList<>();
args.add(tenantId());
StringBuilder where = new StringBuilder();
if (!normalizedStatus.isEmpty()) {
where.append(" AND t.status = ?");
args.add(normalizedStatus);
}
if (!normalizedKeyword.isEmpty()) {
where.append(" AND (t.task_type LIKE ? OR t.material_title LIKE ? OR t.ext_party_id LIKE ?");
where.append(" OR EXISTS (SELECT 1 FROM aihr_org_snapshot os WHERE os.tenant_id=t.tenant_id");
where.append(" AND os.ext_party_id=t.ext_party_id AND os.person_name LIKE ?))");
String like = "%" + normalizedKeyword + "%";
args.add(like);
args.add(like);
args.add(like);
args.add(like);
}
args.add(safeLimit);
return jdbcTemplate.query("""
SELECT t.id, t.ext_party_id,
COALESCE((SELECT NULLIF(o.person_name, '') FROM aihr_org_snapshot o
WHERE o.tenant_id=t.tenant_id AND o.ext_party_id=t.ext_party_id
ORDER BY o.snapshot_date DESC, o.id DESC LIMIT 1), '员工') AS trainee_name,
t.project_code, t.position_code, t.task_type, t.status, t.material_type,
t.material_attachment_id, t.material_title, t.material_version, t.progress_percent,
t.completion_rule, t.exam_id, e.title AS exam_title, t.recurrence_type,
t.assigned_time, t.due_time, t.completed_time, t.assign_by
FROM aihr_onboard_task t
LEFT JOIN aihr_onboard_exam e ON e.tenant_id=t.tenant_id AND e.id=t.exam_id
WHERE t.tenant_id = ? %s
ORDER BY t.id DESC LIMIT ?
""".formatted(where), this::mapTask, args.toArray());
}
public List<LearningMaterialOption> materials() {
ensureSchema();
return jdbcTemplate.query("""
SELECT id, name, type, update_time, create_time
FROM aihr_knowledge_attach
WHERE tenant_id = ? AND status = 2
ORDER BY COALESCE(update_time, create_time) DESC, id DESC LIMIT 500
""", (rs, rowNum) -> {
Timestamp updated = rs.getTimestamp("update_time");
if (updated == null) updated = rs.getTimestamp("create_time");
return new LearningMaterialOption(rs.getLong("id"), clean(rs.getString("name")),
clean(rs.getString("type")), materialVersion(rs.getLong("id"), updated),
format(updated));
}, tenantId());
}
public List<LearningExamOption> publishedExams() {
ensureSchema();
return jdbcTemplate.query("""
SELECT e.id, e.title, e.project_code, e.position_code, e.due_date, COUNT(t.id) target_count
FROM aihr_onboard_exam e
JOIN aihr_onboard_exam_target t ON t.tenant_id=e.tenant_id AND t.exam_id=e.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.due_date
ORDER BY e.id DESC LIMIT 200
""", (rs, rowNum) -> new LearningExamOption(rs.getLong("id"), rs.getString("title"),
rs.getString("project_code"), rs.getString("position_code"),
rs.getDate("due_date") == null ? "" : rs.getDate("due_date").toLocalDate().toString(),
rs.getInt("target_count")), tenantId());
}
public LearningTaskBatchResponse dispatch(LearningTaskDispatchRequest request, String operator) {
ensureSchema();
DispatchContext context = validateRequest(request);
List<LearningTaskTarget> targets = request.targets();
Map<String, OrgTarget> orgTargets = loadOrgTargets(targets, context.projectCode());
Set<String> seen = new LinkedHashSet<>();
List<LearningTaskBatchItemResponse> items = new ArrayList<>();
int created = 0;
int duplicate = 0;
int failed = 0;
for (LearningTaskTarget target : targets) {
String extPartyId = clean(target == null ? null : target.extPartyId());
OrgTarget org = orgTargets.get(extPartyId);
String name = org == null ? clean(target == null ? null : target.traineeName()) : org.name();
if (extPartyId.isEmpty()) {
items.add(new LearningTaskBatchItemResponse("", name, "FAILED", "缺少员工ID", null));
failed++;
continue;
}
if (!seen.add(extPartyId)) {
items.add(new LearningTaskBatchItemResponse(extPartyId, name, "DUPLICATE", "本次名单中已包含该员工", null));
duplicate++;
continue;
}
if (org == null) {
items.add(new LearningTaskBatchItemResponse(extPartyId, name, "FAILED", "员工不在所选项目的在职名单中", null));
failed++;
continue;
}
if (!context.positionCode().equals(canonicalPosition(org.positionName()))) {
items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED", "员工岗位与任务岗位不匹配", null));
failed++;
continue;
}
if (context.examId() != null && !isExamTarget(context.examId(), extPartyId)) {
items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED", "员工不在所选考试的发布名单中", null));
failed++;
continue;
}
String dispatchKey = context.requestId() + ":0";
LearningTaskManageResponse existing = findByDispatch(extPartyId, dispatchKey);
if (existing != null) {
if (!sameDispatch(existing, context)) {
items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED",
"派发请求ID已用于不同的任务配置", null));
failed++;
} else {
items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "DUPLICATE",
"该批次已派发,无需重复创建", existing));
duplicate++;
}
continue;
}
try {
jdbcTemplate.update("""
INSERT INTO aihr_onboard_task
(tenant_id, ext_party_id, project_code, position_code, task_type, status, assign_by,
assigned_time, due_time, material_type, material_attachment_id, material_title,
material_version, progress_percent, completion_rule, exam_id, recurrence_type,
recurrence_series_key, dispatch_request_key, create_time, update_time)
VALUES (?, ?, ?, ?, ?, '待完成', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, now(), now())
""", tenantId(), extPartyId, context.projectCode(), context.positionCode(),
context.taskType(), clean(operator), Timestamp.valueOf(context.startTime()),
Timestamp.valueOf(context.dueTime()), context.materialType(), context.material().id(),
context.material().title(), context.material().version(), context.completionRule(),
context.examId(), context.recurrenceType(), context.requestId(), dispatchKey);
LearningTaskManageResponse task = findByDispatch(extPartyId, dispatchKey);
items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "CREATED", "派发成功", task));
created++;
} catch (DuplicateKeyException duplicateError) {
LearningTaskManageResponse task = findByDispatch(extPartyId, dispatchKey);
if (task != null && sameDispatch(task, context)) {
items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "DUPLICATE", "该批次已派发,无需重复创建", task));
duplicate++;
} else {
items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED", "派发请求ID已用于不同的任务配置", null));
failed++;
}
} catch (RuntimeException error) {
log.warn("learning task dispatch failed for request {} target {}", context.requestId(), extPartyId, error);
items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED", "派发失败,请稍后重试", null));
failed++;
}
}
return new LearningTaskBatchResponse(context.requestId(), items.size(), created, duplicate, failed, items);
}
@Scheduled(cron = "${aihr.learning-task.recurrence-cron:0 */5 * * * *}", zone = "Asia/Shanghai")
public void createDueRecurrences() {
try {
ensureSchema();
List<RecurringTask> rows = jdbcTemplate.query("""
SELECT t.* FROM aihr_onboard_task t
WHERE t.recurrence_type IN ('DAILY','WEEKLY','MONTHLY')
AND t.recurrence_series_key IS NOT NULL AND t.recurrence_series_key <> ''
AND NOT EXISTS (SELECT 1 FROM aihr_onboard_task n
WHERE n.tenant_id=t.tenant_id AND n.ext_party_id=t.ext_party_id
AND n.recurrence_series_key=t.recurrence_series_key AND n.assigned_time > t.assigned_time)
ORDER BY t.assigned_time LIMIT 200
""", (rs, rowNum) -> new RecurringTask(rs.getLong("id"), rs.getString("tenant_id"),
rs.getString("ext_party_id"), rs.getString("project_code"), rs.getString("position_code"),
rs.getString("task_type"), rs.getString("assign_by"), rs.getTimestamp("assigned_time").toLocalDateTime(),
rs.getTimestamp("due_time").toLocalDateTime(), rs.getString("material_type"),
(Long) rs.getObject("material_attachment_id"), rs.getString("material_title"),
rs.getString("material_version"), rs.getString("completion_rule"),
(Long) rs.getObject("exam_id"), rs.getString("recurrence_type"),
rs.getString("recurrence_series_key")));
LocalDateTime now = LocalDateTime.now();
for (RecurringTask row : rows) {
LocalDateTime nextStart = addRecurrence(row.assignedTime(), row.recurrenceType());
if (nextStart.isAfter(now)) continue;
LocalDateTime nextDue = addRecurrence(row.dueTime(), row.recurrenceType());
String key = row.seriesKey() + ":" + nextStart.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
try {
jdbcTemplate.update("""
INSERT INTO aihr_onboard_task
(tenant_id, ext_party_id, project_code, position_code, task_type, status, assign_by,
assigned_time, due_time, material_type, material_attachment_id, material_title,
material_version, progress_percent, completion_rule, exam_id, recurrence_type,
recurrence_series_key, dispatch_request_key, create_time, update_time)
VALUES (?, ?, ?, ?, ?, '待完成', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, now(), now())
""", row.tenantId(), row.extPartyId(), row.projectCode(), row.positionCode(), row.taskType(),
row.assignBy(), Timestamp.valueOf(nextStart), Timestamp.valueOf(nextDue), row.materialType(),
row.materialAttachmentId(), row.materialTitle(), row.materialVersion(), row.completionRule(),
row.examId(), row.recurrenceType(), row.seriesKey(), key);
} catch (DuplicateKeyException ignored) {
// Another node created this recurrence first.
}
}
} catch (ServiceException error) {
log.debug("learning recurrence skipped until schema is available: {}", error.getMessage());
} catch (RuntimeException error) {
log.warn("learning recurrence generation failed", error);
}
}
private DispatchContext validateRequest(LearningTaskDispatchRequest request) {
if (request == null || request.targets() == null || request.targets().isEmpty()) throw new ServiceException("请至少选择一名员工");
if (request.targets().size() > 100) throw new ServiceException("单次最多派发100名员工");
String requestId = clean(request.requestId());
if (requestId.isEmpty() || requestId.length() > 90 || !requestId.matches("[A-Za-z0-9._:-]+")) throw new ServiceException("派发请求ID格式不正确");
String project = clean(request.projectCode());
String position = canonicalPosition(request.positionCode());
if (project.isEmpty() || position.isEmpty()) throw new ServiceException("请选择项目和岗位");
String taskType = truncate(clean(request.taskType()), 20);
if (taskType.isEmpty()) throw new ServiceException("请填写任务名称");
String materialType = clean(request.materialType()).toUpperCase(Locale.ROOT);
if (!MATERIAL_TYPES.contains(materialType)) throw new ServiceException("学习材料类型不正确");
MaterialSnapshot material = requireMaterial(request.materialAttachmentId());
String completionRule = clean(request.completionRule()).toUpperCase(Locale.ROOT);
if (!COMPLETION_RULES.contains(completionRule)) throw new ServiceException("完成条件不正确");
String recurrence = clean(request.recurrenceType()).toUpperCase(Locale.ROOT);
if (!RECURRENCES.contains(recurrence)) throw new ServiceException("任务周期不正确");
if ("EXAM_PASS".equals(completionRule) && request.examId() == null) throw new ServiceException("考试通过型任务必须选择考试");
if ("EXAM_PASS".equals(completionRule) && !"ONCE".equals(recurrence)) throw new ServiceException("考试通过型任务当前只支持单次派发");
ExamSnapshot exam = request.examId() == null ? null : requireExam(request.examId(), project, position);
LocalDateTime start = parseTime(request.startTime(), "生效时间");
LocalDateTime due = parseTime(request.dueTime(), "截止时间");
if (!due.isAfter(start)) throw new ServiceException("截止时间必须晚于生效时间");
if (exam != null && exam.dueDate() != null && due.toLocalDate().isAfter(exam.dueDate())) throw new ServiceException("学习任务截止时间不能晚于考试截止日期");
if (!"ONCE".equals(recurrence) && due.isAfter(addRecurrence(start, recurrence))) throw new ServiceException("周期任务的截止时间不能跨入下一周期");
return new DispatchContext(requestId, project, position, taskType, materialType, material, completionRule,
request.examId(), recurrence, start, due);
}
private Map<String, OrgTarget> loadOrgTargets(List<LearningTaskTarget> targets, String projectCode) {
List<String> ids = targets.stream().filter(item -> item != null).map(LearningTaskTarget::extPartyId)
.map(AihrLearningTaskService::clean).filter(value -> !value.isEmpty()).distinct().toList();
if (ids.isEmpty()) return Map.of();
List<Object> args = new ArrayList<>();
args.add(tenantId()); args.add(projectCode); args.addAll(ids);
List<OrgTarget> rows = jdbcTemplate.query("""
SELECT ext_party_id, person_name, position_name FROM aihr_org_snapshot
WHERE tenant_id=? AND project_code=? AND employment_status='active' AND ext_party_id IN (%s)
ORDER BY snapshot_date DESC, id DESC
""".formatted(placeholders(ids.size())), (rs, rowNum) -> new OrgTarget(rs.getString("ext_party_id"),
clean(rs.getString("person_name")).isEmpty() ? "员工" : rs.getString("person_name"), rs.getString("position_name")), args.toArray());
Map<String, OrgTarget> result = new LinkedHashMap<>();
rows.forEach(row -> result.putIfAbsent(row.extPartyId(), row));
return result;
}
private MaterialSnapshot requireMaterial(Long attachmentId) {
if (attachmentId == null || attachmentId <= 0) throw new ServiceException("请选择已解析的学习材料");
List<MaterialSnapshot> rows = jdbcTemplate.query("""
SELECT id, name, update_time, create_time FROM aihr_knowledge_attach
WHERE tenant_id=? AND id=? AND status=2 LIMIT 1
""", (rs, rowNum) -> {
Timestamp updated = rs.getTimestamp("update_time");
if (updated == null) updated = rs.getTimestamp("create_time");
return new MaterialSnapshot(rs.getLong("id"), clean(rs.getString("name")), materialVersion(rs.getLong("id"), updated));
}, tenantId(), attachmentId);
if (rows.isEmpty()) throw new ServiceException("学习材料不存在、未解析完成或不属于当前租户");
return rows.get(0);
}
private ExamSnapshot requireExam(Long examId, String projectCode, String positionCode) {
List<ExamSnapshot> rows = jdbcTemplate.query("""
SELECT project_code, position_code, due_date FROM aihr_onboard_exam
WHERE tenant_id=? AND id=? AND status='PUBLISHED' LIMIT 1
""", (rs, rowNum) -> new ExamSnapshot(clean(rs.getString("project_code")),
canonicalPosition(rs.getString("position_code")), rs.getDate("due_date") == null ? null : rs.getDate("due_date").toLocalDate()), tenantId(), examId);
if (rows.isEmpty()) throw new ServiceException("考试不存在、未发布或不属于当前租户");
ExamSnapshot exam = rows.get(0);
if (!exam.projectCode().isEmpty() && !exam.projectCode().equals(projectCode)) throw new ServiceException("考试与所选项目不匹配");
if (!exam.positionCode().isEmpty() && !exam.positionCode().equals(positionCode)) throw new ServiceException("考试与所选岗位不匹配");
return exam;
}
private boolean isExamTarget(Long examId, String extPartyId) {
Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM aihr_onboard_exam_target WHERE tenant_id=? AND exam_id=? AND ext_party_id=?", Integer.class, tenantId(), examId, extPartyId);
return count != null && count > 0;
}
private LearningTaskManageResponse findByDispatch(String extPartyId, String dispatchKey) {
List<LearningTaskManageResponse> rows = jdbcTemplate.query("""
SELECT t.*, COALESCE(NULLIF(o.person_name,''),'员工') trainee_name, e.title exam_title
FROM aihr_onboard_task t
LEFT JOIN aihr_org_snapshot o ON o.tenant_id=t.tenant_id AND o.ext_party_id=t.ext_party_id AND o.employment_status='active'
LEFT JOIN aihr_onboard_exam e ON e.tenant_id=t.tenant_id AND e.id=t.exam_id
WHERE t.tenant_id=? AND t.ext_party_id=? AND t.dispatch_request_key=? ORDER BY o.snapshot_date DESC, o.id DESC LIMIT 1
""", this::mapTask, tenantId(), extPartyId, dispatchKey);
return rows.isEmpty() ? null : rows.get(0);
}
private static boolean sameDispatch(LearningTaskManageResponse task, DispatchContext context) {
return task != null
&& clean(task.projectCode()).equals(context.projectCode())
&& canonicalPosition(task.positionCode()).equals(context.positionCode())
&& clean(task.taskType()).equals(context.taskType())
&& clean(task.materialType()).equals(context.materialType())
&& java.util.Objects.equals(task.materialAttachmentId(), context.material().id())
&& clean(task.completionRule()).equals(context.completionRule())
&& java.util.Objects.equals(task.examId(), context.examId())
&& clean(task.recurrenceType()).equals(context.recurrenceType())
&& clean(task.assignedTime()).equals(context.startTime().format(TIME_FORMAT))
&& clean(task.dueTime()).equals(context.dueTime().format(TIME_FORMAT));
}
private LearningTaskManageResponse mapTask(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new LearningTaskManageResponse(rs.getLong("id"), rs.getString("ext_party_id"),
rs.getString("trainee_name"), rs.getString("project_code"), rs.getString("position_code"),
rs.getString("task_type"), rs.getString("status"), rs.getString("material_type"),
(Long) rs.getObject("material_attachment_id"), rs.getString("material_title"), rs.getString("material_version"),
rs.getInt("progress_percent"), rs.getString("completion_rule"), (Long) rs.getObject("exam_id"),
rs.getString("exam_title"), rs.getString("recurrence_type"), format(rs.getTimestamp("assigned_time")),
format(rs.getTimestamp("due_time")), format(rs.getTimestamp("completed_time")), rs.getString("assign_by"));
}
private void ensureSchema() {
if (schemaReady) return;
synchronized (this) {
if (schemaReady) return;
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_onboard_task", "aihr_knowledge_attach",
"aihr_onboard_exam", "aihr_onboard_exam_target", "aihr_org_snapshot");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_task", "material_type",
"material_attachment_id", "material_title", "material_version", "progress_percent",
"completion_rule", "exam_id", "recurrence_type", "recurrence_series_key", "dispatch_request_key");
AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_onboard_task",
"uk_aihr_onboard_task_dispatch", true, "tenant_id", "ext_party_id", "dispatch_request_key");
schemaReady = true;
}
}
static LocalDateTime addRecurrence(LocalDateTime value, String recurrence) {
return switch (clean(recurrence).toUpperCase(Locale.ROOT)) {
case "DAILY" -> value.plusDays(1);
case "WEEKLY" -> value.plusWeeks(1);
case "MONTHLY" -> value.plusMonths(1);
default -> value;
};
}
private static LocalDateTime parseTime(String value, String label) {
String text = clean(value).replace('T', ' ');
try {
if (text.length() == 16) text += ":00";
return LocalDateTime.parse(text, TIME_FORMAT);
} catch (DateTimeParseException error) {
throw new ServiceException(label + "格式不正确");
}
}
private String tenantId() {
return clean(TenantHelper.getTenantId()).isEmpty() ? DEFAULT_TENANT : clean(TenantHelper.getTenantId());
}
private static String materialVersion(long id, Timestamp updated) {
return "attachment-" + id + "@" + (updated == null ? "unknown" : updated.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")));
}
private static String canonicalPosition(String value) {
String text = clean(value);
if (text.contains("生活顾问") || text.contains("客服管家") || text.contains("物业管家") || "管家".equals(text)) return "生活顾问";
if (text.contains("保安") || text.contains("秩序") || text.contains("安保")) return "保安";
if (text.contains("保洁") || text.contains("环境")) return "保洁";
if (text.contains("保修") || text.contains("维修") || text.contains("工程")) return "保修";
if (text.contains("客服") || text.contains("前台")) return "客服";
return text;
}
private static String clean(String value) { return value == null ? "" : value.trim(); }
private static String truncate(String value, int length) { return value.length() <= length ? value : value.substring(0, length); }
private static String placeholders(int count) { return String.join(",", java.util.Collections.nCopies(Math.max(1, count), "?")); }
private static String format(Timestamp value) { return value == null ? "" : value.toLocalDateTime().format(TIME_FORMAT); }
private record MaterialSnapshot(Long id, String title, String version) { }
private record ExamSnapshot(String projectCode, String positionCode, LocalDate dueDate) { }
private record OrgTarget(String extPartyId, String name, String positionName) { }
private record DispatchContext(String requestId, String projectCode, String positionCode, String taskType,
String materialType, MaterialSnapshot material, String completionRule, Long examId,
String recurrenceType, LocalDateTime startTime, LocalDateTime dueTime) { }
private record RecurringTask(Long id, String tenantId, String extPartyId, String projectCode, String positionCode,
String taskType, String assignBy, LocalDateTime assignedTime, LocalDateTime dueTime,
String materialType, Long materialAttachmentId, String materialTitle, String materialVersion,
String completionRule, Long examId, String recurrenceType, String seriesKey) { }
}
@@ -11,7 +11,9 @@ import org.dromara.aihr.domain.AihrCaseDto.CurateResponse;
import org.dromara.aihr.domain.AihrCaseDto.DetailResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
import org.dromara.aihr.domain.AihrCaseDto.PublishRequest;
import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.RetireRequest;
import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest;
import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse;
import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
@@ -24,8 +26,10 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -51,6 +55,7 @@ public class AihrCaseService {
".mp3", ".wav", ".m4a", ".webm", ".ogg", ".aac", ".flac"
);
private static final String CASE_PROMPT_VERSION = "case-curation-v2";
private static final String KNOWLEDGE_STATUS_CANDIDATE = "CANDIDATE";
private final AihrSpeechService speechService;
private final AihrModelSeedService modelService;
@@ -138,14 +143,100 @@ public class AihrCaseService {
}
jdbcTemplate.update("""
UPDATE aihr_case_record
SET supervisor_comment = ?, status = '已入库', reviewer_id = ?, reviewed_time = now(), update_time = now()
SET supervisor_comment = ?, status = '已审核', knowledge_status = 'CANDIDATE',
reviewer_id = ?, reviewed_time = now(), update_time = now()
WHERE tenant_id = ? AND case_id = ? AND status = '待审核'
AND (knowledge_status IS NULL OR knowledge_status <> 'PUBLISHED')
""", comment, currentReviewerId(), tenantId(), state.id());
cases.computeIfPresent(state.id(), (id, current) -> new CaseState(current.id(), current.fileName(), current.projectExtOrgId(),
current.transcript(), current.tags(), current.summary(), current.learningPoints(), comment,
current.mediaOssId(), current.mediaUrl(), current.owner()));
}
/**
* Move a reviewed experience from the candidate pool into the formal
* knowledge-facing case library. This is deliberately a separate write
* from review so AI summaries, raw interviews, and unscoped drafts cannot
* become employee-visible facts by themselves.
*/
public void publish(String caseId, PublishRequest request, List<String> projectScopes) {
CaseState state = requireCase(caseId, projectScopes);
PublishMetadata metadata = validatePublishRequest(request);
int updated = jdbcTemplate.update("""
UPDATE aihr_case_record
SET status = '已入库', knowledge_status = 'PUBLISHED', source_type = ?,
applicable_positions = ?, content_version = ?, release_owner = ?,
release_note = ?, source_authorized = 1, deidentified = 1,
effective_from = ?, effective_to = ?, released_by = ?, released_time = now(), update_time = now()
WHERE tenant_id = ? AND case_id = ? AND status = '已审核'
AND (knowledge_status IS NULL OR knowledge_status = 'CANDIDATE')
""", metadata.sourceType(), metadata.applicablePositions(), metadata.contentVersion(),
metadata.releaseOwner(), metadata.releaseNote(), metadata.effectiveFrom(), metadata.effectiveTo(),
currentReviewerId(), tenantId(), state.id());
if (updated != 1) {
throw new IllegalStateException("案例必须先完成业务审核,且不能重复发布");
}
}
public void retire(String caseId, RetireRequest request, List<String> projectScopes) {
CaseState state = requireCase(caseId, projectScopes);
String reason = truncate(maskSensitiveText(request == null ? "" : request.reason()), 500);
if (reason.isBlank()) {
throw new IllegalArgumentException("下线必须填写原因");
}
int updated = jdbcTemplate.update("""
UPDATE aihr_case_record
SET status = '已下线', knowledge_status = 'OFFLINE', retire_reason = ?,
released_by = ?, released_time = now(), update_time = now()
WHERE tenant_id = ? AND case_id = ? AND knowledge_status = 'PUBLISHED'
""", reason, currentReviewerId(), tenantId(), state.id());
if (updated != 1) {
throw new IllegalStateException("案例当前不是已发布状态,不能重复下线");
}
}
private PublishMetadata validatePublishRequest(PublishRequest request) {
if (request == null) {
throw new IllegalArgumentException("正式发布必须填写来源、适用岗位、版本和负责人");
}
String sourceType = truncate(clean(request.sourceType()), 40);
String positions = truncate(clean(request.applicablePositions()), 500);
String contentVersion = truncate(clean(request.contentVersion()), 50);
String releaseOwner = truncate(maskSensitiveText(clean(request.releaseOwner())), 80);
String releaseNote = truncate(maskSensitiveText(clean(request.releaseNote())), 1000);
if (sourceType.isBlank() || positions.isBlank() || contentVersion.isBlank() || releaseOwner.isBlank()) {
throw new IllegalArgumentException("正式发布必须填写来源、适用岗位、版本和负责人");
}
if (!contentVersion.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,49}")) {
throw new IllegalArgumentException("内容版本只能包含字母、数字、点、下划线或短横线");
}
if (!Boolean.TRUE.equals(request.sourceAuthorized()) || !Boolean.TRUE.equals(request.deidentified())) {
throw new IllegalArgumentException("正式发布前必须确认来源已授权且内容已脱敏");
}
LocalDate effectiveFrom = parseDate(request.effectiveFrom(), "生效日期", true);
LocalDate effectiveTo = parseDate(request.effectiveTo(), "失效日期", false);
if (effectiveTo != null && effectiveTo.isBefore(effectiveFrom)) {
throw new IllegalArgumentException("失效日期不能早于生效日期");
}
return new PublishMetadata(sourceType, positions, contentVersion, releaseOwner, releaseNote,
java.sql.Date.valueOf(effectiveFrom), effectiveTo == null ? null : java.sql.Date.valueOf(effectiveTo));
}
private static LocalDate parseDate(String value, String label, boolean required) {
String text = clean(value);
if (text.isBlank()) {
if (required) {
throw new IllegalArgumentException(label + "不能为空");
}
return null;
}
try {
return LocalDate.parse(text);
} catch (DateTimeParseException error) {
throw new IllegalArgumentException(label + "格式应为 yyyy-MM-dd");
}
}
public List<RecordResponse> records(Integer limit, List<String> projectScopes) {
ensureCaseTable();
if (projectScopes != null && projectScopes.isEmpty()) {
@@ -158,10 +249,13 @@ public class AihrCaseService {
projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")";
args.addAll(projectScopes);
}
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'";
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'"
+ " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())"
+ " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())";
args.add(safeLimit);
String sql = """
SELECT case_id, title, type, status, update_time, owner, summary
SELECT case_id, title, type, status, knowledge_status, source_type,
applicable_positions, content_version, update_time, owner, summary
FROM aihr_case_record
WHERE tenant_id = ?
""" + projectWhere + publishedWhere + """
@@ -170,14 +264,18 @@ public class AihrCaseService {
""";
args.add(0, tenantId());
return jdbcTemplate.query(sql, (rs, rowNum) -> new RecordResponse(
firstNonBlank(rs.getString("case_id"), ""),
firstNonBlank(rs.getString("title"), "现场案例"),
firstNonBlank(rs.getString("type"), "现场案例"),
firstNonBlank(rs.getString("status"), "已入库"),
rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT),
firstNonBlank(rs.getString("owner"), "培训组"),
firstNonBlank(rs.getString("summary"), "")
), args.toArray());
firstNonBlank(rs.getString("case_id"), ""),
firstNonBlank(rs.getString("title"), "现场案例"),
firstNonBlank(rs.getString("type"), "现场案例"),
firstNonBlank(rs.getString("status"), "已入库"),
rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT),
firstNonBlank(rs.getString("owner"), "培训组"),
firstNonBlank(rs.getString("summary"), ""),
firstNonBlank(rs.getString("knowledge_status"), KNOWLEDGE_STATUS_CANDIDATE),
firstNonBlank(rs.getString("source_type"), ""),
firstNonBlank(rs.getString("applicable_positions"), ""),
firstNonBlank(rs.getString("content_version"), "")
), args.toArray());
}
public DetailResponse detail(String caseId, List<String> projectScopes) {
@@ -193,9 +291,13 @@ public class AihrCaseService {
projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")";
args.addAll(projectScopes);
}
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'";
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'"
+ " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())"
+ " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())";
String sql = """
SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary, media_url, media_oss_id,
SELECT case_id, file_name, title, type, status, knowledge_status, source_type,
applicable_positions, content_version, effective_from, effective_to,
release_owner, release_note, retire_reason, update_time, owner, transcript, summary, media_url, media_oss_id,
learning_points, supervisor_comment
FROM aihr_case_record
WHERE tenant_id = ? AND case_id = ?
@@ -207,17 +309,26 @@ public class AihrCaseService {
firstNonBlank(rs.getString("case_id"), normalizedCaseId),
firstNonBlank(rs.getString("file_name"), "case-audio"),
firstNonBlank(rs.getString("title"), "现场案例"),
firstNonBlank(rs.getString("type"), "现场案例"),
firstNonBlank(rs.getString("status"), "已入库"),
rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT),
firstNonBlank(rs.getString("type"), "现场案例"),
firstNonBlank(rs.getString("status"), "已入库"),
rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT),
firstNonBlank(rs.getString("owner"), "培训组"),
firstNonBlank(rs.getString("transcript"), ""),
firstNonBlank(rs.getString("summary"), ""),
"",
mediaOssId,
parseLearningPoints(rs.getString("learning_points")),
firstNonBlank(rs.getString("supervisor_comment"), "")
);
mediaOssId,
parseLearningPoints(rs.getString("learning_points")),
firstNonBlank(rs.getString("supervisor_comment"), ""),
firstNonBlank(rs.getString("knowledge_status"), KNOWLEDGE_STATUS_CANDIDATE),
firstNonBlank(rs.getString("source_type"), ""),
firstNonBlank(rs.getString("applicable_positions"), ""),
firstNonBlank(rs.getString("content_version"), ""),
rs.getDate("effective_from") == null ? "" : rs.getDate("effective_from").toLocalDate().toString(),
rs.getDate("effective_to") == null ? "" : rs.getDate("effective_to").toLocalDate().toString(),
firstNonBlank(rs.getString("release_owner"), ""),
firstNonBlank(rs.getString("release_note"), ""),
firstNonBlank(rs.getString("retire_reason"), "")
);
}, args.toArray()).stream().findFirst().orElse(null);
}
@@ -238,7 +349,9 @@ public class AihrCaseService {
projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")";
args.addAll(projectScopes);
}
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'";
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'"
+ " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())"
+ " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())";
String sql = """
SELECT media_oss_id
FROM aihr_case_record
@@ -309,8 +422,9 @@ public class AihrCaseService {
ensureCaseTable();
jdbcTemplate.update("""
INSERT INTO aihr_case_record
(tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, learning_points, supervisor_comment, media_oss_id, media_url, owner, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
(tenant_id, case_id, file_name, project_ext_org_id, title, type, status, knowledge_status,
transcript, summary, learning_points, supervisor_comment, media_oss_id, media_url, owner, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, 'CANDIDATE', ?, ?, ?, ?, ?, ?, ?, now(), now())
ON DUPLICATE KEY UPDATE
file_name = VALUES(file_name),
project_ext_org_id = VALUES(project_ext_org_id),
@@ -382,7 +496,10 @@ public class AihrCaseService {
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_case_record",
"media_oss_id", "media_url", "learning_points", "supervisor_comment",
"synthetic_content", "generator_type", "model_name", "prompt_version",
"grounding_oss_id", "ai_summary_hash", "reviewer_id", "reviewed_time");
"grounding_oss_id", "ai_summary_hash", "reviewer_id", "reviewed_time",
"knowledge_status", "source_type", "applicable_positions", "content_version",
"source_authorized", "deidentified", "effective_from", "effective_to",
"release_owner", "release_note", "released_by", "released_time", "retire_reason");
tableReady = true;
return;
}
@@ -403,6 +520,19 @@ public class AihrCaseService {
`media_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID',
`media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址',
`owner` varchar(80) DEFAULT '培训组' COMMENT '负责人',
`knowledge_status` varchar(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT 'CANDIDATE/PUBLISHED/OFFLINE',
`source_type` varchar(40) DEFAULT NULL COMMENT '经验来源类型',
`applicable_positions` varchar(500) DEFAULT NULL COMMENT '适用岗位',
`content_version` varchar(50) DEFAULT NULL COMMENT '正式经验版本',
`source_authorized` tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源是否已授权',
`deidentified` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否已脱敏',
`effective_from` date DEFAULT NULL COMMENT '生效日期',
`effective_to` date DEFAULT NULL COMMENT '失效日期',
`release_owner` varchar(80) DEFAULT NULL COMMENT '正式发布负责人',
`release_note` varchar(1000) DEFAULT NULL COMMENT '正式发布说明',
`released_by` bigint DEFAULT NULL COMMENT '正式发布操作人',
`released_time` datetime DEFAULT NULL COMMENT '正式发布时间',
`retire_reason` varchar(500) DEFAULT NULL COMMENT '下线原因',
`synthetic_content` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否包含AI或确定性生成内容',
`generator_type` varchar(40) DEFAULT NULL COMMENT '生成器类型',
`model_name` varchar(120) DEFAULT NULL COMMENT '生成模型',
@@ -430,6 +560,19 @@ public class AihrCaseService {
addCaseColumn("ai_summary_hash", "ALTER TABLE aihr_case_record ADD COLUMN ai_summary_hash char(64) DEFAULT NULL COMMENT '整理摘要SHA-256'");
addCaseColumn("reviewer_id", "ALTER TABLE aihr_case_record ADD COLUMN reviewer_id bigint DEFAULT NULL COMMENT '人工审核人'");
addCaseColumn("reviewed_time", "ALTER TABLE aihr_case_record ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT '人工审核时间'");
addCaseColumn("knowledge_status", "ALTER TABLE aihr_case_record ADD COLUMN knowledge_status varchar(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT 'CANDIDATE/PUBLISHED/OFFLINE'");
addCaseColumn("source_type", "ALTER TABLE aihr_case_record ADD COLUMN source_type varchar(40) DEFAULT NULL COMMENT '经验来源类型'");
addCaseColumn("applicable_positions", "ALTER TABLE aihr_case_record ADD COLUMN applicable_positions varchar(500) DEFAULT NULL COMMENT '适用岗位'");
addCaseColumn("content_version", "ALTER TABLE aihr_case_record ADD COLUMN content_version varchar(50) DEFAULT NULL COMMENT '正式经验版本'");
addCaseColumn("source_authorized", "ALTER TABLE aihr_case_record ADD COLUMN source_authorized tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源是否已授权'");
addCaseColumn("deidentified", "ALTER TABLE aihr_case_record ADD COLUMN deidentified tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否已脱敏'");
addCaseColumn("effective_from", "ALTER TABLE aihr_case_record ADD COLUMN effective_from date DEFAULT NULL COMMENT '生效日期'");
addCaseColumn("effective_to", "ALTER TABLE aihr_case_record ADD COLUMN effective_to date DEFAULT NULL COMMENT '失效日期'");
addCaseColumn("release_owner", "ALTER TABLE aihr_case_record ADD COLUMN release_owner varchar(80) DEFAULT NULL COMMENT '正式发布负责人'");
addCaseColumn("release_note", "ALTER TABLE aihr_case_record ADD COLUMN release_note varchar(1000) DEFAULT NULL COMMENT '正式发布说明'");
addCaseColumn("released_by", "ALTER TABLE aihr_case_record ADD COLUMN released_by bigint DEFAULT NULL COMMENT '正式发布操作人'");
addCaseColumn("released_time", "ALTER TABLE aihr_case_record ADD COLUMN released_time datetime DEFAULT NULL COMMENT '正式发布时间'");
addCaseColumn("retire_reason", "ALTER TABLE aihr_case_record ADD COLUMN retire_reason varchar(500) DEFAULT NULL COMMENT '下线原因'");
tableReady = true;
}
}
@@ -768,6 +911,11 @@ public class AihrCaseService {
private record MediaRef(Long ossId, String url) {
}
private record PublishMetadata(String sourceType, String applicablePositions, String contentVersion,
String releaseOwner, String releaseNote, java.sql.Date effectiveFrom,
java.sql.Date effectiveTo) {
}
private record CaseSummary(List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary,
String source, String modelName, String promptVersion) {
}
@@ -11,6 +11,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskProgressRequest;
import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse;
@@ -130,6 +131,14 @@ public class AihrMobileSeedService {
return practiceSeedService.requireMobileIdentity(mobilePhone);
}
public String requireMobileOrganizationIdentity(String mobilePhone) {
return practiceSeedService.requireMobileOrganizationIdentity(mobilePhone);
}
public String requireMobilePhoneForOrganizationIdentity(String organizationExtPartyId) {
return practiceSeedService.requireMobilePhoneForOrganizationIdentity(organizationExtPartyId);
}
/**
* Resolves an APP supervisor phone to the verified organization external
* ID used by team-scope queries.
@@ -226,6 +235,10 @@ public class AihrMobileSeedService {
return practiceSeedService.completeOnboardTask(taskId, extPartyId, evidenceRef);
}
public boolean updateOnboardTaskProgress(Long taskId, String extPartyId, OnboardTaskProgressRequest request) {
return practiceSeedService.updateOnboardTaskProgress(taskId, extPartyId, request);
}
public QualificationResponse qualification(String extPartyId) {
return practiceSeedService.qualification(extPartyId);
}
@@ -32,6 +32,7 @@ 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.PracticeQuestionEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionReviewRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamProfileResponse;
@@ -40,6 +41,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskProgressRequest;
import org.dromara.aihr.domain.AihrPracticeDto.QualificationGateResponse;
import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
@@ -123,6 +125,7 @@ public class AihrPracticeSeedService {
);
private static final List<String> GROWTH_LEVELS = List.of("初级", "中级", "高级");
private static final Set<String> SCENARIO_REVIEW_STATUSES = Set.of("草稿", "待业务审核", "已发布", "已下线");
private static final Set<String> EXAM_QUESTION_FORMATS = Set.of("single", "multiple", "judge", "short");
private static final Set<String> SCENARIO_RISK_LEVELS = Set.of("待评估", "常规", "高风险");
private static final List<String> PUBLICATION_PLACEHOLDER_MARKERS = List.of(
"待补", "待提供", "待完善", "待定", "占位内容", "占位文本", "占位符", "placeholder", "示例来源", "示例内容",
@@ -797,7 +800,9 @@ public class AihrPracticeSeedService {
return jdbcTemplate.query("""
SELECT q.question_code, q.scenario_code, COALESCE(s.scenario_name, q.scenario_code) AS scenario_name,
q.position_code, q.question_type, q.question_text, q.reference_answer,
q.content_version, q.content_hash, q.enabled, q.sort_order
q.content_version, q.content_hash, q.enabled, q.sort_order, q.exam_format,
q.exam_options_json, q.exam_answers_json, q.exam_explanation, q.exam_enabled,
q.review_status, q.reviewed_by, q.reviewed_time
FROM aihr_learning_question q
LEFT JOIN aihr_practice_scenario s
ON s.tenant_id = q.tenant_id AND s.scenario_code = q.scenario_code
@@ -833,23 +838,40 @@ public class AihrPracticeSeedService {
PracticeQuestionState existing = practiceQuestionState(code);
boolean enabled = request == null || request.enabled() == null || request.enabled();
int sortOrder = Math.max(0, request == null || request.sortOrder() == null ? 100 : request.sortOrder());
if (samePracticeQuestion(existing, scenarioCode, position, type, question, reference, enabled, sortOrder)) {
boolean examEnabled = request != null && Boolean.TRUE.equals(request.examEnabled());
String examFormat = normalizeExamFormat(request == null ? null : request.examFormat(), examEnabled);
List<String> examOptions = cleanExamValues(request == null ? null : request.examOptions());
List<String> examAnswers = cleanExamValues(request == null ? null : request.examAnswers());
String examExplanation = truncateValue(firstNonBlank(request == null ? null : request.examExplanation(), ""), 1000);
validateExamQuestion(examEnabled, examFormat, examOptions, examAnswers);
if (samePracticeQuestion(existing, scenarioCode, position, type, question, reference, enabled, sortOrder,
examFormat, examOptions, examAnswers, examExplanation, examEnabled)) {
return practiceQuestionResponse(code);
}
String version = nextQuestionContentVersion(code);
String hash = AihrLearningService.contentHash(version, question, reference);
String hash = AihrLearningService.contentHash(version,
question + "\n" + examFormat + "\n" + writeJson(examOptions),
reference + "\n" + writeJson(examAnswers) + "\n" + examExplanation);
Timestamp now = Timestamp.valueOf(businessNow());
jdbcTemplate.update("""
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
content_version, content_hash, enabled, sort_order, exam_format, exam_options_json,
exam_answers_json, exam_explanation, exam_enabled, review_status, review_note, reviewed_by,
reviewed_time, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', NULL, NULL, NULL, ?, ?)
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)
""", tenantId(), code, scenarioCode, position, type, question, reference, version, hash, enabled, sortOrder, now, now);
sort_order = VALUES(sort_order), exam_format = VALUES(exam_format),
exam_options_json = VALUES(exam_options_json), exam_answers_json = VALUES(exam_answers_json),
exam_explanation = VALUES(exam_explanation), exam_enabled = VALUES(exam_enabled),
review_status = 'DRAFT', review_note = NULL, reviewed_by = NULL, reviewed_time = NULL,
update_time = VALUES(update_time)
""", tenantId(), code, scenarioCode, position, type, question, reference, version, hash, enabled, sortOrder,
emptyToNull(examFormat), writeJson(examOptions), writeJson(examAnswers), emptyToNull(examExplanation),
examEnabled, now, now);
Map<String, QuestionReviewChange> affectedScenarios = new LinkedHashMap<>();
if (existing != null && !scenarioCode.equals(existing.scenarioCode())) {
affectedScenarios.put(existing.scenarioCode(), new QuestionReviewChange(
@@ -881,10 +903,14 @@ public class AihrPracticeSeedService {
}
ScenarioResponse scenario = scenario(existing.scenarioCode(), true);
String version = nextQuestionContentVersion(questionCode);
String hash = AihrLearningService.contentHash(version, existing.question(), existing.referenceAnswer());
String hash = AihrLearningService.contentHash(version,
existing.question() + "\n" + firstNonBlank(existing.examFormat(), "") + "\n" + writeJson(existing.examOptions()),
existing.referenceAnswer() + "\n" + writeJson(existing.examAnswers()) + "\n"
+ firstNonBlank(existing.examExplanation(), ""));
int updated = jdbcTemplate.update("""
UPDATE aihr_learning_question
SET enabled = ?, content_version = ?, content_hash = ?, update_time = ?
SET enabled = ?, content_version = ?, content_hash = ?, review_status = 'DRAFT',
review_note = NULL, reviewed_by = NULL, reviewed_time = NULL, update_time = ?
WHERE tenant_id = ? AND question_code = ?
""", enabled, version, hash, Timestamp.valueOf(businessNow()), tenantId(), questionCode);
if (updated <= 0) {
@@ -895,13 +921,38 @@ public class AihrPracticeSeedService {
return true;
}
@Transactional(rollbackFor = Exception.class)
public PracticeQuestionResponse reviewQuestion(String code, PracticeQuestionReviewRequest request, String reviewer) {
ensureLearningQuestionTable();
PracticeQuestionState existing = practiceQuestionState(code);
if (existing == null) {
throw new ServiceException("训练题目不存在");
}
String decision = firstNonBlank(request == null ? null : request.decision(), "").toUpperCase(Locale.ROOT);
if (!Set.of("APPROVE", "REJECT").contains(decision)) {
throw new ServiceException("审核决定只能是 APPROVE 或 REJECT");
}
if ("APPROVE".equals(decision)) {
validateExamQuestion(existing.examEnabled(), existing.examFormat(), existing.examOptions(), existing.examAnswers());
}
String status = "APPROVE".equals(decision) ? "APPROVED" : "REJECTED";
jdbcTemplate.update("""
UPDATE aihr_learning_question
SET review_status = ?, review_note = ?, reviewed_by = ?, reviewed_time = now(), update_time = now()
WHERE tenant_id = ? AND question_code = ?
""", status, truncateValue(request == null ? null : request.note(), 500),
truncateValue(firstNonBlank(reviewer, "operator"), 100), tenantId(), code.trim());
return practiceQuestionResponse(code.trim());
}
private PracticeQuestionState practiceQuestionState(String questionCode) {
if (isBlank(questionCode)) {
return null;
}
List<PracticeQuestionState> rows = jdbcTemplate.query("""
SELECT question_code, scenario_code, position_code, question_type, question_text, reference_answer,
content_version, content_hash, enabled, sort_order
content_version, content_hash, enabled, sort_order, exam_format, exam_options_json,
exam_answers_json, exam_explanation, exam_enabled, review_status
FROM aihr_learning_question
WHERE tenant_id = ? AND question_code = ?
LIMIT 1
@@ -909,14 +960,17 @@ public class AihrPracticeSeedService {
rs.getString("question_code"), rs.getString("scenario_code"), rs.getString("position_code"),
rs.getString("question_type"), rs.getString("question_text"), rs.getString("reference_answer"),
firstNonBlank(rs.getString("content_version"), "v1"), rs.getString("content_hash"),
rs.getBoolean("enabled"), rs.getInt("sort_order")
rs.getBoolean("enabled"), rs.getInt("sort_order"), rs.getString("exam_format"),
readStringList(rs.getString("exam_options_json")), readStringList(rs.getString("exam_answers_json")),
rs.getString("exam_explanation"), rs.getBoolean("exam_enabled"), rs.getString("review_status")
), tenantId(), questionCode.trim());
return rows.isEmpty() ? null : rows.get(0);
}
private static boolean samePracticeQuestion(PracticeQuestionState existing, String scenarioCode, String position,
private boolean samePracticeQuestion(PracticeQuestionState existing, String scenarioCode, String position,
String type, String question, String reference, boolean enabled,
int sortOrder) {
int sortOrder, String examFormat, List<String> examOptions,
List<String> examAnswers, String examExplanation, boolean examEnabled) {
return existing != null
&& Objects.equals(existing.scenarioCode(), scenarioCode)
&& Objects.equals(existing.positionCode(), position)
@@ -924,7 +978,84 @@ public class AihrPracticeSeedService {
&& Objects.equals(existing.question(), question)
&& Objects.equals(existing.referenceAnswer(), reference)
&& existing.enabled() == enabled
&& existing.sortOrder() == sortOrder;
&& existing.sortOrder() == sortOrder
&& Objects.equals(firstNonBlank(existing.examFormat(), ""), examFormat)
&& Objects.equals(existing.examOptions(), examOptions)
&& Objects.equals(existing.examAnswers(), examAnswers)
&& Objects.equals(firstNonBlank(existing.examExplanation(), ""), examExplanation)
&& existing.examEnabled() == examEnabled;
}
private String normalizeExamFormat(String value, boolean examEnabled) {
String format = firstNonBlank(value, "").toLowerCase(Locale.ROOT);
if (format.isEmpty() && !examEnabled) {
return "";
}
if (!EXAM_QUESTION_FORMATS.contains(format)) {
throw new ServiceException("考试题型只能是单选、多选、判断或简答");
}
return format;
}
private List<String> cleanExamValues(List<String> values) {
if (values == null) {
return List.of();
}
return values.stream().map(value -> firstNonBlank(value, "")).filter(value -> !value.isEmpty())
.distinct().limit(20).toList();
}
private void validateExamQuestion(boolean examEnabled, String format, List<String> options,
List<String> answers) {
if (!examEnabled) {
return;
}
if (!EXAM_QUESTION_FORMATS.contains(firstNonBlank(format, ""))) {
throw new ServiceException("启用考试用途时必须选择题型");
}
if (answers == null || answers.isEmpty()) {
throw new ServiceException("启用考试用途时必须填写正确答案");
}
if (!"short".equals(format)) {
if (options == null || options.size() < 2) {
throw new ServiceException("客观题至少需要两个选项");
}
if (!options.containsAll(answers)) {
throw new ServiceException("正确答案必须来自已填写选项");
}
}
}
private List<String> readStringList(String json) {
if (isBlank(json)) {
return List.of();
}
try {
JsonNode root = objectMapper.readTree(json);
if (!root.isArray()) {
return List.of();
}
List<String> result = new ArrayList<>();
root.forEach(node -> {
String value = firstNonBlank(node.asText(), "");
if (!value.isEmpty() && !result.contains(value)) {
result.add(value);
}
});
return List.copyOf(result);
} catch (JsonProcessingException error) {
throw new ServiceException("题库选项数据损坏");
}
}
private String emptyToNull(String value) {
String normalized = firstNonBlank(value, "");
return normalized.isEmpty() ? null : normalized;
}
private static String truncateValue(String value, int maxLength) {
String normalized = value == null ? "" : value.trim();
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
}
private PracticeQuestionResponse practiceQuestionResponse(String questionCode) {
@@ -3598,13 +3729,21 @@ public class AihrPracticeSeedService {
args.add(limit);
return jdbcTemplate.query("""
SELECT t.id, t.project_code, t.position_code, t.sop_id, t.course_id, t.task_type,
t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref
t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref,
t.material_type, t.material_attachment_id, t.material_title, t.material_version,
t.progress_percent, t.completion_rule,
CASE WHEN EXISTS (SELECT 1 FROM aihr_onboard_exam_target et
WHERE et.tenant_id = t.tenant_id AND et.exam_id = t.exam_id
AND et.ext_party_id IN (%s)) THEN t.exam_id ELSE NULL END AS exam_id,
t.recurrence_type, t.started_time, t.last_viewed_time,
t.last_position_seconds, t.media_duration_seconds
FROM aihr_onboard_task t
WHERE t.tenant_id = ? AND t.ext_party_id IN (%s)
AND (t.assigned_time IS NULL OR t.assigned_time <= now())
ORDER BY CASE t.status WHEN '待完成' THEN 0 WHEN '进行中' THEN 1 WHEN '已完成' THEN 2 ELSE 3 END,
COALESCE(t.due_time, t.create_time) ASC, t.id DESC
LIMIT ?
""".formatted(inClause(storageKeys.size())), (rs, rowNum) -> new OnboardTaskResponse(
""".formatted(inClause(storageKeys.size()), inClause(storageKeys.size())), (rs, rowNum) -> new OnboardTaskResponse(
rs.getLong("id"),
rs.getString("project_code"),
rs.getString("position_code"),
@@ -3616,8 +3755,14 @@ public class AihrPracticeSeedService {
formatTimestamp(rs.getTimestamp("assigned_time")),
formatTimestamp(rs.getTimestamp("due_time")),
formatTimestamp(rs.getTimestamp("completed_time")),
rs.getString("evidence_ref")
), args.toArray());
rs.getString("evidence_ref"), rs.getString("material_type"),
(Long) rs.getObject("material_attachment_id"), rs.getString("material_title"),
rs.getString("material_version"), rs.getInt("progress_percent"),
rs.getString("completion_rule"), (Long) rs.getObject("exam_id"),
rs.getString("recurrence_type"), formatTimestamp(rs.getTimestamp("started_time")),
formatTimestamp(rs.getTimestamp("last_viewed_time")),
(Integer) rs.getObject("last_position_seconds"), (Integer) rs.getObject("media_duration_seconds")
), currentTaskArgs(storageKeys, limit));
}
private List<PracticeAssignmentResponse> queryCurrentAssignedTasks(List<String> storageKeys, int limit) {
@@ -3767,6 +3912,38 @@ public class AihrPracticeSeedService {
return mobilePracticeIdentity(mobilePhone).mobilePhone();
}
/** Resolve the authenticated APP phone to the one verified organization ID. */
public String requireMobileOrganizationIdentity(String mobilePhone) {
String phone = isBlank(mobilePhone) ? "" : mobilePhone.trim();
if (phone.isEmpty()) {
throw new ServiceException("当前登录账号缺少手机号");
}
String organizationId = resolveCurrentTaskIdentity(phone).organizationExtPartyId();
if (isBlank(organizationId)) {
throw new ServiceException("当前手机号未唯一匹配在职员工");
}
return organizationId;
}
/** Resolve a verified organization ID back to its unique APP phone. */
public String requireMobilePhoneForOrganizationIdentity(String organizationExtPartyId) {
String organizationId = isBlank(organizationExtPartyId) ? "" : organizationExtPartyId.trim();
if (organizationId.isEmpty()) {
throw new ServiceException("当前员工组织身份缺失");
}
List<String> phones = jdbcTemplate.query("""
SELECT DISTINCT person_phone
FROM aihr_org_snapshot
WHERE tenant_id = ? AND employment_status = 'active'
AND ext_party_id = ? AND person_phone IS NOT NULL AND person_phone <> ''
ORDER BY person_phone
""", (rs, rowNum) -> rs.getString("person_phone"), tenantId(), organizationId);
if (phones.size() != 1 || isBlank(phones.get(0))) {
throw new ServiceException("当前组织身份未唯一匹配手机号");
}
return phones.get(0).trim();
}
private void requireCurrentTaskTable(String tableName) {
try {
Integer count = jdbcTemplate.queryForObject("""
@@ -3794,6 +3971,7 @@ public class AihrPracticeSeedService {
* no task is created from this endpoint.
*/
public List<OnboardTaskResponse> onboardTasks(String extPartyId, int limit) {
ensureOnboardTaskLearningColumns();
if (isBlank(extPartyId) || !formalPositionTablesExist("aihr_onboard_task")) {
return List.of();
}
@@ -3804,13 +3982,21 @@ public class AihrPracticeSeedService {
args.add(normalizeLimit(limit));
return jdbcTemplate.query("""
SELECT t.id, t.project_code, t.position_code, t.sop_id, t.course_id, t.task_type,
t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref
t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref,
t.material_type, t.material_attachment_id, t.material_title, t.material_version,
t.progress_percent, t.completion_rule,
CASE WHEN EXISTS (SELECT 1 FROM aihr_onboard_exam_target et
WHERE et.tenant_id = t.tenant_id AND et.exam_id = t.exam_id
AND et.ext_party_id IN (%s)) THEN t.exam_id ELSE NULL END AS exam_id,
t.recurrence_type, t.started_time, t.last_viewed_time,
t.last_position_seconds, t.media_duration_seconds
FROM aihr_onboard_task t
WHERE t.tenant_id = ? AND t.ext_party_id IN (%s)
AND (t.assigned_time IS NULL OR t.assigned_time <= now())
ORDER BY CASE t.status WHEN '待完成' THEN 0 WHEN '进行中' THEN 1 WHEN '已完成' THEN 2 ELSE 3 END,
COALESCE(t.due_time, t.create_time) ASC, t.id DESC
LIMIT ?
""".formatted(inClause(identity.storageKeys().size())), (rs, rowNum) -> new OnboardTaskResponse(
""".formatted(inClause(identity.storageKeys().size()), inClause(identity.storageKeys().size())), (rs, rowNum) -> new OnboardTaskResponse(
rs.getLong("id"),
rs.getString("project_code"),
rs.getString("position_code"),
@@ -3822,11 +4008,117 @@ public class AihrPracticeSeedService {
formatTimestamp(rs.getTimestamp("assigned_time")),
formatTimestamp(rs.getTimestamp("due_time")),
formatTimestamp(rs.getTimestamp("completed_time")),
rs.getString("evidence_ref")
), args.toArray());
rs.getString("evidence_ref"), rs.getString("material_type"),
(Long) rs.getObject("material_attachment_id"), rs.getString("material_title"),
rs.getString("material_version"), rs.getInt("progress_percent"),
rs.getString("completion_rule"), (Long) rs.getObject("exam_id"),
rs.getString("recurrence_type"), formatTimestamp(rs.getTimestamp("started_time")),
formatTimestamp(rs.getTimestamp("last_viewed_time")),
(Integer) rs.getObject("last_position_seconds"), (Integer) rs.getObject("media_duration_seconds")
), currentTaskArgs(identity.storageKeys(), normalizeLimit(limit)));
}
/**
* Record observed material progress for the authenticated employee.
* Progress is monotonic and the material version is checked against the
* dispatch snapshot so an old client cannot complete a replaced document.
*/
public boolean updateOnboardTaskProgress(Long taskId, String extPartyId, OnboardTaskProgressRequest request) {
ensureOnboardTaskLearningColumns();
if (taskId == null || taskId <= 0 || isBlank(extPartyId) || request == null
|| !formalPositionTablesExist("aihr_onboard_task")) {
return false;
}
EmployeePracticeIdentity identity = mobilePracticeIdentity(extPartyId);
if (identity.storageKeys().isEmpty()) {
return false;
}
List<Object> taskArgs = new ArrayList<>();
taskArgs.add(tenantId());
taskArgs.add(taskId);
taskArgs.addAll(identity.storageKeys());
List<OnboardProgressRow> rows = jdbcTemplate.query("""
SELECT material_type, material_version, progress_percent, status,
started_time, last_position_seconds, media_duration_seconds
FROM aihr_onboard_task
WHERE tenant_id = ? AND id = ? AND ext_party_id IN (%s)
AND status IN ('待完成', '进行中')
LIMIT 1
""".formatted(inClause(identity.storageKeys().size())), (rs, rowNum) -> new OnboardProgressRow(
rs.getString("material_type"), rs.getString("material_version"),
rs.getInt("progress_percent"), rs.getString("status"),
rs.getTimestamp("started_time"), (Integer) rs.getObject("last_position_seconds"),
(Integer) rs.getObject("media_duration_seconds")
), taskArgs.toArray());
if (rows.isEmpty()) {
return false;
}
OnboardProgressRow task = rows.get(0);
String expectedVersion = isBlank(request.materialVersion()) ? "" : request.materialVersion().trim();
String storedVersion = isBlank(task.materialVersion()) ? "" : task.materialVersion().trim();
if (!expectedVersion.isEmpty() && !expectedVersion.equals(storedVersion)) {
throw new ServiceException("学习材料版本已更新,请重新打开最新材料");
}
int currentProgress = Math.max(0, Math.min(100, task.progressPercent()));
int requestedProgress = request.progressPercent() == null ? currentProgress : request.progressPercent();
if (requestedProgress < 0 || requestedProgress > 100) {
throw new ServiceException("学习进度必须在 0-100 之间");
}
boolean completed = Boolean.TRUE.equals(request.completed());
String materialType = (isBlank(task.materialType()) ? "" : task.materialType().trim()).toUpperCase(java.util.Locale.ROOT);
int position = request.positionSeconds() == null
? Math.max(0, task.lastPositionSeconds() == null ? 0 : task.lastPositionSeconds())
: request.positionSeconds();
Integer duration = request.durationSeconds() == null ? task.mediaDurationSeconds() : request.durationSeconds();
if (position < 0 || (duration != null && (duration <= 0 || duration > 24 * 60 * 60))
|| (duration != null && position > duration)) {
throw new ServiceException("材料播放位置不合法");
}
if ("VIDEO".equals(materialType)) {
if (duration == null || duration <= 0) {
throw new ServiceException("视频时长尚未读取,暂不能记录学习进度");
}
int observedProgress = (int) Math.floor(position * 100d / duration);
requestedProgress = Math.min(requestedProgress, observedProgress);
boolean atEnd = position >= duration || observedProgress >= 100;
if (completed && !atEnd) {
throw new ServiceException("请播放至视频末尾后再完成学习");
}
if (requestedProgress > currentProgress + 25 && !atEnd) {
throw new ServiceException("检测到视频快进,请按顺序观看材料");
}
if (atEnd) {
requestedProgress = 100;
}
} else if (completed) {
requestedProgress = 100;
}
int nextProgress = Math.max(currentProgress, requestedProgress);
int nextPosition = Math.max(Math.max(0, task.lastPositionSeconds()), position);
List<Object> updateArgs = new ArrayList<>();
updateArgs.add(nextProgress);
updateArgs.add(nextPosition);
updateArgs.add(duration);
updateArgs.add(nextProgress);
updateArgs.add(tenantId());
updateArgs.add(taskId);
updateArgs.addAll(identity.storageKeys());
return jdbcTemplate.update("""
UPDATE aihr_onboard_task
SET progress_percent = ?,
started_time = COALESCE(started_time, now()),
last_viewed_time = now(),
last_position_seconds = GREATEST(COALESCE(last_position_seconds, 0), ?),
media_duration_seconds = COALESCE(?, media_duration_seconds),
status = CASE WHEN status = '待完成' AND ? > 0 THEN '进行中' ELSE status END,
update_time = now()
WHERE tenant_id = ? AND id = ? AND ext_party_id IN (%s)
AND status IN ('待完成', '进行中')
""".formatted(inClause(identity.storageKeys().size())), updateArgs.toArray()) > 0;
}
public boolean completeOnboardTask(Long taskId, String extPartyId, String evidenceRef) {
ensureOnboardTaskLearningColumns();
if (taskId == null || taskId <= 0 || isBlank(extPartyId) || !formalPositionTablesExist("aihr_onboard_task")) {
return false;
}
@@ -3842,13 +4134,22 @@ public class AihrPracticeSeedService {
args.addAll(identity.storageKeys());
return jdbcTemplate.update("""
UPDATE aihr_onboard_task
SET status = '已完成', completed_time = now(),
SET status = '已完成', progress_percent = 100, completed_time = now(),
evidence_ref = COALESCE(NULLIF(?, ''), evidence_ref), update_time = now()
WHERE tenant_id = ? AND id = ? AND ext_party_id IN (%s)
AND status IN ('待完成', '进行中')
AND status IN ('待完成', '进行中') AND completion_rule = 'MANUAL_CONFIRM'
""".formatted(inClause(identity.storageKeys().size())), args.toArray()) > 0;
}
private Object[] currentTaskArgs(List<String> storageKeys, int limit) {
List<Object> args = new ArrayList<>();
args.addAll(storageKeys);
args.add(tenantId());
args.addAll(storageKeys);
args.add(limit);
return args.toArray();
}
/**
* Return qualification evidence for the current APP user without treating an
* empty rules table as a pass. Formal qualification remains NOT_CONFIGURED until
@@ -6163,7 +6464,15 @@ public class AihrPracticeSeedService {
firstNonBlank(rs.getString("content_version"), "v1"),
rs.getString("content_hash"),
rs.getBoolean("enabled"),
rs.getInt("sort_order")
rs.getInt("sort_order"),
rs.getString("exam_format"),
readStringList(rs.getString("exam_options_json")),
readStringList(rs.getString("exam_answers_json")),
rs.getString("exam_explanation"),
rs.getBoolean("exam_enabled"),
firstNonBlank(rs.getString("review_status"), "DRAFT"),
rs.getString("reviewed_by"),
formatTimestamp(rs.getTimestamp("reviewed_time"))
);
}
@@ -7278,7 +7587,9 @@ public class AihrPracticeSeedService {
requirePracticeSchemaColumns("aihr_learning_question",
"tenant_id", "question_code", "scenario_code", "position_code", "question_type",
"question_text", "reference_answer", "content_version", "content_hash", "enabled",
"sort_order");
"sort_order", "exam_format", "exam_options_json", "exam_answers_json",
"exam_explanation", "exam_enabled", "review_status", "review_note", "reviewed_by",
"reviewed_time");
learningQuestionTableReady = true;
return;
}
@@ -7300,18 +7611,88 @@ public class AihrPracticeSeedService {
`content_hash` char(64) NOT NULL COMMENT '题目内容哈希',
`enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用',
`sort_order` int NOT NULL DEFAULT 0 COMMENT '排序',
`exam_format` varchar(20) DEFAULT NULL COMMENT 'single/multiple/judge/short',
`exam_options_json` text COMMENT '考试选项JSON',
`exam_answers_json` text COMMENT '考试答案JSON',
`exam_explanation` varchar(1000) DEFAULT NULL COMMENT '考试解析',
`exam_enabled` tinyint NOT NULL DEFAULT 0 COMMENT '是否可用于考试组卷',
`review_status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED',
`review_note` varchar(500) DEFAULT NULL COMMENT '审核说明',
`reviewed_by` varchar(100) DEFAULT NULL COMMENT '审核人',
`reviewed_time` datetime DEFAULT NULL 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`)
KEY `idx_aihr_learning_question_pick` (`tenant_id`, `position_code`, `question_type`, `enabled`, `sort_order`),
KEY `idx_aihr_learning_question_exam_pick` (`tenant_id`, `position_code`, `review_status`, `exam_enabled`, `enabled`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 训练题目库';
""");
ensureColumn("aihr_learning_question", "exam_format",
"ALTER TABLE aihr_learning_question ADD COLUMN exam_format varchar(20) DEFAULT NULL AFTER sort_order");
ensureColumn("aihr_learning_question", "exam_options_json",
"ALTER TABLE aihr_learning_question ADD COLUMN exam_options_json text AFTER exam_format");
ensureColumn("aihr_learning_question", "exam_answers_json",
"ALTER TABLE aihr_learning_question ADD COLUMN exam_answers_json text AFTER exam_options_json");
ensureColumn("aihr_learning_question", "exam_explanation",
"ALTER TABLE aihr_learning_question ADD COLUMN exam_explanation varchar(1000) DEFAULT NULL AFTER exam_answers_json");
ensureColumn("aihr_learning_question", "exam_enabled",
"ALTER TABLE aihr_learning_question ADD COLUMN exam_enabled tinyint NOT NULL DEFAULT 0 AFTER exam_explanation");
ensureColumn("aihr_learning_question", "review_status",
"ALTER TABLE aihr_learning_question ADD COLUMN review_status varchar(20) NOT NULL DEFAULT 'DRAFT' AFTER exam_enabled");
ensureColumn("aihr_learning_question", "review_note",
"ALTER TABLE aihr_learning_question ADD COLUMN review_note varchar(500) DEFAULT NULL AFTER review_status");
ensureColumn("aihr_learning_question", "reviewed_by",
"ALTER TABLE aihr_learning_question ADD COLUMN reviewed_by varchar(100) DEFAULT NULL AFTER review_note");
ensureColumn("aihr_learning_question", "reviewed_time",
"ALTER TABLE aihr_learning_question ADD COLUMN reviewed_time datetime DEFAULT NULL AFTER reviewed_by");
ensureIndex("aihr_learning_question", "idx_aihr_learning_question_exam_pick",
"ALTER TABLE aihr_learning_question ADD KEY idx_aihr_learning_question_exam_pick "
+ "(tenant_id, position_code, review_status, exam_enabled, enabled, id)");
learningQuestionTableReady = true;
}
}
private void ensureOnboardTaskLearningColumns() {
if (!formalPositionTablesExist("aihr_onboard_task")) {
return;
}
if (!runtimeSchemaBootstrap) {
requirePracticeSchemaColumns("aihr_onboard_task", "material_type", "material_attachment_id",
"material_title", "material_version", "progress_percent", "completion_rule",
"exam_id", "recurrence_type", "dispatch_request_key", "started_time",
"last_viewed_time", "last_position_seconds", "media_duration_seconds");
return;
}
ensureColumn("aihr_onboard_task", "material_type",
"ALTER TABLE aihr_onboard_task ADD COLUMN material_type varchar(20) DEFAULT NULL AFTER course_id");
ensureColumn("aihr_onboard_task", "material_attachment_id",
"ALTER TABLE aihr_onboard_task ADD COLUMN material_attachment_id bigint DEFAULT NULL AFTER material_type");
ensureColumn("aihr_onboard_task", "material_title",
"ALTER TABLE aihr_onboard_task ADD COLUMN material_title varchar(200) DEFAULT NULL AFTER material_attachment_id");
ensureColumn("aihr_onboard_task", "material_version",
"ALTER TABLE aihr_onboard_task ADD COLUMN material_version varchar(50) DEFAULT NULL AFTER material_title");
ensureColumn("aihr_onboard_task", "progress_percent",
"ALTER TABLE aihr_onboard_task ADD COLUMN progress_percent int NOT NULL DEFAULT 0 AFTER material_version");
ensureColumn("aihr_onboard_task", "completion_rule",
"ALTER TABLE aihr_onboard_task ADD COLUMN completion_rule varchar(30) NOT NULL DEFAULT 'MANUAL_CONFIRM' AFTER progress_percent");
ensureColumn("aihr_onboard_task", "exam_id",
"ALTER TABLE aihr_onboard_task ADD COLUMN exam_id bigint DEFAULT NULL AFTER completion_rule");
ensureColumn("aihr_onboard_task", "recurrence_type",
"ALTER TABLE aihr_onboard_task ADD COLUMN recurrence_type varchar(20) NOT NULL DEFAULT 'ONCE' AFTER exam_id");
ensureColumn("aihr_onboard_task", "dispatch_request_key",
"ALTER TABLE aihr_onboard_task ADD COLUMN dispatch_request_key varchar(100) DEFAULT NULL AFTER recurrence_type");
ensureColumn("aihr_onboard_task", "started_time",
"ALTER TABLE aihr_onboard_task ADD COLUMN started_time datetime DEFAULT NULL AFTER progress_percent");
ensureColumn("aihr_onboard_task", "last_viewed_time",
"ALTER TABLE aihr_onboard_task ADD COLUMN last_viewed_time datetime DEFAULT NULL AFTER started_time");
ensureColumn("aihr_onboard_task", "last_position_seconds",
"ALTER TABLE aihr_onboard_task ADD COLUMN last_position_seconds int NOT NULL DEFAULT 0 AFTER last_viewed_time");
ensureColumn("aihr_onboard_task", "media_duration_seconds",
"ALTER TABLE aihr_onboard_task ADD COLUMN media_duration_seconds int DEFAULT NULL AFTER last_position_seconds");
}
private void ensureRubricTables() {
if (rubricTablesReady) {
return;
@@ -8353,7 +8734,9 @@ public class AihrPracticeSeedService {
private record PracticeQuestionState(String questionCode, String scenarioCode, String positionCode,
String questionType, String question, String referenceAnswer,
String contentVersion, String contentHash, boolean enabled, int sortOrder) {
String contentVersion, String contentHash, boolean enabled, int sortOrder,
String examFormat, List<String> examOptions, List<String> examAnswers,
String examExplanation, boolean examEnabled, String reviewStatus) {
}
private record QuestionReviewChange(String questionCode, String contentVersion, String contentHash,
@@ -8385,6 +8768,11 @@ public class AihrPracticeSeedService {
int phoneExternalIdCollisionCount, int externalIdPhoneCollisionCount) {
}
private record OnboardProgressRow(String materialType, String materialVersion, int progressPercent,
String status, Timestamp startedTime, Integer lastPositionSeconds,
Integer mediaDurationSeconds) {
}
public record CurrentTaskSnapshot(List<OnboardTaskResponse> onboardTasks,
List<PracticeAssignmentResponse> practiceAssignments) {
}
@@ -108,6 +108,8 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Supplier;
import java.util.stream.Stream;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* ponytail: deterministic RAG seed; swap to vector search when real SOP corpus exists.
@@ -436,6 +438,10 @@ public class AihrSopSeedService {
}
public AnswerFeedbackResponse answerFeedback(AnswerFeedbackRequest request) {
return answerFeedback(request, null);
}
public AnswerFeedbackResponse answerFeedback(AnswerFeedbackRequest request, Long submitterUserId) {
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
String queryNorm = normalizeFeedbackQuery(queryText);
if (isBlank(queryNorm)) {
@@ -468,9 +474,9 @@ public class AihrSopSeedService {
for (Long fragmentId : fragmentIds) {
saved += jdbcTemplate.update("""
INSERT INTO aihr_knowledge_answer_feedback
(tenant_id, query_text, query_norm, fragment_id, verdict, feedback_reason_codes, feedback_comment,
(tenant_id, submitter_user_id, query_text, query_norm, fragment_id, verdict, feedback_reason_codes, feedback_comment,
category, position, source, review_id, request_id, review_status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
query_text = VALUES(query_text),
verdict = VALUES(verdict),
@@ -487,6 +493,7 @@ public class AihrSopSeedService {
update_time = VALUES(update_time)
""",
tenantId(),
submitterUserId,
truncate(AihrSensitiveText.forModel(queryText), 1000),
normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)),
fragmentId,
@@ -505,8 +512,8 @@ public class AihrSopSeedService {
if ("down".equals(verdict)) {
Long feedbackId = jdbcTemplate.queryForObject("""
select id from aihr_knowledge_answer_feedback
where tenant_id = ? and query_norm = ? and fragment_id = ?
""", Long.class, tenantId(), normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)), fragmentId);
where tenant_id = ? and submitter_user_id <=> ? and request_id <=> ? and fragment_id = ?
""", Long.class, tenantId(), submitterUserId, requestId, fragmentId);
if (feedbackId != null) {
createFeedbackQualityIssue(feedbackId, fragmentId, requestId, reasonCodes);
}
@@ -3146,8 +3153,11 @@ public class AihrSopSeedService {
AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_knowledge_answer_feedback",
"aihr_quality_issue", "aihr_query_evidence");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_knowledge_answer_feedback",
"review_id", "feedback_reason_codes", "feedback_comment", "request_id", "review_action",
"submitter_user_id", "review_id", "feedback_reason_codes", "feedback_comment", "request_id", "review_action",
"review_note", "reviewer", "reviewed_time");
AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_knowledge_answer_feedback",
"uk_aihr_answer_feedback_user_request", true,
"tenant_id", "submitter_user_id", "request_id", "fragment_id");
AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_quality_issue", "origin_feedback_id");
answerFeedbackTableReady = true;
return;
@@ -3156,6 +3166,7 @@ public class AihrSopSeedService {
CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`submitter_user_id` bigint DEFAULT NULL COMMENT '反馈提交用户ID',
`query_text` varchar(1000) NOT NULL COMMENT '问题原文',
`query_norm` varchar(500) NOT NULL COMMENT '规范化问题',
`fragment_id` bigint NOT NULL COMMENT '知识片段ID',
@@ -3175,12 +3186,13 @@ public class AihrSopSeedService {
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_answer_feedback_pair` (`tenant_id`, `query_norm`, `fragment_id`),
UNIQUE KEY `uk_aihr_answer_feedback_user_request` (`tenant_id`, `submitter_user_id`, `request_id`, `fragment_id`),
KEY `idx_aihr_answer_feedback_verdict` (`tenant_id`, `query_norm`, `fragment_id`, `verdict`),
KEY `idx_aihr_answer_feedback_review` (`tenant_id`, `review_status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识答案反馈';
""");
ensureColumn("aihr_knowledge_answer_feedback", "review_id", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `review_id` bigint DEFAULT NULL COMMENT '对应SOP问答评审ID' AFTER `source`");
ensureColumn("aihr_knowledge_answer_feedback", "submitter_user_id", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `submitter_user_id` bigint DEFAULT NULL COMMENT '反馈提交用户ID' AFTER `tenant_id`");
ensureColumn("aihr_knowledge_answer_feedback", "feedback_reason_codes", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `feedback_reason_codes` varchar(255) DEFAULT NULL COMMENT '未解决原因代码' AFTER `verdict`");
ensureColumn("aihr_knowledge_answer_feedback", "feedback_comment", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `feedback_comment` varchar(500) DEFAULT NULL COMMENT '未解决补充说明' AFTER `feedback_reason_codes`");
ensureColumn("aihr_knowledge_answer_feedback", "request_id", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `request_id` varchar(64) DEFAULT NULL COMMENT '知识查询请求ID' AFTER `review_id`");
@@ -3188,6 +3200,7 @@ public class AihrSopSeedService {
ensureColumn("aihr_knowledge_answer_feedback", "review_note", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `review_note` varchar(500) DEFAULT NULL COMMENT '复核结论' AFTER `review_action`");
ensureColumn("aihr_knowledge_answer_feedback", "reviewer", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `reviewer` varchar(100) DEFAULT NULL COMMENT '复核人' AFTER `review_status`");
ensureColumn("aihr_knowledge_answer_feedback", "reviewed_time", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `reviewed_time` datetime DEFAULT NULL COMMENT '复核时间' AFTER `reviewer`");
ensureAnswerFeedbackUserRequestIndex();
answerFeedbackTableReady = true;
}
}
@@ -3389,6 +3402,29 @@ public class AihrSopSeedService {
""".formatted(fragmentAlias);
}
private void ensureAnswerFeedbackUserRequestIndex() {
Integer legacyIndex = jdbcTemplate.queryForObject("""
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'aihr_knowledge_answer_feedback'
and index_name = 'uk_aihr_answer_feedback_pair'
""", Integer.class);
if (legacyIndex != null && legacyIndex > 0) {
jdbcTemplate.execute("ALTER TABLE aihr_knowledge_answer_feedback DROP INDEX uk_aihr_answer_feedback_pair");
}
Integer currentIndex = jdbcTemplate.queryForObject("""
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'aihr_knowledge_answer_feedback'
and index_name = 'uk_aihr_answer_feedback_user_request'
""", Integer.class);
if (currentIndex == null || currentIndex == 0) {
jdbcTemplate.execute("""
ALTER TABLE aihr_knowledge_answer_feedback
ADD UNIQUE KEY uk_aihr_answer_feedback_user_request
(tenant_id, submitter_user_id, request_id, fragment_id)
""");
}
}
private static String normalizeFeedbackRequestId(String value) {
if (isBlank(value)) {
return null;
@@ -4681,8 +4717,10 @@ public class AihrSopSeedService {
""", (rs, n) -> new FragmentLocatorSeed(rs.getLong("id"), rs.getInt("idx"), rs.getString("content")),
tenant, knowledgeId, docId);
for (FragmentLocatorSeed fragment : fragments) {
Long timestamp = "VIDEO".equals(kind) ? firstTimestampMillis(fragment.content()) : null;
boolean frame = fragment.content() != null && fragment.content().contains("[画面");
VideoMarker marker = "VIDEO".equals(kind) ? firstVideoMarker(fragment.content()) : null;
Long timestamp = marker == null ? null : marker.startMs();
boolean frame = marker != null && marker.frame();
Long endTimestamp = marker != null && !frame ? marker.endMs() : null;
jdbcTemplate.update("""
insert into aihr_knowledge_fragment_locator
(tenant_id, fragment_id, knowledge_id, doc_id, attachment_id, source_kind,
@@ -4690,8 +4728,8 @@ public class AihrSopSeedService {
create_time, update_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v1', now(), now())
""", tenant, fragment.id(), knowledgeId, docId, attachmentId, kind,
fragment.index(), fragment.index(), frame ? null : timestamp, frame ? null : timestamp,
frame ? timestamp : null);
fragment.index(), fragment.index(), frame ? null : timestamp, frame ? null : endTimestamp,
frame ? timestamp : null);
}
}
@@ -4706,13 +4744,22 @@ public class AihrSopSeedService {
return "TEXT";
}
private static Long firstTimestampMillis(String value) {
if (value == null) return null;
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\[(?:画面\\s*)?(\\d{1,3}):(\\d{2})]").matcher(value);
private static VideoMarker firstVideoMarker(String value) {
if (value == null || value.isBlank()) return null;
Matcher matcher = VIDEO_MARKER.matcher(value);
if (!matcher.find()) return null;
return (Long.parseLong(matcher.group(1)) * 60 + Long.parseLong(matcher.group(2))) * 1000;
long start = toMillis(matcher.group(2), matcher.group(3));
Long end = matcher.group(4) == null ? null : toMillis(matcher.group(4), matcher.group(5));
return new VideoMarker(matcher.group(1).contains("\u753b\u9762"), start, end);
}
private static long toMillis(String minutes, String seconds) {
return (Long.parseLong(minutes) * 60 + Long.parseLong(seconds)) * 1000;
}
private static final Pattern VIDEO_MARKER = Pattern.compile(
"\\[([^\\]]*?)(\\d{1,3}):(\\d{2})(?:\\s*-\\s*(\\d{1,3}):(\\d{2}))?\\]");
private static FileFingerprint fileFingerprint(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
return fileFingerprint(input);
@@ -5146,6 +5193,9 @@ public class AihrSopSeedService {
private record FragmentLocatorSeed(long id, int index, String content) {
}
private record VideoMarker(boolean frame, long startMs, Long endMs) {
}
private record VectorMatch(String docId, int idx, double score) {
}
@@ -0,0 +1,84 @@
package org.dromara.aihr.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Fails the application health check before upload staging exhausts its filesystem.
*/
@Component("aihrUploadCapacity")
public class AihrUploadCapacityHealthIndicator implements HealthIndicator {
static final long DEFAULT_MINIMUM_FREE_BYTES = 10L * 1024 * 1024 * 1024;
private final Path stagingRoot;
private final long minimumFreeBytes;
@Autowired
public AihrUploadCapacityHealthIndicator(
@Value("${aihr.upload.staging:${AIHR_UPLOAD_STAGING:./.data/staging}}") String stagingRoot,
@Value("${aihr.upload.minimum-free-bytes:${AIHR_UPLOAD_MINIMUM_FREE_BYTES:10737418240}}") long minimumFreeBytes
) {
this(Path.of(stagingRoot).toAbsolutePath().normalize(), minimumFreeBytes);
}
AihrUploadCapacityHealthIndicator(Path stagingRoot, long minimumFreeBytes) {
this.stagingRoot = stagingRoot.toAbsolutePath().normalize();
this.minimumFreeBytes = Math.max(1L, minimumFreeBytes);
}
@Override
public Health health() {
try {
boolean stagingPathInvalid = Files.exists(stagingRoot) && !Files.isDirectory(stagingRoot);
Path existing = existingAncestor(stagingRoot);
FileStore store = Files.getFileStore(existing);
long usableBytes = store.getUsableSpace();
boolean writable = !stagingPathInvalid && Files.isWritable(existing);
boolean enoughSpace = usableBytes >= minimumFreeBytes;
Health.Builder builder = writable && enoughSpace ? Health.up() : Health.down();
return policyDetails(builder)
.withDetail("usableBytes", usableBytes)
.withDetail("totalBytes", store.getTotalSpace())
.withDetail("minimumFreeBytes", minimumFreeBytes)
.withDetail("stagingDirectoryPresent", Files.isDirectory(stagingRoot))
.withDetail("stagingWritable", writable)
.withDetail("capacityReady", enoughSpace)
.build();
} catch (IOException | RuntimeException ex) {
return policyDetails(Health.down())
.withDetail("minimumFreeBytes", minimumFreeBytes)
.withDetail("stagingWritable", false)
.withDetail("capacityReady", false)
.withDetail("message", "upload staging filesystem is unavailable")
.build();
}
}
private static Health.Builder policyDetails(Health.Builder builder) {
return builder
.withDetail("workerConcurrency", AihrUploadQueueService.WORKER_PERMITS)
.withDetail("maxFileBytes", AihrUploadQueueService.MAX_FILE_BYTES)
.withDetail("maxArchiveUnpackedBytes", AihrUploadQueueService.MAX_ARCHIVE_UNPACKED_BYTES)
.withDetail("stagingRetentionHours", AihrUploadQueueService.STAGING_RETENTION_HOURS);
}
private static Path existingAncestor(Path path) throws IOException {
Path candidate = path;
while (candidate != null && !Files.exists(candidate)) {
candidate = candidate.getParent();
}
if (candidate == null) {
throw new IOException("upload staging filesystem is unavailable");
}
return candidate;
}
}
@@ -57,20 +57,20 @@ public class AihrUploadQueueService {
private static final String TENANT_ID = "000000";
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm:ss");
/** 资料处理中心统一支持单文件 500 MiB;Spring multipart 请求上限需保持不低于该值。 */
private static final long MAX_FILE_BYTES = 500L * 1024 * 1024;
private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024;
static final long MAX_FILE_BYTES = 500L * 1024 * 1024;
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;
static final int WORKER_PERMITS = 2;
/** 处理中超过该时长视为孤儿(服务重启/线程异常丢失),由清扫任务重置回待处理。需大于最慢单文件加工时长。 */
private static final int STUCK_MINUTES = 30;
/** 视频加工链路(ffmpeg+分段ASR+关键帧OCR)耗时更长,卡死判定单独放宽。 */
private static final int VIDEO_STUCK_MINUTES = 120;
/** 失败条目的暂存文件保留时长,超时由清扫回收磁盘(之后重试会提示重新上传)。 */
private static final int STAGING_RETENTION_HOURS = 72;
static final int STAGING_RETENTION_HOURS = 72;
private static final ObjectMapper JSON = new ObjectMapper();
private final AihrSopSeedService sopSeedService;
@@ -89,7 +89,7 @@ public class AihrUploadQueueService {
ISysOssService ossService,
JdbcTemplate jdbcTemplate,
ScheduledExecutorService scheduledExecutorService,
@Value("${aihr.upload.staging:./.data/staging}") String stagingRootConfig) {
@Value("${aihr.upload.staging:${AIHR_UPLOAD_STAGING:./.data/staging}}") String stagingRootConfig) {
this.sopSeedService = sopSeedService;
this.ossService = ossService;
this.jdbcTemplate = jdbcTemplate;
@@ -111,8 +111,10 @@ public class AihrVideoService {
continue;
}
Optional<String> piece = speechService.transcribe(Files.readAllBytes(segment), "segment-" + i + ".mp3", "audio/mpeg");
long end = Math.min(durationSeconds, offset + AUDIO_SEGMENT_SECONDS);
piece.filter(s -> !s.isBlank())
.ifPresent(s -> transcript.append('[').append(formatTime(offset)).append("] ").append(s.strip()).append('\n'));
.ifPresent(s -> transcript.append('[').append(formatTime(offset)).append('-')
.append(formatTime(end)).append("] ").append(s.strip()).append('\n'));
Files.deleteIfExists(segment);
}
return transcript.toString();
@@ -3,11 +3,14 @@ package org.dromara.aihr.broadcast;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest;
import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.system.service.ISysOssService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@@ -21,11 +24,13 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -63,6 +68,62 @@ class AihrBroadcastAttachmentContractTest {
assertEquals(42L, attached.attachmentId());
}
@Test
void automaticTopicTagsKeepAnExactSourceExcerpt() {
String source = "第一条:门岗与巡逻人员每天核对安保记录。\n第二条:物业费收费异常应及时复核。";
List<AihrBroadcastDto.BroadcastTopicTag> tags = AihrBroadcastAttachmentService.autoTopicTags(source);
assertEquals(List.of("安保", "收费"), tags.stream().map(AihrBroadcastDto.BroadcastTopicTag::name).toList());
assertTrue(tags.stream().allMatch(tag -> !tag.evidenceQuote().isBlank()));
assertTrue(tags.stream().allMatch(tag -> source.contains(tag.evidenceQuote())));
}
@Test
void onlineOnlyAttachmentRejectsDownloadWithoutWritingAnAudit() {
DownloadJdbcTemplate jdbcTemplate = new DownloadJdbcTemplate(false);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(new AihrKnowledgePrincipal(
"tenant-a", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile"));
AihrBroadcastAttachmentService service = attachmentService(jdbcTemplate, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.authorizedDownloadOssId(9L));
assertEquals(HttpStatus.FORBIDDEN, error.getCode());
assertTrue(jdbcTemplate.updateSql.isEmpty());
}
@Test
void downloadableAttachmentWritesADownloadAuditAfterVisibilityRecheck() {
DownloadJdbcTemplate jdbcTemplate = new DownloadJdbcTemplate(true);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(new AihrKnowledgePrincipal(
"tenant-a", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile"));
AihrBroadcastService broadcastService = mock(AihrBroadcastService.class);
AihrBroadcastAttachmentService service = attachmentService(jdbcTemplate, resolver, broadcastService);
assertEquals(88L, service.authorizedDownloadOssId(9L));
assertEquals(1, jdbcTemplate.updateSql.size());
assertTrue(jdbcTemplate.updateSql.get(0).contains("aihr_broadcast_attachment_access_audit"));
assertArrayEquals(new Object[]{"tenant-a", 42L, 9L, 7L, "DOWNLOAD"}, jdbcTemplate.updateArgs.get(0));
org.mockito.Mockito.verify(broadcastService).message(42L);
}
private static AihrBroadcastAttachmentService attachmentService(
JdbcTemplate jdbcTemplate, AihrKnowledgePrincipalResolver resolver
) {
return attachmentService(jdbcTemplate, resolver, mock(AihrBroadcastService.class));
}
private static AihrBroadcastAttachmentService attachmentService(
JdbcTemplate jdbcTemplate, AihrKnowledgePrincipalResolver resolver, AihrBroadcastService broadcastService
) {
return new AihrBroadcastAttachmentService(
jdbcTemplate, mock(ISysOssService.class), mock(KnowledgeDocumentParser.class),
mock(AihrSopSeedService.class), mock(AihrBroadcastInsightService.class), resolver,
broadcastService, mock(ScheduledExecutorService.class));
}
@Test
void backgroundWorkerReadsOssInsideTheOwningTenant() throws Exception {
String source = Files.readString(Path.of(
@@ -160,4 +221,37 @@ class AihrBroadcastAttachmentContractTest {
});
}
}
private static final class DownloadJdbcTemplate extends JdbcTemplate {
private final boolean allowDownload;
private final List<String> updateSql = new ArrayList<>();
private final List<Object[]> updateArgs = new ArrayList<>();
private DownloadJdbcTemplate(boolean allowDownload) {
this.allowDownload = allowDownload;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
try {
ResultSet resultSet = (ResultSet) Proxy.newProxyInstance(
DownloadJdbcTemplate.class.getClassLoader(), new Class<?>[]{ResultSet.class},
(proxy, method, methodArgs) -> switch (method.getName()) {
case "getLong" -> "oss_id".equals(methodArgs[0]) ? 88L : 42L;
case "getBoolean" -> allowDownload;
default -> throw new UnsupportedOperationException(method.getName());
});
return List.of(rowMapper.mapRow(resultSet, 0));
} catch (SQLException error) {
throw new AssertionError(error);
}
}
@Override
public int update(String sql, Object... args) {
updateSql.add(sql);
updateArgs.add(args);
return 1;
}
}
}
@@ -51,7 +51,8 @@ class AihrBroadcastServiceTest {
assertTrue(jdbcTemplate.queryForObjectSql.contains("m.status = 'PUBLISHED'"));
assertTrue(jdbcTemplate.queryForObjectSql.contains("m.tenant_id = ?"));
assertArrayEquals(new Object[]{"tenant-a", 7L}, jdbcTemplate.queryForObjectArgs);
assertTrue(jdbcTemplate.queryForObjectSql.contains("m.visibility_mode = 'ALL'"));
assertArrayEquals(new Object[]{"tenant-a", 7L, 7L}, jdbcTemplate.queryForObjectArgs);
}
@Test
@@ -114,7 +115,8 @@ class AihrBroadcastServiceTest {
assertEquals("夏季高温作业提醒", context.title());
assertEquals("原始消息正文仅供受控模型上下文使用", context.content());
assertTrue(jdbcTemplate.querySql.contains("m.tenant_id = ? and m.id = ? and m.status = 'PUBLISHED'"));
assertArrayEquals(new Object[]{"tenant-a", 42L}, jdbcTemplate.queryArgs);
assertTrue(jdbcTemplate.querySql.contains("m.visibility_mode = 'ALL' or visible_recipient.user_id is not null"));
assertArrayEquals(new Object[]{"tenant-a", 7L, "tenant-a", 42L}, jdbcTemplate.queryArgs);
}
@Test
@@ -132,11 +134,14 @@ class AihrBroadcastServiceTest {
@Test
void employeeAttachmentKeepsAllPerspectivesAndMarksOnlyTheMatchingDefault() throws Exception {
RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate();
AihrBroadcastService service = new AihrBroadcastService(jdbcTemplate, RESOLVER);
Method attachment = AihrBroadcastService.class.getDeclaredMethod(
"attachment", ResultSet.class, String.class);
"attachment", ResultSet.class, String.class, boolean.class, String.class);
attachment.setAccessible(true);
ResultSet row = RecordingJdbcTemplate.resultSet(Map.ofEntries(
entry("attachment_id", 9L),
entry("tenant_id", "tenant-a"),
entry("attachment_file_name", "制度.txt"),
entry("attachment_file_size", 1024L),
entry("attachment_content_type", "text/plain"),
@@ -155,7 +160,7 @@ class AihrBroadcastServiceTest {
));
AihrBroadcastDto.BroadcastAttachmentResponse response =
(AihrBroadcastDto.BroadcastAttachmentResponse) attachment.invoke(null, row, "finance");
(AihrBroadcastDto.BroadcastAttachmentResponse) attachment.invoke(service, row, "finance", true, "internal");
assertEquals("finance", response.defaultPerspectiveCode());
assertEquals(List.of("财务", "人力"), response.perspectiveLabels());
@@ -256,6 +261,20 @@ class AihrBroadcastServiceTest {
.isAnnotationPresent(Transactional.class));
}
@Test
void targetOnlyPublishRequiresAtLeastOneTargetBeforeWriting() {
RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate();
AihrBroadcastService service = new AihrBroadcastService(jdbcTemplate, RESOLVER);
ServiceException error = assertThrows(ServiceException.class, () -> service.publish(
new AihrBroadcastDto.PublishRequest(
"target-only-empty", "标题", "正文", "tenant-a", false,
"TARGET_ONLY", false, AihrBroadcastDto.BroadcastTargetRequest.empty(), null)));
assertEquals(HttpStatus.BAD_REQUEST, error.getCode());
assertTrue(jdbcTemplate.updateSql == null);
}
@Test
void publishReplaysTheOriginalMessageForTheSameRequestIdAndPayload() {
RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate();
@@ -365,7 +384,7 @@ class AihrBroadcastServiceTest {
Method publish = AihrBroadcastController.class.getMethod(
"publish", AihrBroadcastDto.PublishRequest.class);
Constructor<?> constructor = java.util.Arrays.stream(AihrBroadcastDto.PublishRequest.class.getDeclaredConstructors())
.filter(candidate -> candidate.getParameterCount() == 7)
.filter(candidate -> candidate.getParameterCount() == 9)
.findFirst()
.orElseThrow();
@@ -0,0 +1,154 @@
package org.dromara.aihr.community;
import org.dromara.aihr.community.AihrCommunityDto.AnswerFeedbackQuestionRequest;
import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
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 org.springframework.jdbc.core.RowMapper;
import java.lang.reflect.Field;
import java.sql.ResultSet;
import java.sql.Timestamp;
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.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AihrFeedbackQuestionBridgeTest {
@Test
@Tag("dev")
void duplicateSubmissionReplaysOnePendingQuestionWithoutPointsWrite() throws Exception {
BridgeJdbc jdbc = new BridgeJdbc(7L, true);
AihrCommunityService service = service(jdbc, 7L);
AnswerFeedbackQuestionRequest request = new AnswerFeedbackQuestionRequest("knowledge_req_1", "help-key-1");
QuestionResponse first = service.createQuestionFromAnswerFeedback(request);
QuestionResponse replay = service.createQuestionFromAnswerFeedback(request);
assertEquals(first.id(), replay.id());
assertEquals("PENDING", first.status());
assertEquals(1, jdbc.questionInserts);
assertEquals(0, jdbc.pointsWrites);
}
@Test
@Tag("dev")
void foreignKnowledgeRequestIsRejectedBeforeQuestionInsert() throws Exception {
BridgeJdbc jdbc = new BridgeJdbc(9L, true);
AihrCommunityService service = service(jdbc, 7L);
assertThrows(ServiceException.class, () -> service.createQuestionFromAnswerFeedback(
new AnswerFeedbackQuestionRequest("knowledge_req_1", "help-key-2")));
assertEquals(0, jdbc.questionInserts);
}
@Test
@Tag("dev")
void requestWithoutNegativeFeedbackCannotBeBridged() throws Exception {
BridgeJdbc jdbc = new BridgeJdbc(7L, false);
AihrCommunityService service = service(jdbc, 7L);
assertThrows(ServiceException.class, () -> service.createQuestionFromAnswerFeedback(
new AnswerFeedbackQuestionRequest("knowledge_req_1", "help-key-3")));
assertEquals(0, jdbc.questionInserts);
}
private static AihrCommunityService service(BridgeJdbc jdbc, long userId) throws Exception {
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(new AihrKnowledgePrincipal(
"000000", userId, "app_user", "staff-7", Set.of("employee"), Set.of("P1"), "mobile"));
AihrCommunityProperties properties = new AihrCommunityProperties();
AihrIncentiveService incentive = mock(AihrIncentiveService.class);
AihrCommunityService service = new AihrCommunityService(jdbc, resolver, properties, incentive);
Field ready = AihrCommunityService.class.getDeclaredField("tablesReady");
ready.setAccessible(true);
ready.set(service, true);
return service;
}
private static final class BridgeJdbc extends JdbcTemplate {
private final long queryOwner;
private final boolean hasNegativeFeedback;
private boolean questionExists;
private int questionInserts;
private int pointsWrites;
private BridgeJdbc(long queryOwner, boolean hasNegativeFeedback) {
this.queryOwner = queryOwner;
this.hasNegativeFeedback = hasNegativeFeedback;
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (sql.contains("aihr_knowledge_query_log")) {
long caller = ((Number) args[1]).longValue();
return requiredType.cast(caller == queryOwner ? 1 : 0);
}
throw new AssertionError("Unexpected scalar query: " + sql);
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("insert into aihr_community_question")) {
questionExists = true;
questionInserts++;
return 1;
}
if (sql.contains("aihr_points_ledger")) {
pointsWrites++;
return 1;
}
throw new AssertionError("Unexpected update: " + sql);
}
@Override
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) {
try {
if (sql.contains("from aihr_knowledge_answer_feedback")) {
if (!hasNegativeFeedback) return List.of();
ResultSet rs = mock(ResultSet.class);
when(rs.getString("query_text")).thenReturn("地库积水一直没有处理,下一步应该怎么办?");
when(rs.getString("category")).thenReturn("投诉处理");
return List.of(mapper.mapRow(rs, 0));
}
if (sql.contains("select id, source_request_id from aihr_community_question")) {
if (!questionExists) return List.of();
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(31L);
when(rs.getString("source_request_id")).thenReturn("knowledge_req_1");
return List.of(mapper.mapRow(rs, 0));
}
if (sql.contains("from aihr_community_question q where")) {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(31L);
when(rs.getLong("author_user_id")).thenReturn(7L);
when(rs.getString("author_name")).thenReturn("员工ff-7");
when(rs.getString("question_type")).thenReturn("OPEN");
when(rs.getString("category")).thenReturn("投诉处理");
when(rs.getString("title")).thenReturn("地库积水一直没有处理");
when(rs.getString("content")).thenReturn("地库积水一直没有处理,下一步应该怎么办?");
when(rs.getInt("reward_points")).thenReturn(0);
when(rs.getString("reward_rule_code")).thenReturn("BEST_ANSWER");
when(rs.getString("reward_account_type")).thenReturn("POINTS");
when(rs.getString("question_status")).thenReturn("PENDING");
when(rs.getInt("answer_count")).thenReturn(0);
when(rs.getTimestamp("create_time")).thenReturn(Timestamp.valueOf(LocalDateTime.now()));
return List.of(mapper.mapRow(rs, 0));
}
if (sql.contains("from aihr_community_answer")) return List.of();
} catch (Exception error) {
throw new AssertionError(error);
}
throw new AssertionError("Unexpected list query: " + sql);
}
}
}
@@ -30,15 +30,15 @@ class AihrKnowledgeDataToolServiceTest {
@Test
void myPracticeSummaryAlwaysUsesCurrentAppUserIdentity() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.practiceHistory("employee-1")).thenReturn(List.of(
when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001");
when(mobile.practiceHistory("13900000001")).thenReturn(List.of(
new RecordResponse(9L, "session", "07-16 09:00", "张三", "投诉沟通", 88, "已完成", "表达清晰", "employee-1")));
var service = new AihrKnowledgeDataToolService(mobile);
var result = service.execute(employee(), AihrKnowledgeDataToolService.MY_PRACTICE_SUMMARY);
verify(mobile).requireMobileIdentity("employee-1");
verify(mobile).practiceHistory("employee-1");
verify(mobile).requireMobilePhoneForOrganizationIdentity("employee-1");
verify(mobile).practiceHistory("13900000001");
var data = (AihrKnowledgeDataToolService.MyPracticeSummary) result.data();
assertEquals(1, data.practiceCount());
assertEquals("投诉沟通", data.recentRecords().get(0).scene());
@@ -47,8 +47,8 @@ class AihrKnowledgeDataToolServiceTest {
@Test
void teamPracticeSummaryUsesResolvedSupervisorScope() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("supervisor-1")).thenReturn("supervisor-1");
when(mobile.requireMobileSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor");
when(mobile.requireMobilePhoneForOrganizationIdentity("supervisor-1")).thenReturn("13900000002");
when(mobile.requireMobileSupervisorIdentity("13900000002")).thenReturn("canonical-supervisor");
when(mobile.practiceTeam("canonical-supervisor"))
.thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of()));
var service = new AihrKnowledgeDataToolService(mobile);
@@ -57,15 +57,15 @@ class AihrKnowledgeDataToolServiceTest {
service.execute(principal, AihrKnowledgeDataToolService.TEAM_PRACTICE_SUMMARY);
verify(mobile).requireMobileIdentity("supervisor-1");
verify(mobile).requireMobileSupervisorIdentity("supervisor-1");
verify(mobile).requireMobilePhoneForOrganizationIdentity("supervisor-1");
verify(mobile).requireMobileSupervisorIdentity("13900000002");
verify(mobile).practiceTeam("canonical-supervisor");
}
@Test
void employeeCannotCallTeamPracticeSummary() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001");
var service = new AihrKnowledgeDataToolService(mobile);
assertThrows(ServiceException.class,
@@ -76,8 +76,8 @@ class AihrKnowledgeDataToolServiceTest {
@Test
void currentTasksUsesOnlyCurrentIdentityAndReturnsNoTaskExplicitly() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(
when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001");
when(mobile.currentTasks("13900000001")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(
List.of(), List.of()));
var service = new AihrKnowledgeDataToolService(mobile);
@@ -87,14 +87,14 @@ class AihrKnowledgeDataToolServiceTest {
var data = (AihrKnowledgeDataToolService.CurrentTaskSummary) result.data();
assertEquals("NO_TASKS", data.state());
assertEquals(0, data.taskCount());
verify(mobile).currentTasks("employee-1");
verify(mobile).currentTasks("13900000001");
}
@Test
void currentTasksKeepsOnlyIncompleteOnboardingRows() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(List.of(
when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001");
when(mobile.currentTasks("13900000001")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(List.of(
new OnboardTaskResponse(1L, "P1", "客服", null, null, "岗前学习", "待完成", "主管", null,
"2026-07-30", null, null),
new OnboardTaskResponse(2L, "P1", "客服", null, null, "已完成学习", "已完成", "主管", null,
@@ -113,8 +113,8 @@ class AihrKnowledgeDataToolServiceTest {
@Test
void currentTasksNeverTurnsAnUnavailableSourceIntoNoTasks() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.currentTasks("employee-1"))
when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001");
when(mobile.currentTasks("13900000001"))
.thenThrow(new ServiceException("当前任务数据读取失败,暂时无法确认待办,请稍后再试", HttpStatus.ERROR));
var service = new AihrKnowledgeDataToolService(mobile);
@@ -129,7 +129,7 @@ class AihrKnowledgeDataToolServiceTest {
@Test
void unknownToolIsRejectedWithoutReadingTrainingData() {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1");
when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001");
var service = new AihrKnowledgeDataToolService(mobile);
assertThrows(ServiceException.class, () -> service.execute(employee(), "RAW_SQL"));
@@ -681,7 +681,7 @@ class AihrKnowledgeQueryServiceTest {
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertEquals(null, service.authorizedResourceOssId(321L));
verify(jdbc, never()).queryForList(anyString(), eq(Long.class), any(Object[].class));
verify(jdbc).queryForList(anyString(), eq(Long.class), any(Object[].class));
}
@Test
@@ -27,6 +27,7 @@ class AihrKnowledgePrincipalResolverTest {
AihrOrgSyncService orgService = mock(AihrOrgSyncService.class);
AihrMobileSeedService mobileSeedService = mock(AihrMobileSeedService.class);
when(mobileSeedService.requireMobileIdentity("13900001111")).thenReturn("13900001111");
when(mobileSeedService.requireMobileOrganizationIdentity("13900001111")).thenReturn("party-1");
when(orgService.activeByMobilePhone("13900001111")).thenReturn(List.of(
new OrgPersonRow(1L, "P01", "", "", "party-1", "", "项目经理", "项目经理", "active", "2026-07-16")
));
@@ -36,6 +37,7 @@ class AihrKnowledgePrincipalResolverTest {
assertEquals(Set.of("employee", "supervisor"), principal.roles());
assertEquals(Set.of("P01"), principal.projectCodes());
assertEquals("party-1", principal.extPartyId());
}
@Test
@@ -44,8 +44,17 @@ class AihrLearningContractTest {
assertTrue(controller.contains("@GetMapping(\"/learning/matrix\")"));
assertTrue(controller.contains("@GetMapping(\"/exams\")"));
assertTrue(controller.contains("@PostMapping(\"/exams/{id}/submit\")"));
assertTrue(controller.contains("@PostMapping(\"/onboard/tasks/{id}/progress\")"));
assertTrue(controller.contains("@PostMapping(\"/exams/supervisor/{id}/publish\")"));
assertTrue(controller.contains("@GetMapping(\"/exams/supervisor/{id}/results\")"));
assertTrue(controller.contains("@PostMapping(\"/exams/supervisor/question-bank/draw\")"));
String practiceController = Files.readString(projectPath(
"src/main/java/org/dromara/aihr/controller/AihrPracticeController.java"));
assertTrue(practiceController.contains("@GetMapping(\"/onboard-tasks/manage\")"));
assertTrue(practiceController.contains("@GetMapping(\"/onboard-tasks/manage/materials\")"));
assertTrue(practiceController.contains("@GetMapping(\"/onboard-tasks/manage/exams\")"));
assertTrue(practiceController.contains("@PostMapping(\"/onboard-tasks/manage/batch\")"));
}
@Test
@@ -81,6 +90,50 @@ class AihrLearningContractTest {
assertTrue(service.contains("publish_request_key = ?"));
}
@Test
void approvedQuestionBankAndFormalLearningTaskContractsAreWired() throws Exception {
String examService = Files.readString(projectPath(
"src/main/java/org/dromara/aihr/learning/AihrExamService.java"));
assertTrue(examService.contains("enabled = 1 AND exam_enabled = 1"));
assertTrue(examService.contains("review_status = 'APPROVED'"));
assertTrue(examService.contains("aihr_exam_question_draw"));
assertTrue(examService.contains("source_question_id"));
assertTrue(examService.contains("return List.copyOf(result)"));
String taskService = Files.readString(projectPath(
"src/main/java/org/dromara/aihr/learning/AihrLearningTaskService.java"));
String progressService = Files.readString(projectPath(
"src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
assertTrue(taskService.contains("MANUAL_CONFIRM"));
assertTrue(taskService.contains("EXAM_PASS"));
assertTrue(taskService.contains("DAILY"));
assertTrue(taskService.contains("WEEKLY"));
assertTrue(taskService.contains("MONTHLY"));
assertTrue(taskService.contains("recurrence_series_key"));
assertTrue(taskService.contains("dispatch_request_key"));
assertTrue(progressService.contains("last_position_seconds"));
assertTrue(progressService.contains("检测到视频快进"));
assertTrue(taskService.contains("if (nextStart.isAfter(now)) continue"));
String migration = Files.readString(projectPath(
"../../script/sql/update/aihr_20260802_learning_task_exam_bank_mysql8.sql"));
assertTrue(migration.contains("aihr_exam_question_draw"));
assertTrue(migration.contains("uk_aihr_onboard_task_dispatch"));
assertTrue(migration.contains("idx_aihr_onboard_task_due"));
assertTrue(migration.contains("started_time"));
assertTrue(migration.contains("last_viewed_time"));
assertTrue(migration.contains("media_duration_seconds"));
}
@Test
void recurrenceCalculationUsesCalendarPeriods() {
java.time.LocalDateTime start = java.time.LocalDateTime.of(2026, 1, 31, 9, 0);
assertEquals(start.plusDays(1), AihrLearningTaskService.addRecurrence(start, "DAILY"));
assertEquals(start.plusWeeks(1), AihrLearningTaskService.addRecurrence(start, "WEEKLY"));
assertEquals(start.plusMonths(1), AihrLearningTaskService.addRecurrence(start, "MONTHLY"));
assertEquals(start, AihrLearningTaskService.addRecurrence(start, "ONCE"));
}
@Test
void submitContractUsesAnExplicitAnswerListRatherThanAnAmbiguousRecordMap() {
var answers = java.util.Arrays.stream(ExamSubmitRequest.class.getRecordComponents())
@@ -2,6 +2,7 @@ package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.domain.AihrCaseDto.PublishRequest;
import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
@@ -107,6 +108,30 @@ class AihrCaseServiceTest {
assertEquals(upload.caseId(), jdbcTemplate.updateArgs[3]);
}
@Test
@Tag("dev")
void formalExperiencePublishRequiresAuthorizationAndDeidentification() {
AihrSpeechService speechService = new AihrSpeechService(null, new ObjectMapper()) {
@Override
public Optional<String> transcribe(byte[] audio, String filename, String contentType) {
return Optional.of("涓氫富鎶曡瘔宸插畬鎴愯窡杩?");
}
};
CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, null);
UploadResponse upload = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null);
PublishRequest incomplete = new PublishRequest("访谈", "生活顾问", "experience-v1", "HR", "", false, true,
"2026-08-01", "");
assertThrows(IllegalArgumentException.class, () -> service.publish(upload.caseId(), incomplete, null));
PublishRequest complete = new PublishRequest("访谈", "生活顾问", "experience-v1", "HR", "首批试点经验",
true, true, "2026-08-01", "");
service.publish(upload.caseId(), complete, null);
assertTrue(jdbcTemplate.lastUpdateSql.contains("knowledge_status = 'PUBLISHED'"));
assertEquals("HR", jdbcTemplate.updateArgs[3]);
}
@Test
@Tag("dev")
void caseSummaryHashIsStableAndStoresNoPlaintextProvenance() {
@@ -288,6 +313,7 @@ class AihrCaseServiceTest {
private static class CaseJdbcTemplate extends JdbcTemplate {
private Object[] updateArgs = new Object[0];
private String lastUpdateSql = "";
private boolean failOnUpdate;
@Override
@@ -297,6 +323,7 @@ class AihrCaseServiceTest {
@Override
public int update(String sql, Object... args) {
this.lastUpdateSql = sql;
if (failOnUpdate) {
throw new IllegalStateException("simulated case record insert failure");
}
@@ -768,6 +768,10 @@ public class AihrSopSeedServiceTest {
assertTrue(code.contains("redirectOutput(ProcessBuilder.Redirect.DISCARD)"));
assertTrue(code.contains("terminateProcess(process)"));
assertTrue(code.contains("process.destroyForcibly();\n process.waitFor(5"));
assertTrue(code.contains("append(formatTime(offset)).append('-')"));
String locatorCode = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"))
.replace("\r\n", "\n");
assertTrue(locatorCode.contains("VIDEO_MARKER"));
}
@Test
@@ -0,0 +1,48 @@
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.boot.actuate.health.Status;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Tag("dev")
class AihrUploadCapacityHealthIndicatorTest {
@TempDir
Path tempDir;
@Test
void reportsQueuePolicyAndAvailableCapacity() {
var health = new AihrUploadCapacityHealthIndicator(tempDir.resolve("staging"), 1L).health();
assertEquals(Status.UP, health.getStatus());
assertEquals(2, health.getDetails().get("workerConcurrency"));
assertEquals(500L * 1024 * 1024, health.getDetails().get("maxFileBytes"));
assertEquals(2L * 1024 * 1024 * 1024, health.getDetails().get("maxArchiveUnpackedBytes"));
assertEquals(72, health.getDetails().get("stagingRetentionHours"));
assertEquals(true, health.getDetails().get("capacityReady"));
}
@Test
void failsHealthWhenFreeSpaceIsBelowTheConfiguredGate() {
var health = new AihrUploadCapacityHealthIndicator(tempDir, Long.MAX_VALUE).health();
assertEquals(Status.DOWN, health.getStatus());
assertEquals(false, health.getDetails().get("capacityReady"));
}
@Test
void failsHealthWhenTheConfiguredStagingPathIsAFile() throws Exception {
Path file = Files.writeString(tempDir.resolve("not-a-directory"), "fixture");
var health = new AihrUploadCapacityHealthIndicator(file, 1L).health();
assertEquals(Status.DOWN, health.getStatus());
assertEquals(false, health.getDetails().get("stagingWritable"));
}
}
@@ -0,0 +1,93 @@
-- Broadcast delivery controls: strict audience visibility, controlled downloads,
-- evidence-backed topic tags, and attachment access audit.
-- Idempotent; run after aihr_20260724_broadcast_attachment_mysql8.sql.
SET @has_broadcast_visibility_mode := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message'
AND column_name = 'visibility_mode'
);
SET @broadcast_delivery_ddl := IF(
@has_broadcast_visibility_mode = 0,
'ALTER TABLE aihr_broadcast_message ADD COLUMN visibility_mode varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''ALL/TARGET_ONLY'' AFTER required_read',
'SELECT 1'
);
PREPARE aihr_broadcast_delivery_stmt FROM @broadcast_delivery_ddl; EXECUTE aihr_broadcast_delivery_stmt; DEALLOCATE PREPARE aihr_broadcast_delivery_stmt;
SET @has_broadcast_allow_download := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message'
AND column_name = 'allow_download'
);
SET @broadcast_delivery_ddl := IF(
@has_broadcast_allow_download = 0,
'ALTER TABLE aihr_broadcast_message ADD COLUMN allow_download tinyint(1) NOT NULL DEFAULT 1 COMMENT ''附件是否允许下载'' AFTER visibility_mode',
'SELECT 1'
);
PREPARE aihr_broadcast_delivery_stmt FROM @broadcast_delivery_ddl; EXECUTE aihr_broadcast_delivery_stmt; DEALLOCATE PREPARE aihr_broadcast_delivery_stmt;
CREATE TABLE IF NOT EXISTS `aihr_broadcast_attachment_topic_tag` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主题标签ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`attachment_id` bigint NOT NULL COMMENT '公司文件ID',
`tag_name` varchar(30) NOT NULL COMMENT '主题标签',
`source_type` varchar(20) NOT NULL COMMENT 'AUTO/MANUAL',
`evidence_quote` varchar(200) NOT NULL COMMENT '经原文校验的短引用',
`created_by` bigint DEFAULT NULL COMMENT '人工修正账号;自动标签为空',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_broadcast_attachment_topic_tag` (`tenant_id`, `attachment_id`, `tag_name`),
KEY `idx_aihr_broadcast_attachment_topic_tag_attachment` (`tenant_id`, `attachment_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司文件主题标签及原文依据';
CREATE TABLE IF NOT EXISTS `aihr_broadcast_attachment_access_audit` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '访问审计ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`message_id` bigint NOT NULL COMMENT '消息ID',
`attachment_id` bigint NOT NULL COMMENT '公司文件ID',
`user_id` bigint NOT NULL COMMENT 'APP账号ID',
`access_type` varchar(20) NOT NULL COMMENT 'VIEW/DOWNLOAD',
`access_time` datetime NOT NULL COMMENT '访问时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_broadcast_attachment_access_user` (`tenant_id`, `user_id`, `access_time`),
KEY `idx_aihr_broadcast_attachment_access_file` (`tenant_id`, `attachment_id`, `access_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司文件在线查看与下载审计';
SET @broadcast_delivery_target_collation := (
SELECT collation_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_info' AND column_name = 'tenant_id'
LIMIT 1
);
SET @broadcast_delivery_target_collation := COALESCE(@broadcast_delivery_target_collation, @@collation_database);
SET @broadcast_topic_tag_collation := (
SELECT table_collation FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_attachment_topic_tag'
LIMIT 1
);
SET @broadcast_delivery_ddl := IF(
@broadcast_topic_tag_collation = @broadcast_delivery_target_collation,
'SELECT 1',
CONCAT('ALTER TABLE aihr_broadcast_attachment_topic_tag CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @broadcast_delivery_target_collation)
);
PREPARE aihr_broadcast_delivery_stmt FROM @broadcast_delivery_ddl; EXECUTE aihr_broadcast_delivery_stmt; DEALLOCATE PREPARE aihr_broadcast_delivery_stmt;
SET @broadcast_access_audit_collation := (
SELECT table_collation FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_attachment_access_audit'
LIMIT 1
);
SET @broadcast_delivery_ddl := IF(
@broadcast_access_audit_collation = @broadcast_delivery_target_collation,
'SELECT 1',
CONCAT('ALTER TABLE aihr_broadcast_attachment_access_audit CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @broadcast_delivery_target_collation)
);
PREPARE aihr_broadcast_delivery_stmt FROM @broadcast_delivery_ddl; EXECUTE aihr_broadcast_delivery_stmt; DEALLOCATE PREPARE aihr_broadcast_delivery_stmt;
SET @has_broadcast_visibility_mode := NULL;
SET @has_broadcast_allow_download := NULL;
SET @broadcast_delivery_target_collation := NULL;
SET @broadcast_topic_tag_collation := NULL;
SET @broadcast_access_audit_collation := NULL;
SET @broadcast_delivery_ddl := NULL;
@@ -0,0 +1,95 @@
-- Separate reviewed experience candidates from the formal employee-visible library.
-- This migration adds metadata only; it does not approve or publish existing rows.
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'knowledge_status'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `knowledge_status` varchar(20) NOT NULL DEFAULT ''CANDIDATE'' COMMENT ''CANDIDATE/PUBLISHED/OFFLINE'' AFTER `summary`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'source_type'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `source_type` varchar(40) DEFAULT NULL COMMENT ''经验来源类型'' AFTER `knowledge_status`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'applicable_positions'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `applicable_positions` varchar(500) DEFAULT NULL COMMENT ''适用岗位'' AFTER `source_type`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'content_version'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `content_version` varchar(50) DEFAULT NULL COMMENT ''正式经验版本'' AFTER `applicable_positions`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'source_authorized'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `source_authorized` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''来源已授权'' AFTER `content_version`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'deidentified'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `deidentified` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''内容已脱敏'' AFTER `source_authorized`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'effective_from'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `effective_from` date DEFAULT NULL COMMENT ''生效日期'' AFTER `deidentified`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'effective_to'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `effective_to` date DEFAULT NULL COMMENT ''失效日期'' AFTER `effective_from`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'release_owner'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `release_owner` varchar(80) DEFAULT NULL COMMENT ''正式发布负责人'' AFTER `effective_to`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'release_note'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `release_note` varchar(1000) DEFAULT NULL COMMENT ''正式发布说明'' AFTER `release_owner`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'released_by'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `released_by` bigint DEFAULT NULL COMMENT ''正式发布操作人'' AFTER `release_note`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'released_time'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `released_time` datetime DEFAULT NULL COMMENT ''正式发布时间'' AFTER `released_by`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_ddl := IF(
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'aihr_case_record' AND column_name = 'retire_reason'),
'ALTER TABLE `aihr_case_record` ADD COLUMN `retire_reason` varchar(500) DEFAULT NULL COMMENT ''下线原因'' AFTER `released_time`', 'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_case_record'
AND index_name = 'idx_aihr_case_knowledge_status'
);
SET @aihr_case_governance_ddl := IF(@aihr_case_governance_index_exists = 0,
'ALTER TABLE `aihr_case_record` ADD KEY `idx_aihr_case_knowledge_status` (`tenant_id`, `knowledge_status`, `effective_from`, `effective_to`)',
'SELECT 1');
PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl;
EXECUTE aihr_case_governance_stmt;
DEALLOCATE PREPARE aihr_case_governance_stmt;
SET @aihr_case_governance_index_exists := NULL;
SET @aihr_case_governance_ddl := NULL;
@@ -0,0 +1,114 @@
-- Link an authenticated negative knowledge-answer feedback to one moderated question-board item.
-- Additive and idempotent; existing questions keep no source linkage.
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_answer_feedback'
AND column_name = 'request_id'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_knowledge_answer_feedback` ADD COLUMN `request_id` varchar(64) DEFAULT NULL COMMENT ''知识查询请求ID'' AFTER `review_id`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl;
EXECUTE aihr_stmt;
DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_answer_feedback'
AND column_name = 'submitter_user_id'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_knowledge_answer_feedback` ADD COLUMN `submitter_user_id` bigint DEFAULT NULL COMMENT ''反馈提交用户ID;历史记录为空'' AFTER `tenant_id`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl;
EXECUTE aihr_stmt;
DEALLOCATE PREPARE aihr_stmt;
SET @aihr_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_answer_feedback'
AND index_name = 'uk_aihr_answer_feedback_pair'
);
SET @aihr_ddl := IF(@aihr_index_exists > 0,
'ALTER TABLE `aihr_knowledge_answer_feedback` DROP INDEX `uk_aihr_answer_feedback_pair`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl;
EXECUTE aihr_stmt;
DEALLOCATE PREPARE aihr_stmt;
SET @aihr_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_answer_feedback'
AND index_name = 'uk_aihr_answer_feedback_user_request'
);
SET @aihr_ddl := IF(@aihr_index_exists = 0,
'ALTER TABLE `aihr_knowledge_answer_feedback` ADD UNIQUE KEY `uk_aihr_answer_feedback_user_request` (`tenant_id`, `submitter_user_id`, `request_id`, `fragment_id`)',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl;
EXECUTE aihr_stmt;
DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question'
AND column_name = 'source_type'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_community_question` ADD COLUMN `source_type` varchar(32) DEFAULT NULL COMMENT ''可信来源类型'' AFTER `question_type`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl;
EXECUTE aihr_stmt;
DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question'
AND column_name = 'source_request_id'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_community_question` ADD COLUMN `source_request_id` varchar(64) DEFAULT NULL COMMENT ''知识查询请求ID'' AFTER `source_type`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl;
EXECUTE aihr_stmt;
DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question'
AND column_name = 'submit_idempotency_key'
);
SET @aihr_ddl := IF(@aihr_column_exists = 0,
'ALTER TABLE `aihr_community_question` ADD COLUMN `submit_idempotency_key` varchar(80) DEFAULT NULL COMMENT ''提交幂等键'' AFTER `source_request_id`',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl;
EXECUTE aihr_stmt;
DEALLOCATE PREPARE aihr_stmt;
SET @aihr_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question'
AND index_name = 'uk_aihr_community_question_source'
);
SET @aihr_ddl := IF(@aihr_index_exists = 0,
'ALTER TABLE `aihr_community_question` ADD UNIQUE KEY `uk_aihr_community_question_source` (`tenant_id`, `author_user_id`, `source_type`, `source_request_id`)',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl;
EXECUTE aihr_stmt;
DEALLOCATE PREPARE aihr_stmt;
SET @aihr_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question'
AND index_name = 'uk_aihr_community_question_submit_key'
);
SET @aihr_ddl := IF(@aihr_index_exists = 0,
'ALTER TABLE `aihr_community_question` ADD UNIQUE KEY `uk_aihr_community_question_submit_key` (`tenant_id`, `author_user_id`, `submit_idempotency_key`)',
'SELECT 1');
PREPARE aihr_stmt FROM @aihr_ddl;
EXECUTE aihr_stmt;
DEALLOCATE PREPARE aihr_stmt;
SET @aihr_column_exists := NULL;
SET @aihr_index_exists := NULL;
SET @aihr_ddl := NULL;
@@ -0,0 +1,113 @@
-- Learning task traceability and approved question-bank exam assembly.
-- Existing questions remain DRAFT and exam_disabled; migration never fabricates approval.
SET @aihr_schema := DATABASE();
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_format')=0,
'ALTER TABLE aihr_learning_question ADD COLUMN exam_format varchar(20) DEFAULT NULL COMMENT ''single/multiple/judge/short'' AFTER sort_order', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_options_json')=0,
'ALTER TABLE aihr_learning_question ADD COLUMN exam_options_json text COMMENT ''考试选项JSON'' AFTER exam_format', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_answers_json')=0,
'ALTER TABLE aihr_learning_question ADD COLUMN exam_answers_json text COMMENT ''考试答案JSON'' AFTER exam_options_json', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_explanation')=0,
'ALTER TABLE aihr_learning_question ADD COLUMN exam_explanation varchar(1000) DEFAULT NULL COMMENT ''考试解析'' AFTER exam_answers_json', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_enabled')=0,
'ALTER TABLE aihr_learning_question ADD COLUMN exam_enabled tinyint NOT NULL DEFAULT 0 COMMENT ''是否可用于考试组卷'' AFTER exam_explanation', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='review_status')=0,
'ALTER TABLE aihr_learning_question ADD COLUMN review_status varchar(20) NOT NULL DEFAULT ''DRAFT'' COMMENT ''DRAFT/APPROVED/REJECTED'' AFTER exam_enabled', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='review_note')=0,
'ALTER TABLE aihr_learning_question ADD COLUMN review_note varchar(500) DEFAULT NULL COMMENT ''审核说明'' AFTER review_status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='reviewed_by')=0,
'ALTER TABLE aihr_learning_question ADD COLUMN reviewed_by varchar(100) DEFAULT NULL COMMENT ''审核人'' AFTER review_note', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='reviewed_time')=0,
'ALTER TABLE aihr_learning_question ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT ''审核时间'' AFTER reviewed_by', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND index_name='idx_aihr_learning_question_exam_pick')=0,
'ALTER TABLE aihr_learning_question ADD KEY idx_aihr_learning_question_exam_pick (tenant_id, position_code, review_status, exam_enabled, enabled, id)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS aihr_exam_question_draw (
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
tenant_id varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
owner_ext_party_id varchar(100) NOT NULL COMMENT '主管组织身份',
request_key varchar(100) NOT NULL COMMENT '抽题幂等键',
config_hash char(64) NOT NULL COMMENT '岗位、题型和题数配置哈希',
question_snapshot_json longtext NOT NULL COMMENT '审核题目完整快照',
create_time datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_aihr_exam_question_draw_request (tenant_id, owner_ext_party_id, request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='已审核题库随机组卷快照';
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_exam_question' AND column_name='source_question_id')=0,
'ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_question_id bigint DEFAULT NULL COMMENT ''题库来源主键'' AFTER sort_order', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_exam_question' AND column_name='source_question_code')=0,
'ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_question_code varchar(120) DEFAULT NULL COMMENT ''题库来源编码'' AFTER source_question_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_exam_question' AND column_name='source_content_version')=0,
'ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_content_version varchar(30) DEFAULT NULL COMMENT ''题库来源版本'' AFTER source_question_code', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_exam_question' AND column_name='source_content_hash')=0,
'ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_content_hash char(64) DEFAULT NULL COMMENT ''题库来源哈希'' AFTER source_content_version', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='material_type')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN material_type varchar(20) DEFAULT NULL COMMENT ''DOCUMENT/VIDEO/COURSE/SOP'' AFTER course_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='material_attachment_id')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN material_attachment_id bigint DEFAULT NULL COMMENT ''知识附件ID,受控读取'' AFTER material_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='material_title')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN material_title varchar(200) DEFAULT NULL COMMENT ''材料标题快照'' AFTER material_attachment_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='material_version')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN material_version varchar(50) DEFAULT NULL COMMENT ''材料版本快照'' AFTER material_title', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='progress_percent')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN progress_percent int NOT NULL DEFAULT 0 COMMENT ''0-100学习进度'' AFTER material_version', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='completion_rule')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN completion_rule varchar(30) NOT NULL DEFAULT ''MANUAL_CONFIRM'' COMMENT ''MANUAL_CONFIRM/EXAM_PASS'' AFTER progress_percent', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='exam_id')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN exam_id bigint DEFAULT NULL COMMENT ''完成学习后关联考试'' AFTER completion_rule', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='recurrence_type')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN recurrence_type varchar(20) NOT NULL DEFAULT ''ONCE'' COMMENT ''ONCE/DAILY/WEEKLY/MONTHLY'' AFTER exam_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='dispatch_request_key')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN dispatch_request_key varchar(100) DEFAULT NULL COMMENT ''派发幂等键'' AFTER recurrence_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='recurrence_series_key')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN recurrence_series_key varchar(100) DEFAULT NULL COMMENT ''周期任务系列键'' AFTER recurrence_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND index_name='uk_aihr_onboard_task_dispatch')=0,
'ALTER TABLE aihr_onboard_task ADD UNIQUE KEY uk_aihr_onboard_task_dispatch (tenant_id, ext_party_id, dispatch_request_key)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND index_name='idx_aihr_onboard_task_due')=0,
'ALTER TABLE aihr_onboard_task ADD KEY idx_aihr_onboard_task_due (tenant_id, ext_party_id, status, due_time)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Learning material viewing must be auditable and monotonic. These fields are
-- server-owned; the client only reports observed playback/read progress.
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='started_time')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN started_time datetime DEFAULT NULL COMMENT ''材料首次打开时间'' AFTER progress_percent', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='last_viewed_time')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN last_viewed_time datetime DEFAULT NULL COMMENT ''材料最近查看时间'' AFTER started_time', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='last_position_seconds')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN last_position_seconds int NOT NULL DEFAULT 0 COMMENT ''视频最近播放位置'' AFTER last_viewed_time', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='media_duration_seconds')=0,
'ALTER TABLE aihr_onboard_task ADD COLUMN media_duration_seconds int DEFAULT NULL COMMENT ''视频时长快照'' AFTER last_position_seconds', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;