From 342f84a9de0a92f170183e84718262ef8bfd2bd6 Mon Sep 17 00:00:00 2001 From: let5sne Date: Fri, 24 Jul 2026 23:25:15 +0800 Subject: [PATCH] feat(aihr): complete company message R1-R3 --- .../AihrBroadcastAttachmentService.java | 326 +++++++++++++ .../broadcast/AihrBroadcastController.java | 35 ++ .../aihr/broadcast/AihrBroadcastDto.java | 33 +- .../aihr/broadcast/AihrBroadcastService.java | 96 +++- .../aihr/direct/AihrDirectController.java | 69 +++ .../dromara/aihr/direct/AihrDirectDto.java | 85 ++++ .../aihr/direct/AihrDirectService.java | 444 ++++++++++++++++++ .../service/AihrKnowledgeQueryService.java | 22 +- .../aihr/service/AihrSopSeedService.java | 8 + .../AihrBroadcastAttachmentContractTest.java | 54 +++ .../AihrDirectSecurityContractTest.java | 85 ++++ .../AihrKnowledgeQueryServiceTest.java | 34 ++ ...r_20260724_broadcast_attachment_mysql8.sql | 58 +++ .../aihr_20260724_direct_feedback_mysql8.sql | 71 +++ docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 17 +- docs/DEV_SETUP.md | 2 + frontend/src/api/aihr/broadcast.ts | 32 ++ frontend/src/api/aihr/direct.ts | 50 ++ frontend/src/router/access.ts | 9 + frontend/src/router/index.ts | 14 +- .../broadcast-attachment-contract.test.ts | 21 + frontend/src/utils/broadcast-pending.ts | 6 +- frontend/src/utils/direct-contract.test.ts | 23 + frontend/src/views/content/broadcast.vue | 158 ++++++- frontend/src/views/content/direct.vue | 196 ++++++++ mobile-uni/src/pages.json | 6 + .../src/pages/user/broadcast/detail.vue | 58 ++- mobile-uni/src/pages/user/broadcast/index.vue | 14 + mobile-uni/src/pages/user/direct/index.vue | 252 ++++++++++ mobile-uni/src/services/broadcast.ts | 11 + mobile-uni/src/services/direct.ts | 56 +++ .../tests/broadcast-attachment.test.mjs | 19 + mobile-uni/tests/direct.test.mjs | 24 + scripts/demo-check.sh | 14 +- scripts/dev-backend.sh | 1 + scripts/release-preflight.sh | 76 ++- scripts/tests/aihr-schema-migrations.test.sh | 42 ++ 37 files changed, 2479 insertions(+), 42 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectDto.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/direct/AihrDirectSecurityContractTest.java create mode 100644 backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql create mode 100644 frontend/src/api/aihr/direct.ts create mode 100644 frontend/src/utils/broadcast-attachment-contract.test.ts create mode 100644 frontend/src/utils/direct-contract.test.ts create mode 100644 frontend/src/views/content/direct.vue create mode 100644 mobile-uni/src/pages/user/direct/index.vue create mode 100644 mobile-uni/src/services/direct.ts create mode 100644 mobile-uni/tests/broadcast-attachment.test.mjs create mode 100644 mobile-uni/tests/direct.test.mjs diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java new file mode 100644 index 00000000..73ce1703 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java @@ -0,0 +1,326 @@ +package org.dromara.aihr.broadcast; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.service.AihrMultipartFiles; +import org.dromara.aihr.service.AihrSopSeedService; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.constant.TenantConstants; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.oss.core.OssClient; +import org.dromara.common.oss.factory.OssFactory; +import org.dromara.common.tenant.helper.TenantHelper; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.service.ISysOssService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AihrBroadcastAttachmentService { + + private static final long MAX_FILE_BYTES = 100L * 1024 * 1024; + private static final Set SUPPORTED_SUFFIXES = Set.of( + "txt", "md", "markdown", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx" + ); + + private final JdbcTemplate jdbcTemplate; + private final ISysOssService ossService; + private final KnowledgeDocumentParser documentParser; + private final AihrSopSeedService sopService; + private final AihrKnowledgePrincipalResolver principalResolver; + private final AihrBroadcastService broadcastService; + private final ScheduledExecutorService scheduledExecutorService; + private final AtomicBoolean workerRunning = new AtomicBoolean(); + + @PostConstruct + void scheduleRecovery() { + scheduledExecutorService.scheduleWithFixedDelay(this::recoverAndTrigger, 20, 30, TimeUnit.SECONDS); + } + + public BroadcastAttachmentResponse upload(MultipartFile file, String rawExpectedTenantId) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String tenantId = adminTenant(principal); + requireExpectedTenant(rawExpectedTenantId, tenantId); + if (file == null || file.isEmpty()) { + throw new ServiceException("请选择公司文件", HttpStatus.BAD_REQUEST); + } + if (file.getSize() <= 0 || file.getSize() > MAX_FILE_BYTES) { + throw new ServiceException("公司文件不能超过 100MB", HttpStatus.BAD_REQUEST); + } + String fileName = AihrMultipartFiles.sanitizeFileName(file.getOriginalFilename(), "company-file.txt"); + if (!SUPPORTED_SUFFIXES.contains(suffix(fileName))) { + throw new ServiceException("仅支持 TXT、Markdown、PDF、Word、Excel 和 PPT 文件", HttpStatus.BAD_REQUEST); + } + MultipartFile sanitized = AihrMultipartFiles.withOriginalFilename(file, fileName); + SysOssVo oss = ossService.upload(sanitized); + KeyHolder keyHolder = new GeneratedKeyHolder(); + try { + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_broadcast_attachment + (tenant_id, oss_id, file_name, file_size, content_type, status, uploaded_by, create_time, update_time) + values (?, ?, ?, ?, ?, 'QUEUED', ?, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, tenantId); + statement.setLong(2, oss.getOssId()); + statement.setString(3, fileName); + statement.setLong(4, file.getSize()); + statement.setString(5, contentType(file.getContentType())); + statement.setLong(6, principal.userId()); + return statement; + }, keyHolder); + } catch (RuntimeException error) { + deleteOssQuietly(oss.getOssId()); + throw error; + } + Number key = keyHolder.getKey(); + if (key == null) { + deleteOssQuietly(oss.getOssId()); + throw new ServiceException("公司文件入队失败", 500); + } + trigger(); + return adminAttachment(key.longValue()); + } + + public BroadcastAttachmentResponse adminAttachment(Long rawId) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String tenantId = adminTenant(principal); + long id = attachmentId(rawId); + List rows = jdbcTemplate.query(""" + select id, file_name, file_size, content_type, status, summary, error_message + from aihr_broadcast_attachment + where tenant_id = ? and id = ? + """, (rs, rowNum) -> response( + rs.getLong("id"), rs.getString("file_name"), rs.getLong("file_size"), + rs.getString("content_type"), rs.getString("status"), rs.getString("summary"), + rs.getString("error_message") + ), tenantId, id); + if (rows.isEmpty()) { + throw new ServiceException("公司文件不存在或无权查看", HttpStatus.NOT_FOUND); + } + return rows.get(0); + } + + public Long authorizedDownloadOssId(Long rawAttachmentId) { + long attachmentId = attachmentId(rawAttachmentId); + AihrKnowledgePrincipal principal = principalResolver.current(); + if (UserType.APP_USER.getUserType().equals(principal.userType())) { + List messageIds = jdbcTemplate.query(""" + select message_id from aihr_broadcast_attachment + where tenant_id = ? and id = ? and status = 'READY' and message_id is not null + """, (rs, rowNum) -> rs.getLong("message_id"), principal.tenantId(), attachmentId); + if (messageIds.isEmpty()) { + return null; + } + broadcastService.message(messageIds.get(0)); + return ossId(principal.tenantId(), attachmentId); + } + requireBroadcastAdmin(principal); + return ossId(adminTenant(principal), attachmentId); + } + + private Long ossId(String tenantId, long attachmentId) { + List rows = jdbcTemplate.query(""" + select oss_id from aihr_broadcast_attachment + where tenant_id = ? and id = ? and status = 'READY' + """, (rs, rowNum) -> rs.getLong("oss_id"), tenantId, attachmentId); + return rows.isEmpty() ? null : rows.get(0); + } + + private void recoverAndTrigger() { + try { + jdbcTemplate.update(""" + update aihr_broadcast_attachment + set status = 'QUEUED', error_message = null, update_time = now() + where status = 'PROCESSING' and update_time < date_sub(now(), interval 30 minute) + """); + trigger(); + } catch (RuntimeException error) { + log.warn("broadcast attachment recovery failed"); + } + } + + private void trigger() { + if (!workerRunning.compareAndSet(false, true)) { + return; + } + CompletableFuture.runAsync(this::processQueue, scheduledExecutorService) + .whenComplete((ignored, error) -> { + workerRunning.set(false); + if (error != null) { + log.warn("broadcast attachment worker failed"); + } + if (hasQueued()) { + trigger(); + } + }); + } + + private void processQueue() { + AttachmentWork work; + while ((work = claim()) != null) { + process(work); + } + } + + private AttachmentWork claim() { + List rows = jdbcTemplate.query(""" + select id, tenant_id, oss_id, file_name, content_type, uploaded_by + from aihr_broadcast_attachment + where status = 'QUEUED' + order by id + limit 10 + """, (rs, rowNum) -> new AttachmentWork( + rs.getLong("id"), rs.getString("tenant_id"), rs.getLong("oss_id"), + rs.getString("file_name"), rs.getString("content_type"), rs.getLong("uploaded_by") + )); + for (AttachmentWork row : rows) { + int claimed = jdbcTemplate.update(""" + update aihr_broadcast_attachment set status = 'PROCESSING', update_time = now() + where tenant_id = ? and id = ? and status = 'QUEUED' + """, row.tenantId(), row.id()); + if (claimed == 1) { + return row; + } + } + return null; + } + + private void process(AttachmentWork work) { + try { + SysOssVo object = TenantHelper.dynamic(work.tenantId(), () -> ossService.getById(work.ossId())); + Integer owned = jdbcTemplate.queryForObject(""" + select count(*) from sys_oss + where binary tenant_id = binary ? and oss_id = ? and create_by = ? + """, Integer.class, work.tenantId(), work.ossId(), work.uploadedBy()); + if (object == null || object.getFileName() == null || object.getFileName().isBlank() + || owned == null || owned != 1) { + throw new IllegalStateException("stored company file is unavailable"); + } + OssClient storage = OssFactory.instance(object.getService()); + String text; + try (InputStream input = storage.getObjectContent(object.getFileName())) { + text = documentParser.parse(work.fileName(), work.contentType(), input).text(); + } + String summary = TenantHelper.dynamic(work.tenantId(), + () -> sopService.summarizeBroadcastAttachment(work.fileName(), text)); + int updated = jdbcTemplate.update(""" + update aihr_broadcast_attachment + set status = 'READY', extracted_text = ?, summary = ?, error_message = null, update_time = now() + where tenant_id = ? and id = ? and status = 'PROCESSING' + """, text, summary, work.tenantId(), work.id()); + if (updated != 1) { + log.info("broadcast attachment {} completed after its state changed", work.id()); + } + } catch (Exception error) { + log.warn("broadcast attachment {} processing failed", work.id()); + jdbcTemplate.update(""" + update aihr_broadcast_attachment + set status = 'FAILED', error_message = '文件提炼失败,请重新上传', update_time = now() + where tenant_id = ? and id = ? and status = 'PROCESSING' + """, work.tenantId(), work.id()); + } + } + + private boolean hasQueued() { + Long count = jdbcTemplate.queryForObject(""" + select count(*) from aihr_broadcast_attachment where status = 'QUEUED' + """, Long.class); + return count != null && count > 0; + } + + private void deleteOssQuietly(Long ossId) { + if (ossId == null) return; + try { + ossService.deleteWithValidByIds(List.of(ossId), false); + } catch (RuntimeException cleanupError) { + log.warn("broadcast attachment OSS cleanup failed ossId={}", ossId); + } + } + + private static BroadcastAttachmentResponse response( + long id, + String fileName, + long fileSize, + String contentType, + String status, + String summary, + String errorMessage + ) { + return new BroadcastAttachmentResponse(id, fileName, fileSize, contentType, status, summary, + errorMessage, "READY".equals(status) ? "/api/aihr/broadcast/attachments/" + id + "/content" : null); + } + + private static void requireBroadcastAdmin(AihrKnowledgePrincipal principal) { + if (UserType.APP_USER.getUserType().equals(principal.userType()) + || (!principal.roles().contains(TenantConstants.SUPER_ADMIN_ROLE_KEY) + && !principal.roles().contains("hr_operator"))) { + throw new ServiceException("无权管理公司文件", HttpStatus.FORBIDDEN); + } + } + + private static String adminTenant(AihrKnowledgePrincipal principal) { + requireBroadcastAdmin(principal); + String tenantId = TenantHelper.getTenantId(); + return tenantId == null || tenantId.isBlank() ? principal.tenantId() : tenantId.trim(); + } + + private static void requireExpectedTenant(String rawExpectedTenantId, String tenantId) { + String expected = rawExpectedTenantId == null ? "" : rawExpectedTenantId.trim(); + if (expected.isEmpty() || expected.length() > 64) { + throw new ServiceException("当前租户不能为空", HttpStatus.BAD_REQUEST); + } + if (!expected.equals(tenantId)) { + throw new ServiceException("当前租户已切换,请刷新页面后重试", HttpStatus.CONFLICT); + } + } + + private static long attachmentId(Long value) { + if (value == null || value < 1) { + throw new ServiceException("公司文件编号无效", HttpStatus.BAD_REQUEST); + } + return value; + } + + private static String suffix(String fileName) { + int dot = fileName.lastIndexOf('.'); + return dot < 0 ? "" : fileName.substring(dot + 1).toLowerCase(Locale.ROOT); + } + + private static String contentType(String value) { + String normalized = value == null ? "" : value.trim(); + return normalized.isEmpty() ? "application/octet-stream" : normalized.substring(0, Math.min(100, normalized.length())); + } + + private record AttachmentWork( + long id, + String tenantId, + long ossId, + String fileName, + String contentType, + long uploadedBy + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java index edea2e37..d9e48ac1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java @@ -4,11 +4,13 @@ import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckRole; import cn.dev33.satoken.annotation.SaMode; import jakarta.validation.Valid; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.dromara.aihr.broadcast.AihrBroadcastDto.AdminBroadcastListResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastDetailResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOptions; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.UnreadCountResponse; @@ -21,7 +23,13 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; 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.springframework.http.MediaType; +import org.dromara.system.service.ISysOssService; + +import java.io.IOException; @RestController @RequiredArgsConstructor @@ -32,6 +40,8 @@ public class AihrBroadcastController { private static final String HR_OPERATOR_ROLE = "hr_operator"; private final AihrBroadcastService broadcastService; + private final AihrBroadcastAttachmentService attachmentService; + private final ISysOssService ossService; @GetMapping("/unread-count") public R unreadCount() { @@ -63,6 +73,21 @@ public class AihrBroadcastController { return R.ok(broadcastService.targetOptions()); } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PostMapping(value = "/admin/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R uploadAttachment( + @RequestPart("file") MultipartFile file, + @RequestParam String expectedTenantId + ) { + return R.ok(attachmentService.upload(file, expectedTenantId)); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @GetMapping("/admin/attachments/{id}") + public R attachmentStatus(@PathVariable Long id) { + return R.ok(attachmentService.adminAttachment(id)); + } + @GetMapping("/messages/{id}") public R message(@PathVariable Long id) { return R.ok(broadcastService.message(id)); @@ -74,6 +99,16 @@ public class AihrBroadcastController { return R.ok(); } + @GetMapping("/attachments/{id}/content") + public void attachmentContent(@PathVariable Long id, HttpServletResponse response) throws IOException { + Long ossId = attachmentService.authorizedDownloadOssId(id); + if (ossId == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "公司文件不存在或无权访问"); + return; + } + ossService.download(ossId, response); + } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @PostMapping("/messages") public R publish(@Valid @RequestBody PublishRequest request) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java index 2ad085fe..e70acd8a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java @@ -45,13 +45,26 @@ public final class AihrBroadcastDto { String publishedAt, boolean requiredRead, boolean targeted, - String targetReason + String targetReason, + BroadcastAttachmentResponse attachment ) { public BroadcastDetailResponse(Long id, String title, String content, boolean read, String publishedAt) { - this(id, title, content, read, publishedAt, false, false, null); + this(id, title, content, read, publishedAt, false, false, null, null); } } + public record BroadcastAttachmentResponse( + Long id, + String fileName, + long fileSize, + String contentType, + String status, + String summary, + String errorMessage, + String downloadUrl + ) { + } + public record AdminBroadcastListResponse( String tenantId, long total, @@ -138,10 +151,22 @@ public final class AihrBroadcastDto { @Size(max = 64, message = "当前租户不能超过 64 个字符") String expectedTenantId, Boolean requiredRead, - @Valid BroadcastTargetRequest targets + @Valid BroadcastTargetRequest targets, + Long attachmentId ) { public PublishRequest(String requestId, String title, String content, String expectedTenantId) { - this(requestId, title, content, expectedTenantId, false, BroadcastTargetRequest.empty()); + this(requestId, title, content, expectedTenantId, false, BroadcastTargetRequest.empty(), null); + } + + public PublishRequest( + String requestId, + String title, + String content, + String expectedTenantId, + Boolean requiredRead, + BroadcastTargetRequest targets + ) { + this(requestId, title, content, expectedTenantId, requiredRead, targets, null); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java index 0defd03a..d5beb533 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.dromara.aihr.broadcast.AihrBroadcastDto.AdminBroadcastListItem; import org.dromara.aihr.broadcast.AihrBroadcastDto.AdminBroadcastListResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastDetailResponse; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListItem; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOption; @@ -207,7 +208,11 @@ public class AihrBroadcastService { case when r.id is null then 0 else 1 end as read_flag, case when m.required_read = 1 then 1 else 0 end as required_read, case when tr.user_id is null then 0 else 1 end as targeted_flag, - tr.target_reason + tr.target_reason, + a.id as attachment_id, a.file_name as attachment_file_name, + a.file_size as attachment_file_size, a.content_type as attachment_content_type, + a.status as attachment_status, a.summary as attachment_summary, + a.error_message as attachment_error_message from aihr_broadcast_message m left join aihr_broadcast_read r on r.tenant_id = m.tenant_id and r.message_id = m.id and r.user_id = ? @@ -219,12 +224,14 @@ public class AihrBroadcastService { and recipient.target_status = 'MATCHED' group by recipient.tenant_id, recipient.message_id, recipient.user_id ) tr on tr.tenant_id = m.tenant_id and tr.message_id = m.id + left join aihr_broadcast_attachment a + on a.tenant_id = m.tenant_id and a.id = m.attachment_id and a.status = 'READY' where m.tenant_id = ? and m.id = ? and m.status = 'PUBLISHED' limit 1 """, (rs, rowNum) -> new BroadcastDetailResponse( rs.getLong("id"), rs.getString("title"), rs.getString("content"), rs.getBoolean("read_flag"), format(rs.getTimestamp("published_time")), rs.getBoolean("required_read"), - rs.getBoolean("targeted_flag"), rs.getString("target_reason") + rs.getBoolean("targeted_flag"), rs.getString("target_reason"), attachment(rs) ), principal.userId(), principal.tenantId(), principal.userId(), principal.tenantId(), messageId); if (rows.isEmpty()) { throw unavailable(); @@ -245,12 +252,16 @@ public class AihrBroadcastService { requireEmployeeAudience(principal); long messageId = messageId(rawMessageId); List rows = jdbcTemplate.query(""" - select m.id, m.title, m.content, m.published_time + select m.id, m.title, m.content, m.published_time, a.extracted_text from aihr_broadcast_message m + left join aihr_broadcast_attachment a + on a.tenant_id = m.tenant_id and a.id = m.attachment_id and a.status = 'READY' where m.tenant_id = ? and m.id = ? and m.status = 'PUBLISHED' limit 1 """, (rs, rowNum) -> new BroadcastQuestionContext( - rs.getLong("id"), rs.getString("title"), rs.getString("content"), format(rs.getTimestamp("published_time")) + rs.getLong("id"), rs.getString("title"), questionContent( + rs.getString("content"), rs.getString("extracted_text")), + format(rs.getTimestamp("published_time")) ), principal.tenantId(), messageId); if (rows.isEmpty()) { throw unavailable(); @@ -289,29 +300,33 @@ public class AihrBroadcastService { String content = required(request == null ? null : request.content(), "消息正文", 10_000); boolean requiredRead = request != null && Boolean.TRUE.equals(request.requiredRead()); TargetSelection targetSelection = targetSelection(request == null ? null : request.targets()); + Long attachmentId = optionalAttachmentId(request == null ? null : request.attachmentId()); String requestHash = requestHash(title, content); - String targetPayloadHash = targetPayloadHash(requiredRead, targetSelection); + String targetPayloadHash = targetPayloadHash(requiredRead, targetSelection, attachmentId); PublishReplay existing = findPublishReplay(tenantId, principal.userId(), requestKey); if (existing != null) { return replayOrConflict(existing, title, content, requiredRead, targetSelection, targetPayloadHash); } + requirePublishableAttachment(tenantId, principal.userId(), attachmentId); KeyHolder keyHolder = new GeneratedKeyHolder(); try { jdbcTemplate.update(connection -> { PreparedStatement statement = connection.prepareStatement(""" insert into aihr_broadcast_message - (tenant_id, title, content, required_read, status, published_by, publish_request_key, publish_request_hash, + (tenant_id, title, content, attachment_id, required_read, status, published_by, publish_request_key, publish_request_hash, target_payload_hash, published_time, create_time, update_time) - values (?, ?, ?, ?, 'PUBLISHED', ?, ?, ?, ?, now(), now(), now()) + values (?, ?, ?, ?, ?, 'PUBLISHED', ?, ?, ?, ?, now(), now(), now()) """, Statement.RETURN_GENERATED_KEYS); statement.setString(1, tenantId); statement.setString(2, title); statement.setString(3, content); - statement.setBoolean(4, requiredRead); - statement.setLong(5, principal.userId()); - statement.setString(6, requestKey); - statement.setString(7, requestHash); - statement.setString(8, targetPayloadHash); + if (attachmentId == null) statement.setNull(4, java.sql.Types.BIGINT); + else statement.setLong(4, attachmentId); + statement.setBoolean(5, requiredRead); + statement.setLong(6, principal.userId()); + statement.setString(7, requestKey); + statement.setString(8, requestHash); + statement.setString(9, targetPayloadHash); return statement; }, keyHolder); } catch (DuplicateKeyException duplicate) { @@ -338,6 +353,7 @@ public class AihrBroadcastService { if (snapshots != 1) { throw new ServiceException("保存消息版本失败"); } + bindAttachment(tenantId, principal.userId(), id.longValue(), attachmentId); TargetAudit targetAudit = persistTargetSnapshot(tenantId, id.longValue(), targetSelection); return new PublishResponse(id.longValue(), 1, targetAudit.matchedRecipientCount(), targetAudit.unmatchedTargetCount()); } @@ -599,7 +615,7 @@ public class AihrBroadcastService { return List.copyOf(sorted); } - private static String targetPayloadHash(boolean requiredRead, TargetSelection selection) { + private static String targetPayloadHash(boolean requiredRead, TargetSelection selection, Long attachmentId) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); updateDigestField(digest, requiredRead ? "1" : "0"); @@ -609,6 +625,10 @@ public class AihrBroadcastService { selection.positionNames().forEach(value -> updateDigestField(digest, value)); updateDigestField(digest, "LEVEL"); selection.positionLevels().forEach(value -> updateDigestField(digest, value)); + if (attachmentId != null) { + updateDigestField(digest, "ATTACHMENT"); + updateDigestField(digest, attachmentId.toString()); + } return HexFormat.of().formatHex(digest.digest()); } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); @@ -658,6 +678,56 @@ public class AihrBroadcastService { return value; } + private static Long optionalAttachmentId(Long value) { + if (value == null) return null; + if (value < 1) { + throw new ServiceException("公司文件编号无效", HttpStatus.BAD_REQUEST); + } + return value; + } + + private void requirePublishableAttachment(String tenantId, long userId, Long attachmentId) { + if (attachmentId == null) return; + Long count = jdbcTemplate.queryForObject(""" + select count(*) from aihr_broadcast_attachment + where tenant_id = ? and id = ? and uploaded_by = ? + and status = 'READY' and message_id is null + """, Long.class, tenantId, attachmentId, userId); + if (count == null || count != 1) { + throw new ServiceException("公司文件尚未提炼完成、已被使用或不属于当前账号", HttpStatus.CONFLICT); + } + } + + private void bindAttachment(String tenantId, long userId, long messageId, Long attachmentId) { + if (attachmentId == null) return; + int updated = jdbcTemplate.update(""" + update aihr_broadcast_attachment set message_id = ?, update_time = now() + where tenant_id = ? and id = ? and uploaded_by = ? + and status = 'READY' and message_id is null + """, messageId, tenantId, attachmentId, userId); + if (updated != 1) { + throw new ServiceException("公司文件绑定失败,请刷新后重试", HttpStatus.CONFLICT); + } + } + + private static BroadcastAttachmentResponse attachment(java.sql.ResultSet rs) throws java.sql.SQLException { + Long id = rs.getObject("attachment_id", Long.class); + if (id == null) return null; + return new BroadcastAttachmentResponse( + id, rs.getString("attachment_file_name"), rs.getLong("attachment_file_size"), + rs.getString("attachment_content_type"), rs.getString("attachment_status"), + rs.getString("attachment_summary"), rs.getString("attachment_error_message"), + "/api/aihr/broadcast/attachments/" + id + "/content" + ); + } + + private static String questionContent(String messageContent, String attachmentText) { + if (attachmentText == null || attachmentText.isBlank()) { + return messageContent; + } + return messageContent + "\n\n公司文件提炼内容:\n" + attachmentText; + } + private static String adminStatus(String rawStatus) { String status = rawStatus == null ? "" : rawStatus.trim().toUpperCase(Locale.ROOT); if (status.isEmpty() || "ALL".equals(status)) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java new file mode 100644 index 00000000..c453dac5 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java @@ -0,0 +1,69 @@ +package org.dromara.aihr.direct; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackItem; +import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackPage; +import org.dromara.aihr.direct.AihrDirectDto.ChannelItem; +import org.dromara.aihr.direct.AihrDirectDto.FeedbackItem; +import org.dromara.aihr.direct.AihrDirectDto.FeedbackPage; +import org.dromara.aihr.direct.AihrDirectDto.ReplyRequest; +import org.dromara.aihr.direct.AihrDirectDto.SubmitRequest; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@SaCheckLogin +@RequestMapping("/api/aihr/direct") +public class AihrDirectController { + + private final AihrDirectService directService; + + @GetMapping("/channels") + public R> channels() { + return R.ok(directService.channels()); + } + + @PostMapping("/feedback") + public R submit(@Valid @RequestBody SubmitRequest request) { + return R.ok(directService.submit(request)); + } + + @GetMapping("/mine") + public R mine( + @RequestParam(required = false) Integer pageNum, + @RequestParam(required = false) Integer pageSize + ) { + return R.ok(directService.mine(pageNum, pageSize)); + } + + @GetMapping("/mine/{id}") + public R mineDetail(@PathVariable Long id) { + return R.ok(directService.mineDetail(id)); + } + + @GetMapping("/admin/feedback") + public R adminFeedback( + @RequestParam(required = false) Integer pageNum, + @RequestParam(required = false) Integer pageSize, + @RequestParam(required = false) String status, + @RequestParam(required = false) String channelCode + ) { + return R.ok(directService.adminFeedback(pageNum, pageSize, status, channelCode)); + } + + @PostMapping("/admin/feedback/{id}/reply") + public R reply(@PathVariable Long id, @Valid @RequestBody ReplyRequest request) { + return R.ok(directService.reply(id, request)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectDto.java new file mode 100644 index 00000000..b444027f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectDto.java @@ -0,0 +1,85 @@ +package org.dromara.aihr.direct; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class AihrDirectDto { + + private AihrDirectDto() { + } + + public record ChannelItem(String code, String name, boolean defaultAnonymous) { + } + + public record SubmitRequest( + @NotBlank(message = "请求ID不能为空") + @Size(max = 100, message = "请求ID不能超过 100 个字符") + @Pattern(regexp = "[A-Za-z0-9._:-]+", message = "请求ID格式不正确") + String requestId, + @NotBlank(message = "请选择直达对象") + @Size(max = 20, message = "直达对象格式不正确") + String channelCode, + Boolean anonymous, + @NotBlank(message = "反馈内容不能为空") + @Size(max = 5000, message = "反馈内容不能超过 5000 个字符") + String content + ) { + } + + public record ReplyRequest( + @NotBlank(message = "回复内容不能为空") + @Size(max = 5000, message = "回复内容不能超过 5000 个字符") + String content, + @NotBlank(message = "当前租户不能为空") + @Size(max = 64, message = "当前租户不能超过 64 个字符") + String expectedTenantId + ) { + } + + public record FeedbackItem( + Long id, + String channelCode, + String channelName, + boolean anonymous, + String content, + String status, + String replyContent, + String repliedAt, + String createdAt + ) { + } + + public record FeedbackPage( + long total, + int pageNum, + int pageSize, + List rows + ) { + } + + public record AdminFeedbackItem( + Long id, + String channelCode, + String channelName, + String senderDisplayName, + boolean anonymous, + String content, + String status, + String replyContent, + String repliedAt, + String createdAt + ) { + } + + public record AdminFeedbackPage( + String tenantId, + long total, + int pageNum, + int pageSize, + List rows + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectService.java new file mode 100644 index 00000000..4756e855 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectService.java @@ -0,0 +1,444 @@ +package org.dromara.aihr.direct; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackItem; +import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackPage; +import org.dromara.aihr.direct.AihrDirectDto.ChannelItem; +import org.dromara.aihr.direct.AihrDirectDto.FeedbackItem; +import org.dromara.aihr.direct.AihrDirectDto.FeedbackPage; +import org.dromara.aihr.direct.AihrDirectDto.ReplyRequest; +import org.dromara.aihr.direct.AihrDirectDto.SubmitRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.constant.TenantConstants; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.tenant.helper.TenantHelper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +public class AihrDirectService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + private static final Pattern REQUEST_ID_PATTERN = Pattern.compile("[A-Za-z0-9._:-]+"); + private static final List CHANNELS = List.of( + new DirectChannel("PRESIDENT", "总裁直达", "direct_president", true), + new DirectChannel("FINANCE", "财务直达", "direct_finance", false), + new DirectChannel("HR", "人力直达", "direct_hr", false), + new DirectChannel("AUDIT", "审计直达", "direct_audit", true), + new DirectChannel("OPERATIONS", "运营直达", "direct_operations", false) + ); + + private final JdbcTemplate jdbcTemplate; + private final AihrKnowledgePrincipalResolver principalResolver; + + public List channels() { + requireEmployee(principalResolver.current()); + return CHANNELS.stream() + .map(channel -> new ChannelItem(channel.code(), channel.name(), channel.defaultAnonymous())) + .toList(); + } + + @Transactional + public FeedbackItem submit(SubmitRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String senderName = requireEmployee(principal); + DirectChannel channel = channel(request.channelCode()); + String requestKey = requestKey(request.requestId()); + String content = required(request.content(), "反馈内容", 5000); + boolean anonymous = request.anonymous() == null ? channel.defaultAnonymous() : request.anonymous(); + String requestHash = requestHash(channel.code(), anonymous, content); + FeedbackItem replay = replay(principal, requestKey, requestHash); + if (replay != null) { + return replay; + } + + KeyHolder keyHolder = new GeneratedKeyHolder(); + try { + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_direct_feedback + (tenant_id, channel_code, sender_user_id, sender_name, anonymous_flag, content, + status, submit_request_key, submit_request_hash, create_time, update_time) + values (?, ?, ?, ?, ?, ?, 'SUBMITTED', ?, ?, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, principal.tenantId()); + statement.setString(2, channel.code()); + statement.setLong(3, principal.userId()); + statement.setString(4, senderName); + statement.setBoolean(5, anonymous); + statement.setString(6, content); + statement.setString(7, requestKey); + statement.setString(8, requestHash); + return statement; + }, keyHolder); + } catch (DuplicateKeyException concurrentReplay) { + FeedbackItem completed = replay(principal, requestKey, requestHash); + if (completed != null) { + return completed; + } + throw concurrentReplay; + } + Number key = keyHolder.getKey(); + if (key == null) { + throw new ServiceException("提交反馈失败", 500); + } + return mineDetail(principal, key.longValue()); + } + + public FeedbackPage mine(Integer rawPageNum, Integer rawPageSize) { + AihrKnowledgePrincipal principal = principalResolver.current(); + requireEmployee(principal); + Page page = page(rawPageNum, rawPageSize); + Long total = jdbcTemplate.queryForObject(""" + select count(*) from aihr_direct_feedback + where tenant_id = ? and sender_user_id = ? + """, Long.class, principal.tenantId(), principal.userId()); + List rows = jdbcTemplate.query(""" + select id, channel_code, anonymous_flag, content, status, reply_content, replied_time, create_time + from aihr_direct_feedback + where tenant_id = ? and sender_user_id = ? + order by create_time desc, id desc + limit ? offset ? + """, (rs, rowNum) -> feedbackItem( + rs.getLong("id"), rs.getString("channel_code"), rs.getBoolean("anonymous_flag"), + rs.getString("content"), rs.getString("status"), rs.getString("reply_content"), + rs.getTimestamp("replied_time"), rs.getTimestamp("create_time") + ), principal.tenantId(), principal.userId(), page.pageSize(), page.offset()); + return new FeedbackPage(total == null ? 0 : total, page.pageNum(), page.pageSize(), rows); + } + + public FeedbackItem mineDetail(Long rawId) { + AihrKnowledgePrincipal principal = principalResolver.current(); + requireEmployee(principal); + return mineDetail(principal, id(rawId)); + } + + public AdminFeedbackPage adminFeedback( + Integer rawPageNum, + Integer rawPageSize, + String rawStatus, + String rawChannelCode + ) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String tenantId = adminTenant(principal); + List allowedChannels = allowedChannels(principal); + DirectChannel requestedChannel = optionalChannel(rawChannelCode); + if (requestedChannel != null && !allowedChannels.contains(requestedChannel)) { + throw forbidden(); + } + List channelCodes = requestedChannel == null + ? allowedChannels.stream().map(DirectChannel::code).toList() + : List.of(requestedChannel.code()); + String status = status(rawStatus); + Page page = page(rawPageNum, rawPageSize); + Query query = adminQuery(tenantId, channelCodes, status); + Long total = jdbcTemplate.queryForObject("select count(*) " + query.fromWhere(), + Long.class, query.parameters().toArray()); + List parameters = new ArrayList<>(query.parameters()); + parameters.add(page.pageSize()); + parameters.add(page.offset()); + List rows = jdbcTemplate.query(""" + select id, channel_code, sender_name, anonymous_flag, content, status, + reply_content, replied_time, create_time + """ + query.fromWhere() + """ + order by create_time desc, id desc + limit ? offset ? + """, (rs, rowNum) -> adminItem( + rs.getLong("id"), rs.getString("channel_code"), rs.getString("sender_name"), + rs.getBoolean("anonymous_flag"), rs.getString("content"), rs.getString("status"), + rs.getString("reply_content"), rs.getTimestamp("replied_time"), rs.getTimestamp("create_time") + ), parameters.toArray()); + return new AdminFeedbackPage(tenantId, total == null ? 0 : total, page.pageNum(), page.pageSize(), rows); + } + + @Transactional + public AdminFeedbackItem reply(Long rawId, ReplyRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String tenantId = adminTenant(principal); + requireExpectedTenant(request.expectedTenantId(), tenantId); + List channelCodes = allowedChannels(principal).stream().map(DirectChannel::code).toList(); + long feedbackId = id(rawId); + String replyContent = required(request.content(), "回复内容", 5000); + AdminFeedbackItem existing = adminDetail(tenantId, feedbackId, channelCodes); + if ("REPLIED".equals(existing.status())) { + if (replyContent.equals(existing.replyContent())) { + return existing; + } + throw new ServiceException("该反馈已经回复,不能覆盖原回复", HttpStatus.CONFLICT); + } + int updated = jdbcTemplate.update(""" + update aihr_direct_feedback + set status = 'REPLIED', reply_content = ?, replied_by = ?, replied_time = now(), update_time = now() + where tenant_id = ? and id = ? and status = 'SUBMITTED' + """, replyContent, principal.userId(), tenantId, feedbackId); + if (updated != 1) { + AdminFeedbackItem concurrent = adminDetail(tenantId, feedbackId, channelCodes); + if ("REPLIED".equals(concurrent.status()) && replyContent.equals(concurrent.replyContent())) { + return concurrent; + } + throw new ServiceException("该反馈已经回复,不能覆盖原回复", HttpStatus.CONFLICT); + } + return adminDetail(tenantId, feedbackId, channelCodes); + } + + private FeedbackItem replay(AihrKnowledgePrincipal principal, String requestKey, String requestHash) { + List rows = jdbcTemplate.query(""" + select id, submit_request_hash + from aihr_direct_feedback + where tenant_id = ? and sender_user_id = ? and submit_request_key = ? + """, (rs, rowNum) -> new Replay(rs.getLong("id"), rs.getString("submit_request_hash")), + principal.tenantId(), principal.userId(), requestKey); + if (rows.isEmpty()) { + return null; + } + Replay replay = rows.get(0); + if (!requestHash.equals(replay.requestHash())) { + throw new ServiceException("该请求ID已用于不同的反馈", HttpStatus.CONFLICT); + } + return mineDetail(principal, replay.id()); + } + + private FeedbackItem mineDetail(AihrKnowledgePrincipal principal, long feedbackId) { + List rows = jdbcTemplate.query(""" + select id, channel_code, anonymous_flag, content, status, reply_content, replied_time, create_time + from aihr_direct_feedback + where tenant_id = ? and sender_user_id = ? and id = ? + """, (rs, rowNum) -> feedbackItem( + rs.getLong("id"), rs.getString("channel_code"), rs.getBoolean("anonymous_flag"), + rs.getString("content"), rs.getString("status"), rs.getString("reply_content"), + rs.getTimestamp("replied_time"), rs.getTimestamp("create_time") + ), principal.tenantId(), principal.userId(), feedbackId); + if (rows.isEmpty()) { + throw new ServiceException("反馈不存在或无权查看", HttpStatus.NOT_FOUND); + } + return rows.get(0); + } + + private AdminFeedbackItem adminDetail(String tenantId, long feedbackId, List channelCodes) { + String placeholders = String.join(",", Collections.nCopies(channelCodes.size(), "?")); + List parameters = new ArrayList<>(); + parameters.add(tenantId); + parameters.add(feedbackId); + parameters.addAll(channelCodes); + List rows = jdbcTemplate.query(""" + select id, channel_code, sender_name, anonymous_flag, content, status, + reply_content, replied_time, create_time + from aihr_direct_feedback + where tenant_id = ? and id = ? and channel_code in (""" + placeholders + ")", + (rs, rowNum) -> adminItem( + rs.getLong("id"), rs.getString("channel_code"), rs.getString("sender_name"), + rs.getBoolean("anonymous_flag"), rs.getString("content"), rs.getString("status"), + rs.getString("reply_content"), rs.getTimestamp("replied_time"), rs.getTimestamp("create_time") + ), parameters.toArray()); + if (rows.isEmpty()) { + throw new ServiceException("反馈不存在或无权处理", HttpStatus.NOT_FOUND); + } + return rows.get(0); + } + + private String requireEmployee(AihrKnowledgePrincipal principal) { + if (!UserType.APP_USER.getUserType().equals(principal.userType())) { + throw new ServiceException("仅在职员工可使用直达反馈", HttpStatus.FORBIDDEN); + } + List names = jdbcTemplate.query(""" + select distinct coalesce(nullif(trim(o.person_name), ''), '员工') as person_name + from sys_user u + join aihr_org_snapshot o + on binary o.tenant_id = binary u.tenant_id + and binary o.person_phone = binary u.phonenumber + where u.tenant_id = ? and u.user_id = ? and u.user_type = ? + and u.status = '0' and u.del_flag = '0' and o.employment_status = 'active' + order by person_name limit 1 + """, (rs, rowNum) -> rs.getString("person_name"), + principal.tenantId(), principal.userId(), UserType.APP_USER.getUserType()); + if (names.isEmpty()) { + throw new ServiceException("仅在职员工可使用直达反馈", HttpStatus.FORBIDDEN); + } + return names.get(0); + } + + private static List allowedChannels(AihrKnowledgePrincipal principal) { + if (UserType.APP_USER.getUserType().equals(principal.userType())) { + throw forbidden(); + } + Set roles = new LinkedHashSet<>(principal.roles()); + if (roles.contains(TenantConstants.SUPER_ADMIN_ROLE_KEY)) { + return CHANNELS; + } + List allowed = CHANNELS.stream().filter(channel -> roles.contains(channel.role())).toList(); + if (allowed.isEmpty()) { + throw forbidden(); + } + return allowed; + } + + private static Query adminQuery(String tenantId, List channelCodes, String status) { + String placeholders = String.join(",", Collections.nCopies(channelCodes.size(), "?")); + String statusFilter = status == null ? "" : " and status = ?"; + List parameters = new ArrayList<>(); + parameters.add(tenantId); + parameters.addAll(channelCodes); + if (status != null) { + parameters.add(status); + } + return new Query(" from aihr_direct_feedback where tenant_id = ? and channel_code in (" + + placeholders + ")" + statusFilter, parameters); + } + + private static FeedbackItem feedbackItem( + long id, + String channelCode, + boolean anonymous, + String content, + String status, + String replyContent, + Timestamp repliedAt, + Timestamp createdAt + ) { + return new FeedbackItem(id, channelCode, channel(channelCode).name(), anonymous, content, status, + replyContent, format(repliedAt), format(createdAt)); + } + + private static AdminFeedbackItem adminItem( + long id, + String channelCode, + String senderName, + boolean anonymous, + String content, + String status, + String replyContent, + Timestamp repliedAt, + Timestamp createdAt + ) { + return new AdminFeedbackItem(id, channelCode, channel(channelCode).name(), + anonymous ? "匿名员工" : required(senderName, "提交人姓名", 100), anonymous, + content, status, replyContent, format(repliedAt), format(createdAt)); + } + + private static DirectChannel channel(String rawCode) { + String code = rawCode == null ? "" : rawCode.trim().toUpperCase(Locale.ROOT); + return CHANNELS.stream().filter(channel -> channel.code().equals(code)).findFirst() + .orElseThrow(() -> new ServiceException("直达对象无效", HttpStatus.BAD_REQUEST)); + } + + private static DirectChannel optionalChannel(String rawCode) { + return rawCode == null || rawCode.isBlank() ? null : channel(rawCode); + } + + private static String status(String rawStatus) { + String normalized = rawStatus == null ? "" : rawStatus.trim().toUpperCase(Locale.ROOT); + if (normalized.isEmpty() || "ALL".equals(normalized)) { + return null; + } + if ("SUBMITTED".equals(normalized) || "REPLIED".equals(normalized)) { + return normalized; + } + throw new ServiceException("status 只支持 SUBMITTED、REPLIED 或 all", HttpStatus.BAD_REQUEST); + } + + private static Page page(Integer rawPageNum, Integer rawPageSize) { + int pageNum = rawPageNum == null ? 1 : rawPageNum; + int pageSize = rawPageSize == null ? DEFAULT_PAGE_SIZE : rawPageSize; + if (pageNum < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) { + throw new ServiceException("分页参数无效", HttpStatus.BAD_REQUEST); + } + return new Page(pageNum, pageSize, (long) (pageNum - 1) * pageSize); + } + + private static long id(Long value) { + if (value == null || value < 1) { + throw new ServiceException("反馈编号无效", HttpStatus.BAD_REQUEST); + } + return value; + } + + private static String requestKey(String value) { + String normalized = required(value, "请求ID", 100); + if (!REQUEST_ID_PATTERN.matcher(normalized).matches()) { + throw new ServiceException("请求ID格式不正确", HttpStatus.BAD_REQUEST); + } + return normalized; + } + + private static String requestHash(String channelCode, boolean anonymous, String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(channelCode.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(anonymous ? (byte) 1 : (byte) 0); + digest.update((byte) 0); + digest.update(content.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + private static String required(String value, String label, int maximumLength) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isEmpty()) { + throw new ServiceException(label + "不能为空", HttpStatus.BAD_REQUEST); + } + if (normalized.length() > maximumLength) { + throw new ServiceException(label + "不能超过 " + maximumLength + " 个字符", HttpStatus.BAD_REQUEST); + } + return normalized; + } + + private static String adminTenant(AihrKnowledgePrincipal principal) { + String tenantId = TenantHelper.getTenantId(); + return tenantId == null || tenantId.isBlank() ? principal.tenantId() : tenantId.trim(); + } + + private static void requireExpectedTenant(String rawExpectedTenantId, String tenantId) { + if (!required(rawExpectedTenantId, "当前租户", 64).equals(tenantId)) { + throw new ServiceException("当前租户已切换,请刷新页面后重试", HttpStatus.CONFLICT); + } + } + + private static String format(Timestamp value) { + return value == null ? null + : value.toLocalDateTime().withNano(0).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + private static ServiceException forbidden() { + return new ServiceException("无权处理该直达反馈", HttpStatus.FORBIDDEN); + } + + private record DirectChannel(String code, String name, String role, boolean defaultAnonymous) { + } + + private record Replay(long id, String requestHash) { + } + + private record Query(String fromWhere, List parameters) { + } + + private record Page(int pageNum, int pageSize, long offset) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java index f244266b..f71ee0ed 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -107,16 +107,26 @@ public class AihrKnowledgeQueryService { boolean finalizeAudit = false; QueryResponse response = null; if (context.broadcastMessageId() != null) { - // Project selection has already been verified above. Knowledge-space authorization - // deliberately precedes message resolution, so a caller never learns message - // state while attempting to bypass the selected project or authorized SOP scope. - spaceIds = accessService.resolveInternalSpaceIds( - principal, app, request.spaceCodes(), "READ"); + // A published company message is an independently authorized source. Extra SOP + // evidence is optional unless the caller explicitly requested a knowledge space. + try { + spaceIds = accessService.resolveInternalSpaceIds( + principal, app, request.spaceCodes(), "READ"); + } catch (ServiceException ex) { + if (ex.getCode() != null && ex.getCode() == HttpStatus.FORBIDDEN + && request.spaceCodes().isEmpty()) { + spaceIds = Set.of(); + } else { + throw ex; + } + } BroadcastQuestionContext broadcast = resolveBroadcastContext(principal, context.broadcastMessageId()); response = answerBroadcastQuestion( broadcast, request.queryText(), - queryDocuments(principal, app, spaceIds, routed, request.queryText()) + spaceIds.isEmpty() + ? emptyProjectResponse(request.queryText()) + : queryDocuments(principal, app, spaceIds, routed, request.queryText()) ); // Revalidate immediately before the response can be persisted into the short // conversation. A withdrawal (or an employee becoming ineligible) during the diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index ee01f571..c06ebd20 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -1432,6 +1432,14 @@ public class AihrSopSeedService { } } + /** + * Reuses the configured document-insight model for a broadcast attachment without + * importing that attachment into the enterprise knowledge base. + */ + public String summarizeBroadcastAttachment(String fileName, String content) { + return documentInsight(fileName, content, "公司文件").summary(); + } + private String callInsightModel(ChatRuntime runtime, String fileName, String content, boolean autoCategory, String manualCategory) throws Exception { ObjectNode body = objectMapper.createObjectNode(); body.put("model", runtime.modelName()); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java new file mode 100644 index 00000000..f6839a6f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java @@ -0,0 +1,54 @@ +package org.dromara.aihr.broadcast; + +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse; +import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrBroadcastAttachmentContractTest { + + @Test + void questionContextAddsServerSideExtractedTextWithoutAddingItToTheApiDto() throws Exception { + Method method = AihrBroadcastService.class.getDeclaredMethod("questionContent", String.class, String.class); + method.setAccessible(true); + String context = (String) method.invoke(null, "消息正文", "原文件提取内容"); + List responseFields = List.of(BroadcastAttachmentResponse.class.getRecordComponents()).stream() + .map(component -> component.getName()) + .toList(); + + assertTrue(context.contains("消息正文")); + assertTrue(context.contains("原文件提取内容")); + assertFalse(responseFields.contains("extractedText")); + } + + @Test + void publishRequestKeepsLegacyConstructorAndAcceptsOneAttachment() { + PublishRequest legacy = new PublishRequest("request-1", "标题", "正文", "000000"); + PublishRequest attached = new PublishRequest( + "request-2", "标题", "正文", "000000", false, + AihrBroadcastDto.BroadcastTargetRequest.empty(), 42L + ); + + assertEquals(null, legacy.attachmentId()); + assertEquals(42L, attached.attachmentId()); + } + + @Test + void backgroundWorkerReadsOssInsideTheOwningTenant() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java")); + + assertTrue(source.contains( + "TenantHelper.dynamic(work.tenantId(), () -> ossService.getById(work.ossId()))")); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/direct/AihrDirectSecurityContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/direct/AihrDirectSecurityContractTest.java new file mode 100644 index 00000000..16682165 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/direct/AihrDirectSecurityContractTest.java @@ -0,0 +1,85 @@ +package org.dromara.aihr.direct; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackItem; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.constant.TenantConstants; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +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; + +@Tag("dev") +class AihrDirectSecurityContractTest { + + @Test + void departmentRoleCanOnlyOpenItsOwnInboxWhileSuperadminCanOpenAll() throws Exception { + List auditChannels = allowedChannels(principal("sys_user", Set.of("direct_audit"))); + List allChannels = allowedChannels(principal("sys_user", Set.of(TenantConstants.SUPER_ADMIN_ROLE_KEY))); + + assertEquals(1, auditChannels.size()); + assertTrue(auditChannels.get(0).toString().contains("AUDIT")); + assertEquals(5, allChannels.size()); + } + + @Test + void employeeCannotCallTheHandlerScope() { + InvocationTargetException wrapped = assertThrows(InvocationTargetException.class, + () -> allowedChannels(principal(UserType.APP_USER.getUserType(), Set.of("employee")))); + + ServiceException error = (ServiceException) wrapped.getCause(); + assertEquals(HttpStatus.FORBIDDEN, error.getCode()); + } + + @Test + void businessInboxDtoNeverExposesInternalIdentityKeys() { + List fields = List.of(AdminFeedbackItem.class.getRecordComponents()).stream() + .map(component -> component.getName().toLowerCase()) + .toList(); + + assertFalse(fields.contains("senderuserid")); + assertFalse(fields.contains("extpartyid")); + assertFalse(fields.contains("phone")); + assertTrue(fields.contains("senderdisplayname")); + } + + @Test + void endpointsRequireLoginAndRepliesAreTransactional() throws Exception { + assertTrue(AihrDirectController.class.isAnnotationPresent(SaCheckLogin.class)); + assertTrue(AihrDirectService.class.getMethod("reply", Long.class, AihrDirectDto.ReplyRequest.class) + .isAnnotationPresent(Transactional.class)); + } + + @Test + void employeeIdentityUsesOnlyTheVerifiedMobilePhone() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/org/dromara/aihr/direct/AihrDirectService.java")); + + assertTrue(source.contains("binary o.person_phone = binary u.phonenumber")); + assertFalse(source.contains("o.ext_party_id = binary u.user_name")); + } + + private static List allowedChannels(AihrKnowledgePrincipal principal) throws Exception { + Method method = AihrDirectService.class.getDeclaredMethod("allowedChannels", AihrKnowledgePrincipal.class); + method.setAccessible(true); + return (List) method.invoke(null, principal); + } + + private static AihrKnowledgePrincipal principal(String userType, Set roles) { + return new AihrKnowledgePrincipal("tenant-a", 7L, userType, "", roles, Set.of(), "client"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java index 8687674b..8b054971 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -607,6 +607,40 @@ class AihrKnowledgeQueryServiceTest { eq(List.of("BCAST")), eq("SUCCESS"), anyLong(), eq("broadcast-context-v1")); } + @Test + void broadcastQuestionWorksWithoutAnyKnowledgeSpace() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var sop = mock(AihrSopSeedService.class); + var broadcast = mock(AihrBroadcastService.class); + var model = mock(AihrModelSeedService.class); + var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7", + Set.of("employee"), Set.of(), "app"); + var app = new AuthenticatedApp(3L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + when(resolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")) + .thenThrow(new ServiceException("当前应用和账号没有共同可访问的知识空间", 403)); + when(broadcast.resolveQuestionContext(principal, 42L)).thenReturn( + new AihrBroadcastService.BroadcastQuestionContext( + 42L, "客户投诉首问负责制", "30分钟内登记,2小时内反馈首次处理进展。", "2026-07-24T20:10:00")); + when(model.tryChat(anyString(), anyString(), eq(0.0))).thenReturn( + Optional.of("应在30分钟内登记,并在2小时内反馈首次处理进展。")); + var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, + mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), + mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), + mock(AihrMemoryService.class), broadcast, model); + + var result = service.queryInternal(new QueryRequest( + "多久登记和反馈?", List.of(), "sop", null, "mobile", 5, null, + null, null, null, 42L)); + + assertTrue(result.answer().contains("30分钟")); + assertEquals(42L, result.broadcastContext().messageId()); + verify(sop, never()).searchAuthorized(any(), any(), any()); + } + @Test void broadcastContextIsRejectedForMediaAndExternalCalls() { var service = service(mock(AihrKnowledgePrincipalResolver.class), mock(AihrKnowledgeAppService.class), diff --git a/backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql b/backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql new file mode 100644 index 00000000..c146e20c --- /dev/null +++ b/backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql @@ -0,0 +1,58 @@ +-- 公司消息 R2:单文件异步提炼、原文件受控下载及消息追问上下文。 +-- 可重复执行,需在 aihr_20260723_broadcast_targeting_mysql8.sql 之后执行。 + +CREATE TABLE IF NOT EXISTS `aihr_broadcast_attachment` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '公司文件ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `message_id` bigint DEFAULT NULL COMMENT '绑定的公司消息ID', + `oss_id` bigint NOT NULL COMMENT '原文件OSS编号', + `file_name` varchar(255) NOT NULL COMMENT '原文件名', + `file_size` bigint NOT NULL COMMENT '文件字节数', + `content_type` varchar(100) NOT NULL COMMENT '内容类型', + `status` varchar(20) NOT NULL DEFAULT 'QUEUED' COMMENT 'QUEUED/PROCESSING/READY/FAILED', + `extracted_text` longtext DEFAULT NULL COMMENT '提取文本,仅供服务端上下文使用', + `summary` varchar(1000) DEFAULT NULL COMMENT 'AI提炼摘要', + `error_message` varchar(500) DEFAULT NULL COMMENT '公开失败说明', + `uploaded_by` bigint NOT NULL COMMENT '上传账号ID', + `create_time` datetime NOT NULL COMMENT '上传时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_broadcast_attachment_oss` (`tenant_id`, `oss_id`), + UNIQUE KEY `uk_aihr_broadcast_attachment_message` (`tenant_id`, `message_id`), + KEY `idx_aihr_broadcast_attachment_queue` (`status`, `update_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='银城大喇叭公司文件提炼'; + +SET @has_broadcast_attachment_id := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' + AND column_name = 'attachment_id' +); +SET @broadcast_attachment_ddl := IF( + @has_broadcast_attachment_id = 0, + 'ALTER TABLE aihr_broadcast_message ADD COLUMN attachment_id bigint DEFAULT NULL COMMENT ''公司文件ID'' AFTER content, ADD KEY idx_aihr_broadcast_message_attachment (tenant_id, attachment_id)', + 'SELECT 1' +); +PREPARE aihr_broadcast_attachment_stmt FROM @broadcast_attachment_ddl; EXECUTE aihr_broadcast_attachment_stmt; DEALLOCATE PREPARE aihr_broadcast_attachment_stmt; + +SET @broadcast_attachment_target_collation := ( + SELECT collation_name FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_info' AND column_name = 'tenant_id' + LIMIT 1 +); +SET @broadcast_attachment_target_collation := COALESCE(@broadcast_attachment_target_collation, @@collation_database); +SET @broadcast_attachment_table_collation := ( + SELECT table_collation FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_attachment' + LIMIT 1 +); +SET @broadcast_attachment_ddl := IF( + @broadcast_attachment_table_collation = @broadcast_attachment_target_collation, + 'SELECT 1', + CONCAT('ALTER TABLE aihr_broadcast_attachment CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @broadcast_attachment_target_collation) +); +PREPARE aihr_broadcast_attachment_stmt FROM @broadcast_attachment_ddl; EXECUTE aihr_broadcast_attachment_stmt; DEALLOCATE PREPARE aihr_broadcast_attachment_stmt; + +SET @has_broadcast_attachment_id := NULL; +SET @broadcast_attachment_target_collation := NULL; +SET @broadcast_attachment_table_collation := NULL; +SET @broadcast_attachment_ddl := NULL; diff --git a/backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql b/backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql new file mode 100644 index 00000000..79628fd0 --- /dev/null +++ b/backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql @@ -0,0 +1,71 @@ +-- 公司消息 R1:员工点对点直达反馈。业务匿名不显示提交人,但保留内部账号用于本人查询和审计。 + +CREATE TABLE IF NOT EXISTS `aihr_direct_feedback` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '反馈ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `channel_code` varchar(20) NOT NULL COMMENT 'PRESIDENT/FINANCE/HR/AUDIT/OPERATIONS', + `sender_user_id` bigint NOT NULL COMMENT '内部提交账号ID,不在业务处理接口返回', + `sender_name` varchar(100) NOT NULL COMMENT '提交时员工姓名快照', + `anonymous_flag` tinyint(1) NOT NULL DEFAULT 0 COMMENT '业务展示是否匿名', + `content` text NOT NULL COMMENT '反馈内容', + `status` varchar(20) NOT NULL DEFAULT 'SUBMITTED' COMMENT 'SUBMITTED/REPLIED', + `submit_request_key` varchar(100) NOT NULL COMMENT '员工提交幂等键', + `submit_request_hash` char(64) NOT NULL COMMENT '提交载荷SHA-256', + `reply_content` text DEFAULT NULL COMMENT '正式回复', + `replied_by` bigint DEFAULT NULL COMMENT '回复账号ID', + `replied_time` datetime DEFAULT NULL COMMENT '回复时间', + `create_time` datetime NOT NULL COMMENT '提交时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_direct_feedback_submit` (`tenant_id`, `sender_user_id`, `submit_request_key`), + KEY `idx_aihr_direct_feedback_inbox` (`tenant_id`, `channel_code`, `status`, `create_time`), + KEY `idx_aihr_direct_feedback_sender` (`tenant_id`, `sender_user_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工点对点直达反馈'; + +-- 与租户事实表保持相同排序规则,兼容历史库。 +SET @direct_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 @direct_target_collation := COALESCE(@direct_target_collation, @@collation_database); +SET @direct_table_collation := ( + SELECT table_collation FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'aihr_direct_feedback' + LIMIT 1 +); +SET @direct_ddl := IF( + @direct_table_collation = @direct_target_collation, + 'SELECT 1', + CONCAT('ALTER TABLE aihr_direct_feedback CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @direct_target_collation) +); +PREPARE aihr_direct_stmt FROM @direct_ddl; EXECUTE aihr_direct_stmt; DEALLOCATE PREPARE aihr_direct_stmt; + +SET @direct_target_collation := NULL; +SET @direct_table_collation := NULL; +SET @direct_ddl := NULL; + +-- 当前正式租户预置五个处理角色;具体人员仍由管理员按职责分配。 +INSERT INTO `sys_role` + (`role_id`, `tenant_id`, `role_name`, `role_key`, `role_sort`, `data_scope`, + `menu_check_strictly`, `dept_check_strictly`, `status`, `del_flag`, + `create_by`, `create_time`, `remark`) +SELECT seed.role_id, '000000', seed.role_name, seed.role_key, seed.role_sort, '1', + 1, 1, '0', '0', 1, NOW(), '公司消息直通车固定处理角色' + FROM ( + SELECT 202607240000000101 AS role_id, '总裁直达处理' AS role_name, 'direct_president' AS role_key, 21 AS role_sort + UNION ALL SELECT 202607240000000102, '财务直达处理', 'direct_finance', 22 + UNION ALL SELECT 202607240000000103, '人力直达处理', 'direct_hr', 23 + UNION ALL SELECT 202607240000000104, '审计直达处理', 'direct_audit', 24 + UNION ALL SELECT 202607240000000105, '运营直达处理', 'direct_operations', 25 + ) seed + WHERE NOT EXISTS ( + SELECT 1 FROM `sys_role` existing + WHERE existing.tenant_id = '000000' + AND existing.role_key = seed.role_key + AND existing.del_flag = '0' + ) + AND NOT EXISTS ( + SELECT 1 FROM `sys_role` existing + WHERE existing.role_id = seed.role_id + ); diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 3deef1b3..ece6905a 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -82,6 +82,8 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_knowledge_category_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql ``` @@ -116,7 +118,8 @@ WHERE table_schema = DATABASE() 'aihr_memory_candidate', 'aihr_assistant_capture', 'aihr_assistant_capture_status_log', 'aihr_daily_work_result', 'aihr_service_memory', 'aihr_service_memory_version', 'aihr_work_report', 'aihr_broadcast_message', 'aihr_broadcast_read', 'aihr_broadcast_version', - 'aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient', + 'aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient', 'aihr_broadcast_attachment', + 'aihr_direct_feedback', 'aihr_personal_space', 'aihr_personal_item', 'aihr_personal_fragment', 'aihr_personal_chat_session', 'aihr_personal_chat_message', 'aihr_personal_cleanup_job', 'aihr_personal_ocr_job', 'aihr_personal_ocr_page', 'aihr_personal_export_task', @@ -144,7 +147,7 @@ WHERE table_schema = DATABASE() 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')) - OR (table_name = 'aihr_broadcast_message' AND column_name IN ('publish_request_key', 'publish_request_hash', 'withdraw_reason')) + OR (table_name = 'aihr_broadcast_message' AND column_name IN ('publish_request_key', 'publish_request_hash', 'withdraw_reason', 'attachment_id')) OR (table_name = 'aihr_memory_candidate' AND column_name IN ('work_date', 'source_snapshot_json')) OR (table_name = 'aihr_assistant_capture' AND column_name IN ('work_date', 'source_snapshot_json', 'business_status')) OR (table_name = 'aihr_knowledge_conversation' AND column_name IN ('project_code', 'broadcast_message_id')) @@ -206,7 +209,7 @@ GROUP BY d.dimension_code ORDER BY d.dimension_code; ``` -预期是三张场景/Rubric 表、岗位题库、训练反馈表与训练求助事件表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表和空间内分类表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表、平台组织目录与租户绑定表、四张记忆表、采集状态历史表、每日成果表、原工作上报表、三张大喇叭消息/阅读/v1快照表、两张 M1 定向规则/对象快照表及十张独立个人资料表均存在。`57/57` 只代表当时较窄的发布清单;后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,因此不得把包含该表的 M2 完整清单写成已发布。工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,M1 必读/目标载荷字段契约为 `2/2`,两张目标表字段契约为 `16/16`,定向规则与对象快照唯一约束分别为 `4/4`、`3/3`,公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。私有资料配置只应返回 `ruoyi-personal`、私有策略和 `configured` 状态,不显示访问密钥。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。 +本批完整发布的预期是 `release-preflight` 的 `64/64` 必需表与列契约均存在,新增项为 `aihr_direct_feedback`、`aihr_broadcast_attachment` 及 `aihr_broadcast_message.attachment_id`。此前 `62/62` 仍是发布前历史结果。结构、索引和内置内容通过只证明 schema/seed 与发布产物一致,不证明员工直达反馈、文件提炼或严格试点已经完成业务验收。 ## 迁移后应用回归 @@ -216,9 +219,11 @@ ORDER BY d.dimension_code; 4. 用正式测试账号在“工作助手”选择当前项目并说“帮我记一下三栋 3203 需要保洁服务”;确认前 `aihr_assistant_capture` 不得新增。分别验证 `PRIVATE` 仅本人可召回、`COMPANY` 只显示 `PENDING` 且没有虚假送达回执;新开对话按房号查询必须只命中对应项目记录,双击确认不得重复写入。测试表达如不需长期保留,应在确认卡选择“暂不保存”,不要直接删除生产审计记录。 5. 在“今日工作成果”按员工 + 项目 + 日期重复生成两次,内容不变时版本哈希必须一致;员工访问团队结果、主管访问非授权项目必须被拒绝。生产无主管正式测试账号时必须明确记录未验收,不以本地 seed 代替。 6. 用员工测试账号进入“成果投稿”,确认其仍只承载优秀案例、操作视频、完整 SOP 和有用知识,不回退为日常事实表单。重复提交同一 `request_key` 只有一条 `PENDING` 记录;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。 -7. 用 `superadmin` 或 `hr_operator` 在当前租户发布同一 `requestId` 两次:同内容必须只生成一条消息和一个 v1 快照,改动正文后必须返回 `409`。撤回时提交非空原因,员工端随即不可见;重复撤回不得改写首个撤回人、时间或原因。再确认管理列表不跨租户返回消息。 -8. 完整包发布后运行 `RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh`;必须同时获得线上资源、AIHR 模块和 schema 成功结果。该检查不替代第 7 项的认证态业务闭环。 -9. 迁移完成后再按同一正式时间窗运行: +7. 用员工账号分别向总裁、财务、人力、审计和运营频道提交;匿名反馈在处理端不得显示提交人,但员工本人仍可查看。分别用对应处理角色验证只能看到本频道且只能正式回复一次;`superadmin` 验证全频道兜底。 +8. 用 `superadmin` 或 `hr_operator` 上传一个受支持文件,等待状态变为 `READY` 后随消息发布;员工详情必须显示摘要并能鉴权下载原文件,“问这条消息”应引用服务端提取内容。再验证失败文件不能发布、非在职或跨租户账号不能下载、撤回后不能下载或继续追问。 +9. 用 `superadmin` 或 `hr_operator` 在当前租户发布同一 `requestId` 两次:同内容必须只生成一条消息和一个 v1 快照,改动正文后必须返回 `409`。撤回时提交非空原因,员工端随即不可见;重复撤回不得改写首个撤回人、时间或原因。再确认管理列表不跨租户返回消息。 +10. 完整包发布后运行 `RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh`;必须同时获得线上资源、AIHR 模块和 schema 成功结果。该检查不替代第 7—9 项的认证态业务闭环。 +11. 迁移完成后再按同一正式时间窗运行: ```bash AIHR_PILOT_TENANT_ID=000000 \ diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 37e54830..3a73ec0f 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -38,6 +38,8 @@ portless run --name wygj-api ./scripts/dev-backend.sh ``` +本地启动默认关闭管理端图形验证码;需要专门验证验证码链路时设置 `AIHR_DEV_CAPTCHA_ENABLED=true`。生产启动不使用该开发开关,验证码保持开启。 + 重复启动或切换分支时优先使用 `./scripts/dev.sh restart`,停止使用 `./scripts/dev.sh stop`。脚本会递归终止 portless 包装进程及其 Java/Vite 子进程,避免旧后端脱离后继续占用 MySQL 连接池;进程树回归可用 `bash scripts/tests/process-tree.test.sh` 验证。 也可以只让 portless 按根目录 `portless.json` 启动前端工程: diff --git a/frontend/src/api/aihr/broadcast.ts b/frontend/src/api/aihr/broadcast.ts index 06f2dcb5..1c36d18d 100644 --- a/frontend/src/api/aihr/broadcast.ts +++ b/frontend/src/api/aihr/broadcast.ts @@ -42,6 +42,17 @@ export type BroadcastAdminMessage = { unmatchedTargetCount: number; }; +export type BroadcastAttachment = { + id: number; + fileName: string; + fileSize: number; + contentType: string; + status: 'QUEUED' | 'PROCESSING' | 'READY' | 'FAILED'; + summary?: string; + errorMessage?: string; + downloadUrl?: string; +}; + export type BroadcastAdminPage = { /** Effective request tenant, including an administrator's dynamic-tenant selection. */ tenantId: string; @@ -58,6 +69,7 @@ export type PublishBroadcastRequest = { expectedTenantId: string; requiredRead: boolean; targets: BroadcastTargetRequest; + attachmentId?: number; }; export type WithdrawBroadcastRequest = { @@ -84,6 +96,26 @@ export function loadBroadcastTargetOptions(): Promise> { + const data = new FormData(); + data.append('file', file); + return request({ + url: '/api/aihr/broadcast/admin/attachments', + method: 'post', + params: { expectedTenantId }, + data, + timeout: 120000, + headers: { repeatSubmit: false } + }); +} + +export function getBroadcastAttachment(id: number): Promise> { + return request({ + url: `/api/aihr/broadcast/admin/attachments/${id}`, + method: 'get' + }); +} + export function publishBroadcast(data: PublishBroadcastRequest): Promise = { code: number; msg: string; data: T }; + +export type DirectFeedbackStatus = 'SUBMITTED' | 'REPLIED'; +export type DirectChannelCode = 'PRESIDENT' | 'FINANCE' | 'HR' | 'AUDIT' | 'OPERATIONS'; + +export type DirectAdminFeedback = { + id: number; + channelCode: DirectChannelCode; + channelName: string; + senderDisplayName: string; + anonymous: boolean; + content: string; + status: DirectFeedbackStatus; + replyContent?: string; + repliedAt?: string; + createdAt: string; +}; + +export type DirectAdminPage = { + tenantId: string; + total: number; + pageNum: number; + pageSize: number; + rows: DirectAdminFeedback[]; +}; + +export const listDirectAdminFeedback = (params: { + pageNum: number; + pageSize: number; + status?: DirectFeedbackStatus; + channelCode?: DirectChannelCode; +}): Promise> => + request({ + url: '/api/aihr/direct/admin/feedback', + method: 'get', + params + }); + +export const replyDirectFeedback = ( + id: number, + data: { content: string; expectedTenantId: string } +): Promise> => + request({ + url: `/api/aihr/direct/admin/feedback/${id}/reply`, + method: 'post', + data, + headers: { repeatSubmit: false } + }); diff --git a/frontend/src/router/access.ts b/frontend/src/router/access.ts index 4cc222c2..8daac7a0 100644 --- a/frontend/src/router/access.ts +++ b/frontend/src/router/access.ts @@ -1,4 +1,13 @@ export const operationsConsoleRoles = ['superadmin', 'hr_operator']; +export const directHandlerRoles = [ + 'superadmin', + 'direct_president', + 'direct_finance', + 'direct_hr', + 'direct_audit', + 'direct_operations' +]; +export const contentConsoleRoles = [...new Set([...operationsConsoleRoles, ...directHandlerRoles])]; export const tenantGovernanceRoles = ['superadmin']; type MatchedRoute = { meta?: { roles?: readonly string[] } }; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index d0c1ad3d..a9394b3c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,9 +1,9 @@ import { createWebHistory, createRouter, RouteRecordRaw } from 'vue-router'; /* Layout */ import Layout from '@/layout/index.vue'; -import { operationsConsoleRoles, tenantGovernanceRoles } from './access'; +import { contentConsoleRoles, directHandlerRoles, operationsConsoleRoles, tenantGovernanceRoles } from './access'; -export { operationsConsoleRoles, tenantGovernanceRoles } from './access'; +export { contentConsoleRoles, directHandlerRoles, operationsConsoleRoles, tenantGovernanceRoles } from './access'; /** * Note: 路由配置项 @@ -213,13 +213,19 @@ export const constantRoutes: RouteRecordRaw[] = [ component: Layout, redirect: '/content/broadcast', alwaysShow: true, - meta: { title: '内容运营', icon: 'message', roles: operationsConsoleRoles }, + meta: { title: '内容运营', icon: 'message', roles: contentConsoleRoles }, children: [ { path: 'broadcast', component: () => import('@/views/content/broadcast.vue'), name: 'ContentBroadcast', - meta: { title: '银城大喇叭', icon: 'message' } + meta: { title: '银城大喇叭', icon: 'message', roles: operationsConsoleRoles } + }, + { + path: 'direct', + component: () => import('@/views/content/direct.vue'), + name: 'ContentDirect', + meta: { title: '直达反馈', icon: 'message', roles: directHandlerRoles } } ] }, diff --git a/frontend/src/utils/broadcast-attachment-contract.test.ts b/frontend/src/utils/broadcast-attachment-contract.test.ts new file mode 100644 index 00000000..305a7421 --- /dev/null +++ b/frontend/src/utils/broadcast-attachment-contract.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); + +describe('公司文件提炼发布契约', () => { + it('上传后轮询提炼状态,并将同一附件ID纳入幂等发布', () => { + const api = read('src/api/aihr/broadcast.ts'); + const page = read('src/views/content/broadcast.vue'); + const pending = read('src/utils/broadcast-pending.ts'); + + expect(api).toContain('/api/aihr/broadcast/admin/attachments'); + expect(api).toContain('attachmentId?: number'); + expect(page).toContain('上传并提炼文件'); + expect(page).toContain('refreshAttachment'); + expect(page).toContain("attachment.value?.status !== 'READY'"); + expect(page).toContain('attachmentId: form.attachmentId'); + expect(pending).toContain('attachmentId?: number'); + }); +}); diff --git a/frontend/src/utils/broadcast-pending.ts b/frontend/src/utils/broadcast-pending.ts index c7a28a39..95543d97 100644 --- a/frontend/src/utils/broadcast-pending.ts +++ b/frontend/src/utils/broadcast-pending.ts @@ -9,6 +9,7 @@ export interface PendingBroadcastPublish { content: string; requiredRead: boolean; targets: BroadcastTargetRequest; + attachmentId?: number; } export interface BroadcastPendingStorageOptions { @@ -73,7 +74,10 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde title: candidate.title, content: candidate.content, requiredRead, - targets + targets, + attachmentId: Number.isSafeInteger(candidate.attachmentId) && Number(candidate.attachmentId) > 0 + ? Number(candidate.attachmentId) + : undefined }; } catch { return undefined; diff --git a/frontend/src/utils/direct-contract.test.ts b/frontend/src/utils/direct-contract.test.ts new file mode 100644 index 00000000..c98dcc45 --- /dev/null +++ b/frontend/src/utils/direct-contract.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); + +describe('直达反馈管理端契约', () => { + it('按授权角色进入,匿名不暴露身份,正式回复携带租户确认', () => { + const api = read('src/api/aihr/direct.ts'); + const page = read('src/views/content/direct.vue'); + const access = read('src/router/access.ts'); + const router = read('src/router/index.ts'); + + expect(api).toContain('/api/aihr/direct/admin/feedback'); + expect(api).toContain('expectedTenantId'); + expect(page).toContain('senderDisplayName'); + expect(page).toContain('匿名反馈不会显示提交人'); + expect(page).toContain('回复后不可覆盖'); + expect(access).toContain('direct_audit'); + expect(router).toContain("path: 'direct'"); + expect(router).toContain('roles: directHandlerRoles'); + }); +}); diff --git a/frontend/src/views/content/broadcast.vue b/frontend/src/views/content/broadcast.vue index 67cac4ef..de2e3efc 100644 --- a/frontend/src/views/content/broadcast.vue +++ b/frontend/src/views/content/broadcast.vue @@ -135,6 +135,47 @@ placeholder="请输入完整通知正文" /> + +
+ + 上传并提炼文件 + +
+
+ {{ attachment.fileName }} +

+ {{ attachment.status === 'READY' ? '提炼完成' : attachment.status === 'FAILED' ? '提炼失败' : '正在提炼…' }} + · {{ formatFileSize(attachment.fileSize) }} +

+
+ + {{ attachment.status }} + + 移除 +
+ + +

支持 TXT、Markdown、PDF、Word、Excel、PPT,最大 100MB;提炼完成后才能随消息发布。

+
+
设为必读(员工端会优先显示未读内容) @@ -200,13 +241,16 @@ diff --git a/mobile-uni/src/pages.json b/mobile-uni/src/pages.json index 5264d2ea..b2c0ce2c 100644 --- a/mobile-uni/src/pages.json +++ b/mobile-uni/src/pages.json @@ -37,6 +37,12 @@ "navigationBarTitleText": "消息详情" } }, + { + "path": "pages/user/direct/index", + "style": { + "navigationBarTitleText": "直通车" + } + }, { "path": "pages/user/assistant/index", "style": { diff --git a/mobile-uni/src/pages/user/broadcast/detail.vue b/mobile-uni/src/pages/user/broadcast/detail.vue index 1656ad4e..3727f303 100644 --- a/mobile-uni/src/pages/user/broadcast/detail.vue +++ b/mobile-uni/src/pages/user/broadcast/detail.vue @@ -18,6 +18,19 @@ 发布时间:{{ displayTime(item.publishedAt) }} {{ item.content }} + + + + 公司文件 + {{ item.attachment.fileName }} + + {{ formatFileSize(item.attachment.fileSize) }} + + {{ item.attachment.summary }} + + 将在“问”中围绕本条消息继续咨询 @@ -37,7 +50,8 @@ import { readBroadcastQuestionContext, saveBroadcastQuestionContext } from '@/services/broadcast'; -import { ApiError } from '@/services/api'; +import { ApiError, authenticatedFileUrl } from '@/services/api'; +import { isNativeAppRuntime } from '@/services/speech-capture'; import { isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { currentAccountKey } from '@/services/request-scope'; @@ -45,11 +59,45 @@ const id = ref(0); const item = ref(null); const loading = ref(false); const message = ref(''); +const downloading = ref(false); const goBack = () => uni.navigateBack({ delta: 1, fail: () => uni.switchTab({ url: '/pages/user/today/index' }) }); const displayTime = (value: string) => value ? value.replace('T', ' ').slice(0, 16) : ''; const isBroadcastMessageUnavailable = (error: unknown) => error instanceof ApiError && (error.statusCode === 404 || error.bizCode === 404); +const formatFileSize = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +}; + +const downloadAttachment = async () => { + if (!item.value?.attachment || downloading.value) return; + downloading.value = true; + try { + const localUrl = await authenticatedFileUrl(item.value.attachment.downloadUrl); + if (!isNativeAppRuntime() && typeof document !== 'undefined') { + const link = document.createElement('a'); + link.href = localUrl; + link.download = item.value.attachment.fileName; + link.rel = 'noreferrer'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(localUrl), 1000); + } else { + uni.openDocument({ + filePath: localUrl, + showMenu: true, + fail: () => uni.showToast({ title: '文件无法打开,请稍后重试', icon: 'none' }) + }); + } + } catch (error) { + uni.showToast({ title: error instanceof Error ? error.message : '文件读取失败', icon: 'none' }); + } finally { + downloading.value = false; + } +}; const askAboutMessage = () => { if (!item.value || !ensureLogin()) return; @@ -125,6 +173,14 @@ onLoad((options) => { .detail-tag.related { background: #fff7e8; color: #a76500; } .message-time { color: var(--muted); font-size: 12px; } .message-content { overflow-wrap: anywhere; color: var(--ink); font-size: 15px; line-height: 1.8; white-space: pre-wrap; } +.attachment-card { display: grid; gap: 10px; margin-top: 4px; padding: 14px; border: 1px solid #ead6d8; border-radius: 14px; background: #fffafb; } +.attachment-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.attachment-kicker { display: block; color: var(--brand); font-size: 11px; font-weight: 900; } +.attachment-name { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; font-weight: 900; } +.attachment-size { flex: none; color: var(--muted); font-size: 12px; } +.attachment-summary { overflow-wrap: anywhere; color: #596273; font-size: 13px; line-height: 1.65; } +.download-button { display: flex; min-height: 40px; align-items: center; justify-content: center; padding: 0; border: 1px solid #f0a6ac; border-radius: 12px; background: #fff; color: var(--brand); font-size: 13px; font-weight: 900; } +.download-button::after { border: 0; } .detail-actions { display: grid; gap: 8px; margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--employee-line, #ebedf0); } .ask-message-button { display: flex; width: 100%; min-height: 46px; align-items: center; justify-content: center; padding: 0; border-radius: 14px; background: var(--employee-primary, #e60012); color: #fff; font-size: 15px; font-weight: 800; line-height: 1; } .ask-message-note { color: var(--muted); font-size: 12px; line-height: 1.5; text-align: center; } diff --git a/mobile-uni/src/pages/user/broadcast/index.vue b/mobile-uni/src/pages/user/broadcast/index.vue index 1b6efe23..a30494f0 100644 --- a/mobile-uni/src/pages/user/broadcast/index.vue +++ b/mobile-uni/src/pages/user/broadcast/index.vue @@ -16,6 +16,14 @@