Compare commits

..
15 Commits
Author SHA1 Message Date
ops 35f7a2eeea fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:30 +00:00
ops d7ea119996 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:01 +00:00
chenxing d2cf718b51 fix(小组件外跳): 目录形态 pc_url 保留尾斜杠,修 /front#/weekplan 少一个斜杠
build / build-push (push) Successful in 1m6s
- 根因:归一化把尾斜杠一并去掉,目录形态基址(.../work-week-plan/front/)被削成 .../front,拼出 .../front#/weekplan(应 .../front/#/weekplan)
- 规则:丢 fragment 后,末段不含 '.'(非文件)视为目录并补回单个斜杠;文件形态(.../index.htm(l))直接拼 #
- 注册表两种形态并存:work-week-plan/aca-syllabus 是目录,notice/coursehomework 是 index.htm(l)
- 新增 WidgetLinkResolverTest 8 例:目录/无斜杠目录/文件/fragment 覆盖/路由归一化/三类降级/不可达短路/查无缓存
2026-10-15 21:02:51 +08:00
chenxing 920408dd0f feat(一周安排小组件): 外跳入口配置(link_app_code/link_path) + 地址按应用注册表解析
build / build-push (push) Successful in 2m24s
- 新增 WidgetLinkResolver:走 SDK OpenApiAppClient.getAppByCode 取 pc_url,归一化(丢 fragment/去尾斜杠)+#+路由;60s TTL+30s 短路;查无/不可达/路由写绝对 URL 一律 null(不猜、不渲染)
- widget_catalog 与 widget_school_setting 各加 link_app_code/link_path(校管可覆盖本校,NULL=沿用全局)
- 生效值合成走 COALESCE;新增「两列必须成对」校验;「全空=清除覆盖」判定由 4 字段扩到 6 字段
- getUserSettings 按需下发 overviewUrl(解析不出则不 put)
- sql/widget_catalog_v3.sql(生产 proj11 已执行两条 ALTER + weekplan 配置)
- seeder 空库给 weekplan 默认 work-week-plan + /weekplan
2026-10-15 20:26:09 +08:00
chenxing 1f31612609 feat(一周安排小组件): weekplan 对接 WorkWeekPlan GET /oapi/workplan/Get,替换原 L2 mock
build / build-push (push) Successful in 56s
- 分发 CODE_WEEKPLAN -> weekplan(caller, params),原样透传单个周对象 data(含 days[7])
- week 为相对本周偏移(0=本周/-1=上周/+1=下周),缺省 0 不向外传(签名 target 最小化);合法范围见 minWeek/maxWeek
- 缓存 key 改 schId|week偏移(全校共享、与身份无关,但不同周内容不同)
- 90s TTL + 15s 失败退避 + 上限 500;缺身份/不可达/验签失败/越界均降级空对象,不抛 500
- 配套 front WeekplanWidget 翻页改版
2026-10-15 16:30:13 +08:00
chenxing ad28149731 chore(oapi): acasyllabu/workplan targets 切开放网关——base-host 指向 open-gateway-service:10013(20261015 切门收口,oapi 统一经网关验签/转发/台账);签名口径不变(网关 canonical 与 starter 逐字节一致);workplan 真实 ctx=/work-week-plan 已网关签名 200 实测并登记注册表(targets 18/routes 25)
build / build-push (push) Successful in 1m4s
2026-10-15 15:25:24 +08:00
chenxing 71c571f884 fix(应用列表): schId 非正数哨兵边界归一——超管 GetUserApp 恢复豁免
build / build-push (push) Successful in 57s
前端 getCurSchId 对无学校身份(超管/平台账号)下发哨兵 -1(store: schId || -1),
UserBase 2026-10-12 学校单点开通硬闸门对显式 schoolId 先过 assertSchoolId,
-1 非合法学校主键被拒("学校分组不存在"),且超管豁免(isSuperAdmin bypass)
在校验之后根本走不到。OpenApiAppRemoteServiceImpl 边界归一:schoolId<=0 → null,
交 UserBase 按激活身份/主归属兜底——超管恢复全量豁免,GetAppInfoById 同口径覆盖。
2026-10-13 14:27:39 +08:00
chenxing 0099e5df2b feat(课表小组件): syllabus对接AcaSyllabu GET /oapi/timetable/Get——YschOpenCall出站零手写签名,真实ctx=/aca-syllabu(20261013生产401定位,手册/acasyllabu有误);90s TTL缓存+15s失败退避+上限500,不可达降级空数组不500;身份三参全会话推导前端不可传;widget_catalog种子名改「课程表」
build / build-push (push) Successful in 2m7s
2026-10-13 12:27:18 +08:00
chenxing 927d4b14f8 feat(小组件管理改版): 后端校级覆盖三态+目录权/校级权拆分+内容ETag,退役应用管理(AppCategory全家)——配套已上线front 5a19a4e;含getWidgetData传参改Caller{userId,roleId,schId}(数据层WidgetDataService随下一提交);DDL见sql/widget_catalog_v2.sql(生产需手工执行);msg by-code换源已随9370429上线不在本提交 2026-10-13 12:27:12 +08:00
chenxing 9370429455 feat(msg): 消息归属换源页面属主——rewrite 出参 appId/appName 按 jumpApp by-code 解析(缓存+N+1 消除,查无/不可达回落发送方);countByApp 双桶按注册表 appId 合并;类目筛选翻译 app_key+app_code 双值(SDK appKeys -70);任务书:消息归属子应用收口-20261013
build / build-push (push) Successful in 1m22s
2026-10-13 11:39:40 +08:00
chenxing 175dacc9ad 学校应用开通硬闸门:UserHome 贯通 schId 到 SDK getUserApp
build / build-push (push) Failing after 13s
- AppRemoteService/OpenApiAppRemoteServiceImpl 增 getUserApps(user,schoolId)、getAppById(user,appId,schoolId) 重载(原方法委托传 null)
- UserController.getUserApp 透传 schId 并删「schId 暂不参与」过时注释;getAppInfoById 同步带 schId 收窄
2026-10-12 14:23:26 +08:00
chenxing 29d32eed6e feat(小组件内置化): 候选池真源换自建 widget_catalog + 统一取数/内容/管理端四接口
build / build-push (push) Successful in 58s
- widget_catalog:comp_type=1 内容在 front 工程(库里只登记键)、=2 内容存 content,全表无 url 字段 ⇒ 小组件链路彻底不再有「html 地址」;sch_id 只留字段与 visibleToSchool 过滤钩子(v1 全校一致)
- 新接口(真实路径按 /{platform} 展开为 /api/xxx):getWidgetData?code=&params=(统一取数,未知 code 回空数据+msg 不抛 500)、getWidgetContent?code=(仅 comp_type=2 按需取正文,不塞进 getUserSettings 免每次进桌面下发全部 HTML)、GetWidgetCatalogList、SaveWidgetCatalog(content 走 JSON body)
- 管理端鉴权照抄 AppSettingController.checkAdmin(超管 type==1 或 roleId∈{2,5,7},判定标准一字未改;该方法是 private 故复制逻辑),拒绝走 BizException(ErrorCode.FORBIDDEN),按平台信封为 code=3 非 500
- getUserSettings:widgetAppList 改读自建表并增 code/compType、删 url,id/appId 同值以复用既有 widgetAppIds 存取与面板排序链路;saveUserWidget/toggleWidgetShow 未动
- 数据源按拍板走 L2 可溯源 mock(yunhai 9 工程检索课表/听评课零命中),字段逐一等于 design/widget-snapshots-old-bsch 旧接口口径,接入点以 // TODO 接入 oapi: 标注;本期无出站,故未注入 YschOpenCall(零手写签名代码)
- DDL:仓库无 migration 机制(ddl-auto=update),脚本按既有约定落 resources/sql/widget_catalog.sql,dev 自动建表、测试/生产需手工执行(INSERT IGNORE 可重复跑)
2026-10-11 08:47:54 +08:00
chenxing 47f9e3f751 feat(appearance): /oapi/appearance 出参摘除 logoText(子应用 header 定稿只出 logo 图,门户文字走 /api/schLogoText 自有链;init 侧缺失字段自动清 yh-logo-text)——出参恰三字段 {theme,avatar,logoUrl},测试对齐
build / build-push (push) Successful in 2m14s
2026-10-09 17:08:00 +08:00
chenxing 977b5c90a4 fix(壁纸): 删除选中壁纸后白板修复——删除清理按 raw+展示双形态匹配、getUserSettings 悬空引用自愈回落兜底链、管理员选定默认被删池自动跳过、池兜底改取第一张(min id,与用户口径及原注释一致)
build / build-push (push) Successful in 1m56s
2026-10-09 14:53:12 +08:00
chenxing 570b593293 feat(msgcenter): 消息读取改走 SDK msg 读方法并带当前激活身份学校口径(resolveReadSchId→/oapi/identity/current,0/异常降级 null);todo 角标沿用主学校口径不变;直批标读联动带口径(消息按学校隔离 批次3) 2026-10-09 12:23:10 +08:00
37 changed files with 2583 additions and 715 deletions
+18 -9
View File
@@ -25,7 +25,10 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl(alpine) - name: 安装 git / curl(alpine)
run: apk add --no-cache git curl run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -55,18 +58,24 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存) - name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: | run: |
set -e set -e
docker rm -f mavenbuild 2>/dev/null || true # 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
docker run -d --name mavenbuild \ # 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \ --network infra-infra-net \
-v /opt/docker/projman-m2:/root/.m2 \ -v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600 -w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/ docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml" docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package' docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar' docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target" mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar" docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像 - name: 构建并推送镜像
run: | run: |
@@ -0,0 +1,81 @@
package com.yunhai.userhome.config;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.repository.WidgetCatalogRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* 小组件内置目录行启动 seeding(幂等)。
*
* <p>背景:种子 INSERT 原先只在 {@code resources/sql/widget_catalog.sql} 里,代码无 seeding,
* 新环境首次启动时候选池恒为空。此处补一条启动期兜底:库为空才写入 3 条内置目录行。</p>
*
* <p>三条种子与 {@code resources/sql/widget_catalog.sql} 用同一份常量(code / name / comp_type / scope_type / enabled),
* 仅补「目录行」,不补内容(内置组件内容在前端工程);{@code WidgetDataService} 白名单不动。</p>
*
* @author chenx
* @date 2026-10-11
*/
@Component
public class WidgetCatalogSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(WidgetCatalogSeeder.class);
/**
* {code, name, sort, linkAppCode, linkPath}:与 resources/sql/widget_catalog.sql 的 INSERT 一一对应。
* linkAppCode/linkPath 为外跳入口默认值(**只存子应用 code + 路由,不存绝对 URL**,域名由应用注册表解析);
* 留空 = 该组件不出外跳入口,管理员可在管理台改。
*/
private static final String[][] SEEDS = {
{"evaluate", "听评课", "1", null, null},
{"syllabus", "课程表", "2", null, null},
{"weekplan", "一周安排", "3", "work-week-plan", "/weekplan"},
};
private static final int COMP_TYPE_BUILTIN = 1;
private static final int SCOPE_ALL = 0;
private static final int ENABLED = 1;
@Autowired
private WidgetCatalogRepository widgetCatalogRepository;
@Override
public void run(String... args) {
try {
// 幂等:库非空即整体跳过,避免管理员下线过的内置组件在重启后被重新种回
if (widgetCatalogRepository.count() > 0) {
log.info("[widget] widget_catalog 非空,跳过内置目录 seeding");
return;
}
for (String[] seed : SEEDS) {
String code = seed[0];
if (widgetCatalogRepository.findByCode(code).isPresent()) {
continue;
}
WidgetCatalog catalog = new WidgetCatalog();
catalog.setCode(code);
catalog.setName(seed[1]);
catalog.setCompType(COMP_TYPE_BUILTIN);
catalog.setScopeType(SCOPE_ALL);
catalog.setSort(Integer.valueOf(seed[2]));
catalog.setLinkAppCode(seed[3]);
catalog.setLinkPath(seed[4]);
catalog.setEnabled(ENABLED);
catalog.setUpdateTime(new Date());
widgetCatalogRepository.save(catalog);
log.info("[widget] seeding 内置目录行:{}", code);
}
} catch (Exception e) {
// seeding 失败不阻断应用启动(候选池另有管理端入口补录)
log.error("[widget] 内置目录 seeding 失败,跳过", e);
}
}
}
@@ -1,243 +0,0 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.AppCategory;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.service.IAppCategoryService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.utils.FileUtil;
import com.yunhai.userhome.utils.OtherUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* 应用设置控制器(自 systemsetting AppSettingController + SysController 迁移,1:1 对齐)。
*
* <p>与原版差异点:写操作(应用 CRUD / UpdateResDate / 模板上传保存)校验调用者
* 为超管(type==1)或校管理员(roleId∈{2,5,7})——原版任何登录用户可写,因新入口仅管理员可见,收紧。
* 查询(分页列表 / FindAppCategory / GetTempList / downloadTemp)登录即可。
*/
@RestController
@RequestMapping(Config.API_PATH)
public class AppSettingController {
@Value("${FILE_PATH:./upload/}")
private String FILE_PATH;
@Value("${FILE_WEB_PATH:}")
private String FILE_WEB_PATH;
@Autowired
private IAppCategoryService appCategoryService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
// ==================== 应用类别 CRUD ====================
/** 新增/编辑应用(JSON body,1:1 对齐原 SaveAppCategory) */
@PostMapping(value = "SaveAppCategory", produces = "application/json")
public void saveAppCategory(BoaUser authUserWithDept, @RequestBody AppCategory appCategory) {
checkAdmin(authUserWithDept);
if (appCategory.getCDate() == null) appCategory.setCDate(new Date());
if (appCategory.getResDate() == null) appCategory.setResDate(new Date());
appCategory.setCId(authUserWithDept.getId());
appCategoryService.save(appCategory);
}
@PostMapping(value = "DelAppCategory", produces = "application/json")
public void delAppCategory(BoaUser authUserWithDept, Integer id) {
checkAdmin(authUserWithDept);
appCategoryService.deleteById(id);
}
@GetMapping(value = "GetAppCategoryList")
public Map getAppCategoryList(BoaUser authUserWithDept, Integer page, Integer count,
@RequestParam(value = "roleType") Integer roleType,
@RequestParam(value = "name", required = false) String name) {
Page<AppCategory> data = appCategoryService.findPageList(page, count, roleType, name);
Map map = new HashMap();
map.put("total", data.getTotalElements());
map.put("list", data.getContent());
return map;
}
@GetMapping(value = "FindAppCategory")
public Optional<AppCategory> findAppCategory(BoaUser authUserWithDept, Integer id) {
return appCategoryService.findById(id);
}
@PostMapping(value = "UpdateResDate", produces = "application/json")
public void updateResDate(BoaUser authUserWithDept, Integer id) {
checkAdmin(authUserWithDept);
appCategoryService.updateResDate(id);
}
// ==================== 模板管理(对齐原 SysController) ====================
/** 上传模板压缩包(zip/rar),解压覆盖到 tempUpload/&lt;appModId&gt;/,删除压缩包 */
@PostMapping(value = "tempUpload", produces = "application/json")
public void tempUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file, Integer appModId) {
checkAdmin(authUserWithDept);
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "请选择上传文件");
}
String originalName = file.getOriginalFilename();
String ext = originalName == null ? "" : originalName.substring(originalName.lastIndexOf(".")).toLowerCase(Locale.ROOT);
if (!".zip".equals(ext) && !".rar".equals(ext)) {
throw new BizException(ErrorCode.CUSTOM, "仅支持 zip/rar 格式");
}
// 不走图片 5MB 校验(模板包允许更大,依赖 multipart 配置),存到 tempUpload 子目录
String uploadPath = FILE_PATH + "/tempUpload/";
File fl = new File(uploadPath);
if (!fl.exists()) fl.mkdirs();
String fileName = FileUtil.uploadFile(file, uploadPath);
String filePath = "/upload/tempUpload/" + fileName;
File unzipFiles = new File(FILE_PATH + "/tempUpload/" + appModId);
if (!unzipFiles.exists()) unzipFiles.mkdirs();
else FileUtil.delAllFile(unzipFiles.getPath());
File zipFile = new File(filePath.replace("/upload", FILE_PATH));
try {
FileUtil.zipDecompress(zipFile, unzipFiles);
} catch (Exception e) {
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
} finally {
zipFile.delete();
}
}
/** 模板文件列表(name + 访问 path) */
@GetMapping(value = "GetTempList")
public List<Map> getTempList(BoaUser authUserWithDept, Integer appModId) {
File sourceFile = new File(FILE_PATH + "/tempUpload/" + appModId);
List<Map> result = new ArrayList<>();
if (sourceFile.exists() && sourceFile.isDirectory() && sourceFile.listFiles() != null) {
Arrays.stream(sourceFile.listFiles()).forEach(file -> {
Map map = new HashMap();
map.put("name", file.getName());
map.put("path", FILE_WEB_PATH + "/tempUpload/" + appModId + "/" + file.getName());
result.add(map);
});
}
return result;
}
/** 保存模板文件内容(新增 fileName 路径穿越校验,原版没有;content 走 JSON body 避免 URL 长度限制) */
@PostMapping(value = "SaveTempFile", produces = "application/json")
public void saveTempFile(BoaUser authUserWithDept, @RequestBody Map<String, Object> body) {
checkAdmin(authUserWithDept);
Object appModIdObj = body.get("appModId");
String fileName = body.get("fileName") == null ? null : body.get("fileName").toString();
String content = body.get("content") == null ? "" : body.get("content").toString();
if (appModIdObj == null) {
throw new BizException(ErrorCode.CUSTOM, "缺少appModId");
}
Integer appModId;
try {
appModId = Integer.parseInt(appModIdObj.toString());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.CUSTOM, "非法appModId");
}
if (fileName == null || fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
throw new BizException(ErrorCode.CUSTOM, "非法文件名");
}
File dir = new File(FILE_PATH + "/tempUpload/" + appModId);
if (!dir.exists()) dir.mkdirs();
File file = new File(FILE_PATH + "/tempUpload/" + appModId + "/" + fileName);
try {
if (!file.exists()) {
file.createNewFile();
}
FileOutputStream fos = new FileOutputStream(file);
fos.write(content.getBytes());
fos.flush();
fos.close();
} catch (IOException e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "保存失败");
}
}
/** 打包下载模板目录(zip 流式下载,临时 zip 关闭即删) */
@GetMapping(value = "downloadTemp", produces = "application/json;charset=UTF-8")
public ResponseEntity<InputStreamResource> downloadTemp(BoaUser authUserWithDept, Integer appModId, String name) {
File zipFilePath = new File(FILE_PATH + "/tempUpload/" + appModId);
if (!zipFilePath.exists() || !zipFilePath.isDirectory()) {
throw new BizException(ErrorCode.CUSTOM, "模板不存在");
}
String zipName = UUID.randomUUID().toString().substring(0, 8) + ".zip";
File zipFile = new File(FILE_PATH + "/tempUpload/" + zipName);
try {
FileUtil.zipCompress(zipFilePath, zipFile);
} catch (Exception e) {
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
}
String downloadName = (name == null || name.trim().isEmpty()) ? "template" : name;
try {
InputStream inputStream = new FileInputStream(zipFile) {
@Override
public void close() throws IOException {
super.close();
zipFile.delete();
}
};
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
headers.add("Content-Disposition",
"attachment; filename=" + new String((downloadName + ".zip").getBytes("gbk"), "iso8859-1"));
headers.add("Pragma", "no-cache");
headers.add("Expires", "0");
return ResponseEntity.ok().headers(headers)
.contentType(MediaType.parseMediaType("application/octet-stream"))
.body(new InputStreamResource(inputStream));
} catch (IOException e) {
zipFile.delete();
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "下载失败");
}
}
// ==================== 私有方法 ====================
/**
* 写操作权限校验:超管(type==1) 或 校管理员(roleId∈{2,5,7}),其余 403。
*/
private void checkAdmin(BoaUser authUserWithDept) {
if (authUserWithDept.getType() == 1) {
return;
}
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
if (!currentRoleOpt.isPresent() || !OtherUtil.isAdminById(currentRoleOpt.get().getRoleId())) {
throw new BizException(ErrorCode.FORBIDDEN);
}
}
}
@@ -29,7 +29,11 @@ public class MessageCenterController {
private final MsgCenterProxyService proxy; private final MsgCenterProxyService proxy;
private final MsgApprovalProxyService approvalProxy; private final MsgApprovalProxyService approvalProxy;
/** schId=0 时推断:主学校 → 主归属组(bureau)→ 0(对齐 MessageController 旧口径) */ /**
* 会话主学校(旧口径):<b>只</b>供待办角标(todo)与卡片直批(flow-center 办理人学校)使用——
* 这两条链路口径不变(方案 §8)。<b>消息读取一律不得用它</b>(R2:跨校切换会错),
* 必须用 {@link MsgCenterProxyService#resolveReadSchId}(当前激活身份学校)。
*/
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();
@@ -40,6 +44,11 @@ public class MessageCenterController {
return 0; return 0;
} }
/** 消息读取口径 schId:当前激活身份所属学校(0/平台/超管与身份链不可达 → null=看全部,D5) */
private Integer msgSchId(BoaUser authUserWithDept) {
return proxy.resolveReadSchId(authUserWithDept.getId());
}
/** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空)。 /** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空)。
* 可选服务端筛选:msgType 0/1/2、appId(注册表应用 id,代理翻译下行)、isRead 0/1、keyword 标题/内容模糊(不传与现状一致)。 */ * 可选服务端筛选:msgType 0/1/2、appId(注册表应用 id,代理翻译下行)、isRead 0/1、keyword 标题/内容模糊(不传与现状一致)。 */
@GetMapping("msgCenter/list") @GetMapping("msgCenter/list")
@@ -51,19 +60,20 @@ public class MessageCenterController {
@RequestParam(value = "appId", required = false) String appId, @RequestParam(value = "appId", required = false) String appId,
@RequestParam(value = "isRead", required = false) Integer isRead, @RequestParam(value = "isRead", required = false) Integer isRead,
@RequestParam(value = "keyword", required = false) String keyword) { @RequestParam(value = "keyword", required = false) String keyword) {
return proxy.list(authUserWithDept.getId(), page, count, onlyUnread, msgType, appId, isRead, keyword); return proxy.list(authUserWithDept.getId(), page, count, onlyUnread, msgType, appId, isRead, keyword,
msgSchId(authUserWithDept));
} }
/** 铃铛摘要:未读数 + 各应用待办角标 */ /** 铃铛摘要:未读数 + 各应用待办角标(未读/分类计数按当前身份学校口径;待办角标沿用主学校口径不变) */
@GetMapping("msgCenter/summary") @GetMapping("msgCenter/summary")
public Map<String, Object> summary(BoaUser authUserWithDept) { public Map<String, Object> summary(BoaUser authUserWithDept) {
return proxy.summary(authUserWithDept.getId(), resolveSchId(authUserWithDept)); return proxy.summary(authUserWithDept.getId(), msgSchId(authUserWithDept), resolveSchId(authUserWithDept));
} }
/** 按来源应用消息计数(消息中心「按应用」清单角标):[{appId, appName, total, unread}],总数含已读 */ /** 按来源应用消息计数(消息中心「按应用」清单角标):[{appId, appName, total, unread}],总数含已读 */
@GetMapping("msgCenter/countByApp") @GetMapping("msgCenter/countByApp")
public java.util.List<java.util.Map<String, Object>> countByApp(BoaUser authUserWithDept) { public java.util.List<java.util.Map<String, Object>> countByApp(BoaUser authUserWithDept) {
return proxy.countByApp(authUserWithDept.getId()); return proxy.countByApp(authUserWithDept.getId(), msgSchId(authUserWithDept));
} }
/** /**
@@ -77,16 +87,16 @@ public class MessageCenterController {
return out; return out;
} }
/** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪) */ /** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪;schId 只用于缓存失效键与 readState 口径) */
@PostMapping("msgCenter/read") @PostMapping("msgCenter/read")
public void read(BoaUser authUserWithDept, @RequestParam("msgId") Long msgId) { public void read(BoaUser authUserWithDept, @RequestParam("msgId") Long msgId) {
proxy.read(authUserWithDept.getId(), Collections.singletonList(msgId)); proxy.read(authUserWithDept.getId(), Collections.singletonList(msgId), msgSchId(authUserWithDept));
} }
/** 全部已读 */ /** 全部已读(按当前身份学校限定:只标本校+平台未读,他校未读不受影响,D6) */
@PostMapping("msgCenter/readAll") @PostMapping("msgCenter/readAll")
public void readAll(BoaUser authUserWithDept) { public void readAll(BoaUser authUserWithDept) {
proxy.readAll(authUserWithDept.getId()); proxy.readAll(authUserWithDept.getId(), msgSchId(authUserWithDept));
} }
/** /**
@@ -78,13 +78,13 @@ public class UserController {
* 当前用户可访问的应用全集(跨仓 UserBase 实时拉取,按应用类型分组)。 * 当前用户可访问的应用全集(跨仓 UserBase 实时拉取,按应用类型分组)。
* 前端据此:渲染分类 tab(含「全部」)+ 图标网格;常用区由 getUsedApp 独立提供。 * 前端据此:渲染分类 tab(含「全部」)+ 图标网格;常用区由 getUsedApp 独立提供。
* *
* @param schId 兼容旧前端入参(yh-platform getUserApp 当前按 userId 全局权限过滤,schId 暂不参与) * @param schId 学校上下文:透传 UserBase 按该校单点开通集合收窄(为空则由服务端按当前身份/主归属学校兜底)
* @param userId 指定用户(缺省用登录用户) * @param userId 指定用户(缺省用登录用户)
*/ */
@GetMapping("GetUserApp") @GetMapping("GetUserApp")
public JsonNode getUserApp(BoaUser authUserWithDept, Integer schId, public JsonNode getUserApp(BoaUser authUserWithDept, Integer schId,
@RequestParam(name = "userId", required = false) Integer userId) { @RequestParam(name = "userId", required = false) Integer userId) {
return withIconKeys(appRemoteService.getUserApps(authUserWithDept)); return withIconKeys(appRemoteService.getUserApps(authUserWithDept, schId));
} }
/** /**
@@ -119,10 +119,15 @@ public class UserController {
return root; return root;
} }
/** 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。 */ /**
* 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。<br>
* schId 透传(可选):与 GetUserApp 同口径按学校收窄全集,避免「列表已收窄、单个查询仍全集」的不一致;
* 为空则由 UserBase 按当前身份/主归属学校兜底。
*/
@GetMapping("GetAppInfoById") @GetMapping("GetAppInfoById")
public BoaApp getAppInfoById(BoaUser authUserWithDept, @RequestParam(name = "id", required = false) Integer id) { public BoaApp getAppInfoById(BoaUser authUserWithDept, Integer schId,
return appRemoteService.getAppById(authUserWithDept, id).orElse(null); @RequestParam(name = "id", required = false) Integer id) {
return appRemoteService.getAppById(authUserWithDept, id, schId).orElse(null);
} }
/** 当前用户角色身份列表(跨仓 UserBase,含 学校/角色/子节点)。 */ /** 当前用户角色身份列表(跨仓 UserBase,含 学校/角色/子节点)。 */
@@ -19,7 +19,9 @@ import com.yunhai.userhome.service.AppIconKeyService;
import com.yunhai.userhome.service.ISchSettingsService; import com.yunhai.userhome.service.ISchSettingsService;
import com.yunhai.userhome.service.IUserCurrentRoleService; import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.service.IUserSettingsService; import com.yunhai.userhome.service.IUserSettingsService;
import com.yunhai.userhome.service.IWidgetCatalogService;
import com.yunhai.userhome.service.UsedAppService; import com.yunhai.userhome.service.UsedAppService;
import com.yunhai.userhome.service.WidgetLinkResolver;
import com.yunhai.userhome.utils.EnumUtil; import com.yunhai.userhome.utils.EnumUtil;
import com.yunhai.userhome.utils.FileUtil; import com.yunhai.userhome.utils.FileUtil;
import com.yunhai.userhome.utils.OtherUtil; import com.yunhai.userhome.utils.OtherUtil;
@@ -34,11 +36,6 @@ import java.util.stream.Collectors;
/** /**
* 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。 * 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。
*
* <p>待确认接口(已写入待确认清单):
* <ul>
* <li>getUserSettings 中 bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认</li>
* </ul>
*/ */
@RestController @RestController
@RequestMapping(Config.API_PATH) @RequestMapping(Config.API_PATH)
@@ -62,12 +59,18 @@ public class UserSettingsController {
@Autowired @Autowired
private UsedAppService usedAppService; private UsedAppService usedAppService;
@Autowired
private WidgetLinkResolver widgetLinkResolver;
@Autowired @Autowired
private OpenApiUserClient openApiUserClient; private OpenApiUserClient openApiUserClient;
@Autowired @Autowired
private AppIconKeyService appIconKeyService; private AppIconKeyService appIconKeyService;
@Autowired
private IWidgetCatalogService widgetCatalogService;
// ==================== 壁纸 ==================== // ==================== 壁纸 ====================
/** /**
@@ -286,21 +289,37 @@ public class UserSettingsController {
public UserSettings getUserSettings(BoaUser authUserWithDept) { public UserSettings getUserSettings(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId()); Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null; Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
UserSettings model = new UserSettings(); UserSettings model = new UserSettings();
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId()); Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
// TODO 待确认:bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认 // 候选池真源 = 自建 widget_catalog(生效启用 + 可见范围命中本校 + 可见角色命中当前角色;name 已按显示名三级链合成)
// List<BoaApp> appPageList = bschBaseOpenApi.getPcGrzxPage(authUserWithDept.getId(), schId); List<WidgetCatalog> catalogList = widgetCatalogService.findEnabledForSchool(schId, roleId);
List<BoaApp> appPageList = Collections.emptyList();
if (optional.isPresent()) model = optional.get(); if (optional.isPresent()) model = optional.get();
if (model.getWallpaperUrl() == null || model.getWallpaperUrl().isEmpty()) { // 壁纸池一次取全:悬空自愈判定与兜底选择共用
model.setWallpaperUrl(resolveFallbackWallpaper(authUserWithDept, schId)); List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId());
// 悬空引用自愈:自选壁纸已被删除(删除清理修复前的存量脏数据)时视为未自选,避免返回死 URL 白板
String chosenUrl = normalizeWallpaperUrl(model.getWallpaperUrl());
if (model.getWallpaperUrl() != null && !model.getWallpaperUrl().isEmpty()
&& wallList.stream().noneMatch(w -> chosenUrl != null && chosenUrl.equals(w.getWallpaperUrl()))) {
model.setWallpaperUrl(null);
} }
List<Map> mapList = appPageList.stream().map(boaApp -> { if (model.getWallpaperUrl() == null || model.getWallpaperUrl().isEmpty()) {
model.setWallpaperUrl(resolveFallbackWallpaper(authUserWithDept, schId, wallList));
}
// id/appId 同值:对齐既有 widgetAppIds 的存取与面板排序链路;不再有 url
List<Map> mapList = catalogList.stream().map(catalog -> {
Map map = new HashMap<>(); Map map = new HashMap<>();
map.put("id", boaApp.getId()); map.put("id", catalog.getId());
map.put("appId", boaApp.getId()); map.put("appId", catalog.getId());
map.put("name", boaApp.getName()); map.put("name", catalog.getName());
map.put("url", boaApp.getWebAddr()); map.put("code", catalog.getCode());
map.put("compType", catalog.getCompType());
// 外跳入口:库里只存「子应用 code + 路由」,绝对地址按应用注册表 pc_url 解析后下发;
// 解析不出(未配置 / 查无 / 注册表不可达)则不下发,前端不渲染入口
String overviewUrl = widgetLinkResolver.resolve(catalog.getLinkAppCode(), catalog.getLinkPath());
if (overviewUrl != null) {
map.put("overviewUrl", overviewUrl);
}
return map; return map;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
@@ -310,31 +329,33 @@ public class UserSettingsController {
/** /**
* 兜底壁纸(用户未自选时,壁纸三级默认轮 2026-10-09):本校默认(sch_settings.wallpaper_url,管理员选定) * 兜底壁纸(用户未自选时,壁纸三级默认轮 2026-10-09):本校默认(sch_settings.wallpaper_url,管理员选定)
* → 平台默认(schId=0 行)→ 本地学校壁纸池最新一张 → 平台池最新一张(历史兜底,两级皆未配置时保持原体验)。 * → 平台默认(schId=0 行)→ 本地学校壁纸池第一张 → 平台池第一张(历史兜底,两级皆未配置时保持原体验)。
* <p>学校壁纸完全本地管理(wallpaper 表 type=1),不再依赖跨仓 schConfig。</p> * <p>学校壁纸完全本地管理(wallpaper 表 type=1),不再依赖跨仓 schConfig。
* 管理员选定默认返回前校验其仍在壁纸池中——被删池视为未配置继续下落,避免兜底本身返回死 URL(2026-10-09 白板修复)。</p>
*/ */
private String resolveFallbackWallpaper(BoaUser authUserWithDept, Integer schId) { private String resolveFallbackWallpaper(BoaUser authUserWithDept, Integer schId, List<Wallpaper> wallList) {
Set<String> poolUrls = new HashSet<>();
wallList.forEach(w -> poolUrls.add(w.getWallpaperUrl()));
// 1/2. 管理员选定的默认壁纸(本校 → 平台 级联,读侧 LayerValue) // 1/2. 管理员选定的默认壁纸(本校 → 平台 级联,读侧 LayerValue)
ISchSettingsService.LayerValue wallHit = schSettingsService.readWallpaperWithFallback(schId); ISchSettingsService.LayerValue wallHit = schSettingsService.readWallpaperWithFallback(schId);
if (wallHit.getValue() != null) { if (wallHit.getValue() != null && poolUrls.contains(normalizeWallpaperUrl(wallHit.getValue()))) {
return wallHit.getValue(); return wallHit.getValue();
} }
// 3/4. 历史兜底:壁纸池最新(校 type=1 → 平台 type=0) // 3/4. 历史兜底:壁纸池第一张(校 type=1 → 平台 type=0,最早上传者优先)
List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId());
if (schId != null) { if (schId != null) {
// 3. 本地学校壁纸第一张(type=1,取最新) // 3. 本地学校壁纸第一张(type=1)
Optional<Wallpaper> schWall = wallList.stream() Optional<Wallpaper> schWall = wallList.stream()
.filter(w -> w.getType() != null && w.getType() == 1 .filter(w -> w.getType() != null && w.getType() == 1
&& w.getSchId() != null && w.getSchId().equals(schId)) && w.getSchId() != null && w.getSchId().equals(schId))
.max(Comparator.comparing(Wallpaper::getId)); .min(Comparator.comparing(Wallpaper::getId));
if (schWall.isPresent()) { if (schWall.isPresent()) {
return FILE_WEB_PATH + schWall.get().getWallpaperUrl().replace("/upload", ""); return FILE_WEB_PATH + schWall.get().getWallpaperUrl().replace("/upload", "");
} }
} }
// 4. 超管全局壁纸第一张(type=0,取最新) // 4. 超管全局壁纸第一张(type=0)
Optional<Wallpaper> sysWall = wallList.stream() Optional<Wallpaper> sysWall = wallList.stream()
.filter(w -> w.getType() != null && w.getType() == 0) .filter(w -> w.getType() != null && w.getType() == 0)
.max(Comparator.comparing(Wallpaper::getId)); .min(Comparator.comparing(Wallpaper::getId));
if (sysWall.isPresent()) { if (sysWall.isPresent()) {
return FILE_WEB_PATH + sysWall.get().getWallpaperUrl().replace("/upload", ""); return FILE_WEB_PATH + sysWall.get().getWallpaperUrl().replace("/upload", "");
} }
@@ -0,0 +1,179 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.entity.WidgetSchoolSetting;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.service.IWidgetCatalogService;
import com.yunhai.userhome.service.WidgetDataService;
import com.yunhai.userhome.utils.OtherUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.DigestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 小组件控制器:候选池按需取内容 + 统一取数(登录侧),以及管理端候选池读写(管理员)。
*
* <p>写权限按「目录权 / 校级权」拆开:全局目录(新增 / 改内容 / 删除 / 三态可见范围 / 角色命名)仅超管;
* 校级覆盖(启用 / 可见角色 / 排序 / 别名)超管或校管,且校管只能写本校。</p>
*
* <p>真实路径按 {@code /{platform}} 展开为 {@code /api/getWidgetData} 等(见 Config.API_PATH)。</p>
*/
@RestController
@RequestMapping(Config.API_PATH)
public class WidgetController {
private static final int COMP_TYPE_FRAGMENT = 2;
@Autowired
private IWidgetCatalogService widgetCatalogService;
@Autowired
private WidgetDataService widgetDataService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
/** 统一取数入口:params 为 urlencoded 查询串;未配置的数据键回空数据,不抛异常 */
@GetMapping(value = "getWidgetData", produces = "application/json")
public Object getWidgetData(BoaUser authUserWithDept,
@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "params", required = false) String params) {
Object data = widgetDataService.getData(code, params, resolveCaller(authUserWithDept));
if (data != null) {
return data;
}
Map<String, Object> empty = new HashMap<>();
empty.put("msg", "该小工具暂无可用数据源:" + code);
return empty;
}
/** 仅 comp_type=2:按需取片段内容(不塞进 getUserSettings,避免每次进桌面下发全部 HTML) */
@GetMapping(value = "getWidgetContent", produces = "application/json")
public Map<String, Object> getWidgetContent(BoaUser authUserWithDept, HttpServletResponse response,
@RequestParam(value = "code", required = false) String code) {
Map<String, Object> result = new HashMap<>();
result.put("content", "");
Optional<WidgetCatalog> optional = widgetCatalogService.findByCode(code);
if (!optional.isPresent()) {
result.put("msg", "小工具不存在:" + code);
return result;
}
WidgetCatalog catalog = optional.get();
if (!widgetCatalogService.visibleToSchool(catalog, currentSchId(authUserWithDept))) {
result.put("msg", "该小工具未对本校下发");
return result;
}
if (catalog.getCompType() == null || catalog.getCompType() != COMP_TYPE_FRAGMENT) {
result.put("msg", "内置组件的内容在 front 工程里,无按需取内容这条路:" + code);
return result;
}
String content = catalog.getContent() == null ? "" : catalog.getContent();
result.put("code", catalog.getCode());
result.put("name", catalog.getName());
result.put("compType", catalog.getCompType());
result.put("dataCode", catalog.getDataCode());
result.put("content", content);
// 内容 hash 派生 ETag:内容或更新时间变更即换标签,便于前端条件请求复用
response.setHeader("ETag", "\"" + contentHash(content, catalog.getUpdateTime()) + "\"");
return result;
}
/** 管理端列表(超管 / 校管):按视角返回——超管=全局原值,校管=本校生效值 + 原名 + 覆盖标记 */
@GetMapping(value = "GetWidgetCatalogList", produces = "application/json")
public List<WidgetCatalog> getWidgetCatalogList(BoaUser authUserWithDept) {
checkSchAdmin(authUserWithDept);
return widgetCatalogService.findEffectiveForAdmin(currentSchId(authUserWithDept), isSuperAdmin(authUserWithDept));
}
/** 管理端保存全局目录:content 走 JSON body(同 SaveTempFile 口径,规避 URL 长度限制) */
@PostMapping(value = "SaveWidgetCatalog", produces = "application/json")
public void saveWidgetCatalog(BoaUser authUserWithDept, @RequestBody WidgetCatalog widgetCatalog) {
checkSuperAdmin(authUserWithDept);
widgetCatalogService.save(widgetCatalog);
}
/** 删除全局目录行,连带删可见范围点名表与校级覆盖行 */
@PostMapping(value = "DeleteWidgetCatalog", produces = "application/json")
public void deleteWidgetCatalog(BoaUser authUserWithDept, @RequestParam(value = "id") Integer id) {
checkSuperAdmin(authUserWithDept);
widgetCatalogService.deleteById(id);
}
/** 读取本校覆盖行(编辑回显):只认当前登录校 */
@GetMapping(value = "GetWidgetSchoolSetting", produces = "application/json")
public WidgetSchoolSetting getWidgetSchoolSetting(BoaUser authUserWithDept,
@RequestParam(value = "widgetId") Integer widgetId) {
checkSchAdmin(authUserWithDept);
return widgetCatalogService.findSchoolSetting(widgetId, currentSchId(authUserWithDept)).orElse(null);
}
/** 保存本校覆盖:schId 强制取当前登录校,不接受前端传入 */
@PostMapping(value = "SaveWidgetSchoolSetting", produces = "application/json")
public void saveWidgetSchoolSetting(BoaUser authUserWithDept, @RequestBody WidgetSchoolSetting setting) {
checkSchAdmin(authUserWithDept);
widgetCatalogService.saveSchoolSetting(setting, currentSchId(authUserWithDept));
}
private Integer currentSchId(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
return currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
}
/**
* 取数身份上下文(userid / roleId / schId 全部会话推导,前端不可传):
* userid = 平台用户ID(BoaUser.id);roleId/schId = 当前激活身份(UserBase /oapi/identity/current)。
*/
private WidgetDataService.Caller resolveCaller(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
return new WidgetDataService.Caller(authUserWithDept.getId(), roleId, schId);
}
private boolean isSuperAdmin(BoaUser authUserWithDept) {
return authUserWithDept.getType() == 1;
}
/**
* 目录权校验:仅超管(type==1)可写全局目录 / 删除 / 三态可见范围 / 角色命名 / 登记内置。
* 注:平台 yh-api-std 的异常处理器无 @ResponseStatus,拒绝表现为 HTTP 200 + 信封 code=3(非 500)。
*/
private void checkSuperAdmin(BoaUser authUserWithDept) {
if (authUserWithDept.getType() != 1) {
throw new BizException(ErrorCode.FORBIDDEN);
}
}
/** 校级权校验:超管或校管理员(roleId∈{2,5,7}),其余拒绝 */
private void checkSchAdmin(BoaUser authUserWithDept) {
if (authUserWithDept.getType() == 1) {
return;
}
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
if (!currentRoleOpt.isPresent() || !OtherUtil.isAdminById(currentRoleOpt.get().getRoleId())) {
throw new BizException(ErrorCode.FORBIDDEN);
}
}
private String contentHash(String content, java.util.Date updateTime) {
long stamp = updateTime == null ? 0L : updateTime.getTime();
return DigestUtils.md5DigestAsHex((stamp + "\n" + content).getBytes(StandardCharsets.UTF_8));
}
}
@@ -16,7 +16,8 @@ import java.util.Map;
* 强制(/oapi/* 无签名 401);appKey 准入归网关台账/限流管,本应用不维护 per-app 白名单。 * 强制(/oapi/* 无签名 401);appKey 准入归网关台账/限流管,本应用不维护 per-app 白名单。
* *
* <p>出参 = 当前用户【生效值】{@code {theme, avatar, logoUrl}}(个人 → 学校 → 平台级联合成, * <p>出参 = 当前用户【生效值】{@code {theme, avatar, logoUrl}}(个人 → 学校 → 平台级联合成,
* 层级知识留在 UserHome 内部不外露;null=未配置,消费端 init 侧清对应本地键回落默认观感)。 * 层级知识留在 UserHome 内部不外露;null=未配置,消费端 init 侧清对应本地键回落默认观感;
* logoText 已于 2026-10-09 定稿摘除——子应用不再消费 LOGO 文字,门户走 /api/schLogoText 自有链)。
* 返回裸 payload(平台 oapi 无信封包装),消费端整体透传给浏览器。</p> * 返回裸 payload(平台 oapi 无信封包装),消费端整体透传给浏览器。</p>
*/ */
@RestController @RestController
@@ -1,60 +0,0 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import java.util.Date;
/**
* 桌面应用类别(自 systemsetting.app_category 1:1 迁移)。
*
* <p>roleType:1 校管理员 | 2 教师 | 3 学生(见 {@code enums.RoleType});
* resDate 为资源更新时间戳,前端上传模板后经 UpdateResDate 刷新缓存。</p>
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "app_category")
public class AppCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@NotNull(groups = Update.class)
private Integer id;
private String name;
@Column(name = "api_url", length = 500)
private String apiUrl;
@Column(name = "data_key")
private String dataKey;
/** 接口参数 JSON 数组字符串([{k,v,type}]) */
@Column(name = "params_key", columnDefinition = "TEXT")
private String paramsKey;
@Column(name = "role_type")
private Integer roleType;
@Column(name = "c_id")
private Integer cId;
@Column(name = "c_date")
private Date cDate = new Date();
/** 资源更新时间戳 */
@Column(name = "res_date")
private Date resDate = new Date();
public @interface Update {}
}
@@ -0,0 +1,103 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.Transient;
import javax.persistence.UniqueConstraint;
import java.util.Date;
import java.util.List;
/**
* 小组件候选池(widget_catalog 表):内容自持,全表无 html 地址概念。
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "widget_catalog", uniqueConstraints = @UniqueConstraint(columnNames = "code"))
public class WidgetCatalog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** 内置组件 = 前端 widgetRegistry 的键;自定义片段 = 自定义标识 */
@Column(name = "code", nullable = false, length = 32)
private String code;
@Column(name = "name", nullable = false, length = 64)
private String name;
/** 1 = front 工程内置组件(内容在代码库,本行不存 content);2 = 自定义 HTML 片段(内容在 content) */
@Column(name = "comp_type", nullable = false)
private Integer compType = 1;
/** 仅 comp_type=2:片段 HTML 源码 */
@Column(name = "content", columnDefinition = "mediumtext")
private String content;
/** 片段取数用的数据键,须在 WidgetDataService 白名单内 */
@Column(name = "data_code", length = 32)
private String dataCode;
/** 下发范围:旧语义(NULL = 全校可见)已被 scopeType 取代,字段保留仅为兼容存量数据,读侧不再使用 */
@Column(name = "sch_id")
private Integer schId;
/** 下发范围三态:0 = 全部学校 1 = 白名单(仅点名的学校可见) 2 = 黑名单(点名的学校不可见) */
@Column(name = "scope_type", nullable = false)
private Integer scopeType = 0;
/** 可见角色:逗号分隔 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生);NULL 或空 = 全角色 */
@Column(name = "role_types", length = 16)
private String roleTypes;
/** 角色专属命名(JSON 文本,如 {"2":"全校课表"},键 = RoleTypeEnum);读侧 Jackson 解析,失败按无角色命名降级 */
@Column(name = "role_alias", length = 255)
private String roleAlias;
/**
* 外跳目标子应用:应用注册表(UserBase /oapi/app/by-code/{code})的 app_code,如 work-week-plan。
* NULL = 该组件不显示外跳入口。地址由 linkPath + 注册表 pc_url 解析而成,**不落库绝对 URL**。
*/
@Column(name = "link_app_code", length = 32)
private String linkAppCode;
/** 外跳路由(hash 路由,如 /weekplan);拼接口径同消息中心 jumpUrl:归一化 pc_url + "#" + 本路径 */
@Column(name = "link_path", length = 128)
private String linkPath;
@Column(name = "sort", nullable = false)
private Integer sort = 0;
/** 1 = 进候选池,0 = 下线 */
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
@Column(name = "update_time")
private Date updateTime;
/** 管理端视图:scopeType=1/2 时点名的学校 id 列表(非持久化,随 SaveWidgetCatalog 一并提交) */
@Transient
private List<Integer> scopeSchIds;
/** 管理端视图(校管视角):本校别名(widget_school_setting.alias,非持久化) */
@Transient
private String alias;
/** 管理端视图(校管视角):超管下发的全局名称(非持久化,用于弹窗回显原名) */
@Transient
private String originalName;
/** 管理端视图(校管视角):本校是否已有覆盖行(非持久化) */
@Transient
private Boolean covered;
}
@@ -0,0 +1,43 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 小组件可见范围点名表(widget_catalog_school):只存被点名的学校行。
*
* <p>配合 {@link WidgetCatalog#getScopeType()}:1(白名单)= 表中有记录的学校可见;
* 2(黑名单)= 表中有记录的学校不可见。</p>
*
* @author chenx
* @date 2026-10-11
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "widget_catalog_school",
uniqueConstraints = @UniqueConstraint(name = "uk_widget_school", columnNames = {"widget_id", "sch_id"}))
public class WidgetCatalogSchool {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** widget_catalog.id */
@Column(name = "widget_id", nullable = false)
private Integer widgetId;
/** 被点名的学校 id */
@Column(name = "sch_id", nullable = false)
private Integer schId;
}
@@ -0,0 +1,69 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.util.Date;
/**
* 小组件校级覆盖层(widget_school_setting):一校一行。
*
* <p>字段为 NULL 表示沿用全局值,生效值 = COALESCE(本行, widget_catalog 全局值);
* 「一键清除覆盖」= 删除本行。</p>
*
* @author chenx
* @date 2026-10-11
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "widget_school_setting",
uniqueConstraints = @UniqueConstraint(name = "uk_widget_sch", columnNames = {"widget_id", "sch_id"}))
public class WidgetSchoolSetting {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "widget_id", nullable = false)
private Integer widgetId;
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** NULL = 沿用全局 enabled */
@Column(name = "enabled")
private Integer enabled;
/** NULL = 沿用全局 role_types */
@Column(name = "role_types", length = 16)
private String roleTypes;
/** NULL = 沿用全局 sort */
@Column(name = "sort_no")
private Integer sortNo;
/** 本校别名,NULL = 沿用全局 name */
@Column(name = "alias", length = 64)
private String alias;
/** 本校覆盖的外跳子应用 code,NULL = 沿用全局 link_app_code */
@Column(name = "link_app_code", length = 32)
private String linkAppCode;
/** 本校覆盖的外跳路由,NULL = 沿用全局 link_path */
@Column(name = "link_path", length = 128)
private String linkPath;
@Column(name = "update_time")
private Date updateTime;
}
@@ -1,12 +0,0 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.AppCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
public interface AppCategoryRepository extends JpaRepository<AppCategory, Integer>, JpaSpecificationExecutor<AppCategory> {
List<AppCategory> findAllByRoleType(Integer roleType);
}
@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.Optional; import java.util.Optional;
public interface UserSettingsRepository extends JpaRepository<UserSettings, Integer>, JpaSpecificationExecutor<UserSettings> { public interface UserSettingsRepository extends JpaRepository<UserSettings, Integer>, JpaSpecificationExecutor<UserSettings> {
@@ -21,8 +22,10 @@ public interface UserSettingsRepository extends JpaRepository<UserSettings, Inte
/** /**
* 删除壁纸时将被删除壁纸引用的用户设置 wallpaper_url 置空,避免裂图(调用方需处于事务中)。 * 删除壁纸时将被删除壁纸引用的用户设置 wallpaper_url 置空,避免裂图(调用方需处于事务中)。
* user_settings 存展示形态(FILE_WEB_PATH 前缀 + 去 /upload),wall 表存原始形态,
* 两种形态都需传入匹配,漏形态会留下悬空引用(读侧拿到已删壁纸的死 URL 白板)。
*/ */
@Modifying @Modifying
@Query("update UserSettings u set u.wallpaperUrl = null where u.wallpaperUrl = :url") @Query("update UserSettings u set u.wallpaperUrl = null where u.wallpaperUrl in :urls")
int clearWallpaperUrl(@Param("url") String url); int clearWallpaperUrl(@Param("urls") Collection<String> urls);
} }
@@ -0,0 +1,34 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.WidgetCatalog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface WidgetCatalogRepository extends JpaRepository<WidgetCatalog, Integer>,
JpaSpecificationExecutor<WidgetCatalog> {
Optional<WidgetCatalog> findByCode(String code);
List<WidgetCatalog> findByEnabledOrderBySortAscIdAsc(Integer enabled);
List<WidgetCatalog> findAllByOrderBySortAscIdAsc();
/**
* 某校「可见范围命中」的目录行(不含启用状态、角色过滤与校级覆盖,由服务层合成)。
* 一处 SQL 决定 scope_type 语义:0 全放;1 仅点名表命中;2 仅点名表未命中。
*/
@Query("select w from WidgetCatalog w where coalesce(w.scopeType, 0) = 0 "
+ "or (coalesce(w.scopeType, 0) = 1 and exists "
+ "(select 1 from WidgetCatalogSchool s where s.widgetId = w.id and s.schId = :schId)) "
+ "or (coalesce(w.scopeType, 0) = 2 and not exists "
+ "(select 1 from WidgetCatalogSchool s where s.widgetId = w.id and s.schId = :schId)) "
+ "order by w.sort asc, w.id asc")
List<WidgetCatalog> findVisibleForSchool(@Param("schId") Integer schId);
}
@@ -0,0 +1,27 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.WidgetCatalogSchool;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
/**
* 小组件可见范围点名表访问。
*
* @author chenx
* @date 2026-10-11
*/
@Repository
public interface WidgetCatalogSchoolRepository extends JpaRepository<WidgetCatalogSchool, Integer> {
/** 命中判定:某组件是否点名了某校(scope_type 白/黑名单共用) */
boolean existsByWidgetIdAndSchId(Integer widgetId, Integer schId);
List<WidgetCatalogSchool> findByWidgetId(Integer widgetId);
List<WidgetCatalogSchool> findByWidgetIdIn(Collection<Integer> widgetIds);
void deleteByWidgetId(Integer widgetId);
}
@@ -0,0 +1,26 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.WidgetSchoolSetting;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 小组件校级覆盖层访问。
*
* @author chenx
* @date 2026-10-11
*/
@Repository
public interface WidgetSchoolSettingRepository extends JpaRepository<WidgetSchoolSetting, Integer> {
Optional<WidgetSchoolSetting> findByWidgetIdAndSchId(Integer widgetId, Integer schId);
List<WidgetSchoolSetting> findBySchId(Integer schId);
List<WidgetSchoolSetting> findByWidgetId(Integer widgetId);
void deleteByWidgetId(Integer widgetId);
}
@@ -16,9 +16,31 @@ import java.util.Optional;
*/ */
public interface AppRemoteService { public interface AppRemoteService {
/** 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。 */ /**
* 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。
* 不带学校上下文:由 UserBase 侧按「当前激活身份学校 → 主归属学校」兜底推导生效学校。
*/
List<BoaAppType> getUserApps(BoaUser user); List<BoaAppType> getUserApps(BoaUser user);
/**
* 按学校上下文取用户可访问应用全集(2026-10-12 学校单点开通硬闸门贯通)。<br>
* {@code schoolId} 非空 → UserBase 按该校自身单点开通集合收窄;为空 → 服务端按身份/主归属兜底。
*
* @param user 登录用户
* @param schoolId 学校上下文(可为 null)
*/
List<BoaAppType> getUserApps(BoaUser user, Integer schoolId);
/** 按应用ID取单个应用(在已拉取的全集中查找,避免额外跨仓请求)。 */ /** 按应用ID取单个应用(在已拉取的全集中查找,避免额外跨仓请求)。 */
Optional<BoaApp> getAppById(BoaUser user, Integer appId); Optional<BoaApp> getAppById(BoaUser user, Integer appId);
/**
* 按应用ID取单个应用,并按学校上下文收窄全集(与 {@link #getUserApps(BoaUser, Integer)} 同口径,
* 避免「列表已按校收窄、单个查询仍全集」的不一致)。
*
* @param user 登录用户
* @param appId 应用ID
* @param schoolId 学校上下文(可为 null)
*/
Optional<BoaApp> getAppById(BoaUser user, Integer appId, Integer schoolId);
} }
@@ -1,22 +0,0 @@
package com.yunhai.userhome.service;
import com.yunhai.userhome.entity.AppCategory;
import org.springframework.data.domain.Page;
import java.util.List;
import java.util.Optional;
public interface IAppCategoryService {
void save(AppCategory appCategory);
void deleteById(Integer id);
Optional<AppCategory> findById(Integer id);
List<AppCategory> findByRoleType(Integer roleType);
Page<AppCategory> findPageList(Integer page, Integer count, Integer roleType, String name);
void updateResDate(Integer id);
}
@@ -0,0 +1,83 @@
package com.yunhai.userhome.service;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.entity.WidgetSchoolSetting;
import java.util.List;
import java.util.Optional;
/**
* 小组件候选池服务。
*
* @author chenx
* @date 2026-10-11
*/
public interface IWidgetCatalogService {
/**
* 候选池(登录侧):可见范围命中 + 生效启用 + 角色命中,按生效 sort 升序。
*
* @param schId 当前登录校(用于 scope_type 点名判定与校级覆盖合成)
* @param roleId 当前登录角色 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生),用于 role_types 过滤
* @return 生效值视图(name 已按「校级别名 &gt; 角色命名 &gt; 名称」合成)
*/
List<WidgetCatalog> findEnabledForSchool(Integer schId, Integer roleId);
Optional<WidgetCatalog> findByCode(String code);
/**
* 可见范围判定:scope_type = 0 全放;1 仅点名表命中可见;2 仅点名表未命中可见。
*
* @param catalog 目录行
* @param schId 当前登录校
* @return 命中返回 true
*/
boolean visibleToSchool(WidgetCatalog catalog, Integer schId);
/** 管理端列表(全局原值,含 scopeSchIds 回显),鉴权在 Controller */
List<WidgetCatalog> findAllForAdmin();
/**
* 管理端按视角取列表:超管 = 全局原值;校管 = 本校生效值 + 别名 + 原名 + 覆盖标记。
*
* @param viewerSchId 访问者当前登录校
* @param superAdmin 访问者是否超管(BoaUser.type == 1)
* @return 视角化的目录列表
*/
List<WidgetCatalog> findEffectiveForAdmin(Integer viewerSchId, boolean superAdmin);
/**
* 保存目录行(仅超管调用),并整体重写 scope_type 的点名表。
*
* @param model 目录行(scopeSchIds 为点名的学校列表)
*/
void save(WidgetCatalog model);
/**
* 删除目录行,连带删点名表与校级覆盖行。
*
* @param id 目录 id
*/
void deleteById(Integer id);
Optional<WidgetSchoolSetting> findSchoolSetting(Integer widgetId, Integer schId);
/**
* 保存校级覆盖(仅本校)。schId 一律取登录校,忽略入参里的 schId,防越权。
* 四个可覆盖字段全空表示「清除覆盖」,直接删行。
*
* @param model 覆盖行(widgetId 必填)
* @param currentSchId 当前登录校
*/
void saveSchoolSetting(WidgetSchoolSetting model, Integer currentSchId);
/**
* 显示名三级链:校级别名 &gt; 角色专属命名 &gt; 组件名称。
*
* @param catalog 目录行
* @param schId 当前登录校
* @param roleId 当前登录角色
* @return 最终显示名
*/
String displayName(WidgetCatalog catalog, Integer schId, Integer roleId);
}
@@ -107,7 +107,8 @@ public class MsgApprovalProxyService {
return fail(out, "流程中心暂不可用,请稍后重试"); return fail(out, "流程中心暂不可用,请稍后重试");
} }
msgProxy.read(userId, java.util.Collections.singletonList(msgId)); // 直批后标读:schId 用消息读取口径(当前激活身份学校),保证 UserMsg 侧失效的是本校缓存键
msgProxy.read(userId, java.util.Collections.singletonList(msgId), msgProxy.resolveReadSchId(userId));
out.put("ok", true); out.put("ok", true);
out.put("message", "办理成功"); out.put("message", "办理成功");
return out; return out;
@@ -3,7 +3,15 @@ package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.core.TargetSpec; import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.msg.OpenApiMsgClient;
import com.yunhai.platform.openapi.msg.impl.DefaultOpenApiMsgClient;
import com.yunhai.platform.openapi.msg.model.AppMsgCount;
import com.yunhai.platform.openapi.msg.model.MsgListItem;
import com.yunhai.platform.openapi.msg.model.MsgListPage;
import com.yunhai.platform.openapi.msg.model.MsgListQuery;
import com.yunhai.platform.openapi.msg.model.UserMsgSummary;
import com.yunhai.userhome.config.MsgCenterProperties; import com.yunhai.userhome.config.MsgCenterProperties;
import com.yunhai.userhome.entity.UserCurrentRole;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -21,8 +29,11 @@ import java.util.Set;
* (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path), * (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path),
* UserHome 前端不直连 UserMsg。 * UserHome 前端不直连 UserMsg。
* <ul> * <ul>
* <li>列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;</li> * <li>消息读取(列表/摘要/未读数/按应用计数/全部已读)走 SDK msg 域类型化读方法
* <li>已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);</li> * ({@link OpenApiMsgClient}),并带「当前激活身份」所属学校口径 schId
* ({@link #resolveReadSchId})——按学校隔离读取侧;</li>
* <li>待办角标 todo/list(仍按主学校 schId,链路口径不变)、单条已读 read、WS 票据
* 继续走 {@link YschOpenCall} 泛化直调;</li>
* <li>jumpUrl 改写:绝对 URL 直通;相对路径按应用注册表取 pc_url——jumpApp(值域=app_code) * <li>jumpUrl 改写:绝对 URL 直通;相对路径按应用注册表取 pc_url——jumpApp(值域=app_code)
* 主查,jumpApp 为空才按发送方 appKey 回落(严格二选一,pc_url 空就是空不猜测, * 主查,jumpApp 为空才按发送方 appKey 回落(严格二选一,pc_url 空就是空不猜测,
* 手工基址表 jump-bases 已退役),归一化后拼 "#" + jumpPath;</li> * 手工基址表 jump-bases 已退役),归一化后拼 "#" + jumpPath;</li>
@@ -33,7 +44,8 @@ import java.util.Set;
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401, * base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
* 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为 * 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为
* {@link TargetSpec}(host + /usermsg ctx),签名按目标 ctx 剥离,与 AppAuthFilter 对齐。 * {@link TargetSpec}(host + /usermsg ctx),签名按目标 ctx 剥离,与 AppAuthFilter 对齐。
* 目标地址来源:usermsg-base-url 显式配置优先,其次服务目录 usermsg(yh.open.services / 枚举兜底)。 * 目标地址来源:usermsg-base-url 显式配置优先(ad-hoc TargetSpec,泛化直调与类型化读方法同一寻址),
* 其次服务目录 usermsg(yh.open.services / 枚举兜底)。
* UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。 * UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。
*/ */
@Slf4j @Slf4j
@@ -55,6 +67,7 @@ public class MsgCenterProxyService {
private final YschOpenCall openCall; private final YschOpenCall openCall;
private final MsgCenterProperties properties; private final MsgCenterProperties properties;
private final IUserCurrentRoleService userCurrentRoleService;
/** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */ /** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */
private volatile long userbaseDownUntil; private volatile long userbaseDownUntil;
@@ -88,6 +101,102 @@ public class MsgCenterProxyService {
} }
} }
/**
* by-code 解析条目(UserBase /oapi/app/by-code/{appCode},值域=消息 jump_app)。
* appId=注册表主键 id(2026-10-13 160 实测与 /oapi/app/list 同体系:notice 主键=100001、
* 流程中心主键=100011/app_key=100009,无混体系风险)。
*/
private static final class ByCodeEntry {
final int appId;
final String appKey;
final String name;
final String pcUrl;
final String code;
ByCodeEntry(int appId, String appKey, String name, String pcUrl, String code) {
this.appId = appId;
this.appKey = appKey;
this.name = name;
this.pcUrl = pcUrl;
this.code = code;
}
}
/** 查无哨兵(ConcurrentHashMap 不允许 null 值):脏 code 不重复出站 */
private static final ByCodeEntry CODE_MISS = new ByCodeEntry(-1, null, null, null, null);
/** by-code 缓存本体与时间戳(TTL 对齐 {@link #APP_DICT_TTL_MS}) */
private final java.util.concurrent.ConcurrentHashMap<String, ByCodeEntry> byCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<String, Long> byCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
/**
* by-code 应用缓存(TTL 对齐 {@link #APP_DICT_TTL_MS}):一次调用同时取 name/appId/pcUrl,
* 消掉逐行 by-code 查址的 N+1(旧实现每行现查)。UserBase 不可达复用
* {@link #userbaseDownUntil} 短路窗口;返回 null=不可达(调用方降级),返回
* {@link #CODE_MISS}=查无(回落发送方口径)。
*/
private ByCodeEntry lookupByCode(String appCode) {
if (appCode == null || appCode.isEmpty()) {
return CODE_MISS;
}
Long at = byCodeAt.get(appCode);
boolean fresh = at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS;
if (fresh) {
ByCodeEntry cached = byCodeCache.get(appCode);
return cached == null ? CODE_MISS : cached;
}
if (userbaseDown()) {
ByCodeEntry stale = byCodeCache.get(appCode);
return stale == null ? CODE_MISS : stale;
}
try {
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-code/" + appCode, null, JsonNode.class);
userbaseDownUntil = 0L;
ByCodeEntry entry = app != null && app.hasNonNull("id")
? new ByCodeEntry(app.get("id").asInt(),
app.hasNonNull("appKey") ? String.valueOf(app.get("appKey").asInt()) : null,
text(app, "name"), text(app, "pcUrl"), text(app, "appCode"))
: CODE_MISS;
byCodeCache.put(appCode, entry);
byCodeAt.put(appCode, System.currentTimeMillis());
return entry;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase by-code 解析不可达(降级回落发送方口径)code={}:{}", appCode, e.toString());
return null;
}
}
/** appKey→app_code 反查缓存(list 的 appId 双值翻译用;空串值=查无,退化为单值过滤) */
private final java.util.concurrent.ConcurrentHashMap<String, String> appCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<String, Long> appCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
/** appKey→app_code 反查(/oapi/app/by-key,VO 含 appCode):首次查询后缓存,查无/不可达返回 null */
private String appCodeByKey(String appKey) {
if (appKey == null || appKey.isEmpty()) {
return null;
}
Long at = appCodeAt.get(appKey);
if (at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS) {
return appCodeCache.get(appKey);
}
if (appCodeCache.containsKey(appKey) && userbaseDown()) {
return appCodeCache.get(appKey);
}
try {
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-key/" + appKey, null, JsonNode.class);
userbaseDownUntil = 0L;
String code = app == null || app.isNull() ? null : text(app, "appCode");
appCodeCache.put(appKey, code == null ? "" : code);
appCodeAt.put(appKey, System.currentTimeMillis());
return StringUtils.hasText(code) ? code : null;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase by-key 反查 app_code 不可达(退化单值过滤)appKey={}:{}", appKey, e.toString());
return null;
}
}
/** /**
* 应用字典(UserBase /oapi/app/list 按用户授权全量,TTL {@link #APP_DICT_TTL_MS})。 * 应用字典(UserBase /oapi/app/list 按用户授权全量,TTL {@link #APP_DICT_TTL_MS})。
* 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 appSecret,仅取 id/appKey/name。 * 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 appSecret,仅取 id/appKey/name。
@@ -141,15 +250,46 @@ public class MsgCenterProxyService {
} }
} }
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) { public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties,
IUserCurrentRoleService userCurrentRoleService) {
this.openCall = openCall; this.openCall = openCall;
this.properties = properties; this.properties = properties;
this.userCurrentRoleService = userCurrentRoleService;
} }
private boolean enabled() { private boolean enabled() {
return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG); return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG);
} }
/**
* 消息读取口径 schId(按学校隔离读取侧,R2):取<b>当前激活身份</b>所属学校
* ({@code userCurrentRoleService.findById} → UserBase /oapi/identity/current),
* <b>严禁</b>用会话主学校(跨校切换会错)。解析为 0(平台/超管)或身份链不可达时返回 null
* =不下发过滤(看全部,D5,且与改造前行为一致)。
*/
public Integer resolveReadSchId(Integer userId) {
if (userId == null) {
return null;
}
try {
return userCurrentRoleService.findById(userId)
.map(UserCurrentRole::getSchId)
.filter(schId -> schId != null && schId > 0)
.orElse(null);
} catch (Exception e) {
log.warn("[msgcenter] 当前身份学校解析不可达(降级不过滤=全量口径)userId={}:{}", userId, e.toString());
return null;
}
}
/** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx);否则类型化读方法按服务目录 usermsg 寻址 */
private OpenApiMsgClient msgClient() {
String base = properties.getUsermsgBaseUrl();
return StringUtils.hasText(base)
? new DefaultOpenApiMsgClient(openCall, TargetSpec.fromUrl(base.trim()))
: new DefaultOpenApiMsgClient(openCall);
}
/** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx,per-target 签名剥离);否则按名走服务目录 */ /** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx,per-target 签名剥离);否则按名走服务目录 */
private JsonNode get(String path, Map<String, String> query) { private JsonNode get(String path, Map<String, String> query) {
String base = properties.getUsermsgBaseUrl(); String base = properties.getUsermsgBaseUrl();
@@ -171,9 +311,10 @@ public class MsgCenterProxyService {
* 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的绝对 jumpUrl 与 showJump 判权出参。 * 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的绝对 jumpUrl 与 showJump 判权出参。
* 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致): * 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致):
* msgType 0/1/2、appId(注册表应用 id,代理翻译为发送方 appKey 后下行)、isRead 0/1、keyword 标题/内容模糊。 * msgType 0/1/2、appId(注册表应用 id,代理翻译为发送方 appKey 后下行)、isRead 0/1、keyword 标题/内容模糊。
* schId=当前身份学校口径({@link #resolveReadSchId}),null=不过滤(平台/超管视角,看全部)。
*/ */
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread, public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread,
Integer msgType, String appId, Integer isRead, String keyword) { Integer msgType, String appId, Integer isRead, String keyword, Integer schId) {
Map<String, Object> empty = emptyPage(); Map<String, Object> empty = emptyPage();
if (!enabled()) { if (!enabled()) {
return empty; return empty;
@@ -181,34 +322,38 @@ public class MsgCenterProxyService {
PerRequestAuthz authz = new PerRequestAuthz(userId); PerRequestAuthz authz = new PerRequestAuthz(userId);
AppDict dict = appDict(userId); AppDict dict = appDict(userId);
try { try {
Map<String, String> query = new LinkedHashMap<>(); MsgListQuery query = new MsgListQuery();
query.put("userId", String.valueOf(userId)); query.setUserId(userId);
query.put("pageIndex", String.valueOf(pageIndex == null || pageIndex < 1 ? 1 : pageIndex)); query.setPageIndex(pageIndex == null || pageIndex < 1 ? 1 : pageIndex);
query.put("pageSize", String.valueOf(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100))); query.setPageSize(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100));
query.put("onlyUnread", String.valueOf(Boolean.TRUE.equals(onlyUnread))); query.setOnlyUnread(Boolean.TRUE.equals(onlyUnread));
if (msgType != null) { query.setMsgType(msgType);
query.put("msgType", String.valueOf(msgType));
}
if (StringUtils.hasText(appId)) { if (StringUtils.hasText(appId)) {
AppDictEntry entry = dict.byId.get(Integer.valueOf(appId.trim())); AppDictEntry entry = dict.byId.get(Integer.valueOf(appId.trim()));
if (entry == null) { if (entry == null) {
return empty; // 字典查无此应用:视作该应用无消息 return empty; // 字典查无此应用:视作该应用无消息
} }
query.put("appKey", entry.appKey); // 归属口径双值过滤:该应用自发行桶值=app_key,流程中心代发行桶值=app_code
List<String> appKeys = new ArrayList<>();
appKeys.add(entry.appKey);
String code = appCodeByKey(entry.appKey);
if (StringUtils.hasText(code) && !code.equals(entry.appKey)) {
appKeys.add(code);
} }
if (isRead != null) { query.setAppKeys(appKeys);
query.put("isRead", String.valueOf(isRead));
} }
putIfText(query, "keyword", keyword); query.setIsRead(isRead);
JsonNode data = get("/oapi/msg/list", query); query.setKeyword(StringUtils.hasText(keyword) ? keyword.trim() : null);
query.setSchId(schId);
MsgListPage page = msgClient().list(query);
List<Map<String, Object>> rows = new ArrayList<>(); List<Map<String, Object>> rows = new ArrayList<>();
if (data != null && data.path("content").isArray()) { if (page != null && page.getContent() != null) {
for (JsonNode row : data.get("content")) { for (MsgListItem item : page.getContent()) {
rows.add(rewrite(row, authz, dict)); rows.add(rewrite(item, authz, dict));
} }
} }
Map<String, Object> out = new HashMap<>(); Map<String, Object> out = new HashMap<>();
out.put("total", data == null || !data.has("total") ? 0 : data.get("total").asInt()); out.put("total", page == null ? 0 : page.getTotal());
out.put("list", rows); out.put("list", rows);
return out; return out;
} catch (Exception e) { } catch (Exception e) {
@@ -220,9 +365,12 @@ public class MsgCenterProxyService {
/** /**
* 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 0/空)。 * 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 0/空)。
* 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount)来自 UserMsg /oapi/msg/summary * 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount)来自 UserMsg /oapi/msg/summary
* (消息中心左栏一次喂饱);旧版 UserMsg 无该端点 → 字段缺省不出现,前端按降级处理。 * (消息中心左栏一次喂饱),按 msgSchId 学校口径取数;UserMsg 不可达/字段缺省时降级 unreadCount 单查。
*
* @param msgSchId 消息读取口径(当前激活身份学校,null=全部)
* @param todoSchId 待办角标口径(沿用会话主学校,链路口径不变,见类注释)
*/ */
public Map<String, Object> summary(Integer userId, Integer schId) { public Map<String, Object> summary(Integer userId, Integer msgSchId, Integer todoSchId) {
Map<String, Object> out = new HashMap<>(); Map<String, Object> out = new HashMap<>();
out.put("unreadCount", 0L); out.put("unreadCount", 0L);
out.put("todos", new ArrayList<>()); out.put("todos", new ArrayList<>());
@@ -231,20 +379,18 @@ public class MsgCenterProxyService {
} }
boolean summaryFetched = false; boolean summaryFetched = false;
try { try {
Map<String, String> q = new LinkedHashMap<>(); UserMsgSummary vo = msgClient().summary(userId, msgSchId);
q.put("userId", String.valueOf(userId)); if (vo != null) {
JsonNode vo = get("/oapi/msg/summary", q); out.put("unreadCount", vo.getUnreadCount());
if (vo != null && vo.has("unreadCount")) { out.put("sysUnread", vo.getSysUnread());
out.put("unreadCount", vo.get("unreadCount").asLong()); out.put("bizUnread", vo.getBizUnread());
out.put("sysUnread", vo.path("sysUnread").asLong(0)); out.put("approvalUnread", vo.getApprovalUnread());
out.put("bizUnread", vo.path("bizUnread").asLong(0)); out.put("total", vo.getTotal());
out.put("approvalUnread", vo.path("approvalUnread").asLong(0)); out.put("readCount", vo.getReadCount());
out.put("total", vo.path("total").asLong(0)); // 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径
out.put("readCount", vo.path("readCount").asLong(0)); out.put("sysTotal", vo.getSysTotal());
// 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径;旧版 UserMsg 无该字段时缺省,前端按降级处理 out.put("bizTotal", vo.getBizTotal());
out.put("sysTotal", vo.path("sysTotal").asLong(0)); out.put("approvalTotal", vo.getApprovalTotal());
out.put("bizTotal", vo.path("bizTotal").asLong(0));
out.put("approvalTotal", vo.path("approvalTotal").asLong(0));
summaryFetched = true; summaryFetched = true;
} }
} catch (Exception e) { } catch (Exception e) {
@@ -253,10 +399,7 @@ public class MsgCenterProxyService {
} }
if (!summaryFetched) { if (!summaryFetched) {
try { try {
Map<String, String> q = new LinkedHashMap<>(); out.put("unreadCount", msgClient().unreadCount(userId, msgSchId));
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) { } catch (Exception e) {
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString()); log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString());
} }
@@ -264,8 +407,8 @@ public class MsgCenterProxyService {
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));
if (schId != null) { if (todoSchId != null) {
q.put("schId", String.valueOf(schId)); q.put("schId", String.valueOf(todoSchId));
} }
JsonNode todos = get("/oapi/msg/todo/list", q); JsonNode todos = get("/oapi/msg/todo/list", q);
List<Map<String, Object>> rows = new ArrayList<>(); List<Map<String, Object>> rows = new ArrayList<>();
@@ -287,36 +430,65 @@ public class MsgCenterProxyService {
/** /**
* 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName: * 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName:
* [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。 * [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。
* appId 取应用字典(按用户授权列表),字典查无(如授权已回收的来源应用)时 appId=null,前端不展示。 * UserMsg 桶值为归属口径 code-or-key 混合(代发=业务方 app_code、自发=app_key):
* 停机降级空列表。 * 逐桶先 byCode 解析、未命中回落字典 byKey(发送方),解析出应用后按 appId 合并 total/unread
* (同一应用的自发桶与代发货桶并成一类目)。双查无的桶跳过(与旧「字典查无即丢」一致)。
* schId=当前身份学校口径(null=全部)。停机降级空列表。
*/ */
public List<Map<String, Object>> countByApp(Integer userId) { public List<Map<String, Object>> countByApp(Integer userId, Integer schId) {
List<Map<String, Object>> empty = new ArrayList<>(); List<Map<String, Object>> empty = new ArrayList<>();
if (!enabled()) { if (!enabled()) {
return empty; return empty;
} }
try { try {
Map<String, String> q = new LinkedHashMap<>(); List<AppMsgCount> counts = msgClient().countByApp(userId, schId);
q.put("userId", String.valueOf(userId)); if (counts == null || counts.isEmpty()) {
JsonNode arr = get("/oapi/msg/countByApp", q);
if (arr == null || !arr.isArray()) {
return empty; return empty;
} }
AppDict dict = appDict(userId); AppDict dict = appDict(userId);
List<Map<String, Object>> out = new ArrayList<>(); Map<Object, Map<String, Object>> merged = new LinkedHashMap<>();
for (JsonNode a : arr) { for (AppMsgCount a : counts) {
String appKey = text(a, "appKey"); String bucket = a == null ? null : a.getAppKey();
if (appKey == null) { if (bucket == null || bucket.isEmpty()) {
continue; continue;
} }
AppDictEntry entry = dict.byKey.get(appKey); AppDictEntry entry = dict.byKey.get(bucket);
Map<String, Object> row = new LinkedHashMap<>(); // 纯数字桶值不可能是 app_code,免一次注定查无的出站;
row.put("appId", entry == null ? null : entry.appId); // 数字脏桶(字典查无)维持现状——行保留、appId=null 由前端隐藏
row.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name); ByCodeEntry codeEntry = entry == null && !bucket.matches("\\d+")
row.put("total", a.path("total").asLong(0)); ? lookupByCode(bucket) : null;
row.put("unread", a.path("unread").asLong(0)); Integer appId;
out.add(row); String name;
if (entry != null) {
appId = entry.appId;
name = entry.name;
} else if (codeEntry != null && codeEntry != CODE_MISS) {
appId = codeEntry.appId;
name = codeEntry.name;
} else if (codeEntry == CODE_MISS) {
continue; // code 脏桶双查无:丢弃,与改造前一致
} else {
appId = null;
name = properties.getAppNames().get(bucket);
} }
// 合桶键=注册表主键 appId:自发桶(byKey)与代发桶(byCode)同应用并成一类目
Map<String, Object> row = merged.get(appId);
if (row == null) {
row = new LinkedHashMap<>();
row.put("appId", appId);
row.put("appName", name);
row.put("total", 0L);
row.put("unread", 0L);
merged.put(appId, row);
}
row.put("total", (Long) row.get("total") + a.getTotal());
row.put("unread", (Long) row.get("unread") + a.getUnread());
}
List<Map<String, Object>> out = new ArrayList<>(merged.values());
out.sort((x, y) -> {
int c = Long.compare((Long) y.get("total"), (Long) x.get("total"));
return c != 0 ? c : Long.compare((Long) y.get("unread"), (Long) x.get("unread"));
});
return out; return out;
} catch (Exception e) { } catch (Exception e) {
log.warn("[msgcenter] UserMsg 按应用计数不可达(降级空)userId={}:{}", userId, e.toString()); log.warn("[msgcenter] UserMsg 按应用计数不可达(降级空)userId={}:{}", userId, e.toString());
@@ -324,25 +496,29 @@ public class MsgCenterProxyService {
} }
} }
/** 标记已读(代理按登录人发送,UserMsg 侧 UPDATE 带归属人防伪) */ /**
public void read(Integer userId, List<Long> msgIds) { * 标记已读(代理按登录人发送,UserMsg 侧 UPDATE 带归属人防伪)。
* schId 仅用于 UserMsg 侧缓存键(含学校口径,R1)与 readState 帧口径对齐,不参与 UPDATE 归属判定。
*/
public void read(Integer userId, List<Long> msgIds, Integer schId) {
if (!enabled() || msgIds == null || msgIds.isEmpty()) { if (!enabled() || msgIds == null || msgIds.isEmpty()) {
return; return;
} }
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("msgIds", msgIds); body.put("msgIds", msgIds);
body.put("userId", userId); body.put("userId", userId);
if (schId != null) {
body.put("schId", schId);
}
post("/oapi/msg/read", new HashMap<>(), body); post("/oapi/msg/read", new HashMap<>(), body);
} }
/** 全部已读 */ /** 全部已读(按 schId 学校口径限定,D6:只标本校+平台未读,他校未读不受影响;schId null=全部) */
public void readAll(Integer userId) { public void readAll(Integer userId, Integer schId) {
if (!enabled()) { if (!enabled()) {
return; return;
} }
Map<String, Object> body = new HashMap<>(); msgClient().readAll(userId, schId);
body.put("userId", userId);
post("/oapi/msg/readAll", new HashMap<>(), body);
} }
/** /**
@@ -409,33 +585,56 @@ public class MsgCenterProxyService {
/** /**
* MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。 * MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。
* 应用标识对前端只出 appId/appName(应用字典富化):appKey 属后端签名标识不下发; * 应用标识对前端只出 appId/appName:归属换源(页面属主优先)——jumpApp(值域=app_code)可解析时
* 字典查无(授权已回收等)appId/appName 为 null,前端按类型标签降级。 * 出参指向业务方应用,为空/查无/不可达回落发送方 appKey(字典 byKey,再落手工名称表)。
* appKey 属后端签名标识不下发;两口径均查无时 appId/appName 为 null,前端按类型标签降级。
*/ */
private Map<String, Object> rewrite(JsonNode row, PerRequestAuthz authz, AppDict dict) { private Map<String, Object> rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) {
Map<String, Object> out = new LinkedHashMap<>(); Map<String, Object> out = new LinkedHashMap<>();
out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null); out.put("msgId", row.getMsgId());
out.put("title", text(row, "title")); out.put("title", row.getTitle());
out.put("content", text(row, "content")); out.put("content", row.getContent());
out.put("msgType", row.has("msgType") && row.get("msgType").isNumber() ? row.get("msgType").asInt() : null); out.put("msgType", row.getMsgType());
out.put("jumpType", row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null); out.put("jumpType", row.getJumpType());
out.put("jumpPath", text(row, "jumpPath")); out.put("jumpPath", row.getJumpPath());
out.put("jumpApp", text(row, "jumpApp")); out.put("jumpApp", row.getJumpApp());
String appKey = text(row, "appKey"); // 归属换源(页面属主优先):appId/appName 出参 jumpApp(值域=app_code)非空且 byCode 查到 → 用业务方应用;
AppDictEntry entry = appKey == null ? null : dict.byKey.get(appKey); // jumpApp 为空/查无(脏值)/UserBase 不可达 → 回落发送方 byKey(宁可显示流程中心,不抛错)。
out.put("appId", entry == null ? null : entry.appId); // 其余出参(jumpApp/jumpUrl/showJump/bizType…)不动,前端已依赖。
out.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name); String jumpApp = row.getJumpApp();
out.put("bizType", text(row, "bizType")); String ownerCode = jumpApp == null ? null : jumpApp.trim();
out.put("bizId", text(row, "bizId")); AppDictEntry entry = null;
out.put("sendTime", text(row, "sendTime")); Integer appId = null;
out.put("isRead", row.has("isRead") && row.get("isRead").isNumber() ? row.get("isRead").asInt() : 0); String appName = null;
if (StringUtils.hasText(ownerCode)) {
ByCodeEntry codeEntry = lookupByCode(ownerCode);
if (codeEntry != null && codeEntry != CODE_MISS) {
appId = codeEntry.appId;
appName = codeEntry.name;
}
}
if (appId == null) {
String appKey = row.getAppKey();
entry = appKey == null ? null : dict.byKey.get(appKey);
if (entry != null) {
appId = entry.appId;
appName = entry.name;
} else {
appName = properties.getAppNames().get(appKey);
}
}
out.put("appId", appId);
out.put("appName", appName);
out.put("bizType", row.getBizType());
out.put("bizId", row.getBizId());
out.put("sendTime", row.getSendTime());
out.put("isRead", row.getIsRead() == null ? 0 : row.getIsRead());
String jumpUrl = jumpUrl(row); String jumpUrl = jumpUrl(row);
// showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改); // showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改);
// jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行 // jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行
Boolean allowed = null; Boolean allowed = null;
String jumpApp = text(row, "jumpApp"); if (jumpUrl != null && StringUtils.hasText(ownerCode)) {
if (jumpUrl != null && StringUtils.hasText(jumpApp)) { allowed = authz.authorized(ownerCode);
allowed = authz.authorized(jumpApp.trim());
} }
boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed); boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed);
if (Boolean.FALSE.equals(allowed)) { if (Boolean.FALSE.equals(allowed)) {
@@ -457,10 +656,10 @@ public class MsgCenterProxyService {
* </ol> * </ol>
* 注册表是多端地址(pc/app/小程序/钉钉)唯一来源;jump-bases 手工基址表已退役(C2)。 * 注册表是多端地址(pc/app/小程序/钉钉)唯一来源;jump-bases 手工基址表已退役(C2)。
*/ */
private String jumpUrl(JsonNode row) { private String jumpUrl(MsgListItem row) {
Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null; Integer jumpType = row.getJumpType();
String jumpPath = text(row, "jumpPath"); String jumpPath = row.getJumpPath();
String jumpApp = text(row, "jumpApp"); String jumpApp = row.getJumpApp();
if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) { if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) {
return null; return null;
} }
@@ -472,7 +671,7 @@ public class MsgCenterProxyService {
return null; return null;
} }
String p = path.startsWith("/") ? path : "/" + path; String p = path.startsWith("/") ? path : "/" + path;
String appKey = text(row, "appKey"); String appKey = row.getAppKey();
String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim()) String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim())
: (StringUtils.hasText(appKey) ? pcUrlByKey(appKey.trim()) : null); : (StringUtils.hasText(appKey) ? pcUrlByKey(appKey.trim()) : null);
if (!StringUtils.hasText(pcUrl)) { if (!StringUtils.hasText(pcUrl)) {
@@ -489,9 +688,14 @@ public class MsgCenterProxyService {
return base + "#" + p; return base + "#" + p;
} }
/** 注册表 pc_url 查询(by-code 主查)。UserBase 不可达/查无 → null,失败进短路窗口 */ /**
* 注册表 pc_url 查询(by-code 主查):走 by-code 缓存,一次调用同取 name 与 pcUrl
* (消掉旧逐行现查的 N+1)。不可达短路窗口内沿用已缓存值(旧 pcUrl 拼接语义不变,
* 查不到/无缓存才隐藏跳转入口)。
*/
private String pcUrlByCode(String appCode) { private String pcUrlByCode(String appCode) {
return lookupPcUrl("/oapi/app/by-code/" + appCode); ByCodeEntry entry = lookupByCode(appCode.trim());
return entry == null || entry == CODE_MISS ? null : entry.pcUrl;
} }
/** 注册表 pc_url 查询(发送方 appKey 回落,appKey 是 app_key 列非主键 id) */ /** 注册表 pc_url 查询(发送方 appKey 回落,appKey 是 app_key 列非主键 id) */
@@ -578,13 +782,6 @@ 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;
} }
@@ -0,0 +1,281 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.core.YschOpenCall;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 小组件统一取数。
*
* <p>{@code syllabus}(我的课表)已对接 AcaSyllabu {@code GET /oapi/timetable/Get}(出站走 {@link YschOpenCall},
* 带 yh-app-auth 签名;目标 {@code acasyllabu} 配于 {@code yh.call.targets}),**原样透传** {@code data[]}
* 班级数组(含 {@code timetable.slots[].subjects[7]}),由前端按班级维度渲染。</p>
*
* <p>{@code weekplan}(一周安排)已对接 WorkWeekPlan {@code GET /oapi/workplan/Get?userid=&schId=&week=}(出站走
* {@link YschOpenCall})。全校共享数据、不区分角色,**原样透传**单个周对象 {@code data}(含 {@code days[7]});
* {@code week} 为相对本周偏移(0=本周、-1=上一周、+1=下一周),合法范围见响应 {@code minWeek}/{@code maxWeek};
* 返回内容与身份无关但随周偏移变化,缓存按 {@code schId|week偏移} 双段键。</p>
*
* <p>{@code evaluate} 仍为 L2 可溯源 mock(字段名逐一等于
* {@code design/widget-snapshots-old-bsch/*.js} 的旧接口口径);未知 code 返回 null 由调用方兜空数据,不抛异常。</p>
*/
@Service
public class WidgetDataService {
private static final Logger log = LoggerFactory.getLogger(WidgetDataService.class);
private static final String CODE_EVALUATE = "evaluate";
private static final String CODE_SYLLABUS = "syllabus";
private static final String CODE_WEEKPLAN = "weekplan";
/** 听评课旧接口 pageSize 默认值(evaluate_index.js 传 count=5) */
private static final int EVALUATE_DEFAULT_COUNT = 5;
/** 课表 oapi 目标名(见 yh.call.targets.acasyllabu) */
private static final String TARGET_ACASYLLABU = "acasyllabu";
/** 课表 oapi 路径(签名按目标 ctx 剥离,故此处不含 /acasyllabu) */
private static final String PATH_TIMETABLE = "/oapi/timetable/Get";
/** 课表缓存时长:课表低频变更,避免每次进桌面都打出站 */
private static final long SYLLABUS_TTL_MS = 90_000L;
/** 课表取数失败后的静默期(短 TTL 空结果,避免下游停摆时持续打出站) */
private static final long SYLLABUS_FAIL_BACKOFF_MS = 15_000L;
/** 课表缓存条目上限,超出时清理过期项 */
private static final int SYLLABUS_CACHE_MAX = 500;
/** 一周安排 oapi 目标名(见 yh.call.targets.workplan) */
private static final String TARGET_WORKPLAN = "workplan";
/** 一周安排 oapi 路径(签名按目标 ctx 剥离,故此处不含 /work-week-plan) */
private static final String PATH_WORKPLAN = "/oapi/workplan/Get";
/** 一周安排缓存时长:全校共享、低频变更 */
private static final long WEEKPLAN_TTL_MS = 90_000L;
/** 一周安排取数失败后的静默期 */
private static final long WEEKPLAN_FAIL_BACKOFF_MS = 15_000L;
/** 一周安排缓存条目上限,超出时清理过期项 */
private static final int WEEKPLAN_CACHE_MAX = 500;
@Autowired
private YschOpenCall openCall;
/** key = userId|roleId|schId → 缓存条目 */
private final ConcurrentHashMap<String, CacheEntry> syllabusCache = new ConcurrentHashMap<>();
/** key = schId|weekOffset → 缓存条目(周安排为全校共享数据,与 userid/roleId 无关;但不同周内容不同) */
private final ConcurrentHashMap<String, CacheEntry> weekplanCache = new ConcurrentHashMap<>();
/**
* 按数据键取数;params 为 urlencoded 查询串(对齐旧 otherParams 口径)。
*
* @return 数据结构由数据键决定;null = 该键未接入
*/
public Object getData(String code, String params, Caller caller) {
if (CODE_EVALUATE.equals(code)) return evaluate(params);
if (CODE_SYLLABUS.equals(code)) return syllabus(caller);
if (CODE_WEEKPLAN.equals(code)) return weekplan(caller, params);
log.warn("[widget] 未配置的数据键:{}", code);
return null;
}
/**
* 听评课:分页 {list, count, pageSize},入参 {page, count}(page 从 0 起)。
* // TODO 接入 oapi: openCall.post(TARGET_USERBASE, "/oapi/...", null, body, Vo.class)
*/
private Map<String, Object> evaluate(String params) {
int page = Math.max(0, intParam(params, "page", 0));
int count = Math.min(50, Math.max(1, intParam(params, "count", EVALUATE_DEFAULT_COUNT)));
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(row("id", 101, "title", "《圆的认识》课堂观察",
"lectureTime", "2026-09-28 08:30:00", "lectureteaName", "王晓梅", "subName", "数学"));
rows.add(row("id", 102, "title", "《Unit 5 Reading》公开课",
"lectureTime", "2026-09-29 10:00:00", "lectureteaName", "李强", "subName", "英语"));
rows.add(row("id", 103, "title", "《光的折射》实验课",
"lectureTime", "2026-09-30 14:00:00", "lectureteaName", "陈雷", "subName", "物理"));
rows.add(row("id", 104, "title", "《三角形内角和》研讨课",
"lectureTime", "2026-10-09 09:00:00", "lectureteaName", "赵敏", "subName", "数学"));
int from = Math.min(page * count, rows.size());
int to = Math.min(from + count, rows.size());
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", rows.subList(from, to));
result.put("count", rows.size());
result.put("pageSize", count);
return result;
}
/**
* 我的课表:AcaSyllabu {@code GET /oapi/timetable/Get?userid=&roleId=&schId=}(SDK 出站,签名自动)。
* 原样返回 {@code data[]} 班级数组;无可见范围返回 {@code []};不可达 / 验签失败降级空数组(不抛 500)。
*
* <p>身份三参均来自服务端会话推导({@link Caller}),不接受前端传入;roleId&lt;=0 时不透传
* (oapi 侧默认按普通教师处理)。</p>
*/
private Object syllabus(Caller caller) {
if (caller == null || caller.getUserId() == null || caller.getSchId() == null) {
// 缺身份(超管无当前身份等)→ 空数据,不打出站
return Collections.emptyList();
}
String key = caller.getUserId() + "|" + caller.getRoleId() + "|" + caller.getSchId();
long now = System.currentTimeMillis();
CacheEntry hit = syllabusCache.get(key);
if (hit != null && now < hit.expireAt) {
return hit.data;
}
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("userid", String.valueOf(caller.getUserId()));
if (caller.getRoleId() != null && caller.getRoleId() > 0) {
query.put("roleId", String.valueOf(caller.getRoleId()));
}
query.put("schId", String.valueOf(caller.getSchId()));
JsonNode data = openCall.get(TARGET_ACASYLLABU, PATH_TIMETABLE, query, JsonNode.class);
Object value = (data != null && data.isArray()) ? data : Collections.emptyList();
cache(syllabusCache, SYLLABUS_CACHE_MAX, key, value, now + SYLLABUS_TTL_MS, now);
return value;
} catch (Exception e) {
log.warn("[widget] 课表 oapi 不可达(降级空)user={} role={} sch={}:{}",
caller.getUserId(), caller.getRoleId(), caller.getSchId(), e.toString());
// 失败退避:短 TTL 空结果,避免下游停摆时每次进桌面都吃一次超时
cache(syllabusCache, SYLLABUS_CACHE_MAX, key, Collections.emptyList(), now + SYLLABUS_FAIL_BACKOFF_MS, now);
return Collections.emptyList();
}
}
/**
* 一周安排:WorkWeekPlan {@code GET /oapi/workplan/Get?userid=&schId=&week=}(SDK 出站,签名自动)。
* 全校共享数据(不区分角色),原样透传单个周对象 {@code data}(含 {@code days[7]});
* 缺身份 / 不可达 / 验签失败 / week 越界(oapi {@code code:2},SDK 抛异常)均降级空对象(不抛 500)。
*
* <p>{@code week} 为**相对本周的偏移**(0=本周、-1=上一周、+1=下一周),合法范围由响应
* {@code minWeek}/{@code maxWeek} 提示(前端据此驱动翻页边界);注意与数据字段 {@code data.week}
* (绝对周次)同名不同义。</p>
*
* <p>userid 仅用于 oapi 侧审计、不影响返回内容,故缓存按 {@code schId|week偏移}
* (全校共享、与身份无关,但不同周内容不同,不可共用单键)。</p>
*/
private Object weekplan(Caller caller, String params) {
if (caller == null || caller.getUserId() == null || caller.getSchId() == null) {
// 缺身份(超管无当前身份等)→ 空数据,不打出站
return Collections.emptyMap();
}
// week = 相对本周偏移(0=本周 / -1=上一周 / +1=下一周),复用 evaluate 同款解析(支持负数)
int weekOffset = intParam(params, "week", 0);
String key = caller.getSchId() + "|" + weekOffset;
long now = System.currentTimeMillis();
CacheEntry hit = weekplanCache.get(key);
if (hit != null && now < hit.expireAt) {
return hit.data;
}
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("userid", String.valueOf(caller.getUserId()));
query.put("schId", String.valueOf(caller.getSchId()));
if (weekOffset != 0) {
// 0 为缺省值(本周):不传可让本周请求与签名 target 保持最小化
query.put("week", String.valueOf(weekOffset));
}
JsonNode data = openCall.get(TARGET_WORKPLAN, PATH_WORKPLAN, query, JsonNode.class);
Object value = (data != null && data.isObject()) ? data : Collections.emptyMap();
cache(weekplanCache, WEEKPLAN_CACHE_MAX, key, value, now + WEEKPLAN_TTL_MS, now);
return value;
} catch (Exception e) {
log.warn("[widget] 一周安排 oapi 不可达(降级空)sch={} week={}:{}",
caller.getSchId(), weekOffset, e.toString());
// 失败退避:短 TTL 空结果,避免下游停摆时每次翻页都吃一次超时
cache(weekplanCache, WEEKPLAN_CACHE_MAX, key, Collections.emptyMap(), now + WEEKPLAN_FAIL_BACKOFF_MS, now);
return Collections.emptyMap();
}
}
/** 写缓存并做上限清理(只清过期项,避免无界增长) */
private void cache(ConcurrentHashMap<String, CacheEntry> store, int max, String key, Object value,
long expireAt, long now) {
if (store.size() >= max) {
for (Map.Entry<String, CacheEntry> entry : store.entrySet()) {
if (now >= entry.getValue().expireAt) {
store.remove(entry.getKey(), entry.getValue());
}
}
}
store.put(key, new CacheEntry(value, expireAt));
}
private int intParam(String params, String key, int defaultValue) {
if (params == null || params.isEmpty()) return defaultValue;
for (String pair : params.split("&")) {
int split = pair.indexOf('=');
if (split <= 0 || !key.equals(pair.substring(0, split))) continue;
try {
return Integer.parseInt(URLDecoder.decode(pair.substring(split + 1), "UTF-8").trim());
} catch (NumberFormatException | UnsupportedEncodingException e) {
return defaultValue;
}
}
return defaultValue;
}
private Map<String, Object> row(Object... keyValues) {
Map<String, Object> map = new LinkedHashMap<>();
for (int i = 0; i + 1 < keyValues.length; i += 2) {
map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
}
return map;
}
/** 取数调用方上下文:三参均会话推导,不接受前端传入 */
public static final class Caller {
private final Integer userId;
private final Integer roleId;
private final Integer schId;
public Caller(Integer userId, Integer roleId, Integer schId) {
this.userId = userId;
this.roleId = roleId;
this.schId = schId;
}
public Integer getUserId() {
return userId;
}
public Integer getRoleId() {
return roleId;
}
public Integer getSchId() {
return schId;
}
}
/** 课表缓存条目 */
private static final class CacheEntry {
private final Object data;
private final long expireAt;
private CacheEntry(Object data, long expireAt) {
this.data = data;
this.expireAt = expireAt;
}
}
}
@@ -0,0 +1,150 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.concurrent.ConcurrentHashMap;
/**
* 小组件外跳地址解析:{@code app_code} → 应用注册表 {@code pc_url} → 绝对跳转地址。
*
* <p>「地址不写死」落点:库里只存**子应用 code + 路由**,域名一律来自应用注册表
* (UserBase {@code /oapi/app/by-code/{appCode}},经 SDK {@link OpenApiAppClient#getAppByCode} 调用,
* 签名自动)。拼接口径与消息中心 {@code jumpUrl} 一致:{@code pc_url} 归一化
* (丢 fragment、去尾斜杠)后拼 {@code "#" + path}。</p>
*
* <p>降级纪律:未配置 / 查无 pc_url / 注册表不可达 / 路由写成绝对 URL —— 一律返回 {@code null},
* 调用方不渲染跳转入口(pc_url 空就是空,不猜测、不回落)。</p>
*
* <p>缓存与短路:注册表地址 60s TTL(低频变更,避免每次进桌面都打一次 UserBase);查询失败进 30s
* 短路窗口,窗口内沿用旧缓存,避免下游停摆时每次渲染都吃一次连接超时。</p>
*
* <p>TODO 跨服务公共抽取:本实现只解决「小组件这一侧」;消息中心 {@code MsgCenterProxyService}
* 仍保留自己的 by-code 缓存与短路窗口。待平台层({{@code yh-spring-boot-starter}})提供带缓存的
* 地址解析,或 UserBase oapi 直接下发归一化跳转基址后,本类应退化为薄壳。</p>
*/
@Service
public class WidgetLinkResolver {
private static final Logger log = LoggerFactory.getLogger(WidgetLinkResolver.class);
/** 注册表地址缓存时长 */
private static final long PC_URL_TTL_MS = 60_000L;
/** 注册表不可达之后的静默期 */
private static final long PC_URL_DOWN_MS = 30_000L;
/** 注册表应用域客户端(SDK 自动装配,见 yh-openapi-spring-boot-starter 的 OpenApiAutoConfiguration) */
private final OpenApiAppClient appClient;
public WidgetLinkResolver(OpenApiAppClient appClient) {
this.appClient = appClient;
}
/** app_code → pc_url(空串 = 查无,避免脏 code 反复出站) */
private final ConcurrentHashMap<String, String> pcUrlCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> pcUrlAt = new ConcurrentHashMap<>();
/** 注册表不可达短路截止时间戳 */
private volatile long downUntil;
/**
* 解析外跳地址;无法解析返回 null(调用方不渲染入口)。
*
* @param appCode 应用注册表 app_code,如 {@code work-week-plan}
* @param linkPath 子应用内路由(hash 路由),如 {@code /weekplan};允许写成 {@code #/weekplan} 或 {@code weekplan}
*/
public String resolve(String appCode, String linkPath) {
if (!StringUtils.hasText(appCode) || !StringUtils.hasText(linkPath)) {
return null;
}
String path = normalizePath(linkPath);
if (path == null) {
log.warn("[widget] 外跳路由不合法(应为子应用内相对路由,禁止写绝对 URL):app={} path={}", appCode, linkPath);
return null;
}
String pcUrl = pcUrlOf(appCode.trim());
if (!StringUtils.hasText(pcUrl)) {
return null;
}
String base = pcUrl.trim();
int hash = base.indexOf('#');
if (hash >= 0) {
base = base.substring(0, hash);
}
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
if (!StringUtils.hasText(base)) {
return null;
}
// hash 路由只能拼在「基址」之后:目录形态必须补回斜杠(…/front/ + #/weekplan),
// 文件形态直接拼(…/front/index.html + #/list)。缺斜杠会被反代 301 到带斜杠路径,
// 且逐跳有丢 fragment 的风险(注册表两种形态并存:work-week-plan 是目录、notice 是 index.htm)。
int slash = base.lastIndexOf('/');
String lastSegment = slash >= 0 ? base.substring(slash + 1) : base;
if (lastSegment.indexOf('.') < 0) {
base = base + "/";
}
return base + "#" + path;
}
/**
* 路由归一化:去首部 {@code #}、去空白,保证以 {@code /} 开头;
* 绝对 URL(http/https)视为配置错误返回 null(域名只能来自注册表)。
*/
private String normalizePath(String linkPath) {
String path = linkPath.trim();
if (path.startsWith("http://") || path.startsWith("https://")) {
return null;
}
if (path.startsWith("#")) {
path = path.substring(1);
}
if (path.isEmpty()) {
return null;
}
return path.startsWith("/") ? path : "/" + path;
}
/** 注册表 pc_url 查询:命中缓存则返回;查无记空串;不可达走短路窗口(窗口内沿用旧值) */
private String pcUrlOf(String appCode) {
Long at = pcUrlAt.get(appCode);
if (at != null && System.currentTimeMillis() - at < PC_URL_TTL_MS) {
return emptyToNull(pcUrlCache.get(appCode));
}
if (System.currentTimeMillis() < downUntil) {
return emptyToNull(pcUrlCache.get(appCode));
}
try {
JsonNode app = appClient.getAppByCode(appCode);
downUntil = 0L;
String pcUrl = app == null || app.isNull() ? null : text(app, "pcUrl");
pcUrlCache.put(appCode, pcUrl == null ? "" : pcUrl);
pcUrlAt.put(appCode, System.currentTimeMillis());
if (!StringUtils.hasText(pcUrl)) {
log.warn("[widget] 注册表无 pc_url,外跳入口不渲染:app={}", appCode);
}
return pcUrl;
} catch (Exception e) {
downUntil = System.currentTimeMillis() + PC_URL_DOWN_MS;
log.warn("[widget] 应用注册表不可达({}ms 内外跳入口沿用旧值/不渲染):app={} {}",
PC_URL_DOWN_MS, appCode, e.toString());
return emptyToNull(pcUrlCache.get(appCode));
}
}
private String emptyToNull(String value) {
return StringUtils.hasText(value) ? value : null;
}
private String text(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : value.asText();
}
}
@@ -1,82 +0,0 @@
package com.yunhai.userhome.service.impl;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.userhome.entity.AppCategory;
import com.yunhai.userhome.repository.AppCategoryRepository;
import com.yunhai.userhome.service.IAppCategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
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 javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
/**
* 应用类别服务(自 systemsetting AppCategoryService 1:1 迁移,GetAppCategorySel/getTimestampByIds 无消费方不迁)。
*/
@Service
public class AppCategoryServiceImpl implements IAppCategoryService {
@Autowired
private AppCategoryRepository appCategoryRepository;
@Override
public void save(AppCategory appCategory) {
appCategoryRepository.save(appCategory);
}
@Override
public void deleteById(Integer id) {
if (!appCategoryRepository.existsById(id)) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
}
appCategoryRepository.deleteById(id);
}
@Override
public Optional<AppCategory> findById(Integer id) {
return appCategoryRepository.findById(id);
}
@Override
public List<AppCategory> findByRoleType(Integer roleType) {
return appCategoryRepository.findAllByRoleType(roleType);
}
@SuppressWarnings({"rawtypes", "unchecked"})
@Override
public Page<AppCategory> findPageList(Integer page, Integer count, Integer roleType, String name) {
Sort sort = Sort.by(Sort.Direction.DESC, "id");
Pageable pageable = PageRequest.of(page - 1, count, sort);
Specification specification = (Specification) (root, criteriaQuery, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
if (roleType != null) {
predicates.add(criteriaBuilder.equal(root.get("roleType"), roleType));
}
if (name != null && !name.trim().isEmpty()) {
predicates.add(criteriaBuilder.like(root.get("name"), "%" + name + "%"));
}
return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
};
return appCategoryRepository.findAll(specification, pageable);
}
@Override
public void updateResDate(Integer id) {
Optional<AppCategory> optionalAppCategory = findById(id);
if (!optionalAppCategory.isPresent()) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
}
AppCategory model = optionalAppCategory.get();
model.setResDate(new Date());
appCategoryRepository.save(model);
}
}
@@ -89,9 +89,11 @@ public class AppearanceServiceImpl implements IAppearanceService {
} }
/** /**
* 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源;2026-10-09 JSON 化+LOGO 文字): * 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源;2026-10-09 JSON 化;
* {@code {theme, avatar, logoText, logoUrl}}——theme 为【JSON 对象】(个人自选 → 学校默认 → 平台默认(schId=0) * 同日定稿摘除 logoText——子应用 header 定稿只出 logo 图,文字仅门户消费且走 /api/schLogoText 自有链,
* 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);avatar 为生效样式;logoText 本校→平台级联; * init 侧对缺失字段按 null 语义自动清 yh-logo-text 本地键):
* {@code {theme, avatar, logoUrl}}——theme 为【JSON 对象】(个人自选 → 学校默认 → 平台默认(schId=0)
* 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);avatar 为生效样式;
* logoUrl 为本校 type=1 → 平台 type=0 择优。 * logoUrl 为本校 type=1 → 平台 type=0 择优。
* *
* <p>schId 解析走本地 user_current_role(oapi 入参只有 userId,不引 identity 跨仓):roleId 当前角色学校, * <p>schId 解析走本地 user_current_role(oapi 入参只有 userId,不引 identity 跨仓):roleId 当前角色学校,
@@ -103,7 +105,6 @@ public class AppearanceServiceImpl implements IAppearanceService {
Map<String, Object> out = new HashMap<>(); Map<String, Object> out = new HashMap<>();
out.put("theme", null); out.put("theme", null);
out.put("avatar", null); out.put("avatar", null);
out.put("logoText", null);
out.put("logoUrl", null); out.put("logoUrl", null);
if (userId == null) { if (userId == null) {
return out; return out;
@@ -132,10 +133,9 @@ public class AppearanceServiceImpl implements IAppearanceService {
if (avatar == null) { if (avatar == null) {
avatar = schSettingsService.readAvatarWithFallback(schId).getValue(); avatar = schSettingsService.readAvatarWithFallback(schId).getValue();
} }
out.put("logoText", schSettingsService.readLogoTextWithFallback(schId).getValue());
out.put("logoUrl", resolveLogoUrl(schId)); out.put("logoUrl", resolveLogoUrl(schId));
} catch (Exception e) { } catch (Exception e) {
// 学校/平台段降级:theme/avatar/logoText/logoUrl 保持已得值或 null // 学校/平台段降级:theme/avatar/logoUrl 保持已得值或 null
} }
// theme 出 JSON 对象(2026-10-09 契约);值已过 decodeThemeState 校验,解析失败按 null 降级 // theme 出 JSON 对象(2026-10-09 契约);值已过 decodeThemeState 校验,解析失败按 null 降级
out.put("theme", theme == null ? null : ThemeCodec.toJsonNode(theme)); out.put("theme", theme == null ? null : ThemeCodec.toJsonNode(theme));
@@ -21,19 +21,35 @@ public class OpenApiAppRemoteServiceImpl implements AppRemoteService {
@Override @Override
public List<BoaAppType> getUserApps(BoaUser user) { public List<BoaAppType> getUserApps(BoaUser user) {
return getUserApps(user, null);
}
@Override
public List<BoaAppType> getUserApps(BoaUser user, Integer schoolId) {
if (user == null || user.getId() == null) { if (user == null || user.getId() == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
// PC 浏览器端;UserBase 侧已按 userId 权限过滤(组开通 ∪ 角色模块所属应用,超管全量) // 前端 getCurSchId 对无学校身份(超管/平台账号)下发哨兵 -1(store: schId || -1),非合法学校主键;
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER); // UserBase 2026-10-12 硬闸门对显式 schoolId 先过 assertSchoolId,-1 会被拒("学校分组不存在"),
// 且超管豁免(isSuperAdmin bypass)在校验之后根本走不到——边界归一为 null 交服务端按激活身份/主归属兜底。
if (schoolId != null && schoolId <= 0) {
schoolId = null;
}
// PC 浏览器端;schoolId 透传 UserBase 做学校单点开通收窄(为空则服务端按身份/主归属兜底)
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER, schoolId);
} }
@Override @Override
public Optional<BoaApp> getAppById(BoaUser user, Integer appId) { public Optional<BoaApp> getAppById(BoaUser user, Integer appId) {
return getAppById(user, appId, null);
}
@Override
public Optional<BoaApp> getAppById(BoaUser user, Integer appId, Integer schoolId) {
if (appId == null) { if (appId == null) {
return Optional.empty(); return Optional.empty();
} }
return getUserApps(user).stream() return getUserApps(user, schoolId).stream()
.filter(t -> t.getApps() != null) .filter(t -> t.getApps() != null)
.flatMap(t -> t.getApps().stream()) .flatMap(t -> t.getApps().stream())
.filter(a -> appId.equals(a.getId())) .filter(a -> appId.equals(a.getId()))
@@ -53,6 +53,10 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
@Value("${FILE_PATH:./upload/}") @Value("${FILE_PATH:./upload/}")
private String FILE_PATH; private String FILE_PATH;
/** 与 UserSettingsController 同源:展示形态 = 该前缀 + 去掉 /upload 的池内原始路径 */
@Value("${FILE_WEB_PATH:}")
private String FILE_WEB_PATH;
@Override @Override
public List<Wallpaper> findWallList(Integer userId) { public List<Wallpaper> findWallList(Integer userId) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId); Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId);
@@ -136,8 +140,11 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
if (!(superAdmin || ownPersonal || schAdminOwn)) { if (!(superAdmin || ownPersonal || schAdminOwn)) {
throw new BizException(ErrorCode.FORBIDDEN); throw new BizException(ErrorCode.FORBIDDEN);
} }
// 使用中清理:被删壁纸被用户设置引用时置空,避免裂图 // 使用中清理:被删壁纸被用户设置引用时置空,避免裂图。
userSettingsRepository.clearWallpaperUrl(wallpaper.getWallpaperUrl()); // user_settings 存展示形态(FILE_WEB_PATH 前缀 + 去 /upload),与 wall 表原始形态不同,两种都清
String rawUrl = wallpaper.getWallpaperUrl();
String displayUrl = (FILE_WEB_PATH == null ? "" : FILE_WEB_PATH) + rawUrl.replace("/upload", "");
userSettingsRepository.clearWallpaperUrl(java.util.Arrays.asList(rawUrl, displayUrl));
// 清理物理文件(非本地上传的跨仓地址静默跳过) // 清理物理文件(非本地上传的跨仓地址静默跳过)
FileUtil.deleteFile(FILE_PATH, wallpaper.getWallpaperUrl()); FileUtil.deleteFile(FILE_PATH, wallpaper.getWallpaperUrl());
wallpaperRepository.delete(wallpaper); wallpaperRepository.delete(wallpaper);
@@ -0,0 +1,418 @@
package com.yunhai.userhome.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.entity.WidgetCatalogSchool;
import com.yunhai.userhome.entity.WidgetSchoolSetting;
import com.yunhai.userhome.repository.WidgetCatalogRepository;
import com.yunhai.userhome.repository.WidgetCatalogSchoolRepository;
import com.yunhai.userhome.repository.WidgetSchoolSettingRepository;
import com.yunhai.userhome.service.IWidgetCatalogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 小组件候选池服务(对齐 IUserSettingsService 分层:Controller 只做鉴权与出参组装,规则在此)。
*
* <p>生效值合成口径:生效值 = COALESCE(校级覆盖行, 全局值);显示名 = 校级别名 &gt; 角色专属命名 &gt; 组件名称;
* 可见性 = 生效启用 AND 可见范围命中 AND 可见角色命中。</p>
*/
@Service
public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
private static final Logger log = LoggerFactory.getLogger(WidgetCatalogServiceImpl.class);
private static final int COMP_TYPE_BUILTIN = 1;
private static final int COMP_TYPE_FRAGMENT = 2;
private static final int ENABLED = 1;
/** 下发范围:全部学校 */
private static final int SCOPE_ALL = 0;
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
@Autowired
private WidgetCatalogRepository widgetCatalogRepository;
@Autowired
private WidgetCatalogSchoolRepository widgetCatalogSchoolRepository;
@Autowired
private WidgetSchoolSettingRepository widgetSchoolSettingRepository;
@Override
public List<WidgetCatalog> findEnabledForSchool(Integer schId, Integer roleId) {
Map<Integer, WidgetSchoolSetting> overrideMap = loadOverrideMap(schId);
List<WidgetCatalog> result = new ArrayList<>();
for (WidgetCatalog catalog : widgetCatalogRepository.findVisibleForSchool(schId)) {
WidgetSchoolSetting override = overrideMap.get(catalog.getId());
if (effectiveEnabled(catalog, override) != ENABLED) continue;
if (!roleHit(catalog, override, roleId)) continue;
result.add(effectiveCopy(catalog, override, schId, roleId));
}
result.sort(Comparator.comparing(WidgetCatalog::getSort, Comparator.nullsFirst(Integer::compareTo))
.thenComparing(WidgetCatalog::getId, Comparator.nullsFirst(Integer::compareTo)));
return result;
}
@Override
public Optional<WidgetCatalog> findByCode(String code) {
if (code == null || code.trim().isEmpty()) {
return Optional.empty();
}
return widgetCatalogRepository.findByCode(code);
}
@Override
public boolean visibleToSchool(WidgetCatalog catalog, Integer schId) {
int scopeType = catalog.getScopeType() == null ? SCOPE_ALL : catalog.getScopeType();
if (scopeType == SCOPE_ALL) {
return true;
}
boolean pointed = schId != null
&& widgetCatalogSchoolRepository.existsByWidgetIdAndSchId(catalog.getId(), schId);
// 1 = 白名单(点名才可见);2 = 黑名单(点名不可见)
return scopeType == 1 ? pointed : !pointed;
}
@Override
public List<WidgetCatalog> findAllForAdmin() {
List<WidgetCatalog> list = widgetCatalogRepository.findAllByOrderBySortAscIdAsc();
fillScopeSchIds(list);
return list;
}
@Override
public List<WidgetCatalog> findEffectiveForAdmin(Integer viewerSchId, boolean superAdmin) {
if (superAdmin || viewerSchId == null) {
return findAllForAdmin();
}
Map<Integer, WidgetSchoolSetting> overrideMap = loadOverrideMap(viewerSchId);
List<WidgetCatalog> result = new ArrayList<>();
for (WidgetCatalog catalog : widgetCatalogRepository.findVisibleForSchool(viewerSchId)) {
WidgetSchoolSetting override = overrideMap.get(catalog.getId());
WidgetCatalog item = new WidgetCatalog();
item.setId(catalog.getId());
item.setCode(catalog.getCode());
item.setName(effectiveName(catalog, override, null));
item.setOriginalName(catalog.getName());
item.setAlias(override == null ? null : override.getAlias());
item.setCovered(override != null);
item.setCompType(catalog.getCompType());
item.setContent(catalog.getContent());
item.setDataCode(catalog.getDataCode());
item.setScopeType(catalog.getScopeType());
item.setRoleTypes(effectiveRoleTypes(catalog, override));
item.setRoleAlias(catalog.getRoleAlias());
item.setSort(effectiveSort(catalog, override));
item.setEnabled(effectiveEnabled(catalog, override));
item.setLinkAppCode(effectiveLinkAppCode(catalog, override));
item.setLinkPath(effectiveLinkPath(catalog, override));
item.setUpdateTime(resolveUpdateTime(catalog, override));
result.add(item);
}
fillScopeSchIds(result);
return result;
}
@Override
@Transactional
public void save(WidgetCatalog model) {
if (model.getCode() == null || model.getCode().trim().isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "缺少组件键 code");
}
if (model.getName() == null || model.getName().trim().isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "缺少名称");
}
if (model.getCompType() == null
|| (model.getCompType() != COMP_TYPE_BUILTIN && model.getCompType() != COMP_TYPE_FRAGMENT)) {
throw new BizException(ErrorCode.CUSTOM, "类型只能是内置(1)或自定义片段(2)");
}
Optional<WidgetCatalog> exist = widgetCatalogRepository.findByCode(model.getCode());
if (exist.isPresent() && !exist.get().getId().equals(model.getId())) {
throw new BizException(ErrorCode.CUSTOM, "组件键已存在:" + model.getCode());
}
int scopeType = model.getScopeType() == null ? SCOPE_ALL : model.getScopeType();
if (scopeType != SCOPE_ALL && scopeType != 1 && scopeType != 2) {
throw new BizException(ErrorCode.CUSTOM, "可见范围只能是 全部学校(0) / 指定可见(1) / 指定不可见(2)");
}
model.setScopeType(scopeType);
if (scopeType != SCOPE_ALL && (model.getScopeSchIds() == null || model.getScopeSchIds().isEmpty())) {
throw new BizException(ErrorCode.CUSTOM, "请选择学校后再保存可见范围");
}
if (model.getSort() == null) model.setSort(0);
if (model.getEnabled() == null) model.setEnabled(ENABLED);
// 内置组件的内容在 front 工程里,库里只登记键
if (model.getCompType() == COMP_TYPE_BUILTIN) model.setContent(null);
// 外跳目标(可选):只落「子应用 code + 路由」,绝对 URL 由注册表解析,不落库
if (!StringUtils.hasText(model.getLinkAppCode())) model.setLinkAppCode(null);
if (!StringUtils.hasText(model.getLinkPath())) model.setLinkPath(null);
if ((model.getLinkAppCode() == null) != (model.getLinkPath() == null)) {
throw new BizException(ErrorCode.CUSTOM, "外跳配置需同时填「子应用 code」与「路由」,或都留空");
}
model.setUpdateTime(new Date());
widgetCatalogRepository.save(model);
rewritePointSchools(model);
}
@Override
@Transactional
public void deleteById(Integer id) {
if (id == null) {
throw new BizException(ErrorCode.CUSTOM, "缺少组件 id");
}
widgetCatalogSchoolRepository.deleteByWidgetId(id);
widgetSchoolSettingRepository.deleteByWidgetId(id);
widgetCatalogRepository.deleteById(id);
}
@Override
public Optional<WidgetSchoolSetting> findSchoolSetting(Integer widgetId, Integer schId) {
if (widgetId == null || schId == null) {
return Optional.empty();
}
return widgetSchoolSettingRepository.findByWidgetIdAndSchId(widgetId, schId);
}
@Override
@Transactional
public void saveSchoolSetting(WidgetSchoolSetting model, Integer currentSchId) {
if (model == null || model.getWidgetId() == null) {
throw new BizException(ErrorCode.CUSTOM, "缺少组件 id");
}
if (currentSchId == null) {
throw new BizException(ErrorCode.CUSTOM, "当前登录身份没有学校,无法保存本校设置");
}
// 防越权:schId 一律取当前登录校,不采信前端传参
model.setSchId(currentSchId);
if (!widgetCatalogRepository.findById(model.getWidgetId()).isPresent()) {
throw new BizException(ErrorCode.CUSTOM, "组件不存在:" + model.getWidgetId());
}
if (!StringUtils.hasText(model.getRoleTypes())) model.setRoleTypes(null);
if (!StringUtils.hasText(model.getAlias())) model.setAlias(null);
if (!StringUtils.hasText(model.getLinkAppCode())) model.setLinkAppCode(null);
if (!StringUtils.hasText(model.getLinkPath())) model.setLinkPath(null);
// 外跳必须成对配置:只填 code 或只填路由都是半截配置,直接拒绝
if ((model.getLinkAppCode() == null) != (model.getLinkPath() == null)) {
throw new BizException(ErrorCode.CUSTOM, "外跳配置需同时填「子应用 code」与「路由」,或都留空");
}
WidgetSchoolSetting exist = widgetSchoolSettingRepository
.findByWidgetIdAndSchId(model.getWidgetId(), currentSchId).orElse(null);
// 可覆盖字段全空 = 清除覆盖(删行,恢复沿用全局)
boolean clearing = model.getEnabled() == null && model.getRoleTypes() == null
&& model.getSortNo() == null && model.getAlias() == null
&& model.getLinkAppCode() == null && model.getLinkPath() == null;
if (clearing) {
if (exist != null) widgetSchoolSettingRepository.delete(exist);
return;
}
if (exist != null) model.setId(exist.getId());
model.setUpdateTime(new Date());
widgetSchoolSettingRepository.save(model);
}
@Override
public String displayName(WidgetCatalog catalog, Integer schId, Integer roleId) {
WidgetSchoolSetting override = null;
if (schId != null && catalog.getId() != null) {
override = widgetSchoolSettingRepository
.findByWidgetIdAndSchId(catalog.getId(), schId).orElse(null);
}
return effectiveName(catalog, override, roleId);
}
// ==================== 私有方法 ====================
/** 显示名三级链:校级别名 > 角色专属命名 > 组件名称 */
private String effectiveName(WidgetCatalog catalog, WidgetSchoolSetting override, Integer roleId) {
if (override != null && StringUtils.hasText(override.getAlias())) {
return override.getAlias();
}
String aliasByRole = roleAliasOf(catalog.getRoleAlias(), roleId);
if (StringUtils.hasText(aliasByRole)) {
return aliasByRole;
}
return catalog.getName();
}
/** 角色专属命名读取:JSON 文本里取当前角色那一项;解析失败按「无角色命名」降级,不抛 500 */
private String roleAliasOf(String roleAliasJson, Integer roleId) {
if (!StringUtils.hasText(roleAliasJson) || roleId == null) {
return null;
}
try {
JsonNode value = JSON_MAPPER.readTree(roleAliasJson).get(String.valueOf(roleId));
return value == null || value.isNull() ? null : value.asText();
} catch (Exception e) {
log.warn("[widget] role_alias 解析失败,按无角色命名降级:{}", roleAliasJson);
return null;
}
}
private boolean roleHit(WidgetCatalog catalog, WidgetSchoolSetting override, Integer roleId) {
String roleTypes = effectiveRoleTypes(catalog, override);
if (!StringUtils.hasText(roleTypes)) {
return true;
}
if (roleId == null) {
return false;
}
for (String token : roleTypes.split(",")) {
if (roleId.equals(parseInt(token))) {
return true;
}
}
return false;
}
private String effectiveRoleTypes(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && StringUtils.hasText(override.getRoleTypes())) {
return override.getRoleTypes();
}
return catalog.getRoleTypes();
}
/** 外跳子应用 code:校级覆盖优先(COALESCE 口径) */
private String effectiveLinkAppCode(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && StringUtils.hasText(override.getLinkAppCode())) {
return override.getLinkAppCode();
}
return catalog.getLinkAppCode();
}
/** 外跳路由:校级覆盖优先(COALESCE 口径) */
private String effectiveLinkPath(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && StringUtils.hasText(override.getLinkPath())) {
return override.getLinkPath();
}
return catalog.getLinkPath();
}
private int effectiveEnabled(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && override.getEnabled() != null) {
return override.getEnabled();
}
return catalog.getEnabled() == null ? ENABLED : catalog.getEnabled();
}
private int effectiveSort(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && override.getSortNo() != null) {
return override.getSortNo();
}
return catalog.getSort() == null ? 0 : catalog.getSort();
}
private Date resolveUpdateTime(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && override.getUpdateTime() != null) {
return override.getUpdateTime();
}
return catalog.getUpdateTime();
}
/** 下发视图:复制出独立对象并写入生效值,避免改动 JPA 托管实体(脏检查) */
private WidgetCatalog effectiveCopy(WidgetCatalog catalog, WidgetSchoolSetting override,
Integer schId, Integer roleId) {
WidgetCatalog item = new WidgetCatalog();
item.setId(catalog.getId());
item.setCode(catalog.getCode());
item.setName(effectiveName(catalog, override, roleId));
item.setCompType(catalog.getCompType());
item.setContent(catalog.getContent());
item.setDataCode(catalog.getDataCode());
item.setScopeType(catalog.getScopeType());
item.setRoleTypes(effectiveRoleTypes(catalog, override));
item.setRoleAlias(catalog.getRoleAlias());
item.setSort(effectiveSort(catalog, override));
item.setEnabled(effectiveEnabled(catalog, override));
item.setLinkAppCode(effectiveLinkAppCode(catalog, override));
item.setLinkPath(effectiveLinkPath(catalog, override));
item.setUpdateTime(resolveUpdateTime(catalog, override));
return item;
}
private Map<Integer, WidgetSchoolSetting> loadOverrideMap(Integer schId) {
Map<Integer, WidgetSchoolSetting> map = new HashMap<>();
if (schId == null) {
return map;
}
for (WidgetSchoolSetting setting : widgetSchoolSettingRepository.findBySchId(schId)) {
map.put(setting.getWidgetId(), setting);
}
return map;
}
private void fillScopeSchIds(Collection<WidgetCatalog> catalogs) {
Map<Integer, List<Integer>> pointMap = loadPointSchoolMap(catalogs);
for (WidgetCatalog catalog : catalogs) {
List<Integer> ids = pointMap.get(catalog.getId());
catalog.setScopeSchIds(ids == null ? new ArrayList<>() : ids);
}
}
private Map<Integer, List<Integer>> loadPointSchoolMap(Collection<WidgetCatalog> catalogs) {
Map<Integer, List<Integer>> map = new HashMap<>();
List<Integer> widgetIds = new ArrayList<>();
for (WidgetCatalog catalog : catalogs) {
if (catalog.getId() != null) widgetIds.add(catalog.getId());
}
if (widgetIds.isEmpty()) {
return map;
}
for (WidgetCatalogSchool row : widgetCatalogSchoolRepository.findByWidgetIdIn(widgetIds)) {
map.computeIfAbsent(row.getWidgetId(), key -> new ArrayList<>()).add(row.getSchId());
}
return map;
}
/** 整体重写点名表:scope_type=0 或未选校时清空 */
private void rewritePointSchools(WidgetCatalog model) {
widgetCatalogSchoolRepository.deleteByWidgetId(model.getId());
if (model.getScopeType() == null || model.getScopeType() == SCOPE_ALL) {
return;
}
List<Integer> schIds = model.getScopeSchIds();
if (schIds == null || schIds.isEmpty()) {
return;
}
List<WidgetCatalogSchool> rows = new ArrayList<>();
for (Integer schId : schIds) {
if (schId == null) continue;
WidgetCatalogSchool row = new WidgetCatalogSchool();
row.setWidgetId(model.getId());
row.setSchId(schId);
rows.add(row);
}
if (!rows.isEmpty()) {
widgetCatalogSchoolRepository.saveAll(rows);
}
}
private Integer parseInt(String text) {
if (text == null) {
return null;
}
try {
return Integer.valueOf(text.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
+20
View File
@@ -68,6 +68,26 @@ yh:
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删 # 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
base-host: http://base.yunhaixingtong.com base-host: http://base.yunhaixingtong.com
base-path: /userbase base-path: /userbase
call:
# YschCall 直调目标目录(寻址优先级高于 yh.open.services 与 PlatformService 枚举兜底)。
# acasyllabu:课程表 oapi(小组件 syllabus 取数)。base-path 即目标 ctx,
# 客户端签名按该 ctx 剥离(待签 target = /oapi/timetable/Get?...,不含 /aca-syllabu)。
# ⚠️ 真实 ctx = /aca-syllabu(带横线,20261013 生产实测 401 定位);手册写的 /acasyllabu 有误。
# 本地联调:AcaSyllabu 在 8109 且无 ctx ⇒ 覆盖 base-host: http://localhost:8109、base-path 留空。
# base-host 指 open-gateway(开放网关,容器网地址):20261015 切门收口,oapi 统一经网关
# 验签/转发/台账;签名口径不变(网关 canonical 与 starter 逐字节一致)。base-path 即
# 网关 target 段(= 目标 ctx)。
targets:
acasyllabu:
base-host: http://open-gateway-service:10013
base-path: /aca-syllabu
# workplan:一周安排 oapi(小组件 weekplan 取数)。base-path 即目标 ctx,
# 客户端签名按该 ctx 剥离(待签 target = /oapi/workplan/Get?...,不含 /work-week-plan)。
# 真实 ctx = /work-week-plan(20261015 网关签名 200 实测,网关注册表已登记该 target)。
# 本地联调:WorkWeekPlan 在 8110 且无 ctx ⇒ 覆盖 base-host: http://localhost:8110、base-path 留空。
workplan:
base-host: http://open-gateway-service:10013
base-path: /work-week-plan
# env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见 # env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。 # config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT # 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
+38
View File
@@ -0,0 +1,38 @@
-- ============================================================
-- 小组件候选池 widget_catalog(UserHome小组件内置化落地方案-20261009 §3.1)
-- 候选池真源由旧 bsch 开放接口换成本地表;内容自持,全表没有 url / html 地址字段。
-- comp_type=1 → 内容在 front 工程(内置 Vue 组件),本表只登记键;
-- comp_type=2 → 内容在 content(管理端贴/传,srcdoc 渲染)。
-- dev 由 JPA ddl-auto=update 依实体自动建表;测试/生产等关闭 ddl-auto 的环境手工执行本文件。
-- ============================================================
CREATE TABLE IF NOT EXISTS `widget_catalog` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` VARCHAR(32) NOT NULL COMMENT '组件键:内置=前端注册表键,自定义=自定义标识',
`name` VARCHAR(64) NOT NULL COMMENT '展示名(候选池文案 / 面板标题)',
`comp_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=front 内置组件 2=自定义 HTML 片段',
`content` MEDIUMTEXT NULL COMMENT 'comp_type=2 的 HTML 源码;内置类型恒空',
`data_code` VARCHAR(32) NULL COMMENT '片段取数用的数据键(须在 WidgetDataService 白名单内)',
`sch_id` INT NULL COMMENT '下发范围:NULL=全校可见(本期全校一致,字段与过滤钩子先就位)',
`sort` INT NOT NULL DEFAULT 0 COMMENT '候选池排序',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1=进候选池 0=下线',
`update_time` DATETIME NULL COMMENT '内容最后修改时间(管理端展示)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_widget_catalog_code` (`code`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = '小组件候选池(内容自持,无外部 html 托管依赖)';
-- 初始化三个存量组件(判定规则 §9:全部内置,content 留空)
-- code 上有唯一键,用 INSERT IGNORE 保证脚本可重复执行
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('evaluate', '听评课', 1, NULL, NULL, NULL, 1, 1, NOW());
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('syllabus', '课程表', 1, NULL, NULL, NULL, 2, 1, NOW());
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('weekplan', '一周安排', 1, NULL, NULL, NULL, 3, 1, NOW());
-- 回滚:
-- DROP TABLE IF EXISTS `widget_catalog`;
@@ -0,0 +1,51 @@
-- ============================================================
-- 小组件管理改版 v2(doc/小组件管理改版-执行方案.md §3)
-- dev 由 JPA ddl-auto=update 依实体自动加列/建表;本文件供关闭 ddl-auto 的测试/生产环境手工执行一次。
-- 幂等性:CREATE TABLE 用 IF NOT EXISTS;ALTER ADD COLUMN 在 MySQL 8 无 IF NOT EXISTS,
-- 重复执行会报 1060 Duplicate column name,属预期,按需手动跳过即可。
-- ============================================================
-- ---------- 1. widget_catalog 加 3 列 ----------
ALTER TABLE `widget_catalog`
ADD COLUMN `scope_type` TINYINT NOT NULL DEFAULT 0
COMMENT '下发范围:0=全部学校 1=白名单(仅点名的学校可见) 2=黑名单(点名的学校不可见)',
ADD COLUMN `role_types` VARCHAR(16) NULL
COMMENT '可见角色:逗号分隔 RoleTypeEnum(1教师/2校领导/3学生);NULL/空=全角色',
ADD COLUMN `role_alias` VARCHAR(255) NULL
COMMENT '角色专属命名(JSON 文本):{"2":"全校课表"},键=RoleTypeEnum;读侧按当前角色取值';
-- sch_id 旧语义(NULL=全校 / 单校等值)由 scope_type 取代,标记弃用:列保留以兼容存量数据,读侧不再使用。
-- ---------- 2. 新增点名表(配合 scope_type 白/黑名单) ----------
CREATE TABLE IF NOT EXISTS `widget_catalog_school` (
`id` INT NOT NULL AUTO_INCREMENT,
`widget_id` INT NOT NULL COMMENT 'widget_catalog.id',
`sch_id` INT NOT NULL COMMENT '被点名的学校',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_widget_school` (`widget_id`, `sch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = '小组件可见范围点名表(配合 scope_type 白/黑名单)';
-- ---------- 3. 新增校级覆盖层 ----------
CREATE TABLE IF NOT EXISTS `widget_school_setting` (
`id` INT NOT NULL AUTO_INCREMENT,
`widget_id` INT NOT NULL,
`sch_id` INT NOT NULL,
`enabled` TINYINT NULL COMMENT 'NULL=沿用全局',
`role_types` VARCHAR(16) NULL COMMENT 'NULL=沿用全局',
`sort_no` INT NULL COMMENT 'NULL=沿用全局',
`alias` VARCHAR(64) NULL COMMENT '本校别名,NULL=沿用全局 name',
`update_time` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_widget_sch` (`widget_id`, `sch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = '小组件校级覆盖(一键清除=删行)';
-- 存量 3 条内置目录行的 scope_type / role_types / role_alias 保持默认(0 / NULL / NULL)即「全部学校 + 全角色」。
-- 回滚:
-- ALTER TABLE `widget_catalog` DROP COLUMN `scope_type`, DROP COLUMN `role_types`, DROP COLUMN `role_alias`;
-- DROP TABLE IF EXISTS `widget_catalog_school`;
-- DROP TABLE IF EXISTS `widget_school_setting`;
@@ -0,0 +1,32 @@
-- ============================================================
-- 小组件外跳配置 v3(「查看概览」等外跳入口)
-- dev 由 JPA ddl-auto=update 依实体自动加列;本文件供关闭 ddl-auto 的测试/生产环境手工执行一次。
-- 幂等性:MySQL 8 的 ALTER ADD COLUMN 无 IF NOT EXISTS,重复执行报 1060 Duplicate column
-- name,属预期,按需手动跳过即可。
-- ============================================================
-- ---------- 1. widget_catalog 加 2 列(全局默认值) ----------
ALTER TABLE `widget_catalog`
ADD COLUMN `link_app_code` VARCHAR(32) NULL
COMMENT '外跳目标子应用:应用注册表 app_code(UserBase /oapi/app/by-code);NULL=不显示外跳入口',
ADD COLUMN `link_path` VARCHAR(128) NULL
COMMENT '外跳路由(hash 路由,如 /weekplan);地址=注册表 pc_url 归一化 + "#" + 本路径';
-- ---------- 2. widget_school_setting 加 2 列(校级覆盖,NULL=沿用全局) ----------
ALTER TABLE `widget_school_setting`
ADD COLUMN `link_app_code` VARCHAR(32) NULL COMMENT 'NULL=沿用全局 link_app_code',
ADD COLUMN `link_path` VARCHAR(128) NULL COMMENT 'NULL=沿用全局 link_path';
-- 说明:
-- 1) 库里**只存「子应用 code + 路由」**,不落绝对 URL —— 域名一律来自应用注册表
-- (UserBase /oapi/app/by-code/{appCode} → pc_url),换环境/换域名只改注册表。
-- 2) 两列必须成对配置(只填一个会被服务端拒绝),都留空 = 不显示外跳入口。
-- 3) 生效口径:COALESCE(校级覆盖行, 全局值),与 alias / sort / enabled 同口径。
-- ---------- 3.(可选)给「一周安排」开箱配上外跳 ----------
-- 也可在管理台「小组件管理 → 一周安排 → 配置 → 外跳入口」里填,效果等价。
-- UPDATE `widget_catalog` SET `link_app_code` = 'work-week-plan', `link_path` = '/weekplan' WHERE `code` = 'weekplan';
-- 回滚:
-- ALTER TABLE `widget_catalog` DROP COLUMN `link_app_code`, DROP COLUMN `link_path`;
-- ALTER TABLE `widget_school_setting` DROP COLUMN `link_app_code`, DROP COLUMN `link_path`;
@@ -257,14 +257,15 @@ class AppearanceServiceTest {
assertTrue(theme.get("sideDeep").asBoolean()); assertTrue(theme.get("sideDeep").asBoolean());
assertFalse(theme.get("gradOn").asBoolean()); assertFalse(theme.get("gradOn").asBoolean());
assertEquals("silhouette", out.get("avatar")); assertEquals("silhouette", out.get("avatar"));
assertNull(out.get("logoText")); assertFalse(out.containsKey("logoText"), "logoText 已定稿摘除(子应用不消费 LOGO 文字)");
assertNull(out.get("logoUrl")); assertNull(out.get("logoUrl"));
verify(schSettingsService, never()).readThemeWithFallback(any()); verify(schSettingsService, never()).readThemeWithFallback(any());
verify(schSettingsService, never()).readAvatarWithFallback(any()); verify(schSettingsService, never()).readAvatarWithFallback(any());
verify(schSettingsService, never()).readLogoTextWithFallback(any());
} }
@Test @Test
@DisplayName("生效值:个人未设 → 学校/平台级联(theme 出 JSON 对象)+ logoText 本校→平台级联") @DisplayName("生效值:个人未设 → 学校/平台级联(theme 出 JSON 对象),出参恰三字段")
void effective_fallsToCascade() { void effective_fallsToCascade() {
stubCurrentRole(3); stubCurrentRole(3);
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty()); when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
@@ -272,8 +273,6 @@ class AppearanceServiceTest {
.thenReturn(new ISchSettingsService.LayerValue("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":false}", 0)); .thenReturn(new ISchSettingsService.LayerValue("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":false}", 0));
when(schSettingsService.readAvatarWithFallback(3)) when(schSettingsService.readAvatarWithFallback(3))
.thenReturn(new ISchSettingsService.LayerValue("text", 3)); .thenReturn(new ISchSettingsService.LayerValue("text", 3));
when(schSettingsService.readLogoTextWithFallback(3))
.thenReturn(new ISchSettingsService.LayerValue("云海教育平台", 0));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList()); when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getEffectiveAppearance(10133); Map<String, Object> out = service.getEffectiveAppearance(10133);
@@ -282,11 +281,9 @@ class AppearanceServiceTest {
assertFalse(theme.get("sideDeep").asBoolean()); assertFalse(theme.get("sideDeep").asBoolean());
assertTrue(theme.get("gradOn").asBoolean()); assertTrue(theme.get("gradOn").asBoolean());
assertEquals("text", out.get("avatar"), "本校已配直接生效(hitSchId=本校)"); assertEquals("text", out.get("avatar"), "本校已配直接生效(hitSchId=本校)");
assertEquals("云海教育平台", out.get("logoText"), "logoText 本校空→平台层"); // 出参只三字段(logoText 已摘),不外露 my/sch 拆分
// 出参只四字段,不外露 my/sch 拆分 assertEquals(3, out.size());
assertEquals(4, out.size()); assertTrue(out.containsKey("theme") && out.containsKey("avatar") && out.containsKey("logoUrl"));
assertTrue(out.containsKey("theme") && out.containsKey("avatar")
&& out.containsKey("logoText") && out.containsKey("logoUrl"));
} }
@Test @Test
@@ -301,16 +298,13 @@ class AppearanceServiceTest {
.thenReturn(new ISchSettingsService.LayerValue(null, 0)); .thenReturn(new ISchSettingsService.LayerValue(null, 0));
when(schSettingsService.readAvatarWithFallback(0)) when(schSettingsService.readAvatarWithFallback(0))
.thenReturn(new ISchSettingsService.LayerValue(null, 0)); .thenReturn(new ISchSettingsService.LayerValue(null, 0));
when(schSettingsService.readLogoTextWithFallback(0))
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList()); when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getEffectiveAppearance(10133); Map<String, Object> out = service.getEffectiveAppearance(10133);
assertNull(out.get("theme"), "脏值落级联且两级皆无 → null"); assertNull(out.get("theme"), "脏值落级联且两级皆无 → null");
assertNull(out.get("avatar")); assertNull(out.get("avatar"));
assertNull(out.get("logoText"));
assertNull(out.get("logoUrl")); assertNull(out.get("logoUrl"));
assertEquals(4, out.size()); assertEquals(3, out.size());
verify(schSettingsService).readThemeWithFallback(0); verify(schSettingsService).readThemeWithFallback(0);
verify(schSettingsService).readAvatarWithFallback(0); verify(schSettingsService).readAvatarWithFallback(0);
} }
@@ -136,7 +136,7 @@ class MsgApprovalProxyServiceTest {
assertEquals("张三", postQuery.getValue().get("operatorName")); assertEquals("张三", postQuery.getValue().get("operatorName"));
ArgumentCaptor<List<Long>> readIds = ArgumentCaptor.forClass(List.class); ArgumentCaptor<List<Long>> readIds = ArgumentCaptor.forClass(List.class);
verify(msgProxy).read(eq(201), readIds.capture()); verify(msgProxy).read(eq(201), readIds.capture(), any());
assertTrue(readIds.getValue().contains(5L), "办理成功后标记已读"); assertTrue(readIds.getValue().contains(5L), "办理成功后标记已读");
} }
@@ -180,6 +180,6 @@ class MsgApprovalProxyServiceTest {
assertFalse((Boolean) out.get("ok")); assertFalse((Boolean) out.get("ok"));
assertEquals("当前用户不是该任务办理人", out.get("message"), "业务拒绝原文透出(flow-center 逐字复核)"); assertEquals("当前用户不是该任务办理人", out.get("message"), "业务拒绝原文透出(flow-center 逐字复核)");
verify(msgProxy, never()).read(any(), any()); verify(msgProxy, never()).read(any(), any(), any());
} }
} }
@@ -5,13 +5,19 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.OpenApiException; import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.openapi.core.TargetSpec; import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.msg.model.AppMsgCount;
import com.yunhai.platform.openapi.msg.model.MsgListPage;
import com.yunhai.platform.openapi.msg.model.ReadAllMsgRequest;
import com.yunhai.platform.openapi.msg.model.UserMsgSummary;
import com.yunhai.userhome.config.MsgCenterProperties; import com.yunhai.userhome.config.MsgCenterProperties;
import com.yunhai.userhome.entity.UserCurrentRole;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -45,27 +51,56 @@ import static org.mockito.Mockito.when;
* 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时 * 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时
* 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从 * 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从
* usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。 * usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。
* <p>
* 按学校隔离(2026-10-09 批次3):消息读取(list/summary/unreadCount/countByApp/readAll)改走
* SDK msg 域类型化读方法(Tier 1,仍委托同一个 {@link YschOpenCall},故断言靶心从 JsonNode
* 换成 DTO class:{@code MsgListPage/UserMsgSummary/Long/AppMsgCount/Void}),并带
* <b>当前激活身份</b>学校口径({@link MsgCenterProxyService#resolveReadSchId},R2);
* 待办角标 todo/list 仍按主学校口径(§8 不动角标链路),两者在 summary 上是两个独立入参。
*/ */
@DisplayName("MsgCenterProxyService - YschOpenCall 代理、jumpUrl 改写与 showJump 判权") @DisplayName("MsgCenterProxyService - SDK 类型化读取、学校口径下行与 jumpUrl/showJump 改写")
class MsgCenterProxyServiceTest { class MsgCenterProxyServiceTest {
/** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */ /** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */
private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg"; private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg";
private static final ObjectMapper MAPPER = new ObjectMapper();
private YschOpenCall openCall; private YschOpenCall openCall;
private IUserCurrentRoleService userCurrentRoleService;
private MsgCenterProperties properties; private MsgCenterProperties properties;
private MsgCenterProxyService service; private MsgCenterProxyService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
openCall = mock(YschOpenCall.class); openCall = mock(YschOpenCall.class);
userCurrentRoleService = mock(IUserCurrentRoleService.class);
properties = new MsgCenterProperties(); properties = new MsgCenterProperties();
properties.setUsermsgBaseUrl(PROD_BASE_URL); properties.setUsermsgBaseUrl(PROD_BASE_URL);
service = new MsgCenterProxyService(openCall, properties); service = new MsgCenterProxyService(openCall, properties, userCurrentRoleService);
} }
private static JsonNode json(String s) throws Exception { private static JsonNode json(String s) throws Exception {
return new ObjectMapper().readTree(s); return MAPPER.readTree(s);
}
/** SDK 类型化读方法的响应靶心:同一份 JSON 夹具直接绑成 SDK DTO(契约字段与 UserMsg 出参对齐) */
private static <T> T dto(String s, Class<T> type) throws Exception {
return MAPPER.readValue(s, type);
}
private static <E> java.util.List<E> listDto(String s, Class<E> elementType) throws Exception {
return MAPPER.readValue(s, MAPPER.getTypeFactory()
.constructCollectionType(java.util.List.class, elementType));
}
/** 当前身份 stub(R2 数据源):findById(userId) 返回给定 schId 的激活身份 */
private void stubCurrentIdentity(Integer schId) {
UserCurrentRole role = new UserCurrentRole();
role.setUserId(201);
role.setRoleId(9);
role.setSchId(schId);
when(userCurrentRoleService.findById(201)).thenReturn(Optional.of(role));
} }
/** 判权 stub:/oapi/app/authorized-codes 返回给定 app_code 集合 */ /** 判权 stub:/oapi/app/authorized-codes 返回给定 app_code 集合 */
@@ -89,16 +124,124 @@ class MsgCenterProxyServiceTest {
+ "\",\"name\":\"" + name + "\"}]}]")); + "\",\"name\":\"" + name + "\"}]}]"));
} }
/** 应用字典 stub(多应用形态):合桶/双值翻译验证两桶同归一应用 */
private void stubAppDictRaw(String groupsJson) throws Exception {
when(openCall.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json(groupsJson));
}
// ===== R2:读取口径 schId 必须来自当前激活身份(严禁会话主学校) =====
@Test
void resolveReadSchId_usesCurrentIdentitySchool() {
stubCurrentIdentity(7);
assertEquals(Integer.valueOf(7), service.resolveReadSchId(201), "当前身份 schId>0 → 原值下行(本校+平台可见)");
}
@Test
void resolveReadSchId_platformIdentityZeroMeansSeeAll() {
stubCurrentIdentity(0);
assertNull(service.resolveReadSchId(201), "schId=0(平台/超管)→ null=看全部(D5)");
stubCurrentIdentity(null);
assertNull(service.resolveReadSchId(201), "身份无 schId 同样按看全部处理");
}
@Test
void resolveReadSchId_identityChainDown_seesAllWithoutThrowing() {
when(userCurrentRoleService.findById(201)).thenThrow(new OpenApiException("userbase down"));
assertNull(service.resolveReadSchId(201), "身份链不可达降级 null=改造前全量口径,铃铛不 500");
assertNull(service.resolveReadSchId(null), "无登录人不出站");
verify(userCurrentRoleService, never()).findById(null);
}
// ===== 批次3:学校口径随读端点下行(R1 的下游半程) =====
@Test
void list_andSummary_passSchIdDownstream() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
.thenReturn(dto("{\"unreadCount\":1}", UserMsgSummary.class));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
service.list(201, 1, 10, null, null, null, null, null, 7);
service.summary(201, 7, 3);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> listQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), listQuery.capture(), eq(MsgListPage.class));
assertEquals("7", listQuery.getValue().get("schId"));
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> summaryQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/summary"), summaryQuery.capture(),
eq(UserMsgSummary.class));
assertEquals("7", summaryQuery.getValue().get("schId"), "摘要未读/分类计数按当前身份学校口径");
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> todoQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), todoQuery.capture(), eq(JsonNode.class));
assertEquals("3", todoQuery.getValue().get("schId"), "待办角标沿用主学校口径(§8 链路不变)");
}
@Test
void countByApp_passesSchIdAndEnriches() throws Exception {
stubAppDict(100011, "100019", "流程中心");
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenReturn(listDto("[{\"appKey\":\"100019\",\"total\":4,\"unread\":1}]", AppMsgCount.class));
java.util.List<Map<String, Object>> rows = service.countByApp(201, 7);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), query.capture(),
eq(AppMsgCount.class));
assertEquals("7", query.getValue().get("schId"));
assertEquals(1, rows.size());
assertEquals(100011, rows.get(0).get("appId"));
assertEquals(4L, rows.get(0).get("total"));
}
@Test
void read_schIdGoesIntoBodyOnlyWhenPresent() {
ArgumentCaptor<Map<String, Object>> body = ArgumentCaptor.forClass(Map.class);
service.read(201, java.util.Collections.singletonList(7L), 7);
verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), body.capture(),
eq(JsonNode.class));
assertEquals(7, body.getValue().get("schId"), "schId 供 UserMsg 侧缓存失效键与 readState 口径(D8)");
assertEquals(201, body.getValue().get("userId"));
ArgumentCaptor<Map<String, Object>> body2 = ArgumentCaptor.forClass(Map.class);
service.read(201, java.util.Collections.singletonList(8L), null);
verify(openCall, org.mockito.Mockito.times(2))
.post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), body2.capture(), eq(JsonNode.class));
assertFalse(body2.getAllValues().get(1).containsKey("schId"), "null 不下发=平台视角(与历史调用一致)");
}
@Test
void readAll_goesThroughSdkWithSchId() {
ArgumentCaptor<ReadAllMsgRequest> body = ArgumentCaptor.forClass(ReadAllMsgRequest.class);
service.readAll(201, 7);
verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/readAll"), anyMap(), body.capture(),
eq(Void.class));
assertEquals(Integer.valueOf(201), body.getValue().getUserId());
assertEquals(Integer.valueOf(7), body.getValue().getSchId(), "全部已读按当前身份学校限定(D6)");
}
// ===== 寻址回归(160 事故)与 list 改写 =====
@Test @Test
void list_targetSpecStripsUsermsgCtx_forPerTargetSign() { void list_targetSpecStripsUsermsgCtx_forPerTargetSign() {
// 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg // 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg
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(MsgListPage.class)))
.thenReturn(null); .thenReturn(null);
service.list(201, 1, 10, null, null, null, null, null); service.list(201, 1, 10, null, 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(MsgListPage.class));
assertEquals("http://usermsg-service:10009", spec.getValue().getBaseHost()); assertEquals("http://usermsg-service:10009", spec.getValue().getBaseHost());
assertEquals("/usermsg", spec.getValue().getBasePath(), "basePath=目标 ctx,AppSignInterceptor 据此剥离后签名"); assertEquals("/usermsg", spec.getValue().getBasePath(), "basePath=目标 ctx,AppSignInterceptor 据此剥离后签名");
} }
@@ -109,29 +252,31 @@ class MsgCenterProxyServiceTest {
// 自发消息无 jumpApp → 按发送方 appKey 走 by-key 注册表(严格二选一) // 自发消息无 jumpApp → 按发送方 appKey 走 by-key 注册表(严格二选一)
stubUserbaseGet("/oapi/app/by-key/100019", stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}"); "{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":2,\"content\":[" .thenReturn(dto("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2," + "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2,"
+ "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\"," + "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\","
+ "\"bizId\":\"123\",\"sendTime\":\"2026-09-13 10:00:00\",\"isRead\":0}," + "\"bizId\":\"123\",\"sendTime\":\"2026-09-13 10:00:00\",\"isRead\":0},"
+ "{\"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}]}", MsgListPage.class));
Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE, null, null, null, null); Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE, 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(MsgListPage.class));
assertEquals("201", query.getValue().get("userId")); assertEquals("201", query.getValue().get("userId"));
assertEquals("2", query.getValue().get("pageIndex")); assertEquals("2", query.getValue().get("pageIndex"));
assertEquals("20", query.getValue().get("pageSize")); assertEquals("20", query.getValue().get("pageSize"));
assertEquals("true", query.getValue().get("onlyUnread")); assertEquals("true", query.getValue().get("onlyUnread"));
assertFalse(query.getValue().containsKey("schId"), "schId=null 不下发(向后兼容,验收 7)");
assertEquals(2, out.get("total")); assertEquals(2, out.get("total"));
java.util.List<?> rows = (java.util.List<?>) out.get("list"); java.util.List<?> rows = (java.util.List<?>) out.get("list");
Map<?, ?> first = (Map<?, ?>) rows.get(0); Map<?, ?> first = (Map<?, ?>) rows.get(0);
assertEquals("http://localhost:8088#/task/123", first.get("jumpUrl"), "注册表 pc_url + # + 裸路径"); assertEquals("http://localhost:8088#/task/123", first.get("jumpUrl"), "注册表 pc_url + # + 裸路径");
assertEquals("flow.task", first.get("bizType")); assertEquals("flow.task", first.get("bizType"));
assertEquals("2026-09-13 10:00:00", first.get("sendTime"), "sendTime 逐字段透传(SDK 侧 String 口径)");
assertEquals(Boolean.TRUE, first.get("showJump"), "无 jumpApp 不判权放行"); assertEquals(Boolean.TRUE, first.get("showJump"), "无 jumpApp 不判权放行");
assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名"); assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名");
Map<?, ?> second = (Map<?, ?>) rows.get(1); Map<?, ?> second = (Map<?, ?>) rows.get(1);
@@ -141,12 +286,12 @@ class MsgCenterProxyServiceTest {
@Test @Test
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(MsgListPage.class)))
.thenReturn(null); .thenReturn(null);
service.list(201, 0, 999, null, null, null, null, null); service.list(201, 0, 999, null, 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(MsgListPage.class));
assertEquals("1", query.getValue().get("pageIndex"), "非法页码回退 1"); assertEquals("1", query.getValue().get("pageIndex"), "非法页码回退 1");
assertEquals("100", query.getValue().get("pageSize"), "页长上限 100"); assertEquals("100", query.getValue().get("pageSize"), "页长上限 100");
assertEquals("false", query.getValue().get("onlyUnread")); assertEquals("false", query.getValue().get("onlyUnread"));
@@ -155,10 +300,11 @@ class MsgCenterProxyServiceTest {
@Test @Test
void list_appNameMappedFromAppKey() throws Exception { void list_appNameMappedFromAppKey() throws Exception {
properties.getAppNames().put("100617", "通知公告"); properties.getAppNames().put("100617", "通知公告");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\"," .thenReturn(dto("{\"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, null, null, null, null); MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, 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 命中映射补应用名");
} }
@@ -167,9 +313,9 @@ class MsgCenterProxyServiceTest {
* 应用字典(appId 翻译/行富化)2026-10-08 起每次 list 固定取一次,与查址无关。 */ * 应用字典(appId 翻译/行富化)2026-10-08 起每次 list 固定取一次,与查址无关。 */
@Test @Test
void list_noRegistrySource_jumpUrlNull() throws Exception { void list_noRegistrySource_jumpUrlNull() throws Exception {
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写"); assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写");
assertEquals(Boolean.FALSE, first.get("showJump")); assertEquals(Boolean.FALSE, first.get("showJump"));
@@ -188,10 +334,10 @@ class MsgCenterProxyServiceTest {
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\"," "{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\","
+ "\"pcUrl\":\"http://localhost:8077\"}"); + "\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("notice", "flow-center"); stubAuthorizedCodes("notice", "flow-center");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\"," + "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\","
+ "\"isRead\":0}]}")); + "\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"), assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
"归属应用 jumpApp 主查 by-code"); "归属应用 jumpApp 主查 by-code");
@@ -202,10 +348,10 @@ class MsgCenterProxyServiceTest {
/** 存量兼容:改造前落库的绝对 URL 直接透传,不再拼基址(B5;双前缀缺陷即源于二次拼接) */ /** 存量兼容:改造前落库的绝对 URL 直接透传,不再拼基址(B5;双前缀缺陷即源于二次拼接) */
@Test @Test
void list_absoluteJumpPath_passedThrough() throws Exception { void list_absoluteJumpPath_passedThrough() throws Exception {
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/detail?serid=1\"," + "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/detail?serid=1\","
+ "\"bizType\":\"flow.task\",\"isRead\":0}]}")); + "\"bizType\":\"flow.task\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"), assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"),
"绝对 URL 原样透传"); "绝对 URL 原样透传");
@@ -217,9 +363,10 @@ class MsgCenterProxyServiceTest {
void list_jumpAppAbsent_fallsBackToSendersAppKey() throws Exception { void list_jumpAppAbsent_fallsBackToSendersAppKey() throws Exception {
stubUserbaseGet("/oapi/app/by-key/100019", stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}"); "{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"appKey\":\"100019\",\"isRead\":0}]}")); + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"appKey\":\"100019\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals("http://localhost:8088#/task/9", first.get("jumpUrl"), "无归属声明回落发送方 appKey"); assertEquals("http://localhost:8088#/task/9", first.get("jumpUrl"), "无归属声明回落发送方 appKey");
assertEquals(Boolean.TRUE, first.get("showJump")); assertEquals(Boolean.TRUE, first.get("showJump"));
@@ -229,10 +376,10 @@ class MsgCenterProxyServiceTest {
@Test @Test
void list_jumpAppMiss_noAppKeyFallback_jumpUrlNull() throws Exception { void list_jumpAppMiss_noAppKeyFallback_jumpUrlNull() throws Exception {
stubUserbaseGet("/oapi/app/by-code/unknown-app", null); stubUserbaseGet("/oapi/app/by-code/unknown-app", null);
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\"," + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\","
+ "\"isRead\":0}]}")); + "\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl")); assertNull(first.get("jumpUrl"));
assertEquals(Boolean.FALSE, first.get("showJump")); assertEquals(Boolean.FALSE, first.get("showJump"));
@@ -244,9 +391,10 @@ class MsgCenterProxyServiceTest {
stubUserbaseGet("/oapi/app/by-code/notice", null); stubUserbaseGet("/oapi/app/by-code/notice", null);
stubUserbaseGet("/oapi/app/by-key/100006", stubUserbaseGet("/oapi/app/by-key/100006",
"{\"id\":100006,\"appKey\":100006,\"name\":\"成绩管理\",\"pcUrl\":\"http://localhost:8090\"}"); "{\"id\":100006,\"appKey\":100006,\"name\":\"成绩管理\",\"pcUrl\":\"http://localhost:8090\"}");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl"), "jumpApp 命中即只认注册表结果,空就是空"); assertNull(first.get("jumpUrl"), "jumpApp 命中即只认注册表结果,空就是空");
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/by-key/100006"), isNull(), eq(JsonNode.class)); verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/by-key/100006"), isNull(), eq(JsonNode.class));
@@ -258,9 +406,10 @@ class MsgCenterProxyServiceTest {
stubUserbaseGet("/oapi/app/by-code/notice", stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077/\"}"); "{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077/\"}");
stubAuthorizedCodes("notice"); stubAuthorizedCodes("notice");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"), assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
"尾斜杠归一 + 无前导斜杠路径补斜杠"); "尾斜杠归一 + 无前导斜杠路径补斜杠");
@@ -268,9 +417,9 @@ class MsgCenterProxyServiceTest {
@Test @Test
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(MsgListPage.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, null, null, null, null); Map<String, Object> out = service.list(201, 1, 10, null, 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());
} }
@@ -303,12 +452,12 @@ class MsgCenterProxyServiceTest {
@Test @Test
void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception { void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(json("3")); .thenReturn(3L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]")); .thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]"));
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, null, 1);
assertEquals(3L, out.get("unreadCount")); assertEquals(3L, out.get("unreadCount"));
assertEquals(1, ((java.util.List<?>) out.get("todos")).size()); assertEquals(1, ((java.util.List<?>) out.get("todos")).size());
// appKey 属后端签名标识不下发(0e57022 口径收尾):待办行只出 count // appKey 属后端签名标识不下发(0e57022 口径收尾):待办行只出 count
@@ -318,9 +467,9 @@ class MsgCenterProxyServiceTest {
assertEquals(2, todoRow.get("count")); assertEquals(2, todoRow.get("count"));
// 未读数不可达单独降级为 0,待办仍可用 // 未读数不可达单独降级为 0,待办仍可用
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenThrow(new OpenApiException("down")); .thenThrow(new OpenApiException("down"));
Map<String, Object> out2 = service.summary(201, 1); Map<String, Object> out2 = service.summary(201, null, 1);
assertEquals(0L, out2.get("unreadCount")); assertEquals(0L, out2.get("unreadCount"));
assertEquals(1, ((java.util.List<?>) out2.get("todos")).size()); assertEquals(1, ((java.util.List<?>) out2.get("todos")).size());
} }
@@ -330,25 +479,26 @@ class MsgCenterProxyServiceTest {
@Test @Test
void list_filtersPassedThrough() throws Exception { void list_filtersPassedThrough() throws Exception {
stubAppDict(100019, "100019", "流程中心"); stubAppDict(100019, "100019", "流程中心");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":0,\"content\":[]}")); .thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
service.list(201, 1, 10, null, 2, " 100019 ", 0, "请假"); service.list(201, 1, 10, null, 2, " 100019 ", 0, "请假", 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(MsgListPage.class));
assertEquals("2", query.getValue().get("msgType")); assertEquals("2", query.getValue().get("msgType"));
assertEquals("100019", query.getValue().get("appKey"), "appId 去空白经字典翻译为 appKey 下行"); assertEquals("100019", query.getValue().get("appKeys"), "appId 去空白经字典翻译为归属口径 appKeys 下行");
assertFalse(query.getValue().containsKey("appKey"), "归属口径取代发送方 appKey 口径");
assertEquals("0", query.getValue().get("isRead")); assertEquals("0", query.getValue().get("isRead"));
assertEquals("请假", query.getValue().get("keyword")); assertEquals("请假", query.getValue().get("keyword"));
// 全空筛选不出现新键(与历史调用逐字一致) // 全空筛选不出现新键(与历史调用逐字一致)
service.list(201, 1, 10, null, null, " ", null, ""); service.list(201, 1, 10, null, null, " ", null, "", null);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query2 = ArgumentCaptor.forClass(Map.class); ArgumentCaptor<Map<String, String>> query2 = ArgumentCaptor.forClass(Map.class);
verify(openCall, org.mockito.Mockito.times(2)) verify(openCall, org.mockito.Mockito.times(2))
.get(any(TargetSpec.class), eq("/oapi/msg/list"), query2.capture(), eq(JsonNode.class)); .get(any(TargetSpec.class), eq("/oapi/msg/list"), query2.capture(), eq(MsgListPage.class));
assertFalse(query2.getAllValues().get(1).containsKey("msgType")); assertFalse(query2.getAllValues().get(1).containsKey("msgType"));
assertFalse(query2.getAllValues().get(1).containsKey("appKey")); assertFalse(query2.getAllValues().get(1).containsKey("appKey"));
assertFalse(query2.getAllValues().get(1).containsKey("isRead")); assertFalse(query2.getAllValues().get(1).containsKey("isRead"));
@@ -359,12 +509,12 @@ class MsgCenterProxyServiceTest {
@Test @Test
void list_appIdUnknownInDict_emptyPageNoUserMsgOutbound() throws Exception { void list_appIdUnknownInDict_emptyPageNoUserMsgOutbound() throws Exception {
// 字典未 stub → /oapi/app/list 返回 null → EMPTY // 字典未 stub → /oapi/app/list 返回 null → EMPTY
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(MsgListPage.class)))
.thenReturn(json("{\"total\":0,\"content\":[]}")); .thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, null, "999999", null, null); Map<String, Object> out = service.list(201, 1, 10, null, null, "999999", 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());
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)); verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class));
} }
/** 按应用计数(0e57022):UserMsg countByApp 条目经应用字典富化出 appId/appName,总数口径; /** 按应用计数(0e57022):UserMsg countByApp 条目经应用字典富化出 appId/appName,总数口径;
@@ -372,11 +522,11 @@ class MsgCenterProxyServiceTest {
@Test @Test
void countByApp_enrichesWithRegistryDict() throws Exception { void countByApp_enrichesWithRegistryDict() throws Exception {
stubAppDict(100011, "100019", "流程中心"); stubAppDict(100011, "100019", "流程中心");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(JsonNode.class))) when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenReturn(json("[{\"appKey\":\"100019\",\"total\":12,\"unread\":3}," .thenReturn(listDto("[{\"appKey\":\"100019\",\"total\":12,\"unread\":3},"
+ "{\"appKey\":\"999999\",\"total\":5,\"unread\":1}," + "{\"appKey\":\"999999\",\"total\":5,\"unread\":1},"
+ "{\"total\":2,\"unread\":0}]")); + "{\"total\":2,\"unread\":0}]", AppMsgCount.class));
java.util.List<Map<String, Object>> rows = service.countByApp(201); java.util.List<Map<String, Object>> rows = service.countByApp(201, null);
assertEquals(2, rows.size(), "无 appKey 行跳过"); assertEquals(2, rows.size(), "无 appKey 行跳过");
Map<String, Object> first = rows.get(0); Map<String, Object> first = rows.get(0);
assertEquals(100011, first.get("appId")); assertEquals(100011, first.get("appId"));
@@ -393,61 +543,60 @@ class MsgCenterProxyServiceTest {
void countByApp_disabledOrDown_degradesEmpty() { void countByApp_disabledOrDown_degradesEmpty() {
properties.setUsermsgBaseUrl(""); properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false); when(openCall.hasTarget("usermsg")).thenReturn(false);
assertTrue(service.countByApp(201).isEmpty()); assertTrue(service.countByApp(201, null).isEmpty());
properties.setUsermsgBaseUrl(PROD_BASE_URL); properties.setUsermsgBaseUrl(PROD_BASE_URL);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(JsonNode.class))) when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenThrow(new OpenApiException("down")); .thenThrow(new OpenApiException("down"));
assertTrue(service.countByApp(201).isEmpty(), "UserMsg 不可达降级空,不抛出"); assertTrue(service.countByApp(201, null).isEmpty(), "UserMsg 不可达降级空,不抛出");
} }
/** 摘要优先走 /oapi/msg/summary(分类计数一次喂饱),且不再单查 unreadCount */ /** 摘要优先走 /oapi/msg/summary(分类计数一次喂饱),且不再单查 unreadCount */
@Test @Test
void summary_prefersSummaryEndpoint() throws Exception { void summary_prefersSummaryEndpoint() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
.thenReturn(json("{\"unreadCount\":5,\"sysUnread\":2,\"bizUnread\":1,\"approvalUnread\":2," .thenReturn(dto("{\"unreadCount\":5,\"sysUnread\":2,\"bizUnread\":1,\"approvalUnread\":2,"
+ "\"total\":9,\"readCount\":4}")); + "\"total\":9,\"readCount\":4,\"sysTotal\":3,\"bizTotal\":2,\"approvalTotal\":4}",
UserMsgSummary.class));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]")); .thenReturn(json("[]"));
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, null, 1);
assertEquals(5L, out.get("unreadCount")); assertEquals(5L, out.get("unreadCount"));
assertEquals(2L, out.get("sysUnread")); assertEquals(2L, out.get("sysUnread"));
assertEquals(1L, out.get("bizUnread")); assertEquals(1L, out.get("bizUnread"));
assertEquals(2L, out.get("approvalUnread")); assertEquals(2L, out.get("approvalUnread"));
assertEquals(9L, out.get("total")); assertEquals(9L, out.get("total"));
assertEquals(4L, out.get("readCount")); assertEquals(4L, out.get("readCount"));
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)); assertEquals(3L, out.get("sysTotal"));
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class));
} }
/** 旧版 UserMsg 无 summary 端点(404/异常)→ 降级 unreadCount 单查,分类字段缺省 */ /** 旧版 UserMsg 无 summary 端点(404/异常)→ 降级 unreadCount 单查,分类字段缺省 */
@Test @Test
void summary_legacyUserMsg_fallsBackToUnreadCount() throws Exception { void summary_legacyUserMsg_fallsBackToUnreadCount() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=404")); .thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=404"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(json("7")); .thenReturn(7L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]")); .thenReturn(json("[]"));
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, 7, 1);
assertEquals(7L, out.get("unreadCount")); assertEquals(7L, out.get("unreadCount"));
assertFalse(out.containsKey("sysUnread"), "旧端点无分类计数,字段缺省由前端降级"); assertFalse(out.containsKey("sysUnread"), "旧端点无分类计数,字段缺省由前端降级");
} @SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
@Test verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), query.capture(), eq(Long.class));
void read_sendsOwnerScopedPayload() { assertEquals("7", query.getValue().get("schId"), "降级单查同样带当前身份学校口径");
service.read(201, java.util.Collections.singletonList(7L));
verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), any(Map.class),
eq(JsonNode.class));
} }
@Test @Test
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, null, null, null, null).get("total")); assertEquals(0, service.list(201, 1, 10, null, null, null, null, null, null).get("total"));
service.read(201, java.util.Collections.singletonList(7L)); service.read(201, java.util.Collections.singletonList(7L), null);
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());
} }
@@ -457,9 +606,9 @@ class MsgCenterProxyServiceTest {
// base-url 未配但服务目录有 usermsg(平台公共配置形态):按名调用仍可用 // base-url 未配但服务目录有 usermsg(平台公共配置形态):按名调用仍可用
properties.setUsermsgBaseUrl(""); properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(true); when(openCall.hasTarget("usermsg")).thenReturn(true);
when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(json("5")); .thenReturn(5L);
Map<String, Object> out = service.summary(201, null); Map<String, Object> out = service.summary(201, null, null);
assertEquals(5L, out.get("unreadCount")); assertEquals(5L, out.get("unreadCount"));
} }
@@ -467,19 +616,19 @@ class MsgCenterProxyServiceTest {
void summary_disabled_degradesToZero() { void summary_disabled_degradesToZero() {
properties.setUsermsgBaseUrl(""); properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false); when(openCall.hasTarget("usermsg")).thenReturn(false);
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, null, 1);
assertEquals(0L, out.get("unreadCount")); assertEquals(0L, out.get("unreadCount"));
assertEquals(0, ((java.util.List<?>) out.get("todos")).size()); assertEquals(0, ((java.util.List<?>) out.get("todos")).size());
} }
@Test @Test
void summary_todoQueryIncludesSchIdOnly_whenNotNull() throws Exception { void summary_todoQueryIncludesSchIdOnly_whenNotNull() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(json("0")); .thenReturn(0L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]")); .thenReturn(json("[]"));
service.summary(201, null); service.summary(201, null, null);
service.summary(201, 3); service.summary(201, null, 3);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class); ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall, org.mockito.Mockito.times(2)) verify(openCall, org.mockito.Mockito.times(2))
@@ -491,7 +640,7 @@ class MsgCenterProxyServiceTest {
@Test @Test
void malformedBaseUrl_disabledNotCrash() { void malformedBaseUrl_disabledNotCrash() {
properties.setUsermsgBaseUrl("::::not-a-url"); properties.setUsermsgBaseUrl("::::not-a-url");
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, null, 1);
assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出"); assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出");
} }
@@ -503,10 +652,92 @@ class MsgCenterProxyServiceTest {
} }
private Map<?, ?> firstRow() throws Exception { private Map<?, ?> firstRow() throws Exception {
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null); Map<String, Object> out = service.list(201, 1, 10, null, 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);
} }
// ===== 消息归属子应用收口(2026-10-13):出参归属换源、countByApp 合桶、list 双值翻译 =====
private void stubMsgList(String contentJson) throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":" + contentJson + "}", MsgListPage.class));
}
/** 换源三态①:jumpApp 可解析 → appId/appName 指向业务方应用(页面属主优先于发送方) */
@Test
void list_jumpAppResolvable_ownershipRewrittenToBusinessApp() throws Exception {
stubAppDict(100011, "100009", "流程中心");
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\",\"appCode\":\"notice\",\"pcUrl\":\"http://localhost:8077\"}");
stubMsgList("[{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/detail?serid=59\","
+ "\"appKey\":\"100009\",\"jumpApp\":\"notice\",\"bizType\":\"flow.task\",\"isRead\":0}]");
Map<?, ?> first = firstRow();
assertEquals(100001, first.get("appId"), "归属出参跟页面属主,不跟发送方");
assertEquals("通知公告", first.get("appName"));
}
/** 换源三态②:jumpApp 脏值查无 → 回落发送方字典口径(现状对齐,appId/appName 走 byKey) */
@Test
void list_jumpAppDirty_fallsBackToSenderDictEntry() throws Exception {
stubAppDict(100011, "100009", "流程中心");
stubUserbaseGet("/oapi/app/by-code/svc_item", null);
stubMsgList("[{\"msgId\":1,\"appKey\":\"100009\",\"jumpApp\":\"svc_item\",\"bizType\":\"flow.task\",\"isRead\":0}]");
Map<?, ?> first = firstRow();
assertEquals(100011, first.get("appId"), "脏 jumpApp 回落发送方 appKey 字典");
assertEquals("流程中心", first.get("appName"));
}
/** 换源三态③:jumpApp 为空(办结/催办按拍板留流程中心)→ 不调 by-code,直接发送方口径 */
@Test
void list_jumpAppAbsent_noByCodeCall_senderOwnership() throws Exception {
stubAppDict(100011, "100009", "流程中心");
stubMsgList("[{\"msgId\":1,\"appKey\":\"100009\",\"jumpApp\":null,\"bizType\":\"flow.result\",\"isRead\":0}]");
Map<?, ?> first = firstRow();
assertEquals(100011, first.get("appId"));
assertEquals("流程中心", first.get("appName"));
verify(openCall, never()).get(eq("userbase"), contains("/oapi/app/by-code/"), isNull(), eq(JsonNode.class));
}
/** countByApp 合桶:notice 代发桶 + 100001 自发桶解析到同一注册表主键 → 并一行、计数相加 */
@Test
void countByApp_mergesCodeAndKeyBucketsIntoOneApp() throws Exception {
stubAppDict(100001, "100001", "通知公告");
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\",\"appCode\":\"notice\"}");
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenReturn(listDto("[{\"appKey\":\"notice\",\"total\":2,\"unread\":2},"
+ "{\"appKey\":\"100001\",\"total\":1,\"unread\":0},"
+ "{\"appKey\":\"100009\",\"total\":3,\"unread\":1}]", AppMsgCount.class));
java.util.List<Map<String, Object>> rows = service.countByApp(201, null);
assertEquals(2, rows.size(), "notice 与 100001 两桶合并为通知公告一类目");
Map<String, Object> notice = rows.stream().filter(r -> Integer.valueOf(100001).equals(r.get("appId")))
.findFirst().orElseThrow(IllegalStateException::new);
assertEquals("通知公告", notice.get("appName"));
assertEquals(3L, notice.get("total"));
assertEquals(2L, notice.get("unread"));
assertEquals(3L, rows.get(0).get("total"), "合并后仍按总数降序");
}
/** list 的 appId 筛选翻译改双值:app_key + app_code 下行 appKeys,不再下行 appKey */
@Test
void list_appIdFilter_translatesToDualOwnerValues() throws Exception {
stubAppDictRaw("[{\"apps\":[{\"id\":100001,\"appKey\":\"100001\",\"name\":\"通知公告\"},"
+ "{\"id\":100011,\"appKey\":\"100009\",\"name\":\"流程中心\"}]}]");
stubUserbaseGet("/oapi/app/by-key/100001", "{\"id\":100001,\"appKey\":100001,\"appCode\":\"notice\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
service.list(201, 1, 10, null, null, "100001", null, null, null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
assertEquals("100001,notice", query.getValue().get("appKeys"), "自发桶+代发桶一次筛全");
assertFalse(query.getValue().containsKey("appKey"), "归属口径取代发送方口径");
}
/** 注册表命中:pc_url 含 #/route fragment → 丢 fragment、保 index.html、拼 #,无双 hash(§3.2) */ /** 注册表命中:pc_url 含 #/route fragment → 丢 fragment、保 index.html、拼 #,无双 hash(§3.2) */
@Test @Test
void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception { void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception {
@@ -514,9 +745,10 @@ class MsgCenterProxyServiceTest {
"{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\"," "{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\","
+ "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}"); + "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}");
stubAuthorizedCodes("flow-center"); stubAuthorizedCodes("flow-center");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}")); + "\"jumpPath\":\"/task/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"), assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"),
"丢 fragment + # 拼接,不产第二个 #"); "丢 fragment + # 拼接,不产第二个 #");
@@ -531,9 +763,9 @@ class MsgCenterProxyServiceTest {
stubUserbaseGet("/oapi/app/by-code/notice", stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}"); "{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("flow-center"); stubAuthorizedCodes("flow-center");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + "\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals(Boolean.FALSE, first.get("showJump"), "无该应用权限 → 隐藏跳转入口"); assertEquals(Boolean.FALSE, first.get("showJump"), "无该应用权限 → 隐藏跳转入口");
assertNull(first.get("jumpUrl"), "无权时 jumpUrl 同步置 null(双保险)"); assertNull(first.get("jumpUrl"), "无权时 jumpUrl 同步置 null(双保险)");
@@ -544,9 +776,9 @@ class MsgCenterProxyServiceTest {
void list_jumpAppBlank_noAuthzOutbound() throws Exception { void list_jumpAppBlank_noAuthzOutbound() throws Exception {
stubUserbaseGet("/oapi/app/by-key/100019", stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}"); "{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"appKey\":\"100019\",\"isRead\":0}]}")); + "\"jumpPath\":\"/task/9\",\"appKey\":\"100019\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals(Boolean.TRUE, first.get("showJump")); assertEquals(Boolean.TRUE, first.get("showJump"));
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class)); verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class));
@@ -557,10 +789,10 @@ class MsgCenterProxyServiceTest {
void list_authzUnreachable_absoluteUrlStillPasses() throws Exception { void list_authzUnreachable_absoluteUrlStillPasses() throws Exception {
when(openCall.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class))) when(openCall.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class)))
.thenThrow(new RuntimeException("connect timeout")); .thenThrow(new RuntimeException("connect timeout"));
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(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/1\",\"jumpApp\":\"notice\"," + "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/1\",\"jumpApp\":\"notice\","
+ "\"isRead\":0}]}")); + "\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals(Boolean.TRUE, first.get("showJump"), "判权通道故障 → 放行"); assertEquals(Boolean.TRUE, first.get("showJump"), "判权通道故障 → 放行");
assertEquals("http://base.yunhaixingtong.com/notice/#/list/1", first.get("jumpUrl")); assertEquals("http://base.yunhaixingtong.com/notice/#/list/1", first.get("jumpUrl"));
@@ -572,11 +804,12 @@ class MsgCenterProxyServiceTest {
stubUserbaseGet("/oapi/app/by-code/notice", stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}"); "{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("notice"); stubAuthorizedCodes("notice");
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(MsgListPage.class)))
.thenReturn(json("{\"total\":2,\"content\":[" .thenReturn(dto("{\"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, null, null, null, null); MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, 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"));
@@ -589,11 +822,12 @@ class MsgCenterProxyServiceTest {
void list_userbaseDown_shortCircuit_relativeJumpNull() throws Exception { void list_userbaseDown_shortCircuit_relativeJumpNull() throws Exception {
when(openCall.get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class))) when(openCall.get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class)))
.thenThrow(new RuntimeException("connect timeout")); .thenThrow(new RuntimeException("connect timeout"));
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(MsgListPage.class)))
.thenReturn(json("{\"total\":2,\"content\":[" .thenReturn(dto("{\"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, null, null, null, null); MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, 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"));
@@ -0,0 +1,132 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 小组件外跳地址解析单测:冻结「注册表 pc_url → 绝对跳转地址」的拼接口径。
*
* <p>关键回归点(2026-10-15 生产实锤):注册表两种 pc_url 形态并存——
* 目录形态({@code …/work-week-plan/front/})与文件形态({@code …/notice/front/index.htm})。
* 目录形态**必须保留斜杠**({@code …/front/#/weekplan}),曾因「去尾斜杠」削成 {@code …/front#/weekplan}
* 被反代 301,且有丢 fragment 风险;文件形态直接拼 {@code #} 不能补斜杠。</p>
*/
class WidgetLinkResolverTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private OpenApiAppClient appClient;
private WidgetLinkResolver resolver;
@BeforeEach
void setUp() {
appClient = mock(OpenApiAppClient.class);
resolver = new WidgetLinkResolver(appClient);
}
private void stubPcUrl(String appCode, String pcUrl) throws Exception {
JsonNode node = pcUrl == null ? MAPPER.readTree("{}") : MAPPER.readTree("{\"pcUrl\":\"" + pcUrl + "\"}");
when(appClient.getAppByCode(eq(appCode))).thenReturn(node);
}
@Test
@DisplayName("目录形态 pc_url:尾斜杠保留,路由拼在斜杠之后")
void dirFormKeepsTrailingSlash() throws Exception {
stubPcUrl("work-week-plan", "http://base.yunhaixingtong.com/work-week-plan/front/");
assertEquals("http://base.yunhaixingtong.com/work-week-plan/front/#/weekplan",
resolver.resolve("work-week-plan", "/weekplan"));
}
@Test
@DisplayName("目录形态但注册表没写尾斜杠:按目录补回斜杠")
void dirFormWithoutSlashGetsSlash() throws Exception {
stubPcUrl("aca-classroom", "http://base.yunhaixingtong.com/aca-classroom/front");
assertEquals("http://base.yunhaixingtong.com/aca-classroom/front/#/home",
resolver.resolve("aca-classroom", "/home"));
}
@Test
@DisplayName("文件形态 pc_url(index.htm(l)):直接拼 #,不补斜杠")
void fileFormKeepsFile() throws Exception {
stubPcUrl("notice", "http://base.yunhaixingtong.com/notice/front/index.htm");
stubPcUrl("coursehomework", "http://base.yunhaixingtong.com/coursehomework/front/index.html");
assertEquals("http://base.yunhaixingtong.com/notice/front/index.htm#/detail",
resolver.resolve("notice", "/detail"));
assertEquals("http://base.yunhaixingtong.com/coursehomework/front/index.html#/list",
resolver.resolve("coursehomework", "/list"));
}
@Test
@DisplayName("注册表 pc_url 自带 fragment:先丢再按配置路由拼")
void registryFragmentIsDropped() throws Exception {
stubPcUrl("flow-center", "http://base.yunhaixingtong.com/flow-center/front/index.html#/my");
assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/weekplan",
resolver.resolve("flow-center", "/weekplan"));
}
@Test
@DisplayName("路由容错:无前导斜杠 / 带 # 前缀都归一化")
void pathIsNormalized() throws Exception {
stubPcUrl("work-week-plan", "http://base.yunhaixingtong.com/work-week-plan/front/");
assertEquals("http://base.yunhaixingtong.com/work-week-plan/front/#/weekplan",
resolver.resolve("work-week-plan", "weekplan"));
assertEquals("http://base.yunhaixingtong.com/work-week-plan/front/#/weekplan",
resolver.resolve("work-week-plan", "#/weekplan"));
}
@Test
@DisplayName("降级:未配置 / 查无 pc_url / 路由写绝对 URL 一律 null(不猜、不渲染入口)")
void degradesToNull() throws Exception {
stubPcUrl("no-pcurl", null);
assertNull(resolver.resolve(null, "/x"));
assertNull(resolver.resolve("", "/x"));
assertNull(resolver.resolve("work-week-plan", null));
assertNull(resolver.resolve("work-week-plan", " "));
assertNull(resolver.resolve("no-pcurl", "/x"));
assertNull(resolver.resolve("work-week-plan", "http://evil.example.com/x"));
}
@Test
@DisplayName("注册表不可达:返回 null 且不抛;同一 code 命中缓存只出站一次")
void registryDownAndCache() throws Exception {
when(appClient.getAppByCode(anyString())).thenThrow(new RuntimeException("boom"));
assertNull(resolver.resolve("work-week-plan", "/weekplan"));
assertNull(resolver.resolve("work-week-plan", "/weekplan"));
// 失败进短路窗口:窗口内不再出站
verify(appClient, times(1)).getAppByCode(eq("work-week-plan"));
OpenApiAppClient ok = mock(OpenApiAppClient.class);
stubPcUrlOk(ok);
WidgetLinkResolver cached = new WidgetLinkResolver(ok);
cached.resolve("work-week-plan", "/weekplan");
cached.resolve("work-week-plan", "/weekplan");
verify(ok, times(1)).getAppByCode(eq("work-week-plan"));
}
private void stubPcUrlOk(OpenApiAppClient client) throws Exception {
when(client.getAppByCode(eq("work-week-plan")))
.thenReturn(MAPPER.readTree("{\"pcUrl\":\"http://base.yunhaixingtong.com/work-week-plan/front/\"}"));
}
@Test
@DisplayName("查无 pc_url 也进缓存:脏 code 不重复出站")
void missIsCached() {
when(appClient.getAppByCode(eq("ghost"))).thenReturn(null);
assertNull(resolver.resolve("ghost", "/x"));
assertNull(resolver.resolve("ghost", "/x"));
verify(appClient, times(1)).getAppByCode(eq("ghost"));
}
}