feat(aihr): close functional checklist gaps
This commit is contained in:
@@ -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:
|
||||
# 增加客户端开关
|
||||
|
||||
+208
-10
@@ -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,
|
||||
|
||||
+17
@@ -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) {
|
||||
|
||||
+76
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+131
-24
@@ -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()) {
|
||||
|
||||
+7
@@ -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));
|
||||
|
||||
+3
@@ -15,6 +15,9 @@ public final class AihrCommunityDto {
|
||||
String ruleCode) {
|
||||
}
|
||||
|
||||
public record AnswerFeedbackQuestionRequest(String requestId, String idempotencyKey) {
|
||||
}
|
||||
|
||||
public record CreateAnswerRequest(String content) {
|
||||
}
|
||||
|
||||
|
||||
+179
-2
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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()));
|
||||
|
||||
+27
-3
@@ -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());
|
||||
|
||||
+41
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+37
-3
@@ -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", "", "", "", "", "", "", "", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+52
-3
@@ -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,
|
||||
|
||||
+3
-4
@@ -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);
|
||||
|
||||
+3
-2
@@ -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");
|
||||
}
|
||||
|
||||
+35
-9
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+229
-24
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+32
-4
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
+90
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+433
@@ -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) { }
|
||||
}
|
||||
+172
-24
@@ -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) {
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
|
||||
+416
-28
@@ -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) {
|
||||
}
|
||||
|
||||
+64
-14
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+84
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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;
|
||||
|
||||
+3
-1
@@ -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();
|
||||
|
||||
+94
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-5
@@ -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();
|
||||
|
||||
|
||||
+154
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -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"));
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+53
@@ -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())
|
||||
|
||||
+27
@@ -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");
|
||||
}
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+48
@@ -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;
|
||||
Reference in New Issue
Block a user