Compare commits

..
2 Commits
Author SHA1 Message Date
chenxing d71901db35 feat(theme): 学校默认主题档三接口——schTheme 读 / saveSchThemeState 写 / clearSchTheme 清空(主题设置 S3)
build / build-push (push) Successful in 1m28s
- 契约对齐 YschTheme《主题设置与通道改动》§3.5:读已登录可开放且可空不兜底(与 getSchSettings 的 default-theme 兜底语义刻意区分);写仅学校管理员/超管(权限先例照抄 UserSettingsServiceImpl.findSchWallpaperManageList:type==1 或 OtherUtil.isAdminById(roleId));清空=置空 theme 字段(sch_settings 行还承载 logo 等配置不能删行,置空后读侧 null,效果与删行等价)
- 存储形态:theme_name 列存紧凑四段编码 theme|sideDeep01|gradOn01|sideFollow01(如 teal|0|1|1)——不拆列零 DDL;列宽未确认故不存整份 JSON;读侧只认该格式,旧档名/脏值一律视同未配置(防旧库值意外生效);写入侧四字段校验从严
- schId 解析:超管=显式参数或 0(平台默认层);其余=当前角色学校(UserCurrentRole),兜底主归属
- ISchSettingsService 新增 findBySchIdRaw(无兜底原样读,可空语义专用);既有 saveSchTheme/saveSchLogo/getSchSettings 不动(无其他调用方,影响面零)
2026-09-30 12:52:22 +08:00
chenxing 29f6c30309 feat(msgcenter): 列表筛选参数透传 + summary 聚合 UserMsg 分类计数(消息对接 2026-09-30)
- MessageCenterController.msgCenter/list:可选参数 msgType(0/1/2)/appKey/isRead(0/1)/keyword 透传代理,不传与现状一致(Header 回归无影响)
- MsgCenterProxyService.list:非空才下发 query(旧 UserMsg 收到未知参数会被忽略——UserMsg 部署前线上筛选静默失效,属预期过渡,待人工清单含 UserMsg 部署)
- MsgCenterProxyService.summary:优先调 UserMsg /oapi/msg/summary(新端点:unreadCount/sysUnread/bizUnread/approvalUnread/total/readCount 一次喂饱左栏);旧版无该端点(404/异常)自动降级 unreadCount 单查,分类字段缺省由前端兜底
- 单测:list_filtersPassedThrough(透传+空值不下发)、summary_prefersSummaryEndpoint、summary_legacyUserMsg_fallsBackToUnreadCount;既有 28 用例回归全绿(31 run 0 fail)
2026-09-30 12:37:55 +08:00
6 changed files with 264 additions and 18 deletions
@@ -40,13 +40,18 @@ public class MessageCenterController {
return 0; return 0;
} }
/** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空) */ /** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空)。
* 可选服务端筛选:msgType 0/1/2、appKey、isRead 0/1、keyword 标题/内容模糊(不传与现状一致)。 */
@GetMapping("msgCenter/list") @GetMapping("msgCenter/list")
public Map<String, Object> list(BoaUser authUserWithDept, public Map<String, Object> list(BoaUser authUserWithDept,
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "count", required = false) Integer count, @RequestParam(value = "count", required = false) Integer count,
@RequestParam(value = "onlyUnread", required = false) Boolean onlyUnread) { @RequestParam(value = "onlyUnread", required = false) Boolean onlyUnread,
return proxy.list(authUserWithDept.getId(), page, count, onlyUnread); @RequestParam(value = "msgType", required = false) Integer msgType,
@RequestParam(value = "appKey", required = false) String appKey,
@RequestParam(value = "isRead", required = false) Integer isRead,
@RequestParam(value = "keyword", required = false) String keyword) {
return proxy.list(authUserWithDept.getId(), page, count, onlyUnread, msgType, appKey, isRead, keyword);
} }
/** 铃铛摘要:未读数 + 各应用待办角标 */ /** 铃铛摘要:未读数 + 各应用待办角标 */
@@ -1,10 +1,15 @@
package com.yunhai.userhome.controller.api; package com.yunhai.userhome.controller.api;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config; import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.SchSettings; import com.yunhai.userhome.entity.SchSettings;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.service.ISchSettingsService; import com.yunhai.userhome.service.ISchSettingsService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.utils.OtherUtil;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -12,20 +17,34 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Date; import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
* 学校设置接口(CAS 登录态,路径 {@code /{platform}/}),对齐旧 UserHome SchSettingsController。 * 学校设置接口(CAS 登录态,路径 {@code /{platform}/}),对齐旧 UserHome SchSettingsController。
* <p>getSchSettings 查本地 sch_settings 表(不再依赖跨仓 schConfig); * <p>getSchSettings 查本地 sch_settings 表(不再依赖跨仓 schConfig);
* saveSchTheme/saveSchLogo 写本地 sch_settings 表。</p> * saveSchTheme/saveSchLogo 写本地 sch_settings 表。</p>
* <p>主题设置轮(2026-09-30)新增学校默认主题档三接口(契约见 YschTheme《主题设置与通道改动-执行方案》§3.5):
* schTheme 读(已登录可读,可空不兜底——与 getSchSettings 的兜底语义刻意区分);
* saveSchThemeState 写(仅学校管理员/超管);clearSchTheme 清空(置空 theme 字段——sch_settings 行还承载
* logo 等字段不能删行,置空后读侧返回 null,效果等同"未配置")。存储形态:theme_name 列存紧凑四段编码
* {@code theme|sideDeep01|gradOn01|sideFollow01}(如 teal|0|1|1;不拆列、零 DDL,列宽未确认故不存整份 JSON;
* 旧格式值/非法值读侧一律视同未配置)。</p>
*/ */
@RestController @RestController
@RequestMapping(value = Config.API_PATH) @RequestMapping(value = Config.API_PATH)
public class SchSettingsController { public class SchSettingsController {
/** 合法档位(与通道 THEME_NAMES 一致;防旧库脏值意外生效) */
private static final String[] THEME_KEYS = {"blue", "ocean", "ink", "violet", "teal"};
@Autowired @Autowired
private ISchSettingsService schSettingsService; private ISchSettingsService schSettingsService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
private Integer resolveSchId(BoaUser user) { private Integer resolveSchId(BoaUser user) {
if (user.getSchool() != null) { if (user.getSchool() != null) {
return user.getSchool().getId(); return user.getSchool().getId();
@@ -36,6 +55,114 @@ public class SchSettingsController {
return 0; return 0;
} }
/** 主题档的目标学校:超管=显式 schId 或 0(平台默认层);其余=当前角色学校(兜底主归属) */
private Integer resolveThemeSchId(BoaUser user, Integer explicitSchId) {
if (user.getType() != null && user.getType() == 1) {
return explicitSchId == null ? 0 : explicitSchId;
}
return userCurrentRoleService.findById(user.getId())
.map(UserCurrentRole::getSchId)
.filter(schId -> schId != null && schId > 0)
.orElseGet(() -> resolveSchId(user));
}
/** 主题档写权限:超管或学校管理员(先例:UserSettingsServiceImpl.findSchWallpaperManageList) */
private void requireThemeAdmin(BoaUser user) {
boolean superAdmin = user.getType() != null && user.getType() == 1;
if (!superAdmin) {
Optional<UserCurrentRole> currentRole = userCurrentRoleService.findById(user.getId());
if (!currentRole.isPresent() || !OtherUtil.isAdminById(currentRole.get().getRoleId())) {
throw new DException("仅学校管理员可配置本校默认主题");
}
}
}
/** 紧凑编码 → ThemeState JSON;非本编码形态(旧档名/脏值/字段非法)一律 null(视同未配置) */
private static String decodeThemeState(String encoded) {
if (!StringUtils.hasText(encoded)) return null;
String[] parts = encoded.trim().split("\\|");
if (parts.length != 4) return null;
if (!isThemeKey(parts[0])) return null;
for (int i = 1; i < 4; i++) {
if (parts[i].length() != 1 || (parts[i].charAt(0) != '0' && parts[i].charAt(0) != '1')) return null;
}
return "{\"theme\":\"" + parts[0] + "\",\"sideDeep\":" + parts[1]
+ ",\"gradOn\":" + parts[2] + ",\"sideFollow\":" + parts[3] + "}";
}
/** ThemeState JSON 校验(四字段齐全、档名合法)+ → 紧凑编码;非法抛业务异常(写入侧从严) */
private static String encodeThemeState(String themeStateJson) {
if (!StringUtils.hasText(themeStateJson)) throw new DException("主题档不能为空");
com.fasterxml.jackson.databind.JsonNode n;
try {
n = new com.fasterxml.jackson.databind.ObjectMapper().readTree(themeStateJson);
} catch (Exception e) {
throw new DException("主题档格式不合法");
}
if (n == null || !n.isObject() || !n.hasNonNull("theme") || !n.has("sideDeep") || !n.has("gradOn") || !n.has("sideFollow")) {
throw new DException("主题档格式不合法");
}
String theme = n.get("theme").asText();
if (!isThemeKey(theme)) throw new DException("主题档名不合法: " + theme);
return theme + "|" + (n.get("sideDeep").asBoolean() ? 1 : 0)
+ "|" + (n.get("gradOn").asBoolean() ? 1 : 0)
+ "|" + (n.get("sideFollow").asBoolean() ? 1 : 0);
}
private static boolean isThemeKey(String theme) {
if (theme == null) return false;
for (String k : THEME_KEYS) {
if (k.equals(theme)) return true;
}
return false;
}
/**
* 学校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null, schId}。
* 未配置/旧格式值/脏数据 → themeState=null(前端"不写键、不报错")。
*/
@GetMapping("schTheme")
public Map<String, Object> schTheme(BoaUser authUserWithDept) {
Integer schId = resolveThemeSchId(authUserWithDept, null);
String themeState = schSettingsService.findBySchIdRaw(schId)
.map(SchSettings::getThemeName)
.map(SchSettingsController::decodeThemeState)
.orElse(null);
Map<String, Object> out = new HashMap<>();
out.put("themeState", themeState);
out.put("schId", schId);
return out;
}
/** 保存学校默认主题档(仅学校管理员/超管):themeState 为整份 ThemeState JSON,服务端转紧凑编码存 theme_name。 */
@PostMapping("saveSchThemeState")
public void saveSchThemeState(BoaUser authUserWithDept,
@RequestParam("themeState") String themeState,
@RequestParam(name = "schId", required = false) Integer schId) {
requireThemeAdmin(authUserWithDept);
Integer targetSchId = resolveThemeSchId(authUserWithDept, schId);
String encoded = encodeThemeState(themeState);
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
schSettings.setCDate(new Date());
schSettings.setSchId(targetSchId);
schSettings.setCId(authUserWithDept.getId());
schSettings.setThemeName(encoded);
schSettingsService.save(schSettings);
}
/** 清空学校默认主题(仅管理员):置空 theme 字段(行还承载 logo 等配置不能删行;置空后读侧返回 null)。 */
@PostMapping("clearSchTheme")
public void clearSchTheme(BoaUser authUserWithDept,
@RequestParam(name = "schId", required = false) Integer schId) {
requireThemeAdmin(authUserWithDept);
Integer targetSchId = resolveThemeSchId(authUserWithDept, schId);
Optional<SchSettings> existing = schSettingsService.findBySchIdRaw(targetSchId);
if (!existing.isPresent()) return;
SchSettings schSettings = existing.get();
schSettings.setThemeName(null);
schSettingsService.save(schSettings);
}
@PostMapping("saveSchTheme") @PostMapping("saveSchTheme")
public void saveSchTheme(BoaUser authUserWithDept, String themeName, @RequestParam(name = "schId", defaultValue = "0") Integer schId) { public void saveSchTheme(BoaUser authUserWithDept, String themeName, @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
if (schId == null || schId == 0) { if (schId == null || schId == 0) {
@@ -12,5 +12,8 @@ public interface ISchSettingsService {
Optional<SchSettings> findBySchId(Integer schId); Optional<SchSettings> findBySchId(Integer schId);
/** 原样读(无兜底):学校默认主题档等"可空语义"场景专用——查无返回 empty,不合成默认对象 */
Optional<SchSettings> findBySchIdRaw(Integer schId);
List<SchSettings> findBySchIds(List<Integer> schIds); List<SchSettings> findBySchIds(List<Integer> schIds);
} }
@@ -82,8 +82,13 @@ public class MsgCenterProxyService {
return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class); return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class);
} }
/** 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl 与 showJump 判权出参 */ /**
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) { * 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl 与 showJump 判权出参。
* 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致):
* msgType 0/1/2、appKey 精确、isRead 0/1、keyword 标题/内容模糊。
*/
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread,
Integer msgType, String appKey, Integer isRead, String keyword) {
Map<String, Object> empty = emptyPage(); Map<String, Object> empty = emptyPage();
if (!enabled()) { if (!enabled()) {
return empty; return empty;
@@ -95,6 +100,14 @@ public class MsgCenterProxyService {
query.put("pageIndex", String.valueOf(pageIndex == null || pageIndex < 1 ? 1 : pageIndex)); query.put("pageIndex", String.valueOf(pageIndex == null || pageIndex < 1 ? 1 : pageIndex));
query.put("pageSize", String.valueOf(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100))); query.put("pageSize", String.valueOf(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100)));
query.put("onlyUnread", String.valueOf(Boolean.TRUE.equals(onlyUnread))); query.put("onlyUnread", String.valueOf(Boolean.TRUE.equals(onlyUnread)));
if (msgType != null) {
query.put("msgType", String.valueOf(msgType));
}
putIfText(query, "appKey", appKey);
if (isRead != null) {
query.put("isRead", String.valueOf(isRead));
}
putIfText(query, "keyword", keyword);
JsonNode data = get("/oapi/msg/list", query); JsonNode data = get("/oapi/msg/list", query);
List<Map<String, Object>> rows = new ArrayList<>(); List<Map<String, Object>> rows = new ArrayList<>();
if (data != null && data.path("content").isArray()) { if (data != null && data.path("content").isArray()) {
@@ -112,7 +125,11 @@ public class MsgCenterProxyService {
} }
} }
/** 铃铛摘要:未读数 + 各应用待办角标(停机各自降级 0/空) */ /**
* 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 0/空)。
* 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount)来自 UserMsg /oapi/msg/summary
* (消息中心左栏一次喂饱);旧版 UserMsg 无该端点 → 字段缺省不出现,前端按降级处理。
*/
public Map<String, Object> summary(Integer userId, Integer schId) { public Map<String, Object> summary(Integer userId, Integer schId) {
Map<String, Object> out = new HashMap<>(); Map<String, Object> out = new HashMap<>();
out.put("unreadCount", 0L); out.put("unreadCount", 0L);
@@ -120,13 +137,33 @@ public class MsgCenterProxyService {
if (!enabled()) { if (!enabled()) {
return out; return out;
} }
boolean summaryFetched = false;
try { try {
Map<String, String> q = new LinkedHashMap<>(); Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId)); q.put("userId", String.valueOf(userId));
JsonNode unread = get("/oapi/msg/unreadCount", q); JsonNode vo = get("/oapi/msg/summary", q);
out.put("unreadCount", unread == null || !unread.isNumber() ? 0L : unread.asLong()); if (vo != null && vo.has("unreadCount")) {
out.put("unreadCount", vo.get("unreadCount").asLong());
out.put("sysUnread", vo.path("sysUnread").asLong(0));
out.put("bizUnread", vo.path("bizUnread").asLong(0));
out.put("approvalUnread", vo.path("approvalUnread").asLong(0));
out.put("total", vo.path("total").asLong(0));
out.put("readCount", vo.path("readCount").asLong(0));
summaryFetched = true;
}
} catch (Exception e) { } catch (Exception e) {
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString()); // 旧版 UserMsg 无 summary 端点:降级走 unreadCount 单查,字段缺省由前端兜底
log.debug("[msgcenter] UserMsg 摘要端点不可用(降级未读单查)userId={}:{}", userId, e.toString());
}
if (!summaryFetched) {
try {
Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId));
JsonNode unread = get("/oapi/msg/unreadCount", q);
out.put("unreadCount", unread == null || !unread.isNumber() ? 0L : unread.asLong());
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString());
}
} }
try { try {
Map<String, String> q = new LinkedHashMap<>(); Map<String, String> q = new LinkedHashMap<>();
@@ -400,6 +437,13 @@ public class MsgCenterProxyService {
return out; return out;
} }
/** 有值(非空白)才进 query——空串不透传,保持与不传一致 */
private static void putIfText(Map<String, String> query, String key, String value) {
if (StringUtils.hasText(value)) {
query.put(key, value.trim());
}
}
private static String text(JsonNode node, String field) { private static String text(JsonNode node, String field) {
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null; return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
} }
@@ -23,6 +23,11 @@ public class SchSettingsServiceImpl implements ISchSettingsService {
schSettingsRepository.save(schSettings); schSettingsRepository.save(schSettings);
} }
@Override
public Optional<SchSettings> findBySchIdRaw(Integer schId) {
return schSettingsRepository.findFirstBySchId(schId);
}
@Override @Override
public Optional<SchSettings> findBySchId(Integer schId) { public Optional<SchSettings> findBySchId(Integer schId) {
Optional<SchSettings> local = schSettingsRepository.findFirstBySchId(schId); Optional<SchSettings> local = schSettingsRepository.findFirstBySchId(schId);
@@ -87,7 +87,7 @@ class MsgCenterProxyServiceTest {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(null); .thenReturn(null);
service.list(201, 1, 10, null); service.list(201, 1, 10, null, null, null, null, null);
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class); ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
verify(openCall).get(spec.capture(), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)); verify(openCall).get(spec.capture(), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class));
@@ -109,7 +109,7 @@ class MsgCenterProxyServiceTest {
+ "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0," + "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0,"
+ "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}")); + "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}"));
Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE); Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE, null, null, null, null);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class); ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
@@ -135,7 +135,7 @@ class MsgCenterProxyServiceTest {
void list_defaultsPageParams() { void list_defaultsPageParams() {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(null); .thenReturn(null);
service.list(201, 0, 999, null); service.list(201, 0, 999, null, null, null, null, null);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class); ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class)); verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class));
@@ -150,7 +150,7 @@ class MsgCenterProxyServiceTest {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\"," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\","
+ "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}")); + "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名"); assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名");
} }
@@ -259,7 +259,7 @@ class MsgCenterProxyServiceTest {
void list_userMsgDown_degradesToEmpty() { void list_userMsgDown_degradesToEmpty() {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401")); .thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
assertEquals(0, out.get("total")); assertEquals(0, out.get("total"));
assertEquals(0, ((java.util.List<?>) out.get("list")).size()); assertEquals(0, ((java.util.List<?>) out.get("list")).size());
} }
@@ -309,6 +309,68 @@ class MsgCenterProxyServiceTest {
assertEquals(1, ((java.util.List<?>) out2.get("todos")).size()); assertEquals(1, ((java.util.List<?>) out2.get("todos")).size());
} }
/** /msg 页服务端筛选(消息对接 2026-09-30):msgType/appKey/isRead/keyword 透传,空值不下发 */
@Test
void list_filtersPassedThrough() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":0,\"content\":[]}"));
service.list(201, 1, 10, null, 2, " 100019 ", 0, "请假");
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class));
assertEquals("2", query.getValue().get("msgType"));
assertEquals("100019", query.getValue().get("appKey"), "appKey 去首尾空白后透传");
assertEquals("0", query.getValue().get("isRead"));
assertEquals("请假", query.getValue().get("keyword"));
// 全空筛选不出现新键(与历史调用逐字一致)
service.list(201, 1, 10, null, null, " ", null, "");
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query2 = ArgumentCaptor.forClass(Map.class);
verify(openCall, org.mockito.Mockito.times(2))
.get(any(TargetSpec.class), eq("/oapi/msg/list"), query2.capture(), eq(JsonNode.class));
assertFalse(query2.getAllValues().get(1).containsKey("msgType"));
assertFalse(query2.getAllValues().get(1).containsKey("appKey"));
assertFalse(query2.getAllValues().get(1).containsKey("isRead"));
assertFalse(query2.getAllValues().get(1).containsKey("keyword"));
}
/** 摘要优先走 /oapi/msg/summary(分类计数一次喂饱),且不再单查 unreadCount */
@Test
void summary_prefersSummaryEndpoint() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"unreadCount\":5,\"sysUnread\":2,\"bizUnread\":1,\"approvalUnread\":2,"
+ "\"total\":9,\"readCount\":4}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
Map<String, Object> out = service.summary(201, 1);
assertEquals(5L, out.get("unreadCount"));
assertEquals(2L, out.get("sysUnread"));
assertEquals(1L, out.get("bizUnread"));
assertEquals(2L, out.get("approvalUnread"));
assertEquals(9L, out.get("total"));
assertEquals(4L, out.get("readCount"));
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class));
}
/** 旧版 UserMsg 无 summary 端点(404/异常)→ 降级 unreadCount 单查,分类字段缺省 */
@Test
void summary_legacyUserMsg_fallsBackToUnreadCount() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(JsonNode.class)))
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=404"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("7"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
Map<String, Object> out = service.summary(201, 1);
assertEquals(7L, out.get("unreadCount"));
assertFalse(out.containsKey("sysUnread"), "旧端点无分类计数,字段缺省由前端降级");
}
@Test @Test
void read_sendsOwnerScopedPayload() { void read_sendsOwnerScopedPayload() {
service.read(201, java.util.Collections.singletonList(7L)); service.read(201, java.util.Collections.singletonList(7L));
@@ -320,7 +382,7 @@ class MsgCenterProxyServiceTest {
void disabled_degradesWithoutOutbound() { void disabled_degradesWithoutOutbound() {
properties.setUsermsgBaseUrl(""); properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false); when(openCall.hasTarget("usermsg")).thenReturn(false);
assertEquals(0, service.list(201, 1, 10, null).get("total")); assertEquals(0, service.list(201, 1, 10, null, null, null, null, null).get("total"));
service.read(201, java.util.Collections.singletonList(7L)); service.read(201, java.util.Collections.singletonList(7L));
verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any()); verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any());
verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any()); verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any());
@@ -377,7 +439,7 @@ class MsgCenterProxyServiceTest {
} }
private Map<?, ?> firstRow() throws Exception { private Map<?, ?> firstRow() throws Exception {
Map<String, Object> out = service.list(201, 1, 10, null); Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
return (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); return (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
} }
@@ -450,7 +512,7 @@ class MsgCenterProxyServiceTest {
.thenReturn(json("{\"total\":2,\"content\":[" .thenReturn(json("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0}," + "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0},"
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/list/2\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/list/2\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
java.util.List<?> rows = (java.util.List<?>) out.get("list"); java.util.List<?> rows = (java.util.List<?>) out.get("list");
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(0)).get("showJump")); assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(0)).get("showJump"));
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(1)).get("showJump")); assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(1)).get("showJump"));
@@ -467,7 +529,7 @@ class MsgCenterProxyServiceTest {
.thenReturn(json("{\"total\":2,\"content\":[" .thenReturn(json("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/task/1\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}," + "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/task/1\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0},"
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/task/2\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); + "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/task/2\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
java.util.List<?> rows = (java.util.List<?>) out.get("list"); java.util.List<?> rows = (java.util.List<?>) out.get("list");
assertNull(((Map<?, ?>) rows.get(0)).get("jumpUrl"), "注册表不可达无兜底 → null"); assertNull(((Map<?, ?>) rows.get(0)).get("jumpUrl"), "注册表不可达无兜底 → null");
assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl")); assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"));