feat(aihr): add M1 broadcast targeting and mandatory reads

This commit is contained in:
2026-07-23 12:11:41 +08:00
parent cd94f4fe9f
commit f6609808df
14 changed files with 925 additions and 69 deletions
@@ -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<BroadcastListResponse> 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<BroadcastTargetOptions> targetOptions() {
return R.ok(broadcastService.targetOptions());
}
@GetMapping("/messages/{id}")
public R<BroadcastDetailResponse> message(@PathVariable Long id) {
return R.ok(broadcastService.message(id));
@@ -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<BroadcastTargetOption> projects,
List<BroadcastTargetOption> positionNames,
List<BroadcastTargetOption> 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(
@@ -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<Object> parameters = new ArrayList<>(employeeMessages.parameters());
parameters.add(page.pageSize());
parameters.add(page.offset());
List<BroadcastListItem> 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<AdminBroadcastListItem> 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<BroadcastTargetOption> 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<BroadcastTargetOption> 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<BroadcastTargetOption> 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<BroadcastDetailResponse> 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<Object> 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<PublishReplay> 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<Long> 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<String> 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<TargetCandidate> 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<Object> 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<Object> parameters,
String column,
List<String> 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<String> projectCodes = targetValues(targets.projectCodes(), 50, 50, "项目编码");
List<String> positionNames = targetValues(targets.positionNames(), 50, 100, "岗位");
List<String> positionLevels = targetValues(targets.positionLevels(), 20, 30, "职级");
List<String> 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<String> targetValues(List<String> rawValues, int maximumCount, int maximumLength, String label) {
List<String> values = rawValues == null ? List.of() : rawValues;
if (values.size() > maximumCount) {
throw new ServiceException(label + "不能超过 " + maximumCount + " 个", HttpStatus.BAD_REQUEST);
}
Set<String> 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<String> 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<Object> parameters) {
}
private record TargetSelection(
List<String> projectCodes,
List<String> positionNames,
List<String> 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
) {
}
/**
@@ -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<Map<String, Object>> targetCandidates = List.of();
private List<Map<String, Object>> publishReplays = List.of();
private String targetCandidateSql;
private Object[] targetCandidateArgs;
private String messageListSql;
private String publishReplaySql;
private final List<String> updateSqls = new ArrayList<>();
@Override
@@ -101,7 +157,10 @@ class AihrBroadcastM1ServiceTest {
@Override
public <T> List<T> query(String sql, RowMapper<T> 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;
@@ -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));
@@ -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;
+36 -1
View File
@@ -8,6 +8,23 @@ type ApiResult<T> = {
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<ApiResult<{ id: number; version: number }>> {
export function loadBroadcastTargetOptions(): Promise<ApiResult<BroadcastTargetOptions>> {
return request({
url: '/api/aihr/broadcast/admin/target-options',
method: 'get'
});
}
export function publishBroadcast(data: PublishBroadcastRequest): Promise<ApiResult<{
id: number;
version: number;
matchedRecipientCount: number;
unmatchedTargetCount: number;
}>> {
return request({
url: '/api/aihr/broadcast/messages',
method: 'post',
+7 -1
View File
@@ -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', () => {
+36 -1
View File
@@ -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<BroadcastTargetRequest>;
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<PendingBroadcastPublish>;
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;
+171 -7
View File
@@ -3,7 +3,7 @@
<section class="page-head">
<div>
<h1>银城大喇叭</h1>
<p>向当前租户全员公开发布公司通知。发布后立即可见;撤回会停止员工端展示,但保留发布记录。</p>
<p>向当前租户全员公开发布公司通知;可额外定向提醒项目、岗位或职级人员,并设置必读。撤回会停止员工端展示,但保留发布记录。</p>
</div>
<el-button type="danger" icon="Plus" :disabled="publishing || publishPending || !scopeTenantId || !storageAvailable" @click="openPublish">发布消息</el-button>
</section>
@@ -38,6 +38,16 @@
<el-table-column label="内容摘要" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.content }}</template>
</el-table-column>
<el-table-column label="阅读与范围" min-width="164">
<template #default="{ row }">
<div class="target-summary">
<el-tag v-if="row.requiredRead" type="danger" size="small">必读</el-tag>
<el-tag v-if="row.targetRuleCount" type="warning" size="small">定向提醒</el-tag>
<span v-if="row.targetRuleCount" class="target-count">已匹配 {{ row.matchedRecipientCount }} 人<span v-if="row.unmatchedTargetCount">,待匹配 {{ row.unmatchedTargetCount }} 人</span></span>
<span v-else class="muted">全员公开</span>
</div>
</template>
</el-table-column>
<el-table-column label="版本" width="64" align="center">
<template #default="{ row }">v{{ row.currentVersion }}</template>
</el-table-column>
@@ -125,6 +135,59 @@
placeholder="请输入完整通知正文"
/>
</el-form-item>
<el-form-item label="阅读要求">
<el-checkbox v-model="form.requiredRead" :disabled="publishing || publishPending">设为必读(员工端会优先显示未读内容)</el-checkbox>
</el-form-item>
<el-form-item label="定向提醒(可选)">
<el-alert
class="targeting-note"
type="info"
:closable="false"
show-icon
title="不选择范围时仍是全员公开。选择多个值时,同一维度按“或”匹配,不同维度按“且”匹配;未匹配账号不会丢失公开消息。"
/>
<div class="target-selects">
<el-select
v-model="form.targets.projectCodes"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
:loading="targetOptionsLoading"
:disabled="publishing || publishPending"
placeholder="按项目定向"
>
<el-option v-for="option in targetOptions.projects" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-select
v-model="form.targets.positionNames"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
:loading="targetOptionsLoading"
:disabled="publishing || publishPending"
placeholder="按岗位定向"
>
<el-option v-for="option in targetOptions.positionNames" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-select
v-model="form.targets.positionLevels"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
:loading="targetOptionsLoading"
:disabled="publishing || publishPending"
placeholder="按职级定向"
>
<el-option v-for="option in targetOptions.positionLevels" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="publishing" @click="requestClosePublish">{{ publishPending ? '放弃并关闭' : '取消' }}</el-button>
@@ -141,9 +204,12 @@ import type { FormInstance } from 'element-plus';
import { onMounted, reactive, ref } from 'vue';
import {
listBroadcastAdminMessages,
loadBroadcastTargetOptions,
publishBroadcast,
withdrawBroadcast,
type BroadcastAdminMessage,
type BroadcastTargetRequest,
type BroadcastTargetOptions,
type BroadcastStatus
} from '@/api/aihr/broadcast';
import { useUserStore } from '@/store/modules/user';
@@ -165,7 +231,15 @@ const publishing = ref(false);
const publishPending = ref(false);
const withdrawingId = ref<number>();
const publishFormRef = ref<FormInstance>();
const form = reactive({ requestId: '', title: '', content: '' });
const targetOptionsLoading = ref(false);
const targetOptions = ref<BroadcastTargetOptions>({ projects: [], positionNames: [], positionLevels: [] });
const form = reactive({
requestId: '',
title: '',
content: '',
requiredRead: false,
targets: { projectCodes: [] as string[], positionNames: [] as string[], positionLevels: [] as string[] }
});
const userStore = useUserStore();
const scopeTenantId = ref('');
const draftTenantId = ref('');
@@ -178,6 +252,22 @@ const rules = {
const displayTime = (value?: string) => (value ? value.replace('T', ' ').slice(0, 16) : '—');
const createRequestId = () => globalThis.crypto?.randomUUID?.() ?? `broadcast-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
const emptyTargets = (): BroadcastTargetRequest => ({ projectCodes: [], positionNames: [], positionLevels: [] });
const copyTargets = (targets: BroadcastTargetRequest): BroadcastTargetRequest => ({
projectCodes: [...targets.projectCodes],
positionNames: [...targets.positionNames],
positionLevels: [...targets.positionLevels]
});
const isValidTargetValues = (values: unknown, maximumCount: number, maximumLength: number): values is string[] =>
Array.isArray(values) && values.length <= maximumCount
&& values.every((value) => typeof value === 'string' && !!value.trim() && value.trim().length <= maximumLength);
const isValidTargets = (targets: unknown): targets is BroadcastTargetRequest => {
if (!targets || typeof targets !== 'object') return false;
const candidate = targets as Partial<BroadcastTargetRequest>;
return isValidTargetValues(candidate.projectCodes, 50, 50)
&& isValidTargetValues(candidate.positionNames, 50, 100)
&& isValidTargetValues(candidate.positionLevels, 20, 30);
};
const isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): candidate is PendingBroadcastPublish =>
typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value &&
@@ -185,7 +275,8 @@ const isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): can
typeof candidate.requestId === 'string' && candidate.requestId === candidate.requestId.trim() &&
/^[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.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000 &&
typeof candidate.requiredRead === 'boolean' && isValidTargets(candidate.targets);
const pendingPublishUserId = () => String(userStore.userId || '').trim();
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
@@ -211,7 +302,9 @@ const persistPendingPublish = () => {
tenantId: pendingTenantId.value,
requestId: form.requestId,
title: form.title,
content: form.content
content: form.content,
requiredRead: form.requiredRead,
targets: copyTargets(form.targets)
};
try {
if (!pendingPublishStorageKey()) throw new Error('missing tenant scope');
@@ -263,6 +356,10 @@ const applyPendingPublish = (candidate: PendingBroadcastPublish) => {
form.requestId = candidate.requestId;
form.title = candidate.title;
form.content = candidate.content;
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;
};
@@ -275,6 +372,32 @@ const restorePendingPublish = () => {
return true;
};
const resetTargetForm = () => {
const targets = emptyTargets();
form.requiredRead = false;
form.targets.projectCodes = targets.projectCodes;
form.targets.positionNames = targets.positionNames;
form.targets.positionLevels = targets.positionLevels;
};
const loadTargetOptions = async () => {
if (targetOptionsLoading.value) return;
targetOptionsLoading.value = true;
try {
const response = await loadBroadcastTargetOptions();
targetOptions.value = {
projects: Array.isArray(response.data?.projects) ? response.data.projects : [],
positionNames: Array.isArray(response.data?.positionNames) ? response.data.positionNames : [],
positionLevels: Array.isArray(response.data?.positionLevels) ? response.data.positionLevels : []
};
} catch {
targetOptions.value = { projects: [], positionNames: [], positionLevels: [] };
ElMessage.warning('定向范围暂不可读取;仍可按全员公开方式发布。');
} finally {
targetOptionsLoading.value = false;
}
};
const loadMessages = async (allowWhilePublishing = false) => {
if (publishing.value && !allowWhilePublishing) return;
loading.value = true;
@@ -297,6 +420,7 @@ const loadMessages = async (allowWhilePublishing = false) => {
form.requestId = '';
form.title = '';
form.content = '';
resetTargetForm();
}
scopeTenantId.value = resolvedTenantId;
restorePendingPublish();
@@ -327,6 +451,7 @@ const openPublish = () => {
ElMessage.warning('正在确认当前租户,请稍候。');
return;
}
void loadTargetOptions();
if (restorePendingPublish()) {
ElMessage.warning('检测到同一租户存在结果未确认的发布,请先重试或明确放弃。');
return;
@@ -336,6 +461,7 @@ const openPublish = () => {
draftTenantId.value = scopeTenantId.value;
form.title = '';
form.content = '';
resetTargetForm();
publishVisible.value = true;
};
@@ -346,6 +472,7 @@ const resetPublishForm = () => {
form.requestId = '';
form.title = '';
form.content = '';
resetTargetForm();
publishFormRef.value?.clearValidate();
return true;
};
@@ -395,6 +522,7 @@ const submitPublish = async () => {
form.requestId = '';
form.title = '';
form.content = '';
resetTargetForm();
draftTenantId.value = '';
return;
}
@@ -415,18 +543,26 @@ const submitPublish = async () => {
}
publishing.value = true;
try {
await publishBroadcast({
const publishedWithTargeting = form.targets.projectCodes.length > 0
|| form.targets.positionNames.length > 0 || form.targets.positionLevels.length > 0;
const response = await publishBroadcast({
requestId: form.requestId,
title: form.title,
content: form.content,
expectedTenantId: pendingTenantId.value
expectedTenantId: pendingTenantId.value,
requiredRead: form.requiredRead,
targets: copyTargets(form.targets)
});
if (!resetPublishForm()) {
ElMessage.warning('消息已发布,但浏览器无法清理重试记录;请不要新建消息,先恢复浏览器存储后重试原请求。');
return;
}
publishVisible.value = false;
ElMessage.success('消息已发布');
const matchedRecipientCount = Number(response.data?.matchedRecipientCount) || 0;
const unmatchedTargetCount = Number(response.data?.unmatchedTargetCount) || 0;
ElMessage.success(publishedWithTargeting
? `消息已发布,已定向提醒 ${matchedRecipientCount} 名员工${unmatchedTargetCount ? `,另有 ${unmatchedTargetCount} 名待匹配` : ''}`
: '消息已发布');
pageNum.value = 1;
await loadMessages(true);
} catch {
@@ -522,6 +658,18 @@ onMounted(() => {
margin-bottom: 12px;
}
.targeting-note {
width: 100%;
margin-bottom: 10px;
}
.target-selects {
display: grid;
width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.table-card {
padding: 18px;
border-radius: 8px;
@@ -558,6 +706,18 @@ onMounted(() => {
}
}
.target-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.target-count {
color: #687080;
font-size: 12px;
}
p {
margin: 6px 0 0;
}
@@ -594,5 +754,9 @@ onMounted(() => {
.broadcast-page {
padding: 16px;
}
.target-selects {
grid-template-columns: 1fr;
}
}
</style>
@@ -12,6 +12,10 @@
<view v-if="loading" class="panel employee-card loading-card" role="status" aria-live="polite">消息加载中…</view>
<view v-else-if="item" class="panel employee-card message-detail">
<text class="message-title">{{ item.title }}</text>
<view class="detail-tags">
<text v-if="item.requiredRead" class="detail-tag required">必读消息</text>
<text v-if="item.targeted" class="detail-tag related">与我有关{{ item.targetReason ? ` · ${item.targetReason}` : '' }}</text>
</view>
<text class="message-time">发布时间:{{ displayTime(item.publishedAt) }}</text>
<text class="message-content">{{ item.content }}</text>
<view class="detail-actions">
@@ -115,6 +119,10 @@ onLoad((options) => {
.status-message { display: block; margin-top: 16px; padding: 12px; border-radius: 12px; background: #fff0f1; color: #a9000d; line-height: 1.55; }
.loading-card, .message-detail { display: grid; gap: 12px; margin-top: 18px; padding: 20px; }
.message-title { overflow-wrap: anywhere; color: var(--ink); font-size: 21px; font-weight: 900; line-height: 1.45; }
.detail-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.detail-tag { padding: 4px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; }
.detail-tag.required { background: #fff0f1; color: #b20d19; }
.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; }
.detail-actions { display: grid; gap: 8px; margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--employee-line, #ebedf0); }
+38 -4
View File
@@ -16,6 +16,17 @@
</view>
<template v-else>
<view class="scope-tabs" role="tablist" aria-label="消息范围筛选">
<button
v-for="option in scopeOptions"
:key="option.value"
class="scope-tab"
:class="{ active: scope === option.value }"
:aria-selected="scope === option.value"
role="tab"
@click="changeScope(option.value)"
>{{ option.label }}</button>
</view>
<text v-if="message && !loadFailed" class="status-message" role="status" aria-live="polite">{{ message }}</text>
<view v-if="loading" class="panel employee-card loading-card" role="status" aria-live="polite">消息加载中…</view>
<view v-else-if="loadFailed" class="panel employee-card empty-card" role="alert" aria-live="assertive">
@@ -30,7 +41,11 @@
<view v-else class="message-list">
<button v-for="item in items" :key="item.id" class="message-card employee-card" @click="openMessage(item.id)">
<view class="message-head">
<text class="message-state" :class="{ read: item.read }">{{ item.read ? '已读' : '未读' }}</text>
<view class="message-tags">
<text class="message-state" :class="{ read: item.read }">{{ item.read ? '已读' : '未读' }}</text>
<text v-if="item.requiredRead" class="message-tag required">必读</text>
<text v-if="item.targeted" class="message-tag related">与我有关</text>
</view>
<text class="message-time">{{ displayTime(item.publishedAt) }}</text>
</view>
<text class="message-title">{{ item.title }}</text>
@@ -47,7 +62,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type { BroadcastMessageSummary } from '@/services/broadcast';
import type { BroadcastMessageScope, BroadcastMessageSummary } from '@/services/broadcast';
import { listBroadcastMessages } from '@/services/broadcast';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
@@ -64,6 +79,12 @@ const items = ref<BroadcastMessageSummary[]>([]);
const total = ref(0);
const pageNum = ref(1);
const moreAvailable = ref(false);
const scope = ref<BroadcastMessageScope>('ALL');
const scopeOptions: Array<{ value: BroadcastMessageScope; label: string }> = [
{ value: 'ALL', label: '全部' },
{ value: 'RELATED', label: '与我有关' },
{ value: 'REQUIRED_UNREAD', label: '必读未读' }
];
const requests = createPageRequestScope();
const hasMore = computed(() => moreAvailable.value && items.value.length < total.value);
@@ -112,7 +133,7 @@ const load = async () => {
const request = requests.begin('broadcast-list');
loading.value = true;
try {
const result = await listBroadcastMessages(1, pageSize);
const result = await listBroadcastMessages(1, pageSize, scope.value);
if (!requests.isCurrent(request)) return;
applyFirstPage(result);
} catch (error) {
@@ -131,7 +152,7 @@ const loadMore = async () => {
loadingMore.value = true;
message.value = '';
try {
const result = await listBroadcastMessages(nextPageNum, pageSize);
const result = await listBroadcastMessages(nextPageNum, pageSize, scope.value);
if (!requests.isCurrent(request)) return;
appendPage(result, nextPageNum);
} catch (error) {
@@ -144,6 +165,12 @@ const loadMore = async () => {
const openMessage = (id: number) => uni.navigateTo({ url: `/pages/user/broadcast/detail?id=${id}` });
const changeScope = (nextScope: BroadcastMessageScope) => {
if (scope.value === nextScope || loading.value || loadingMore.value) return;
scope.value = nextScope;
void load();
};
onShow(() => {
requests.activate();
resetPageScroll();
@@ -166,10 +193,17 @@ onUnload(() => {
.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; }
.scope-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 16px; padding: 4px; border-radius: 14px; background: #f3f4f6; }
.scope-tab { min-height: 36px; border-radius: 10px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 800; }
.scope-tab.active { background: #fff; color: var(--brand); box-shadow: 0 1px 4px rgba(20, 28, 42, .12); }
.message-card { display: grid; width: 100%; gap: 10px; padding: 16px; color: var(--ink); text-align: left; }
.message-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.message-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.message-state { padding: 3px 8px; border-radius: 999px; background: #fff0f1; color: var(--brand); font-size: 12px; font-weight: 900; }
.message-state.read { background: #f2f3f5; color: var(--muted); }
.message-tag { padding: 3px 7px; border-radius: 999px; font-size: 11px; font-weight: 800; }
.message-tag.required { background: #fff0f1; color: #b20d19; }
.message-tag.related { background: #fff7e8; color: #a76500; }
.message-time { color: var(--muted); font-size: 12px; }
.message-title { overflow-wrap: anywhere; font-size: 17px; font-weight: 900; line-height: 1.5; }
.load-more { width: 100%; min-height: 42px; border: 1px solid #f0a6ac; border-radius: 999px; background: #fff; color: var(--brand); font-size: 14px; font-weight: 800; }
+7 -2
View File
@@ -3,6 +3,8 @@ import { apiRequest } from './api';
export const broadcastQuestionContextStorageKey = 'aihr_broadcast_question_context';
export const broadcastQuestionContextTtlMs = 15 * 60 * 1000;
export type BroadcastMessageScope = 'ALL' | 'RELATED' | 'REQUIRED_UNREAD';
export interface BroadcastQuestionContext {
messageId: number;
accountKey: string;
@@ -14,6 +16,9 @@ export interface BroadcastMessageSummary {
title: string;
read: boolean;
publishedAt: string;
requiredRead: boolean;
targeted: boolean;
targetReason?: string;
}
export interface BroadcastMessage extends BroadcastMessageSummary {
@@ -36,9 +41,9 @@ export const getBroadcastUnreadCount = async () => {
return Math.max(0, Number(result?.unreadCount) || 0);
};
export const listBroadcastMessages = (pageNum = 1, pageSize = 20) =>
export const listBroadcastMessages = (pageNum = 1, pageSize = 20, scope: BroadcastMessageScope = 'ALL') =>
apiRequest<BroadcastMessagePage>({
url: `/api/aihr/broadcast/messages?pageNum=${pageNum}&pageSize=${pageSize}`,
url: `/api/aihr/broadcast/messages?pageNum=${pageNum}&pageSize=${pageSize}&scope=${scope}`,
method: 'GET',
timeout: 15000
});
+1 -1
View File
@@ -43,7 +43,7 @@ test('频道支持失败重试、分页和请求失效保护', async () => {
assert.match(list, /@click="load">重新加载/);
assert.match(list, /v-if="hasMore" class="load-more"/);
assert.match(list, /const loadMore = async \(\)/);
assert.match(list, /listBroadcastMessages\(nextPageNum, pageSize\)/);
assert.match(list, /listBroadcastMessages\(nextPageNum, pageSize, scope\.value\)/);
assert.match(list, /createPageRequestScope/);
assert.match(list, /const clearLoading = \(\) => \{[\s\S]*loadingMore\.value = false;/);
assert.match(list, /onHide\(\(\) => \{[\s\S]*clearLoading\(\);[\s\S]*requests\.invalidate\(\);/);