通知
build / build-push (push) Failing after 1m18s

This commit is contained in:
wanghu
2026-09-10 15:39:41 +08:00
parent 2c8125a549
commit 3016a97f1b
86 changed files with 3427 additions and 1669 deletions
+3 -3
View File
@@ -73,8 +73,8 @@ jobs:
set -e
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u "${{ secrets.NEXUS_USER }}" --password-stdin
docker build -t "nexus:8083/docker-hosted/notice-service:${TAG}" -f docker/Dockerfile .
docker push "nexus:8083/docker-hosted/notice-service:${TAG}"
docker build -t "nexus:8083/docker-hosted/campusnotice:${TAG}" -f docker/Dockerfile .
docker push "nexus:8083/docker-hosted/campusnotice:${TAG}"
echo "TAG=${TAG}" >> "$GITHUB_ENV"
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
@@ -83,4 +83,4 @@ jobs:
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
-d "{\"app\":\"notice\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}"
-d "{\"app\":\"campusnotice\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}"
+5 -5
View File
@@ -1,11 +1,11 @@
# Notice 生产配置(线上以 nacos 的 notice.yaml 为准,本文件是 nacos 不可达时的兜底)
# 真正上线请在 Nacos 新建 dataId=notice.yaml、group=NOTICE 的配置覆盖以下项
# CampusNotice 生产配置(线上以 nacos 的 campusnotice.yaml 为准,本文件是 nacos 不可达时的兜底)
# 真正上线请在 Nacos 新建 dataId=campusnotice.yaml、group=OA_APP 的配置覆盖以下项
# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。
server:
port: 8070
servlet:
# 对外经 caddy 暴露为 http://base.yunhaixingtong.com/notice
context-path: /notice
# 对外经 caddy 暴露为 http://base.yunhaixingtong.com/campusnotice
context-path: /campusnotice
spring:
mvc:
@@ -35,7 +35,7 @@ spring:
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://base.yunhaixingtong.com/notice
client-host-url: http://base.yunhaixingtong.com/campusnotice
allow-headers: all
allow-origins: all
url-pattern: /api/*
+3 -3
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# Notice 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
# CampusNotice 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
# 镜像内烤好的 application-prod.yml 兜底默认。
set -e
echo "[run-app] starting Notice (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/notice.jar \
echo "[run-app] starting CampusNotice (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar \
--spring.profiles.active=prod
+4 -4
View File
@@ -11,9 +11,9 @@
</parent>
<groupId>com.yunhai</groupId>
<artifactId>notice</artifactId>
<artifactId>campusnotice</artifactId>
<version>0.0.1</version>
<name>Notice</name>
<name>CampusNotice</name>
<description>通知公告子应用</description>
<properties>
<java.version>1.8</java.version>
@@ -141,7 +141,7 @@
</dependencies>
<build>
<finalName>notice</finalName>
<finalName>campusnotice</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -157,7 +157,7 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.yunhai.notice.NoticeApplication</mainClass>
<mainClass>com.yunhai.campusnotice.CampusNoticeApplication</mainClass>
</configuration>
<executions>
<execution>
@@ -1,18 +1,20 @@
package com.yunhai.notice;
package com.yunhai.campusnotice;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication(scanBasePackages = "com.yunhai.notice")
@SpringBootApplication(scanBasePackages = "com.yunhai.campusnotice")
@EnableTransactionManagement
public class NoticeApplication {
@EnableScheduling
public class CampusNoticeApplication {
public static void main(String[] args) {
// 开启 fastjson 安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(NoticeApplication.class, args);
SpringApplication.run(CampusNoticeApplication.class, args);
}
}
@@ -1,6 +1,6 @@
package com.yunhai.notice.common.listener;
package com.yunhai.campusnotice.common.listener;
import com.yunhai.notice.entity.BaseEntity;
import com.yunhai.campusnotice.entity.BaseEntity;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import org.springframework.stereotype.Component;
@@ -1,4 +1,4 @@
package com.yunhai.notice.common.validation;
package com.yunhai.campusnotice.common.validation;
/**
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
@@ -1,4 +1,4 @@
package com.yunhai.notice.config;
package com.yunhai.campusnotice.config;
/**
* 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。
@@ -1,4 +1,4 @@
package com.yunhai.notice.config;
package com.yunhai.campusnotice.config;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
@@ -1,4 +1,4 @@
package com.yunhai.notice.config;
package com.yunhai.campusnotice.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
@@ -1,9 +1,11 @@
package com.yunhai.notice.controller.api;
package com.yunhai.campusnotice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.notice.UpFileResultVo;
import com.yunhai.notice.service.IFileService;
import com.yunhai.notice.service.IUserCenterService;
import com.yunhai.campusnotice.config.Config;
import com.yunhai.campusnotice.model.bsch.SchTreeNodeDto;
import com.yunhai.campusnotice.model.notice.UpFileResultVo;
import com.yunhai.campusnotice.service.IClassService;
import com.yunhai.campusnotice.service.IFileService;
import com.yunhai.campusnotice.service.IUserCenterService;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.utils.model.BoaUser;
@@ -32,6 +34,9 @@ public class BaseApiController {
@Autowired
private IUserCenterService userCenterService;
@Autowired
private IClassService classService;
@Autowired
private IFileService fileService;
@@ -73,9 +78,16 @@ public class BaseApiController {
// ============================ GetSchoolAll ============================
/**
* 获取当前用户学校组及用户(树形结构)。
* 获取当前用户学校学生树及学生
* 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。
*/
@GetMapping("getStuTree")
public List<SchTreeNodeDto> getStuTree(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return classService.getStuTree(schId);
}
@GetMapping("GetSchoolAll")
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
@@ -1,7 +1,7 @@
package com.yunhai.notice.controller.api;
package com.yunhai.campusnotice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.service.IContactsService;
import com.yunhai.campusnotice.config.Config;
import com.yunhai.campusnotice.service.IContactsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -1,7 +1,7 @@
package com.yunhai.notice.controller.api;
package com.yunhai.campusnotice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.service.IDictService;
import com.yunhai.campusnotice.config.Config;
import com.yunhai.campusnotice.service.IDictService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -1,9 +1,9 @@
package com.yunhai.notice.controller.api;
package com.yunhai.campusnotice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.notice.UpFileResultVo;
import com.yunhai.notice.model.notice.UpFileVo;
import com.yunhai.notice.service.IFileService;
import com.yunhai.campusnotice.config.Config;
import com.yunhai.campusnotice.model.notice.UpFileResultVo;
import com.yunhai.campusnotice.model.notice.UpFileVo;
import com.yunhai.campusnotice.service.IFileService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -0,0 +1,391 @@
package com.yunhai.campusnotice.controller.api;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.campusnotice.config.Config;
import com.yunhai.campusnotice.common.validation.ValidationGroups;
import com.yunhai.campusnotice.model.BasePageResult;
import com.yunhai.campusnotice.model.notice.*;
import com.yunhai.campusnotice.service.INoticeService;
import com.yunhai.campusnotice.utils.PageUtils;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 通知公告接口(合并 NoticeApiController + 旧 NoticeController)。
* <p>路径前缀:{@code Config.API_PATH + "/notice/"}(platform = api / manage / mobiapi)。
* 方法动词式 PascalCase(对齐 UserBase);写操作一律 POST。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/")
public class NoticeController {
@Autowired
private INoticeService noticeService;
// ============================ 收件箱列表 ============================
/**
* 已收通知列表(用户端)。
*/
@GetMapping("List")
public NoticePageResult<NoticeVo> list(NoticeQuery query,
@SortDefault(sort = {"topmost", "createTime"},
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.userList(query, PageUtils.toPageable(query, sort));
}
// ============================ 已发/全部通知列表 ============================
/**
* 已发/全部通知列表(管理端)。type=user 只看自己。
*/
@GetMapping("SendList")
public BasePageResult<NoticeVo> sendList(NoticeQuery query,
@SortDefault(sort = "createTime",
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.adminList(query, PageUtils.toPageable(query, sort));
}
// ============================ 查看详情(标读) ============================
/**
* 查看通知详情(type=1 普通查看标记已读, type=0 编辑模式不标记已读)。
*/
@GetMapping("Detail")
public NoticeDetailVo detail(@RequestParam("serid") Integer serid,
@RequestParam(value = "type", defaultValue = "1") Integer type) {
return noticeService.detailMap(serid, type);
}
// ============================ 编辑回显(不标读) ============================
/**
* 获取通知详情(编辑/转发用,不标记已读)。
*/
@GetMapping("GetDetail")
public NoticeDetailVo getDetail(@RequestParam("serid") Integer serid) {
return noticeService.getDetailMap(serid);
}
// ============================ 新建 ============================
/**
* 新建通知(JSON body)。fileIds 为附件ID逗号串,smsFlag 为短信标记。
*/
@PostMapping("Create")
public Integer create(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) {
return noticeService.publish(request);
}
// ============================ 编辑 ============================
/**
* 编辑通知(JSON body,含 id)。
*/
@PostMapping("Edit")
public void edit(@RequestBody @Validated(ValidationGroups.Update.class) NoticeRequest request) {
noticeService.edit(request.getSourceId() != null ? request.getSourceId() : 0, request);
}
// ============================ 批量删除 ============================
/**
* 批量删除通知(ids 数组)。
*/
@PostMapping("Delete")
public void delete(@RequestBody List<Integer> ids) {
noticeService.deleteBatch(ids);
}
// ============================ 撤回 ============================
/**
* 撤回通知。
*/
@PostMapping("UserPull")
public void userPull(@RequestParam("serid") Integer serid) {
noticeService.pull(serid);
}
// ============================ 置顶 ============================
/**
* 置顶/取消置顶。
*/
@PostMapping("SetTopmost")
public void setTopmost(@RequestParam("serid") Integer serid,
@RequestParam(value = "topmost", defaultValue = "1") Integer topmost) {
noticeService.setTopmost(serid, topmost);
}
// ============================ 追加接收人 ============================
/**
* 追加接收人。
*/
@PostMapping("ReceiveUsers")
public void receiveUsers(@RequestParam("serid") Integer serid,
@RequestParam("users") String users) {
noticeService.receiveUsers(serid, users);
}
// ============================ 催收 ============================
/**
* 催收未读。
*/
@PostMapping("Expedite")
public void expedite(@RequestParam("serid") Integer serid) {
noticeService.expedite(serid);
}
// ============================ 一键已读 ============================
/**
* 一键全部已读(POST 化,原为 GET 写操作)。
*/
@PostMapping("InitializeRed")
public InitializeRedVo initializeRed() {
return noticeService.initializeRedResult();
}
// ============================ 阅读情况 ============================
/**
* 阅读情况(按已读/未读分组)。
*/
@GetMapping("ReadDetail")
public List<Map<String, Object>> readDetail(@RequestParam("serid") Integer serid) {
return noticeService.getReadDetailGrouped(serid);
}
// ============================ 上一篇/下一篇 ============================
/**
* 上一篇/下一篇通知ID。
*/
@GetMapping("GetNextPrev")
public Map<String, Object> getNextPrev(@RequestParam("serid") Integer serid,
@RequestParam("type") String type) {
return noticeService.getNextPrev(serid, type);
}
// ============================ 附件打包下载 ============================
/**
* 批量下载通知全部附件(打包 zip)。
*/
@GetMapping("AllFileDown")
public void allFileDown(@RequestParam("dataid") Integer dataid,
HttpServletResponse response) {
noticeService.downloadAllFiles(dataid, response);
}
// ============================ 统计 ============================
/**
* 通知统计。
*/
@GetMapping("Statis")
public NoticeStatisticsVo statis(
@RequestParam(value = "starttime", required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime start,
@RequestParam(value = "endtime", required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime end) {
return noticeService.statistics(new NoticeQuery(), start, end);
}
// ============================ 转发(合并旧 REST 壳 forward) ============================
/**
* 转发通知(基于源通知创建新通知)。sourceId 在 request body 中传递。
*/
@PostMapping("Forward")
public Integer forward(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) {
return noticeService.forward(request.getSourceId(), request);
}
// ============================ 推送范围树(新版发布表单,改造方案 3.1) ============================
/**
* 当前学校学员推送范围树:全校根 + 学段→年级→班级(数据源 bschclass),
* 供表单「推送范围」胶囊按钮渲染。节点结构:{scopeType, scopeId, name, children}。
*/
@GetMapping("ScopeTree")
public List<Map<String, Object>> scopeTree() {
return noticeService.scopeTree();
}
// ============================ 推送范围预估 ============================
/**
* 按已选范围预估接收人数(学员 = 范围展开去重账号数 + 教师 = receiveList 定向名单数)。
* scopes 为 JSON 数组:[{"scopeType":3,"scopeId":5,"scopeName":"初一"}]
*/
@GetMapping("ScopePreview")
public ScopePreviewVo scopePreview(@RequestParam(value = "scopes", required = false) String scopes,
@RequestParam(value = "receiveList", required = false) String receiveList) {
return noticeService.scopePreview(parseScopes(scopes), receiveList);
}
// ============================ 存为草稿 ============================
/**
* 存为草稿(status=0):不圈人、不发短信、不落接收人,仅存内容 + 范围快照 + 附件。
*/
@PostMapping("SaveDraft")
public Integer saveDraft(@RequestBody NoticeRequest request) {
return noticeService.saveDraft(request);
}
// ============================ 草稿箱列表 ============================
/**
* 草稿箱列表(status=0 本人草稿):重要程度 type/typeDesc、定时标注与 timingSendTime。
*/
@GetMapping("DraftList")
public BasePageResult<NoticeVo> draftList(NoticeQuery query,
@SortDefault(sort = "updateTime",
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.draftList(query, PageUtils.toPageable(query, sort));
}
// ============================ 已发送阅读回执 ============================
/**
* 已发送通知阅读回执:按 年级→班级→学生 分组返回(身份快照),
* 学生带已读/未读状态与阅读时间(前端着色),教师接收行归「教师」组。
*/
@GetMapping("ReadReceipt")
public ReadReceiptVo readReceipt(@RequestParam("serid") Integer serid) {
return noticeService.readReceipt(serid);
}
// ============================ 审批人记忆回显 ============================
/**
* 审批人记忆回显:当前发布人最近一次所选审批人组合(无记忆返回空)。
*/
@GetMapping("ApproverSuggest")
public ApproverSuggestVo approverSuggest() {
return noticeService.approverSuggest();
}
// ============================ 审批人解析(教师树ID↔账号) ============================
/**
* 审批人解析:教师树(base/GetSchoolAll 叶子 id=教师用户ID)与登录账号互转。
* ids=1,2,3 解析账号(选人提交前);accounts=a,b 解析回ID(编辑回显勾树)。
* 两参数至少传其一;解析失败的项保留原值、缺目标字段,由前端提示。
*/
@GetMapping("ApproverResolve")
public List<ApproverResolveVo> approverResolve(
@RequestParam(value = "ids", required = false) String ids,
@RequestParam(value = "accounts", required = false) String accounts) {
if ((ids == null || ids.trim().isEmpty()) && (accounts == null || accounts.trim().isEmpty())) {
throw new BizException(ErrorCode.PARAM_ERROR, "ids 与 accounts 至少传其一");
}
return noticeService.approverResolve(
parseCsvInts(ids), parseCsvStrings(accounts));
}
/** 逗号串 → Integer 列表(忽略空段与非数字) */
private List<Integer> parseCsvInts(String csv) {
if (csv == null || csv.trim().isEmpty()) {
return null;
}
List<Integer> result = new ArrayList<>();
for (String s : csv.split(",")) {
try {
result.add(Integer.valueOf(s.trim()));
} catch (NumberFormatException ignore) {
}
}
return result;
}
/** 逗号串 → 字符串列表(忽略空段,兼容中文逗号/分号) */
private List<String> parseCsvStrings(String csv) {
if (csv == null || csv.trim().isEmpty()) {
return null;
}
return new ArrayList<>(java.util.Arrays.asList(csv.split("[,,;;]")));
}
// ============================ 提交审批 ============================
/**
* 提交审批(status: 0/3→2):发布人选定审批人(可多选,或签),
* 写 notice_approval 提交行并 upsert 审批人记忆表。
*/
@PostMapping("Submit")
public void submit(@RequestBody SubmitRequest request) {
noticeService.submit(request.getSerid(), request.getApproverAccounts(), request.getOpinion());
}
// ============================ 审批通过 ============================
/**
* 审批通过(或签):任一审批人通过即 status→1 并触发真实发布;
* 被他人先审时返回带实际处理人/时间的冲突提示。
*/
@PostMapping("Approve")
public void approve(@RequestParam("serid") Integer serid,
@RequestParam(value = "opinion", required = false) String opinion) {
noticeService.approve(serid, opinion);
}
// ============================ 审批驳回 ============================
/**
* 审批驳回(或签):任一审批人驳回即 status→3,意见必填;发布人可改后重新提交。
*/
@PostMapping("Reject")
public void reject(@RequestParam("serid") Integer serid,
@RequestParam("opinion") String opinion) {
noticeService.reject(serid, opinion);
}
// ============================ 审批列表(待审/已审合一) ============================
/**
* 审批列表:按审批人快照含当前账号过滤(schId 隔离)。
* auditState=pending(默认) 待审 / reviewed 已审(含或签下他人已处理,带实际处理人)。
*/
@GetMapping("ApprovalList")
public BasePageResult<ApprovalListVo> approvalList(
@RequestParam(value = "auditState", defaultValue = "pending") String auditState,
NoticeQuery query,
@SortDefault(sort = "updateTime", direction = Sort.Direction.DESC) Sort sort) {
return noticeService.approvalList(auditState, query, PageUtils.toPageable(query, sort));
}
/** 解析 scopes JSON 参数为范围列表(空/非法返回 null = 无范围) */
private List<PushScopeItem> parseScopes(String scopes) {
if (scopes == null || scopes.trim().isEmpty()) {
return null;
}
try {
return new ObjectMapper().readValue(scopes, new TypeReference<List<PushScopeItem>>() {
});
} catch (IOException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "scopes 参数格式错误,应为 JSON 数组:[{\"scopeType\":3,\"scopeId\":5}]");
}
}
}
@@ -1,7 +1,7 @@
package com.yunhai.notice.controller.api;
package com.yunhai.campusnotice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.service.ISmsService;
import com.yunhai.campusnotice.config.Config;
import com.yunhai.campusnotice.service.ISmsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -1,7 +1,7 @@
package com.yunhai.notice.controller.api;
package com.yunhai.campusnotice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.service.ITemplateService;
import com.yunhai.campusnotice.config.Config;
import com.yunhai.campusnotice.service.ITemplateService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -1,6 +1,6 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import com.yunhai.notice.common.listener.NoticeAuditListener;
import com.yunhai.campusnotice.common.listener.NoticeAuditListener;
import lombok.Getter;
import lombok.Setter;
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -10,7 +10,7 @@ import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 通知公告主表。对应原 .NET Notify_Message(已剔除短信/模板/门户等无关字段)。
* 校园公告主表。对应原 .NET Notify_Message(已剔除短信/模板/门户等无关字段)。
* <p>
* 阅读状态不落主表:采用「懒标记」模型——读者首次查看时写入 {@link NoticeRead},
* 不存在阅读记录即视为未读,从而无需在发通知时枚举全校用户预插接收行。
@@ -41,7 +41,7 @@ public class Notice extends BaseEntity {
private String content;
/**
* 通知类型:1 普通 / 2 重要(见 {@link com.yunhai.notice.enums.NoticeTypeEnum})
* 通知类型:1 普通 / 2 重要(见 {@link com.yunhai.campusnotice.enums.NoticeTypeEnum})
*/
@Column(name = "type", nullable = false)
private Integer type = 1;
@@ -59,22 +59,23 @@ public class Notice extends BaseEntity {
private String publisherName;
/**
* 发布部门ID(原 DeptID)
* 所属分类ID(原 DeptID)
*/
@Column(name = "dept_id", length = 64)
private String deptId;
/**
* 发布部门名称
* 所属分类名称
*/
@Column(name = "dept_name", length = 128)
private String deptName;
/**
* 状态:1 已发布(无草稿态,见 {@link com.yunhai.notice.enums.NoticeStatusEnum})
* 状态机值见 {@link com.yunhai.campusnotice.enums.NoticeStatusEnum}:
* 0 草稿 / 1 已发布 / 2 待审批 / 3 已驳回。
*/
@Column(name = "status", nullable = false)
private Integer status = 1;
private Integer status = 0;
/**
* 是否置顶:0 否 / 1 是(原 topmost)
@@ -145,4 +146,29 @@ public class Notice extends BaseEntity {
*/
@Column(name = "sms_flag")
private Integer smsFlag = 0;
// ==================== 新版发布表单扩展字段(改造方案 2.1) ====================
/**
* 是否需要学员阅读回执:0 否 / 1 是。
* =1 时管理端展示已读/未读统计与「催收未读」入口,学员端展示回执提示;
* =0 时阅读仍照常记录(notice_read),但不出催收与回执提示(改动面最小)。
*/
@Column(name = "receipt_flag")
private Integer receiptFlag = 0;
/**
* 审批人快照(JSON 账号数组,如 ["account1","account2"]):提交审批时由发布人选定(可多选)。
* ApprovalList 据此过滤「待我审批」;或签任一处理后状态即离开待审批,快照留作历史追溯。
* 驳回重提并更换审批人后,该快照被新一轮提交覆盖(已知边界,见改造方案 4.4)。
*/
@Column(name = "approver_accounts", columnDefinition = "LONGTEXT")
private String approverAccounts;
/**
* 定时发送已真正发布标记:到点调度后回写为 1,避免重复触发(改造方案 4.2)。
* 立即发布的通知在真实发布时一并置 1。
*/
@Column(name = "timing_published")
private Integer timingPublished = 0;
}
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -46,4 +46,26 @@ public class NoticeRead extends BaseEntity {
*/
@Column(name = "read_time")
private LocalDateTime readTime;
// ==================== 阅读身份快照(改造方案 2.3,学员阅读时带出) ====================
/** 学校ID(取通知 sch_id) */
@Column(name = "sch_id")
private Integer schId;
/** 年级ID快照 */
@Column(name = "grade_id")
private Integer gradeId;
/** 年级名称快照 */
@Column(name = "grade_name", length = 128)
private String gradeName;
/** 班级ID快照 */
@Column(name = "class_id")
private Integer classId;
/** 班级名称快照 */
@Column(name = "class_name", length = 128)
private String className;
}
@@ -0,0 +1,67 @@
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* 通知接收人关联表(快照语义:真实发布时一次写入,后续组织调整不影响历史通知)。
* <p>
* 教师定向:全员通知(receive_list="ALL")不写明细行,读取时按 receive_list="ALL" 标识走旧逻辑兜底。
* 学员圈人(新版发布表单):推送范围按 bschclass 展开为学员账号集合批量写入,
* 并携带年级/班级身份快照,供学员端可见性过滤、回执分组(年级→班级→学生)、催收取数。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_receiver",
indexes = {
@Index(name = "idx_notice_id", columnList = "notice_id"),
@Index(name = "idx_account", columnList = "account")
})
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeReceiver extends BaseEntity {
/** 接收类型:1 教师(现状语义,存量数据全为 1) / 2 学员(推送范围解析快照) */
public static final int TYPE_TEACHER = 1;
public static final int TYPE_STUDENT = 2;
/** 通知ID(关联 notice.id) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** 接收人登录账号(学员=SchStudent.account) */
@Column(name = "account", nullable = false, length = 64)
private String account;
/** 接收类型:1 教师 / 2 学员(原「2 抄送」从未写入过数据,语义重定义,存量无需迁移) */
@Column(name = "receive_type", nullable = false)
private Integer receiveType = TYPE_TEACHER;
// ==================== 学员身份快照(发布时从 bschclass 带出,仅 receive_type=2 使用) ====================
/** 学员姓名快照 */
@Column(name = "stu_name", length = 64)
private String stuName;
/** 年级ID快照 */
@Column(name = "grade_id")
private Integer gradeId;
/** 年级名称快照(如「初二年级」) */
@Column(name = "grade_name", length = 128)
private String gradeName;
/** 班级ID快照(sch_class.class_id) */
@Column(name = "class_id")
private Integer classId;
/** 班级名称快照 */
@Column(name = "class_name", length = 128)
private String className;
}
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -18,7 +18,7 @@ import javax.persistence.Table;
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysDict extends BaseEntity {
/** 字典类型(如 "dept"=发文部门) */
/** 字典类型(如 "dept"=所属分类) */
@Column(name = "dict_type", length = 50)
private String dictType;
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -1,4 +1,4 @@
package com.yunhai.notice.entity;
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -0,0 +1,44 @@
package com.yunhai.campusnotice.enums;
import lombok.Getter;
/**
* 通知状态机(新版发布表单:直接发布 / 提交审批 / 存为草稿 / 定时发送)。
* <pre>
* 0 DRAFT 草稿(存为草稿)
* 1 PUBLISHED 已发布(直接发布 / 审批通过 / 定时到点)
* 2 PENDING 待审批(提交校办审批)
* 3 REJECTED 已驳回(可改后重新提交)
* </pre>
* 兼容性:.NET 原系统「已发布=2」迁移时已归一化为 1,存量数据无 0/2/3;
* 实体历史注释「2 定时发送」从未真实写入(定时靠 timingSendTime 字段表达)。
* {@link #of(Integer)} 未知值回落 PUBLISHED 的兜底保留。
*/
@Getter
public enum NoticeStatusEnum {
DRAFT(0, "草稿"),
PUBLISHED(1, "已发布"),
PENDING(2, "待审批"),
REJECTED(3, "已驳回");
private final int code;
private final String desc;
NoticeStatusEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static NoticeStatusEnum of(Integer code) {
if (code == null) {
return PUBLISHED;
}
for (NoticeStatusEnum e : values()) {
if (e.code == code) {
return e;
}
}
return PUBLISHED;
}
}
@@ -1,4 +1,4 @@
package com.yunhai.notice.enums;
package com.yunhai.campusnotice.enums;
import lombok.Getter;
@@ -8,8 +8,8 @@ import lombok.Getter;
@Getter
public enum NoticeTypeEnum {
NORMAL(1, "普通通知"),
IMPORTANT(2, "重要通知");
NORMAL(1, "普通公告"),
IMPORTANT(2, "重要公告");
private final int code;
private final String desc;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model;
package com.yunhai.campusnotice.model;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model;
package com.yunhai.campusnotice.model;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model;
package com.yunhai.campusnotice.model;
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@@ -55,6 +55,48 @@ public class NoticeDetailVo {
@JsonProperty("tlist")
private Object tlist;
// ==================== 新版发布表单扩展(改造方案 3.2 / 4.6) ====================
/** 重要程度:1 普通 / 2 重要(编辑回显) */
@JsonProperty("type")
private Integer type;
/** 是否置顶:0 否 / 1 是(编辑回显) */
@JsonProperty("topmost")
private Integer topmost;
/** 短信提醒标记:0 否 / 1 是(编辑回显) */
@JsonProperty("smsFlag")
private Integer smsFlag;
/** 状态:0 草稿 / 1 已发布 / 2 待审批 / 3 已驳回 */
@JsonProperty("status")
private Integer status;
/** 状态描述 */
@JsonProperty("statusDesc")
private String statusDesc;
/** 是否需要学员阅读回执:0 否 / 1 是 */
@JsonProperty("receiptFlag")
private Integer receiptFlag;
/** 推送范围快照(编辑回显) */
@JsonProperty("pushScopes")
private List<PushScopeItem> pushScopes;
/** 审批轨迹(管理端详情展示;学员端 mobiapi 不返回) */
@JsonProperty("approvalRecords")
private List<ApprovalRecordVo> approvalRecords;
/** 当前绑定的审批人账号快照(待审批/已驳回通知的编辑回显,按账号勾选教师树) */
@JsonProperty("approverAccounts")
private List<String> approverAccounts;
/** 审批人姓名(与 approverAccounts 一一对应,取自最新提交行 assignees 快照) */
@JsonProperty("approverNames")
private List<String> approverNames;
/**
* 附件项(旧 ChaoSend 数组元素)。
*/
@@ -1,6 +1,6 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.campusnotice.model.BasePageResult;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -1,6 +1,6 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import com.yunhai.notice.model.BasePageQuery;
import com.yunhai.campusnotice.model.BasePageQuery;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
@@ -1,12 +1,13 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import com.yunhai.notice.common.validation.ValidationGroups;
import com.yunhai.campusnotice.common.validation.ValidationGroups;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.List;
/**
* 通知发布/编辑入参。发布人由当前登录用户上下文填充,不在此入参中传递。
@@ -50,4 +51,18 @@ public class NoticeRequest {
/** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */
private Integer sourceId;
// ==================== 新版发布表单扩展(改造方案 3.2) ====================
/** 推送范围列表(与 receiveList 可并存:pushScopes 解析学员,receiveList 作为教师定向名单) */
private List<PushScopeItem> pushScopes;
/** 是否需要学员阅读回执:0 否 / 1 是 */
private Integer receiptFlag = 0;
/** 发布方式:direct 直接发布(默认,兼容旧链路)/ draft 存为草稿 / approve 提交审批(需携带 approverAccounts) */
private String publishType;
/** 提交审批时所选审批人账号(publishType=approve 时必填;独立 Submit 接口走 SubmitRequest) */
private List<String> approverAccounts;
}
@@ -1,4 +1,4 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
@@ -47,6 +47,17 @@ public class NoticeVo {
/** 接收人列表(JSON) */
private String receiveList;
// ==================== 新版发布表单扩展(改造方案 3.2) ====================
/** 是否需要学员阅读回执:0 否 / 1 是 */
private Integer receiptFlag;
/** 定时发送已真正发布标记:0 未触发 / 1 已真实发布(配合 timingSendTime 区分「定时中/已发布」) */
private Integer timingPublished;
/** 审批人账号快照(JSON 数组串,发布流程展示用) */
private String approverAccounts;
/** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */
private String UpFile;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.yunhai.notice.model.notice;
package com.yunhai.campusnotice.model.notice;
import java.util.ArrayList;
import java.util.List;
@@ -1,6 +1,6 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.ContactsGroup;
import com.yunhai.campusnotice.entity.ContactsGroup;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -1,6 +1,6 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.ContactsGroupUser;
import com.yunhai.campusnotice.entity.ContactsGroupUser;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -1,6 +1,6 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.NoticeRead;
import com.yunhai.campusnotice.entity.NoticeRead;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -0,0 +1,48 @@
package com.yunhai.campusnotice.repository;
import com.yunhai.campusnotice.entity.NoticeReceiver;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 通知接收人关联表仓储。
*/
public interface NoticeReceiverRepository extends JpaRepository<NoticeReceiver, Integer> {
/** 按通知ID查询全部接收人 */
List<NoticeReceiver> findByNoticeId(Integer noticeId);
/** 按通知ID删除全部接收人 */
void deleteByNoticeId(Integer noticeId);
/** 按通知ID和接收类型查询接收人 */
List<NoticeReceiver> findByNoticeIdAndReceiveType(Integer noticeId, Integer receiveType);
/**
* 批量查 N 个通知的接收人,用于列表展示(避免 N+1)。
* @param noticeIds 通知ID列表
* @return 接收人关联记录列表
*/
@Query("select r from NoticeReceiver r where r.noticeId in :noticeIds")
List<NoticeReceiver> findByNoticeIds(@Param("noticeIds") List<Integer> noticeIds);
// ==================== 新版发布流程(改造方案 2.3 / 3.2) ====================
/** 当前账号命中的通知ID集合(学员端列表可见性过滤:仅对存在范围的通知启用) */
@Query("select distinct r.noticeId from NoticeReceiver r where r.account = :account")
List<Integer> findNoticeIdsByAccount(@Param("account") String account);
/** 某人某通知的接收快照行(markRead 身份快照取数:学员行优先) */
Optional<NoticeReceiver> findFirstByNoticeIdAndAccountOrderByReceiveTypeDescIdDesc(
Integer noticeId, String account);
/** 按通知ID + 接收类型删除(学员圈人重新展开时清理旧快照) */
void deleteByNoticeIdAndReceiveType(Integer noticeId, Integer receiveType);
/** 按通知ID + 接收类型计数(allowCount = 学员数 + 教师数) */
long countByNoticeIdAndReceiveType(Integer noticeId, Integer receiveType);
}
@@ -1,8 +1,9 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.Notice;
import com.yunhai.campusnotice.entity.Notice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -56,6 +57,16 @@ public interface NoticeRepository extends JpaRepository<Notice, Integer>,
*/
long countBySchId(Integer schId);
/**
* 排除指定状态计数(统计"累计发布"时剔除草稿)。
*/
long countByStatusNot(Integer status);
/**
* 按学校排除指定状态计数(多租户隔离统计,剔除草稿)。
*/
long countBySchIdAndStatusNot(Integer schId, Integer status);
/**
* 已发布且已到点(定时发送未到点不计入)且属于指定学校的通知数(用户端未读数统计,多租户隔离)。
*/
@@ -86,4 +97,40 @@ public interface NoticeRepository extends JpaRepository<Notice, Integer>,
@Param("status") Integer status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
// ==================== 新版发布流程(改造方案 4.2 / 4.3) ====================
/**
* 定时发送待触发列表:已发布、已到点、尚未真正发布(timing_published=0)。
* TimingPublishJob 每分钟扫描,逐条 CAS 抢占后执行真实发布。
*/
@Query("select n from Notice n where n.status = 1 " +
"and n.timingSendTime is not null and n.timingSendTime <= :now " +
"and n.timingPublished = 0")
List<Notice> findDueTimingPublish(@Param("now") LocalDateTime now);
/**
* 定时发布幂等抢占:仅当 timing_published 仍为 0 时置 1,返回影响行数(0=已被其他线程/实例处理)。
*/
@Modifying
@Query("update Notice n set n.timingPublished = 1, n.updateTime = :now " +
"where n.id = :id and n.timingPublished = 0")
int casMarkTimingPublished(@Param("id") Integer id, @Param("now") LocalDateTime now);
/**
* 审批通过 CAS:仅当仍处于待审批(status=2)时置为已发布,返回影响行数。
* 或签并发场景下先提交者生效,后提交者影响行数为 0,由 Service 转为冲突提示。
*/
@Modifying
@Query("update Notice n set n.status = 1, n.updateTime = :now " +
"where n.id = :id and n.status = 2")
int casApprove(@Param("id") Integer id, @Param("now") LocalDateTime now);
/**
* 审批驳回 CAS:仅当仍处于待审批(status=2)时置为已驳回。
*/
@Modifying
@Query("update Notice n set n.status = 3, n.updateTime = :now " +
"where n.id = :id and n.status = 2")
int casReject(@Param("id") Integer id, @Param("now") LocalDateTime now);
}
@@ -1,6 +1,6 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.NoticeTemplate;
import com.yunhai.campusnotice.entity.NoticeTemplate;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -1,6 +1,6 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.SmsModule;
import com.yunhai.campusnotice.entity.SmsModule;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
@@ -1,6 +1,6 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.SmsSchoolConfig;
import com.yunhai.campusnotice.entity.SmsSchoolConfig;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -1,6 +1,6 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.SysDict;
import com.yunhai.campusnotice.entity.SysDict;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
@@ -1,6 +1,6 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.TemplateType;
import com.yunhai.campusnotice.entity.TemplateType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
@@ -1,6 +1,6 @@
package com.yunhai.notice.repository;
package com.yunhai.campusnotice.repository;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.campusnotice.entity.UpFile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
@@ -0,0 +1,88 @@
package com.yunhai.campusnotice.service;
import com.yunhai.campusnotice.model.SchTeaList;
import com.yunhai.campusnotice.model.bsch.SchClassDto;
import com.yunhai.campusnotice.model.bsch.SchGradeDto;
import com.yunhai.campusnotice.model.bsch.SchInfoDto;
import com.yunhai.campusnotice.model.bsch.SchStudentDto;
import com.yunhai.campusnotice.model.bsch.SchSystemDto;
import com.yunhai.campusnotice.model.bsch.SchTreeNodeDto;
import java.util.List;
/**
* 班级管理(bschclass)开放接口客户端门面。
* <p>基址取配置 {@code yh.open.bschclass}(/oapi/** 无需应用签名,独立 RestTemplate 直连)。
* 推送范围树、圈人、身份快照统一经本门面取数,不散落 RestTemplate 调用(改造方案 2.0)。
*/
public interface IClassService {
/**
* 获取指定学校全部教师(含所属组 GroupIds)。
* 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}
*
* @param schId 学校ID(即用户当前身份的 schId)
*/
List<SchTeaList> getSchTeaList(int schId);
/**
* 用户中心学校ID → bschclass 学校信息(SchSerId / 学校名)。
* 对应:GET {@code {bschclass}/oapi/SchInfo/GetUserSchFirst?UserSchID={schId}}
*/
SchInfoDto getSchFirst(int schId);
/**
* 学校学段(学制)列表:小学部 / 初中部…。
* 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenlist?SchSerID=}
*/
List<SchSystemDto> getSchLenList(String schSerId);
/**
* 学校年级列表(Id=gradeId、Name=gradeNickname、OwnerId=学段id)。
* 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenLevellist?SchSerID=}
*/
List<SchGradeDto> getSchGradeList(String schSerId);
/**
* 学校全部在读班级(ClassID/ClassName/Levell=gradeId),ScopeTree 一次取数客户端组装。
* 对应:GET {@code {bschclass}/oapi/VSchClass/GetVClassList?SchSerID=}
*/
List<SchClassDto> getSchClassList(String schSerId);
/**
* 按校全部学生(圈人:全校范围展开)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetSchStulist?schserid=}
*/
List<SchStudentDto> getSchStuList(String schSerId);
/**
* 按年级直查学生(圈人:年级范围免逐班聚合;bschclass 侧补缺接口)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetGradeStulist?SchSerID=&GradeId=}
*/
List<SchStudentDto> getGradeStuList(String schSerId, Integer gradeId);
/**
* 按学段直查学生(圈人:学部范围一键展开;bschclass 侧补缺接口)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetSubStulist?SchSerID=&SchSystemId=}
*/
List<SchStudentDto> getSubStuList(String schSerId, Integer schSystemId);
/**
* 按班级批量查学生(圈人:班级范围一次多班)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetClaArrStulist?ClassIDs=1,2,3}
*/
List<SchStudentDto> getClaArrStuList(List<Integer> classIds);
/**
* 按学生ID批量查(圈人:指定学员范围)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetStuIdslist?stuids=1,2,3}
*/
List<SchStudentDto> getStuIdsList(List<Integer> stuIds);
/**
* 学校学生树:学校→学段→年级→班级→学生(单元素列表,根为学校节点)。
* 节点契约:组节点 type=1、isParent=true、children 非 null;学生叶子 type=2、isParent=false、children=null。
* 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?SchSerID=}
*/
List<SchTreeNodeDto> getStuTree(Integer schId);
}
@@ -1,4 +1,4 @@
package com.yunhai.notice.service;
package com.yunhai.campusnotice.service;
import java.util.List;
import java.util.Map;
@@ -1,4 +1,4 @@
package com.yunhai.notice.service;
package com.yunhai.campusnotice.service;
import java.util.List;
import java.util.Map;
@@ -1,8 +1,8 @@
package com.yunhai.notice.service;
package com.yunhai.campusnotice.service;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.model.notice.UpFileVo;
import com.yunhai.notice.model.notice.UpFileResultVo;
import com.yunhai.campusnotice.entity.UpFile;
import com.yunhai.campusnotice.model.notice.UpFileVo;
import com.yunhai.campusnotice.model.notice.UpFileResultVo;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
@@ -0,0 +1,222 @@
package com.yunhai.campusnotice.service;
import com.yunhai.campusnotice.entity.Notice;
import com.yunhai.campusnotice.model.BasePageResult;
import com.yunhai.campusnotice.model.notice.*;
import org.springframework.data.domain.Pageable;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 通知公告业务接口。
*/
public interface INoticeService {
/**
* 发通知:以当前登录用户为发布人入库。
* 按 request.publishType 分流(改造方案 4.1):
* direct/空 = 直接发布(兼容旧 Create 链路;携带未来 timingSendTime 时置 status=1 待调度);
* draft = 存为草稿;approve = 提交审批(request.approverAccounts 选人,或签)。
*
* @return 新建通知 ID(用于后续回填附件 TopicID 等)
*/
Integer publish(NoticeRequest request);
/**
* 存为草稿(status=0):仅存内容 + 范围快照 + 附件,不圈人、不发短信、不落接收人。
*/
Integer saveDraft(NoticeRequest request);
/**
* 草稿箱列表(status=0,本人草稿):返回重要程度 type/typeDesc、定时标注与 timingSendTime。
*/
BasePageResult<NoticeVo> draftList(NoticeQuery query, Pageable pageable);
/**
* 提交审批(status: 0/3 → 2):发布人选定审批人(可多人,或签),
* 后端逐个校验审批人资格;写 notice_approval 提交行并 upsert 审批人记忆表。
*/
void submit(Integer serid, List<String> approverAccounts, String opinion);
/**
* 审批通过(或签):任一审批人通过即 status: 2→1(CAS),并触发真实发布;
* 已被他人先行处理时抛业务冲突提示(带实际处理人与时间)。
*/
void approve(Integer serid, String opinion);
/**
* 审批驳回(或签):任一审批人驳回即 status: 2→3(CAS),记录意见;发布人可改后重新提交。
*/
void reject(Integer serid, String opinion);
/**
* 审批列表(待审/已审合一):按 schId + 审批人快照含当前账号过滤。
*
* @param auditState pending(默认)=待审 status=2;reviewed=已审 status∈{1,3}(含或签下他人已处理)
*/
BasePageResult<ApprovalListVo> approvalList(String auditState, NoticeQuery query, Pageable pageable);
/**
* 审批人记忆回显:当前发布人最近一次所选审批人组合,无记忆返回空。
*/
ApproverSuggestVo approverSuggest();
/**
* 审批人解析:教师树节点ID(用户中心用户ID)与登录账号互转。
* ids 按用户ID解析出账号,accounts 按账号解析回ID;单项解析失败跳过不中断。
*/
List<ApproverResolveVo> approverResolve(List<Integer> ids, List<String> accounts);
/**
* 编辑通知:草稿/已驳回/已发布(旧链路,无推送范围)可编辑;
* 待审批禁止编辑;新版表单已发布(存在范围快照)禁止编辑(先撤回后重发)。
*/
void edit(Integer id, NoticeRequest request);
/**
* 批量删除通知:仅草稿、已驳回、已发布(本人)可删,待审批不可删;
* 级联清理阅读记录、接收人、推送范围、审批记录、附件关联。
*/
void deleteBatch(List<Integer> ids);
/**
* 撤回通知:仅已发布且未撤回的通知可撤回,置 isPulled=1。
*/
void pull(Integer id);
/**
* 置顶/取消置顶。
*/
void setTopmost(Integer id, Integer topmost);
/**
* 转发通知:基于源通知创建新通知(已发布),parentId 关联源通知。
*/
Integer forward(Integer sourceId, NoticeRequest request);
/**
* 用户端列表:仅展示已发布、已到点、且对当前账号可见的通知。
* 可见性(改造方案 3.2):无推送范围维持「全校广播」现状;
* 存在范围的通知须 notice_receiver 快照命中当前账号(学员 receive_type=2 / 教师 receive_type=1)。
*/
NoticePageResult<NoticeVo> userList(NoticeQuery query, Pageable pageable);
/**
* 管理端列表:按条件查询全部通知(管理员查看全部)。
*/
BasePageResult<NoticeVo> adminList(NoticeQuery query, Pageable pageable);
/**
* 学员推送范围树(学段→年级→班级 + 全校根,数据源 bschclass),
* 供表单「推送范围」胶囊按钮渲染。
*/
List<Map<String, Object>> scopeTree();
/**
* 按已选范围预估接收人数(学员=范围展开去重账号数 + 教师=receiveList 定向名单数)。
*/
ScopePreviewVo scopePreview(List<PushScopeItem> scopes, String receiveList);
/**
* 已发送通知阅读回执:按 年级→班级→学生 分组返回(身份快照),
* 学生带已读/未读状态与阅读时间(前端着色),教师接收行归「教师」组。
*/
ReadReceiptVo readReceipt(Integer serid);
/**
* 通知详情:返回视图对象,并顺带为当前用户标记已读(打开即视为已读)。
* 草稿/待审批/已驳回态仅发布人本人或该轮审批人可打开(改造方案 3.2 访问校验补口)。
*/
NoticeVo detail(Integer id);
/**
* 编辑回显详情(不标记已读,访问校验同 {@link #detail})。
*/
NoticeVo getDetail(Integer id);
/**
* 标记已读:为当前用户写入(幂等)该通知的阅读记录,
* 学员阅读时按接收快照(缺失时 bschclass 实时兜底)写入学校/年级/班级身份快照。
*/
void markRead(Integer id);
/**
* 一键全部已读(批量化):当前用户所有可见的已发布通知批量插入阅读记录。
*
* @return 已读条数
*/
int initializeRed();
/**
* 追加接收人(事务化):并入接收人列表并重算 allowcount(范围通知含学员数)。
*/
void receiveUsers(Integer noticeId, String users);
/**
* 上一篇/下一篇通知ID(SQL 相邻查询)。
*
* @param serid 当前通知ID
* @param type 0 已发(当前用户发布的序列)/ 1 已收(阅读记录序列)
* @return {prevId, nextId}
*/
Map<String, Object> getNextPrev(Integer serid, String type);
/**
* 获取某通知的阅读明细列表。
*/
List<NoticeVo.ReadDetail> getReadDetail(Integer noticeId);
/**
* 阅读情况分组(已读/未读,旧接口保留过渡;新版表单走 {@link #readReceipt})。
*/
List<Map<String, Object>> getReadDetailGrouped(Integer serid);
/**
* 催收未读:仅 receiptFlag=1 的通知可用;
* 催收对象优先 notice_receiver 快照(含学员),回退 receiveList JSON(历史数据)。
*/
void expedite(Integer serid);
/**
* 定时发送调度(TimingPublishJob 每分钟触发):
* 扫描 status=1 且 timingSendTime<=now 且 timing_published=0 的通知,
* 逐条 CAS 抢占(幂等)后执行真实发布。
*
* @return 本次真实发布条数
*/
int timingPublishDue();
/**
* 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。
*/
NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end);
/**
* 通知详情视图(含附件、接收人、推送范围、审批轨迹),供 Controller 返回。
*/
NoticeDetailVo getDetailMap(Integer serid);
/**
* 已发列表附件批量查询(消除 N+1)。
*/
Map<Integer, List<Integer>> batchFileIds(List<Integer> noticeIds);
/**
* 查看通知详情视图(含附件、接收人)。
* type=1 时先标记已读再返回,type=0 时直接返回不标记。
*/
NoticeDetailVo detailMap(Integer serid, Integer type);
/**
* 一键全部已读并返回结果(count = 已读条数)。
*/
InitializeRedVo initializeRedResult();
/**
* 批量下载通知全部附件(打包 zip):查通知标题后调文件域打包下载。
*/
void downloadAllFiles(Integer dataid, HttpServletResponse response);
}
@@ -1,4 +1,4 @@
package com.yunhai.notice.service;
package com.yunhai.campusnotice.service;
import java.util.List;
import java.util.Map;
@@ -1,4 +1,4 @@
package com.yunhai.notice.service;
package com.yunhai.campusnotice.service;
import java.util.List;
import java.util.Map;
@@ -1,4 +1,4 @@
package com.yunhai.notice.service;
package com.yunhai.campusnotice.service;
import java.util.List;
import java.util.Map;
@@ -0,0 +1,176 @@
package com.yunhai.campusnotice.service.impl;
import com.yunhai.campusnotice.model.SchTeaList;
import com.yunhai.campusnotice.model.bsch.SchClassDto;
import com.yunhai.campusnotice.model.bsch.SchGradeDto;
import com.yunhai.campusnotice.model.bsch.SchInfoDto;
import com.yunhai.campusnotice.model.bsch.SchStudentDto;
import com.yunhai.campusnotice.model.bsch.SchSystemDto;
import com.yunhai.campusnotice.model.bsch.SchTreeNodeDto;
import com.yunhai.campusnotice.service.IClassService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 班级管理(bschclass)开放接口实现。
* bschclass 为开放接口(无需应用签名),使用独立 RestTemplate 直连。
* <p>推送范围树/圈人/身份快照统一经本门面(改造方案 2.0):
* 学段 GetSchLenlist、年级 GetSchLenLevellist、班级 GetVClassList、
* 学生 GetSchStulist / GetGradeStulist(补缺) / GetSubStulist(补缺) / GetClaArrStulist / GetStuIdslist、
* 学生树 SchGroup/GetStuTree。
*/
@Service
public class ClassService implements IClassService {
private static final Logger log = LoggerFactory.getLogger(ClassService.class);
/** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */
@Value("${yh.open.bschclass:}")
private String bschClassHost;
private final RestTemplate restTemplate;
public ClassService() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
this.restTemplate = new RestTemplate(factory);
}
@Override
public List<SchTeaList> getSchTeaList(int schId) {
return exchangeList(bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId,
new ParameterizedTypeReference<List<SchTeaList>>() {
}, "获取教师数据", "schId=" + schId);
}
@Override
public SchInfoDto getSchFirst(int schId) {
requireHost();
String url = bschClassHost + "/oapi/SchInfo/GetUserSchFirst?UserSchID=" + schId;
try {
ResponseEntity<SchInfoDto> resp = restTemplate.getForEntity(URI.create(url), SchInfoDto.class);
return resp.getBody();
} catch (RestClientException e) {
log.error("[getSchFirst] 请求班级管理接口失败, url={}", url, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学校信息失败");
}
}
@Override
public List<SchSystemDto> getSchLenList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenlist?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchSystemDto>>() {
}, "获取学段列表", "schSerId=" + schSerId);
}
@Override
public List<SchGradeDto> getSchGradeList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenLevellist?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchGradeDto>>() {
}, "获取年级列表", "schSerId=" + schSerId);
}
@Override
public List<SchClassDto> getSchClassList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchClassDto>>() {
}, "获取班级列表", "schSerId=" + schSerId);
}
@Override
public List<SchStudentDto> getSchStuList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取学校学生", "schSerId=" + schSerId);
}
@Override
public List<SchStudentDto> getGradeStuList(String schSerId, Integer gradeId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetGradeStulist?SchSerID=" + schSerId + "&GradeId=" + gradeId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取年级学生", "schSerId=" + schSerId + ", gradeId=" + gradeId);
}
@Override
public List<SchStudentDto> getSubStuList(String schSerId, Integer schSystemId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetSubStulist?SchSerID=" + schSerId + "&SchSystemId=" + schSystemId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取学段学生", "schSerId=" + schSerId + ", schSystemId=" + schSystemId);
}
@Override
public List<SchStudentDto> getClaArrStuList(List<Integer> classIds) {
String csv = joinIds(classIds);
return exchangeList(bschClassHost + "/oapi/OAStu/GetClaArrStulist?ClassIDs=" + csv,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取班级学生", "classIds=" + csv);
}
@Override
public List<SchStudentDto> getStuIdsList(List<Integer> stuIds) {
String csv = joinIds(stuIds);
return exchangeList(bschClassHost + "/oapi/OAStu/GetStuIdslist?stuids=" + csv,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取指定学生", "stuIds=" + csv);
}
@Override
public List<SchTreeNodeDto> getStuTree(Integer schId) {
return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId,
new ParameterizedTypeReference<List<SchTreeNodeDto>>() {
}, "获取学生树", "schId=" + schId);
}
// ============================ 通用调用 ============================
private <T> List<T> exchangeList(String url, ParameterizedTypeReference<List<T>> type,
String action, String arg) {
requireHost();
try {
// GET + Accept: application/json,保持与既有 GetSchteaList 调用一致
HttpHeaders headers = new HttpHeaders();
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
ResponseEntity<List<T>> resp = restTemplate.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), type);
List<T> list = resp.getBody();
log.info("[bschclass] {} 完成, {} 返回 {} 条", action, arg, list == null ? 0 : list.size());
return list != null ? list : Collections.emptyList();
} catch (RestClientException e) {
log.error("[bschclass] {} 失败, {}, url={}", action, arg, url, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, action + "失败");
}
}
private void requireHost() {
if (!StringUtils.hasText(bschClassHost)) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)");
}
}
private String joinIds(List<Integer> ids) {
if (ids == null || ids.isEmpty()) {
return "";
}
return ids.stream().map(String::valueOf).collect(Collectors.joining(","));
}
}
@@ -1,10 +1,10 @@
package com.yunhai.notice.service.impl;
package com.yunhai.campusnotice.service.impl;
import com.yunhai.notice.entity.ContactsGroup;
import com.yunhai.notice.entity.ContactsGroupUser;
import com.yunhai.notice.repository.ContactsGroupRepository;
import com.yunhai.notice.repository.ContactsGroupUserRepository;
import com.yunhai.notice.service.IContactsService;
import com.yunhai.campusnotice.entity.ContactsGroup;
import com.yunhai.campusnotice.entity.ContactsGroupUser;
import com.yunhai.campusnotice.repository.ContactsGroupRepository;
import com.yunhai.campusnotice.repository.ContactsGroupUserRepository;
import com.yunhai.campusnotice.service.IContactsService;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.apistd.BizException;
@@ -1,10 +1,10 @@
package com.yunhai.notice.service.impl;
package com.yunhai.campusnotice.service.impl;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.SysDict;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.SysDictRepository;
import com.yunhai.notice.service.IDictService;
import com.yunhai.campusnotice.entity.Notice;
import com.yunhai.campusnotice.entity.SysDict;
import com.yunhai.campusnotice.repository.NoticeRepository;
import com.yunhai.campusnotice.repository.SysDictRepository;
import com.yunhai.campusnotice.service.IDictService;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.apistd.BizException;
@@ -1,10 +1,10 @@
package com.yunhai.notice.service.impl;
package com.yunhai.campusnotice.service.impl;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.model.notice.UpFileVo;
import com.yunhai.notice.model.notice.UpFileResultVo;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.service.IFileService;
import com.yunhai.campusnotice.entity.UpFile;
import com.yunhai.campusnotice.model.notice.UpFileVo;
import com.yunhai.campusnotice.model.notice.UpFileResultVo;
import com.yunhai.campusnotice.repository.UpFileRepository;
import com.yunhai.campusnotice.service.IFileService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.slf4j.Logger;
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,10 @@
package com.yunhai.notice.service.impl;
package com.yunhai.campusnotice.service.impl;
import com.yunhai.notice.entity.SmsModule;
import com.yunhai.notice.entity.SmsSchoolConfig;
import com.yunhai.notice.repository.SmsModuleRepository;
import com.yunhai.notice.repository.SmsSchoolConfigRepository;
import com.yunhai.notice.service.ISmsService;
import com.yunhai.campusnotice.entity.SmsModule;
import com.yunhai.campusnotice.entity.SmsSchoolConfig;
import com.yunhai.campusnotice.repository.SmsModuleRepository;
import com.yunhai.campusnotice.repository.SmsSchoolConfigRepository;
import com.yunhai.campusnotice.service.ISmsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -1,12 +1,12 @@
package com.yunhai.notice.service.impl;
package com.yunhai.campusnotice.service.impl;
import com.yunhai.notice.entity.NoticeTemplate;
import com.yunhai.notice.entity.TemplateType;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.repository.NoticeTemplateRepository;
import com.yunhai.notice.repository.TemplateTypeRepository;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.service.ITemplateService;
import com.yunhai.campusnotice.entity.NoticeTemplate;
import com.yunhai.campusnotice.entity.TemplateType;
import com.yunhai.campusnotice.entity.UpFile;
import com.yunhai.campusnotice.repository.NoticeTemplateRepository;
import com.yunhai.campusnotice.repository.TemplateTypeRepository;
import com.yunhai.campusnotice.repository.UpFileRepository;
import com.yunhai.campusnotice.service.ITemplateService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
@@ -1,8 +1,8 @@
package com.yunhai.notice.service.impl;
package com.yunhai.campusnotice.service.impl;
import com.yunhai.notice.model.SchTeaList;
import com.yunhai.notice.service.IClassService;
import com.yunhai.notice.service.IUserCenterService;
import com.yunhai.campusnotice.model.SchTeaList;
import com.yunhai.campusnotice.service.IClassService;
import com.yunhai.campusnotice.service.IUserCenterService;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
@@ -1,4 +1,4 @@
package com.yunhai.notice.service.sms;
package com.yunhai.campusnotice.service.sms;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -1,4 +1,4 @@
package com.yunhai.notice.service.sms;
package com.yunhai.campusnotice.service.sms;
import java.util.List;
@@ -1,7 +1,7 @@
package com.yunhai.notice.utils;
package com.yunhai.campusnotice.utils;
import com.yunhai.notice.model.BasePageQuery;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.campusnotice.model.BasePageQuery;
import com.yunhai.campusnotice.model.BasePageResult;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
@@ -1,217 +0,0 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.common.validation.ValidationGroups;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.*;
import com.yunhai.notice.service.INoticeService;
import com.yunhai.notice.utils.PageUtils;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 通知公告接口(合并 NoticeApiController + 旧 NoticeController)。
* <p>路径前缀:{@code Config.API_PATH + "/notice/"}(platform = api / manage / mobiapi)。
* 方法动词式 PascalCase(对齐 UserBase);写操作一律 POST。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/")
public class NoticeController {
@Autowired
private INoticeService noticeService;
// ============================ 收件箱列表 ============================
/**
* 已收通知列表(用户端)。
*/
@GetMapping("List")
public NoticePageResult<NoticeVo> list(NoticeQuery query,
@SortDefault(sort = {"topmost", "createTime"},
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.userList(query, PageUtils.toPageable(query, sort));
}
// ============================ 已发/全部通知列表 ============================
/**
* 已发/全部通知列表(管理端)。type=user 只看自己。
*/
@GetMapping("SendList")
public BasePageResult<NoticeVo> sendList(NoticeQuery query,
@SortDefault(sort = "createTime",
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.adminList(query, PageUtils.toPageable(query, sort));
}
// ============================ 查看详情(标读) ============================
/**
* 查看通知详情(type=1 普通查看标记已读, type=0 编辑模式不标记已读)。
*/
@GetMapping("Detail")
public NoticeDetailVo detail(@RequestParam("serid") Integer serid,
@RequestParam(value = "type", defaultValue = "1") Integer type) {
return noticeService.detailMap(serid, type);
}
// ============================ 编辑回显(不标读) ============================
/**
* 获取通知详情(编辑/转发用,不标记已读)。
*/
@GetMapping("GetDetail")
public NoticeDetailVo getDetail(@RequestParam("serid") Integer serid) {
return noticeService.getDetailMap(serid);
}
// ============================ 新建 ============================
/**
* 新建通知(JSON body)。fileIds 为附件ID逗号串,smsFlag 为短信标记。
*/
@PostMapping("Create")
public Integer create(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) {
return noticeService.publish(request);
}
// ============================ 编辑 ============================
/**
* 编辑通知(JSON body,含 id)。
*/
@PostMapping("Edit")
public void edit(@RequestBody @Validated(ValidationGroups.Update.class) NoticeRequest request) {
noticeService.edit(request.getSourceId() != null ? request.getSourceId() : 0, request);
}
// ============================ 批量删除 ============================
/**
* 批量删除通知(ids 数组)。
*/
@PostMapping("Delete")
public void delete(@RequestBody List<Integer> ids) {
noticeService.deleteBatch(ids);
}
// ============================ 撤回 ============================
/**
* 撤回通知。
*/
@PostMapping("UserPull")
public void userPull(@RequestParam("serid") Integer serid) {
noticeService.pull(serid);
}
// ============================ 置顶 ============================
/**
* 置顶/取消置顶。
*/
@PostMapping("SetTopmost")
public void setTopmost(@RequestParam("serid") Integer serid,
@RequestParam(value = "topmost", defaultValue = "1") Integer topmost) {
noticeService.setTopmost(serid, topmost);
}
// ============================ 追加接收人 ============================
/**
* 追加接收人。
*/
@PostMapping("ReceiveUsers")
public void receiveUsers(@RequestParam("serid") Integer serid,
@RequestParam("users") String users) {
noticeService.receiveUsers(serid, users);
}
// ============================ 催收 ============================
/**
* 催收未读。
*/
@PostMapping("Expedite")
public void expedite(@RequestParam("serid") Integer serid) {
noticeService.expedite(serid);
}
// ============================ 一键已读 ============================
/**
* 一键全部已读(POST 化,原为 GET 写操作)。
*/
@PostMapping("InitializeRed")
public InitializeRedVo initializeRed() {
return noticeService.initializeRedResult();
}
// ============================ 阅读情况 ============================
/**
* 阅读情况(按已读/未读分组)。
*/
@GetMapping("ReadDetail")
public List<Map<String, Object>> readDetail(@RequestParam("serid") Integer serid) {
return noticeService.getReadDetailGrouped(serid);
}
// ============================ 上一篇/下一篇 ============================
/**
* 上一篇/下一篇通知ID。
*/
@GetMapping("GetNextPrev")
public Map<String, Object> getNextPrev(@RequestParam("serid") Integer serid,
@RequestParam("type") String type) {
return noticeService.getNextPrev(serid, type);
}
// ============================ 附件打包下载 ============================
/**
* 批量下载通知全部附件(打包 zip)。
*/
@GetMapping("AllFileDown")
public void allFileDown(@RequestParam("dataid") Integer dataid,
HttpServletResponse response) {
noticeService.downloadAllFiles(dataid, response);
}
// ============================ 统计 ============================
/**
* 通知统计。
*/
@GetMapping("Statis")
public NoticeStatisticsVo statis(
@RequestParam(value = "starttime", required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime start,
@RequestParam(value = "endtime", required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime end) {
return noticeService.statistics(new NoticeQuery(), start, end);
}
// ============================ 转发(合并旧 REST 壳 forward) ============================
/**
* 转发通知(基于源通知创建新通知)。sourceId 在 request body 中传递。
*/
@PostMapping("Forward")
public Integer forward(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) {
return noticeService.forward(request.getSourceId(), request);
}
}
@@ -1,40 +0,0 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* 通知接收人关联表(替代旧 receive_list JSON 字符串存储)。
* <p>
* 每条记录代表一个通知的一个接收人。全员通知(receive_list="ALL")不写明细行,
* 读取时按 receive_list="ALL" 标识走旧逻辑兜底。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_receiver",
indexes = {
@Index(name = "idx_notice_id", columnList = "notice_id"),
@Index(name = "idx_account", columnList = "account")
})
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeReceiver extends BaseEntity {
/** 通知ID(关联 notice.id) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** 接收人登录账号 */
@Column(name = "account", nullable = false, length = 64)
private String account;
/** 接收类型:1 接收 / 2 抄送 */
@Column(name = "receive_type", nullable = false)
private Integer receiveType = 1;
}
@@ -1,34 +0,0 @@
package com.yunhai.notice.enums;
import lombok.Getter;
/**
* 通知状态。无草稿态:发通知一律置为「已发布」,
* 定时发送通过 timingSendTime + 查询侧「已到点」过滤控制可见性。
* 对应原 .NET Notify_Message.status(原系统已发布=2,此处归一化为 1)。
*/
@Getter
public enum NoticeStatusEnum {
PUBLISHED(1, "已发布");
private final int code;
private final String desc;
NoticeStatusEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static NoticeStatusEnum of(Integer code) {
if (code == null) {
return PUBLISHED;
}
for (NoticeStatusEnum e : values()) {
if (e.code == code) {
return e;
}
}
return PUBLISHED;
}
}
@@ -1,31 +0,0 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeReceiver;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* 通知接收人关联表仓储。
*/
public interface NoticeReceiverRepository extends JpaRepository<NoticeReceiver, Integer> {
/** 按通知ID查询全部接收人 */
List<NoticeReceiver> findByNoticeId(Integer noticeId);
/** 按通知ID删除全部接收人 */
void deleteByNoticeId(Integer noticeId);
/** 按通知ID和接收类型查询接收人 */
List<NoticeReceiver> findByNoticeIdAndReceiveType(Integer noticeId, Integer receiveType);
/**
* 批量查 N 个通知的接收人,用于列表展示(避免 N+1)。
* @param noticeIds 通知ID列表
* @return 接收人关联记录列表
*/
@Query("select r from NoticeReceiver r where r.noticeId in :noticeIds")
List<NoticeReceiver> findByNoticeIds(@Param("noticeIds") List<Integer> noticeIds);
}
@@ -1,20 +0,0 @@
package com.yunhai.notice.service;
import com.yunhai.notice.model.SchTeaList;
import java.util.List;
/**
* 班级管理(bschclass)开放接口客户端。
* 基址取配置 {@code yh.open.bschclass},当前仅提供教师查询,后续班级/年级等接口按需追加。
*/
public interface IClassService {
/**
* 获取指定学校全部教师(含所属组 GroupIds)。
* 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}
*
* @param schId 学校ID(即用户当前身份的 schId)
*/
List<SchTeaList> getSchTeaList(int schId);
}
@@ -1,142 +0,0 @@
package com.yunhai.notice.service;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.*;
import org.springframework.data.domain.Pageable;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 通知公告业务接口。
*/
public interface INoticeService {
/**
* 发通知:以当前登录用户为发布人,状态置为「已发布」入库。
* 若 request.timingSendTime 不为空且晚于当前时间,则作为定时发送,
* 未到点前用户端收件箱不可见(到点后由查询过滤自然可见)。
*
* @return 新建通知 ID(用于后续回填附件 TopicID 等)
*/
Integer publish(NoticeRequest request);
/**
* 编辑通知:按 id 更新标题/内容/类型/部门/置顶,发布人与状态保持不变。
*/
void edit(Integer id, NoticeRequest request);
/**
* 批量删除通知:删除主记录并级联清理阅读记录、接收人、附件关联。
*/
void deleteBatch(List<Integer> ids);
/**
* 撤回通知:仅已发布且未撤回的通知可撤回,置 isPulled=1。
*/
void pull(Integer id);
/**
* 置顶/取消置顶。
*/
void setTopmost(Integer id, Integer topmost);
/**
* 转发通知:基于源通知创建新通知(已发布),parentId 关联源通知。
*/
Integer forward(Integer sourceId, NoticeRequest request);
/**
* 用户端列表:仅展示已发布且已到点的通知,并标记当前用户是否已读,附未读总数。
* 返回 NoticePageResult(扩展 unreadCount)。
*/
NoticePageResult<NoticeVo> userList(NoticeQuery query, Pageable pageable);
/**
* 管理端列表:按条件查询全部通知(管理员查看全部)。
*/
BasePageResult<NoticeVo> adminList(NoticeQuery query, Pageable pageable);
/**
* 通知详情:返回视图对象,并顺带为当前用户标记已读(打开即视为已读)。
*/
NoticeVo detail(Integer id);
/**
* 编辑回显详情(不标记已读)。
*/
NoticeVo getDetail(Integer id);
/**
* 标记已读:为当前用户写入(幂等)该通知的阅读记录。
*/
void markRead(Integer id);
/**
* 一键全部已读(批量化):当前用户所有未读的已发布通知批量插入阅读记录。
* @return 已读条数
*/
int initializeRed();
/**
* 追加接收人(事务化):并入接收人列表并重算 allowcount。
*/
void receiveUsers(Integer noticeId, String users);
/**
* 上一篇/下一篇通知ID(SQL 相邻查询)。
* @param serid 当前通知ID
* @param type 0 已发(当前用户发布的序列)/ 1 已收(阅读记录序列)
* @return {prevId, nextId}
*/
Map<String, Object> getNextPrev(Integer serid, String type);
/**
* 获取某通知的阅读明细列表。
*/
List<NoticeVo.ReadDetail> getReadDetail(Integer noticeId);
/**
* 阅读情况分组(已读/未读)。
*/
List<Map<String, Object>> getReadDetailGrouped(Integer serid);
/**
* 催收未读:枚举未读接收人,后续接入消息推送。
*/
void expedite(Integer serid);
/**
* 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。
*/
NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end);
/**
* 通知详情视图(含附件、接收人信息),供 Controller 返回。
*/
NoticeDetailVo getDetailMap(Integer serid);
/**
* 已发列表附件批量查询(消除 N+1)。
*/
Map<Integer, List<Integer>> batchFileIds(List<Integer> noticeIds);
/**
* 查看通知详情视图(含附件、接收人)。
* type=1 时先标记已读再返回,type=0 时直接返回不标记。
*/
NoticeDetailVo detailMap(Integer serid, Integer type);
/**
* 一键全部已读并返回结果(count = 已读条数)。
*/
InitializeRedVo initializeRedResult();
/**
* 批量下载通知全部附件(打包 zip):查通知标题后调文件域打包下载。
*/
void downloadAllFiles(Integer dataid, HttpServletResponse response);
}
@@ -1,63 +0,0 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.model.SchTeaList;
import com.yunhai.notice.service.IClassService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.util.Collections;
import java.util.List;
/**
* 班级管理(bschclass)开放接口实现。
* bschclass 为开放接口(无需应用签名),使用独立 RestTemplate 直连。
*/
@Service
public class ClassService implements IClassService {
private static final Logger log = LoggerFactory.getLogger(ClassService.class);
/** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */
@Value("${yh.open.bschclass:}")
private String bschClassHost;
private final RestTemplate restTemplate;
public ClassService() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
this.restTemplate = new RestTemplate(factory);
}
@Override
public List<SchTeaList> getSchTeaList(int schId) {
if (!StringUtils.hasText(bschClassHost)) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)");
}
String url = bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId;
try {
ResponseEntity<List<SchTeaList>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<SchTeaList>>() {
});
List<SchTeaList> list = resp.getBody();
log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, list == null ? 0 : list.size());
return list != null ? list : Collections.emptyList();
} catch (RestClientException e) {
log.error("[getSchTeaList] 请求班级管理接口失败, schId={}, url={}", schId, url, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取教师数据失败");
}
}
}
@@ -1,957 +0,0 @@
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"));
}
}
+11 -5
View File
@@ -9,9 +9,9 @@ spring:
date-format: yyyy-MM-dd HH:mm:ss
datasource:
url: jdbc:mysql://localhost:3306/bsch_notice?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai
url: jdbc:mysql://localhost:3306/campus_notice?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai
username: root
password: 123456
password: 123123
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
@@ -48,12 +48,12 @@ file:
# 短信模块配置
sms:
enabled: true
module-id: notice
module-id: campusnotice
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com/cas/
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://localhost:8101
allow-headers: all
allow-origins: all
@@ -69,4 +69,10 @@ yh:
base-host: http://base.yunhaixingtong.com
user-center-url: http://base.yunhaixingtong.com/userbase
# 班级管理(bschclass)服务基址
bschclass: http://base.yunhaixingtong.com/bschclass
bschclass: http://base.yunhaixingtong.com/userclass
# 通知业务配置
notice:
# 审批人资格组ID(用户中心组):>0 时 Submit 逐个校验被选人属于该组;
# 0 = 仅校验用户存在(审批资格数据源待产品确认,改造方案 5.1-#5)
approver-group-id: 0
+2 -2
View File
@@ -1,6 +1,6 @@
spring:
application:
name: notice
name: campusnotice
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
cloud:
@@ -11,7 +11,7 @@ spring:
namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc}
# 应用自身配置所在分组
group: OA_APP
# 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 notice.yaml
# 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 campusnotice.yaml
file-extension: yaml
encode: UTF-8
# nacos 不可达时不强制退出,回退到本地 application.yml 兜底
@@ -2,7 +2,7 @@
-- notice_receiver 建表(替代 notice.receive_list JSON 字符串存储)
-- 决策 D2-B:拆关联表,支撑阅读情况/催收/统计 SQL 化
--
-- 对应实体:com.yunhai.notice.entity.NoticeReceiver
-- 对应实体:com.yunhai.campusnotice.entity.NoticeReceiver
-- 索引:idx_notice_id(notice_id) + idx_account(account)
--
-- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑