diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java index 7cb2f1bb..edea2e37 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java @@ -8,6 +8,7 @@ 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.PublishRequest; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.UnreadCountResponse; @@ -40,9 +41,10 @@ public class AihrBroadcastController { @GetMapping("/messages") public R messages( @RequestParam(required = false) Integer pageNum, - @RequestParam(required = false) Integer pageSize + @RequestParam(required = false) Integer pageSize, + @RequestParam(required = false) String scope ) { - return R.ok(broadcastService.messages(pageNum, pageSize)); + return R.ok(broadcastService.messages(pageNum, pageSize, scope)); } @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @@ -55,6 +57,12 @@ public class AihrBroadcastController { return R.ok(broadcastService.adminMessages(pageNum, pageSize, status)); } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @GetMapping("/admin/target-options") + public R targetOptions() { + return R.ok(broadcastService.targetOptions()); + } + @GetMapping("/messages/{id}") public R message(@PathVariable Long id) { return R.ok(broadcastService.message(id)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java index b52c63bd..2ad085fe 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java @@ -1,5 +1,6 @@ package org.dromara.aihr.broadcast; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; @@ -26,8 +27,14 @@ public final class AihrBroadcastDto { Long id, String title, boolean read, - String publishedAt + String publishedAt, + boolean requiredRead, + boolean targeted, + String targetReason ) { + public BroadcastListItem(Long id, String title, boolean read, String publishedAt) { + this(id, title, read, publishedAt, false, false, null); + } } public record BroadcastDetailResponse( @@ -35,8 +42,14 @@ public final class AihrBroadcastDto { String title, String content, boolean read, - String publishedAt + String publishedAt, + boolean requiredRead, + boolean targeted, + String targetReason ) { + public BroadcastDetailResponse(Long id, String title, String content, boolean read, String publishedAt) { + this(id, title, content, read, publishedAt, false, false, null); + } } public record AdminBroadcastListResponse( @@ -58,8 +71,56 @@ public final class AihrBroadcastDto { String publishedAt, Long withdrawnBy, String withdrawnAt, - String withdrawReason + String withdrawReason, + boolean requiredRead, + long targetRuleCount, + long matchedRecipientCount, + long unmatchedTargetCount ) { + public AdminBroadcastListItem( + Long id, + String title, + String content, + String status, + int currentVersion, + Long publishedBy, + String publishedAt, + Long withdrawnBy, + String withdrawnAt, + String withdrawReason + ) { + this(id, title, content, status, currentVersion, publishedBy, publishedAt, + withdrawnBy, withdrawnAt, withdrawReason, false, 0, 0, 0); + } + } + + public record BroadcastTargetOption(String value, String label) { + } + + public record BroadcastTargetOptions( + List projects, + List positionNames, + List positionLevels + ) { + } + + public record BroadcastTargetRequest( + @Size(max = 50, message = "定向项目不能超过 50 个") + List<@NotBlank(message = "项目编码不能为空") @Size(max = 50, message = "项目编码不能超过 50 个字符") String> projectCodes, + @Size(max = 50, message = "定向岗位不能超过 50 个") + List<@NotBlank(message = "岗位不能为空") @Size(max = 100, message = "岗位不能超过 100 个字符") String> positionNames, + @Size(max = 20, message = "定向职级不能超过 20 个") + List<@NotBlank(message = "职级不能为空") @Size(max = 30, message = "职级不能超过 30 个字符") String> positionLevels + ) { + public BroadcastTargetRequest { + projectCodes = projectCodes == null ? List.of() : List.copyOf(projectCodes); + positionNames = positionNames == null ? List.of() : List.copyOf(positionNames); + positionLevels = positionLevels == null ? List.of() : List.copyOf(positionLevels); + } + + public static BroadcastTargetRequest empty() { + return new BroadcastTargetRequest(List.of(), List.of(), List.of()); + } } public record PublishRequest( @@ -75,11 +136,19 @@ public final class AihrBroadcastDto { String content, @NotBlank(message = "当前租户不能为空") @Size(max = 64, message = "当前租户不能超过 64 个字符") - String expectedTenantId + String expectedTenantId, + Boolean requiredRead, + @Valid BroadcastTargetRequest targets ) { + public PublishRequest(String requestId, String title, String content, String expectedTenantId) { + this(requestId, title, content, expectedTenantId, false, BroadcastTargetRequest.empty()); + } } - public record PublishResponse(Long id, int version) { + public record PublishResponse(Long id, int version, long matchedRecipientCount, long unmatchedTargetCount) { + public PublishResponse(Long id, int version) { + this(id, version, 0, 0); + } } public record WithdrawRequest( diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java index 7d4860ef..0defd03a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java @@ -6,6 +6,9 @@ import org.dromara.aihr.broadcast.AihrBroadcastDto.AdminBroadcastListResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastDetailResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListItem; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListResponse; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOption; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOptions; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetRequest; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.UnreadCountResponse; @@ -30,9 +33,13 @@ import java.sql.Statement; import java.sql.Timestamp; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; 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 @@ -65,27 +72,45 @@ public class AihrBroadcastService { } public BroadcastListResponse messages(Integer rawPageNum, Integer rawPageSize) { + return messages(rawPageNum, rawPageSize, null); + } + + public BroadcastListResponse messages(Integer rawPageNum, Integer rawPageSize, String rawScope) { AihrKnowledgePrincipal principal = principalResolver.current(); requireEmployeeAudience(principal); Page page = page(rawPageNum, rawPageSize); - Long total = jdbcTemplate.queryForObject(""" - select count(*) - from aihr_broadcast_message m - where m.tenant_id = ? and m.status = 'PUBLISHED' - """, Long.class, principal.tenantId()); + BroadcastMessageScope scope = messageScope(rawScope); + EmployeeMessageQuery employeeMessages = employeeMessageQuery(principal, scope); + Long total = jdbcTemplate.queryForObject("select count(*) " + employeeMessages.fromWhere(), + Long.class, employeeMessages.parameters().toArray()); + List parameters = new ArrayList<>(employeeMessages.parameters()); + parameters.add(page.pageSize()); + parameters.add(page.offset()); List rows = jdbcTemplate.query(""" select m.id, m.title, m.published_time, - case when r.id is null then 0 else 1 end as read_flag - 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 = ? - where m.tenant_id = ? and m.status = 'PUBLISHED' - order by m.published_time desc, m.id desc + 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 + """ + employeeMessages.fromWhere() + """ + order by case + when m.required_read = 1 + and r.id is null + and (not exists ( + select 1 from aihr_broadcast_target_rule required_rule + where required_rule.tenant_id = m.tenant_id + and required_rule.message_id = m.id + ) or tr.user_id is not null) then 0 + when tr.user_id is not null then 1 + else 2 + end, + m.published_time desc, m.id desc limit ? offset ? """, (rs, rowNum) -> new BroadcastListItem( rs.getLong("id"), rs.getString("title"), rs.getBoolean("read_flag"), - format(rs.getTimestamp("published_time")) - ), principal.userId(), principal.tenantId(), page.pageSize(), page.offset()); + format(rs.getTimestamp("published_time")), rs.getBoolean("required_read"), + rs.getBoolean("targeted_flag"), rs.getString("target_reason") + ), parameters.toArray()); return new BroadcastListResponse(total == null ? 0 : total, page.pageNum(), page.pageSize(), rows); } @@ -108,7 +133,16 @@ public class AihrBroadcastService { parameters.add(page.offset()); List rows = jdbcTemplate.query(""" select m.id, m.title, m.content, m.status, 1 as current_version, - m.published_by, m.published_time, m.withdrawn_by, m.withdrawn_time, m.withdraw_reason + m.published_by, m.published_time, m.withdrawn_by, m.withdrawn_time, m.withdraw_reason, + case when m.required_read = 1 then 1 else 0 end as required_read, + (select count(*) from aihr_broadcast_target_rule target_rule + where target_rule.tenant_id = m.tenant_id and target_rule.message_id = m.id) as target_rule_count, + (select count(distinct recipient.user_id) from aihr_broadcast_target_recipient recipient + where recipient.tenant_id = m.tenant_id and recipient.message_id = m.id + and recipient.target_status = 'MATCHED') as matched_recipient_count, + (select count(*) from aihr_broadcast_target_recipient recipient + where recipient.tenant_id = m.tenant_id and recipient.message_id = m.id + and recipient.target_status = 'UNMATCHED') as unmatched_target_count from aihr_broadcast_message m where m.tenant_id = ?""" + statusFilter + """ order by m.published_time desc, m.id desc @@ -117,27 +151,81 @@ public class AihrBroadcastService { rs.getLong("id"), rs.getString("title"), rs.getString("content"), rs.getString("status"), rs.getInt("current_version"), rs.getLong("published_by"), format(rs.getTimestamp("published_time")), rs.getObject("withdrawn_by", Long.class), format(rs.getTimestamp("withdrawn_time")), - rs.getString("withdraw_reason") + rs.getString("withdraw_reason"), rs.getBoolean("required_read"), rs.getLong("target_rule_count"), + rs.getLong("matched_recipient_count"), rs.getLong("unmatched_target_count") ), parameters.toArray()); return new AdminBroadcastListResponse(tenantId, total == null ? 0 : total, page.pageNum(), page.pageSize(), rows); } + public BroadcastTargetOptions targetOptions() { + AihrKnowledgePrincipal principal = principalResolver.current(); + String tenantId = adminScopeTenantId(principal); + List projects = jdbcTemplate.query(""" + select project_code as value, + coalesce(nullif(max(project_name), ''), project_code) as label + from aihr_org_snapshot + where binary tenant_id = binary ? + and employment_status = 'active' + and project_code is not null and project_code <> '' + group by project_code + order by label, value + limit 200 + """, (rs, rowNum) -> new BroadcastTargetOption( + rs.getString("value"), rs.getString("label") + ), tenantId); + List positions = jdbcTemplate.query(""" + select distinct position_name as value, position_name as label + from aihr_org_snapshot + where binary tenant_id = binary ? + and employment_status = 'active' + and position_name is not null and position_name <> '' + order by label + limit 200 + """, (rs, rowNum) -> new BroadcastTargetOption( + rs.getString("value"), rs.getString("label") + ), tenantId); + List levels = jdbcTemplate.query(""" + select distinct position_level as value, position_level as label + from aihr_org_snapshot + where binary tenant_id = binary ? + and employment_status = 'active' + and position_level is not null and position_level <> '' + order by label + limit 50 + """, (rs, rowNum) -> new BroadcastTargetOption( + rs.getString("value"), rs.getString("label") + ), tenantId); + return new BroadcastTargetOptions(projects, positions, levels); + } + public BroadcastDetailResponse message(Long rawMessageId) { AihrKnowledgePrincipal principal = principalResolver.current(); requireEmployeeAudience(principal); long messageId = messageId(rawMessageId); List rows = jdbcTemplate.query(""" select m.id, m.title, m.content, m.published_time, - case when r.id is null then 0 else 1 end as read_flag + 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 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 = ? + left join ( + select recipient.tenant_id, recipient.message_id, recipient.user_id, + min(recipient.target_reason) as target_reason + from aihr_broadcast_target_recipient recipient + where recipient.tenant_id = ? and recipient.user_id = ? + 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 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")) - ), principal.userId(), principal.tenantId(), messageId); + format(rs.getTimestamp("published_time")), rs.getBoolean("required_read"), + rs.getBoolean("targeted_flag"), rs.getString("target_reason") + ), principal.userId(), principal.tenantId(), principal.userId(), principal.tenantId(), messageId); if (rows.isEmpty()) { throw unavailable(); } @@ -199,26 +287,31 @@ public class AihrBroadcastService { String requestKey = requestKey(request == null ? null : request.requestId()); String title = required(request == null ? null : request.title(), "消息标题", 200); 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()); String requestHash = requestHash(title, content); + String targetPayloadHash = targetPayloadHash(requiredRead, targetSelection); PublishReplay existing = findPublishReplay(tenantId, principal.userId(), requestKey); if (existing != null) { - return replayOrConflict(existing, title, content); + return replayOrConflict(existing, title, content, requiredRead, targetSelection, targetPayloadHash); } KeyHolder keyHolder = new GeneratedKeyHolder(); try { jdbcTemplate.update(connection -> { PreparedStatement statement = connection.prepareStatement(""" insert into aihr_broadcast_message - (tenant_id, title, content, status, published_by, publish_request_key, publish_request_hash, - published_time, create_time, update_time) - values (?, ?, ?, 'PUBLISHED', ?, ?, ?, now(), now(), now()) + (tenant_id, title, content, 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()) """, Statement.RETURN_GENERATED_KEYS); statement.setString(1, tenantId); statement.setString(2, title); statement.setString(3, content); - statement.setLong(4, principal.userId()); - statement.setString(5, requestKey); - statement.setString(6, requestHash); + statement.setBoolean(4, requiredRead); + statement.setLong(5, principal.userId()); + statement.setString(6, requestKey); + statement.setString(7, requestHash); + statement.setString(8, targetPayloadHash); return statement; }, keyHolder); } catch (DuplicateKeyException duplicate) { @@ -227,7 +320,7 @@ public class AihrBroadcastService { // concurrent request that just won the unique-key race. PublishReplay raced = findPublishReplayForUpdate(tenantId, principal.userId(), requestKey); if (raced != null) { - return replayOrConflict(raced, title, content); + return replayOrConflict(raced, title, content, requiredRead, targetSelection, targetPayloadHash); } throw duplicate; } @@ -245,7 +338,8 @@ public class AihrBroadcastService { if (snapshots != 1) { throw new ServiceException("保存消息版本失败"); } - return new PublishResponse(id.longValue(), 1); + TargetAudit targetAudit = persistTargetSnapshot(tenantId, id.longValue(), targetSelection); + return new PublishResponse(id.longValue(), 1, targetAudit.matchedRecipientCount(), targetAudit.unmatchedTargetCount()); } @Transactional(rollbackFor = Exception.class) @@ -280,6 +374,58 @@ public class AihrBroadcastService { } } + private static EmployeeMessageQuery employeeMessageQuery( + AihrKnowledgePrincipal principal, + BroadcastMessageScope scope + ) { + List parameters = new ArrayList<>(); + parameters.add(principal.userId()); + parameters.add(principal.tenantId()); + parameters.add(principal.userId()); + parameters.add(principal.tenantId()); + String scopeFilter = switch (scope) { + case ALL -> ""; + case RELATED -> " and tr.user_id is not null"; + case REQUIRED_UNREAD -> """ + and m.required_read = 1 + and r.id is null + and (not exists ( + select 1 from aihr_broadcast_target_rule required_rule + where required_rule.tenant_id = m.tenant_id + and required_rule.message_id = m.id + ) or tr.user_id is not null) + """; + }; + return new EmployeeMessageQuery(""" + 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 = ? + left join ( + select recipient.tenant_id, recipient.message_id, recipient.user_id, + min(recipient.target_reason) as target_reason + from aihr_broadcast_target_recipient recipient + where recipient.tenant_id = ? and recipient.user_id = ? + 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 + where m.tenant_id = ? and m.status = 'PUBLISHED' + """ + scopeFilter, parameters); + } + + private static BroadcastMessageScope messageScope(String rawScope) { + String scope = rawScope == null ? "" : rawScope.trim().toUpperCase(Locale.ROOT); + if (scope.isEmpty() || "ALL".equals(scope)) { + return BroadcastMessageScope.ALL; + } + if ("RELATED".equals(scope)) { + return BroadcastMessageScope.RELATED; + } + if ("REQUIRED_UNREAD".equals(scope)) { + return BroadcastMessageScope.REQUIRED_UNREAD; + } + throw new ServiceException("scope 只支持 ALL、RELATED 或 REQUIRED_UNREAD", 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; @@ -302,24 +448,171 @@ public class AihrBroadcastService { private PublishReplay findPublishReplay(String tenantId, Long publishedBy, String requestKey, boolean currentRead) { List rows = jdbcTemplate.query(""" - select id, title, content - from aihr_broadcast_message - where tenant_id = ? and published_by = ? and publish_request_key = ? + select m.id, m.title, m.content, m.target_payload_hash, + (select count(distinct recipient.user_id) from aihr_broadcast_target_recipient recipient + where recipient.tenant_id = m.tenant_id and recipient.message_id = m.id + and recipient.target_status = 'MATCHED') as matched_recipient_count, + (select count(*) from aihr_broadcast_target_recipient recipient + where recipient.tenant_id = m.tenant_id and recipient.message_id = m.id + and recipient.target_status = 'UNMATCHED') as unmatched_target_count + from aihr_broadcast_message m + where m.tenant_id = ? and m.published_by = ? and m.publish_request_key = ? limit 1 """ + (currentRead ? " for update" : ""), (rs, rowNum) -> new PublishReplay( - rs.getLong("id"), rs.getString("title"), rs.getString("content")), + rs.getLong("id"), rs.getString("title"), rs.getString("content"), rs.getString("target_payload_hash"), + rs.getLong("matched_recipient_count"), rs.getLong("unmatched_target_count")), tenantId, publishedBy, requestKey); return rows.isEmpty() ? null : rows.get(0); } - private static PublishResponse replayOrConflict(PublishReplay existing, String title, String content) { - // Compare the immutable payload itself, not just its digest. This preserves safe - // replay semantics for rows created by earlier hash formats and cannot be confused - // by delimiter collisions in user-entered text. - if (!title.equals(existing.title()) || !content.equals(existing.content())) { + private static PublishResponse replayOrConflict( + PublishReplay existing, + String title, + String content, + boolean requiredRead, + TargetSelection targetSelection, + String targetPayloadHash + ) { + // The target payload has an independent length-delimited digest, so a retry cannot + // reuse a request ID while silently changing mandatory-read or target dimensions. + // Historical M0 rows have no digest and remain replayable only as M0 payloads. + boolean legacyM0TargetPayload = existing.targetPayloadHash() == null + && !requiredRead && !targetSelection.hasTargets(); + if (!title.equals(existing.title()) || !content.equals(existing.content()) + || (!legacyM0TargetPayload && !targetPayloadHash.equals(existing.targetPayloadHash()))) { throw new ServiceException("该请求ID已用于不同的公司消息", 409); } - return new PublishResponse(existing.id(), 1); + return new PublishResponse(existing.id(), 1, + existing.matchedRecipientCount(), existing.unmatchedTargetCount()); + } + + private TargetAudit persistTargetSnapshot(String tenantId, long messageId, TargetSelection selection) { + if (!selection.hasTargets()) { + return TargetAudit.empty(); + } + persistTargetRules(tenantId, messageId, "PROJECT", selection.projectCodes()); + persistTargetRules(tenantId, messageId, "POSITION", selection.positionNames()); + persistTargetRules(tenantId, messageId, "LEVEL", selection.positionLevels()); + + Set matchedUsers = new LinkedHashSet<>(); + long unmatchedTargets = 0; + for (TargetCandidate candidate : targetCandidates(tenantId, selection)) { + boolean matched = candidate.userId() != null && candidate.userId() > 0; + if (matched) { + matchedUsers.add(candidate.userId()); + } else { + unmatchedTargets++; + } + String recipientKey = candidate.subjectRef() + "#" + (matched ? candidate.userId() : "UNMATCHED"); + jdbcTemplate.update(""" + insert into aihr_broadcast_target_recipient + (tenant_id, message_id, recipient_key, subject_ref, user_id, target_status, target_reason, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, now(), now()) + on duplicate key update target_status = values(target_status), target_reason = values(target_reason), + update_time = now() + """, tenantId, messageId, recipientKey, candidate.subjectRef(), candidate.userId(), + matched ? "MATCHED" : "UNMATCHED", selection.reason()); + } + return new TargetAudit(matchedUsers.size(), unmatchedTargets); + } + + private void persistTargetRules(String tenantId, long messageId, String targetType, List targetValues) { + for (String targetValue : targetValues) { + jdbcTemplate.update(""" + insert into aihr_broadcast_target_rule + (tenant_id, message_id, target_type, target_value, create_time) + values (?, ?, ?, ?, now()) + on duplicate key update target_value = values(target_value) + """, tenantId, messageId, targetType, targetValue); + } + } + + private List targetCandidates(String tenantId, TargetSelection selection) { + StringBuilder sql = new StringBuilder(""" + select distinct o.ext_party_id as subject_ref, u.user_id + from aihr_org_snapshot o + left join sys_user u + on binary o.tenant_id = binary u.tenant_id + and (binary o.person_phone = binary u.phonenumber or binary o.ext_party_id = binary u.user_name) + and u.user_type = 'app_user' + and u.status = '0' + and u.del_flag = '0' + where binary o.tenant_id = binary ? + and o.employment_status = 'active' + and o.ext_party_id is not null and o.ext_party_id <> '' + """); + List parameters = new ArrayList<>(); + parameters.add(tenantId); + appendTargetFilter(sql, parameters, "o.project_code", selection.projectCodes()); + appendTargetFilter(sql, parameters, "o.position_name", selection.positionNames()); + appendTargetFilter(sql, parameters, "o.position_level", selection.positionLevels()); + sql.append(" order by o.ext_party_id, u.user_id"); + return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new TargetCandidate( + rs.getString("subject_ref"), rs.getObject("user_id", Long.class) + ), parameters.toArray()); + } + + private static void appendTargetFilter( + StringBuilder sql, + List parameters, + String column, + List values + ) { + if (values.isEmpty()) { + return; + } + sql.append(" and ").append(column).append(" in (") + .append(String.join(",", Collections.nCopies(values.size(), "?"))).append(')'); + parameters.addAll(values); + } + + private static TargetSelection targetSelection(BroadcastTargetRequest rawTargets) { + BroadcastTargetRequest targets = rawTargets == null ? BroadcastTargetRequest.empty() : rawTargets; + List projectCodes = targetValues(targets.projectCodes(), 50, 50, "项目编码"); + List positionNames = targetValues(targets.positionNames(), 50, 100, "岗位"); + List positionLevels = targetValues(targets.positionLevels(), 20, 30, "职级"); + List labels = new ArrayList<>(); + if (!projectCodes.isEmpty()) labels.add("项目"); + if (!positionNames.isEmpty()) labels.add("岗位"); + if (!positionLevels.isEmpty()) labels.add("职级"); + return new TargetSelection(projectCodes, positionNames, positionLevels, String.join("、", labels)); + } + + private static List targetValues(List rawValues, int maximumCount, int maximumLength, String label) { + List values = rawValues == null ? List.of() : rawValues; + if (values.size() > maximumCount) { + throw new ServiceException(label + "不能超过 " + maximumCount + " 个", HttpStatus.BAD_REQUEST); + } + Set normalized = new LinkedHashSet<>(); + for (String rawValue : values) { + String value = rawValue == null ? "" : rawValue.trim(); + if (value.isEmpty()) { + throw new ServiceException(label + "不能为空", HttpStatus.BAD_REQUEST); + } + if (value.length() > maximumLength) { + throw new ServiceException(label + "不能超过 " + maximumLength + " 个字符", HttpStatus.BAD_REQUEST); + } + normalized.add(value); + } + List sorted = new ArrayList<>(normalized); + sorted.sort(Comparator.naturalOrder()); + return List.copyOf(sorted); + } + + private static String targetPayloadHash(boolean requiredRead, TargetSelection selection) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + updateDigestField(digest, requiredRead ? "1" : "0"); + updateDigestField(digest, "PROJECT"); + selection.projectCodes().forEach(value -> updateDigestField(digest, value)); + updateDigestField(digest, "POSITION"); + selection.positionNames().forEach(value -> updateDigestField(digest, value)); + updateDigestField(digest, "LEVEL"); + selection.positionLevels().forEach(value -> updateDigestField(digest, value)); + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } } private void requireEmployeeAudience(AihrKnowledgePrincipal principal) { @@ -330,8 +623,8 @@ public class AihrBroadcastService { select count(*) from sys_user u join aihr_org_snapshot o - on o.tenant_id = u.tenant_id - and (o.person_phone = u.phonenumber or o.ext_party_id = u.user_name) + on binary o.tenant_id = binary u.tenant_id + and (binary o.person_phone = binary u.phonenumber or binary o.ext_party_id = binary u.user_name) where u.tenant_id = ? and u.user_id = ? and u.user_type = ? @@ -439,7 +732,43 @@ public class AihrBroadcastService { return new ServiceException("仅在职员工可查看公司消息", HttpStatus.FORBIDDEN); } - private record PublishReplay(long id, String title, String content) { + private enum BroadcastMessageScope { + ALL, + RELATED, + REQUIRED_UNREAD + } + + private record EmployeeMessageQuery(String fromWhere, List parameters) { + } + + private record TargetSelection( + List projectCodes, + List positionNames, + List positionLevels, + String reason + ) { + private boolean hasTargets() { + return !projectCodes.isEmpty() || !positionNames.isEmpty() || !positionLevels.isEmpty(); + } + } + + private record TargetCandidate(String subjectRef, Long userId) { + } + + private record TargetAudit(long matchedRecipientCount, long unmatchedTargetCount) { + private static TargetAudit empty() { + return new TargetAudit(0, 0); + } + } + + private record PublishReplay( + long id, + String title, + String content, + String targetPayloadHash, + long matchedRecipientCount, + long unmatchedTargetCount + ) { } /** diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastM1ServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastM1ServiceTest.java index c864f4eb..efbf1b76 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastM1ServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastM1ServiceTest.java @@ -17,6 +17,8 @@ import java.lang.reflect.Proxy; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; +import java.security.MessageDigest; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -72,12 +74,39 @@ class AihrBroadcastM1ServiceTest { assertTrue(jdbcTemplate.messageListSql.contains("aihr_broadcast_target_recipient")); assertTrue(jdbcTemplate.messageListSql.contains("tr.user_id is not null")); - service.messages(1, 20, "ALL"); + BroadcastListResponse allMessages = service.messages(1, 20, "ALL"); - assertFalse(jdbcTemplate.messageListSql.contains("tr.user_id is not null")); + assertEquals(1, allMessages.rows().size()); assertTrue(jdbcTemplate.messageListSql.contains("m.status = 'PUBLISHED'")); } + @Test + void idempotentTargetedRetryReturnsTheOriginalRecipientAudit() { + BroadcastTargetRequest targets = new BroadcastTargetRequest( + List.of("PRJ-FCW"), List.of("物业管家"), List.of() + ); + TargetingJdbcTemplate jdbcTemplate = new TargetingJdbcTemplate(); + jdbcTemplate.publishReplays = List.of(Map.of( + "id", 42L, + "title", "夏季服务标准", + "content", "请项目客服人员在今日完成学习。", + "target_payload_hash", targetPayloadHash(true, targets), + "matched_recipient_count", 4L, + "unmatched_target_count", 2L + )); + AihrBroadcastService service = new AihrBroadcastService(jdbcTemplate, RESOLVER); + + PublishResponse response = service.publish(new PublishRequest( + "m1-targeted-retry", "夏季服务标准", "请项目客服人员在今日完成学习。", "tenant-a", true, targets + )); + + assertEquals(42L, response.id()); + assertEquals(4L, response.matchedRecipientCount()); + assertEquals(2L, response.unmatchedTargetCount()); + assertTrue(jdbcTemplate.publishReplaySql.contains("matched_recipient_count")); + assertTrue(jdbcTemplate.publishReplaySql.contains("unmatched_target_count")); + } + private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null) { @Override public AihrKnowledgePrincipal current() { @@ -86,11 +115,38 @@ class AihrBroadcastM1ServiceTest { } }; + private static String targetPayloadHash(boolean requiredRead, BroadcastTargetRequest targets) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + updateDigestField(digest, requiredRead ? "1" : "0"); + updateDigestField(digest, "PROJECT"); + targets.projectCodes().stream().sorted().forEach(value -> updateDigestField(digest, value)); + updateDigestField(digest, "POSITION"); + targets.positionNames().stream().sorted().forEach(value -> updateDigestField(digest, value)); + updateDigestField(digest, "LEVEL"); + targets.positionLevels().stream().sorted().forEach(value -> updateDigestField(digest, value)); + return java.util.HexFormat.of().formatHex(digest.digest()); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } + + private static void updateDigestField(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update((byte) (bytes.length >>> 24)); + digest.update((byte) (bytes.length >>> 16)); + digest.update((byte) (bytes.length >>> 8)); + digest.update((byte) bytes.length); + digest.update(bytes); + } + private static final class TargetingJdbcTemplate extends JdbcTemplate { private List> targetCandidates = List.of(); + private List> publishReplays = List.of(); private String targetCandidateSql; private Object[] targetCandidateArgs; private String messageListSql; + private String publishReplaySql; private final List updateSqls = new ArrayList<>(); @Override @@ -101,7 +157,10 @@ class AihrBroadcastM1ServiceTest { @Override public List query(String sql, RowMapper rowMapper, Object... args) { - if (sql.contains("publish_request_key")) return List.of(); + if (sql.contains("publish_request_key")) { + publishReplaySql = sql; + return publishReplays.stream().map(values -> map(rowMapper, values)).toList(); + } if (sql.contains("from aihr_org_snapshot o")) { targetCandidateSql = sql; targetCandidateArgs = args; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java index 1deae282..6f523098 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java @@ -63,6 +63,9 @@ class AihrBroadcastServiceTest { assertEquals(HttpStatus.FORBIDDEN, error.getCode()); assertTrue(jdbcTemplate.queryForObjectSql.contains("from sys_user u")); + assertTrue(jdbcTemplate.queryForObjectSql.contains("binary o.tenant_id = binary u.tenant_id")); + assertTrue(jdbcTemplate.queryForObjectSql.contains("binary o.person_phone = binary u.phonenumber")); + assertTrue(jdbcTemplate.queryForObjectSql.contains("binary o.ext_party_id = binary u.user_name")); assertArrayEquals(new Object[]{"tenant-a", 7L, "app_user"}, jdbcTemplate.queryForObjectArgs); } @@ -76,7 +79,7 @@ class AihrBroadcastServiceTest { assertEquals(HttpStatus.NOT_FOUND, error.getCode()); assertTrue(jdbcTemplate.querySql.contains("m.tenant_id = ? and m.id = ? and m.status = 'PUBLISHED'")); - assertArrayEquals(new Object[]{7L, "tenant-a", 42L}, jdbcTemplate.queryArgs); + assertArrayEquals(new Object[]{7L, "tenant-a", 7L, "tenant-a", 42L}, jdbcTemplate.queryArgs); } @Test @@ -299,10 +302,12 @@ class AihrBroadcastServiceTest { "withdraw", Long.class, AihrBroadcastDto.WithdrawRequest.class); Method adminMessages = AihrBroadcastController.class.getMethod( "adminMessages", Integer.class, Integer.class, String.class); + Method targetOptions = AihrBroadcastController.class.getMethod("targetOptions"); assertRoleGate(publish.getAnnotation(SaCheckRole.class)); assertRoleGate(withdraw.getAnnotation(SaCheckRole.class)); assertRoleGate(adminMessages.getAnnotation(SaCheckRole.class)); + assertRoleGate(targetOptions.getAnnotation(SaCheckRole.class)); assertTrue(withdraw.getParameters()[1].isAnnotationPresent(Valid.class)); } @@ -310,7 +315,10 @@ class AihrBroadcastServiceTest { void publishRequestValidatesTransportInputBeforeItReachesTheService() throws Exception { Method publish = AihrBroadcastController.class.getMethod( "publish", AihrBroadcastDto.PublishRequest.class); - Constructor constructor = AihrBroadcastDto.PublishRequest.class.getDeclaredConstructors()[0]; + Constructor constructor = java.util.Arrays.stream(AihrBroadcastDto.PublishRequest.class.getDeclaredConstructors()) + .filter(candidate -> candidate.getParameterCount() == 6) + .findFirst() + .orElseThrow(); assertTrue(publish.getParameters()[0].isAnnotationPresent(Valid.class)); assertTrue(constructor.getParameters()[0].isAnnotationPresent(NotBlank.class)); diff --git a/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql b/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql new file mode 100644 index 00000000..99a9aed7 --- /dev/null +++ b/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql @@ -0,0 +1,96 @@ +-- 银城大喇叭 M1:定向提醒与必读阅读闭环。 +-- 保留 M0 的全租户公开消息语义;定向规则只决定优先提醒/必读对象,不隐藏公开频道内容。 +-- 可重复执行,需在 aihr_20260722_broadcast_mysql8.sql 之后执行。 + +SET @has_broadcast_required_read := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' + AND column_name = 'required_read' +); +SET @broadcast_m1_ddl := IF( + @has_broadcast_required_read = 0, + 'ALTER TABLE aihr_broadcast_message ADD COLUMN required_read tinyint(1) NOT NULL DEFAULT 0 COMMENT ''是否必读'' AFTER content', + 'SELECT 1' +); +PREPARE aihr_broadcast_m1_stmt FROM @broadcast_m1_ddl; EXECUTE aihr_broadcast_m1_stmt; DEALLOCATE PREPARE aihr_broadcast_m1_stmt; + +-- 用于发布幂等重试时校验必读/定向载荷。历史 M0 消息保留 NULL,等价于非必读且未定向。 +SET @has_broadcast_target_payload_hash := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' + AND column_name = 'target_payload_hash' +); +SET @broadcast_m1_ddl := IF( + @has_broadcast_target_payload_hash = 0, + 'ALTER TABLE aihr_broadcast_message ADD COLUMN target_payload_hash char(64) DEFAULT NULL COMMENT ''必读及定向载荷SHA-256'' AFTER publish_request_hash', + 'SELECT 1' +); +PREPARE aihr_broadcast_m1_stmt FROM @broadcast_m1_ddl; EXECUTE aihr_broadcast_m1_stmt; DEALLOCATE PREPARE aihr_broadcast_m1_stmt; + +CREATE TABLE IF NOT EXISTS `aihr_broadcast_target_rule` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '定向规则ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `message_id` bigint NOT NULL COMMENT '消息ID', + `target_type` varchar(20) NOT NULL COMMENT 'PROJECT/POSITION/LEVEL', + `target_value` varchar(100) NOT NULL COMMENT '目标值', + `create_time` datetime NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_broadcast_target_rule` (`tenant_id`, `message_id`, `target_type`, `target_value`), + KEY `idx_aihr_broadcast_target_rule_message` (`tenant_id`, `message_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='银城大喇叭定向规则快照'; + +CREATE TABLE IF NOT EXISTS `aihr_broadcast_target_recipient` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '定向对象快照ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `message_id` bigint NOT NULL COMMENT '消息ID', + `recipient_key` varchar(180) NOT NULL COMMENT '外部主体及映射账号唯一键', + `subject_ref` varchar(100) NOT NULL COMMENT '组织快照外部主体ID', + `user_id` bigint DEFAULT NULL COMMENT '匹配到的员工账号ID', + `target_status` varchar(20) NOT NULL COMMENT 'MATCHED/UNMATCHED', + `target_reason` varchar(100) NOT NULL COMMENT '项目/岗位/职级定向原因', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_broadcast_target_recipient` (`tenant_id`, `message_id`, `recipient_key`), + KEY `idx_aihr_broadcast_target_recipient_user` (`tenant_id`, `user_id`, `message_id`, `target_status`), + KEY `idx_aihr_broadcast_target_recipient_message` (`tenant_id`, `message_id`, `target_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='银城大喇叭定向对象快照'; + +-- 与 M0 消息表保持同一租户事实排序规则,避免历史库的 utf8mb4_general_ci 与 MySQL 8 0900_ai_ci 混用。 +SET @broadcast_m1_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_m1_target_collation := COALESCE(@broadcast_m1_target_collation, @@collation_database); + +SET @broadcast_target_rule_collation := ( + SELECT table_collation FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_target_rule' + LIMIT 1 +); +SET @broadcast_m1_ddl := IF( + @broadcast_target_rule_collation = @broadcast_m1_target_collation, + 'SELECT 1', + CONCAT('ALTER TABLE aihr_broadcast_target_rule CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @broadcast_m1_target_collation) +); +PREPARE aihr_broadcast_m1_stmt FROM @broadcast_m1_ddl; EXECUTE aihr_broadcast_m1_stmt; DEALLOCATE PREPARE aihr_broadcast_m1_stmt; + +SET @broadcast_target_recipient_collation := ( + SELECT table_collation FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_target_recipient' + LIMIT 1 +); +SET @broadcast_m1_ddl := IF( + @broadcast_target_recipient_collation = @broadcast_m1_target_collation, + 'SELECT 1', + CONCAT('ALTER TABLE aihr_broadcast_target_recipient CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @broadcast_m1_target_collation) +); +PREPARE aihr_broadcast_m1_stmt FROM @broadcast_m1_ddl; EXECUTE aihr_broadcast_m1_stmt; DEALLOCATE PREPARE aihr_broadcast_m1_stmt; + +SET @has_broadcast_required_read := NULL; +SET @has_broadcast_target_payload_hash := NULL; +SET @broadcast_m1_target_collation := NULL; +SET @broadcast_target_rule_collation := NULL; +SET @broadcast_target_recipient_collation := NULL; +SET @broadcast_m1_ddl := NULL; diff --git a/frontend/src/api/aihr/broadcast.ts b/frontend/src/api/aihr/broadcast.ts index 16d6e296..06f2dcb5 100644 --- a/frontend/src/api/aihr/broadcast.ts +++ b/frontend/src/api/aihr/broadcast.ts @@ -8,6 +8,23 @@ type ApiResult = { export type BroadcastStatus = 'PUBLISHED' | 'WITHDRAWN'; +export type BroadcastTargetRequest = { + projectCodes: string[]; + positionNames: string[]; + positionLevels: string[]; +}; + +export type BroadcastTargetOption = { + value: string; + label: string; +}; + +export type BroadcastTargetOptions = { + projects: BroadcastTargetOption[]; + positionNames: BroadcastTargetOption[]; + positionLevels: BroadcastTargetOption[]; +}; + export type BroadcastAdminMessage = { id: number; title: string; @@ -19,6 +36,10 @@ export type BroadcastAdminMessage = { withdrawnBy?: number; withdrawnAt?: string; withdrawReason?: string; + requiredRead: boolean; + targetRuleCount: number; + matchedRecipientCount: number; + unmatchedTargetCount: number; }; export type BroadcastAdminPage = { @@ -35,6 +56,8 @@ export type PublishBroadcastRequest = { title: string; content: string; expectedTenantId: string; + requiredRead: boolean; + targets: BroadcastTargetRequest; }; export type WithdrawBroadcastRequest = { @@ -54,7 +77,19 @@ export function listBroadcastAdminMessages(params: { }); } -export function publishBroadcast(data: PublishBroadcastRequest): Promise> { +export function loadBroadcastTargetOptions(): Promise> { + return request({ + url: '/api/aihr/broadcast/admin/target-options', + method: 'get' + }); +} + +export function publishBroadcast(data: PublishBroadcastRequest): Promise> { return request({ url: '/api/aihr/broadcast/messages', method: 'post', diff --git a/frontend/src/utils/broadcast-pending.test.ts b/frontend/src/utils/broadcast-pending.test.ts index 7ebc7145..275e64ca 100644 --- a/frontend/src/utils/broadcast-pending.test.ts +++ b/frontend/src/utils/broadcast-pending.test.ts @@ -39,7 +39,13 @@ const pending: PendingBroadcastPublish = { tenantId: 'tenant-a', requestId: 'request-1', title: '夏季服务标准通知', - content: '请按最新标准执行。' + content: '请按最新标准执行。', + requiredRead: true, + targets: { + projectCodes: ['PRJ-FCW'], + positionNames: ['物业管家'], + positionLevels: [] + } }; describe('pending broadcast publish storage', () => { diff --git a/frontend/src/utils/broadcast-pending.ts b/frontend/src/utils/broadcast-pending.ts index 969bde27..c7a28a39 100644 --- a/frontend/src/utils/broadcast-pending.ts +++ b/frontend/src/utils/broadcast-pending.ts @@ -1,3 +1,5 @@ +import type { BroadcastTargetRequest } from '@/api/aihr/broadcast'; + export const pendingBroadcastStoragePrefix = 'aihr.broadcast.pending.v2'; export interface PendingBroadcastPublish { @@ -5,6 +7,8 @@ export interface PendingBroadcastPublish { requestId: string; title: string; content: string; + requiredRead: boolean; + targets: BroadcastTargetRequest; } export interface BroadcastPendingStorageOptions { @@ -14,6 +18,31 @@ export interface BroadcastPendingStorageOptions { const accountKey = (userId: string | number) => String(userId).trim(); +const emptyTargets = (): BroadcastTargetRequest => ({ + projectCodes: [], + positionNames: [], + positionLevels: [] +}); + +const parseTargetValues = (value: unknown, maximumCount: number, maximumLength: number) => { + if (!Array.isArray(value) || value.length > maximumCount) return undefined; + const normalized = value.map((entry) => typeof entry === 'string' ? entry.trim() : ''); + if (normalized.some((entry) => !entry || entry.length > maximumLength)) return undefined; + return [...new Set(normalized)]; +}; + +const parseTargets = (value: unknown): BroadcastTargetRequest | undefined => { + if (value === undefined) return emptyTargets(); + if (!value || typeof value !== 'object') return undefined; + const candidate = value as Partial; + const projectCodes = parseTargetValues(candidate.projectCodes, 50, 50); + const positionNames = parseTargetValues(candidate.positionNames, 50, 100); + const positionLevels = parseTargetValues(candidate.positionLevels, 20, 30); + return projectCodes && positionNames && positionLevels + ? { projectCodes, positionNames, positionLevels } + : undefined; +}; + export const broadcastPendingStorageKey = (tenantId: string, userId: string | number) => { const normalizedTenantId = String(tenantId).trim(); const normalizedUserId = accountKey(userId); @@ -26,11 +55,15 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde if (!raw) return undefined; try { const candidate = JSON.parse(raw) as Partial; + const targets = parseTargets(candidate.targets); + const requiredRead = candidate.requiredRead === undefined ? false : candidate.requiredRead; if ( typeof candidate.tenantId !== 'string' || typeof candidate.requestId !== 'string' || typeof candidate.title !== 'string' || typeof candidate.content !== 'string' + || typeof requiredRead !== 'boolean' + || !targets ) { return undefined; } @@ -38,7 +71,9 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde tenantId: candidate.tenantId, requestId: candidate.requestId, title: candidate.title, - content: candidate.content + content: candidate.content, + requiredRead, + targets }; } catch { return undefined; diff --git a/frontend/src/views/content/broadcast.vue b/frontend/src/views/content/broadcast.vue index 792d7295..67cac4ef 100644 --- a/frontend/src/views/content/broadcast.vue +++ b/frontend/src/views/content/broadcast.vue @@ -3,7 +3,7 @@

银城大喇叭

-

向当前租户全员公开发布公司通知。发布后立即可见;撤回会停止员工端展示,但保留发布记录。

+

向当前租户全员公开发布公司通知;可额外定向提醒项目、岗位或职级人员,并设置必读。撤回会停止员工端展示,但保留发布记录。

发布消息
@@ -38,6 +38,16 @@ + + + @@ -125,6 +135,59 @@ placeholder="请输入完整通知正文" /> + + 设为必读(员工端会优先显示未读内容) + + + +
+ + + + + + + + + +
+