From 925b48e4367e86941e1f6999404d8bb7dfe50f1d Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 19 Jul 2026 12:17:11 +0800 Subject: [PATCH] feat(work-report): add voice-first reporting flow --- .../aihr/report/AihrWorkReportController.java | 25 + .../aihr/report/AihrWorkReportDto.java | 21 +- .../aihr/report/AihrWorkReportService.java | 464 ++++- .../aihr/service/AihrSensitiveText.java | 6 +- .../AihrWorkReportAccessContractTest.java | 33 + .../report/AihrWorkReportServiceTest.java | 510 +++++ .../aihr/service/AihrSensitiveTextTest.java | 4 +- ...0260719_work_report_idempotency_mysql8.sql | 109 + docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 14 +- mobile-uni/src/pages/user/report/index.vue | 1770 +++++++++++++---- mobile-uni/src/services/work-report.ts | 54 +- mobile-uni/tests/work-report.test.mjs | 173 ++ scripts/release-preflight.sh | 46 +- scripts/reset-dev-db.sh | 1 + scripts/tests/aihr-schema-migrations.test.sh | 34 +- 15 files changed, 2813 insertions(+), 451 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/report/AihrWorkReportAccessContractTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/report/AihrWorkReportServiceTest.java create mode 100644 backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql create mode 100644 mobile-uni/tests/work-report.test.mjs diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportController.java index e8df2537..719bb32a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportController.java @@ -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 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 uploadAttachment(@RequestPart("file") MultipartFile file) { if (file == null || file.isEmpty()) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportDto.java index 0dd1db9e..f8cfffb4 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportDto.java @@ -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 missingQuestions, + List privacyWarnings, + double confidence, + String notice) { + } + + public record CreateReportRequest(String requestId, + String type, String title, String content, Long attachmentOssId, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportService.java index 440c7e13..78306951 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/AihrWorkReportService.java @@ -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 REVIEW_DECISIONS = Set.of("APPROVE", "REJECT"); + private static final Set VIDEO_SUFFIXES = Set.of("mp4", "mov", "avi", "mkv", "webm", "m4v"); + private static final Set 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("(? 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 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 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 parseOrganize(String raw, List 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 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 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 warnings) { + return localOrganize(draftType, rawTitle, rawContent, warnings, 0, + "AI 整理暂不可用,已保留当前草稿和本次原话,请核对并补充后再上报。"); + } + + private OrganizeResponse localOrganize(String draftType, String rawTitle, String rawContent, + List warnings, double confidence, String notice) { + List 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 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 privacyWarnings(String text) { + List 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 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 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()) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java index 61c483f9..dcb44356 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java @@ -15,7 +15,9 @@ public final class AihrSensitiveText { private static final Pattern EMAIL = Pattern.compile("(?i)(? 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 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 inserted; + + @Override + public int update(PreparedStatementCreator creator, KeyHolder keyHolder) { + try { + Map 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 List query(String sql, RowMapper 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 queryForObject(String sql, Class 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 List query(String sql, RowMapper rowMapper, Object... args) { + this.sql = sql; + this.args = args.clone(); + return List.of(); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java index e1530344..cfdbaacc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java @@ -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("张*先生")); } diff --git a/backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql b/backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql new file mode 100644 index 00000000..b568adf0 --- /dev/null +++ b/backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql @@ -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; diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 7cff3b90..f716ac64 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -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 \ diff --git a/mobile-uni/src/pages/user/report/index.vue b/mobile-uni/src/pages/user/report/index.vue index 613ec304..4d40ce6b 100644 --- a/mobile-uni/src/pages/user/report/index.vue +++ b/mobile-uni/src/pages/user/report/index.vue @@ -1,527 +1,1471 @@