feat(aihr): complete company message R1-R3

This commit is contained in:
2026-07-24 23:25:15 +08:00
parent 5d864a1444
commit 342f84a9de
37 changed files with 2479 additions and 42 deletions
@@ -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<String> 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<BroadcastAttachmentResponse> 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<Long> 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<Long> 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<AttachmentWork> 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
) {
}
}
@@ -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<UnreadCountResponse> 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<BroadcastAttachmentResponse> 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<BroadcastAttachmentResponse> attachmentStatus(@PathVariable Long id) {
return R.ok(attachmentService.adminAttachment(id));
}
@GetMapping("/messages/{id}")
public R<BroadcastDetailResponse> 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<PublishResponse> publish(@Valid @RequestBody PublishRequest request) {
@@ -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);
}
}
@@ -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<BroadcastQuestionContext> 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)) {
@@ -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<List<ChannelItem>> channels() {
return R.ok(directService.channels());
}
@PostMapping("/feedback")
public R<FeedbackItem> submit(@Valid @RequestBody SubmitRequest request) {
return R.ok(directService.submit(request));
}
@GetMapping("/mine")
public R<FeedbackPage> mine(
@RequestParam(required = false) Integer pageNum,
@RequestParam(required = false) Integer pageSize
) {
return R.ok(directService.mine(pageNum, pageSize));
}
@GetMapping("/mine/{id}")
public R<FeedbackItem> mineDetail(@PathVariable Long id) {
return R.ok(directService.mineDetail(id));
}
@GetMapping("/admin/feedback")
public R<AdminFeedbackPage> 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<AdminFeedbackItem> reply(@PathVariable Long id, @Valid @RequestBody ReplyRequest request) {
return R.ok(directService.reply(id, request));
}
}
@@ -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<FeedbackItem> 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<AdminFeedbackItem> rows
) {
}
}
@@ -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<DirectChannel> 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<ChannelItem> 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<FeedbackItem> 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<DirectChannel> allowedChannels = allowedChannels(principal);
DirectChannel requestedChannel = optionalChannel(rawChannelCode);
if (requestedChannel != null && !allowedChannels.contains(requestedChannel)) {
throw forbidden();
}
List<String> 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<Object> parameters = new ArrayList<>(query.parameters());
parameters.add(page.pageSize());
parameters.add(page.offset());
List<AdminFeedbackItem> 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<String> 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<Replay> 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<FeedbackItem> 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<String> channelCodes) {
String placeholders = String.join(",", Collections.nCopies(channelCodes.size(), "?"));
List<Object> parameters = new ArrayList<>();
parameters.add(tenantId);
parameters.add(feedbackId);
parameters.addAll(channelCodes);
List<AdminFeedbackItem> 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<String> 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<DirectChannel> allowedChannels(AihrKnowledgePrincipal principal) {
if (UserType.APP_USER.getUserType().equals(principal.userType())) {
throw forbidden();
}
Set<String> roles = new LinkedHashSet<>(principal.roles());
if (roles.contains(TenantConstants.SUPER_ADMIN_ROLE_KEY)) {
return CHANNELS;
}
List<DirectChannel> allowed = CHANNELS.stream().filter(channel -> roles.contains(channel.role())).toList();
if (allowed.isEmpty()) {
throw forbidden();
}
return allowed;
}
private static Query adminQuery(String tenantId, List<String> channelCodes, String status) {
String placeholders = String.join(",", Collections.nCopies(channelCodes.size(), "?"));
String statusFilter = status == null ? "" : " and status = ?";
List<Object> 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<Object> parameters) {
}
private record Page(int pageNum, int pageSize, long offset) {
}
}
@@ -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
@@ -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());
@@ -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<String> 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()))"));
}
}
@@ -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<String> 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<String> roles) {
return new AihrKnowledgePrincipal("tenant-a", 7L, userType, "", roles, Set.of(), "client");
}
}
@@ -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),
@@ -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;
@@ -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
);
+11 -6
View File
@@ -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 \
+2
View File
@@ -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` 启动前端工程:
+32
View File
@@ -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<ApiResult<BroadcastTargetO
});
}
export function uploadBroadcastAttachment(file: File, expectedTenantId: string): Promise<ApiResult<BroadcastAttachment>> {
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<ApiResult<BroadcastAttachment>> {
return request({
url: `/api/aihr/broadcast/admin/attachments/${id}`,
method: 'get'
});
}
export function publishBroadcast(data: PublishBroadcastRequest): Promise<ApiResult<{
id: number;
version: number;
+50
View File
@@ -0,0 +1,50 @@
import request from '@/utils/request';
type ApiResult<T> = { 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<ApiResult<DirectAdminPage>> =>
request({
url: '/api/aihr/direct/admin/feedback',
method: 'get',
params
});
export const replyDirectFeedback = (
id: number,
data: { content: string; expectedTenantId: string }
): Promise<ApiResult<DirectAdminFeedback>> =>
request({
url: `/api/aihr/direct/admin/feedback/${id}/reply`,
method: 'post',
data,
headers: { repeatSubmit: false }
});
+9
View File
@@ -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[] } };
+10 -4
View File
@@ -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 }
}
]
},
@@ -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');
});
});
+5 -1
View File
@@ -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;
@@ -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');
});
});
+154 -4
View File
@@ -135,6 +135,47 @@
placeholder="请输入完整通知正文"
/>
</el-form-item>
<el-form-item label="公司文件(可选,仅 1 个)">
<div class="attachment-field">
<el-upload
v-if="!attachment"
:show-file-list="false"
:http-request="uploadAttachment"
accept=".txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
:disabled="publishing || publishPending || attachmentUploading"
>
<el-button :loading="attachmentUploading">上传并提炼文件</el-button>
</el-upload>
<div v-else class="attachment-state">
<div>
<strong>{{ attachment.fileName }}</strong>
<p>
{{ attachment.status === 'READY' ? '提炼完成' : attachment.status === 'FAILED' ? '提炼失败' : '正在提炼…' }}
· {{ formatFileSize(attachment.fileSize) }}
</p>
</div>
<el-tag :type="attachment.status === 'READY' ? 'success' : attachment.status === 'FAILED' ? 'danger' : 'warning'">
{{ attachment.status }}
</el-tag>
<el-button v-if="!publishPending" link type="danger" @click="clearAttachment">移除</el-button>
</div>
<el-alert
v-if="attachment?.summary"
type="success"
:closable="false"
show-icon
:title="`提炼摘要:${attachment.summary}`"
/>
<el-alert
v-if="attachment?.status === 'FAILED'"
type="error"
:closable="false"
show-icon
:title="attachment.errorMessage || '文件提炼失败,请移除后重新上传'"
/>
<p class="attachment-note">支持 TXT、Markdown、PDF、Word、Excel、PPT,最大 100MB;提炼完成后才能随消息发布。</p>
</div>
</el-form-item>
<el-form-item label="阅读要求">
<el-checkbox v-model="form.requiredRead" :disabled="publishing || publishPending">设为必读(员工端会优先显示未读内容)</el-checkbox>
</el-form-item>
@@ -200,13 +241,16 @@
<script setup name="ContentBroadcast" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { FormInstance } from 'element-plus';
import { onMounted, reactive, ref } from 'vue';
import type { FormInstance, UploadRequestOptions } from 'element-plus';
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import {
getBroadcastAttachment,
listBroadcastAdminMessages,
loadBroadcastTargetOptions,
publishBroadcast,
uploadBroadcastAttachment,
withdrawBroadcast,
type BroadcastAttachment,
type BroadcastAdminMessage,
type BroadcastTargetRequest,
type BroadcastTargetOptions,
@@ -233,10 +277,14 @@ const withdrawingId = ref<number>();
const publishFormRef = ref<FormInstance>();
const targetOptionsLoading = ref(false);
const targetOptions = ref<BroadcastTargetOptions>({ projects: [], positionNames: [], positionLevels: [] });
const attachment = ref<BroadcastAttachment>();
const attachmentUploading = ref(false);
let attachmentPollTimer: number | undefined;
const form = reactive({
requestId: '',
title: '',
content: '',
attachmentId: undefined as number | undefined,
requiredRead: false,
targets: { projectCodes: [] as string[], positionNames: [] as string[], positionLevels: [] as string[] }
});
@@ -276,7 +324,9 @@ const isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): can
/^[A-Za-z0-9._:-]{1,100}$/.test(candidate.requestId) &&
typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 &&
typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000 &&
typeof candidate.requiredRead === 'boolean' && isValidTargets(candidate.targets);
typeof candidate.requiredRead === 'boolean' &&
(candidate.attachmentId === undefined || (Number.isSafeInteger(candidate.attachmentId) && Number(candidate.attachmentId) > 0)) &&
isValidTargets(candidate.targets);
const pendingPublishUserId = () => String(userStore.userId || '').trim();
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
@@ -303,6 +353,7 @@ const persistPendingPublish = () => {
requestId: form.requestId,
title: form.title,
content: form.content,
attachmentId: form.attachmentId,
requiredRead: form.requiredRead,
targets: copyTargets(form.targets)
};
@@ -356,12 +407,14 @@ const applyPendingPublish = (candidate: PendingBroadcastPublish) => {
form.requestId = candidate.requestId;
form.title = candidate.title;
form.content = candidate.content;
form.attachmentId = candidate.attachmentId;
form.requiredRead = candidate.requiredRead;
form.targets.projectCodes = [...candidate.targets.projectCodes];
form.targets.positionNames = [...candidate.targets.positionNames];
form.targets.positionLevels = [...candidate.targets.positionLevels];
publishPending.value = true;
publishVisible.value = true;
if (candidate.attachmentId) void refreshAttachment(candidate.attachmentId);
};
const restorePendingPublish = () => {
@@ -420,6 +473,7 @@ const loadMessages = async (allowWhilePublishing = false) => {
form.requestId = '';
form.title = '';
form.content = '';
clearAttachment();
resetTargetForm();
}
scopeTenantId.value = resolvedTenantId;
@@ -461,6 +515,7 @@ const openPublish = () => {
draftTenantId.value = scopeTenantId.value;
form.title = '';
form.content = '';
clearAttachment();
resetTargetForm();
publishVisible.value = true;
};
@@ -472,6 +527,7 @@ const resetPublishForm = () => {
form.requestId = '';
form.title = '';
form.content = '';
clearAttachment();
resetTargetForm();
publishFormRef.value?.clearValidate();
return true;
@@ -522,6 +578,7 @@ const submitPublish = async () => {
form.requestId = '';
form.title = '';
form.content = '';
clearAttachment();
resetTargetForm();
draftTenantId.value = '';
return;
@@ -534,6 +591,10 @@ const submitPublish = async () => {
return;
}
if (!form.requestId) form.requestId = createRequestId();
if (form.attachmentId && attachment.value?.status !== 'READY') {
ElMessage.warning('公司文件仍在提炼中,完成后才能发布。');
return;
}
form.title = form.title.trim();
form.content = form.content.trim();
publishPending.value = true;
@@ -551,7 +612,8 @@ const submitPublish = async () => {
content: form.content,
expectedTenantId: pendingTenantId.value,
requiredRead: form.requiredRead,
targets: copyTargets(form.targets)
targets: copyTargets(form.targets),
attachmentId: form.attachmentId
});
if (!resetPublishForm()) {
ElMessage.warning('消息已发布,但浏览器无法清理重试记录;请不要新建消息,先恢复浏览器存储后重试原请求。');
@@ -606,9 +668,60 @@ const handleWithdraw = async (message: BroadcastAdminMessage) => {
}
};
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 stopAttachmentPolling = () => {
if (attachmentPollTimer !== undefined) window.clearTimeout(attachmentPollTimer);
attachmentPollTimer = undefined;
};
const refreshAttachment = async (id: number, attemptsLeft = 80) => {
stopAttachmentPolling();
try {
const response = await getBroadcastAttachment(id);
attachment.value = response.data;
form.attachmentId = response.data.id;
if ((response.data.status === 'QUEUED' || response.data.status === 'PROCESSING') && attemptsLeft > 0) {
attachmentPollTimer = window.setTimeout(() => void refreshAttachment(id, attemptsLeft - 1), 1500);
}
} catch {
attachment.value = undefined;
form.attachmentId = undefined;
ElMessage.error('公司文件状态读取失败,请重新上传。');
}
};
const uploadAttachment = async (options: UploadRequestOptions) => {
if (!scopeTenantId.value || attachmentUploading.value) return;
attachmentUploading.value = true;
try {
const response = await uploadBroadcastAttachment(options.file, scopeTenantId.value);
attachment.value = response.data;
form.attachmentId = response.data.id;
ElMessage.success('文件已上传,正在提炼内容');
void refreshAttachment(response.data.id);
} catch (error) {
options.onError(error instanceof Error ? error : new Error('文件上传失败'));
ElMessage.error('公司文件上传失败');
} finally {
attachmentUploading.value = false;
}
};
const clearAttachment = () => {
stopAttachmentPolling();
attachment.value = undefined;
form.attachmentId = undefined;
};
onMounted(() => {
void loadMessages();
});
onUnmounted(stopAttachmentPolling);
</script>
<style scoped lang="scss">
@@ -670,6 +783,43 @@ onMounted(() => {
gap: 10px;
}
.attachment-field {
display: grid;
width: 100%;
gap: 10px;
}
.attachment-state {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e4e7ec;
border-radius: 8px;
div {
flex: 1;
min-width: 0;
}
strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin: 4px 0 0;
}
}
.attachment-note {
margin: 0;
color: #858991;
font-size: 12px;
}
.table-card {
padding: 18px;
border-radius: 8px;
+196
View File
@@ -0,0 +1,196 @@
<template>
<div class="direct-admin-page">
<section class="page-head">
<div>
<h1>直达反馈</h1>
<p>按当前账号被授权的部门查看和回复员工点对点反馈;匿名反馈不会显示提交人。</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
</section>
<el-alert
type="info"
:closable="false"
show-icon
title="每条反馈只允许一条正式回复。回复后不可覆盖,避免处理口径被静默修改。"
/>
<section class="filter-panel panel">
<el-select v-model="channelCode" clearable placeholder="全部授权通道" @change="resetAndLoad">
<el-option v-for="item in channels" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="status" clearable placeholder="全部状态" @change="resetAndLoad">
<el-option label="待处理" value="SUBMITTED" />
<el-option label="已回复" value="REPLIED" />
</el-select>
</section>
<section class="panel table-panel">
<el-table v-loading="loading" :data="items" empty-text="暂无直达反馈" row-key="id">
<el-table-column prop="channelName" label="直达对象" width="120" />
<el-table-column prop="senderDisplayName" label="提交人" width="120">
<template #default="{ row }">
<el-tag :type="row.anonymous ? 'warning' : 'info'">{{ row.senderDisplayName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="反馈内容" min-width="320" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'REPLIED' ? 'success' : 'danger'">
{{ row.status === 'REPLIED' ? '已回复' : '待处理' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="170">
<template #default="{ row }">{{ displayTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openFeedback(row)">
{{ row.status === 'REPLIED' ? '查看' : '回复' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > pageSize"
class="pagination"
v-model:current-page="pageNum"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="load"
/>
</section>
<el-drawer v-model="drawerVisible" title="直达反馈详情" size="560px">
<template v-if="selected">
<div class="detail-meta">
<el-tag>{{ selected.channelName }}</el-tag>
<el-tag :type="selected.anonymous ? 'warning' : 'info'">{{ selected.senderDisplayName }}</el-tag>
<el-tag :type="selected.status === 'REPLIED' ? 'success' : 'danger'">
{{ selected.status === 'REPLIED' ? '已回复' : '待处理' }}
</el-tag>
</div>
<p class="detail-time">{{ displayTime(selected.createdAt) }}</p>
<section class="feedback-copy">{{ selected.content }}</section>
<template v-if="selected.status === 'REPLIED'">
<h3>正式回复</h3>
<section class="reply-copy">{{ selected.replyContent }}</section>
<p class="detail-time">回复于 {{ displayTime(selected.repliedAt) }}</p>
</template>
<template v-else>
<el-form label-position="top" class="reply-form">
<el-form-item label="正式回复">
<el-input
v-model="replyContent"
type="textarea"
:rows="7"
maxlength="5000"
show-word-limit
placeholder="请输入可直接回传给员工的正式答复"
/>
</el-form-item>
</el-form>
<el-button type="danger" :loading="replying" :disabled="!replyContent.trim()" @click="submitReply">
确认回复
</el-button>
</template>
</template>
</el-drawer>
</div>
</template>
<script setup name="ContentDirect" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { onMounted, ref } from 'vue';
import {
listDirectAdminFeedback,
replyDirectFeedback,
type DirectAdminFeedback,
type DirectChannelCode,
type DirectFeedbackStatus
} from '@/api/aihr/direct';
const channels: Array<{ value: DirectChannelCode; label: string }> = [
{ value: 'PRESIDENT', label: '总裁直达' },
{ value: 'FINANCE', label: '财务直达' },
{ value: 'HR', label: '人力直达' },
{ value: 'AUDIT', label: '审计直达' },
{ value: 'OPERATIONS', label: '运营直达' }
];
const items = ref<DirectAdminFeedback[]>([]);
const total = ref(0);
const pageNum = ref(1);
const pageSize = 20;
const tenantId = ref('');
const status = ref<DirectFeedbackStatus | ''>('');
const channelCode = ref<DirectChannelCode | ''>('');
const loading = ref(false);
const drawerVisible = ref(false);
const selected = ref<DirectAdminFeedback>();
const replyContent = ref('');
const replying = ref(false);
const displayTime = (value?: string) => value ? value.replace('T', ' ').slice(0, 16) : '—';
const load = async () => {
loading.value = true;
try {
const result = await listDirectAdminFeedback({
pageNum: pageNum.value,
pageSize,
status: status.value || undefined,
channelCode: channelCode.value || undefined
});
tenantId.value = result.data.tenantId;
items.value = result.data.rows || [];
total.value = result.data.total || 0;
} catch {
items.value = [];
total.value = 0;
ElMessage.error('直达反馈读取失败');
} finally {
loading.value = false;
}
};
const resetAndLoad = () => {
pageNum.value = 1;
void load();
};
const openFeedback = (item: DirectAdminFeedback) => {
selected.value = item;
replyContent.value = '';
drawerVisible.value = true;
};
const submitReply = async () => {
if (!selected.value || !replyContent.value.trim() || replying.value) return;
await ElMessageBox.confirm('回复后不能覆盖或修改,确认提交这条正式回复?', '确认回复', {
type: 'warning',
confirmButtonText: '确认回复',
cancelButtonText: '取消'
});
replying.value = true;
try {
const result = await replyDirectFeedback(selected.value.id, {
content: replyContent.value.trim(),
expectedTenantId: tenantId.value
});
selected.value = result.data;
ElMessage.success('正式回复已回传给员工');
await load();
} finally {
replying.value = false;
}
};
onMounted(load);
</script>
<style scoped>
.direct-admin-page{padding:24px;color:#172033}.page-head,.filter-panel,.detail-meta{display:flex;align-items:center;gap:12px}.page-head{justify-content:space-between;margin-bottom:16px}.page-head h1{margin:0;font-size:28px}.page-head p{margin:8px 0 0;color:#667085}.panel{border:1px solid #e4e7ec;border-radius:12px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}.filter-panel{margin-top:14px;padding:14px}.filter-panel .el-select{width:180px}.table-panel{margin-top:14px;padding:8px}.pagination{justify-content:flex-end;padding:14px}.detail-meta{flex-wrap:wrap}.detail-time{color:#667085;font-size:13px}.feedback-copy,.reply-copy{white-space:pre-wrap;overflow-wrap:anywhere;padding:16px;border-radius:10px;background:#f7f8fa;line-height:1.8}.reply-copy{border-left:3px solid #c91825;background:#fff7f7}.reply-form{margin-top:24px}@media(max-width:900px){.direct-admin-page{padding:16px}.page-head{align-items:flex-start}.filter-panel{flex-wrap:wrap}}
</style>
+6
View File
@@ -37,6 +37,12 @@
"navigationBarTitleText": "消息详情"
}
},
{
"path": "pages/user/direct/index",
"style": {
"navigationBarTitleText": "直通车"
}
},
{
"path": "pages/user/assistant/index",
"style": {
+57 -1
View File
@@ -18,6 +18,19 @@
</view>
<text class="message-time">发布时间:{{ displayTime(item.publishedAt) }}</text>
<text class="message-content">{{ item.content }}</text>
<view v-if="item.attachment" class="attachment-card">
<view class="attachment-head">
<view>
<text class="attachment-kicker">公司文件</text>
<text class="attachment-name">{{ item.attachment.fileName }}</text>
</view>
<text class="attachment-size">{{ formatFileSize(item.attachment.fileSize) }}</text>
</view>
<text v-if="item.attachment.summary" class="attachment-summary">{{ item.attachment.summary }}</text>
<button class="download-button" :disabled="downloading" @click="downloadAttachment">
{{ downloading ? '正在读取…' : '查看原文件' }}
</button>
</view>
<view class="detail-actions">
<button class="ask-message-button" @click="askAboutMessage">问这条消息</button>
<text class="ask-message-note">将在“问”中围绕本条消息继续咨询</text>
@@ -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<BroadcastMessage | null>(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; }
@@ -16,6 +16,14 @@
</view>
<template v-else>
<button class="direct-entry employee-card" @click="openDirect">
<view>
<text class="direct-kicker">直通车</text>
<text class="direct-title">直接反馈给高管、财务、人力、审计或运营</text>
<text class="direct-note">支持业务匿名,处理结果仅回到你的账号。</text>
</view>
<uni-icons type="right" size="18" color="#c91825" />
</button>
<view class="scope-tabs" role="tablist" aria-label="消息范围筛选">
<button
v-for="option in scopeOptions"
@@ -164,6 +172,7 @@ const loadMore = async () => {
};
const openMessage = (id: number) => uni.navigateTo({ url: `/pages/user/broadcast/detail?id=${id}` });
const openDirect = () => uni.navigateTo({ url: '/pages/user/direct/index' });
const changeScope = (nextScope: BroadcastMessageScope) => {
if (scope.value === nextScope || loading.value || loadingMore.value) return;
@@ -190,6 +199,11 @@ onUnload(() => {
<style scoped>
.broadcast-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: calc(28px + env(safe-area-inset-bottom)); }
.login-panel, .loading-card, .empty-card { display: grid; gap: 10px; margin-top: 18px; padding: 20px; }
.direct-entry { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 14px; margin-top: 18px; padding: 16px; border: 1px solid #f4cbd0; background: linear-gradient(135deg, #fff, #fff5f6); color: var(--ink); text-align: left; }
.direct-entry::after { border: 0; }
.direct-kicker { display: block; color: var(--brand); font-size: 12px; font-weight: 900; }
.direct-title { display: block; margin-top: 5px; font-size: 16px; font-weight: 900; line-height: 1.45; }
.direct-note { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; line-height: 1.5; }
.status-message { display: block; margin-top: 16px; padding: 12px; border-radius: 12px; background: #fff0f1; color: #a9000d; }
.empty-title { font-size: 17px; font-weight: 900; }
.message-list { display: grid; gap: 12px; margin-top: 18px; }
+252
View File
@@ -0,0 +1,252 @@
<template>
<view class="page employee-page-shell direct-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回公司消息" @click="goBack">
<uni-icons type="back" size="20" color="#25282d" />
</button>
<view class="subpage-heading">
<text class="employee-kicker">点对点反馈</text>
<text class="page-title">直通车</text>
<text class="page-subtitle">选择对象后直接提交,处理结果会回到“我的反馈”。</text>
</view>
</view>
<view v-if="!loggedIn" class="panel employee-card login-panel">
<text class="section-title">登录后使用</text>
<text class="muted">直达反馈只接受当前在职员工提交。</text>
<button class="primary-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel employee-card submit-card">
<view class="section-head">
<view>
<text class="section-title">选择直达对象</text>
<text class="section-note">每次只提交给一个处理部门。</text>
</view>
</view>
<view class="channel-grid" role="radiogroup" aria-label="直达对象">
<button
v-for="channel in channels"
:key="channel.code"
class="channel-button"
:class="{ active: form.channelCode === channel.code }"
role="radio"
:aria-checked="form.channelCode === channel.code"
@click="chooseChannel(channel)"
>
<text>{{ channel.name }}</text>
<text v-if="channel.defaultAnonymous" class="channel-hint">默认匿名</text>
</button>
</view>
<label class="field-label" for="direct-content">反馈内容</label>
<textarea
id="direct-content"
v-model="form.content"
class="feedback-input"
maxlength="5000"
placeholder="请说明具体情况、影响和希望得到的处理。"
aria-label="反馈内容"
/>
<view class="form-foot">
<label class="anonymous-control">
<switch
:checked="form.anonymous"
color="#c91825"
aria-label="匿名提交"
@change="form.anonymous = Boolean($event.detail.value)"
/>
<view>
<text class="anonymous-title">匿名提交</text>
<text class="section-note">处理端不显示提交人;系统内部仍保留审计记录。</text>
</view>
</label>
<text class="counter">{{ form.content.length }}/5000</text>
</view>
<text v-if="submitMessage" class="status-message" :class="{ error: submitFailed }" role="status" aria-live="polite">
{{ submitMessage }}
</text>
<button class="primary-button submit-button" :disabled="submitting || !canSubmit" @click="submit">
{{ submitting ? '提交中…' : '确认提交' }}
</button>
</view>
<view class="history-head">
<view>
<text class="section-title">我的反馈</text>
<text class="section-note">只有你本人可以查看提交内容和正式回复。</text>
</view>
<button class="refresh-button" :disabled="loading" @click="loadMine(true)">刷新</button>
</view>
<view v-if="loading" class="panel employee-card loading-card" role="status">反馈加载中…</view>
<view v-else-if="loadFailed" class="panel employee-card empty-card" role="alert">
<text class="section-title">反馈加载失败</text>
<button class="secondary-button" @click="loadMine(true)">重新加载</button>
</view>
<view v-else-if="!items.length" class="panel employee-card empty-card">
<text class="section-title">还没有反馈</text>
<text class="muted">提交后会在这里显示处理状态。</text>
</view>
<view v-else class="feedback-list">
<view v-for="item in items" :key="item.id" class="panel employee-card feedback-card">
<view class="feedback-head">
<view class="feedback-tags">
<text class="channel-tag">{{ item.channelName }}</text>
<text v-if="item.anonymous" class="anonymous-tag">匿名</text>
</view>
<text class="status-tag" :class="{ replied: item.status === 'REPLIED' }">
{{ item.status === 'REPLIED' ? '已回复' : '待处理' }}
</text>
</view>
<text class="feedback-content">{{ item.content }}</text>
<view v-if="item.replyContent" class="reply-box">
<text class="reply-label">正式回复</text>
<text class="reply-content">{{ item.replyContent }}</text>
</view>
<text class="feedback-time">{{ displayTime(item.createdAt) }}</text>
</view>
<button v-if="hasMore" class="secondary-button load-more" :disabled="loadingMore" @click="loadMine(false)">
{{ loadingMore ? '加载中…' : '加载更多' }}
</button>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import {
listDirectChannels,
listMyDirectFeedback,
submitDirectFeedback,
type DirectChannel,
type DirectChannelCode,
type DirectFeedback
} from '@/services/direct';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { createPageRequestScope } from '@/services/request-scope';
const path = '/pages/user/direct/index';
const pageSize = 20;
const loggedIn = ref(false);
const channels = ref<DirectChannel[]>([]);
const items = ref<DirectFeedback[]>([]);
const total = ref(0);
const pageNum = ref(1);
const loading = ref(false);
const loadingMore = ref(false);
const loadFailed = ref(false);
const submitting = ref(false);
const submitFailed = ref(false);
const submitMessage = ref('');
const requests = createPageRequestScope();
const form = reactive({
requestId: '',
channelCode: '' as DirectChannelCode | '',
anonymous: false,
content: ''
});
const hasMore = computed(() => items.value.length < total.value);
const canSubmit = computed(() => Boolean(form.channelCode) && Boolean(form.content.trim()));
const createRequestId = () =>
globalThis.crypto?.randomUUID?.() ?? `direct-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
const goBack = () => uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: '/pages/user/broadcast/index' }) });
const goLogin = () => {
rememberLoginRedirect(path);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const displayTime = (value?: string) => value ? value.replace('T', ' ').slice(0, 16) : '';
const chooseChannel = (channel: DirectChannel) => {
form.channelCode = channel.code;
form.anonymous = channel.defaultAnonymous;
};
const loadChannels = async () => {
channels.value = await listDirectChannels();
if (!form.channelCode && channels.value.length) chooseChannel(channels.value[0]);
};
const loadMine = async (reset: boolean) => {
if (loading.value || loadingMore.value) return;
const nextPage = reset ? 1 : pageNum.value + 1;
const request = requests.begin('direct-mine');
reset ? loading.value = true : loadingMore.value = true;
if (reset) loadFailed.value = false;
try {
const result = await listMyDirectFeedback(nextPage, pageSize);
if (!requests.isCurrent(request)) return;
const rows = Array.isArray(result.rows) ? result.rows : [];
if (reset) {
items.value = rows;
} else {
const existing = new Set(items.value.map((item) => item.id));
items.value = [...items.value, ...rows.filter((item) => !existing.has(item.id))];
}
total.value = Math.max(items.value.length, Number(result.total) || 0);
pageNum.value = nextPage;
} catch {
if (requests.isCurrent(request) && reset) loadFailed.value = true;
} finally {
if (requests.isCurrent(request)) {
loading.value = false;
loadingMore.value = false;
}
}
};
const submit = async () => {
if (!canSubmit.value || submitting.value || !form.channelCode) return;
submitting.value = true;
submitFailed.value = false;
submitMessage.value = '';
if (!form.requestId) form.requestId = createRequestId();
try {
await submitDirectFeedback({
requestId: form.requestId,
channelCode: form.channelCode,
anonymous: form.anonymous,
content: form.content.trim()
});
form.requestId = '';
form.content = '';
submitMessage.value = '反馈已提交,可在“我的反馈”查看处理结果。';
await loadMine(true);
} catch (error) {
submitFailed.value = true;
submitMessage.value = error instanceof Error ? error.message : '提交失败,请使用同一页面重试。';
} finally {
submitting.value = false;
}
};
onShow(async () => {
requests.activate();
resetPageScroll();
loggedIn.value = isLoggedIn();
if (!loggedIn.value) return;
try {
await Promise.all([loadChannels(), loadMine(true)]);
} catch {
loadFailed.value = true;
}
});
onHide(() => requests.invalidate());
onUnload(() => requests.invalidate());
</script>
<style scoped>
.direct-page{max-width:var(--mobile-shell-max-width);margin:0 auto;padding-bottom:calc(32px + env(safe-area-inset-bottom))}
.login-panel,.submit-card,.loading-card,.empty-card{display:grid;gap:12px;margin-top:18px;padding:18px}
.section-head,.history-head,.feedback-head,.form-foot,.anonymous-control{display:flex;align-items:center;justify-content:space-between;gap:12px}
.section-title{display:block;color:var(--ink);font-size:18px;font-weight:900}.section-note,.muted{display:block;margin-top:4px;color:var(--muted);font-size:12px;line-height:1.6}
.channel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.channel-button{display:grid;min-height:60px;align-content:center;gap:3px;padding:10px;border:1px solid #e6e8ec;border-radius:14px;background:#fff;color:var(--ink);font-size:14px;font-weight:900}.channel-button.active{border-color:var(--brand);background:#fff5f6;color:var(--brand)}.channel-button::after,.refresh-button::after{border:0}.channel-hint{font-size:11px;font-weight:700;opacity:.72}
.field-label{margin-top:4px;color:var(--ink);font-size:14px;font-weight:900}.feedback-input{box-sizing:border-box;width:100%;height:150px;padding:14px;border:1px solid #dfe2e7;border-radius:14px;background:#fafafa;color:var(--ink);font-size:15px;line-height:1.6}
.form-foot{align-items:flex-start}.anonymous-control{justify-content:flex-start;flex:1}.anonymous-title{display:block;font-size:14px;font-weight:900}.counter{color:var(--muted);font-size:12px}.submit-button{margin-top:2px}.status-message{padding:10px 12px;border-radius:10px;background:#edf8f2;color:#147a45;font-size:13px}.status-message.error{background:#fff0f1;color:#a9000d}
.history-head{margin-top:28px}.refresh-button{padding:7px 12px;border-radius:999px;background:#fff0f1;color:var(--brand);font-size:13px;font-weight:800}.feedback-list{display:grid;gap:12px;margin-top:12px}.feedback-card{display:grid;gap:11px;padding:16px}.feedback-tags{display:flex;flex-wrap:wrap;gap:6px}.channel-tag,.anonymous-tag,.status-tag{padding:4px 8px;border-radius:999px;font-size:11px;font-weight:900}.channel-tag{background:#f2f3f5;color:#343b46}.anonymous-tag{background:#fff7e8;color:#925b00}.status-tag{background:#fff0f1;color:var(--brand)}.status-tag.replied{background:#edf8f2;color:#147a45}.feedback-content,.reply-content{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);font-size:15px;line-height:1.7}.reply-box{display:grid;gap:6px;padding:12px;border-left:3px solid var(--brand);border-radius:4px 10px 10px 4px;background:#faf6f6}.reply-label{color:var(--brand);font-size:12px;font-weight:900}.feedback-time{color:var(--muted);font-size:12px}.load-more{width:100%}
</style>
+11
View File
@@ -23,6 +23,17 @@ export interface BroadcastMessageSummary {
export interface BroadcastMessage extends BroadcastMessageSummary {
content: string;
attachment?: BroadcastAttachment;
}
export interface BroadcastAttachment {
id: number;
fileName: string;
fileSize: number;
contentType: string;
status: 'READY';
summary?: string;
downloadUrl: string;
}
export interface BroadcastMessagePage {
+56
View File
@@ -0,0 +1,56 @@
import { apiRequest } from './api';
export type DirectChannelCode = 'PRESIDENT' | 'FINANCE' | 'HR' | 'AUDIT' | 'OPERATIONS';
export type DirectFeedbackStatus = 'SUBMITTED' | 'REPLIED';
export interface DirectChannel {
code: DirectChannelCode;
name: string;
defaultAnonymous: boolean;
}
export interface DirectFeedback {
id: number;
channelCode: DirectChannelCode;
channelName: string;
anonymous: boolean;
content: string;
status: DirectFeedbackStatus;
replyContent?: string;
repliedAt?: string;
createdAt: string;
}
export interface DirectFeedbackPage {
total: number;
pageNum: number;
pageSize: number;
rows: DirectFeedback[];
}
export const listDirectChannels = () =>
apiRequest<DirectChannel[]>({
url: '/api/aihr/direct/channels',
method: 'GET',
timeout: 15000
});
export const submitDirectFeedback = (data: {
requestId: string;
channelCode: DirectChannelCode;
anonymous: boolean;
content: string;
}) =>
apiRequest<DirectFeedback>({
url: '/api/aihr/direct/feedback',
method: 'POST',
data,
timeout: 15000
});
export const listMyDirectFeedback = (pageNum = 1, pageSize = 20) =>
apiRequest<DirectFeedbackPage>({
url: `/api/aihr/direct/mine?pageNum=${pageNum}&pageSize=${pageSize}`,
method: 'GET',
timeout: 15000
});
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('公司文件在员工端展示摘要、受控原文件入口并继续复用消息ID追问', async () => {
const [service, detail] = await Promise.all([
read('../src/services/broadcast.ts'),
read('../src/pages/user/broadcast/detail.vue')
]);
assert.match(service, /interface BroadcastAttachment/);
assert.match(service, /downloadUrl: string/);
assert.match(detail, /公司文件/);
assert.match(detail, /attachment\.summary/);
assert.match(detail, /authenticatedFileUrl/);
assert.match(detail, /saveBroadcastQuestionContext\(item\.value\.id/);
});
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('员工端直通车包含固定五通道、业务匿名和本人回复闭环', async () => {
const [service, page, broadcast, pages] = await Promise.all([
read('../src/services/direct.ts'),
read('../src/pages/user/direct/index.vue'),
read('../src/pages/user/broadcast/index.vue'),
read('../src/pages.json')
]);
assert.match(service, /'PRESIDENT' \| 'FINANCE' \| 'HR' \| 'AUDIT' \| 'OPERATIONS'/);
assert.ok(service.includes('/api/aihr/direct/feedback'));
assert.ok(service.includes('/api/aihr/direct/mine'));
assert.match(page, /选择直达对象/);
assert.match(page, /匿名提交/);
assert.match(page, /正式回复/);
assert.match(page, /requestId/);
assert.match(broadcast, /openDirect/);
assert.ok(pages.includes('pages/user/direct/index'));
});
+12 -2
View File
@@ -378,8 +378,12 @@ tenant_payload="$(curl -k -fsS "$FRONTEND_URL/$FRONTEND_API_PREFIX/auth/tenant/l
echo "OK: tenant list"
code_payload="$(curl -k -fsS "$FRONTEND_URL/$FRONTEND_API_PREFIX/auth/code")"
[[ "$code_payload" == *'"uuid"'* ]] || fail "captcha response missing uuid"
echo "OK: captcha"
if [[ "$code_payload" == *'"captchaEnabled":false'* ]]; then
echo "OK: captcha disabled for development"
else
[[ "$code_payload" == *'"uuid"'* ]] || fail "captcha response missing uuid"
echo "OK: captcha"
fi
contains frontend/src/router/index.ts "/recruit/interview"
contains frontend/src/router/index.ts "/train/practice"
@@ -613,6 +617,12 @@ contains backend/script/sql/update/aihr_20260715_candidate_interview_review_mysq
contains backend/script/sql/update/aihr_20260715_candidate_interview_review_mysql8.sql "aihr_interview_result"
contains backend/script/sql/update/aihr_20260715_candidate_interview_review_mysql8.sql "aihr_candidate_employee_link"
contains scripts/tests/aihr-schema-migrations.test.sh "AIHR legacy schema migrations are idempotent"
contains backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql "aihr_direct_feedback"
contains backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql "aihr_broadcast_attachment"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java '"/api/aihr/direct"'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java '"/attachments"'
contains mobile-uni/src/pages/user/direct/index.vue "选择直达对象"
contains frontend/src/views/content/direct.vue "直通车处理台"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "hireDate"
contains frontend/src/api/aihr/practice.ts '/api/train/practice/scenarios/${id}/enabled'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/scenarios/{id}/enabled"
+1
View File
@@ -45,6 +45,7 @@ fi
exec java -jar "$JAR" \
"--server.port=$BACKEND_PORT" \
"--captcha.enable=${AIHR_DEV_CAPTCHA_ENABLED:-false}" \
"--spring.datasource.dynamic.datasource.master.url=jdbc:mysql://localhost:13306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true" \
"--spring.data.redis.port=16379" \
"--spring.boot.admin.client.enabled=false" \
+75 -1
View File
@@ -170,6 +170,8 @@ FROM (
UNION ALL SELECT 'aihr_broadcast_version'
UNION ALL SELECT 'aihr_broadcast_target_rule'
UNION ALL SELECT 'aihr_broadcast_target_recipient'
UNION ALL SELECT 'aihr_broadcast_attachment'
UNION ALL SELECT 'aihr_direct_feedback'
UNION ALL SELECT 'aihr_personal_space'
UNION ALL SELECT 'aihr_personal_item'
UNION ALL SELECT 'aihr_personal_fragment'
@@ -472,6 +474,75 @@ REMOTE
[[ "$broadcast_target_recipient_unique_index" = "0|tenant_id,message_id,recipient_key|0" ]] \
|| fail "remote broadcast target-recipient unique index invalid: expected unique full columns tenant_id,message_id,recipient_key; got ${broadcast_target_recipient_unique_index:-missing}"
local direct_feedback_contract
direct_feedback_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT CONCAT(
COUNT(DISTINCT c.column_name), '|',
MIN(s.non_unique), '|',
COALESCE(GROUP_CONCAT(DISTINCT s.column_name ORDER BY s.seq_in_index), ''), '|',
SUM(s.sub_part IS NOT NULL)
)
FROM information_schema.columns c
LEFT JOIN information_schema.statistics s
ON s.table_schema = c.table_schema
AND s.table_name = c.table_name
AND s.index_name = 'uk_aihr_direct_feedback_submit'
WHERE c.table_schema = DATABASE()
AND c.table_name = 'aihr_direct_feedback'
AND c.column_name IN ('tenant_id','channel_code','sender_user_id','anonymous_flag','content','status','submit_request_key','submit_request_hash','reply_content','replied_by','replied_time');
SQL
REMOTE
)" || fail "remote direct-feedback schema check failed: $remote_ssh:$remote_db"
[[ "$direct_feedback_contract" = "11|0|tenant_id,sender_user_id,submit_request_key|0" ]] \
|| fail "remote direct-feedback contract invalid: expected 11 business columns and unique tenant_id,sender_user_id,submit_request_key; got ${direct_feedback_contract:-missing}"
local direct_role_count
direct_role_count="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT COUNT(DISTINCT role_key)
FROM sys_role
WHERE tenant_id = '000000'
AND del_flag = '0'
AND role_key IN ('direct_president','direct_finance','direct_hr','direct_audit','direct_operations');
SQL
REMOTE
)" || fail "remote direct-feedback role check failed: $remote_ssh:$remote_db"
[[ "$direct_role_count" = "5" ]] \
|| fail "remote direct-feedback roles incomplete: expected 5/5; got ${direct_role_count:-0}/5"
local broadcast_attachment_contract
broadcast_attachment_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT CONCAT(
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'aihr_broadcast_attachment'
AND column_name IN ('tenant_id','message_id','oss_id','file_name','file_size','content_type','status','extracted_text','summary','error_message','uploaded_by')),
'|',
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'aihr_broadcast_message'
AND column_name = 'attachment_id'),
'|',
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'aihr_broadcast_message'
AND index_name = 'idx_aihr_broadcast_message_attachment'), '')
);
SQL
REMOTE
)" || fail "remote broadcast-attachment schema check failed: $remote_ssh:$remote_db"
[[ "$broadcast_attachment_contract" = "11|1|tenant_id,attachment_id" ]] \
|| fail "remote broadcast-attachment contract invalid: expected 11 attachment columns and indexed message attachment_id; got ${broadcast_attachment_contract:-missing}"
local missing_work_assistant_columns
missing_work_assistant_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
@@ -500,7 +571,7 @@ REMOTE
[[ -z "$missing_work_assistant_columns" ]] \
|| fail "remote work-assistant schema missing required columns: $(printf '%s' "$missing_work_assistant_columns" | tr '\n' ', ' | sed 's/, $//')"
echo "remote_schema=62/62 $remote_ssh:$remote_db"
echo "remote_schema=64/64 $remote_ssh:$remote_db"
echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db"
echo "remote_knowledge_category_columns=7/7 $remote_ssh:$remote_db"
echo "remote_broadcast_publish_audit_columns=3/3 $remote_ssh:$remote_db"
@@ -511,6 +582,9 @@ REMOTE
echo "remote_broadcast_target_table_contract=16/16 $remote_ssh:$remote_db"
echo "remote_broadcast_target_rule_uniqueness=4/4 $remote_ssh:$remote_db"
echo "remote_broadcast_target_recipient_uniqueness=3/3 $remote_ssh:$remote_db"
echo "remote_direct_feedback_contract=11/11 $remote_ssh:$remote_db"
echo "remote_direct_feedback_roles=5/5 $remote_ssh:$remote_db"
echo "remote_broadcast_attachment_contract=12/12 $remote_ssh:$remote_db"
echo "remote_broadcast_question_context=1/1 $remote_ssh:$remote_db"
echo "remote_work_assistant_columns=8/8 $remote_ssh:$remote_db"
echo "remote_personal_oss_configuration=true $remote_ssh:$remote_db"
@@ -346,6 +346,48 @@ test "$broadcast_target_rule_unique_index" = '0|tenant_id,message_id,target_type
broadcast_target_recipient_unique_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_target_recipient' AND index_name='uk_aihr_broadcast_target_recipient';")"
test "$broadcast_target_recipient_unique_index" = '0|tenant_id,message_id,recipient_key|0'
direct_feedback_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql"
test -f "$direct_feedback_migration"
mysql "$DB" <<'SQL'
CREATE TABLE sys_role (
role_id bigint NOT NULL,
tenant_id varchar(20) NOT NULL,
role_name varchar(30) NOT NULL,
role_key varchar(100) NOT NULL,
role_sort int NOT NULL,
data_scope char(1) NOT NULL,
menu_check_strictly tinyint NOT NULL,
dept_check_strictly tinyint NOT NULL,
status char(1) NOT NULL,
del_flag char(1) NOT NULL,
create_by bigint DEFAULT NULL,
create_time datetime DEFAULT NULL,
remark varchar(500) DEFAULT NULL,
PRIMARY KEY (role_id)
) ENGINE=InnoDB;
SQL
mysql "$DB" < "$direct_feedback_migration"
mysql "$DB" < "$direct_feedback_migration"
direct_feedback_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_direct_feedback' AND column_name IN ('tenant_id','channel_code','sender_user_id','anonymous_flag','content','status','submit_request_key','submit_request_hash','reply_content','replied_by','replied_time');")"
test "$direct_feedback_columns" = '11'
direct_feedback_submit_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_direct_feedback' AND index_name='uk_aihr_direct_feedback_submit';")"
test "$direct_feedback_submit_index" = '0|tenant_id,sender_user_id,submit_request_key|0'
direct_feedback_roles="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM sys_role WHERE tenant_id='000000' AND role_key IN ('direct_president','direct_finance','direct_hr','direct_audit','direct_operations') AND status='0' AND del_flag='0';")"
test "$direct_feedback_roles" = '5'
broadcast_attachment_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql"
test -f "$broadcast_attachment_migration"
mysql "$DB" < "$broadcast_attachment_migration"
mysql "$DB" < "$broadcast_attachment_migration"
broadcast_attachment_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_broadcast_attachment' AND column_name IN ('tenant_id','message_id','oss_id','file_name','file_size','content_type','status','extracted_text','summary','error_message','uploaded_by');")"
test "$broadcast_attachment_columns" = '11'
broadcast_attachment_message_column="$(mysql "$DB" -N -B -e "SELECT CONCAT(column_type,'|',is_nullable) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_broadcast_message' AND column_name='attachment_id';")"
test "$broadcast_attachment_message_column" = 'bigint|YES'
broadcast_attachment_message_index="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_message' AND index_name='idx_aihr_broadcast_message_attachment';")"
test "$broadcast_attachment_message_index" = 'tenant_id,attachment_id'
broadcast_attachment_bind_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_attachment' AND index_name='uk_aihr_broadcast_attachment_message';")"
test "$broadcast_attachment_bind_index" = '0|tenant_id,message_id|0'
mysql "$DB" -e "ALTER TABLE aihr_broadcast_message DROP INDEX uk_aihr_broadcast_message_publish_request, DROP COLUMN publish_request_hash, DROP COLUMN publish_request_key, DROP COLUMN withdraw_reason;"
mysql "$DB" <<'SQL'
INSERT INTO aihr_broadcast_message (