Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d99ce81cc0 | ||
|
|
b96de59b6d | ||
|
|
a4e8702dc0 | ||
|
|
48cd8884d8 | ||
|
|
b85f5e16fb | ||
|
|
81a988f96a | ||
|
|
5efe9c09fd | ||
|
|
e6e91157be | ||
|
|
0721cf3b7f | ||
|
|
867809fe37 | ||
|
|
a103d661fc |
@@ -0,0 +1,34 @@
|
|||||||
|
package com.yunhai.notice.common.interceptor;
|
||||||
|
|
||||||
|
import com.yunhai.platform.auth.BoaUserContext;
|
||||||
|
import com.yunhai.platform.auth.UserContextHolder;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端 /mobiapi 访问拦截(口径 = 登录即可访问)。
|
||||||
|
* <p>不做访问级别判级——移动端主体含学生(旧 SUPER/BUREAU/SCHOOL/NONE 判级照抄会全 403)。
|
||||||
|
* token 解析、401+40101 信封与 pre-token 豁免由平台 SDK {@code LoginUserInterceptor} 承担
|
||||||
|
* (tokenSurfaces 缺省 /mobiapi);本拦截器是业务侧兜底:SDK 之后的任何 /mobiapi 业务请求,
|
||||||
|
* 身份上下文为空即 401,防止依赖链路变化时出现"匿名可达的业务端点"。</p>
|
||||||
|
*/
|
||||||
|
public class MobiAccessInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
if (UserContextHolder.get() == null && BoaUserContext.get() == null) {
|
||||||
|
try {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write(
|
||||||
|
"{\"code\":40101,\"msg\":\"未登录或登录已过期\",\"data\":null}");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 响应已提交等场景:状态码已设置,写体失败不掩盖原始语义
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.yunhai.notice.config;
|
package com.yunhai.notice.config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。
|
* 全局路径常量(三层接口面,闭集):/api PC 管理端(CAS)、/mobiapi 移动端(token)、/oapi 开放接口(应用签名)。
|
||||||
|
* 其余前缀(如旧 /manage、/{platform} 通配)一律 404(方案 §4.1 路径闭集)。
|
||||||
*/
|
*/
|
||||||
public enum Config {
|
public enum Config {
|
||||||
Inst;
|
Inst;
|
||||||
|
|
||||||
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔
|
// 末尾不带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会拼出字面双斜杠而 404
|
||||||
|
public static final String API_PATH = "/api"; // PC 管理端(CAS 会话)
|
||||||
|
public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 Bearer token)
|
||||||
public static final String OPEN_API_PATH = "/oapi";
|
public static final String OPEN_API_PATH = "/oapi";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.yunhai.notice.config;
|
||||||
|
|
||||||
|
import com.yunhai.notice.common.interceptor.MobiAccessInterceptor;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端 /mobiapi 拦截装配。
|
||||||
|
* <p>{@code LOWEST_PRECEDENCE} 保证本配置器在 SDK 的 {@code YhAuthWebMvcConfigurer}
|
||||||
|
* (无 @Order)之后注册,登录态解析先于本兜底拦截执行。</p>
|
||||||
|
* <p>排除 {@code /mobiapi/auth/**}:其中 login/bind/exchange 是 SDK 定义的 pre-token 端点
|
||||||
|
* ({@code LoginUserInterceptor.PRE_TOKEN_ENDPOINTS}),设计上匿名可达——它们是登录链本身,
|
||||||
|
* 不是"业务免登录端点";logout/jsapiSignature 仍由 SDK 按 token 强制。业务端点无任何白名单。</p>
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class MobiWebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(new MobiAccessInterceptor())
|
||||||
|
.addPathPatterns("/mobiapi/**")
|
||||||
|
.excludePathPatterns("/mobiapi/auth/**")
|
||||||
|
.order(Ordered.LOWEST_PRECEDENCE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import java.util.Map;
|
|||||||
* <li>POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);</li>
|
* <li>POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);</li>
|
||||||
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
|
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
|
||||||
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
|
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
|
||||||
|
* <li>GET Flow/Embed:办件进度嵌入页地址(Drawer iframe 直嵌,可见性与 Trace 同口径)。</li>
|
||||||
|
* <li>GET Flow/DiagramDef:流程定义示意图(绑定页/发通知页 Drawer,登录即可读——与 Binding 同口径)。</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@@ -30,6 +32,8 @@ import java.util.Map;
|
|||||||
public class NoticeFlowController {
|
public class NoticeFlowController {
|
||||||
|
|
||||||
private final NoticeFlowService noticeFlowService;
|
private final NoticeFlowService noticeFlowService;
|
||||||
|
private final com.yunhai.notice.service.flow.NoticeFlowEmbedService noticeFlowEmbedService;
|
||||||
|
private final com.yunhai.notice.service.flow.FlowClient flowClient;
|
||||||
|
|
||||||
/** 审核流程下拉选项(流程中心未启用时返回空列表,前端隐藏选择器) */
|
/** 审核流程下拉选项(流程中心未启用时返回空列表,前端隐藏选择器) */
|
||||||
@GetMapping("Options")
|
@GetMapping("Options")
|
||||||
@@ -81,6 +85,30 @@ public class NoticeFlowController {
|
|||||||
return noticeFlowService.diagramImage(serid, user);
|
return noticeFlowService.diagramImage(serid, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流程定义示意图(PNG 流,绑定页/发通知页 Drawer 同源直嵌 img)。def 口径无办件,
|
||||||
|
* 不适用 NoticeFlowGuard 办件守卫——与绑定读取(Binding,登录即可读)同口径:CAS 登录即可,
|
||||||
|
* schId 从登录用户推导,本校限定由 flow-center 按 schId+flowGuid 收敛;
|
||||||
|
* 绑定已解除/无已发布版时对端报业务错(JSON 信封),前端 img onerror 转提示,不留 500。
|
||||||
|
*/
|
||||||
|
@GetMapping(value = "DiagramDef", produces = org.springframework.http.MediaType.IMAGE_PNG_VALUE)
|
||||||
|
public byte[] diagramDef(@RequestParam("flowGuid") String flowGuid, BoaUser user) {
|
||||||
|
if (flowGuid == null || flowGuid.trim().isEmpty()) {
|
||||||
|
throw new com.yunhai.platform.apistd.BizException(
|
||||||
|
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "缺少流程标识");
|
||||||
|
}
|
||||||
|
return flowClient.diagramImageByFlowGuid(currentSchId(user), flowGuid.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 办件进度嵌入页地址(详情页 Drawer 内 iframe 直嵌 flow-center 统一进度页)。
|
||||||
|
* 可见性与 Trace/Diagram 同口径;pageUrl 含短时 token,现取现发不落前端缓存。
|
||||||
|
*/
|
||||||
|
@GetMapping("Embed")
|
||||||
|
public Map<String, String> embed(@RequestParam("serid") Integer serid, BoaUser user) {
|
||||||
|
return noticeFlowEmbedService.embedUrl(serid, user);
|
||||||
|
}
|
||||||
|
|
||||||
private Integer currentSchId(BoaUser user) {
|
private Integer currentSchId(BoaUser user) {
|
||||||
if (user == null || user.getCurrentRole() == null) {
|
if (user == null || user.getCurrentRole() == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.yunhai.notice.controller.mobiapi;
|
||||||
|
|
||||||
|
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.platform.utils.model.BoaUser;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台代理域移动端接口(/mobiapi/base/,token 身份)。
|
||||||
|
* <p>端点从 {@code controller.api.BaseApiController} 按移动端需要迁出,业务逻辑仍在
|
||||||
|
* {@link IUserCenterService} / {@link IFileService},本控制器仅"取参 → 调 Service → 返回";
|
||||||
|
* 管理端专用端点留在 api/ 面,本期不删。</p>
|
||||||
|
* <p>出入参口径与 api 侧一致(路径名沿用旧移动端 base/FilePreview|FileDown 调用形状;
|
||||||
|
* 文件预览/下载在 api 侧实际位于 FileController 的 file/Down|Preview,此处对齐其
|
||||||
|
* IFileService 签名)。登出不在此暴露——SDK 已提供 {@code POST /mobiapi/auth/logout}。</p>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.MOBI_API_PATH + "/base/")
|
||||||
|
public class BaseMobiController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IUserCenterService userCenterService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IFileService fileService;
|
||||||
|
|
||||||
|
/** 获取当前登录用户信息(token 链注入)。 */
|
||||||
|
@GetMapping("GetUserInfo")
|
||||||
|
public BoaUser getUserInfo(BoaUser authUserWithDept) {
|
||||||
|
return authUserWithDept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户已授权的模块列表(移动端仅用于发布权限判定)。 */
|
||||||
|
@GetMapping("GetUserMod")
|
||||||
|
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
|
||||||
|
Integer schId = authUserWithDept.getCurrentRole() != null
|
||||||
|
? authUserWithDept.getCurrentRole().getSchId() : null;
|
||||||
|
|
||||||
|
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户学校组及用户(树形结构)。 */
|
||||||
|
@GetMapping("GetSchoolAll")
|
||||||
|
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
|
||||||
|
Integer schId = authUserWithDept.getCurrentRole() != null
|
||||||
|
? authUserWithDept.getCurrentRole().getSchId() : null;
|
||||||
|
return userCenterService.getSchoolAllTree(schId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取指定组内用户(教师 + 管理员)。 */
|
||||||
|
@GetMapping("GetGroupUser")
|
||||||
|
public List<Map<String, Object>> getGroupUser(
|
||||||
|
@RequestParam("gid") Integer gid,
|
||||||
|
@RequestParam(value = "role", defaultValue = "2") Integer role) {
|
||||||
|
return userCenterService.getGroupUser(gid, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件上传(单文件)。返回 { fileid, host, fileurl, name, type, size }。 */
|
||||||
|
@PostMapping("FileUpload")
|
||||||
|
public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) {
|
||||||
|
return fileService.uploadToResult(file, "notice");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件在线预览(inline 输出,前端取 Blob 渲染;type 兼容旧调用参数)。 */
|
||||||
|
@GetMapping("FilePreview")
|
||||||
|
public void filePreview(@RequestParam("id") Integer id,
|
||||||
|
@RequestParam(value = "type", required = false) Integer type,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
fileService.preview(id, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件下载。 */
|
||||||
|
@GetMapping("FileDown")
|
||||||
|
public void fileDown(@RequestParam("id") Integer id, HttpServletResponse response) {
|
||||||
|
fileService.download(id, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.yunhai.notice.controller.mobiapi;
|
||||||
|
|
||||||
|
import com.yunhai.notice.config.Config;
|
||||||
|
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 org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.web.SortDefault;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知公告移动端接口(/mobiapi 面,token 身份)。
|
||||||
|
* <p>路径前缀:{@code Config.MOBI_API_PATH + "/notice/"};方法名/参数与
|
||||||
|
* {@code controller.api.NoticeController} 保持一致,注入同一 {@link INoticeService},
|
||||||
|
* 不改 Service 实现;返回裸对象,由 ApiResponseAdvice 统一包装 {code, msg, data}。</p>
|
||||||
|
* <p>口径:登录即可访问(移动端主体含学生),不按管理端访问级别判级;
|
||||||
|
* 拦截与 401 语义见 {@code config.MobiWebMvcConfig} / {@code common.interceptor.MobiAccessInterceptor}。</p>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.MOBI_API_PATH + "/notice/")
|
||||||
|
public class NoticeMobiController {
|
||||||
|
|
||||||
|
@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("ReadDetail")
|
||||||
|
public List<Map<String, Object>> readDetail(@RequestParam("serid") Integer serid) {
|
||||||
|
return noticeService.getReadDetailGrouped(serid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 催收未读。 */
|
||||||
|
@PostMapping("Expedite")
|
||||||
|
public void expedite(@RequestParam("serid") Integer serid) {
|
||||||
|
noticeService.expedite(serid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一键全部已读。 */
|
||||||
|
@PostMapping("InitializeRed")
|
||||||
|
public InitializeRedVo initializeRed() {
|
||||||
|
return noticeService.initializeRedResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上一篇/下一篇通知ID({prevId, nextId},0=无)。 */
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -197,6 +197,37 @@ public class FlowCenterClient implements FlowClient {
|
|||||||
return token.isEmpty() ? null : token;
|
return token.isEmpty() ? null : token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] diagramImageByFlowGuid(Integer schId, String flowGuid) {
|
||||||
|
requireEnabled();
|
||||||
|
String imgUrl;
|
||||||
|
if (directoryResolved) {
|
||||||
|
FlowModels.DiagramUrlVo dto = com.yunhai.platform.openapi.flow.YschFlowOpenApi.diagramUrl(
|
||||||
|
null, flowGuid, null, schId, FlowModels.DiagramUrlVo.class);
|
||||||
|
imgUrl = dto == null ? null : dto.getImgUrl();
|
||||||
|
} else {
|
||||||
|
Map<String, String> query = new HashMap<>();
|
||||||
|
query.put("flowGuid", flowGuid);
|
||||||
|
query.put("schId", String.valueOf(schId));
|
||||||
|
FlowModels.DiagramUrlVo dto =
|
||||||
|
openCall.get(target(), "/oapi/flow/diagram/url", query, FlowModels.DiagramUrlVo.class);
|
||||||
|
imgUrl = dto == null ? null : dto.getImgUrl();
|
||||||
|
}
|
||||||
|
String token = tokenOf(imgUrl);
|
||||||
|
if (token == null) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未返回有效的示意图地址");
|
||||||
|
}
|
||||||
|
Map<String, String> imgQuery = new HashMap<>();
|
||||||
|
imgQuery.put("token", token);
|
||||||
|
byte[] bytes = directoryResolved
|
||||||
|
? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw().download(DIAGRAM_IMG_PATH, imgQuery)
|
||||||
|
: openCall.download(target(), DIAGRAM_IMG_PATH, imgQuery);
|
||||||
|
if (bytes == null || bytes.length == 0) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "流程示意图为空(流程可能未发布节点)");
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
private TargetSpec target() {
|
private TargetSpec target() {
|
||||||
// base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定)
|
// base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定)
|
||||||
return TargetSpec.of(properties.getBaseUrl(), "");
|
return TargetSpec.of(properties.getBaseUrl(), "");
|
||||||
|
|||||||
@@ -33,4 +33,11 @@ public interface FlowClient {
|
|||||||
|
|
||||||
/** 流程示意图字节(token 换图两步都在本方法内,浏览器只拿同源 PNG,token 不落前端) */
|
/** 流程示意图字节(token 换图两步都在本方法内,浏览器只拿同源 PNG,token 不落前端) */
|
||||||
byte[] diagramImage(Integer schId, Long instanceId);
|
byte[] diagramImage(Integer schId, Long instanceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流程定义示意图字节(def 口径,绑定页/发通知页 Drawer):按 flowGuid 取最高已发布版出图。
|
||||||
|
* 与办件图同一免签 token 通道;本校限定由 flow-center 按 schId+flowGuid 收敛(绑定已解除/
|
||||||
|
* 无已发布版 → 业务异常文案,不留 500)。
|
||||||
|
*/
|
||||||
|
byte[] diagramImageByFlowGuid(Integer schId, String flowGuid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.yunhai.notice.service.flow;
|
||||||
|
|
||||||
|
import com.yunhai.notice.entity.Notice;
|
||||||
|
import com.yunhai.notice.repository.NoticeRepository;
|
||||||
|
import com.yunhai.platform.apistd.BizException;
|
||||||
|
import com.yunhai.platform.openapi.flow.YschFlowOpenApi;
|
||||||
|
import com.yunhai.platform.openapi.flow.model.FlowEmbedUrl;
|
||||||
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 办件进度嵌入页换址(2026-10-03 嵌入页方案):子应用 Drawer 内 iframe 直嵌 flow-center
|
||||||
|
* 统一进度页,零样式复制。与 Diagram 同口径的用户级守卫(未发布仅提交人/管理员/办理人
|
||||||
|
* /被抄送人可见);oapi 层只认 schId——"谁能看"必须落在本端点,应用签名不替代身份。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NoticeFlowEmbedService {
|
||||||
|
|
||||||
|
private final NoticeRepository noticeRepository;
|
||||||
|
private final NoticeFlowGuard noticeFlowGuard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取嵌入页地址(pageUrl 含 10 分钟短时 token):现取现发、不缓存、不落库、
|
||||||
|
* 不打进应用日志(token 泄露窗口即有效期,纪律见方案 §6.1/§7)。
|
||||||
|
*/
|
||||||
|
public Map<String, String> embedUrl(Integer noticeId, BoaUser user) {
|
||||||
|
Notice notice = noticeRepository.findById(noticeId)
|
||||||
|
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
|
||||||
|
if (notice.getStatus() == null || notice.getStatus() != 1) {
|
||||||
|
noticeFlowGuard.requireUnpublishedVisible(notice, user);
|
||||||
|
}
|
||||||
|
if (notice.getFlowInstanceId() == null || notice.getSchId() == null) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程");
|
||||||
|
}
|
||||||
|
FlowEmbedUrl embed = YschFlowOpenApi.embedUrl(
|
||||||
|
null, FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()),
|
||||||
|
notice.getSchId(), FlowEmbedUrl.class);
|
||||||
|
if (embed == null || !StringUtils.hasText(embed.getPageUrl())) {
|
||||||
|
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "嵌入页地址获取失败,请稍后重试");
|
||||||
|
}
|
||||||
|
return Collections.singletonMap("pageUrl", embed.getPageUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,10 @@ import com.yunhai.notice.model.flow.FlowModels;
|
|||||||
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
||||||
import com.yunhai.notice.repository.NoticeRepository;
|
import com.yunhai.notice.repository.NoticeRepository;
|
||||||
import com.yunhai.notice.repository.UpFileRepository;
|
import com.yunhai.notice.repository.UpFileRepository;
|
||||||
|
import com.yunhai.notice.utils.ReceiveParser;
|
||||||
import com.yunhai.platform.apistd.BizException;
|
import com.yunhai.platform.apistd.BizException;
|
||||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||||
|
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||||
import com.yunhai.platform.openapi.core.JumpTarget;
|
import com.yunhai.platform.openapi.core.JumpTarget;
|
||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
import com.yunhai.platform.utils.model.BoaUser;
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
@@ -152,7 +154,7 @@ public class NoticeFlowService {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 业务摘要:{title, summary(纯文本截 300 字), attachmentNames[]}——富文本正文不传(概览 §十边界) */
|
/** 业务摘要:原键照旧(流程中心透传、嵌入页不展示)+ 约定键 summaryFields(嵌入页「业务信息」唯一渲染源) */
|
||||||
private Map<String, Object> buildSnapshot(Notice notice) {
|
private Map<String, Object> buildSnapshot(Notice notice) {
|
||||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||||
snapshot.put("title", notice.getTitle());
|
snapshot.put("title", notice.getTitle());
|
||||||
@@ -164,9 +166,60 @@ public class NoticeFlowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
snapshot.put("attachmentNames", names);
|
snapshot.put("attachmentNames", names);
|
||||||
|
snapshot.put("summaryFields", buildSummaryFields(notice));
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 嵌入页「业务信息」三行:通知标题 / 来源部门 / 通知对象 */
|
||||||
|
private List<Map<String, Object>> buildSummaryFields(Notice notice) {
|
||||||
|
List<Map<String, Object>> fields = new ArrayList<>();
|
||||||
|
fields.add(summaryField("通知标题", notice.getTitle()));
|
||||||
|
fields.add(summaryField("来源部门", notice.getDeptName()));
|
||||||
|
fields.add(summaryField("通知对象", buildReceiveSummary(notice)));
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> summaryField(String label, Object value) {
|
||||||
|
Map<String, Object> field = new LinkedHashMap<>();
|
||||||
|
field.put("label", label);
|
||||||
|
field.put("value", value == null ? "" : value);
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知对象展示串:全员(receive_list 空 / ALL / UserAll)⇒ "全部";指定 ⇒ 前 3 个姓名以「、」连接,
|
||||||
|
* 多于 3 人再追 " 等N人"(如 "张三、李四、王五 等11人")。
|
||||||
|
* 姓名经用户中心解析(手法同 NoticeService.getReadDetailGrouped),失败回落原 id/账号;
|
||||||
|
* 只解析前 3 个——本方法在 apply 提交时执行,避免 N 次远程调用。
|
||||||
|
*/
|
||||||
|
private String buildReceiveSummary(Notice notice) {
|
||||||
|
List<String> accounts = ReceiveParser.parse(notice.getReceiveList());
|
||||||
|
if (accounts.isEmpty()) {
|
||||||
|
return "全部";
|
||||||
|
}
|
||||||
|
int total = accounts.size();
|
||||||
|
List<String> shown = new ArrayList<>();
|
||||||
|
for (String account : accounts.subList(0, Math.min(3, total))) {
|
||||||
|
shown.add(resolveReceiverName(account));
|
||||||
|
}
|
||||||
|
String joined = String.join("、", shown);
|
||||||
|
return total > 3 ? joined + " 等" + total + "人" : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 接收明细可能存登录名,也可能是历史数字 userId:先按登录名查,查不到且纯数字再按 id 查 */
|
||||||
|
private String resolveReceiverName(String account) {
|
||||||
|
try {
|
||||||
|
BoaUser user = YschBaseOpenApi.getUserInfo(account);
|
||||||
|
if (user == null && StringUtils.hasText(account) && account.chars().allMatch(Character::isDigit)) {
|
||||||
|
user = YschBaseOpenApi.getUserInfo(Integer.parseInt(account));
|
||||||
|
}
|
||||||
|
return user != null && StringUtils.hasText(user.getName()) ? user.getName() : account;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[notice-flow] 通知对象姓名解析失败 account={}", account, e);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 富文本剥标签取纯文本摘要(截断 300 字) */
|
/** 富文本剥标签取纯文本摘要(截断 300 字) */
|
||||||
private String plainSummary(String content) {
|
private String plainSummary(String content) {
|
||||||
if (!StringUtils.hasText(content)) {
|
if (!StringUtils.hasText(content)) {
|
||||||
|
|||||||
@@ -53,7 +53,16 @@ public class FileService implements IFileService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UpFileRepository upFileRepository;
|
private UpFileRepository upFileRepository;
|
||||||
|
|
||||||
@Value("${file.upload-path}")
|
/**
|
||||||
|
* 附件存储根目录(生产容器内 /data/upload,对应 projman 卷 /data/uploads/notice)。
|
||||||
|
* 平台标准(与 userhome/userbase/flow-center 同款):FILE_PATH 各子应用值相同
|
||||||
|
* (/data/upload),统一收敛在共享配置 application-platform.yml 一份,子应用不再各自维护;
|
||||||
|
* notice 曾走过渡键 FILE_UPLOAD_PATH(prod 配置 2026-10-10 已加,保留兼容);
|
||||||
|
* file.upload-path(application.yml 的 D:/data/upload)仅本地 Windows 开发用。
|
||||||
|
* 教训:生产曾因键名不匹配回落到 D:/data/upload——Linux 锚定为 /app/D:/data/upload
|
||||||
|
* 写进容器可写层,每次重新部署文件即丢(2026-10 事故)。
|
||||||
|
*/
|
||||||
|
@Value("${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}")
|
||||||
private String uploadPath;
|
private String uploadPath;
|
||||||
|
|
||||||
private static final String DEFAULT_APP_TYPE = "notice";
|
private static final String DEFAULT_APP_TYPE = "notice";
|
||||||
|
|||||||
@@ -707,6 +707,8 @@ public class NoticeService implements INoticeService {
|
|||||||
if (receivers.isEmpty()) {
|
if (receivers.isEmpty()) {
|
||||||
receivers = parseReceiveAccounts(notice.getReceiveList());
|
receivers = parseReceiveAccounts(notice.getReceiveList());
|
||||||
}
|
}
|
||||||
|
// 空态(ALL 全员通知)返回的是不可变 emptyList,先拷贝再追加
|
||||||
|
receivers = new ArrayList<>(receivers);
|
||||||
// 已读者不在接收明细中(如追送/数据修补),补充到末尾,保证已读情况完整
|
// 已读者不在接收明细中(如追送/数据修补),补充到末尾,保证已读情况完整
|
||||||
for (String account : readAccounts) {
|
for (String account : readAccounts) {
|
||||||
if (!receivers.contains(account)) {
|
if (!receivers.contains(account)) {
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ spring:
|
|||||||
simple:
|
simple:
|
||||||
auto-startup: false
|
auto-startup: false
|
||||||
|
|
||||||
# 文件上传存储路径(Docker 部署时与 volume 挂载一致)
|
# 文件上传存储路径——仅供本地 Windows 开发兜底;生产读 FILE_PATH(平台统一,
|
||||||
|
# 共享配置 application-platform.yml 一份 /data/upload,子应用不再各自维护),
|
||||||
|
# notice 过渡键 FILE_UPLOAD_PATH(prod 配置已加)仍兼容。
|
||||||
|
# 切勿在生产只依赖本键:历史教训是 Linux 容器把 D:/data/upload
|
||||||
|
# 锚定成 /app/D:/data/upload 写进容器可写层,每次更新部署附件全部丢失(2026-10)。
|
||||||
file:
|
file:
|
||||||
upload-path: D:/data/upload
|
upload-path: D:/data/upload
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.yunhai.notice.common.interceptor;
|
||||||
|
|
||||||
|
import com.yunhai.platform.auth.BoaUserContext;
|
||||||
|
import com.yunhai.platform.auth.LoginUser;
|
||||||
|
import com.yunhai.platform.auth.UserContextHolder;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端兜底拦截契约(批次3 T5):<br>
|
||||||
|
* ① 上下文为空 → 401 + 40101 信封;② 非空 → 放行;<br>
|
||||||
|
* ③ 断言不存在访问级别判级:最低权限制用户(学生 NONE:无角色/无学校语境)同样放行——
|
||||||
|
* 移动端主体含学生,照抄管理端 SUPER/BUREAU/SCHOOL/NONE 判级会全 403。
|
||||||
|
*/
|
||||||
|
@DisplayName("MobiAccessInterceptor - 登录即可,不判级")
|
||||||
|
class MobiAccessInterceptorTest {
|
||||||
|
|
||||||
|
private final MobiAccessInterceptor interceptor = new MobiAccessInterceptor();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
UserContextHolder.clear();
|
||||||
|
BoaUserContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
UserContextHolder.clear();
|
||||||
|
BoaUserContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("上下文为空 → 401 + 40101 信封")
|
||||||
|
void anonymousRejected401() throws Exception {
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mobiapi/notice/List");
|
||||||
|
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
boolean pass = interceptor.preHandle(req, res, new Object());
|
||||||
|
|
||||||
|
assertFalse(pass);
|
||||||
|
assertEquals(401, res.getStatus());
|
||||||
|
assertEquals("{\"code\":40101,\"msg\":\"未登录或登录已过期\",\"data\":null}",
|
||||||
|
res.getContentAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("LoginUser 在位 → 放行")
|
||||||
|
void loginContextPasses() {
|
||||||
|
UserContextHolder.set(new LoginUser());
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mobiapi/notice/List");
|
||||||
|
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
assertTrue(interceptor.preHandle(req, res, new Object()));
|
||||||
|
assertEquals(200, res.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("BoaUser 在位(无角色/学校语境的学生形态)→ 放行,无判级")
|
||||||
|
void studentShapedContextPassesWithoutLevelCheck() {
|
||||||
|
// 故意不设任何角色/权限字段:若实现引入访问级别判级,本用例将开始失败(回归哨)
|
||||||
|
BoaUserContext.set(new com.yunhai.platform.utils.model.BoaUser());
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mobiapi/notice/List");
|
||||||
|
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
assertTrue(interceptor.preHandle(req, res, new Object()));
|
||||||
|
assertEquals(200, res.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.yunhai.notice.config;
|
||||||
|
|
||||||
|
import com.yunhai.notice.common.interceptor.MobiAccessInterceptor;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端拦截装配契约(批次3 T5):/mobiapi/** 已注册兜底拦截、/mobiapi/auth/** 豁免
|
||||||
|
* (SDK pre-token 端点 login/bind/exchange 是登录链本身,设计上匿名可达)。
|
||||||
|
*/
|
||||||
|
@DisplayName("MobiWebMvcConfig - 拦截装配")
|
||||||
|
class MobiWebMvcConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("/mobiapi/** 注册 MobiAccessInterceptor 且豁免 /mobiapi/auth/**")
|
||||||
|
void registersMobiInterceptorWithAuthExclusion() throws Exception {
|
||||||
|
MobiWebMvcConfig config = new MobiWebMvcConfig();
|
||||||
|
InterceptorRegistry registry = new InterceptorRegistry();
|
||||||
|
config.addInterceptors(registry);
|
||||||
|
|
||||||
|
List<InterceptorRegistration> registrations = registrationsOf(registry);
|
||||||
|
assertEquals(1, registrations.size(), "应恰好注册一个拦截器");
|
||||||
|
|
||||||
|
InterceptorRegistration reg = registrations.get(0);
|
||||||
|
assertTrue(interceptorOf(reg) instanceof MobiAccessInterceptor,
|
||||||
|
"拦截器类型应为 MobiAccessInterceptor");
|
||||||
|
assertEquals(Arrays.asList("/mobiapi/**"), pathPatternsOf(reg), "拦截面应为 /mobiapi/**");
|
||||||
|
assertEquals(Arrays.asList("/mobiapi/auth/**"), excludePatternsOf(reg),
|
||||||
|
"豁免面应为 /mobiapi/auth/**(pre-token 端点是登录链,非业务白名单)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- InterceptorRegistration 无公开读取器,用反射断言(结构契约测试口径) ----
|
||||||
|
|
||||||
|
private static Object interceptorOf(InterceptorRegistration reg) throws Exception {
|
||||||
|
Field f = InterceptorRegistration.class.getDeclaredField("interceptor");
|
||||||
|
f.setAccessible(true);
|
||||||
|
return f.get(reg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InterceptorRegistration> registrationsOf(InterceptorRegistry registry) throws Exception {
|
||||||
|
Field f = InterceptorRegistry.class.getDeclaredField("registrations");
|
||||||
|
f.setAccessible(true);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<InterceptorRegistration> list = (List<InterceptorRegistration>) f.get(registry);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> pathPatternsOf(InterceptorRegistration reg) throws Exception {
|
||||||
|
Object include = patternsField(reg, "includePatterns");
|
||||||
|
return asStringList(include, "/mobiapi/**");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> excludePatternsOf(InterceptorRegistration reg) throws Exception {
|
||||||
|
Object exclude = patternsField(reg, "excludePatterns");
|
||||||
|
return asStringList(exclude, "/mobiapi/auth/**");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object patternsField(InterceptorRegistration reg, String name) throws Exception {
|
||||||
|
Field f = InterceptorRegistration.class.getDeclaredField(name);
|
||||||
|
f.setAccessible(true);
|
||||||
|
return f.get(reg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> asStringList(Object patterns, String expectedSingle) {
|
||||||
|
// PatternsRequestCondition 内部持有 List<String>(PathMatcher 语义,Ant 风格)
|
||||||
|
assertNotNull(patterns, "注册时必须显式声明路径模式(缺 " + expectedSingle + ")");
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (patterns instanceof Iterable) {
|
||||||
|
for (Object o : (Iterable<?>) patterns) {
|
||||||
|
out.add(String.valueOf(o));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.yunhai.notice.controller.mobiapi;
|
||||||
|
|
||||||
|
import com.yunhai.notice.model.notice.NoticeDetailVo;
|
||||||
|
import com.yunhai.notice.model.notice.NoticePageResult;
|
||||||
|
import com.yunhai.notice.model.notice.NoticeQuery;
|
||||||
|
import com.yunhai.notice.model.notice.NoticeVo;
|
||||||
|
import com.yunhai.notice.service.INoticeService;
|
||||||
|
import com.yunhai.platform.apistd.spring.ApiResponseAdvice;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端通知契约测试(批次3 T5):<br>
|
||||||
|
* ① 端点齐全性:List/SendList/Detail/ReadDetail/Expedite/InitializeRed/GetNextPrev/AllFileDown 八个映射在位;<br>
|
||||||
|
* ② 薄透传等价:与 {@code api.NoticeController} 注入同一 INoticeService、同参调用(Mock 入参捕获比对);<br>
|
||||||
|
* ③ 响应信封 {code,msg,data}(code:0,经 ApiResponseAdvice 包装,standalone MockMvc 注册同款 Advice);<br>
|
||||||
|
* ④ 401/200:拦截语义属 {@code MobiAccessInterceptorTest},此处验登录上下文在位时 200。
|
||||||
|
*/
|
||||||
|
@DisplayName("NoticeMobiController - 契约(端点齐全 / 薄透传 / 信封)")
|
||||||
|
class NoticeMobiControllerTest {
|
||||||
|
|
||||||
|
private INoticeService noticeService;
|
||||||
|
private NoticeMobiController controller;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
noticeService = mock(INoticeService.class);
|
||||||
|
controller = new NoticeMobiController();
|
||||||
|
org.springframework.test.util.ReflectionTestUtils.setField(controller, "noticeService", noticeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ① 端点齐全性:八个移动端路径都存在且方法级映射非空 */
|
||||||
|
@Test
|
||||||
|
@DisplayName("端点齐全:八路径映射在位")
|
||||||
|
void allEightEndpointsMapped() {
|
||||||
|
Set<String> paths = new HashSet<>();
|
||||||
|
for (Method m : NoticeMobiController.class.getDeclaredMethods()) {
|
||||||
|
GetMapping gm = m.getAnnotation(GetMapping.class);
|
||||||
|
PostMapping pm = m.getAnnotation(PostMapping.class);
|
||||||
|
if (gm != null) {
|
||||||
|
paths.addAll(Arrays.asList(gm.value()));
|
||||||
|
}
|
||||||
|
if (pm != null) {
|
||||||
|
paths.addAll(Arrays.asList(pm.value()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Set<String> expected = new HashSet<>(Arrays.asList(
|
||||||
|
"List", "SendList", "Detail", "ReadDetail",
|
||||||
|
"Expedite", "InitializeRed", "GetNextPrev", "AllFileDown"));
|
||||||
|
Set<String> missing = new HashSet<>(expected);
|
||||||
|
missing.removeAll(paths);
|
||||||
|
assertTrue(missing.isEmpty(), "缺少端点: " + missing + ",实际=" + paths);
|
||||||
|
assertEquals(expected.size(), paths.size(), "出现未预期端点: " + paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ② 薄透传:List 与 api 侧同参(同一 INoticeService.userList(query, pageable)) */
|
||||||
|
@Test
|
||||||
|
@DisplayName("薄透传:List 同参透传 INoticeService.userList")
|
||||||
|
void listDelegatesSameAsApiController() {
|
||||||
|
NoticeVo vo = new NoticeVo();
|
||||||
|
vo.setId(82);
|
||||||
|
vo.setTitle("契约测试");
|
||||||
|
NoticePageResultStub stub = new NoticePageResultStub(
|
||||||
|
Collections.singletonList(vo), 1, 1, 10, 1, false, 3);
|
||||||
|
when(noticeService.userList(any(), any())).thenReturn(stub);
|
||||||
|
|
||||||
|
NoticeQuery query = new NoticeQuery();
|
||||||
|
query.setPageIndex(1);
|
||||||
|
query.setPageSize(10);
|
||||||
|
Sort sort = Sort.by(Sort.Direction.DESC, "topmost", "createTime");
|
||||||
|
NoticePageResult<NoticeVo> out = controller.list(query, sort);
|
||||||
|
|
||||||
|
ArgumentCaptor<NoticeQuery> qCap = ArgumentCaptor.forClass(NoticeQuery.class);
|
||||||
|
ArgumentCaptor<org.springframework.data.domain.Pageable> pCap =
|
||||||
|
ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
||||||
|
verify(noticeService).userList(qCap.capture(), pCap.capture());
|
||||||
|
assertEquals(1, qCap.getValue().getPageIndex());
|
||||||
|
assertEquals(10, qCap.getValue().getPageSize());
|
||||||
|
assertEquals(10, pCap.getValue().getPageSize());
|
||||||
|
assertNotNull(pCap.getValue().getSort().getOrderFor("topmost"));
|
||||||
|
assertEquals(82, out.getContent().get(0).getId());
|
||||||
|
assertEquals(3, out.getUnreadCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ③ 信封 + ④ 登录态 200:standalone MockMvc + 同款 ApiResponseAdvice(走无 Sort 参数的 Detail) */
|
||||||
|
@Test
|
||||||
|
@DisplayName("信封 {code,msg,data} code:0 且登录态 200")
|
||||||
|
void envelopeShapeAndAuthorized200() throws Exception {
|
||||||
|
NoticeDetailVo vo = new NoticeDetailVo();
|
||||||
|
when(noticeService.detailMap(77, 1)).thenReturn(vo);
|
||||||
|
|
||||||
|
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller)
|
||||||
|
.setControllerAdvice(new ApiResponseAdvice())
|
||||||
|
.build();
|
||||||
|
// 模拟登录上下文(真实链路由 SDK LoginUserInterceptor 写入)
|
||||||
|
com.yunhai.platform.auth.UserContextHolder.set(new com.yunhai.platform.auth.LoginUser());
|
||||||
|
try {
|
||||||
|
mvc.perform(get("/mobiapi/notice/Detail").param("serid", "77").param("type", "1"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.msg").exists())
|
||||||
|
.andExpect(jsonPath("$.data").exists());
|
||||||
|
} finally {
|
||||||
|
com.yunhai.platform.auth.UserContextHolder.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detail 同参透传(type 默认 1 标读) */
|
||||||
|
@Test
|
||||||
|
@DisplayName("薄透传:Detail 同参透传 detailMap(serid,type)")
|
||||||
|
void detailDelegates() {
|
||||||
|
NoticeDetailVo vo = new NoticeDetailVo();
|
||||||
|
when(noticeService.detailMap(77, 1)).thenReturn(vo);
|
||||||
|
NoticeDetailVo out = controller.detail(77, 1);
|
||||||
|
assertNotNull(out);
|
||||||
|
verify(noticeService).detailMap(77, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试桩:避免依赖具体实现类构造(行为等价于 NoticeServiceImpl 返回形状) */
|
||||||
|
private static class NoticePageResultStub extends com.yunhai.notice.model.notice.NoticePageResult<NoticeVo> {
|
||||||
|
NoticePageResultStub(List<NoticeVo> content, int total, int pageIndex, int pageSize,
|
||||||
|
int totalPages, boolean hasNext, long unreadCount) {
|
||||||
|
super(content, total, pageIndex, pageSize, totalPages, hasNext, unreadCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.yunhai.notice.sdk;
|
||||||
|
|
||||||
|
import com.yunhai.platform.apistd.ApiResponse;
|
||||||
|
import com.yunhai.platform.apistd.spring.ApiStdProperties;
|
||||||
|
import com.yunhai.platform.apistd.spring.GlobalExceptionHandler;
|
||||||
|
import com.yunhai.platform.openapi.core.OpenApiException;
|
||||||
|
import com.yunhai.platform.openapi.spring.OpenApiAutoConfiguration;
|
||||||
|
import com.yunhai.platform.openapi.spring.OpenApiExceptionAdvice;
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D4 应用级证据(SDK 健壮性自审 2026-09-27):Notice 依赖树上 advice 真的可用的三重证明——
|
||||||
|
* <ol>
|
||||||
|
* <li>类可达:Notice classpath(聚合 starter → yh-openapi-spring-boot-starter + yh-api-std)
|
||||||
|
* 上 OpenApiExceptionAdvice 可加载;</li>
|
||||||
|
* <li>装配可达:servlet web 应用上下文里仅注册 OpenApiAutoConfiguration(无 YschOpenCall 等
|
||||||
|
* 其它 bean),嵌套条件装配即产出 advice bean——证明自动注册不依赖应用额外声明;</li>
|
||||||
|
* <li>行为可达:与 GlobalExceptionHandler 同场,SDK 出向失败落 code=5+档位文案
|
||||||
|
* (A 档原文 / B 档目标+状态 / C 档通用),非 SDK 异常仍 code=6,401 仍 code=4。</li>
|
||||||
|
* </ol>
|
||||||
|
* 本测试在 Notice 工程内运行(对 .m2 中新装 SDK 编译),即"应用上下文里 advice 生效"的
|
||||||
|
* 发布前等价证明;线上运行时证据另由部署后分档验证补齐。
|
||||||
|
*/
|
||||||
|
class NoticeSdkAdviceExposureTest {
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
static class SdkFailureController {
|
||||||
|
|
||||||
|
@GetMapping("/sdk/a")
|
||||||
|
public String addressing() {
|
||||||
|
// 真实 A 档形态:未配置的调用目标(ServiceDirectory.resolve 的原文)
|
||||||
|
throw new OpenApiException("unknown call target 'ghost': configure yh.open.services.ghost"
|
||||||
|
+ " (or yh.call.targets.ghost.base-host) first",
|
||||||
|
OpenApiException.FailureKind.ADDRESSING, "ghost");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sdk/b")
|
||||||
|
public String downstream() {
|
||||||
|
throw new OpenApiException("OpenAPI request failed: httpStatus=500", 500, "SECRET-DOWN",
|
||||||
|
OpenApiException.FailureKind.DOWNSTREAM, "usermsg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sdk/other")
|
||||||
|
public String other() {
|
||||||
|
throw new IllegalStateException("boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sdk/unauthorized")
|
||||||
|
public String unauthorized() {
|
||||||
|
throw new DException(ErrorCode.UNAUTHORIZED, "验签失败", null, null, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adviceClass_resolvable_onNoticeClasspath() throws Exception {
|
||||||
|
assertSame(OpenApiExceptionAdvice.class,
|
||||||
|
Class.forName("com.yunhai.platform.openapi.spring.OpenApiExceptionAdvice"));
|
||||||
|
assertSame(OpenApiException.FailureKind.class,
|
||||||
|
Class.forName("com.yunhai.platform.openapi.core.OpenApiException$FailureKind"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adviceBean_autoRegistered_inServletWebContext_withoutAppDeclaration() {
|
||||||
|
try (AnnotationConfigWebApplicationContext ctx = new AnnotationConfigWebApplicationContext()) {
|
||||||
|
ctx.register(OpenApiAutoConfiguration.class);
|
||||||
|
ctx.refresh();
|
||||||
|
assertNotNull(ctx.getBean(OpenApiExceptionAdvice.class),
|
||||||
|
"仅注册自动装配类(应用零声明)也应产出 advice bean");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sdkFailures_tieredExposure_code5_fallbackIntact() throws Exception {
|
||||||
|
MockMvc mvc = MockMvcBuilders.standaloneSetup(new SdkFailureController())
|
||||||
|
.setControllerAdvice(new OpenApiExceptionAdvice(),
|
||||||
|
new GlobalExceptionHandler(new ApiStdProperties()))
|
||||||
|
.build();
|
||||||
|
// A 档:原文透出(可行动配置指引),code≠6
|
||||||
|
mvc.perform(get("/sdk/a"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(5))
|
||||||
|
.andExpect(jsonPath("$.msg").value(
|
||||||
|
"unknown call target 'ghost': configure yh.open.services.ghost"
|
||||||
|
+ " (or yh.call.targets.ghost.base-host) first"));
|
||||||
|
// B 档:目标名+HTTP 状态,下游响应体不外露
|
||||||
|
String body = mvc.perform(get("/sdk/b")).andReturn().getResponse().getContentAsString();
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(body.contains("target=usermsg") && body.contains("HTTP 500"),
|
||||||
|
body);
|
||||||
|
org.junit.jupiter.api.Assertions.assertFalse(body.contains("SECRET-DOWN"), body);
|
||||||
|
// 非 SDK 异常:兜底未被打穿
|
||||||
|
mvc.perform(get("/sdk/other"))
|
||||||
|
.andExpect(jsonPath("$.code").value(6))
|
||||||
|
.andExpect(jsonPath("$.msg").value("内部服务错误"));
|
||||||
|
// 401 既有语义:code=4
|
||||||
|
mvc.perform(get("/sdk/unauthorized"))
|
||||||
|
.andExpect(jsonPath("$.code").value(4))
|
||||||
|
.andExpect(jsonPath("$.msg").value("验签失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.yunhai.notice.service.impl;
|
||||||
|
|
||||||
|
import com.yunhai.notice.entity.Notice;
|
||||||
|
import com.yunhai.notice.entity.NoticeRead;
|
||||||
|
import com.yunhai.notice.repository.NoticeReadRepository;
|
||||||
|
import com.yunhai.notice.repository.NoticeReceiverRepository;
|
||||||
|
import com.yunhai.notice.repository.NoticeRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归锁(批次3 T5):ALL 型全员通知的阅读情况分组不得再抛
|
||||||
|
* {@link UnsupportedOperationException}。<br>
|
||||||
|
* 根因:receive_list=ALL 时 parseReceiveAccounts 返回不可变空表,
|
||||||
|
* 历史实现直接在其上追加已读人 → 抛异常(PC api 侧同样命中)。
|
||||||
|
* 修复 = 追加前 new ArrayList 拷贝;本测试锁死该行为,防回归。
|
||||||
|
*/
|
||||||
|
@DisplayName("getReadDetailGrouped - ALL 型通知回归锁")
|
||||||
|
class NoticeReadDetailGroupedAllTypeTest {
|
||||||
|
|
||||||
|
private NoticeRepository noticeRepository;
|
||||||
|
private NoticeReadRepository noticeReadRepository;
|
||||||
|
private NoticeReceiverRepository noticeReceiverRepository;
|
||||||
|
private NoticeService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
noticeRepository = mock(NoticeRepository.class);
|
||||||
|
noticeReadRepository = mock(NoticeReadRepository.class);
|
||||||
|
noticeReceiverRepository = mock(NoticeReceiverRepository.class);
|
||||||
|
service = new NoticeService();
|
||||||
|
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
|
||||||
|
ReflectionTestUtils.setField(service, "noticeReadRepository", noticeReadRepository);
|
||||||
|
ReflectionTestUtils.setField(service, "noticeReceiverRepository", noticeReceiverRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Notice allTypeNotice() {
|
||||||
|
Notice n = new Notice();
|
||||||
|
ReflectionTestUtils.setField(n, "id", 77);
|
||||||
|
n.setTitle("E2E直发");
|
||||||
|
n.setReceiveList("ALL");
|
||||||
|
n.setSchId(990001);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ALL 型 + 已读一人:不再抛 UnsupportedOperationException,已读人入组")
|
||||||
|
void allTypeNotice_appendsReadersWithoutException() {
|
||||||
|
when(noticeRepository.findById(77)).thenReturn(Optional.of(allTypeNotice()));
|
||||||
|
NoticeRead read = new NoticeRead();
|
||||||
|
read.setReaderAccount("flow_liu");
|
||||||
|
read.setReaderName("刘老师(提交人)");
|
||||||
|
read.setReadTime(LocalDateTime.of(2026, 9, 27, 15, 14, 25));
|
||||||
|
when(noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(77))
|
||||||
|
.thenReturn(Collections.singletonList(read));
|
||||||
|
// ALL 型不落接收明细
|
||||||
|
when(noticeReceiverRepository.findByNoticeId(77)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
List<Map<String, Object>> groups = assertDoesNotThrow(() -> service.getReadDetailGrouped(77));
|
||||||
|
|
||||||
|
// 用户中心未装配(单测环境)→ 归「其他」组;已读人必须在列且状态 read
|
||||||
|
assertEquals(1, groups.size());
|
||||||
|
assertEquals("其他", groups.get(0).get("GroupName"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> teas = (List<Map<String, Object>>) groups.get(0).get("teaslist");
|
||||||
|
assertEquals(1, teas.size());
|
||||||
|
assertEquals("flow_liu", teas.get(0).get("account"));
|
||||||
|
assertEquals("read", teas.get(0).get("states"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user