feat(aihr): add M1 broadcast targeting and mandatory reads
This commit is contained in:
+10
-2
@@ -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));
|
||||
|
||||
+74
-5
@@ -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(
|
||||
|
||||
+369
-40
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+62
-3
@@ -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;
|
||||
|
||||
+10
-2
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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\(\);/);
|
||||
|
||||
Reference in New Issue
Block a user