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(
@@ -49,6 +62,22 @@ public final class AihrBroadcastDto {
}
public record AdminBroadcastListItem(
Long id,
String title,
String content,
String status,
int currentVersion,
Long publishedBy,
String publishedAt,
Long withdrawnBy,
String withdrawnAt,
String withdrawReason,
boolean requiredRead,
long targetRuleCount,
long matchedRecipientCount,
long unmatchedTargetCount
) {
public AdminBroadcastListItem(
Long id,
String title,
String content,
@@ -60,6 +89,38 @@ public final class AihrBroadcastDto {
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); }
+37 -3
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">
<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\(\);/);