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 lombok.RequiredArgsConstructor;
import org.dromara.aihr.report.AihrWorkReportDto.AttachmentResponse; import org.dromara.aihr.report.AihrWorkReportDto.AttachmentResponse;
import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest; 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.ReportResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest; import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
import org.dromara.common.core.domain.R; 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.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService; import org.dromara.system.service.ISysOssService;
import org.springframework.http.MediaType; 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.RequestPart;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.redisson.api.RateType;
import java.util.List; import java.util.List;
@@ -41,6 +47,25 @@ public class AihrWorkReportController {
return R.ok(reportService.create(request)); 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) @PostMapping(value = "/attachment", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<AttachmentResponse> uploadAttachment(@RequestPart("file") MultipartFile file) { public R<AttachmentResponse> uploadAttachment(@RequestPart("file") MultipartFile file) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
@@ -11,7 +11,26 @@ public final class AihrWorkReportDto {
private 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 title,
String content, String content,
Long attachmentOssId, Long attachmentOssId,
@@ -1,23 +1,46 @@
package org.dromara.aihr.report; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.community.AihrCommunityPolicy; import org.dromara.aihr.community.AihrCommunityPolicy;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest; 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.ReportResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest; 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.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.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service; 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.sql.Timestamp;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern;
/** /**
* 工作上报:员工提交 → 主管/运营审核(通过/驳回)。 * 工作上报:员工提交 → 主管/运营审核(通过/驳回)。
@@ -41,50 +64,412 @@ public class AihrWorkReportService {
"REJECTED", "已驳回" "REJECTED", "已驳回"
); );
private static final Set<String> REVIEW_DECISIONS = Set.of("APPROVE", "REJECT"); 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_TITLE = 100;
private static final int MAX_CONTENT = 2000; 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 JdbcTemplate jdbcTemplate;
private final AihrKnowledgePrincipalResolver principalResolver; private final AihrKnowledgePrincipalResolver principalResolver;
private final AihrModelSeedService modelService;
private final ObjectMapper objectMapper;
private final ISysOssService ossService;
private volatile boolean tableReady; 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) { public ReportResponse create(CreateReportRequest request) {
ensureTable(); if (request == null) {
AihrKnowledgePrincipal principal = principalResolver.current(); throw badRequest("上报内容不能为空");
String type = clean(request == null ? null : request.type()).toUpperCase(); }
String type = clean(request.type()).toUpperCase(Locale.ROOT);
if (!TYPE_LABELS.containsKey(type)) { if (!TYPE_LABELS.containsKey(type)) {
throw new ServiceException("上报类型无效,仅支持:优秀案例/操作视频/完整SOP/有用知识", 400); throw badRequest("上报类型无效,仅支持:优秀案例/操作视频/完整SOP/有用知识");
} }
String title = clean(request.title()); String title = clean(request.title());
if (title.isBlank()) { if (title.isBlank()) {
throw new ServiceException("请填写上报标题", 400); throw badRequest("请填写上报标题");
} }
requireMax(title, MAX_TITLE, "上报标题不能超过 100 字");
String content = clean(request.content()); String content = clean(request.content());
if (content.isBlank()) { 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(); LocalDateTime now = LocalDateTime.now();
jdbcTemplate.update(""" try {
INSERT INTO aihr_work_report long id = insert(type, title, content, attachment, principal, requestKey, requestHash, now);
(tenant_id, report_type, title, content, attachment_oss_id, attachment_name, return byId(id, principal.tenantId());
status, submitter_user_id, submitter_identity, submitter_name, create_time, update_time) } catch (DuplicateKeyException duplicate) {
VALUES (?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?, ?) IdempotentRecord raced = findIdempotent(principal, requestKey);
""", if (raced != null) {
principal.tenantId(), return replayOrConflict(raced, requestHash, principal.tenantId());
type, }
truncate(title, MAX_TITLE), throw duplicate;
truncate(content, MAX_CONTENT), }
request.attachmentOssId(), }
truncate(clean(request.attachmentName()), 200),
principal.userId(), private Optional<OrganizeResponse> parseOrganize(String raw, List<String> warnings,
clean(principal.extPartyId()), String rawTitle, String rawContent) {
displayName(principal), try {
Timestamp.valueOf(now), JsonNode root = objectMapper.reader()
Timestamp.valueOf(now) .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() { public List<ReportResponse> myReports() {
@@ -215,17 +600,46 @@ public class AihrWorkReportService {
`reviewer_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核人显示名', `reviewer_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核人显示名',
`review_note` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核意见', `review_note` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核意见',
`review_time` datetime 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 '创建时间', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`), 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_submitter` (`tenant_id`, `submitter_user_id`, `id`),
KEY `idx_aihr_work_report_status` (`tenant_id`, `status`, `id`) KEY `idx_aihr_work_report_status` (`tenant_id`, `status`, `id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '工作上报' ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '工作上报'
"""); """);
requireIdempotencySchema();
tableReady = true; 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) { private static String displayName(AihrKnowledgePrincipal principal) {
String identity = clean(principal.extPartyId()); String identity = clean(principal.extPartyId());
if (identity.isBlank()) { 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 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 = 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_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 HONORIFIC_NAME = Pattern.compile("([\\u4e00-\\u9fa5])([\\u4e00-\\u9fa5])(先生|女士|经理)");
private static final Pattern LEADING_HONORIFIC_NAME = Pattern.compile("^([\\u4e00-\\u9fa5])(先生|女士|经理)");
private AihrSensitiveText() { private AihrSensitiveText() {
} }
@@ -28,7 +30,9 @@ public final class AihrSensitiveText {
masked = EMAIL.matcher(masked).replaceAll("[邮箱已脱敏]"); masked = EMAIL.matcher(masked).replaceAll("[邮箱已脱敏]");
masked = ROOM.matcher(masked).replaceAll("$1$2****"); masked = ROOM.matcher(masked).replaceAll("$1$2****");
masked = ROOM_SHORT.matcher(masked).replaceAll("$1-****"); 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 @Test
void masksKnownPiiBeforeExternalModelUse() { 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); String masked = AihrSensitiveText.forModel(raw);
@@ -22,10 +22,12 @@ class AihrSensitiveTextTest {
assertFalse(masked.contains("zhangsan@example.com")); assertFalse(masked.contains("zhangsan@example.com"));
assertFalse(masked.contains("3栋1201")); assertFalse(masked.contains("3栋1201"));
assertFalse(masked.contains("2-302")); assertFalse(masked.contains("2-302"));
assertFalse(masked.contains("1203室"));
assertTrue(masked.contains("1381****5678")); assertTrue(masked.contains("1381****5678"));
assertTrue(masked.contains("[邮箱已脱敏]")); assertTrue(masked.contains("[邮箱已脱敏]"));
assertTrue(masked.contains("3栋****")); assertTrue(masked.contains("3栋****"));
assertTrue(masked.contains("2-****")); assertTrue(masked.contains("2-****"));
assertTrue(masked.contains("****室"));
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;
+9 -5
View File
@@ -14,7 +14,7 @@
上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。 上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。
2026-07-19 增量在上述基线上新增“项目服务记忆最小切片”:候选信息只有在当前员工明确确认后,才会写入其已授权项目的服务记录;本批不包含个人想法/经验的长期知识库、跨项目共享、自动提醒或向量化个人资料。迁移后结构门禁由 `35/35` 增至 `38/38`。 2026-07-19 增量在上述基线上新增“项目服务记忆最小切片”:候选信息只有在当前员工明确确认后,才会写入其已授权项目的服务记录;本批不包含个人想法/经验的长期知识库、跨项目共享、自动提醒或向量化个人资料。同日的“工作上报语音会话化”把既有 `aihr_work_report` 纳入结构门禁,并补充 `request_key/request_hash` 和租户内提交人唯一索引,用于确认上报的幂等重试,不新建草稿或会话表。迁移后发布门禁统一为 `39/39`。
## 发布前置条件 ## 发布前置条件
@@ -52,9 +52,10 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql
``` ```
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移最后创建候选、正式记录和版本留痕三张表,同样对齐该规则,兼容历史生产库与全新 MySQL 8 数据库。 顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、正式记录和版本留痕三张表;工作上报幂等迁移随后只补原表列与唯一索引。两者同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
## 迁移后只读核验 ## 迁移后只读核验
@@ -79,7 +80,8 @@ WHERE table_schema = DATABASE()
'aihr_candidate_material', 'aihr_interview_result', 'aihr_candidate_employee_link', 'aihr_candidate_material', 'aihr_interview_result', 'aihr_candidate_employee_link',
'aihr_position_responsibility', 'aihr_sop_applicability', 'aihr_position_responsibility', 'aihr_sop_applicability',
'aihr_onboard_task', 'aihr_qualification_gate', 'aihr_onboard_task', 'aihr_qualification_gate',
'aihr_memory_candidate', 'aihr_service_memory', 'aihr_service_memory_version' 'aihr_memory_candidate', 'aihr_service_memory', 'aihr_service_memory_version',
'aihr_work_report'
) )
ORDER BY table_name; ORDER BY table_name;
@@ -101,6 +103,7 @@ WHERE table_schema = DATABASE()
OR (table_name = 'aihr_sop_applicability' AND column_name IN ('sop_id', 'sop_code', 'project_type', 'industry', 'org_scope')) OR (table_name = 'aihr_sop_applicability' AND column_name IN ('sop_id', 'sop_code', 'project_type', 'industry', 'org_scope'))
OR (table_name = 'aihr_onboard_task' AND column_name IN ('ext_party_id', 'position_code', 'task_type', 'status', 'assign_by')) OR (table_name = 'aihr_onboard_task' AND column_name IN ('ext_party_id', 'position_code', 'task_type', 'status', 'assign_by'))
OR (table_name = 'aihr_qualification_gate' AND column_name IN ('ext_party_id', 'position_code', 'cert_id', 'passed', 'valid_thru')) OR (table_name = 'aihr_qualification_gate' AND column_name IN ('ext_party_id', 'position_code', 'cert_id', 'passed', 'valid_thru'))
OR (table_name = 'aihr_work_report' AND column_name IN ('request_key', 'request_hash'))
) )
ORDER BY table_name, column_name; ORDER BY table_name, column_name;
@@ -122,7 +125,7 @@ GROUP BY question_type
ORDER BY question_type; ORDER BY question_type;
``` ```
预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、两张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及三张项目服务记忆表均存在;会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新服务记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。 预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、两张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及三张项目服务记忆表和原工作上报表均存在;工作上报幂等字段为 `2/2`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新服务记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。
## 迁移后应用回归 ## 迁移后应用回归
@@ -130,7 +133,8 @@ ORDER BY question_type;
2. 检查根站、`/h5/`、租户接口和员工首页 API 的 HTTP 与业务码均为 `200`。 2. 检查根站、`/h5/`、租户接口和员工首页 API 的 HTTP 与业务码均为 `200`。
3. 用正式测试账号完成员工端训练、主管复盘和管理端场景/Prompt 只读检查;不要使用开发固定验证码验证生产安全策略。 3. 用正式测试账号完成员工端训练、主管复盘和管理端场景/Prompt 只读检查;不要使用开发固定验证码验证生产安全策略。
4. 用具备单一项目授权的正式测试账号,在“工作助手”说出一条包含房号、住户需求和跟进时间的测试信息;确认候选卡前数据库不得出现正式服务记录,确认后只能写入该账号已授权的 `project_code`。随后新开对话查询该事项,必须能召回;双击确认不得重复写入。测试结束保留审计证据,不直接删除生产业务记录。 4. 用具备单一项目授权的正式测试账号,在“工作助手”说出一条包含房号、住户需求和跟进时间的测试信息;确认候选卡前数据库不得出现正式服务记录,确认后只能写入该账号已授权的 `project_code`。随后新开对话查询该事项,必须能召回;双击确认不得重复写入。测试结束保留审计证据,不直接删除生产业务记录。
5. 迁移完成后再按同一正式时间窗运行: 5. 用员工测试账号进入“工作上报”,确认首屏为语音会话而非传统表单;完成一次整理、建议类型修改和显式确认。确认前 `aihr_work_report` 行数不变,重复提交同一 `request_key` 只有一条 `PENDING` 记录,“我的上报”返回同一 ID;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。
6. 迁移完成后再按同一正式时间窗运行:
```bash ```bash
AIHR_PILOT_TENANT_ID=000000 \ AIHR_PILOT_TENANT_ID=000000 \
File diff suppressed because it is too large Load Diff
+51 -3
View File
@@ -23,6 +23,35 @@ export interface WorkReportAttachment {
fileName: string; fileName: string;
} }
export interface WorkReportDraft {
suggestedType: WorkReportType;
title: string;
content: string;
}
export interface OrganizeResponse extends WorkReportDraft {
missingQuestions: string[];
privacyWarnings: string[];
confidence: number;
notice?: string;
}
export const stableWorkReportRequestId = (current: string, create: () => string) => current || create();
export const isDefinitiveWorkReportRejection = (error: { statusCode?: number; bizCode?: number }) => {
const status = Number(error.statusCode || 0);
const bizCode = Number(error.bizCode || 0);
const isDefinitive = (code: number) => code >= 400 && code < 500 && ![408, 409, 425, 429].includes(code);
return isDefinitive(status) || isDefinitive(bizCode);
};
export const isCurrentWorkReportRequest = (
generation: number,
currentGeneration: number,
accountKey: string,
currentAccountKey: string
) => generation === currentGeneration && accountKey === currentAccountKey;
export const workReportTypes: Array<{ value: WorkReportType; label: string; desc: string; icon: string }> = [ export const workReportTypes: Array<{ value: WorkReportType; label: string; desc: string; icon: string }> = [
{ value: 'CASE', label: '优秀案例', desc: '现场处理得好的真实案例', icon: '⭐' }, { value: 'CASE', label: '优秀案例', desc: '现场处理得好的真实案例', icon: '⭐' },
{ value: 'VIDEO', label: '操作视频', desc: '值得推广的操作演示视频', icon: '🎬' }, { value: 'VIDEO', label: '操作视频', desc: '值得推广的操作演示视频', icon: '🎬' },
@@ -31,6 +60,7 @@ export const workReportTypes: Array<{ value: WorkReportType; label: string; desc
]; ];
export const submitWorkReport = (params: { export const submitWorkReport = (params: {
requestId: string;
type: WorkReportType; type: WorkReportType;
title: string; title: string;
content: string; content: string;
@@ -44,6 +74,19 @@ export const submitWorkReport = (params: {
timeout: 20000 timeout: 20000
}); });
export const organizeWorkReport = (params: {
transcript: string;
currentDraft?: WorkReportDraft;
attachmentOssId?: number | string;
attachmentName?: string;
}) =>
apiRequest<OrganizeResponse>({
url: '/api/aihr/work-report/organize',
method: 'POST',
data: params,
timeout: 45000
});
export const listMyWorkReports = () => export const listMyWorkReports = () =>
apiRequest<WorkReportRecord[]>({ apiRequest<WorkReportRecord[]>({
url: '/api/aihr/work-report/reports/mine', url: '/api/aihr/work-report/reports/mine',
@@ -66,9 +109,12 @@ export const reviewWorkReport = (id: number, decision: 'APPROVE' | 'REJECT', not
timeout: 20000 timeout: 20000
}); });
export const uploadWorkReportAttachment = (filePath: string) => export const uploadWorkReportAttachment = (
filePath: string,
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void
) =>
new Promise<WorkReportAttachment>((resolve, reject) => { new Promise<WorkReportAttachment>((resolve, reject) => {
uni.uploadFile({ const task = uni.uploadFile({
url: apiUrl('/api/aihr/work-report/attachment'), url: apiUrl('/api/aihr/work-report/attachment'),
filePath, filePath,
name: 'file', name: 'file',
@@ -80,6 +126,8 @@ export const uploadWorkReportAttachment = (filePath: string) =>
reject(error); reject(error);
} }
}, },
fail: () => reject(new Error('附件上传失败')) fail: () => reject(new Error('附件上传失败')),
complete: () => onUploadTask?.(null)
}); });
onUploadTask?.(task);
}); });
+173
View File
@@ -0,0 +1,173 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('工作上报首屏是语音优先会话,不再暴露传统表单主路径', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /工作上报/);
assert.match(page, /数字师傅帮你整理/);
assert.match(page, /我的上报/);
assert.match(page, /今天处理了什么?按住说,也可以发照片或视频。/);
assert.match(page, /<ChatComposer/);
assert.doesNotMatch(page, /选择上报类型/);
assert.doesNotMatch(page, /class="type-grid"/);
assert.doesNotMatch(page, /placeholder="一句话说清上报内容"/);
});
test('工作上报为 ChatComposer 提供自己的照片视频和音频面板', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /:panel-items="composerPanelItems"/);
assert.match(page, /label: '照片\/视频'/);
assert.match(page, /label: '选择音频'/);
assert.match(page, /@panel="handlePanelAction"/);
assert.doesNotMatch(page, /仅用于本次现场分析/);
});
test('语音转写、文字和附件补充都进入无状态整理', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /语音转文字/);
assert.match(page, /transcribeSpeechBlob/);
assert.match(page, /transcribeSpeechFile/);
assert.match(page, /organizeWorkReport\(/);
assert.match(page, /const previousDraft = draftPayload\(\)/);
assert.match(page, /currentDraft:\s*previousDraft/);
assert.match(page, /attachmentOssId:\s*attachment\.value\?\.ossId/);
assert.match(page, /void organizeInput\(text, \{ voice: false \}\)/);
assert.match(page, /void addMediaAttachment\(\)/);
});
test('短追问最多两条,文字直接可见并只提供播放', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /missingQuestions\.slice\(0, 2\)/);
assert.match(page, /class="question-text"/);
assert.match(page, /toggleQuestionSpeech/);
assert.match(page, /aria-label="播放追问"/);
assert.doesNotMatch(page, />转文字<\/button>/);
assert.doesNotMatch(page, /收起文字/);
});
test('确认卡允许修改最终类型标题正文且不承诺自动脱敏', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /建议类型/);
assert.match(page, /changeDraftType/);
assert.match(page, /v-model="currentDraft\.title"/);
assert.match(page, /v-model="currentDraft\.content"/);
assert.match(page, /检测到具体房号,请修改后再提交/);
assert.match(page, /const hasRoomPrivacyWarning = computed/);
assert.match(page, /请处理上述敏感信息后再提交/);
assert.match(page, /const finishDraftEditing = async/);
assert.match(page, /manuallyConfirmedType\.value\s*\|\|/);
assert.match(page, /manuallyConfirmedType\.value = selected\.value/);
assert.match(page, /requestId:\s*submitRequestId\.value[\s\S]*type:\s*draft\.suggestedType/);
assert.match(page, /transcript:\s*''/);
assert.match(page, /if \(privacyReviewRequired\.value\)/);
assert.doesNotMatch(page, /privacyReviewed/);
assert.match(page, /editingDraft \? '完成修改' : '修改'/);
assert.match(page, /@click="confirmSubmit"/);
assert.match(page, /'确认上报'/);
assert.doesNotMatch(page, /自动隐藏/);
assert.doesNotMatch(page, />继续补充<\/button>/);
});
test('正式提交只发生在确认动作并复用稳定幂等键', async () => {
const page = await source('../src/pages/user/report/index.vue');
const submitCalls = page.match(/submitWorkReport\(/g) || [];
assert.equal(submitCalls.length, 1);
assert.match(page, /const confirmSubmit = async \(\) =>/);
assert.match(page, /if \(submitActionDisabled\.value \|\| editingDraft\.value\)/);
assert.match(page, /stableWorkReportRequestId\(submitRequestId\.value, createRequestId\)/);
assert.match(page, /requestId:\s*submitRequestId\.value/);
assert.match(page, /const draftLocked = computed/);
assert.match(page, /\|\| Boolean\(submitRequestId\.value\)/);
assert.match(page, /isDefinitiveWorkReportRejection/);
assert.match(page, /if \(rejected\) submitRequestId\.value = ''/);
assert.match(page, /const record = await submitWorkReport/);
assert.match(page, /submittedReport\.value = record/);
assert.match(page, /await loadMine\(true\)/);
assert.match(page, /PENDING/);
assert.match(page, /待审核/);
assert.match(page, /submittedReport\.status\.toLowerCase\(\)/);
assert.match(page, /submittedReport\.statusLabel/);
assert.match(page, /@click="startNewReport">再报一条/);
});
test('异步请求具备 generation、上传取消和离页 teardown', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /let organizeGeneration = 0/);
assert.match(page, /isCurrentWorkReportRequest/);
assert.match(page, /currentAccountKey\(\)/);
assert.match(page, /visibleAccountKey/);
assert.match(page, /mediaSelecting\.value/);
assert.match(page, /abortVoiceTranscription\?\.\(\)/);
assert.match(page, /activeUploadTask\?\.abort\(\)/);
assert.match(page, /!previousDraft \|\| submitActionDisabled\.value \|\| draftLocked\.value/);
assert.match(page, /:disabled="draftLocked \|\| submitActionDisabled"/);
assert.match(page, /composerRef\.value\?\.teardown\(\)/);
assert.match(page, /speechPlayback\.stop\(\)/);
assert.match(page, /onHide\(teardown\)/);
assert.match(page, /onUnload/);
});
test('AI 长草稿不被前端静默截断,改由确认时明确校验', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.doesNotMatch(page, /String\(result\.content[\s\S]*?\.slice\(0, 2000\)/);
assert.match(page, /content\.length > 2000/);
assert.match(page, /上报内容不能超过2000字/);
});
test('工作上报服务声明整理契约并在正式提交携带 requestId', async () => {
const service = await source('../src/services/work-report.ts');
assert.match(service, /export interface WorkReportDraft/);
assert.match(service, /suggestedType:\s*WorkReportType/);
assert.match(service, /export interface OrganizeResponse extends WorkReportDraft/);
assert.match(service, /missingQuestions:\s*string\[\]/);
assert.match(service, /privacyWarnings:\s*string\[\]/);
assert.match(service, /export const organizeWorkReport/);
assert.match(service, /\/api\/aihr\/work-report\/organize/);
assert.match(service, /requestId:\s*string/);
assert.match(service, /data:\s*params/);
});
test('幂等键与异步账号守卫是可执行行为,不只是源码字符串', async (context) => {
const { createServer } = await import('vite');
const root = new URL('../', import.meta.url).pathname;
const server = await createServer({
root,
configFile: false,
appType: 'custom',
logLevel: 'silent',
resolve: { alias: { '@': new URL('../src', import.meta.url).pathname } },
server: { middlewareMode: true }
});
context.after(() => server.close());
const state = await server.ssrLoadModule('/src/services/work-report.ts');
let created = 0;
const create = () => `request-${++created}`;
const first = state.stableWorkReportRequestId('', create);
const retry = state.stableWorkReportRequestId(first, create);
assert.equal(first, retry);
assert.equal(created, 1);
assert.equal(state.isDefinitiveWorkReportRejection({ statusCode: 400 }), true);
assert.equal(state.isDefinitiveWorkReportRejection({ bizCode: 409 }), false);
assert.equal(state.isDefinitiveWorkReportRejection({ statusCode: 408 }), false);
assert.equal(state.isDefinitiveWorkReportRejection({ statusCode: 429 }), false);
assert.equal(state.isDefinitiveWorkReportRejection({ statusCode: 500 }), false);
assert.equal(state.isDefinitiveWorkReportRejection({}), false);
assert.equal(state.isCurrentWorkReportRequest(3, 3, 'account-a', 'account-a'), true);
assert.equal(state.isCurrentWorkReportRequest(2, 3, 'account-a', 'account-a'), false);
assert.equal(state.isCurrentWorkReportRequest(3, 3, 'account-a', 'account-b'), false);
});
+45 -1
View File
@@ -156,6 +156,7 @@ FROM (
UNION ALL SELECT 'aihr_memory_candidate' UNION ALL SELECT 'aihr_memory_candidate'
UNION ALL SELECT 'aihr_service_memory' UNION ALL SELECT 'aihr_service_memory'
UNION ALL SELECT 'aihr_service_memory_version' UNION ALL SELECT 'aihr_service_memory_version'
UNION ALL SELECT 'aihr_work_report'
) required ) required
LEFT JOIN information_schema.tables actual LEFT JOIN information_schema.tables actual
ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name
@@ -166,7 +167,50 @@ REMOTE
)" || fail "remote schema check failed: $remote_ssh:$remote_db" )" || fail "remote schema check failed: $remote_ssh:$remote_db"
[[ -z "$missing" ]] || fail "remote schema missing required tables: $(printf '%s' "$missing" | tr '\n' ', ' | sed 's/, $//')" [[ -z "$missing" ]] || fail "remote schema missing required tables: $(printf '%s' "$missing" | tr '\n' ', ' | sed 's/, $//')"
echo "remote_schema=38/38 $remote_ssh:$remote_db" local missing_work_report_columns
missing_work_report_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT required.column_name
FROM (
SELECT 'request_key' AS column_name
UNION ALL SELECT 'request_hash'
) required
LEFT JOIN information_schema.columns actual
ON actual.table_schema = DATABASE()
AND actual.table_name = 'aihr_work_report'
AND actual.column_name = required.column_name
WHERE actual.column_name IS NULL
ORDER BY required.column_name;
SQL
REMOTE
)" || fail "remote work-report schema check failed: $remote_ssh:$remote_db"
[[ -z "$missing_work_report_columns" ]] \
|| fail "remote work-report schema missing required columns: $(printf '%s' "$missing_work_report_columns" | tr '\n' ', ' | sed 's/, $//')"
local work_report_unique_index
work_report_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT CONCAT(
MIN(non_unique), '|',
COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|',
SUM(sub_part IS NOT NULL)
)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'aihr_work_report'
AND index_name = 'uk_aihr_work_report_request';
SQL
REMOTE
)" || fail "remote work-report unique-index check failed: $remote_ssh:$remote_db"
[[ "$work_report_unique_index" = "0|tenant_id,submitter_user_id,request_key|0" ]] \
|| fail "remote work-report unique index invalid: expected unique full columns tenant_id,submitter_user_id,request_key; got ${work_report_unique_index:-missing}"
echo "remote_schema=39/39 $remote_ssh:$remote_db"
echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db"
} }
changed_files="$(git status --porcelain)" changed_files="$(git status --porcelain)"
+1
View File
@@ -24,6 +24,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_service_memory_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_service_memory_mysql8.sql"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \ docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
+33 -1
View File
@@ -194,4 +194,36 @@ test "$service_memory_tables" = 'aihr_memory_candidate,aihr_service_memory,aihr_
service_memory_version_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND ((table_name = 'aihr_memory_candidate' AND column_name IN ('version','confirm_idempotency_key','confirm_request_hash','source_conversation_id','source_request_id')) OR (table_name = 'aihr_service_memory' AND column_name IN ('project_code','version')) OR (table_name = 'aihr_service_memory_version' AND column_name IN ('memory_id','version')));")" service_memory_version_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND ((table_name = 'aihr_memory_candidate' AND column_name IN ('version','confirm_idempotency_key','confirm_request_hash','source_conversation_id','source_request_id')) OR (table_name = 'aihr_service_memory' AND column_name IN ('project_code','version')) OR (table_name = 'aihr_service_memory_version' AND column_name IN ('memory_id','version')));")"
test "$service_memory_version_columns" = '9' test "$service_memory_version_columns" = '9'
echo "PASS: AIHR legacy schema migrations are idempotent, including practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, and the project service memory slice" work_report_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql"
test -f "$work_report_migration"
mysql "$DB" < "$work_report_migration"
mysql "$DB" < "$work_report_migration"
work_report_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_work_report' AND column_name IN ('request_key','request_hash');")"
test "$work_report_columns" = 'request_key,request_hash'
work_report_unique_index="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_work_report' AND index_name='uk_aihr_work_report_request';")"
test "$work_report_unique_index" = 'tenant_id,submitter_user_id,request_key'
mysql "$DB" -e "ALTER TABLE aihr_work_report DROP INDEX uk_aihr_work_report_request, ADD INDEX uk_aihr_work_report_request (tenant_id, request_key(20), submitter_user_id);"
mysql "$DB" < "$work_report_migration"
work_report_repaired_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_work_report' AND index_name='uk_aihr_work_report_request';")"
test "$work_report_repaired_index" = '0|tenant_id,submitter_user_id,request_key|0'
mysql "$DB" <<'SQL'
ALTER TABLE aihr_work_report
DROP INDEX uk_aihr_work_report_request,
ADD INDEX uk_aihr_work_report_request (tenant_id, request_key(20), submitter_user_id);
INSERT INTO aihr_work_report (
tenant_id, report_type, title, content, status,
submitter_user_id, request_key, request_hash
) VALUES
('000000', 'CASE', 'duplicate migration guard 1', 'content', 'PENDING', 1, 'duplicate-migration-guard', REPEAT('a', 64)),
('000000', 'CASE', 'duplicate migration guard 2', 'content', 'PENDING', 1, 'duplicate-migration-guard', REPEAT('b', 64));
SQL
if mysql "$DB" < "$work_report_migration" >/dev/null 2>&1; then
echo "FAIL: work-report migration must reject duplicate idempotency keys" >&2
exit 1
fi
work_report_failed_repair_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_work_report' AND index_name='uk_aihr_work_report_request';")"
test "$work_report_failed_repair_index" = '1|tenant_id,request_key,submitter_user_id|1'
echo "PASS: AIHR legacy schema migrations are idempotent, including practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, project service memory, and work-report idempotency"