feat(work-report): add voice-first reporting flow

This commit is contained in:
2026-07-19 12:17:11 +08:00
parent 24da833fa6
commit 925b48e436
15 changed files with 2813 additions and 451 deletions
@@ -4,9 +4,14 @@ import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.report.AihrWorkReportDto.AttachmentResponse;
import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest;
import org.dromara.aihr.report.AihrWorkReportDto.OrganizeRequest;
import org.dromara.aihr.report.AihrWorkReportDto.OrganizeResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.springframework.http.MediaType;
@@ -19,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.redisson.api.RateType;
import java.util.List;
@@ -41,6 +47,25 @@ public class AihrWorkReportController {
return R.ok(reportService.create(request));
}
@PostMapping("/organize")
public R<OrganizeResponse> organize(@RequestBody OrganizeRequest request) {
enforceOrganizeRateLimit();
return R.ok(reportService.organize(request));
}
private static void enforceOrganizeRateLimit() {
String key = "aihr:work-report:organize:" + LoginHelper.getTenantId() + ":" + LoginHelper.getUserId();
try {
if (RedisUtils.rateLimiter(key, RateType.OVERALL, 30, 60, 86400) == -1) {
throw new ServiceException("工作上报整理过于频繁,请稍后再试", 429);
}
} catch (ServiceException known) {
throw known;
} catch (Exception unavailable) {
throw new ServiceException("工作上报整理服务暂不可用,请稍后再试", 503);
}
}
@PostMapping(value = "/attachment", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<AttachmentResponse> uploadAttachment(@RequestPart("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
@@ -11,7 +11,26 @@ public final class AihrWorkReportDto {
private AihrWorkReportDto() {
}
public record CreateReportRequest(String type,
public record CurrentDraft(String suggestedType, String title, String content) {
}
public record OrganizeRequest(String transcript,
CurrentDraft currentDraft,
Long attachmentOssId,
String attachmentName) {
}
public record OrganizeResponse(String suggestedType,
String title,
String content,
List<String> missingQuestions,
List<String> privacyWarnings,
double confidence,
String notice) {
}
public record CreateReportRequest(String requestId,
String type,
String title,
String content,
Long attachmentOssId,
@@ -1,23 +1,46 @@
package org.dromara.aihr.report;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.community.AihrCommunityPolicy;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest;
import org.dromara.aihr.report.AihrWorkReportDto.CurrentDraft;
import org.dromara.aihr.report.AihrWorkReportDto.OrganizeRequest;
import org.dromara.aihr.report.AihrWorkReportDto.OrganizeResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
import org.dromara.aihr.service.AihrModelSeedService;
import org.dromara.aihr.service.AihrSensitiveText;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
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.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 工作上报:员工提交 → 主管/运营审核(通过/驳回)。
@@ -41,50 +64,412 @@ public class AihrWorkReportService {
"REJECTED", "已驳回"
);
private static final Set<String> REVIEW_DECISIONS = Set.of("APPROVE", "REJECT");
private static final Set<String> VIDEO_SUFFIXES = Set.of("mp4", "mov", "avi", "mkv", "webm", "m4v");
private static final Set<String> ATTACHMENT_SUFFIXES = Set.of(
"jpg", "jpeg", "png", "gif", "webp", "bmp",
"mp4", "mov", "avi", "mkv", "webm", "m4v",
"mp3", "wav", "m4a", "aac", "ogg", "opus", "flac", "amr",
"txt", "md", "markdown", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"
);
private static final Pattern PHONE = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
private static final Pattern EMAIL = Pattern.compile("(?i)(?<![\\w.+-])[\\w.+-]+@[\\w-]+(?:\\.[\\w-]+)+(?![\\w.-])");
private static final Pattern ROOM = Pattern.compile("(?<!\\d)(?:\\d{1,2}(?:栋|幢|号楼|单元)\\d{3,4}|\\d{1,2}[--]\\d{3,4}|\\d{3,4}室)(?!\\d)");
private static final Pattern HONORIFIC_NAME = Pattern.compile("[\\u4e00-\\u9fa5]{1,3}(?:先生|女士)");
private static final int MAX_TITLE = 100;
private static final int MAX_CONTENT = 2000;
private static final int MAX_TRANSCRIPT = 4000;
private static final int MAX_ATTACHMENT_NAME = 200;
private static final int MAX_QUESTION = 200;
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgePrincipalResolver principalResolver;
private final AihrModelSeedService modelService;
private final ObjectMapper objectMapper;
private final ISysOssService ossService;
private volatile boolean tableReady;
public OrganizeResponse organize(OrganizeRequest request) {
if (request == null) {
throw badRequest("整理内容不能为空");
}
String transcript = clean(request.transcript());
CurrentDraft draft = request.currentDraft();
String draftType = clean(draft == null ? null : draft.suggestedType()).toUpperCase(Locale.ROOT);
String draftTitle = clean(draft == null ? null : draft.title());
String draftContent = clean(draft == null ? null : draft.content());
requireMax(transcript, MAX_TRANSCRIPT, "单次转写不能超过 4000 字");
requireMax(draftTitle, MAX_TITLE, "当前草稿标题不能超过 100 字");
requireMax(draftContent, MAX_CONTENT, "当前草稿正文不能超过 2000 字");
requireMax(clean(request.attachmentName()), MAX_ATTACHMENT_NAME, "附件名不能超过 200 字");
if (!draftType.isBlank() && !TYPE_LABELS.containsKey(draftType)) {
throw badRequest("当前草稿类型无效");
}
if (transcript.isBlank() && draftTitle.isBlank() && draftContent.isBlank()
&& request.attachmentOssId() == null) {
throw badRequest("请先说一段话、输入文字或添加附件");
}
Attachment attachment = request.attachmentOssId() == null ? null
: resolveAttachment(request.attachmentOssId(), principalResolver.current());
String rawText = joinNonBlank(draftTitle, draftContent, transcript,
attachment == null ? "" : attachment.fileName());
List<String> warnings = privacyWarnings(rawText);
String rawContent = mergeDraft(draftContent, transcript);
requireMax(rawContent, MAX_CONTENT, "工作上报草稿正文不能超过 2000 字,请缩短后重试");
String rawTitle = safeTitle(draftTitle, transcript, draftContent);
if (transcript.isBlank() && draft != null) {
return localOrganize(draftType, rawTitle, rawContent, warnings, 1, "");
}
Optional<OrganizeResponse> organized = modelService.tryChat(
organizeSystemPrompt(), organizeUserPrompt(transcript, draftType, draftTitle, draftContent, attachment), 0.0
).flatMap(raw -> parseOrganize(raw, warnings, rawTitle, rawContent));
return organized.orElseGet(() -> fallbackOrganize(draftType, rawTitle, rawContent, warnings));
}
public ReportResponse create(CreateReportRequest request) {
ensureTable();
AihrKnowledgePrincipal principal = principalResolver.current();
String type = clean(request == null ? null : request.type()).toUpperCase();
if (request == null) {
throw badRequest("上报内容不能为空");
}
String type = clean(request.type()).toUpperCase(Locale.ROOT);
if (!TYPE_LABELS.containsKey(type)) {
throw new ServiceException("上报类型无效,仅支持:优秀案例/操作视频/完整SOP/有用知识", 400);
throw badRequest("上报类型无效,仅支持:优秀案例/操作视频/完整SOP/有用知识");
}
String title = clean(request.title());
if (title.isBlank()) {
throw new ServiceException("请填写上报标题", 400);
throw badRequest("请填写上报标题");
}
requireMax(title, MAX_TITLE, "上报标题不能超过 100 字");
String content = clean(request.content());
if (content.isBlank()) {
throw new ServiceException("请填写上报说明", 400);
throw badRequest("请填写上报说明");
}
requireMax(content, MAX_CONTENT, "上报正文不能超过 2000 字");
if ("VIDEO".equals(type) && request.attachmentOssId() == null) {
throw badRequest("操作视频上报必须上传视频附件");
}
String requestKey = normalizeRequestKey(request.requestId());
String requestHash = requestHash(type, title, content, request.attachmentOssId());
AihrKnowledgePrincipal principal = principalResolver.current();
ensureTable();
IdempotentRecord existing = findIdempotent(principal, requestKey);
if (existing != null) {
return replayOrConflict(existing, requestHash, principal.tenantId());
}
Attachment attachment = request.attachmentOssId() == null ? null
: resolveAttachment(request.attachmentOssId(), principal);
if ("VIDEO".equals(type) && !VIDEO_SUFFIXES.contains(attachment.suffix())) {
throw badRequest("操作视频上报必须使用视频附件");
}
List<String> privacyIssues = privacyWarnings(joinNonBlank(title, content,
attachment == null ? "" : attachment.fileName()));
if (!privacyIssues.isEmpty()) {
throw badRequest(privacyIssues.get(0));
}
LocalDateTime now = LocalDateTime.now();
jdbcTemplate.update("""
INSERT INTO aihr_work_report
(tenant_id, report_type, title, content, attachment_oss_id, attachment_name,
status, submitter_user_id, submitter_identity, submitter_name, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?, ?)
""",
principal.tenantId(),
type,
truncate(title, MAX_TITLE),
truncate(content, MAX_CONTENT),
request.attachmentOssId(),
truncate(clean(request.attachmentName()), 200),
principal.userId(),
clean(principal.extPartyId()),
displayName(principal),
Timestamp.valueOf(now),
Timestamp.valueOf(now)
try {
long id = insert(type, title, content, attachment, principal, requestKey, requestHash, now);
return byId(id, principal.tenantId());
} catch (DuplicateKeyException duplicate) {
IdempotentRecord raced = findIdempotent(principal, requestKey);
if (raced != null) {
return replayOrConflict(raced, requestHash, principal.tenantId());
}
throw duplicate;
}
}
private Optional<OrganizeResponse> parseOrganize(String raw, List<String> warnings,
String rawTitle, String rawContent) {
try {
JsonNode root = objectMapper.reader()
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(raw);
if (root == null || !root.isObject()
|| !root.path("suggestedType").isTextual()
|| !root.path("title").isTextual()
|| !root.path("content").isTextual()
|| !root.path("missingQuestions").isArray()
|| !root.path("confidence").isNumber()) {
return Optional.empty();
}
String type = clean(root.path("suggestedType").asText()).toUpperCase(Locale.ROOT);
String modelTitle = clean(root.path("title").asText());
String modelContent = clean(root.path("content").asText());
double confidence = root.path("confidence").asDouble();
if (!TYPE_LABELS.containsKey(type) || modelTitle.length() > MAX_TITLE || modelContent.length() > MAX_CONTENT
|| !Double.isFinite(confidence) || confidence < 0 || confidence > 1) {
return Optional.empty();
}
List<String> modelQuestions = new ArrayList<>(2);
for (JsonNode node : root.path("missingQuestions")) {
if (!node.isTextual()) {
return Optional.empty();
}
String question = clean(node.asText());
if (question.length() > MAX_QUESTION) {
return Optional.empty();
}
if (!question.isBlank() && modelQuestions.size() < 2) {
modelQuestions.add(question);
}
}
List<String> questions = new ArrayList<>(2);
if (rawTitle.isBlank()) {
questions.add("请用一句话概括这次上报的主题。");
}
if (rawContent.isBlank()) {
questions.add("请补充事情经过、你的处理和最终结果。");
}
for (String question : modelQuestions) {
if (questions.size() < 2 && !questions.contains(question)) {
questions.add(question);
}
}
return Optional.of(new OrganizeResponse(type, rawTitle, rawContent, List.copyOf(questions), warnings,
confidence, ""));
} catch (Exception ignored) {
log.debug("work report organize model JSON invalid; using raw draft fallback");
return Optional.empty();
}
}
private OrganizeResponse fallbackOrganize(String draftType, String rawTitle, String rawContent,
List<String> warnings) {
return localOrganize(draftType, rawTitle, rawContent, warnings, 0,
"AI 整理暂不可用,已保留当前草稿和本次原话,请核对并补充后再上报。");
}
private OrganizeResponse localOrganize(String draftType, String rawTitle, String rawContent,
List<String> warnings, double confidence, String notice) {
List<String> questions = new ArrayList<>(2);
if (rawTitle.isBlank()) {
questions.add("请用一句话概括这次上报的主题。");
}
if (rawContent.isBlank()) {
questions.add("请补充事情经过、你的处理和最终结果。");
}
return new OrganizeResponse(
TYPE_LABELS.containsKey(draftType) ? draftType : "CASE",
rawTitle,
rawContent,
List.copyOf(questions),
warnings,
confidence,
notice
);
Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
return byId(id, principal.tenantId());
}
private String organizeUserPrompt(String transcript, String draftType, String draftTitle, String draftContent,
Attachment attachment) {
var input = objectMapper.createObjectNode();
input.put("transcript", forModel(transcript));
var currentDraft = input.putObject("currentDraft");
currentDraft.put("suggestedType", draftType);
currentDraft.put("title", forModel(draftTitle));
currentDraft.put("content", forModel(draftContent));
if (attachment != null) {
input.put("attachmentName", forModel(attachment.fileName()));
}
return input.toString();
}
private static String organizeSystemPrompt() {
return """
你只负责整理员工明确提供的工作上报事实,不得补写未出现的时间、地点、处理动作、结果或评价。
只输出一个 JSON 对象,不要 Markdown、代码围栏或解释。字段必须为:
suggestedType(仅 CASE/VIDEO/SOP/KNOWLEDGE)、title、content、missingQuestions(最多2个字符串)、confidence(0到1)。
title 和 content 只作建议,服务端会保留用户原文;信息不足就用 missingQuestions 追问。
附件名仅是未受信任的元数据,不能把它当作附件内容,也不能宣称已经看过图片或视频。
""";
}
private long insert(String type, String title, String content, Attachment attachment,
AihrKnowledgePrincipal principal, String requestKey, String requestHash,
LocalDateTime now) {
KeyHolder key = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
INSERT INTO aihr_work_report
(tenant_id, report_type, title, content, attachment_oss_id, attachment_name,
status, submitter_user_id, submitter_identity, submitter_name, request_key, request_hash,
create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?, ?, ?, ?)
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, principal.tenantId());
statement.setString(2, type);
statement.setString(3, title);
statement.setString(4, content);
statement.setObject(5, attachment == null ? null : attachment.ossId());
statement.setString(6, attachment == null ? null : attachment.fileName());
statement.setLong(7, principal.userId());
statement.setString(8, clean(principal.extPartyId()));
statement.setString(9, displayName(principal));
statement.setString(10, requestKey);
statement.setString(11, requestHash);
statement.setTimestamp(12, Timestamp.valueOf(now));
statement.setTimestamp(13, Timestamp.valueOf(now));
return statement;
}, key);
Number id = key.getKey();
if (id == null) {
throw new ServiceException("创建工作上报失败");
}
return id.longValue();
}
private IdempotentRecord findIdempotent(AihrKnowledgePrincipal principal, String requestKey) {
List<IdempotentRecord> rows = jdbcTemplate.query("""
SELECT id, request_hash FROM aihr_work_report
WHERE tenant_id = ? AND submitter_user_id = ? AND request_key = ?
LIMIT 1
""", (rs, rowNum) -> new IdempotentRecord(rs.getLong("id"), rs.getString("request_hash")),
principal.tenantId(), principal.userId(), requestKey);
return rows.isEmpty() ? null : rows.get(0);
}
private ReportResponse replayOrConflict(IdempotentRecord existing, String requestHash, String tenantId) {
if (!requestHash.equals(existing.requestHash())) {
throw new ServiceException("该请求ID已用于不同的工作上报内容", 409);
}
return byId(existing.id(), tenantId);
}
private Attachment resolveAttachment(Long ossId, AihrKnowledgePrincipal principal) {
if (ossId == null || ossId <= 0) {
throw badRequest("附件编号无效");
}
SysOssVo oss = ossService.getById(ossId);
if (oss == null) {
throw badRequest("附件不存在或已删除");
}
Integer owned = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM sys_oss
WHERE tenant_id = ? AND oss_id = ? AND create_by = ?
""", Integer.class, principal.tenantId(), ossId, principal.userId());
if (owned == null || owned == 0 || !principal.userId().equals(oss.getCreateBy())) {
throw new ServiceException("附件不属于当前用户或当前租户", 403);
}
String fileName = serverFileName(oss);
requireMax(fileName, MAX_ATTACHMENT_NAME, "附件名不能超过 200 字");
String suffix = suffix(oss, fileName);
if (!ATTACHMENT_SUFFIXES.contains(suffix)) {
throw badRequest("附件格式不支持");
}
return new Attachment(ossId, fileName, suffix);
}
private static List<String> privacyWarnings(String text) {
List<String> warnings = new ArrayList<>(4);
if (ROOM.matcher(text).find()) {
warnings.add("检测到具体房号,请修改后再提交。");
}
if (PHONE.matcher(text).find()) {
warnings.add("检测到手机号,请修改后再提交。");
}
if (EMAIL.matcher(text).find()) {
warnings.add("检测到邮箱,请修改后再提交。");
}
if (HONORIFIC_NAME.matcher(text).find()) {
warnings.add("检测到住户姓名或称谓,请修改后再提交。");
}
return List.copyOf(warnings);
}
private static String normalizeRequestKey(String value) {
String key = clean(value);
if (key.isBlank()) {
throw badRequest("请求ID不能为空");
}
if (key.length() > 100 || !key.matches("[A-Za-z0-9._:-]+")) {
throw badRequest("请求ID格式不正确");
}
return key;
}
private static String requestHash(String type, String title, String content, Long attachmentOssId) {
String canonical = String.join("\u001f", type, title, content,
attachmentOssId == null ? "" : attachmentOssId.toString());
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
}
private static String mergeDraft(String current, String addition) {
if (current.isBlank()) {
return addition;
}
if (addition.isBlank() || current.equals(addition)) {
return current;
}
return current + "\n" + addition;
}
private static String safeTitle(String draftTitle, String transcript, String draftContent) {
if (!clean(draftTitle).isBlank()) {
return clean(draftTitle);
}
String source = clean(transcript);
if (source.isBlank()) {
source = clean(draftContent);
}
source = source.replaceAll("\\s+", " ");
return source.length() <= 30 ? source : source.substring(0, 30);
}
private static String forModel(String value) {
return AihrSensitiveText.forModel(clean(value));
}
private static String joinNonBlank(String... values) {
List<String> parts = new ArrayList<>();
for (String value : values) {
if (!clean(value).isBlank()) {
parts.add(clean(value));
}
}
return String.join("\n", parts);
}
private static String serverFileName(SysOssVo oss) {
String value = clean(oss.getOriginalName());
if (value.isBlank()) {
value = clean(oss.getFileName());
}
value = value.replace('\\', '/');
int slash = value.lastIndexOf('/');
return slash < 0 ? value : value.substring(slash + 1);
}
private static String suffix(SysOssVo oss, String fileName) {
String suffix = clean(oss.getFileSuffix()).toLowerCase(Locale.ROOT);
while (suffix.startsWith(".")) {
suffix = suffix.substring(1);
}
if (!suffix.isBlank()) {
return suffix;
}
int dot = fileName.lastIndexOf('.');
return dot < 0 ? "" : fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
}
private static void requireMax(String value, int max, String message) {
if (value.length() > max) {
throw badRequest(message);
}
}
private static ServiceException badRequest(String message) {
return new ServiceException(message, 400);
}
private record Attachment(Long ossId, String fileName, String suffix) {
}
private record IdempotentRecord(Long id, String requestHash) {
}
public List<ReportResponse> myReports() {
@@ -215,17 +600,46 @@ public class AihrWorkReportService {
`reviewer_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核人显示名',
`review_note` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核意见',
`review_time` datetime DEFAULT NULL COMMENT '审核时间',
`request_key` varchar(100) COLLATE utf8mb4_general_ci NOT NULL COMMENT '提交幂等键',
`request_hash` char(64) COLLATE utf8mb4_general_ci NOT NULL COMMENT '提交载荷SHA-256',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_work_report_request` (`tenant_id`, `submitter_user_id`, `request_key`),
KEY `idx_aihr_work_report_submitter` (`tenant_id`, `submitter_user_id`, `id`),
KEY `idx_aihr_work_report_status` (`tenant_id`, `status`, `id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '工作上报'
""");
requireIdempotencySchema();
tableReady = true;
}
}
private void requireIdempotencySchema() {
Integer columns = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_work_report'
AND column_name IN ('request_key', 'request_hash')
""", Integer.class);
Integer validIndexes = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM (
SELECT index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_work_report'
AND index_name = 'uk_aihr_work_report_request'
GROUP BY index_name
HAVING MIN(non_unique) = 0
AND COUNT(*) = 3
AND SUM(sub_part IS NOT NULL) = 0
AND GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') =
'tenant_id,submitter_user_id,request_key'
) valid_work_report_request_index
""", Integer.class);
if (columns == null || columns != 2 || validIndexes == null || validIndexes != 1) {
throw new ServiceException("工作上报数据库迁移未执行,请先运行幂等迁移脚本");
}
}
private static String displayName(AihrKnowledgePrincipal principal) {
String identity = clean(principal.extPartyId());
if (identity.isBlank()) {
@@ -15,7 +15,9 @@ public final class AihrSensitiveText {
private static final Pattern EMAIL = Pattern.compile("(?i)(?<![\\w.+-])[\\w.+-]+@[\\w-]+(?:\\.[\\w-]+)+(?![\\w.-])");
private static final Pattern ROOM = Pattern.compile("(?<!\\d)(\\d{1,2})(栋|幢|号楼|单元)(\\d{3,4})(?!\\d)");
private static final Pattern ROOM_SHORT = Pattern.compile("(?<!\\d)(\\d{1,2})[--](\\d{3,4})(?!\\d)");
private static final Pattern ROOM_WITH_SUFFIX = Pattern.compile("(?<!\\d)(\\d{3,4})室(?!\\d)");
private static final Pattern HONORIFIC_NAME = Pattern.compile("([\\u4e00-\\u9fa5])([\\u4e00-\\u9fa5])(先生|女士|经理)");
private static final Pattern LEADING_HONORIFIC_NAME = Pattern.compile("^([\\u4e00-\\u9fa5])(先生|女士|经理)");
private AihrSensitiveText() {
}
@@ -28,7 +30,9 @@ public final class AihrSensitiveText {
masked = EMAIL.matcher(masked).replaceAll("[邮箱已脱敏]");
masked = ROOM.matcher(masked).replaceAll("$1$2****");
masked = ROOM_SHORT.matcher(masked).replaceAll("$1-****");
return HONORIFIC_NAME.matcher(masked).replaceAll("$1*$3");
masked = ROOM_WITH_SUFFIX.matcher(masked).replaceAll("****室");
masked = HONORIFIC_NAME.matcher(masked).replaceAll("$1*$3");
return LEADING_HONORIFIC_NAME.matcher(masked).replaceAll("*$2");
}
/** 已知的结构化姓名字段不应仅依赖称谓正则,直接用匿名角色替换。 */
@@ -0,0 +1,33 @@
package org.dromara.aihr.report;
import cn.dev33.satoken.annotation.SaCheckLogin;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrWorkReportAccessContractTest {
@Test
void everyWorkReportEndpointRequiresLoginAndOrganizeIsUserRateLimited() throws Exception {
assertNotNull(AihrWorkReportController.class.getAnnotation(SaCheckLogin.class));
String source = Files.readString(controllerSource());
assertTrue(source.contains("RedisUtils.rateLimiter(key, RateType.OVERALL, 30, 60, 86400)"));
assertTrue(source.contains("LoginHelper.getTenantId()"));
assertTrue(source.contains("LoginHelper.getUserId()"));
assertTrue(source.contains("new ServiceException(\"工作上报整理过于频繁,请稍后再试\", 429)"));
}
private static Path controllerSource() {
Path direct = Path.of("src/main/java/org/dromara/aihr/report/AihrWorkReportController.java");
return Files.exists(direct)
? direct
: Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportController.java");
}
}
@@ -0,0 +1,510 @@
package org.dromara.aihr.report;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest;
import org.dromara.aihr.report.AihrWorkReportDto.CurrentDraft;
import org.dromara.aihr.report.AihrWorkReportDto.OrganizeRequest;
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
import org.dromara.aihr.service.AihrModelSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.KeyHolder;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrWorkReportServiceTest {
private JdbcTemplate jdbcTemplate;
private AihrKnowledgePrincipalResolver principalResolver;
private AihrModelSeedService modelService;
private ISysOssService ossService;
private AihrWorkReportService service;
@BeforeEach
void setUp() {
jdbcTemplate = mock(JdbcTemplate.class);
principalResolver = mock(AihrKnowledgePrincipalResolver.class);
modelService = mock(AihrModelSeedService.class);
ossService = mock(ISysOssService.class);
service = new AihrWorkReportService(jdbcTemplate, principalResolver, modelService,
new ObjectMapper(), ossService);
}
@Test
void organizeIsStatelessAndNeverLetsTheModelRewriteUserFacts() {
when(modelService.tryChat(anyString(), anyString(), eq(0.0))).thenReturn(Optional.of("""
{"suggestedType":"CASE","title":"模型编写的标题","content":"模型声称住户已经表扬。",\
"missingQuestions":[],"confidence":0.9}
"""));
var result = service.organize(new OrganizeRequest("发现漏水并完成现场处置。", null, null, null));
assertEquals("CASE", result.suggestedType());
assertEquals("发现漏水并完成现场处置。", result.title());
assertEquals("发现漏水并完成现场处置。", result.content());
assertFalse(result.content().contains("住户已经表扬"));
assertTrue(result.notice().isBlank());
verify(modelService).tryChat(anyString(), anyString(), eq(0.0));
verifyNoInteractions(jdbcTemplate);
}
@Test
void invalidModelTypeFallsBackToDraftAndRawTranscriptWithoutInventingResult() {
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.of("""
{"suggestedType":"OTHER","title":"模型标题","content":"已彻底解决并获得业主表扬。",\
"missingQuestions":[],"confidence":1.0}
"""));
var result = service.organize(new OrganizeRequest(
"补充原话", new CurrentDraft("CASE", "已有标题", "已有经过"), null, null));
assertEquals("CASE", result.suggestedType());
assertEquals("已有标题", result.title());
assertEquals("已有经过\n补充原话", result.content());
assertFalse(result.content().contains("彻底解决"));
assertFalse(result.notice().isBlank());
}
@Test
void markdownWrappedModelJsonIsRejectedInsteadOfLooselyExtracted() {
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.of("""
```json
{"suggestedType":"CASE","title":"编造标题","content":"编造结果",\
"missingQuestions":[],"confidence":1.0}
```
"""));
var result = service.organize(new OrganizeRequest(
"现场原话", new CurrentDraft("CASE", "", ""), null, null));
assertEquals("现场原话", result.title());
assertEquals("现场原话", result.content());
assertFalse(result.notice().isBlank());
}
@Test
void modelFailureKeepsOriginalSensitiveTextAndOnlyAddsWarning() {
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty());
String transcript = "5栋1203的王女士电话13812345678反馈漏水";
var result = service.organize(new OrganizeRequest(transcript, null, null, null));
assertEquals(transcript, result.content());
assertFalse(result.privacyWarnings().isEmpty());
assertTrue(result.privacyWarnings().stream().allMatch(item -> item.contains("请修改后再提交")));
assertFalse(result.notice().contains("已脱敏"));
}
@Test
void privacyWarningDoesNotLetModelReplaceTheFinalTextWithMaskedContent() {
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.of("""
{"suggestedType":"CASE","title":"漏水反馈","content":"5栋****的住户反馈漏水",\
"missingQuestions":[],"confidence":0.8}
"""));
String transcript = "5栋1203的住户反馈漏水";
var result = service.organize(new OrganizeRequest(transcript, null, null, null));
assertEquals(transcript, result.content());
assertTrue(result.privacyWarnings().stream().anyMatch(item -> item.contains("房号")));
}
@Test
void serverAttachmentNameAlsoParticipatesInPrivacyWarnings() throws Exception {
when(principalResolver.current()).thenReturn(principal());
when(ossService.getById(12L)).thenReturn(attachment(12L, 7L, "5栋1203现场.jpg", "jpg"));
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any(Object[].class))).thenReturn(1);
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty());
var result = service.organize(new OrganizeRequest("现场照片", null, 12L, "无风险名称.jpg"));
assertTrue(result.privacyWarnings().stream().anyMatch(item -> item.contains("房号")));
}
@Test
void attachmentOnlyPrivacyWarningRechecksThePreservedEmptyContent() throws Exception {
when(principalResolver.current()).thenReturn(principal());
when(ossService.getById(13L)).thenReturn(attachment(13L, 7L, "5栋1203现场.jpg", "jpg"));
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any(Object[].class))).thenReturn(1);
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.of("""
{"suggestedType":"CASE","title":"现场记录","content":"模型补写的现场情况",\
"missingQuestions":[],"confidence":0.7}
"""));
var result = service.organize(new OrganizeRequest("", null, 13L, "无风险名称.jpg"));
assertEquals("", result.content());
assertTrue(result.missingQuestions().stream().anyMatch(item -> item.contains("事情经过")));
assertTrue(result.privacyWarnings().stream().anyMatch(item -> item.contains("房号")));
}
@Test
void modelQuestionsAreKeptWithoutLettingBlankModelFieldsEraseRawText() {
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.of("""
{"suggestedType":"CASE","title":"","content":"",\
"missingQuestions":["具体做了什么处理?","最终结果怎么样?"],"confidence":0.3}
"""));
var result = service.organize(new OrganizeRequest("说得不清楚", null, null, null));
assertEquals("说得不清楚", result.title());
assertEquals("说得不清楚", result.content());
assertEquals(2, result.missingQuestions().size());
assertEquals("具体做了什么处理?", result.missingQuestions().get(0));
assertEquals("最终结果怎么样?", result.missingQuestions().get(1));
}
@Test
void correctiveFollowUpIsAppendedEvenWhenItIsASubstringOfThePreviousDraft() {
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty());
var result = service.organize(new OrganizeRequest(
"处理完成", new CurrentDraft("CASE", "电梯故障", "电梯尚未处理完成"), null, null));
assertEquals("电梯尚未处理完成\n处理完成", result.content());
}
@Test
void manualDraftRecheckUsesLocalValidationWithoutCallingTheModelAgain() {
var result = service.organize(new OrganizeRequest(
"", new CurrentDraft("CASE", "电梯故障", "项目经理安排了巡检"), null, null));
assertEquals("电梯故障", result.title());
assertEquals("项目经理安排了巡检", result.content());
assertTrue(result.notice().isBlank());
verifyNoInteractions(modelService);
}
@Test
void rejectsOrganizeAndCreateLengthOverflowInsteadOfTruncating() {
ServiceException transcript = assertThrows(ServiceException.class,
() -> service.organize(new OrganizeRequest("长".repeat(4001), null, null, null)));
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty());
ServiceException fallback = assertThrows(ServiceException.class,
() -> service.organize(new OrganizeRequest("长".repeat(2001), null, null, null)));
ServiceException title = assertThrows(ServiceException.class,
() -> service.create(new CreateReportRequest("request-1", "CASE", "题".repeat(101), "正文", null, null)));
ServiceException content = assertThrows(ServiceException.class,
() -> service.create(new CreateReportRequest("request-2", "CASE", "标题", "文".repeat(2001), null, null)));
assertEquals(400, transcript.getCode());
assertEquals(400, fallback.getCode());
assertEquals(400, title.getCode());
assertEquals(400, content.getCode());
verifyNoInteractions(principalResolver);
verifyNoInteractions(modelService);
}
@Test
void masksStandaloneRoomAndResidentNameBeforeCallingTheModel() {
when(modelService.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty());
service.organize(new OrganizeRequest("1203室王女士反馈漏水", null, null, null));
ArgumentCaptor<String> prompt = ArgumentCaptor.forClass(String.class);
verify(modelService).tryChat(anyString(), prompt.capture(), eq(0.0));
assertFalse(prompt.getValue().contains("1203室"));
assertFalse(prompt.getValue().contains("王女士"));
assertTrue(prompt.getValue().contains("****室"));
}
@Test
void createRequiresRequestIdRejectsStandaloneRoomButAllowsJobTitle() throws Exception {
markTableReady(service);
when(principalResolver.current()).thenReturn(principal());
ServiceException missingKey = assertThrows(ServiceException.class, () -> service.create(
new CreateReportRequest("", "CASE", "设备巡检", "项目经理安排了巡检", null, null)));
assertEquals(400, missingKey.getCode());
StateJdbcTemplate stateJdbc = new StateJdbcTemplate();
AihrWorkReportService stateService = new AihrWorkReportService(stateJdbc, principalResolver, modelService,
new ObjectMapper(), ossService);
markTableReady(stateService);
ServiceException room = assertThrows(ServiceException.class, () -> stateService.create(
new CreateReportRequest("room-key", "CASE", "住户反馈", "1203室业主反馈漏水", null, null)));
assertEquals(400, room.getCode());
var allowed = stateService.create(
new CreateReportRequest("manager-key", "CASE", "设备巡检", "项目经理安排了巡检", null, null));
assertEquals("项目经理安排了巡检", allowed.content());
assertEquals(1, stateJdbc.insertCount());
}
@Test
void reportReadsAreTenantScopedAndEmployeesCannotOpenTheReviewQueue() throws Exception {
TenantCapturingJdbcTemplate tenantJdbc = new TenantCapturingJdbcTemplate();
AihrWorkReportService tenantService = new AihrWorkReportService(tenantJdbc, principalResolver, modelService,
new ObjectMapper(), ossService);
markTableReady(tenantService);
when(principalResolver.current()).thenReturn(principal());
assertTrue(tenantService.myReports().isEmpty());
assertTrue(tenantJdbc.sql.contains("tenant_id = ? AND submitter_user_id = ?"));
assertEquals(List.of("000000", 7L), List.of(tenantJdbc.args));
ServiceException forbidden = assertThrows(ServiceException.class,
() -> tenantService.pendingReports("PENDING"));
assertEquals(403, forbidden.getCode());
ServiceException reviewForbidden = assertThrows(ServiceException.class,
() -> tenantService.review(41L, new ReviewReportRequest("APPROVE", "")));
assertEquals(403, reviewForbidden.getCode());
}
@Test
void videoRequiresARealVideoAttachment() throws Exception {
markTableReady(service);
when(principalResolver.current()).thenReturn(principal());
ServiceException missing = assertThrows(ServiceException.class,
() -> service.create(new CreateReportRequest("video-1", "VIDEO", "操作演示", "演示正文", null, null)));
assertEquals(400, missing.getCode());
SysOssVo image = attachment(9L, 7L, "现场图.jpg", "jpg");
when(ossService.getById(9L)).thenReturn(image);
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any(Object[].class))).thenReturn(1);
ServiceException wrongType = assertThrows(ServiceException.class,
() -> service.create(new CreateReportRequest("video-2", "VIDEO", "操作演示", "演示正文", 9L, "伪造.mp4")));
assertEquals(400, wrongType.getCode());
verify(ossService).getById(9L);
verify(jdbcTemplate, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void rejectsAttachmentOwnedByAnotherUploader() throws Exception {
markTableReady(service);
when(principalResolver.current()).thenReturn(principal());
when(ossService.getById(10L)).thenReturn(attachment(10L, 99L, "现场图.jpg", "jpg"));
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any(Object[].class))).thenReturn(1);
ServiceException error = assertThrows(ServiceException.class, () -> service.create(
new CreateReportRequest("case-attachment", "CASE", "案例", "正文", 10L, "现场图.jpg")));
assertEquals(403, error.getCode());
verify(jdbcTemplate, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void successfulAttachmentUsesServerFileNameInsteadOfClientName() throws Exception {
StateJdbcTemplate stateJdbc = new StateJdbcTemplate();
AihrWorkReportService stateService = new AihrWorkReportService(stateJdbc, principalResolver, modelService,
new ObjectMapper(), ossService);
markTableReady(stateService);
when(principalResolver.current()).thenReturn(principal());
when(ossService.getById(11L)).thenReturn(attachment(11L, 7L, "真实操作.mp4", "mp4"));
var result = stateService.create(
new CreateReportRequest("video-server-name", "VIDEO", "操作演示", "正文", 11L, "伪造名称.jpg"));
assertEquals("真实操作.mp4", result.attachmentName());
assertEquals(11L, result.attachmentOssId());
}
@Test
void createReplaysSameKeyAndRejectsDifferentPayload() throws Exception {
StateJdbcTemplate stateJdbc = new StateJdbcTemplate();
AihrWorkReportService stateService = new AihrWorkReportService(stateJdbc, principalResolver, modelService,
new ObjectMapper(), ossService);
markTableReady(stateService);
when(principalResolver.current()).thenReturn(principal());
CreateReportRequest request = new CreateReportRequest("same-key", "CASE", "标题", "原始正文", null, null);
var first = stateService.create(request);
var replay = stateService.create(request);
ServiceException conflict = assertThrows(ServiceException.class, () -> stateService.create(
new CreateReportRequest("same-key", "CASE", "不同标题", "原始正文", null, null)));
assertEquals(first, replay);
assertEquals(1, stateJdbc.insertCount);
assertEquals(409, conflict.getCode());
}
@Test
void duplicateKeyRaceReplaysTheWinningInsert() throws Exception {
RacingJdbcTemplate racingJdbc = new RacingJdbcTemplate();
AihrWorkReportService racingService = new AihrWorkReportService(racingJdbc, principalResolver, modelService,
new ObjectMapper(), ossService);
markTableReady(racingService);
when(principalResolver.current()).thenReturn(principal());
var result = racingService.create(
new CreateReportRequest("raced-key", "CASE", "标题", "正文", null, null));
assertEquals(41L, result.id());
assertEquals(1, racingJdbc.insertCount());
}
@Test
void migrationAddsRequestHashAndScopedUniqueKeyWithoutCreatingDraftTables() throws Exception {
String migration = Files.readString(projectPath(
"../../script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql"));
assertTrue(migration.contains("request_key"));
assertTrue(migration.contains("request_hash"));
assertTrue(migration.contains("uk_aihr_work_report_request"));
assertTrue(migration.contains("tenant_id, submitter_user_id, request_key"));
assertTrue(migration.contains("aihr_knowledge_info"));
assertTrue(migration.contains("CONVERT TO CHARACTER SET utf8mb4 COLLATE"));
assertFalse(migration.contains("work_report_draft"));
assertFalse(migration.contains("work_report_conversation"));
}
private static AihrKnowledgePrincipal principal() {
return new AihrKnowledgePrincipal("000000", 7L, "app_user", "EMP-7", Set.of("employee"), Set.of(), "client");
}
private static SysOssVo attachment(Long id, Long createBy, String name, String suffix) {
SysOssVo oss = new SysOssVo();
oss.setOssId(id);
oss.setCreateBy(createBy);
oss.setOriginalName(name);
oss.setFileName("2026/07/" + id + "." + suffix);
oss.setFileSuffix(suffix);
return oss;
}
private static void markTableReady(AihrWorkReportService target) throws Exception {
var field = AihrWorkReportService.class.getDeclaredField("tableReady");
field.setAccessible(true);
field.set(target, true);
}
private static Path projectPath(String relative) {
Path direct = Path.of(relative);
return Files.exists(direct) ? direct : Path.of("ruoyi-modules/ruoyi-aihr").resolve(relative).normalize();
}
private static class StateJdbcTemplate extends JdbcTemplate {
private final long id = 41L;
private int insertCount;
int insertCount() {
return insertCount;
}
private Map<Integer, Object> inserted;
@Override
public int update(PreparedStatementCreator creator, KeyHolder keyHolder) {
try {
Map<Integer, Object> values = new HashMap<>();
Connection connection = mock(Connection.class);
PreparedStatement statement = mock(PreparedStatement.class);
when(connection.prepareStatement(anyString(), eq(Statement.RETURN_GENERATED_KEYS))).thenReturn(statement);
doAnswer(call -> values.put(call.getArgument(0), call.getArgument(1)))
.when(statement).setString(any(Integer.class), anyString());
doAnswer(call -> values.put(call.getArgument(0), call.getArgument(1)))
.when(statement).setLong(any(Integer.class), any(Long.class));
doAnswer(call -> values.put(call.getArgument(0), call.getArgument(1)))
.when(statement).setObject(any(Integer.class), any());
doAnswer(call -> values.put(call.getArgument(0), call.getArgument(1)))
.when(statement).setTimestamp(any(Integer.class), any(Timestamp.class));
creator.createPreparedStatement(connection);
inserted = values;
insertCount++;
keyHolder.getKeyList().add(Map.of("id", id));
return 1;
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (inserted == null) {
return List.of();
}
try {
ResultSet row = mock(ResultSet.class);
if (sql.contains("request_key = ?")) {
if (!inserted.get(10).equals(args[2])) {
return List.of();
}
when(row.getLong("id")).thenReturn(id);
when(row.getString("request_hash")).thenReturn((String) inserted.get(11));
} else {
when(row.getLong("id")).thenReturn(id);
when(row.getString("report_type")).thenReturn((String) inserted.get(2));
when(row.getString("title")).thenReturn((String) inserted.get(3));
when(row.getString("content")).thenReturn((String) inserted.get(4));
when(row.getObject("attachment_oss_id", Long.class)).thenReturn((Long) inserted.get(5));
when(row.getString("attachment_name")).thenReturn((String) inserted.get(6));
when(row.getString("status")).thenReturn("PENDING");
when(row.getString("submitter_name")).thenReturn("员工MP-7");
when(row.getString("review_note")).thenReturn(null);
when(row.getTimestamp("create_time")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 7, 19, 10, 0)));
when(row.getTimestamp("review_time")).thenReturn(null);
}
return List.of(rowMapper.mapRow(row, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
@SuppressWarnings("unchecked")
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (Integer.class.equals(requiredType) && sql.contains("FROM sys_oss")) {
return (T) Integer.valueOf(1);
}
return null;
}
}
private static final class RacingJdbcTemplate extends StateJdbcTemplate {
@Override
public int update(PreparedStatementCreator creator, KeyHolder keyHolder) {
super.update(creator, keyHolder);
throw new org.springframework.dao.DuplicateKeyException("simulated concurrent insert");
}
}
private static final class TenantCapturingJdbcTemplate extends JdbcTemplate {
private String sql = "";
private Object[] args = new Object[0];
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
this.sql = sql;
this.args = args.clone();
return List.of();
}
}
}
@@ -14,7 +14,7 @@ class AihrSensitiveTextTest {
@Test
void masksKnownPiiBeforeExternalModelUse() {
String raw = "请联系张三先生,电话13812345678,邮箱zhangsan@example.com,住3栋1201和2-302。";
String raw = "请联系张三先生,电话13812345678,邮箱zhangsan@example.com,住3栋1201、2-302和1203室。";
String masked = AihrSensitiveText.forModel(raw);
@@ -22,10 +22,12 @@ class AihrSensitiveTextTest {
assertFalse(masked.contains("zhangsan@example.com"));
assertFalse(masked.contains("3栋1201"));
assertFalse(masked.contains("2-302"));
assertFalse(masked.contains("1203室"));
assertTrue(masked.contains("1381****5678"));
assertTrue(masked.contains("[邮箱已脱敏]"));
assertTrue(masked.contains("3栋****"));
assertTrue(masked.contains("2-****"));
assertTrue(masked.contains("****室"));
assertTrue(masked.contains("张*先生"));
}
@@ -0,0 +1,109 @@
-- 工作上报正式提交幂等:首次部署可建表,已有表仅补请求键、载荷哈希和唯一索引。
-- 历史行保留 NULL;MySQL 唯一索引允许多行 NULL。
CREATE TABLE IF NOT EXISTS `aihr_work_report` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
`report_type` varchar(20) NOT NULL COMMENT '类型 CASE/VIDEO/SOP/KNOWLEDGE',
`title` varchar(100) NOT NULL COMMENT '标题',
`content` varchar(2000) NOT NULL COMMENT '说明',
`attachment_oss_id` bigint DEFAULT NULL COMMENT '附件OSS编号',
`attachment_name` varchar(200) DEFAULT NULL COMMENT '附件名',
`status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/APPROVED/REJECTED',
`submitter_user_id` bigint NOT NULL COMMENT '提交人用户ID',
`submitter_identity` varchar(100) DEFAULT NULL COMMENT '提交人身份',
`submitter_name` varchar(100) DEFAULT NULL COMMENT '提交人显示名',
`reviewer_user_id` bigint DEFAULT NULL COMMENT '审核人用户ID',
`reviewer_name` varchar(100) DEFAULT NULL COMMENT '审核人显示名',
`review_note` varchar(500) DEFAULT NULL COMMENT '审核意见',
`review_time` datetime DEFAULT NULL COMMENT '审核时间',
`request_key` varchar(100) NOT NULL COMMENT '提交幂等键',
`request_hash` char(64) NOT NULL COMMENT '提交载荷SHA-256',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_work_report_request` (`tenant_id`, `submitter_user_id`, `request_key`),
KEY `idx_aihr_work_report_submitter` (`tenant_id`, `submitter_user_id`, `id`),
KEY `idx_aihr_work_report_status` (`tenant_id`, `status`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='工作上报';
SET @has_work_report_request_key := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE table_schema = DATABASE() AND table_name = 'aihr_work_report' AND column_name = 'request_key'
);
SET @ddl_work_report_request_key := IF(
@has_work_report_request_key = 0,
'ALTER TABLE aihr_work_report ADD COLUMN request_key varchar(100) DEFAULT NULL COMMENT ''提交幂等键'' AFTER review_time',
'SELECT 1'
);
PREPARE stmt_work_report_request_key FROM @ddl_work_report_request_key;
EXECUTE stmt_work_report_request_key;
DEALLOCATE PREPARE stmt_work_report_request_key;
SET @has_work_report_request_hash := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE table_schema = DATABASE() AND table_name = 'aihr_work_report' AND column_name = 'request_hash'
);
SET @ddl_work_report_request_hash := IF(
@has_work_report_request_hash = 0,
'ALTER TABLE aihr_work_report ADD COLUMN request_hash char(64) DEFAULT NULL COMMENT ''提交载荷SHA-256'' AFTER request_key',
'SELECT 1'
);
PREPARE stmt_work_report_request_hash FROM @ddl_work_report_request_hash;
EXECUTE stmt_work_report_request_hash;
DEALLOCATE PREPARE stmt_work_report_request_hash;
SET @has_work_report_request_index := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE table_schema = DATABASE() AND table_name = 'aihr_work_report'
AND index_name = 'uk_aihr_work_report_request'
);
SET @work_report_request_index_valid := (
SELECT COUNT(*) FROM (
SELECT index_name
FROM information_schema.STATISTICS
WHERE table_schema = DATABASE() AND table_name = 'aihr_work_report'
AND index_name = 'uk_aihr_work_report_request'
GROUP BY index_name
HAVING MIN(non_unique) = 0
AND COUNT(*) = 3
AND SUM(sub_part IS NOT NULL) = 0
AND GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') =
'tenant_id,submitter_user_id,request_key'
) valid_work_report_request_index
);
SET @ddl_work_report_request_index := CASE
WHEN @work_report_request_index_valid > 0 THEN 'SELECT 1'
WHEN @has_work_report_request_index > 0 THEN
'ALTER TABLE aihr_work_report DROP INDEX uk_aihr_work_report_request, ADD UNIQUE KEY uk_aihr_work_report_request (tenant_id, submitter_user_id, request_key)'
ELSE
'ALTER TABLE aihr_work_report ADD UNIQUE KEY uk_aihr_work_report_request (tenant_id, submitter_user_id, request_key)'
END;
PREPARE stmt_work_report_request_index FROM @ddl_work_report_request_index;
EXECUTE stmt_work_report_request_index;
DEALLOCATE PREPARE stmt_work_report_request_index;
-- 与租户事实表保持同一排序规则,兼容旧库 general_ci 与新 MySQL 8 库 0900_ai_ci。
SET @work_report_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 @work_report_target_collation := COALESCE(@work_report_target_collation, @@collation_database);
SET @work_report_current_collation := (
SELECT table_collation FROM information_schema.TABLES
WHERE table_schema = DATABASE() AND table_name = 'aihr_work_report'
LIMIT 1
);
SET @ddl_work_report_collation := IF(
@work_report_current_collation = @work_report_target_collation,
'SELECT 1',
CONCAT('ALTER TABLE aihr_work_report CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @work_report_target_collation)
);
PREPARE stmt_work_report_collation FROM @ddl_work_report_collation;
EXECUTE stmt_work_report_collation;
DEALLOCATE PREPARE stmt_work_report_collation;
SET @work_report_target_collation := NULL;
SET @work_report_current_collation := NULL;
SET @ddl_work_report_collation := NULL;