958 lines
41 KiB
Java
958 lines
41 KiB
Java
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"));
|
||
}
|
||
}
|