merge: integrate broadcast question correction
# Conflicts: # docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md
This commit is contained in:
+326
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+35
@@ -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) {
|
||||
|
||||
+29
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+83
-13
@@ -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)) {
|
||||
|
||||
+69
@@ -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));
|
||||
}
|
||||
}
|
||||
+85
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+444
@@ -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) {
|
||||
}
|
||||
}
|
||||
+16
-6
@@ -112,16 +112,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
|
||||
|
||||
+8
@@ -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());
|
||||
|
||||
+54
@@ -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()))"));
|
||||
}
|
||||
}
|
||||
+85
@@ -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");
|
||||
}
|
||||
}
|
||||
+34
@@ -683,6 +683,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
|
||||
);
|
||||
@@ -84,6 +84,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/update/aihr_20260724_agent_orchestration_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
|
||||
@@ -119,7 +121,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',
|
||||
@@ -147,7 +150,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'))
|
||||
@@ -210,7 +213,7 @@ GROUP BY d.dimension_code
|
||||
ORDER BY d.dimension_code;
|
||||
```
|
||||
|
||||
预期是三张场景/Rubric 表、岗位题库、训练反馈表与训练求助事件表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表和空间内分类表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表、平台组织目录与租户绑定表、四张记忆表、采集状态历史表、每日成果表、原工作上报表、三张大喇叭消息/阅读/v1快照表、两张 M1 定向规则/对象快照表、`aihr_agent_run` 及十张独立个人资料表均存在。`57/57` 只代表当时较窄的发布清单;后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,Agent 迁移也仍是本地发布候选,因此不得把包含它们的完整清单写成已发布。Agent 审计字段应为 `14/14`,且表中不得出现问题、答案或附件列。工作上报幂等字段为 `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` 和 `aihr_agent_run`。此前 `57/57`、`62/62` 都只代表较窄的历史发布清单。后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,Agent 迁移也仍是本地发布候选,因此不得把完整清单写成已发布。Agent 审计字段应为 `14/14`,且表中不得出现问题、答案或附件列。工作上报幂等字段为 `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 与发布产物一致,不证明员工直达反馈、文件提炼、员工确认记忆、完整个人知识库或严格试点已经完成业务验收。
|
||||
|
||||
## 迁移后应用回归
|
||||
|
||||
@@ -220,10 +223,12 @@ 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. 用正式移动端员工账号验证数字师傅 Agent:寒暄不得调用 RAG;本人待办/训练显示实时来源;普通图片不要求知识空间;现场制度问题只在授权空间补充 SOP;全网查询必须先返回同意卡;“记一下”确认前不落库。再检查 `aihr_agent_run` 只有主体、意图、工具、状态、来源和耗时等元数据,没有问题、答案或附件。生产无对应角色、媒体或撤权样本时必须记录未验收。
|
||||
9. 完整包发布后运行 `RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh`;必须同时获得线上资源、AIHR 模块和 schema 成功结果。该检查不替代第 7、8 项的认证态业务闭环。
|
||||
10. 迁移完成后再按同一正式时间窗运行:
|
||||
7. 用员工账号分别向总裁、财务、人力、审计和运营频道提交;匿名反馈在处理端不得显示提交人,但员工本人仍可查看。分别用对应处理角色验证只能看到本频道且只能正式回复一次;`superadmin` 验证全频道兜底。
|
||||
8. 用 `superadmin` 或 `hr_operator` 上传一个受支持文件,等待状态变为 `READY` 后随消息发布;员工详情必须显示摘要并能鉴权下载原文件,“问这条消息”应引用服务端提取内容。再验证失败文件不能发布、非在职或跨租户账号不能下载、撤回后不能下载或继续追问。
|
||||
9. 用 `superadmin` 或 `hr_operator` 在当前租户发布同一 `requestId` 两次:同内容必须只生成一条消息和一个 v1 快照,改动正文后必须返回 `409`。撤回时提交非空原因,员工端随即不可见;重复撤回不得改写首个撤回人、时间或原因。再确认管理列表不跨租户返回消息。
|
||||
10. 用正式移动端员工账号验证数字师傅 Agent:寒暄不得调用 RAG;本人待办/训练显示实时来源;普通图片不要求知识空间;现场制度问题只在授权空间补充 SOP;全网查询必须先返回同意卡;“记一下”确认前不落库。再检查 `aihr_agent_run` 只有主体、意图、工具、状态、来源和耗时等元数据,没有问题、答案或附件。生产无对应角色、媒体或撤权样本时必须记录未验收。
|
||||
11. 完整包发布后运行 `RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh`;必须同时获得线上资源、AIHR 模块和 schema 成功结果。该检查不替代第 7—10 项的认证态业务闭环。
|
||||
12. 迁移完成后再按同一正式时间窗运行:
|
||||
|
||||
```bash
|
||||
AIHR_PILOT_TENANT_ID=000000 \
|
||||
|
||||
@@ -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` 启动前端工程:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
@@ -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[] } };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -37,6 +37,12 @@
|
||||
"navigationBarTitleText": "消息详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/direct/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "直通车"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/assistant/index",
|
||||
"style": {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -171,6 +171,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'
|
||||
@@ -496,6 +498,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
|
||||
@@ -524,7 +595,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"
|
||||
@@ -535,6 +606,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 (
|
||||
|
||||
Reference in New Issue
Block a user