Files
NoticeService/src/main/java/com/yunhai/notice/service/impl/NoticeService.java
T
2026-09-01 19:55:12 +08:00

958 lines
41 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.notice.service.impl;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeRead;
import com.yunhai.notice.entity.NoticeReceiver;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.enums.NoticeStatusEnum;
import com.yunhai.notice.enums.NoticeTypeEnum;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.*;
import com.yunhai.notice.repository.NoticeReadRepository;
import com.yunhai.notice.repository.NoticeReceiverRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.service.IFileService;
import com.yunhai.notice.service.IDictService;
import com.yunhai.notice.service.INoticeService;
import com.yunhai.notice.service.sms.SmsSender;
import com.yunhai.notice.utils.PageUtils;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.auth.spring.CurrentBoaUser;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* 通知公告业务实现。
*/
@Service
public class NoticeService implements INoticeService {
private static final Logger log = LoggerFactory.getLogger(NoticeService.class);
@Autowired
private NoticeRepository noticeRepository;
@Autowired
private NoticeReadRepository noticeReadRepository;
@Autowired
private NoticeReceiverRepository noticeReceiverRepository;
@Autowired
private UpFileRepository upFileRepository;
@Autowired
private SmsSender smsSender;
@Autowired
private IFileService fileService;
@Autowired
private IDictService dictService;
@Autowired
private CurrentBoaUser currentBoaUser;
private static final int PUBLISHED = NoticeStatusEnum.PUBLISHED.getCode();
/**
* 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。
*/
private static String currentAccount() {
LoginUser user = UserContextHolder.get();
return user == null ? null : user.getLoginName();
}
/**
* 当前登录人姓名(平台默认仅提供登录名,展示姓名后续可接用户中心补齐)。
*/
private static String currentName() {
LoginUser user = UserContextHolder.get();
return user == null ? null : user.getDisplayName();
}
/**
* 当前登录人学校ID(多租户隔离:发布时填充,查询时过滤)。
* 取 BoaUser.currentRole.schId(当前激活身份学校),无身份时为 null。
*/
private Integer currentSchId() {
BoaUser boaUser = currentBoaUser.get();
if (boaUser == null || boaUser.getCurrentRole() == null) {
return null;
}
return boaUser.getCurrentRole().getSchId();
}
// ============================ 发通知 ============================
@Override
@Transactional
public Integer publish(NoticeRequest request) {
Notice entity = new Notice();
BeanUtils.copyProperties(request, entity);
entity.setId(null);
entity.setDeptName(resolveDeptName(request));
normalizeReceiveList(entity, request.getReceiveList());
// 无草稿态:一律置为「已发布」;定时发送保留 timingSendTime,由查询侧按「已到点」过滤可见性
entity.setStatus(PUBLISHED);
entity.setPublisherAccount(currentAccount());
entity.setPublisherName(currentName());
entity.setSchId(currentSchId());
entity.setIsPulled(0);
entity.setReadCount(0);
noticeRepository.save(entity);
// 写入接收人关联表(非全员时)
saveReceivers(entity);
// 绑定附件(前端上传后回传的 fileIds 逗号串)
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
// 立即发布且开启短信时预留短信发送(真实通道后续接入)
trySendSms(entity);
return entity.getId();
}
// ============================ 编辑 ============================
@Override
@Transactional
public void edit(Integer id, NoticeRequest request) {
Objects.requireNonNull(id, "通知ID不得为空");
Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id));
entity.setTitle(request.getTitle());
entity.setContent(request.getContent());
entity.setType(request.getType());
entity.setDeptId(request.getDeptId());
entity.setDeptName(resolveDeptName(request));
entity.setTopmost(request.getTopmost());
entity.setSmsFlag(request.getSmsFlag());
if (request.getReceiveList() != null) {
normalizeReceiveList(entity, request.getReceiveList());
// 更新接收人关联表
noticeReceiverRepository.deleteByNoticeId(entity.getId());
saveReceivers(entity);
}
if (request.getTimingSendTime() != null) {
entity.setTimingSendTime(request.getTimingSendTime());
}
// 附件全量替换:先解绑旧附件,再绑定本次提交的列表(fileIds 为 null 时不改动附件)
if (request.getFileIds() != null) {
for (UpFile old : upFileRepository.findByTopicTypeAndTopicId("notice", entity.getId())) {
old.setTopicId(null);
upFileRepository.save(old);
}
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
}
noticeRepository.save(entity);
}
/** 解析 "1,2,3" 形式的附件ID串,忽略非法片段 */
private List<Integer> parseFileIds(String fileIds) {
if (!StringUtils.hasText(fileIds)) {
return Collections.emptyList();
}
return Arrays.stream(fileIds.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(s -> {
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
// ============================ 删除(批量) ============================
@Override
@Transactional
public void deleteBatch(List<Integer> ids) {
Objects.requireNonNull(ids, "通知ID列表不得为空");
if (ids.isEmpty()) {
return;
}
for (Integer id : ids) {
if (!noticeRepository.existsById(id)) {
continue;
}
noticeReadRepository.deleteByNoticeId(id);
noticeReceiverRepository.deleteByNoticeId(id);
upFileRepository.deleteByTopicTypeAndTopicId("notice", id);
noticeRepository.deleteById(id);
}
}
// ============================ 撤回 ============================
@Override
@Transactional
public void pull(Integer id) {
Objects.requireNonNull(id, "通知ID不得为空");
Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id));
if (entity.getStatus() != PUBLISHED) {
throw new BizException(ErrorCode.FORBIDDEN, "仅已发布的通知可撤回");
}
if (entity.getIsPulled() != null && entity.getIsPulled() == 1) {
throw new BizException(ErrorCode.FORBIDDEN, "通知已被撤回,请勿重复操作");
}
entity.setIsPulled(1);
noticeRepository.save(entity);
}
// ============================ 置顶/取消置顶 ============================
@Override
@Transactional
public void setTopmost(Integer id, Integer topmost) {
Objects.requireNonNull(id, "通知ID不得为空");
Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id));
entity.setTopmost(topmost == null ? 0 : topmost);
noticeRepository.save(entity);
}
// ============================ 转发 ============================
@Override
@Transactional
public Integer forward(Integer sourceId, NoticeRequest request) {
Objects.requireNonNull(sourceId, "源通知ID不得为空");
Notice source = noticeRepository.findById(sourceId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "源通知不存在: " + sourceId));
Notice entity = new Notice();
BeanUtils.copyProperties(request, entity);
entity.setId(null);
entity.setDeptName(resolveDeptName(request));
normalizeReceiveList(entity, request.getReceiveList());
entity.setStatus(PUBLISHED);
entity.setPublisherAccount(currentAccount());
entity.setPublisherName(currentName());
entity.setParentId(sourceId);
entity.setIsPulled(0);
entity.setReadCount(0);
entity.setParentId(sourceId);
entity.setSchId(currentSchId());
noticeRepository.save(entity);
saveReceivers(entity);
trySendSms(entity);
return entity.getId();
}
// ============================ 用户端列表(含未读) ============================
@Override
public NoticePageResult<NoticeVo> userList(NoticeQuery query, Pageable pageable) {
String account = currentAccount();
Set<Integer> readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account));
Page<Notice> page = noticeRepository.findAll(userSpec(query, readIds), pageable);
List<NoticeVo> content = page.getContent().stream()
.map(n -> toVo(n, readIds.contains(n.getId())))
.collect(Collectors.toList());
long unreadCount = 0;
Integer schId = currentSchId();
if (schId != null) {
unreadCount = noticeRepository.countPublishedDueBySchId(schId, PUBLISHED, LocalDateTime.now())
- noticeReadRepository.countByReaderAccount(account);
} else {
unreadCount = noticeRepository.countPublishedDue(PUBLISHED, LocalDateTime.now())
- noticeReadRepository.countByReaderAccount(account);
}
if (unreadCount < 0) {
unreadCount = 0;
}
return new NoticePageResult<>(content, (int) page.getTotalElements(),
page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext(), unreadCount);
}
/**
* 用户端动态查询:仅已发布且已到点(定时发送未到点不可见);支持标题关键词、类型、部门、时间范围、未读/已读过滤。
* 多租户:按当前用户学校过滤(schId 为空时不加过滤,兼容无学校归属用户)。
*/
private Specification<Notice> userSpec(NoticeQuery query, Set<Integer> readIds) {
return (Root<Notice> root, CriteriaQuery<?> cq, CriteriaBuilder cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("status"), PUBLISHED));
predicates.add(cb.or(cb.isNull(root.get("timingSendTime")),
cb.lessThanOrEqualTo(root.get("timingSendTime"), LocalDateTime.now())));
appendCommonPredicates(query, root, cb, predicates);
appendSchIdPredicate(root, cb, predicates);
Integer readType = query.getReadType() == null ? 0 : query.getReadType();
if (readType == 1 && !readIds.isEmpty()) {
predicates.add(cb.not(root.get("id").in(readIds)));
} else if (readType == 2) {
if (readIds.isEmpty()) {
predicates.add(cb.equal(root.get("id"), -1));
} else {
predicates.add(root.get("id").in(readIds));
}
}
return cq.where(predicates.toArray(new Predicate[0])).getRestriction();
};
}
// ============================ 管理端列表(全部) ============================
@Override
public BasePageResult<NoticeVo> adminList(NoticeQuery query, Pageable pageable) {
Page<Notice> page = noticeRepository.findAll(adminSpec(query), pageable);
List<NoticeVo> content = page.getContent().stream()
.map(n -> toVo(n, false))
.collect(Collectors.toList());
// 批量填充附件ID串("1,2,3"):列表页"附件下载"入口依赖 UpFile 字段
if (!content.isEmpty()) {
Map<Integer, List<Integer>> fileMap = batchFileIds(
content.stream().map(NoticeVo::getId).collect(Collectors.toList()));
for (NoticeVo vo : content) {
List<Integer> fids = fileMap.getOrDefault(vo.getId(), Collections.emptyList());
vo.setUpFile(fids.stream().map(String::valueOf).collect(Collectors.joining(",")));
}
}
return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements()));
}
/**
* 管理端动态查询:按状态/标题/类型/部门/发布人/时间范围过滤。
* 多租户:按当前用户学校过滤(schId 为空时不加过滤)。
* scope="user" 时仅看当前用户发布的通知。
*/
private Specification<Notice> adminSpec(NoticeQuery query) {
return (Root<Notice> root, CriteriaQuery<?> cq, CriteriaBuilder cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (query.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), query.getStatus()));
}
appendCommonPredicates(query, root, cb, predicates);
// scope="user" → 只看自己发布的通知
if ("user".equalsIgnoreCase(query.getScope())) {
String account = currentAccount();
if (account != null) {
predicates.add(cb.equal(root.get("publisherAccount"), account));
}
} else if (StringUtils.hasText(query.getPublisherAccount())) {
predicates.add(cb.equal(root.get("publisherAccount"), query.getPublisherAccount()));
}
appendSchIdPredicate(root, cb, predicates);
return cq.where(predicates.toArray(new Predicate[0])).getRestriction();
};
}
/**
* 多租户学校过滤:当前用户有学校身份时按 schId 过滤,无学校归属(区域组/根直属)不过滤。
*/
private void appendSchIdPredicate(Root<Notice> root, CriteriaBuilder cb, List<Predicate> predicates) {
Integer schId = currentSchId();
if (schId != null) {
predicates.add(cb.equal(root.get("schId"), schId));
}
}
private void appendCommonPredicates(NoticeQuery query, Root<Notice> root, CriteriaBuilder cb,
List<Predicate> predicates) {
if (StringUtils.hasText(query.getKeyword())) {
predicates.add(cb.like(root.get("title"), "%" + query.getKeyword() + "%"));
}
if (query.getType() != null) {
predicates.add(cb.equal(root.get("type"), query.getType()));
}
if (StringUtils.hasText(query.getDeptId())) {
predicates.add(cb.equal(root.get("deptId"), query.getDeptId()));
}
// 前端传 yyyy-MM-dd 短日期:startTime 当天 00:00:00 起,endTime 补全到当天 23:59:59
if (query.getStartTime() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime().atStartOfDay()));
}
if (query.getEndTime() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime().atTime(23, 59, 59)));
}
}
// ============================ 详情(顺带标记已读) ============================
@Override
public NoticeVo detail(Integer id) {
Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id));
String account = currentAccount();
boolean read = noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account);
NoticeVo vo = toVo(entity, read);
if (!read) {
markRead(id);
vo.setRead(true);
}
return vo;
}
// ============================ 编辑回显(不标记已读) ============================
@Override
public NoticeVo getDetail(Integer id) {
Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id));
NoticeVo vo = toVo(entity, false);
vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId()));
return vo;
}
// ============================ 标记已读 ============================
@Override
@Transactional
public void markRead(Integer id) {
Objects.requireNonNull(id, "通知ID不得为空");
String account = currentAccount();
if (noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account)) {
return;
}
NoticeRead read = new NoticeRead();
read.setNoticeId(id);
read.setReaderAccount(account);
read.setReaderName(currentName());
read.setReadTime(LocalDateTime.now());
noticeReadRepository.save(read);
}
// ============================ 一键全部已读(批量化) ============================
@Override
@Transactional
public int initializeRed() {
String account = currentAccount();
List<Integer> readIds = noticeReadRepository.findNoticeIdsByReaderAccount(account);
Set<Integer> readSet = new HashSet<>(readIds);
List<Notice> published = noticeRepository.findAll((root, query, cb) ->
cb.and(
cb.equal(root.get("status"), PUBLISHED),
cb.or(cb.isNull(root.get("timingSendTime")),
cb.lessThanOrEqualTo(root.get("timingSendTime"), LocalDateTime.now()))
));
int count = 0;
for (Notice n : published) {
if (!readSet.contains(n.getId())) {
markRead(n.getId());
count++;
}
}
return count;
}
// ============================ 追加接收人(事务化) ============================
@Override
@Transactional
public void receiveUsers(Integer noticeId, String users) {
if (!StringUtils.hasText(users)) {
throw new BizException(ErrorCode.PARAM_ERROR, "请选择追送用户!");
}
Notice entity = noticeRepository.findById(noticeId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
if (entity.getReceiveList() != null && "ALL".equalsIgnoreCase(entity.getReceiveList().trim())) {
return;
}
// 合并旧接收人 + 新接收人
Set<String> merged = new LinkedHashSet<>(parseReceiveAccounts(entity.getReceiveList()));
for (String u : users.split(",")) {
String account = u.trim();
if (StringUtils.hasText(account)) {
merged.add(account);
}
}
entity.setReceiveList(toJsonAccounts(new ArrayList<>(merged)));
entity.setAllowCount(merged.size());
noticeRepository.save(entity);
// 更新接收人关联表
noticeReceiverRepository.deleteByNoticeId(noticeId);
saveReceivers(entity);
log.info("[receiveUsers] 通知 {} 追送用户, 现接收人 {} 人", noticeId, merged.size());
}
// ============================ 上一篇/下一篇(SQL 相邻查询) ============================
@Override
public Map<String, Object> getNextPrev(Integer serid, String type) {
Map<String, Object> data = new LinkedHashMap<>();
Notice current = noticeRepository.findById(serid).orElse(null);
if (current == null) {
data.put("prevId", 0);
data.put("nextId", 0);
return data;
}
if ("0".equals(type)) {
// 已发序列:按当前用户发布的通知,按 createTime 排序
List<Notice> published = noticeRepository.findAll(
(root, query, cb) -> cb.equal(root.get("publisherAccount"), currentAccount()),
Sort.by(Sort.Direction.DESC, "createTime"));
List<Integer> ids = published.stream().map(Notice::getId).collect(Collectors.toList());
int index = ids.indexOf(serid);
data.put("prevId", index > 0 ? ids.get(index - 1) : 0);
data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0);
} else {
// 已收序列:按阅读记录
List<Integer> ids = noticeReadRepository.findNoticeIdsByReaderAccount(currentAccount());
Collections.reverse(ids);
int index = ids.indexOf(serid);
data.put("prevId", index > 0 ? ids.get(index - 1) : 0);
data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0);
}
return data;
}
// ============================ 阅读明细 ============================
@Override
public List<NoticeVo.ReadDetail> getReadDetail(Integer noticeId) {
Objects.requireNonNull(noticeId, "通知ID不得为空");
List<NoticeRead> readList = noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(noticeId);
return readList.stream().map(r -> {
NoticeVo.ReadDetail detail = new NoticeVo.ReadDetail();
detail.setAccount(r.getReaderAccount());
detail.setName(r.getReaderName());
detail.setRead(true);
detail.setReadTime(r.getReadTime());
return detail;
}).collect(Collectors.toList());
}
// ============================ 阅读情况分组 ============================
/**
* 阅读情况按「学校组」分组(对齐旧 .NET OrgizeModify:组名 + 组内用户,绿已读/红未读)。
* <p>与旧版差异:旧版接收人逐行落库(含状态),本实现已读=notice_read 明细、
* 接收人=notice_receiver 明细;用户姓名/所属组经用户中心 byLoginName 实时查询,
* 查不到或跨校的接收人归「其他」组。全员通知(receive_list=ALL)不落接收明细,
* 仅能展示已读人员。</p>
*/
@Override
public List<Map<String, Object>> getReadDetailGrouped(Integer serid) {
Notice notice = noticeRepository.findById(serid)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + serid));
List<NoticeVo.ReadDetail> readDetails = getReadDetail(serid);
Set<String> readAccounts = readDetails.stream()
.map(NoticeVo.ReadDetail::getAccount)
.collect(Collectors.toSet());
// 已读快照姓名:用户中心查不到时兜底展示
Map<String, String> readNameMap = new HashMap<>();
for (NoticeVo.ReadDetail d : readDetails) {
if (StringUtils.hasText(d.getName())) {
readNameMap.putIfAbsent(d.getAccount(), d.getName());
}
}
// 接收人:优先关联表明细,缺失回退 receive_list JSON(历史数据)
List<String> receivers = noticeReceiverRepository.findByNoticeId(serid).stream()
.map(NoticeReceiver::getAccount)
.distinct()
.collect(Collectors.toList());
if (receivers.isEmpty()) {
receivers = parseReceiveAccounts(notice.getReceiveList());
}
// 已读者不在接收明细中(如追送/数据修补),补充到末尾,保证已读情况完整
for (String account : readAccounts) {
if (!receivers.contains(account)) {
receivers.add(account);
}
}
// 逐接收人查用户中心(姓名+组织链)。接收明细可能存登录名,也可能存历史数字ID
// (如 10012):先按登录名查,查不到且为纯数字再按 userId 查,保证未读人也能取到组
Map<String, BoaUser> userMap = new LinkedHashMap<>();
Map<String, String> canonicalAccount = new HashMap<>(); // 原始串 -> 规范登录名
for (String account : receivers) {
BoaUser user = null;
try {
user = YschBaseOpenApi.getUserInfo(account);
if (user == null && account.chars().allMatch(Character::isDigit)) {
user = YschBaseOpenApi.getUserInfo(Integer.parseInt(account));
}
} catch (Exception e) {
log.warn("[readDetailGrouped] 查询用户中心失败, account={}", account, e);
}
if (user != null) {
userMap.put(account, user);
if (StringUtils.hasText(user.getAccount())) {
canonicalAccount.put(account, user.getAccount());
}
}
}
// 组名=用户组织链最近端(主组),跨校/查不到归「其他」(对齐旧版逻辑)
Map<String, List<Map<String, Object>>> groupUsers = new LinkedHashMap<>();
for (String account : receivers) {
BoaUser user = userMap.get(account);
// 已读比对/姓名兜底统一使用规范登录名(接收明细可能存的是历史数字ID)
String canonical = canonicalAccount.getOrDefault(account, account);
boolean read = readAccounts.contains(canonical) || readAccounts.contains(account);
String groupName = resolveReadGroupName(user, notice.getSchId());
String teaName = user != null && StringUtils.hasText(user.getName())
? user.getName()
: readNameMap.getOrDefault(canonical, canonical);
Map<String, Object> item = new LinkedHashMap<>();
item.put("account", canonical);
item.put("teaname", teaName);
item.put("states", read ? "read" : "unread");
groupUsers.computeIfAbsent(groupName, k -> new ArrayList<>()).add(item);
}
List<Map<String, Object>> groups = new ArrayList<>();
for (Map.Entry<String, List<Map<String, Object>>> entry : groupUsers.entrySet()) {
Map<String, Object> group = new LinkedHashMap<>();
group.put("GroupName", entry.getKey());
group.put("teaslist", entry.getValue());
groups.add(group);
}
return groups;
}
/**
* 阅读情况分组名:用户中心组织链最近端(主组)即所属组(如「英语组」);
* 直接挂学校根的展示学校名;跨校或用户中心查不到的归「其他」。
*/
private String resolveReadGroupName(BoaUser user, Integer noticeSchId) {
if (user == null) {
return "其他";
}
if (noticeSchId != null && user.getSchool() != null && user.getSchool().getId() != null
&& !noticeSchId.equals(user.getSchool().getId())) {
return "其他";
}
List<BoaGroup> groups = user.getGroups();
if (groups != null && !groups.isEmpty() && StringUtils.hasText(groups.get(0).getName())) {
return groups.get(0).getName();
}
if (user.getSchool() != null && StringUtils.hasText(user.getSchool().getName())) {
return user.getSchool().getName();
}
return "其他";
}
// ============================ 催收未读 ============================
@Override
public void expedite(Integer serid) {
Notice notice = noticeRepository.findById(serid)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + serid));
Set<String> readAccounts = noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(serid).stream()
.map(NoticeRead::getReaderAccount)
.collect(Collectors.toSet());
List<String> receivers = parseReceiveAccounts(notice.getReceiveList());
List<String> unreadAccounts = receivers.stream()
.filter(a -> !readAccounts.contains(a))
.collect(Collectors.toList());
log.info("[expedite] 通知 {} 催收未读 {} 人: {}", serid, unreadAccounts.size(), unreadAccounts);
// TODO: 接入 yh-platform 消息推送后,对 unreadAccounts 发送催办消息
}
// ============================ 统计 ============================
@Override
public NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end) {
String account = currentAccount();
Integer schId = currentSchId();
LocalDateTime s = (start == null) ? LocalDateTime.of(1970, 1, 1, 0, 0) : start;
LocalDateTime e = (end == null) ? LocalDateTime.now().plusDays(1) : end;
// 多租户:统计总数按当前学校过滤(schId 为空时统计全校)
long total = schId != null ? noticeRepository.countBySchId(schId)
: noticeRepository.count();
long publishedCount = schId != null ? noticeRepository.countBySchIdAndStatus(schId, PUBLISHED)
: noticeRepository.countByStatus(PUBLISHED);
long totalReadRecords = noticeReadRepository.count();
long unreadForCurrentUser = (schId != null ? noticeRepository.countPublishedDueBySchId(schId, PUBLISHED, LocalDateTime.now())
: noticeRepository.countPublishedDue(PUBLISHED, LocalDateTime.now()))
- noticeReadRepository.countByReaderAccount(account);
if (unreadForCurrentUser < 0) {
unreadForCurrentUser = 0;
}
List<LocalDateTime> createTimes = schId != null
? noticeRepository.findCreateTimesBySchIdStatusAndRange(schId, PUBLISHED, s, e)
: noticeRepository.findCreateTimesByStatusAndRange(PUBLISHED, s, e);
Map<String, Long> monthMap = new LinkedHashMap<>();
for (LocalDateTime t : createTimes) {
String key = t.getYear() + "-" + String.format("%02d", t.getMonthValue());
monthMap.merge(key, 1L, Long::sum);
}
List<NoticeStatisticsVo.MonthStat> byMonth = monthMap.entrySet().stream()
.map(en -> {
String[] ym = en.getKey().split("-");
return new NoticeStatisticsVo.MonthStat(
Integer.parseInt(ym[0]), Integer.parseInt(ym[1]), en.getValue());
})
.collect(Collectors.toList());
List<String> publishers = schId != null
? noticeRepository.findPublisherNamesBySchStatusAndRange(schId, PUBLISHED, s, e)
: noticeRepository.findPublisherNamesByStatusAndRange(PUBLISHED, s, e);
Map<String, Long> pubMap = new LinkedHashMap<>();
for (String p : publishers) {
pubMap.merge(p == null ? "未知" : p, 1L, Long::sum);
}
List<NoticeStatisticsVo.PublisherStat> byPublisher = pubMap.entrySet().stream()
.map(en -> new NoticeStatisticsVo.PublisherStat(en.getKey(), en.getValue()))
.collect(Collectors.toList());
NoticeStatisticsVo vo = new NoticeStatisticsVo();
vo.setTotal(total);
vo.setPublishedCount(publishedCount);
vo.setTotalReadRecords(totalReadRecords);
vo.setUnreadForCurrentUser(unreadForCurrentUser);
vo.setByPublisher(byPublisher);
vo.setByMonth(byMonth);
return vo;
}
// ============================ 详情视图 VO(供 Controller 返回) ============================
@Override
public NoticeDetailVo getDetailMap(Integer serid) {
NoticeVo vo = getDetail(serid);
NoticeDetailVo data = new NoticeDetailVo();
data.setSerid(serid);
data.setTitle(vo.getTitle());
data.setContents(vo.getContent());
data.setDeptId(vo.getDeptId());
data.setDeptName(vo.getDeptName());
data.setPublisherName(vo.getPublisherName());
data.setEditDate(formatTime(vo.getCreateTime()));
data.setNumCount(vo.getAllowCount() != null ? vo.getAllowCount() : 0);
data.setReadNumCount(vo.getReadCount());
data.setReceiveList(vo.getReceiveList());
data.setDsfsTime(vo.getTimingSendTime() != null ? formatTime(vo.getTimingSendTime()) : null);
data.setIsDsfs(vo.getTimingSendTime() != null);
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId("notice", serid);
List<NoticeDetailVo.FileItem> fileList = files.stream().map(f -> {
NoticeDetailVo.FileItem item = new NoticeDetailVo.FileItem();
item.setId(f.getId());
item.setName(f.getFileTrueName());
item.setSize(f.getFileSize());
item.setFileid(f.getId());
item.setFilename(f.getFileName());
return item;
}).collect(Collectors.toList());
data.setChaoSend(fileList);
String rl = vo.getReceiveList();
if (rl == null || rl.trim().isEmpty() || "ALL".equalsIgnoreCase(rl.trim())) {
data.setTlist("all");
} else {
data.setTlist(parseReceiveAccounts(rl));
}
return data;
}
// ============================ 已发列表附件批量查询 ============================
@Override
public Map<Integer, List<Integer>> batchFileIds(List<Integer> noticeIds) {
if (noticeIds == null || noticeIds.isEmpty()) {
return Collections.emptyMap();
}
Map<Integer, List<Integer>> result = new HashMap<>();
for (Integer id : noticeIds) {
result.put(id, new ArrayList<>());
}
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicIdIn("notice", noticeIds);
for (UpFile f : files) {
result.computeIfAbsent(f.getTopicId(), k -> new ArrayList<>()).add(f.getId());
}
return result;
}
// ============================ 详情视图 VO(type 控制标读) ============================
@Override
public NoticeDetailVo detailMap(Integer serid, Integer type) {
if (type != null && type == 0) {
// 编辑模式:不标记已读
return getDetailMap(serid);
}
// 查看模式:先标记已读再返回
detail(serid);
return getDetailMap(serid);
}
// ============================ 一键已读结果 ============================
@Override
@Transactional
public InitializeRedVo initializeRedResult() {
int count = initializeRed();
return new InitializeRedVo(count);
}
// ============================ 批量下载附件 ============================
@Override
public void downloadAllFiles(Integer dataid, HttpServletResponse response) {
NoticeDetailVo detail = getDetailMap(dataid);
String title = detail.getTitle();
fileService.downloadZipByTopic("notice", dataid, title != null ? title : "附件", response);
}
// ============================ 转换 ============================
private NoticeVo toVo(Notice entity, boolean read) {
NoticeVo vo = new NoticeVo();
BeanUtils.copyProperties(entity, vo);
vo.setTypeDesc(NoticeTypeEnum.of(entity.getType()).getDesc());
vo.setStatusDesc(NoticeStatusEnum.of(entity.getStatus()).getDesc());
// 存量数据 dept_name 为空时按 deptId 从字典兜底解析(对应 C# GetDeptName)
if (!StringUtils.hasText(vo.getDeptName())) {
vo.setDeptName(dictService.resolveDeptName(vo.getDeptId()));
}
vo.setRead(read);
vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId()));
return vo;
}
/**
* 写入时解析部门名称:优先按 deptId 查字典,字典缺失时回退前端传值。
*/
private String resolveDeptName(NoticeRequest request) {
String resolved = dictService.resolveDeptName(request.getDeptId());
return StringUtils.hasText(resolved) ? resolved : request.getDeptName();
}
// ============================ 短信(预留能力) ============================
private void trySendSms(Notice entity) {
if (entity.getSmsFlag() == null || entity.getSmsFlag() != 1) {
return;
}
if (!isImmediatePublish(entity)) {
return;
}
smsSender.sendNoticeSms(entity.getId(), entity.getTitle(),
parseReceiveList(entity.getReceiveList()));
}
private boolean isImmediatePublish(Notice entity) {
return entity.getTimingSendTime() == null
|| !entity.getTimingSendTime().isAfter(LocalDateTime.now());
}
// ============================ 接收人工具方法 ============================
/**
* 接收人列表归一化:前端/存量可能传 "all"/"UserAll"/"ALL"(全员),统一存 "ALL"。
* 其余视为 JSON 数组(账号)或逗号分隔串,原样保留。
*/
private void normalizeReceiveList(Notice entity, String receiveList) {
if (receiveList == null) {
entity.setReceiveList(null);
return;
}
String rl = receiveList.trim();
if ("all".equalsIgnoreCase(rl) || "UserAll".equalsIgnoreCase(rl) || "ALL".equalsIgnoreCase(rl)) {
entity.setReceiveList("ALL");
return;
}
entity.setReceiveList(receiveList);
// 设置接收人数
entity.setAllowCount(parseReceiveAccounts(receiveList).size());
}
/**
* 写入接收人关联表(非全员时)。
*/
private void saveReceivers(Notice entity) {
String rl = entity.getReceiveList();
if (rl == null || "ALL".equalsIgnoreCase(rl.trim())) {
return;
}
List<String> accounts = parseReceiveAccounts(rl);
for (String account : accounts) {
NoticeReceiver receiver = new NoticeReceiver();
receiver.setNoticeId(entity.getId());
receiver.setAccount(account);
receiver.setReceiveType(1);
noticeReceiverRepository.save(receiver);
}
}
/**
* 解析接收人列表为账号集合:支持 "ALL"→空、JSON 数组 ["a","b"]、逗号分隔串。
*/
private List<String> parseReceiveAccounts(String receiveList) {
if (receiveList == null || receiveList.trim().isEmpty()) {
return Collections.emptyList();
}
String rl = receiveList.trim();
if ("ALL".equalsIgnoreCase(rl) || "UserAll".equalsIgnoreCase(rl) || "all".equalsIgnoreCase(rl)) {
return Collections.emptyList();
}
List<String> result = new ArrayList<>();
if (rl.startsWith("[")) {
String inner = rl.substring(1, rl.length() - 1).trim();
if (!inner.isEmpty()) {
for (String part : inner.split(",")) {
String account = part.trim().replace("\"", "").replace("'", "");
if (StringUtils.hasText(account) && !result.contains(account)) {
result.add(account);
}
}
}
} else {
for (String part : rl.split(",")) {
String account = part.trim();
if (StringUtils.hasText(account) && !result.contains(account)) {
result.add(account);
}
}
}
return result;
}
/**
* 解析接收人列表(JSON 数组字符串 -> 账号列表)。
* 为空或解析失败时返回空列表(表示全员通知)。
*/
private List<String> parseReceiveList(String receiveListJson) {
return parseReceiveAccounts(receiveListJson);
}
private String toJsonAccounts(List<String> accounts) {
return accounts.stream()
.map(a -> "\"" + a.replace("\"", "\\\"") + "\"")
.collect(Collectors.joining(",", "[", "]"));
}
private String formatTime(LocalDateTime time) {
if (time == null) return null;
return time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
}