feat(work-report): add voice-first reporting flow
This commit is contained in:
+25
@@ -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()) {
|
||||
|
||||
+20
-1
@@ -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,
|
||||
|
||||
+439
-25
@@ -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()) {
|
||||
|
||||
+5
-1
@@ -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");
|
||||
}
|
||||
|
||||
/** 已知的结构化姓名字段不应仅依赖称谓正则,直接用匿名角色替换。 */
|
||||
|
||||
+33
@@ -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");
|
||||
}
|
||||
}
|
||||
+510
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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;
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
上述事实只证明本批版本和 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_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_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_position_responsibility', 'aihr_sop_applicability',
|
||||
'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;
|
||||
|
||||
@@ -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_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_work_report' AND column_name IN ('request_key', 'request_hash'))
|
||||
)
|
||||
ORDER BY table_name, column_name;
|
||||
|
||||
@@ -122,7 +125,7 @@ GROUP 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`。
|
||||
3. 用正式测试账号完成员工端训练、主管复盘和管理端场景/Prompt 只读检查;不要使用开发固定验证码验证生产安全策略。
|
||||
4. 用具备单一项目授权的正式测试账号,在“工作助手”说出一条包含房号、住户需求和跟进时间的测试信息;确认候选卡前数据库不得出现正式服务记录,确认后只能写入该账号已授权的 `project_code`。随后新开对话查询该事项,必须能召回;双击确认不得重复写入。测试结束保留审计证据,不直接删除生产业务记录。
|
||||
5. 迁移完成后再按同一正式时间窗运行:
|
||||
5. 用员工测试账号进入“工作上报”,确认首屏为语音会话而非传统表单;完成一次整理、建议类型修改和显式确认。确认前 `aihr_work_report` 行数不变,重复提交同一 `request_key` 只有一条 `PENDING` 记录,“我的上报”返回同一 ID;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。
|
||||
6. 迁移完成后再按同一正式时间窗运行:
|
||||
|
||||
```bash
|
||||
AIHR_PILOT_TENANT_ID=000000 \
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,35 @@ export interface WorkReportAttachment {
|
||||
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 }> = [
|
||||
{ value: 'CASE', 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: {
|
||||
requestId: string;
|
||||
type: WorkReportType;
|
||||
title: string;
|
||||
content: string;
|
||||
@@ -44,6 +74,19 @@ export const submitWorkReport = (params: {
|
||||
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 = () =>
|
||||
apiRequest<WorkReportRecord[]>({
|
||||
url: '/api/aihr/work-report/reports/mine',
|
||||
@@ -66,9 +109,12 @@ export const reviewWorkReport = (id: number, decision: 'APPROVE' | 'REJECT', not
|
||||
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) => {
|
||||
uni.uploadFile({
|
||||
const task = uni.uploadFile({
|
||||
url: apiUrl('/api/aihr/work-report/attachment'),
|
||||
filePath,
|
||||
name: 'file',
|
||||
@@ -80,6 +126,8 @@ export const uploadWorkReportAttachment = (filePath: string) =>
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('附件上传失败'))
|
||||
fail: () => reject(new Error('附件上传失败')),
|
||||
complete: () => onUploadTask?.(null)
|
||||
});
|
||||
onUploadTask?.(task);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -156,6 +156,7 @@ FROM (
|
||||
UNION ALL SELECT 'aihr_memory_candidate'
|
||||
UNION ALL SELECT 'aihr_service_memory'
|
||||
UNION ALL SELECT 'aihr_service_memory_version'
|
||||
UNION ALL SELECT 'aihr_work_report'
|
||||
) required
|
||||
LEFT JOIN information_schema.tables actual
|
||||
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"
|
||||
|
||||
[[ -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)"
|
||||
|
||||
@@ -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_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/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 wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
|
||||
|
||||
@@ -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')));")"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user