Compare commits
21
Commits
0e57022555
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f7a2eeea | ||
|
|
d7ea119996 | ||
|
|
d2cf718b51 | ||
|
|
920408dd0f | ||
|
|
1f31612609 | ||
|
|
ad28149731 | ||
|
|
71c571f884 | ||
|
|
0099e5df2b | ||
|
|
927d4b14f8 | ||
|
|
9370429455 | ||
|
|
175dacc9ad | ||
|
|
29d32eed6e | ||
|
|
47f9e3f751 | ||
|
|
977b5c90a4 | ||
|
|
570b593293 | ||
|
|
1f352214f7 | ||
|
|
f9282574e2 | ||
|
|
8b3f8afd77 | ||
|
|
0ac521f286 | ||
|
|
54d1b848c3 | ||
|
|
c6acd5d71a |
@@ -25,7 +25,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- 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 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -55,18 +58,24 @@ jobs:
|
||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f mavenbuild 2>/dev/null || true
|
||||
docker run -d --name mavenbuild \
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 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 \
|
||||
-v /opt/docker/projman-m2:/root/.m2 \
|
||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||
docker cp "${PWD}/." mavenbuild:/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 mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
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 "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
mkdir -p "${PWD}/target"
|
||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f mavenbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
|
||||
- name: 构建并推送镜像
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.userhome.config;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 强制开放接口必须带应用签名(对齐 UserMsg/UserBase 同名配置)。<br>
|
||||
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
|
||||
* 但 UserHome 的 /oapi/**(外观偏好开放接口)绝不允许匿名访问。本过滤器在 AppAuthFilter 之后运行,
|
||||
* 校验其写入的请求属性:已验签(属性存在)则放行,否则 401。<br>
|
||||
* 顺序须晚于 AppAuthFilter(HIGHEST_PRECEDENCE+10),故置为 +11。
|
||||
*/
|
||||
@Configuration
|
||||
public class OpenApiAuthEnforceConfiguration {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
|
||||
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new RequireAppSignFilter());
|
||||
bean.addUrlPatterns("/oapi/*");
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
|
||||
return bean;
|
||||
}
|
||||
|
||||
public static class RequireAppSignFilter implements Filter {
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<appModId>/,删除压缩包 */
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.service.IAppearanceService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户外观偏好接口(CAS 登录态,路径 {@code /{platform}/},外观偏好服务端化 2026-10-08)。
|
||||
* <p>服务端真源 = user_appearance(per-user 一行)+ sch_settings + logo 表;
|
||||
* 消费方:UserHome/front 设置页双写(saveMyTheme/saveMyAvatarStyle)与各子应用
|
||||
* yh-theme-init.js 异步补齐段(GET appearance,挂载后 fetch、失败静默回落默认观感)。</p>
|
||||
* <p>三端点全部登录态、走本应用 /api/** 既有 CAS 链路,不进 oapi/enforce。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH)
|
||||
public class AppearanceController {
|
||||
|
||||
@Autowired
|
||||
private IAppearanceService appearanceService;
|
||||
|
||||
/**
|
||||
* 聚合出参:{myTheme, schTheme, avatarMy, avatarSch, logoUrl}。
|
||||
* myTheme/schTheme 为紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01};
|
||||
* avatarMy/avatarSch 为 {@code text|silhouette};logoUrl 为绝对 URL;
|
||||
* 查不到/脏值/依赖故障的字段一律 null(init 侧按"null=清键回落默认"收敛)。
|
||||
*/
|
||||
@GetMapping("appearance")
|
||||
public Map<String, Object> appearance(BoaUser authUserWithDept) {
|
||||
return appearanceService.getAppearance(authUserWithDept);
|
||||
}
|
||||
|
||||
/** 保存用户自选主题档:body {@code {"themeState":"ocean|1|0|1"}};null=跟随学校默认;非法值抛业务异常。 */
|
||||
@PostMapping("saveMyTheme")
|
||||
public void saveMyTheme(BoaUser authUserWithDept, @RequestBody Map<String, Object> body) {
|
||||
appearanceService.saveMyTheme(authUserWithDept.getId(), stringOrNull(body == null ? null : body.get("themeState")));
|
||||
}
|
||||
|
||||
/** 保存用户自选头像样式:body {@code {"avatarStyle":"text"|"silhouette"}};null=跟随学校默认。 */
|
||||
@PostMapping("saveMyAvatarStyle")
|
||||
public void saveMyAvatarStyle(BoaUser authUserWithDept, @RequestBody Map<String, Object> body) {
|
||||
appearanceService.saveMyAvatarStyle(authUserWithDept.getId(), stringOrNull(body == null ? null : body.get("avatarStyle")));
|
||||
}
|
||||
|
||||
private static String stringOrNull(Object v) {
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,11 @@ public class MessageCenterController {
|
||||
private final MsgCenterProxyService proxy;
|
||||
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) {
|
||||
if (user.getSchool() != null) {
|
||||
return user.getSchool().getId();
|
||||
@@ -40,6 +44,11 @@ public class MessageCenterController {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 消息读取口径 schId:当前激活身份所属学校(0/平台/超管与身份链不可达 → null=看全部,D5) */
|
||||
private Integer msgSchId(BoaUser authUserWithDept) {
|
||||
return proxy.resolveReadSchId(authUserWithDept.getId());
|
||||
}
|
||||
|
||||
/** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空)。
|
||||
* 可选服务端筛选:msgType 0/1/2、appId(注册表应用 id,代理翻译下行)、isRead 0/1、keyword 标题/内容模糊(不传与现状一致)。 */
|
||||
@GetMapping("msgCenter/list")
|
||||
@@ -51,19 +60,20 @@ public class MessageCenterController {
|
||||
@RequestParam(value = "appId", required = false) String appId,
|
||||
@RequestParam(value = "isRead", required = false) Integer isRead,
|
||||
@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")
|
||||
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}],总数含已读 */
|
||||
@GetMapping("msgCenter/countByApp")
|
||||
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;
|
||||
}
|
||||
|
||||
/** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪) */
|
||||
/** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪;schId 只用于缓存失效键与 readState 口径) */
|
||||
@PostMapping("msgCenter/read")
|
||||
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")
|
||||
public void readAll(BoaUser authUserWithDept) {
|
||||
proxy.readAll(authUserWithDept.getId());
|
||||
proxy.readAll(authUserWithDept.getId(), msgSchId(authUserWithDept));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,8 @@ import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.service.ISchSettingsService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.utils.OtherUtil;
|
||||
import com.yunhai.userhome.utils.ThemeCodec;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -26,52 +26,31 @@ import java.util.Optional;
|
||||
* <p>getSchSettings 查本地 sch_settings 表(不再依赖跨仓 schConfig);
|
||||
* saveSchTheme/saveSchLogo 写本地 sch_settings 表。</p>
|
||||
* <p>主题设置轮(2026-09-30)新增学校默认主题档三接口(契约见 YschTheme《主题设置与通道改动-执行方案》§3.5):
|
||||
* schTheme 读(已登录可读,可空不兜底——与 getSchSettings 的兜底语义刻意区分);
|
||||
* schTheme 读(已登录可读,当时"可空不兜底"——S7 起改为学校→平台级联,见文末说明);
|
||||
* saveSchThemeState 写(仅学校管理员/超管);clearSchTheme 清空(置空 theme 字段——sch_settings 行还承载
|
||||
* logo 等字段不能删行,置空后读侧返回 null,效果等同"未配置")。存储形态:theme_name 列存紧凑四段编码
|
||||
* {@code theme|sideDeep01|gradOn01|sideFollow01}(如 teal|0|1|1;不拆列、零 DDL,列宽未确认故不存整份 JSON;
|
||||
* logo 等字段不能删行,置空后读侧返回 null,效果等同"未配置")。存储形态(2026-10-09 JSON 化):
|
||||
* theme_name 列存规范化 ThemeState JSON 串(固定字段序;历史紧凑四段编码读侧兼容自动转 JSON;
|
||||
* 旧格式值/非法值读侧一律视同未配置)。</p>
|
||||
* <p>默认头像轮(2026-09-30)同构新增学校默认头像样式三接口:schAvatarStyle 读(可空不兜底);
|
||||
* saveSchAvatarStyle 写(仅学校管理员/超管,值只接受 text|silhouette);clearSchAvatarStyle 清空(置空
|
||||
* avatar_style 字段)。</p>
|
||||
* <p>外观偏好轮(2026-10-08):编解码下沉 {@link ThemeCodec}、schId 解析下沉
|
||||
* {@link ISchSettingsService#resolveThemeSchId}(行为等价重构,供 AppearanceController 共享)。</p>
|
||||
* <p>三级重构 S7(2026-10-08):{@code schTheme}/{@code schAvatarStyle} 读侧加"学校 → 平台默认(schId=0)"级联
|
||||
* (实现已随 oapi 代理轮下沉 {@link ISchSettingsService#readThemeWithFallback}/{@link ISchSettingsService#readAvatarWithFallback})
|
||||
* ——本校未配置或脏值时回落平台档,平台也无配置才返回 null。写侧与接口签名、响应体结构均未变;
|
||||
* {@code getSchSettings}/{@code findBySchId} 的"合成默认对象"兜底语义也未变。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH)
|
||||
public class SchSettingsController {
|
||||
|
||||
/** 合法档位(与通道 THEME_NAMES 一致;防旧库脏值意外生效) */
|
||||
private static final String[] THEME_KEYS = {"blue", "ocean", "ink", "violet", "teal"};
|
||||
|
||||
/** 合法头像样式(与前端 avatar.ts 的 AvatarStyle 一致;写入侧从严,对齐 encodeThemeState 做法) */
|
||||
private static final String[] AVATAR_STYLES = {"text", "silhouette"};
|
||||
|
||||
@Autowired
|
||||
private ISchSettingsService schSettingsService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
private Integer resolveSchId(BoaUser user) {
|
||||
if (user.getSchool() != null) {
|
||||
return user.getSchool().getId();
|
||||
}
|
||||
if (user.getBureau() != null) {
|
||||
return user.getBureau().getId();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 主题档的目标学校:超管=显式 schId 或 0(平台默认层);其余=当前角色学校(兜底主归属) */
|
||||
private Integer resolveThemeSchId(BoaUser user, Integer explicitSchId) {
|
||||
if (user.getType() != null && user.getType() == 1) {
|
||||
return explicitSchId == null ? 0 : explicitSchId;
|
||||
}
|
||||
return userCurrentRoleService.findById(user.getId())
|
||||
.map(UserCurrentRole::getSchId)
|
||||
.filter(schId -> schId != null && schId > 0)
|
||||
.orElseGet(() -> resolveSchId(user));
|
||||
}
|
||||
|
||||
/** 主题档写权限:超管或学校管理员(先例:UserSettingsServiceImpl.findSchWallpaperManageList) */
|
||||
private void requireThemeAdmin(BoaUser user) {
|
||||
boolean superAdmin = user.getType() != null && user.getType() == 1;
|
||||
@@ -83,68 +62,16 @@ public class SchSettingsController {
|
||||
}
|
||||
}
|
||||
|
||||
/** 紧凑编码 → ThemeState JSON;非本编码形态(旧档名/脏值/字段非法)一律 null(视同未配置) */
|
||||
private static String decodeThemeState(String encoded) {
|
||||
if (!StringUtils.hasText(encoded)) return null;
|
||||
String[] parts = encoded.trim().split("\\|");
|
||||
if (parts.length != 4) return null;
|
||||
if (!isThemeKey(parts[0])) return null;
|
||||
for (int i = 1; i < 4; i++) {
|
||||
if (parts[i].length() != 1 || (parts[i].charAt(0) != '0' && parts[i].charAt(0) != '1')) return null;
|
||||
}
|
||||
return "{\"theme\":\"" + parts[0] + "\",\"sideDeep\":" + parts[1]
|
||||
+ ",\"gradOn\":" + parts[2] + ",\"sideFollow\":" + parts[3] + "}";
|
||||
}
|
||||
|
||||
/** ThemeState JSON 校验(四字段齐全、档名合法)+ → 紧凑编码;非法抛业务异常(写入侧从严) */
|
||||
private static String encodeThemeState(String themeStateJson) {
|
||||
if (!StringUtils.hasText(themeStateJson)) throw new DException("主题档不能为空");
|
||||
com.fasterxml.jackson.databind.JsonNode n;
|
||||
try {
|
||||
n = new com.fasterxml.jackson.databind.ObjectMapper().readTree(themeStateJson);
|
||||
} catch (Exception e) {
|
||||
throw new DException("主题档格式不合法");
|
||||
}
|
||||
if (n == null || !n.isObject() || !n.hasNonNull("theme") || !n.has("sideDeep") || !n.has("gradOn") || !n.has("sideFollow")) {
|
||||
throw new DException("主题档格式不合法");
|
||||
}
|
||||
String theme = n.get("theme").asText();
|
||||
if (!isThemeKey(theme)) throw new DException("主题档名不合法: " + theme);
|
||||
return theme + "|" + (n.get("sideDeep").asBoolean() ? 1 : 0)
|
||||
+ "|" + (n.get("gradOn").asBoolean() ? 1 : 0)
|
||||
+ "|" + (n.get("sideFollow").asBoolean() ? 1 : 0);
|
||||
}
|
||||
|
||||
private static boolean isThemeKey(String theme) {
|
||||
if (theme == null) return false;
|
||||
for (String k : THEME_KEYS) {
|
||||
if (k.equals(theme)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isAvatarStyle(String style) {
|
||||
if (style == null) return false;
|
||||
for (String k : AVATAR_STYLES) {
|
||||
if (k.equals(style)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校默认头像样式(默认头像轮 2026-09-30,已登录可读):{avatarStyle: "text"|"silhouette"|null, schId}。
|
||||
* 未配置/脏值 → avatarStyle=null(读侧可空不兜底,对齐 schTheme 语义;前端"不写键、不报错")。
|
||||
*/
|
||||
/** 学校默认头像样式(默认头像轮 2026-09-30,已登录可读):{avatarStyle: "text"|"silhouette"|null, schId}。
|
||||
* 未配置/脏值 → 回落平台默认层(级联见 {@link ISchSettingsService#readAvatarWithFallback});两级都无 →
|
||||
* avatarStyle=null(前端"不写键、不报错")。 */
|
||||
@GetMapping("schAvatarStyle")
|
||||
public Map<String, Object> schAvatarStyle(BoaUser authUserWithDept) {
|
||||
Integer schId = resolveThemeSchId(authUserWithDept, null);
|
||||
String avatarStyle = schSettingsService.findBySchIdRaw(schId)
|
||||
.map(SchSettings::getAvatarStyle)
|
||||
.filter(SchSettingsController::isAvatarStyle)
|
||||
.orElse(null);
|
||||
ISchSettingsService.LayerValue hit =
|
||||
schSettingsService.readAvatarWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("avatarStyle", avatarStyle);
|
||||
out.put("schId", schId);
|
||||
out.put("avatarStyle", hit.getValue());
|
||||
out.put("schId", hit.getHitSchId());
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -154,10 +81,10 @@ public class SchSettingsController {
|
||||
@RequestParam("avatarStyle") String avatarStyle,
|
||||
@RequestParam(name = "schId", required = false) Integer schId) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
if (!isAvatarStyle(avatarStyle)) {
|
||||
if (!ThemeCodec.isAvatarStyle(avatarStyle)) {
|
||||
throw new DException("头像样式不合法: " + avatarStyle);
|
||||
}
|
||||
Integer targetSchId = resolveThemeSchId(authUserWithDept, schId);
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
|
||||
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(targetSchId);
|
||||
@@ -166,12 +93,12 @@ public class SchSettingsController {
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
/** 清空学校默认头像样式(仅管理员):置空 avatar_style 字段(行还承载 logo/theme 等配置不能删行,置空后读侧返回 null)。 */
|
||||
/** 清空学校默认头像样式(仅管理员):置空 avatar_style 字段(行还承载 logo/theme 等配置不能删行;置空后读侧回落平台层,平台也无则 null)。 */
|
||||
@PostMapping("clearSchAvatarStyle")
|
||||
public void clearSchAvatarStyle(BoaUser authUserWithDept,
|
||||
@RequestParam(name = "schId", required = false) Integer schId) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
Integer targetSchId = resolveThemeSchId(authUserWithDept, schId);
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
|
||||
Optional<SchSettings> existing = schSettingsService.findBySchIdRaw(targetSchId);
|
||||
if (!existing.isPresent()) return;
|
||||
SchSettings schSettings = existing.get();
|
||||
@@ -179,31 +106,29 @@ public class SchSettingsController {
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null, schId}。
|
||||
* 未配置/旧格式值/脏数据 → themeState=null(前端"不写键、不报错")。
|
||||
/** 学校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null, schId};schId 为实际命中层
|
||||
* (见 {@link ISchSettingsService.LayerValue})。本校未配置/脏值 → 回落平台默认层
|
||||
* (级联见 {@link ISchSettingsService#readThemeWithFallback});两级都无 → themeState=null
|
||||
* (前端"不写键、不报错")。
|
||||
*/
|
||||
@GetMapping("schTheme")
|
||||
public Map<String, Object> schTheme(BoaUser authUserWithDept) {
|
||||
Integer schId = resolveThemeSchId(authUserWithDept, null);
|
||||
String themeState = schSettingsService.findBySchIdRaw(schId)
|
||||
.map(SchSettings::getThemeName)
|
||||
.map(SchSettingsController::decodeThemeState)
|
||||
.orElse(null);
|
||||
ISchSettingsService.LayerValue hit =
|
||||
schSettingsService.readThemeWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("themeState", themeState);
|
||||
out.put("schId", schId);
|
||||
out.put("themeState", hit.getValue());
|
||||
out.put("schId", hit.getHitSchId());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 保存学校默认主题档(仅学校管理员/超管):themeState 为整份 ThemeState JSON,服务端转紧凑编码存 theme_name。 */
|
||||
/** 保存学校默认主题档(仅学校管理员/超管):themeState 为整份 ThemeState JSON,规范化后原形态存 theme_name(2026-10-09 JSON 化)。 */
|
||||
@PostMapping("saveSchThemeState")
|
||||
public void saveSchThemeState(BoaUser authUserWithDept,
|
||||
@RequestParam("themeState") String themeState,
|
||||
@RequestParam(name = "schId", required = false) Integer schId) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
Integer targetSchId = resolveThemeSchId(authUserWithDept, schId);
|
||||
String encoded = encodeThemeState(themeState);
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
|
||||
String encoded = ThemeCodec.encodeThemeState(themeState);
|
||||
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(targetSchId);
|
||||
@@ -217,7 +142,7 @@ public class SchSettingsController {
|
||||
public void clearSchTheme(BoaUser authUserWithDept,
|
||||
@RequestParam(name = "schId", required = false) Integer schId) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
Integer targetSchId = resolveThemeSchId(authUserWithDept, schId);
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
|
||||
Optional<SchSettings> existing = schSettingsService.findBySchIdRaw(targetSchId);
|
||||
if (!existing.isPresent()) return;
|
||||
SchSettings schSettings = existing.get();
|
||||
@@ -225,10 +150,73 @@ public class SchSettingsController {
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
// ===== LOGO 文字(平台/学校名称,LOGO 文字轮 2026-10-09):与主题三件套同模式 =====
|
||||
|
||||
/** LOGO 文字读(已登录可读):{logoText: string|null, schId=实际命中层}。本校空 → 平台层 → null。 */
|
||||
@GetMapping("schLogoText")
|
||||
public Map<String, Object> schLogoText(BoaUser authUserWithDept) {
|
||||
ISchSettingsService.LayerValue hit =
|
||||
schSettingsService.readLogoTextWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("logoText", hit.getValue());
|
||||
out.put("schId", hit.getHitSchId());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 保存 LOGO 文字(仅学校管理员/超管):trim 后落本级(超管=平台层 0,校管=本校);空串存 null(=清空)。 */
|
||||
@PostMapping("saveSchLogoText")
|
||||
public void saveSchLogoText(BoaUser authUserWithDept,
|
||||
@RequestParam("logoText") String logoText) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
String text = logoText == null ? "" : logoText.trim();
|
||||
if (text.length() > 64) {
|
||||
throw new DException("LOGO 文字不能超过 64 个字符");
|
||||
}
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, null);
|
||||
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(targetSchId);
|
||||
schSettings.setCId(authUserWithDept.getId());
|
||||
schSettings.setLogoText(text.isEmpty() ? null : text);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
// ===== 默认壁纸(平台/学校两级,壁纸三级默认轮 2026-10-09):与 LOGO 文字同模式 =====
|
||||
|
||||
/** 默认壁纸读(已登录可读):{wallpaperUrl: string|null, schId=实际命中层}。本校空 → 平台层 → null。 */
|
||||
@GetMapping("schWallpaper")
|
||||
public Map<String, Object> schWallpaper(BoaUser authUserWithDept) {
|
||||
ISchSettingsService.LayerValue hit =
|
||||
schSettingsService.readWallpaperWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("wallpaperUrl", hit.getValue());
|
||||
out.put("schId", hit.getHitSchId());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 保存默认壁纸(仅学校管理员/超管):trim 后落本级(超管=平台层 0,校管=本校);空串存 null(=清空)。
|
||||
* URL 为管理池返回的绝对地址原样存(与 user_settings.wallpaper_url 同口径),≤255。 */
|
||||
@PostMapping("saveSchWallpaper")
|
||||
public void saveSchWallpaper(BoaUser authUserWithDept,
|
||||
@RequestParam("wallpaperUrl") String wallpaperUrl) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
String url = wallpaperUrl == null ? "" : wallpaperUrl.trim();
|
||||
if (url.length() > 255) {
|
||||
throw new DException("壁纸地址不能超过 255 个字符");
|
||||
}
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, null);
|
||||
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(targetSchId);
|
||||
schSettings.setCId(authUserWithDept.getId());
|
||||
schSettings.setWallpaperUrl(url.isEmpty() ? null : url);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
@PostMapping("saveSchTheme")
|
||||
public void saveSchTheme(BoaUser authUserWithDept, String themeName, @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
|
||||
if (schId == null || schId == 0) {
|
||||
schId = resolveSchId(authUserWithDept);
|
||||
schId = schSettingsService.resolveSchId(authUserWithDept);
|
||||
}
|
||||
SchSettings schSettings = new SchSettings();
|
||||
Optional<SchSettings> model = schSettingsService.findBySchId(schId);
|
||||
@@ -245,7 +233,7 @@ public class SchSettingsController {
|
||||
@PostMapping("saveSchLogo")
|
||||
public void saveSchLogo(BoaUser authUserWithDept, String logoUrl, @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
|
||||
if (schId == null || schId == 0) {
|
||||
schId = resolveSchId(authUserWithDept);
|
||||
schId = schSettingsService.resolveSchId(authUserWithDept);
|
||||
}
|
||||
SchSettings schSettings = newSchSettingsOrLoad(schId, authUserWithDept);
|
||||
schSettings.setLogoUrl(logoUrl);
|
||||
@@ -257,7 +245,7 @@ public class SchSettingsController {
|
||||
public Optional<SchSettings> getSchSettings(BoaUser authUserWithDept,
|
||||
@RequestParam(name = "schId", defaultValue = "0") Integer schId) {
|
||||
if (schId == null || schId == 0) {
|
||||
schId = resolveSchId(authUserWithDept);
|
||||
schId = schSettingsService.resolveSchId(authUserWithDept);
|
||||
}
|
||||
return schSettingsService.findBySchId(schId);
|
||||
}
|
||||
|
||||
@@ -78,13 +78,13 @@ public class UserController {
|
||||
* 当前用户可访问的应用全集(跨仓 UserBase 实时拉取,按应用类型分组)。
|
||||
* 前端据此:渲染分类 tab(含「全部」)+ 图标网格;常用区由 getUsedApp 独立提供。
|
||||
*
|
||||
* @param schId 兼容旧前端入参(yh-platform getUserApp 当前按 userId 全局权限过滤,schId 暂不参与)
|
||||
* @param schId 学校上下文:透传 UserBase 按该校单点开通集合收窄(为空则由服务端按当前身份/主归属学校兜底)
|
||||
* @param userId 指定用户(缺省用登录用户)
|
||||
*/
|
||||
@GetMapping("GetUserApp")
|
||||
public JsonNode getUserApp(BoaUser authUserWithDept, Integer schId,
|
||||
@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;
|
||||
}
|
||||
|
||||
/** 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。 */
|
||||
/**
|
||||
* 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。<br>
|
||||
* schId 透传(可选):与 GetUserApp 同口径按学校收窄全集,避免「列表已收窄、单个查询仍全集」的不一致;
|
||||
* 为空则由 UserBase 按当前身份/主归属学校兜底。
|
||||
*/
|
||||
@GetMapping("GetAppInfoById")
|
||||
public BoaApp getAppInfoById(BoaUser authUserWithDept, @RequestParam(name = "id", required = false) Integer id) {
|
||||
return appRemoteService.getAppById(authUserWithDept, id).orElse(null);
|
||||
public BoaApp getAppInfoById(BoaUser authUserWithDept, Integer schId,
|
||||
@RequestParam(name = "id", required = false) Integer id) {
|
||||
return appRemoteService.getAppById(authUserWithDept, id, schId).orElse(null);
|
||||
}
|
||||
|
||||
/** 当前用户角色身份列表(跨仓 UserBase,含 学校/角色/子节点)。 */
|
||||
|
||||
@@ -16,9 +16,12 @@ import com.yunhai.userhome.enums.LightAppGroup;
|
||||
import com.yunhai.userhome.enums.LinkOpenType;
|
||||
import com.yunhai.userhome.model.AppIconEntryVo;
|
||||
import com.yunhai.userhome.service.AppIconKeyService;
|
||||
import com.yunhai.userhome.service.ISchSettingsService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.service.IUserSettingsService;
|
||||
import com.yunhai.userhome.service.IWidgetCatalogService;
|
||||
import com.yunhai.userhome.service.UsedAppService;
|
||||
import com.yunhai.userhome.service.WidgetLinkResolver;
|
||||
import com.yunhai.userhome.utils.EnumUtil;
|
||||
import com.yunhai.userhome.utils.FileUtil;
|
||||
import com.yunhai.userhome.utils.OtherUtil;
|
||||
@@ -33,11 +36,6 @@ import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。
|
||||
*
|
||||
* <p>待确认接口(已写入待确认清单):
|
||||
* <ul>
|
||||
* <li>getUserSettings 中 bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH)
|
||||
@@ -52,18 +50,27 @@ public class UserSettingsController {
|
||||
@Autowired
|
||||
private IUserSettingsService userSettingsService;
|
||||
|
||||
@Autowired
|
||||
private ISchSettingsService schSettingsService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Autowired
|
||||
private UsedAppService usedAppService;
|
||||
|
||||
@Autowired
|
||||
private WidgetLinkResolver widgetLinkResolver;
|
||||
|
||||
@Autowired
|
||||
private OpenApiUserClient openApiUserClient;
|
||||
|
||||
@Autowired
|
||||
private AppIconKeyService appIconKeyService;
|
||||
|
||||
@Autowired
|
||||
private IWidgetCatalogService widgetCatalogService;
|
||||
|
||||
// ==================== 壁纸 ====================
|
||||
|
||||
/**
|
||||
@@ -282,21 +289,37 @@ public class UserSettingsController {
|
||||
public UserSettings getUserSettings(BoaUser authUserWithDept) {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
|
||||
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
|
||||
UserSettings model = new UserSettings();
|
||||
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
|
||||
// TODO 待确认:bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认
|
||||
// List<BoaApp> appPageList = bschBaseOpenApi.getPcGrzxPage(authUserWithDept.getId(), schId);
|
||||
List<BoaApp> appPageList = Collections.emptyList();
|
||||
// 候选池真源 = 自建 widget_catalog(生效启用 + 可见范围命中本校 + 可见角色命中当前角色;name 已按显示名三级链合成)
|
||||
List<WidgetCatalog> catalogList = widgetCatalogService.findEnabledForSchool(schId, roleId);
|
||||
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.put("id", boaApp.getId());
|
||||
map.put("appId", boaApp.getId());
|
||||
map.put("name", boaApp.getName());
|
||||
map.put("url", boaApp.getWebAddr());
|
||||
map.put("id", catalog.getId());
|
||||
map.put("appId", catalog.getId());
|
||||
map.put("name", catalog.getName());
|
||||
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;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
@@ -305,25 +328,34 @@ public class UserSettingsController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底壁纸(用户未设置时):本地学校壁纸第一张 → 超管全局壁纸第一张。
|
||||
* <p>学校壁纸完全本地管理(wallpaper 表 type=1),不再依赖跨仓 schConfig。</p>
|
||||
* 兜底壁纸(用户未自选时,壁纸三级默认轮 2026-10-09):本校默认(sch_settings.wallpaper_url,管理员选定)
|
||||
* → 平台默认(schId=0 行)→ 本地学校壁纸池第一张 → 平台池第一张(历史兜底,两级皆未配置时保持原体验)。
|
||||
* <p>学校壁纸完全本地管理(wallpaper 表 type=1),不再依赖跨仓 schConfig。
|
||||
* 管理员选定默认返回前校验其仍在壁纸池中——被删池视为未配置继续下落,避免兜底本身返回死 URL(2026-10-09 白板修复)。</p>
|
||||
*/
|
||||
private String resolveFallbackWallpaper(BoaUser authUserWithDept, Integer schId) {
|
||||
List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId());
|
||||
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)
|
||||
ISchSettingsService.LayerValue wallHit = schSettingsService.readWallpaperWithFallback(schId);
|
||||
if (wallHit.getValue() != null && poolUrls.contains(normalizeWallpaperUrl(wallHit.getValue()))) {
|
||||
return wallHit.getValue();
|
||||
}
|
||||
// 3/4. 历史兜底:壁纸池第一张(校 type=1 → 平台 type=0,最早上传者优先)
|
||||
if (schId != null) {
|
||||
// 1. 本地学校壁纸第一张(type=1,取最新)
|
||||
// 3. 本地学校壁纸第一张(type=1)
|
||||
Optional<Wallpaper> schWall = wallList.stream()
|
||||
.filter(w -> w.getType() != null && w.getType() == 1
|
||||
&& w.getSchId() != null && w.getSchId().equals(schId))
|
||||
.max(Comparator.comparing(Wallpaper::getId));
|
||||
.min(Comparator.comparing(Wallpaper::getId));
|
||||
if (schWall.isPresent()) {
|
||||
return FILE_WEB_PATH + schWall.get().getWallpaperUrl().replace("/upload", "");
|
||||
}
|
||||
}
|
||||
// 2. 超管全局壁纸第一张(type=0,取最新)
|
||||
// 4. 超管全局壁纸第一张(type=0)
|
||||
Optional<Wallpaper> sysWall = wallList.stream()
|
||||
.filter(w -> w.getType() != null && w.getType() == 0)
|
||||
.max(Comparator.comparing(Wallpaper::getId));
|
||||
.min(Comparator.comparing(Wallpaper::getId));
|
||||
if (sysWall.isPresent()) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.userhome.controller.oapi;
|
||||
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.service.IAppearanceService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 外观偏好开放接口(oapi 代理轮 2026-10-08):子应用后端经 open-gateway 签名直调(target=userhome),
|
||||
* 替代浏览器直连 /api/appearance + CAS iframe 引导。入站验签由 {@code OpenApiAuthEnforceConfiguration}
|
||||
* 强制(/oapi/* 无签名 401);appKey 准入归网关台账/限流管,本应用不维护 per-app 白名单。
|
||||
*
|
||||
* <p>出参 = 当前用户【生效值】{@code {theme, avatar, logoUrl}}(个人 → 学校 → 平台级联合成,
|
||||
* 层级知识留在 UserHome 内部不外露;null=未配置,消费端 init 侧清对应本地键回落默认观感;
|
||||
* logoText 已于 2026-10-09 定稿摘除——子应用不再消费 LOGO 文字,门户走 /api/schLogoText 自有链)。
|
||||
* 返回裸 payload(平台 oapi 无信封包装),消费端整体透传给浏览器。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.OPEN_API_PATH)
|
||||
public class OapiAppearanceController {
|
||||
|
||||
@Autowired
|
||||
private IAppearanceService appearanceService;
|
||||
|
||||
/** 当前用户生效外观。userId 由调用方(子应用后端)从其登录态取,服务间签名保证调用方可信。 */
|
||||
@GetMapping("appearance")
|
||||
public Map<String, Object> appearance(@RequestParam("userId") Integer userId) {
|
||||
return appearanceService.getEffectiveAppearance(userId);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -37,6 +37,15 @@ public class SchSettings {
|
||||
@Column(name = "logo_url")
|
||||
private String logoUrl;
|
||||
|
||||
/** LOGO 配套平台/学校名称文字(LOGO 图在 logo 表池,文字是单值配置随 settings;NULL=未配置回落上级) */
|
||||
@Column(name = "logo_text")
|
||||
private String logoText;
|
||||
|
||||
/** 默认壁纸(壁纸三级默认轮 2026-10-09;绝对 URL,管理员从本级壁纸池选定;NULL=未配置回落上级。
|
||||
* 个人未自选时生效链:本校默认 → 平台默认(schId=0 行)→ 壁纸池最新一张(历史兜底) */
|
||||
@Column(name = "wallpaper_url")
|
||||
private String wallpaperUrl;
|
||||
|
||||
@Column(name = "home_img_url")
|
||||
private String homeImgUrl;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 用户外观偏好(user_appearance 表,外观偏好服务端化 2026-10-08)。
|
||||
* <p>跨浏览器一致的真源,全局 per-user 一行(user_id 唯一)——与 localStorage 键同维度,
|
||||
* 刻意不挂 user_settings 的 sch/role 维度。</p>
|
||||
* <p>theme_state 存紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01}(如 {@code ocean|1|0|1});
|
||||
* avatar_style 取值 {@code text|silhouette};两列 NULL = 跟随学校默认。</p>
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "user_appearance", uniqueConstraints = {@UniqueConstraint(columnNames = "user_id", name = "uk_user")})
|
||||
public class UserAppearance {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Integer userId;
|
||||
|
||||
/** 用户自选主题档紧凑编码;NULL=跟随学校默认 */
|
||||
@Column(name = "theme_state")
|
||||
private String themeState;
|
||||
|
||||
/** 用户自选头像样式 text|silhouette;NULL=跟随学校默认 */
|
||||
@Column(name = "avatar_style")
|
||||
private String avatarStyle;
|
||||
|
||||
@Column(name = "c_date")
|
||||
private Date cDate;
|
||||
|
||||
@Column(name = "u_date")
|
||||
private Date uDate;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.UserAppearance;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface UserAppearanceRepository extends JpaRepository<UserAppearance, Integer> {
|
||||
|
||||
Optional<UserAppearance> findByUserId(Integer userId);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserSettingsRepository extends JpaRepository<UserSettings, Integer>, JpaSpecificationExecutor<UserSettings> {
|
||||
@@ -21,8 +22,10 @@ public interface UserSettingsRepository extends JpaRepository<UserSettings, Inte
|
||||
|
||||
/**
|
||||
* 删除壁纸时将被删除壁纸引用的用户设置 wallpaper_url 置空,避免裂图(调用方需处于事务中)。
|
||||
* user_settings 存展示形态(FILE_WEB_PATH 前缀 + 去 /upload),wall 表存原始形态,
|
||||
* 两种形态都需传入匹配,漏形态会留下悬空引用(读侧拿到已删壁纸的死 URL 白板)。
|
||||
*/
|
||||
@Modifying
|
||||
@Query("update UserSettings u set u.wallpaperUrl = null where u.wallpaperUrl = :url")
|
||||
int clearWallpaperUrl(@Param("url") String url);
|
||||
@Query("update UserSettings u set u.wallpaperUrl = null where u.wallpaperUrl in :urls")
|
||||
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 {
|
||||
|
||||
/** 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。 */
|
||||
/**
|
||||
* 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。
|
||||
* 不带学校上下文:由 UserBase 侧按「当前激活身份学校 → 主归属学校」兜底推导生效学校。
|
||||
*/
|
||||
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取单个应用(在已拉取的全集中查找,避免额外跨仓请求)。 */
|
||||
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,31 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户外观偏好聚合服务(外观偏好服务端化 2026-10-08):
|
||||
* 主题档/头像样式 per-user 真源(user_appearance)+ 学校默认(sch_settings)+ 学校 Logo(logo 表)。
|
||||
*/
|
||||
public interface IAppearanceService {
|
||||
|
||||
/**
|
||||
* 聚合出参:{myTheme, schTheme, avatarMy, avatarSch, logoUrl},查不到/脏值/依赖故障的字段一律 null,
|
||||
* 不抛异常(子应用异步补齐段按"缺字段=清键回落默认"消费)。
|
||||
*/
|
||||
Map<String, Object> getAppearance(BoaUser user);
|
||||
|
||||
/**
|
||||
* 当前用户生效外观(oapi 代理轮 2026-10-08):{theme, avatar, logoUrl}——主题/头像为合成后的
|
||||
* 【生效值】(个人 → 学校 → 平台级联,层级知识不外露);logoUrl 本校→平台择优。
|
||||
* 查不到/脏值/依赖故障的字段 null(init 侧清对应键回落默认观感)。
|
||||
*/
|
||||
Map<String, Object> getEffectiveAppearance(Integer userId);
|
||||
|
||||
/** 保存用户自选主题档(紧凑编码 theme|sideDeep|gradOn|sideFollow);themeState=null 表示跟随学校默认 */
|
||||
void saveMyTheme(Integer userId, String themeState);
|
||||
|
||||
/** 保存用户自选头像样式(text|silhouette);avatarStyle=null 表示跟随学校默认 */
|
||||
void saveMyAvatarStyle(Integer userId, String avatarStyle);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
|
||||
import java.util.List;
|
||||
@@ -16,4 +17,51 @@ public interface ISchSettingsService {
|
||||
Optional<SchSettings> findBySchIdRaw(Integer schId);
|
||||
|
||||
List<SchSettings> findBySchIds(List<Integer> schIds);
|
||||
|
||||
/** 主学校 → 主归属组 → 0(对齐旧 SchSettingsController.resolveSchId) */
|
||||
Integer resolveSchId(BoaUser user);
|
||||
|
||||
/** 主题/头像默认档的目标学校:超管=显式 schId 或 0(平台默认层);其余=当前角色学校(兜底主归属)。
|
||||
* 原为 SchSettingsController 私有方法,外观偏好轮(2026-10-08)下沉供 AppearanceController 共享。 */
|
||||
Integer resolveThemeSchId(BoaUser user, Integer explicitSchId);
|
||||
|
||||
/** 平台默认层在 sch_settings 中的固定 schId(与 UsedAppController/壁纸/LOGO 的 0 层同口径) */
|
||||
int PLATFORM_SCH_ID = 0;
|
||||
|
||||
/**
|
||||
* 主题档读侧级联(设置中心三级重构 S7,2026-10-08;oapi 代理轮下沉 service 供 schTheme 读端点与
|
||||
* /oapi/appearance 合成共享):本校行未配置/旧格式/脏值 → 再查平台默认层(schId=0),
|
||||
* 命中即返回平台档;两级都无 → value=null。schId 自身即平台层时不二次回落(避免自查两次)。
|
||||
*/
|
||||
LayerValue readThemeWithFallback(Integer schId);
|
||||
|
||||
/** 头像样式读侧级联:语义与 {@link #readThemeWithFallback} 同构(本校空/脏 → 平台层 → null)。 */
|
||||
LayerValue readAvatarWithFallback(Integer schId);
|
||||
|
||||
/** LOGO 文字读侧级联(LOGO 文字轮 2026-10-09):本校空 → 平台层 → null。 */
|
||||
LayerValue readLogoTextWithFallback(Integer schId);
|
||||
|
||||
/** 默认壁纸读侧级联(壁纸三级默认轮 2026-10-09):本校空 → 平台层 → null。 */
|
||||
LayerValue readWallpaperWithFallback(Integer schId);
|
||||
|
||||
/** 级联读命中结果:{@code value}=命中的档位值(两级都无则 null),{@code hitSchId}=实际命中层的 schId
|
||||
* (回落发生时是 0,不是解析出的本校 schId)。设置中心读端点的响应体 schId 即此语义——
|
||||
* 前端据此区分「本校已配置」与「本校未配置、正在回落平台默认」。 */
|
||||
final class LayerValue {
|
||||
private final String value;
|
||||
private final Integer hitSchId;
|
||||
|
||||
public LayerValue(String value, Integer hitSchId) {
|
||||
this.value = value;
|
||||
this.hitSchId = hitSchId;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public Integer getHitSchId() {
|
||||
return hitSchId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 已按「校级别名 > 角色命名 > 名称」合成)
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* 显示名三级链:校级别名 > 角色专属命名 > 组件名称。
|
||||
*
|
||||
* @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, "流程中心暂不可用,请稍后重试");
|
||||
}
|
||||
|
||||
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("message", "办理成功");
|
||||
return out;
|
||||
|
||||
@@ -3,7 +3,15 @@ package com.yunhai.userhome.service;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.platform.openapi.core.TargetSpec;
|
||||
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.entity.UserCurrentRole;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -21,8 +29,11 @@ import java.util.Set;
|
||||
* (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path),
|
||||
* UserHome 前端不直连 UserMsg。
|
||||
* <ul>
|
||||
* <li>列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;</li>
|
||||
* <li>已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);</li>
|
||||
* <li>消息读取(列表/摘要/未读数/按应用计数/全部已读)走 SDK msg 域类型化读方法
|
||||
* ({@link OpenApiMsgClient}),并带「当前激活身份」所属学校口径 schId
|
||||
* ({@link #resolveReadSchId})——按学校隔离读取侧;</li>
|
||||
* <li>待办角标 todo/list(仍按主学校 schId,链路口径不变)、单条已读 read、WS 票据
|
||||
* 继续走 {@link YschOpenCall} 泛化直调;</li>
|
||||
* <li>jumpUrl 改写:绝对 URL 直通;相对路径按应用注册表取 pc_url——jumpApp(值域=app_code)
|
||||
* 主查,jumpApp 为空才按发送方 appKey 回落(严格二选一,pc_url 空就是空不猜测,
|
||||
* 手工基址表 jump-bases 已退役),归一化后拼 "#" + jumpPath;</li>
|
||||
@@ -33,7 +44,8 @@ import java.util.Set;
|
||||
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
|
||||
* 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为
|
||||
* {@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 本地表链路不动。
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -55,6 +67,7 @@ public class MsgCenterProxyService {
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final MsgCenterProperties properties;
|
||||
private final IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
/** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */
|
||||
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})。
|
||||
* 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 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.properties = properties;
|
||||
this.userCurrentRoleService = userCurrentRoleService;
|
||||
}
|
||||
|
||||
private boolean enabled() {
|
||||
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 签名剥离);否则按名走服务目录 */
|
||||
private JsonNode get(String path, Map<String, String> query) {
|
||||
String base = properties.getUsermsgBaseUrl();
|
||||
@@ -171,9 +311,10 @@ public class MsgCenterProxyService {
|
||||
* 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的绝对 jumpUrl 与 showJump 判权出参。
|
||||
* 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致):
|
||||
* 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,
|
||||
Integer msgType, String appId, Integer isRead, String keyword) {
|
||||
Integer msgType, String appId, Integer isRead, String keyword, Integer schId) {
|
||||
Map<String, Object> empty = emptyPage();
|
||||
if (!enabled()) {
|
||||
return empty;
|
||||
@@ -181,34 +322,38 @@ public class MsgCenterProxyService {
|
||||
PerRequestAuthz authz = new PerRequestAuthz(userId);
|
||||
AppDict dict = appDict(userId);
|
||||
try {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("userId", String.valueOf(userId));
|
||||
query.put("pageIndex", String.valueOf(pageIndex == null || pageIndex < 1 ? 1 : pageIndex));
|
||||
query.put("pageSize", String.valueOf(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100)));
|
||||
query.put("onlyUnread", String.valueOf(Boolean.TRUE.equals(onlyUnread)));
|
||||
if (msgType != null) {
|
||||
query.put("msgType", String.valueOf(msgType));
|
||||
}
|
||||
MsgListQuery query = new MsgListQuery();
|
||||
query.setUserId(userId);
|
||||
query.setPageIndex(pageIndex == null || pageIndex < 1 ? 1 : pageIndex);
|
||||
query.setPageSize(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100));
|
||||
query.setOnlyUnread(Boolean.TRUE.equals(onlyUnread));
|
||||
query.setMsgType(msgType);
|
||||
if (StringUtils.hasText(appId)) {
|
||||
AppDictEntry entry = dict.byId.get(Integer.valueOf(appId.trim()));
|
||||
if (entry == null) {
|
||||
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.put("isRead", String.valueOf(isRead));
|
||||
query.setAppKeys(appKeys);
|
||||
}
|
||||
putIfText(query, "keyword", keyword);
|
||||
JsonNode data = get("/oapi/msg/list", query);
|
||||
query.setIsRead(isRead);
|
||||
query.setKeyword(StringUtils.hasText(keyword) ? keyword.trim() : null);
|
||||
query.setSchId(schId);
|
||||
MsgListPage page = msgClient().list(query);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (data != null && data.path("content").isArray()) {
|
||||
for (JsonNode row : data.get("content")) {
|
||||
rows.add(rewrite(row, authz, dict));
|
||||
if (page != null && page.getContent() != null) {
|
||||
for (MsgListItem item : page.getContent()) {
|
||||
rows.add(rewrite(item, authz, dict));
|
||||
}
|
||||
}
|
||||
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);
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
@@ -220,9 +365,12 @@ public class MsgCenterProxyService {
|
||||
/**
|
||||
* 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 0/空)。
|
||||
* 分类计数(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<>();
|
||||
out.put("unreadCount", 0L);
|
||||
out.put("todos", new ArrayList<>());
|
||||
@@ -231,20 +379,18 @@ public class MsgCenterProxyService {
|
||||
}
|
||||
boolean summaryFetched = false;
|
||||
try {
|
||||
Map<String, String> q = new LinkedHashMap<>();
|
||||
q.put("userId", String.valueOf(userId));
|
||||
JsonNode vo = get("/oapi/msg/summary", q);
|
||||
if (vo != null && vo.has("unreadCount")) {
|
||||
out.put("unreadCount", vo.get("unreadCount").asLong());
|
||||
out.put("sysUnread", vo.path("sysUnread").asLong(0));
|
||||
out.put("bizUnread", vo.path("bizUnread").asLong(0));
|
||||
out.put("approvalUnread", vo.path("approvalUnread").asLong(0));
|
||||
out.put("total", vo.path("total").asLong(0));
|
||||
out.put("readCount", vo.path("readCount").asLong(0));
|
||||
// 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径;旧版 UserMsg 无该字段时缺省,前端按降级处理
|
||||
out.put("sysTotal", vo.path("sysTotal").asLong(0));
|
||||
out.put("bizTotal", vo.path("bizTotal").asLong(0));
|
||||
out.put("approvalTotal", vo.path("approvalTotal").asLong(0));
|
||||
UserMsgSummary vo = msgClient().summary(userId, msgSchId);
|
||||
if (vo != null) {
|
||||
out.put("unreadCount", vo.getUnreadCount());
|
||||
out.put("sysUnread", vo.getSysUnread());
|
||||
out.put("bizUnread", vo.getBizUnread());
|
||||
out.put("approvalUnread", vo.getApprovalUnread());
|
||||
out.put("total", vo.getTotal());
|
||||
out.put("readCount", vo.getReadCount());
|
||||
// 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径
|
||||
out.put("sysTotal", vo.getSysTotal());
|
||||
out.put("bizTotal", vo.getBizTotal());
|
||||
out.put("approvalTotal", vo.getApprovalTotal());
|
||||
summaryFetched = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -253,10 +399,7 @@ public class MsgCenterProxyService {
|
||||
}
|
||||
if (!summaryFetched) {
|
||||
try {
|
||||
Map<String, String> q = new LinkedHashMap<>();
|
||||
q.put("userId", String.valueOf(userId));
|
||||
JsonNode unread = get("/oapi/msg/unreadCount", q);
|
||||
out.put("unreadCount", unread == null || !unread.isNumber() ? 0L : unread.asLong());
|
||||
out.put("unreadCount", msgClient().unreadCount(userId, msgSchId));
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString());
|
||||
}
|
||||
@@ -264,15 +407,15 @@ public class MsgCenterProxyService {
|
||||
try {
|
||||
Map<String, String> q = new LinkedHashMap<>();
|
||||
q.put("userId", String.valueOf(userId));
|
||||
if (schId != null) {
|
||||
q.put("schId", String.valueOf(schId));
|
||||
if (todoSchId != null) {
|
||||
q.put("schId", String.valueOf(todoSchId));
|
||||
}
|
||||
JsonNode todos = get("/oapi/msg/todo/list", q);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (todos != null && todos.isArray()) {
|
||||
for (JsonNode t : todos) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("appKey", text(t, "appKey"));
|
||||
// appKey 属后端签名标识不下发(0e57022 口径收尾);待办角标前端已改走 /msgCenter/countByApp
|
||||
row.put("count", t.has("count") ? t.get("count").asInt() : 0);
|
||||
rows.add(row);
|
||||
}
|
||||
@@ -287,36 +430,65 @@ public class MsgCenterProxyService {
|
||||
/**
|
||||
* 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName:
|
||||
* [{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<>();
|
||||
if (!enabled()) {
|
||||
return empty;
|
||||
}
|
||||
try {
|
||||
Map<String, String> q = new LinkedHashMap<>();
|
||||
q.put("userId", String.valueOf(userId));
|
||||
JsonNode arr = get("/oapi/msg/countByApp", q);
|
||||
if (arr == null || !arr.isArray()) {
|
||||
List<AppMsgCount> counts = msgClient().countByApp(userId, schId);
|
||||
if (counts == null || counts.isEmpty()) {
|
||||
return empty;
|
||||
}
|
||||
AppDict dict = appDict(userId);
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (JsonNode a : arr) {
|
||||
String appKey = text(a, "appKey");
|
||||
if (appKey == null) {
|
||||
Map<Object, Map<String, Object>> merged = new LinkedHashMap<>();
|
||||
for (AppMsgCount a : counts) {
|
||||
String bucket = a == null ? null : a.getAppKey();
|
||||
if (bucket == null || bucket.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
AppDictEntry entry = dict.byKey.get(appKey);
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("appId", entry == null ? null : entry.appId);
|
||||
row.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name);
|
||||
row.put("total", a.path("total").asLong(0));
|
||||
row.put("unread", a.path("unread").asLong(0));
|
||||
out.add(row);
|
||||
AppDictEntry entry = dict.byKey.get(bucket);
|
||||
// 纯数字桶值不可能是 app_code,免一次注定查无的出站;
|
||||
// 数字脏桶(字典查无)维持现状——行保留、appId=null 由前端隐藏
|
||||
ByCodeEntry codeEntry = entry == null && !bucket.matches("\\d+")
|
||||
? lookupByCode(bucket) : null;
|
||||
Integer appId;
|
||||
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;
|
||||
} catch (Exception e) {
|
||||
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()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("msgIds", msgIds);
|
||||
body.put("userId", userId);
|
||||
if (schId != null) {
|
||||
body.put("schId", schId);
|
||||
}
|
||||
post("/oapi/msg/read", new HashMap<>(), body);
|
||||
}
|
||||
|
||||
/** 全部已读 */
|
||||
public void readAll(Integer userId) {
|
||||
/** 全部已读(按 schId 学校口径限定,D6:只标本校+平台未读,他校未读不受影响;schId null=全部) */
|
||||
public void readAll(Integer userId, Integer schId) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("userId", userId);
|
||||
post("/oapi/msg/readAll", new HashMap<>(), body);
|
||||
msgClient().readAll(userId, schId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,33 +585,56 @@ public class MsgCenterProxyService {
|
||||
|
||||
/**
|
||||
* MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。
|
||||
* 应用标识对前端只出 appId/appName(应用字典富化):appKey 属后端签名标识不下发;
|
||||
* 字典查无(授权已回收等)appId/appName 为 null,前端按类型标签降级。
|
||||
* 应用标识对前端只出 appId/appName:归属换源(页面属主优先)——jumpApp(值域=app_code)可解析时
|
||||
* 出参指向业务方应用,为空/查无/不可达回落发送方 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<>();
|
||||
out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null);
|
||||
out.put("title", text(row, "title"));
|
||||
out.put("content", text(row, "content"));
|
||||
out.put("msgType", row.has("msgType") && row.get("msgType").isNumber() ? row.get("msgType").asInt() : null);
|
||||
out.put("jumpType", row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null);
|
||||
out.put("jumpPath", text(row, "jumpPath"));
|
||||
out.put("jumpApp", text(row, "jumpApp"));
|
||||
String appKey = text(row, "appKey");
|
||||
AppDictEntry entry = appKey == null ? null : dict.byKey.get(appKey);
|
||||
out.put("appId", entry == null ? null : entry.appId);
|
||||
out.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name);
|
||||
out.put("bizType", text(row, "bizType"));
|
||||
out.put("bizId", text(row, "bizId"));
|
||||
out.put("sendTime", text(row, "sendTime"));
|
||||
out.put("isRead", row.has("isRead") && row.get("isRead").isNumber() ? row.get("isRead").asInt() : 0);
|
||||
out.put("msgId", row.getMsgId());
|
||||
out.put("title", row.getTitle());
|
||||
out.put("content", row.getContent());
|
||||
out.put("msgType", row.getMsgType());
|
||||
out.put("jumpType", row.getJumpType());
|
||||
out.put("jumpPath", row.getJumpPath());
|
||||
out.put("jumpApp", row.getJumpApp());
|
||||
// 归属换源(页面属主优先):appId/appName 出参 jumpApp(值域=app_code)非空且 byCode 查到 → 用业务方应用;
|
||||
// jumpApp 为空/查无(脏值)/UserBase 不可达 → 回落发送方 byKey(宁可显示流程中心,不抛错)。
|
||||
// 其余出参(jumpApp/jumpUrl/showJump/bizType…)不动,前端已依赖。
|
||||
String jumpApp = row.getJumpApp();
|
||||
String ownerCode = jumpApp == null ? null : jumpApp.trim();
|
||||
AppDictEntry entry = null;
|
||||
Integer appId = null;
|
||||
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);
|
||||
// showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改);
|
||||
// jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行
|
||||
Boolean allowed = null;
|
||||
String jumpApp = text(row, "jumpApp");
|
||||
if (jumpUrl != null && StringUtils.hasText(jumpApp)) {
|
||||
allowed = authz.authorized(jumpApp.trim());
|
||||
if (jumpUrl != null && StringUtils.hasText(ownerCode)) {
|
||||
allowed = authz.authorized(ownerCode);
|
||||
}
|
||||
boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed);
|
||||
if (Boolean.FALSE.equals(allowed)) {
|
||||
@@ -457,10 +656,10 @@ public class MsgCenterProxyService {
|
||||
* </ol>
|
||||
* 注册表是多端地址(pc/app/小程序/钉钉)唯一来源;jump-bases 手工基址表已退役(C2)。
|
||||
*/
|
||||
private String jumpUrl(JsonNode row) {
|
||||
Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null;
|
||||
String jumpPath = text(row, "jumpPath");
|
||||
String jumpApp = text(row, "jumpApp");
|
||||
private String jumpUrl(MsgListItem row) {
|
||||
Integer jumpType = row.getJumpType();
|
||||
String jumpPath = row.getJumpPath();
|
||||
String jumpApp = row.getJumpApp();
|
||||
if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) {
|
||||
return null;
|
||||
}
|
||||
@@ -472,7 +671,7 @@ public class MsgCenterProxyService {
|
||||
return null;
|
||||
}
|
||||
String p = path.startsWith("/") ? path : "/" + path;
|
||||
String appKey = text(row, "appKey");
|
||||
String appKey = row.getAppKey();
|
||||
String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim())
|
||||
: (StringUtils.hasText(appKey) ? pcUrlByKey(appKey.trim()) : null);
|
||||
if (!StringUtils.hasText(pcUrl)) {
|
||||
@@ -489,9 +688,14 @@ public class MsgCenterProxyService {
|
||||
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) {
|
||||
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) */
|
||||
@@ -578,13 +782,6 @@ public class MsgCenterProxyService {
|
||||
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) {
|
||||
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<=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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.Logo;
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
import com.yunhai.userhome.entity.UserAppearance;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.repository.LogoRepository;
|
||||
import com.yunhai.userhome.repository.UserAppearanceRepository;
|
||||
import com.yunhai.userhome.service.IAppearanceService;
|
||||
import com.yunhai.userhome.service.ISchSettingsService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.utils.ThemeCodec;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 用户外观偏好聚合实现(外观偏好服务端化 2026-10-08)。
|
||||
*
|
||||
* <p>消费方是各子应用 init 的异步补齐段(挂载后 fetch,失败静默回落默认观感),因此本服务
|
||||
* 对外承诺:聚合读永不抛异常——个人段/学校段各自独立降级(一段故障不影响另一段),
|
||||
* 查不到/脏值/依赖故障的字段一律 null。</p>
|
||||
*
|
||||
* <p>数据来源:myTheme/avatarMy ← user_appearance(纯本地,per-user 一行);
|
||||
* schTheme/avatarSch/logoUrl ← sch_settings + logo 表(schId 复用 resolveThemeSchId——
|
||||
* 超管=平台层 0,其余=当前角色学校;identity 跨仓查询失败时学校段整体降级 null)。
|
||||
* Logo 择优复用 UserSettingsServiceImpl.findLogoList 口径:本校 type=1 优先 → 系统 type=0 → null。</p>
|
||||
*/
|
||||
@Service
|
||||
public class AppearanceServiceImpl implements IAppearanceService {
|
||||
|
||||
@Autowired
|
||||
private UserAppearanceRepository userAppearanceRepository;
|
||||
|
||||
@Autowired
|
||||
private ISchSettingsService schSettingsService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Autowired
|
||||
private LogoRepository logoRepository;
|
||||
|
||||
@Value("${FILE_WEB_PATH:}")
|
||||
private String fileWebPath;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAppearance(BoaUser user) {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("myTheme", null);
|
||||
out.put("schTheme", null);
|
||||
out.put("avatarMy", null);
|
||||
out.put("avatarSch", null);
|
||||
out.put("logoUrl", null);
|
||||
// 个人段(纯本地查询)
|
||||
try {
|
||||
Optional<UserAppearance> ua = userAppearanceRepository.findByUserId(user.getId());
|
||||
if (ua.isPresent()) {
|
||||
out.put("myTheme", validThemeState(ua.get().getThemeState()));
|
||||
out.put("avatarMy", validAvatarStyle(ua.get().getAvatarStyle()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 降级:个人段 null,不影响学校段
|
||||
}
|
||||
// 学校段(schId 解析含 identity 跨仓调用;失败整体降级 null)
|
||||
try {
|
||||
Integer schId = schSettingsService.resolveThemeSchId(user, null);
|
||||
if (schId != null) {
|
||||
SchSettings sch = schSettingsService.findBySchIdRaw(schId).orElse(null);
|
||||
if (sch != null) {
|
||||
out.put("schTheme", validThemeState(sch.getThemeName()));
|
||||
out.put("avatarSch", validAvatarStyle(sch.getAvatarStyle()));
|
||||
}
|
||||
out.put("logoUrl", resolveLogoUrl(schId));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 降级:学校段 null(含 logo),功能不受影响
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源;2026-10-09 JSON 化;
|
||||
* 同日定稿摘除 logoText——子应用 header 定稿只出 logo 图,文字仅门户消费且走 /api/schLogoText 自有链,
|
||||
* init 侧对缺失字段按 null 语义自动清 yh-logo-text 本地键):
|
||||
* {@code {theme, avatar, logoUrl}}——theme 为【JSON 对象】(个人自选 → 学校默认 → 平台默认(schId=0)
|
||||
* 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);avatar 为生效样式;
|
||||
* logoUrl 为本校 type=1 → 平台 type=0 择优。
|
||||
*
|
||||
* <p>schId 解析走本地 user_current_role(oapi 入参只有 userId,不引 identity 跨仓):roleId 当前角色学校,
|
||||
* 无角色/跨校态回落 0(平台层)。与 /api/appearance 的 resolveThemeSchId(超管强制 0)仅在「超管临时切入
|
||||
* 某校角色」场景不同——该场景按当前角色学校取默认反而更贴近其当下观感,属登记偏差而非回归。</p>
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> getEffectiveAppearance(Integer userId) {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("theme", null);
|
||||
out.put("avatar", null);
|
||||
out.put("logoUrl", null);
|
||||
if (userId == null) {
|
||||
return out;
|
||||
}
|
||||
Integer schId = userCurrentRoleService.findById(userId)
|
||||
.map(UserCurrentRole::getSchId)
|
||||
.filter(s -> s != null && s > 0)
|
||||
.orElse(ISchSettingsService.PLATFORM_SCH_ID);
|
||||
String myTheme = null;
|
||||
String myAvatar = null;
|
||||
try {
|
||||
Optional<UserAppearance> ua = userAppearanceRepository.findByUserId(userId);
|
||||
if (ua.isPresent()) {
|
||||
myTheme = validThemeState(ua.get().getThemeState());
|
||||
myAvatar = validAvatarStyle(ua.get().getAvatarStyle());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 个人段降级:myTheme/myAvatar 保持 null,不影响学校/平台段
|
||||
}
|
||||
String theme = myTheme;
|
||||
String avatar = myAvatar;
|
||||
try {
|
||||
if (theme == null) {
|
||||
theme = schSettingsService.readThemeWithFallback(schId).getValue();
|
||||
}
|
||||
if (avatar == null) {
|
||||
avatar = schSettingsService.readAvatarWithFallback(schId).getValue();
|
||||
}
|
||||
out.put("logoUrl", resolveLogoUrl(schId));
|
||||
} catch (Exception e) {
|
||||
// 学校/平台段降级:theme/avatar/logoUrl 保持已得值或 null
|
||||
}
|
||||
// theme 出 JSON 对象(2026-10-09 契约);值已过 decodeThemeState 校验,解析失败按 null 降级
|
||||
out.put("theme", theme == null ? null : ThemeCodec.toJsonNode(theme));
|
||||
out.put("avatar", avatar);
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveMyTheme(Integer userId, String themeState) {
|
||||
UserAppearance ua = loadOrCreate(userId);
|
||||
if (themeState == null) {
|
||||
// 跟随学校默认:置 NULL(列语义见实体注释)
|
||||
ua.setThemeState(null);
|
||||
} else {
|
||||
// 写侧从严:只收 JSON、产规范化 JSON 串(紧凑入参抛 DException,2026-10-09 JSON 化契约)
|
||||
ua.setThemeState(ThemeCodec.encodeThemeState(themeState));
|
||||
}
|
||||
ua.setUDate(new Date());
|
||||
userAppearanceRepository.save(ua);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveMyAvatarStyle(Integer userId, String avatarStyle) {
|
||||
UserAppearance ua = loadOrCreate(userId);
|
||||
if (avatarStyle == null) {
|
||||
ua.setAvatarStyle(null);
|
||||
} else {
|
||||
String style = avatarStyle.trim();
|
||||
if (!ThemeCodec.isAvatarStyle(style)) {
|
||||
throw new DException("头像样式不合法: " + avatarStyle);
|
||||
}
|
||||
ua.setAvatarStyle(style);
|
||||
}
|
||||
ua.setUDate(new Date());
|
||||
userAppearanceRepository.save(ua);
|
||||
}
|
||||
|
||||
private UserAppearance loadOrCreate(Integer userId) {
|
||||
return userAppearanceRepository.findByUserId(userId).orElseGet(() -> {
|
||||
UserAppearance ua = new UserAppearance();
|
||||
ua.setUserId(userId);
|
||||
ua.setCDate(new Date());
|
||||
return ua;
|
||||
});
|
||||
}
|
||||
|
||||
/** 主题档校验读(JSON 校验→规范化;旧紧凑四段兼容转 JSON;脏值 → null 视同未配置) */
|
||||
private String validThemeState(String raw) {
|
||||
return StringUtils.hasText(raw) ? ThemeCodec.decodeThemeState(raw) : null;
|
||||
}
|
||||
|
||||
private String validAvatarStyle(String raw) {
|
||||
return ThemeCodec.isAvatarStyle(raw) ? raw : null;
|
||||
}
|
||||
|
||||
/** Logo 择优:本校(type=1)优先 → 系统(type=0) → null;URL 拼法对齐 UserSettingsController.getLogoList */
|
||||
private String resolveLogoUrl(Integer schId) {
|
||||
if (schId != null) {
|
||||
String url = firstLogoUrl(logoRepository.findBySchIdAndType(schId, 1));
|
||||
if (url != null) return url;
|
||||
}
|
||||
return firstLogoUrl(logoRepository.findBySchIdAndType(0, 0));
|
||||
}
|
||||
|
||||
private String firstLogoUrl(List<Logo> list) {
|
||||
if (list == null) return null;
|
||||
for (Logo logo : list) {
|
||||
String path = logo == null ? null : logo.getLogoUrl();
|
||||
if (!StringUtils.hasText(path)) continue;
|
||||
// 本地上传路径拼 FILE_WEB_PATH 绝对 URL;已是绝对地址的(预留跨仓存储)原样透传
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
return fileWebPath + path.replace("/upload", "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -21,19 +21,35 @@ public class OpenApiAppRemoteServiceImpl implements AppRemoteService {
|
||||
|
||||
@Override
|
||||
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) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// PC 浏览器端;UserBase 侧已按 userId 权限过滤(组开通 ∪ 角色模块所属应用,超管全量)
|
||||
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER);
|
||||
// 前端 getCurSchId 对无学校身份(超管/平台账号)下发哨兵 -1(store: schId || -1),非合法学校主键;
|
||||
// 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
|
||||
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) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return getUserApps(user).stream()
|
||||
return getUserApps(user, schoolId).stream()
|
||||
.filter(t -> t.getApps() != null)
|
||||
.flatMap(t -> t.getApps().stream())
|
||||
.filter(a -> appId.equals(a.getId()))
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.repository.SchSettingsRepository;
|
||||
import com.yunhai.userhome.service.ISchSettingsService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.utils.ThemeCodec;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -18,6 +22,9 @@ public class SchSettingsServiceImpl implements ISchSettingsService {
|
||||
@Autowired
|
||||
private SchSettingsRepository schSettingsRepository;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Override
|
||||
public void save(SchSettings schSettings) {
|
||||
schSettingsRepository.save(schSettings);
|
||||
@@ -46,4 +53,100 @@ public class SchSettingsServiceImpl implements ISchSettingsService {
|
||||
public java.util.List<SchSettings> findBySchIds(java.util.List<Integer> schId) {
|
||||
return schSettingsRepository.getSchSettingList(schId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer resolveSchId(BoaUser user) {
|
||||
if (user.getSchool() != null) {
|
||||
return user.getSchool().getId();
|
||||
}
|
||||
if (user.getBureau() != null) {
|
||||
return user.getBureau().getId();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer resolveThemeSchId(BoaUser user, Integer explicitSchId) {
|
||||
if (user.getType() != null && user.getType() == 1) {
|
||||
return explicitSchId == null ? 0 : explicitSchId;
|
||||
}
|
||||
return userCurrentRoleService.findById(user.getId())
|
||||
.map(UserCurrentRole::getSchId)
|
||||
.filter(schId -> schId != null && schId > 0)
|
||||
.orElseGet(() -> resolveSchId(user));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ISchSettingsService.LayerValue readThemeWithFallback(Integer schId) {
|
||||
String themeState = decodeTheme(schId);
|
||||
if (themeState != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
|
||||
return new ISchSettingsService.LayerValue(themeState, schId);
|
||||
}
|
||||
return new ISchSettingsService.LayerValue(decodeTheme(ISchSettingsService.PLATFORM_SCH_ID),
|
||||
ISchSettingsService.PLATFORM_SCH_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ISchSettingsService.LayerValue readAvatarWithFallback(Integer schId) {
|
||||
String avatarStyle = readAvatarAt(schId);
|
||||
if (avatarStyle != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
|
||||
return new ISchSettingsService.LayerValue(avatarStyle, schId);
|
||||
}
|
||||
return new ISchSettingsService.LayerValue(readAvatarAt(ISchSettingsService.PLATFORM_SCH_ID),
|
||||
ISchSettingsService.PLATFORM_SCH_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ISchSettingsService.LayerValue readLogoTextWithFallback(Integer schId) {
|
||||
String text = readLogoTextAt(schId);
|
||||
if (text != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
|
||||
return new ISchSettingsService.LayerValue(text, schId);
|
||||
}
|
||||
return new ISchSettingsService.LayerValue(readLogoTextAt(ISchSettingsService.PLATFORM_SCH_ID),
|
||||
ISchSettingsService.PLATFORM_SCH_ID);
|
||||
}
|
||||
|
||||
/** 原样读某层 LOGO 文字(查无行/空白 → null;trim 后存储口径) */
|
||||
private String readLogoTextAt(Integer schId) {
|
||||
return schSettingsRepository.findFirstBySchId(schId)
|
||||
.map(SchSettings::getLogoText)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ISchSettingsService.LayerValue readWallpaperWithFallback(Integer schId) {
|
||||
String url = readWallpaperAt(schId);
|
||||
if (url != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
|
||||
return new ISchSettingsService.LayerValue(url, schId);
|
||||
}
|
||||
return new ISchSettingsService.LayerValue(readWallpaperAt(ISchSettingsService.PLATFORM_SCH_ID),
|
||||
ISchSettingsService.PLATFORM_SCH_ID);
|
||||
}
|
||||
|
||||
/** 原样读某层默认壁纸(查无行/空白 → null;trim 后存储口径,URL 为管理员从池中选定的原值) */
|
||||
private String readWallpaperAt(Integer schId) {
|
||||
return schSettingsRepository.findFirstBySchId(schId)
|
||||
.map(SchSettings::getWallpaperUrl)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** 原样读某层主题档并解码(查无行/旧格式/脏值 → null) */
|
||||
private String decodeTheme(Integer schId) {
|
||||
return schSettingsRepository.findFirstBySchId(schId)
|
||||
.map(SchSettings::getThemeName)
|
||||
.map(ThemeCodec::decodeThemeState)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** 原样读某层头像样式(查无行/非法值 → null) */
|
||||
private String readAvatarAt(Integer schId) {
|
||||
return schSettingsRepository.findFirstBySchId(schId)
|
||||
.map(SchSettings::getAvatarStyle)
|
||||
.filter(ThemeCodec::isAvatarStyle)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
|
||||
@Value("${FILE_PATH:./upload/}")
|
||||
private String FILE_PATH;
|
||||
|
||||
/** 与 UserSettingsController 同源:展示形态 = 该前缀 + 去掉 /upload 的池内原始路径 */
|
||||
@Value("${FILE_WEB_PATH:}")
|
||||
private String FILE_WEB_PATH;
|
||||
|
||||
@Override
|
||||
public List<Wallpaper> findWallList(Integer userId) {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId);
|
||||
@@ -136,8 +140,11 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
|
||||
if (!(superAdmin || ownPersonal || schAdminOwn)) {
|
||||
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());
|
||||
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(校级覆盖行, 全局值);显示名 = 校级别名 > 角色专属命名 > 组件名称;
|
||||
* 可见性 = 生效启用 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.yunhai.userhome.utils;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 主题档/头像样式的编解码与校验(原 SchSettingsController 私有实现下沉,2026-10-08 外观偏好轮;
|
||||
* 2026-10-09 存储与传输全面 JSON 化)。
|
||||
* <p>存储形态:{@code {"theme":"teal","sideDeep":false,"gradOn":false,"sideFollow":false}}
|
||||
* (固定字段序的规范化 JSON 串;sch_settings.theme_name 与 user_appearance.theme_state 同形态)。
|
||||
* 历史紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01} 仅在【读侧】兼容——解码时自动转 JSON,
|
||||
* 写侧一律产出 JSON(存量行靠迁移 UPDATE 或读时懒迁移消化)。
|
||||
* 读侧宽松(脏值/不可解析一律 null 视同未配置),写侧从严(非法抛 DException)。</p>
|
||||
*/
|
||||
public final class ThemeCodec {
|
||||
|
||||
/** 合法档位(与通道 THEME_NAMES 一致;防旧库脏值意外生效) */
|
||||
public static final String[] THEME_KEYS = {"blue", "ocean", "ink", "violet", "teal"};
|
||||
|
||||
/** 合法头像样式(与前端 avatar.ts 的 AvatarStyle 一致;写入侧从严,对齐 encodeThemeState 做法) */
|
||||
public static final String[] AVATAR_STYLES = {"text", "silhouette"};
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private ThemeCodec() {
|
||||
}
|
||||
|
||||
/** 解析并校验 ThemeState JSON(四字段齐全、档名合法),合法返回【规范化 JSON 串】;非法 null */
|
||||
private static String normalizeJson(String themeStateJson) {
|
||||
try {
|
||||
JsonNode n = MAPPER.readTree(themeStateJson);
|
||||
if (n == null || !n.isObject() || !n.hasNonNull("theme") || !n.has("sideDeep")
|
||||
|| !n.has("gradOn") || !n.has("sideFollow")) {
|
||||
return null;
|
||||
}
|
||||
String theme = n.get("theme").asText();
|
||||
if (!isThemeKey(theme)) {
|
||||
return null;
|
||||
}
|
||||
return "{\"theme\":\"" + theme + "\",\"sideDeep\":" + n.get("sideDeep").asBoolean()
|
||||
+ ",\"gradOn\":" + n.get("gradOn").asBoolean()
|
||||
+ ",\"sideFollow\":" + n.get("sideFollow").asBoolean() + "}";
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧紧凑编码 theme|01|01|01 → JSON;非本形态返回 null */
|
||||
private static String fromCompact(String compact) {
|
||||
String[] p = compact.trim().split("\\|");
|
||||
if (p.length != 4 || !isThemeKey(p[0])) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 1; i < 4; i++) {
|
||||
if (p[i].length() != 1 || (p[i].charAt(0) != '0' && p[i].charAt(0) != '1')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return "{\"theme\":\"" + p[0] + "\",\"sideDeep\":" + ("1".equals(p[1]))
|
||||
+ ",\"gradOn\":" + ("1".equals(p[2]))
|
||||
+ ",\"sideFollow\":" + ("1".equals(p[3])) + "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 读侧统一入口:JSON 校验 → 规范化 JSON;旧紧凑四段 → 转 JSON;其余(旧档名/脏值)null(视同未配置)。
|
||||
* 返回值形态:ThemeState JSON 串(与 localStorage yh-theme 键形态一致)。
|
||||
*/
|
||||
public static String decodeThemeState(String raw) {
|
||||
if (!StringUtils.hasText(raw)) return null;
|
||||
String s = raw.trim();
|
||||
return s.startsWith("{") ? normalizeJson(s) : fromCompact(s);
|
||||
}
|
||||
|
||||
/** ThemeState JSON 校验(四字段齐全、档名合法)+ 规范化(固定字段序);非法抛业务异常(写入侧从严) */
|
||||
public static String encodeThemeState(String themeStateJson) {
|
||||
if (!StringUtils.hasText(themeStateJson)) throw new DException("主题档不能为空");
|
||||
String normalized = normalizeJson(themeStateJson.trim());
|
||||
if (normalized == null) {
|
||||
throw new DException("主题档格式不合法");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** ThemeState JSON 串 → 节点(oapi 出参对象形态用);入串须先过 decodeThemeState 校验 */
|
||||
public static JsonNode toJsonNode(String themeStateJson) {
|
||||
try {
|
||||
return MAPPER.readTree(themeStateJson);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isThemeKey(String theme) {
|
||||
if (theme == null) return false;
|
||||
for (String k : THEME_KEYS) {
|
||||
if (k.equals(theme)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isAvatarStyle(String style) {
|
||||
if (style == null) return false;
|
||||
for (String k : AVATAR_STYLES) {
|
||||
if (k.equals(style)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,26 @@ yh:
|
||||
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
|
||||
base-host: http://base.yunhaixingtong.com
|
||||
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,数据源见
|
||||
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
|
||||
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
|
||||
|
||||
@@ -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`;
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.Logo;
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
import com.yunhai.userhome.entity.UserAppearance;
|
||||
import com.yunhai.userhome.repository.LogoRepository;
|
||||
import com.yunhai.userhome.repository.UserAppearanceRepository;
|
||||
import com.yunhai.userhome.service.impl.AppearanceServiceImpl;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* AppearanceService 单测(外观偏好服务端化 2026-10-08):
|
||||
* 冻结 GET /api/appearance 聚合契约(myTheme/schTheme 紧凑编码、脏值与依赖故障降级 null、永不抛)、
|
||||
* upsert 置 NULL 语义(null=跟随学校默认)与 Logo 择优口径(本校 type=1 → 系统 type=0 → null)。
|
||||
*/
|
||||
@DisplayName("AppearanceService - 聚合降级与 upsert 置 NULL 语义")
|
||||
class AppearanceServiceTest {
|
||||
|
||||
private UserAppearanceRepository userAppearanceRepository;
|
||||
private ISchSettingsService schSettingsService;
|
||||
private LogoRepository logoRepository;
|
||||
private AppearanceServiceImpl service;
|
||||
private BoaUser user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userAppearanceRepository = mock(UserAppearanceRepository.class);
|
||||
schSettingsService = mock(ISchSettingsService.class);
|
||||
logoRepository = mock(LogoRepository.class);
|
||||
service = new AppearanceServiceImpl();
|
||||
ReflectionTestUtils.setField(service, "userAppearanceRepository", userAppearanceRepository);
|
||||
ReflectionTestUtils.setField(service, "schSettingsService", schSettingsService);
|
||||
ReflectionTestUtils.setField(service, "logoRepository", logoRepository);
|
||||
ReflectionTestUtils.setField(service, "fileWebPath", "http://base.yunhaixingtong.com/userhome/upload/");
|
||||
|
||||
user = new BoaUser();
|
||||
user.setId(10133);
|
||||
user.setType(0);
|
||||
when(schSettingsService.resolveThemeSchId(any(), any())).thenReturn(3);
|
||||
}
|
||||
|
||||
private static SchSettings schSettings(String themeName, String avatarStyle) {
|
||||
SchSettings s = new SchSettings();
|
||||
s.setThemeName(themeName);
|
||||
s.setAvatarStyle(avatarStyle);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static Logo logo(String url) {
|
||||
Logo l = new Logo();
|
||||
l.setLogoUrl(url);
|
||||
return l;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("聚合:个人段+学校段+Logo 全存在 → 五字段齐全(紧凑旧值读侧转 JSON、Logo 拼 FILE_WEB_PATH 绝对 URL)")
|
||||
void aggregateFull() {
|
||||
UserAppearance ua = new UserAppearance();
|
||||
ua.setThemeState("ocean|1|0|1"); // 旧紧凑值(读侧兼容自动转 JSON)
|
||||
ua.setAvatarStyle("silhouette");
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
|
||||
when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.of(
|
||||
schSettings("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}", "text")));
|
||||
when(logoRepository.findBySchIdAndType(3, 1)).thenReturn(Collections.singletonList(logo("/upload/logo/sch.png")));
|
||||
when(logoRepository.findBySchIdAndType(0, 0)).thenReturn(Collections.singletonList(logo("/upload/logo/sys.png")));
|
||||
|
||||
Map<String, Object> out = service.getAppearance(user);
|
||||
assertEquals("{\"theme\":\"ocean\",\"sideDeep\":true,\"gradOn\":false,\"sideFollow\":true}", out.get("myTheme"));
|
||||
assertEquals("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}", out.get("schTheme"));
|
||||
assertEquals("silhouette", out.get("avatarMy"));
|
||||
assertEquals("text", out.get("avatarSch"));
|
||||
assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sch.png", out.get("logoUrl"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("聚合:无任何数据 → 五字段全 null 且不抛(200 出参口径)")
|
||||
void aggregateEmpty() {
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
|
||||
when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.empty());
|
||||
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
||||
|
||||
Map<String, Object> out = service.getAppearance(user);
|
||||
for (String k : new String[]{"myTheme", "schTheme", "avatarMy", "avatarSch", "logoUrl"}) {
|
||||
assertTrue(out.containsKey(k));
|
||||
assertNull(out.get(k));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("聚合:脏值降级(theme_state 旧格式/头像样式非法 → 对应字段 null,其余字段不受影响)")
|
||||
void aggregateDirtyValues() {
|
||||
UserAppearance ua = new UserAppearance();
|
||||
ua.setThemeState("default-theme");
|
||||
ua.setAvatarStyle("circle");
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
|
||||
when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.of(schSettings("teal|0|1|1", "text")));
|
||||
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
||||
|
||||
Map<String, Object> out = service.getAppearance(user);
|
||||
assertNull(out.get("myTheme"));
|
||||
assertNull(out.get("avatarMy"));
|
||||
assertEquals("{\"theme\":\"teal\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}", out.get("schTheme"),
|
||||
"学校层紧凑旧值读侧转 JSON");
|
||||
assertEquals("text", out.get("avatarSch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("聚合:identity 跨仓故障 → 学校段(含 Logo)整体 null,个人段不受影响且不抛")
|
||||
void aggregateIdentityFailure() {
|
||||
UserAppearance ua = new UserAppearance();
|
||||
ua.setThemeState("ocean|1|0|1");
|
||||
ua.setAvatarStyle("text");
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
|
||||
when(schSettingsService.resolveThemeSchId(any(), any())).thenThrow(new RuntimeException("userbase down"));
|
||||
|
||||
Map<String, Object> out = service.getAppearance(user);
|
||||
assertEquals("{\"theme\":\"ocean\",\"sideDeep\":true,\"gradOn\":false,\"sideFollow\":true}", out.get("myTheme"),
|
||||
"存量紧凑值读侧兼容转 JSON");
|
||||
assertEquals("text", out.get("avatarMy"));
|
||||
assertNull(out.get("schTheme"));
|
||||
assertNull(out.get("avatarSch"));
|
||||
assertNull(out.get("logoUrl"));
|
||||
verify(logoRepository, never()).findBySchIdAndType(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("聚合:本校 Logo 优先于系统 Logo;本校无 → 系统兜底;系统也无 → null")
|
||||
void logoPreference() {
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
|
||||
when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.empty());
|
||||
|
||||
when(logoRepository.findBySchIdAndType(3, 1)).thenReturn(Collections.emptyList());
|
||||
when(logoRepository.findBySchIdAndType(0, 0)).thenReturn(Collections.singletonList(logo("/upload/logo/sys.png")));
|
||||
assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sys.png", service.getAppearance(user).get("logoUrl"));
|
||||
|
||||
when(logoRepository.findBySchIdAndType(3, 1)).thenReturn(Collections.singletonList(logo("/upload/logo/sch.png")));
|
||||
assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sch.png", service.getAppearance(user).get("logoUrl"));
|
||||
|
||||
when(logoRepository.findBySchIdAndType(eq(3), eq(1))).thenReturn(Arrays.asList(logo(""), logo("/upload/logo/sch.png")));
|
||||
assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sch.png", service.getAppearance(user).get("logoUrl"));
|
||||
|
||||
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
||||
assertNull(service.getAppearance(user).get("logoUrl"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("upsert:已有一行 → 更新原行;themeState=null 置 NULL(跟随学校默认)")
|
||||
void upsertExistingSetNull() {
|
||||
UserAppearance ua = new UserAppearance();
|
||||
ua.setId(7);
|
||||
ua.setUserId(10133);
|
||||
ua.setThemeState("ocean|1|0|1");
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
|
||||
|
||||
service.saveMyTheme(10133, null);
|
||||
ArgumentCaptor<UserAppearance> captor = ArgumentCaptor.forClass(UserAppearance.class);
|
||||
verify(userAppearanceRepository).save(captor.capture());
|
||||
assertEquals(7, captor.getValue().getId());
|
||||
assertNull(captor.getValue().getThemeState());
|
||||
assertNotNull(captor.getValue().getUDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("upsert:无行 → 插入新行(含 cDate)")
|
||||
void upsertInsert() {
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
|
||||
service.saveMyAvatarStyle(10133, "silhouette");
|
||||
ArgumentCaptor<UserAppearance> captor = ArgumentCaptor.forClass(UserAppearance.class);
|
||||
verify(userAppearanceRepository).save(captor.capture());
|
||||
assertEquals(10133, captor.getValue().getUserId());
|
||||
assertEquals("silhouette", captor.getValue().getAvatarStyle());
|
||||
assertNotNull(captor.getValue().getCDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("saveMyTheme:非法值抛 DException(紧凑串不再可写/坏 JSON/缺字段/非法档名);合法 JSON trim 后规范化存")
|
||||
void saveMyThemeValidation() {
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
|
||||
assertThrows(DException.class, () -> service.saveMyTheme(10133, "red|0|1|1"));
|
||||
assertThrows(DException.class, () -> service.saveMyTheme(10133, "teal|2|1|1"));
|
||||
assertThrows(DException.class, () -> service.saveMyTheme(10133, "teal|0|1|1"), "紧凑串不再可写");
|
||||
assertThrows(DException.class, () -> service.saveMyTheme(10133, "{\"theme\":\"teal\"}"));
|
||||
|
||||
service.saveMyTheme(10133, " {\"theme\":\"teal\",\"sideDeep\":0,\"gradOn\":true,\"sideFollow\":true} ");
|
||||
ArgumentCaptor<UserAppearance> captor = ArgumentCaptor.forClass(UserAppearance.class);
|
||||
verify(userAppearanceRepository).save(captor.capture());
|
||||
assertEquals("{\"theme\":\"teal\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}",
|
||||
captor.getValue().getThemeState(), "trim + 0/1 布尔归一为规范化 JSON");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("saveMyAvatarStyle:非法值抛 DException;null 置 NULL")
|
||||
void saveMyAvatarStyleValidation() {
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
|
||||
assertThrows(DException.class, () -> service.saveMyAvatarStyle(10133, "circle"));
|
||||
|
||||
service.saveMyAvatarStyle(10133, null);
|
||||
ArgumentCaptor<UserAppearance> captor = ArgumentCaptor.forClass(UserAppearance.class);
|
||||
verify(userAppearanceRepository).save(captor.capture());
|
||||
assertNull(captor.getValue().getAvatarStyle());
|
||||
}
|
||||
|
||||
// ===== 生效值合成(oapi 代理轮 2026-10-08,/oapi/appearance 出参真源)=====
|
||||
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
private void stubCurrentRole(Integer schId) {
|
||||
userCurrentRoleService = mock(IUserCurrentRoleService.class);
|
||||
ReflectionTestUtils.setField(service, "userCurrentRoleService", userCurrentRoleService);
|
||||
if (schId == null) {
|
||||
when(userCurrentRoleService.findById(10133)).thenReturn(Optional.empty());
|
||||
} else {
|
||||
com.yunhai.userhome.entity.UserCurrentRole role = new com.yunhai.userhome.entity.UserCurrentRole();
|
||||
role.setUserId(10133);
|
||||
role.setSchId(schId);
|
||||
when(userCurrentRoleService.findById(10133)).thenReturn(Optional.of(role));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("生效值:个人自选优先(旧紧凑值兼容转 JSON)——theme/avatar 取 user_appearance,级联读不出站")
|
||||
void effective_personalOverridesCascade() {
|
||||
stubCurrentRole(3);
|
||||
UserAppearance ua = new UserAppearance();
|
||||
ua.setThemeState("ocean|1|0|1"); // 旧紧凑值:读侧兼容
|
||||
ua.setAvatarStyle("silhouette");
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
|
||||
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
||||
|
||||
Map<String, Object> out = service.getEffectiveAppearance(10133);
|
||||
com.fasterxml.jackson.databind.JsonNode theme = (com.fasterxml.jackson.databind.JsonNode) out.get("theme");
|
||||
assertEquals("ocean", theme.get("theme").asText());
|
||||
assertTrue(theme.get("sideDeep").asBoolean());
|
||||
assertFalse(theme.get("gradOn").asBoolean());
|
||||
assertEquals("silhouette", out.get("avatar"));
|
||||
assertFalse(out.containsKey("logoText"), "logoText 已定稿摘除(子应用不消费 LOGO 文字)");
|
||||
assertNull(out.get("logoUrl"));
|
||||
verify(schSettingsService, never()).readThemeWithFallback(any());
|
||||
verify(schSettingsService, never()).readAvatarWithFallback(any());
|
||||
verify(schSettingsService, never()).readLogoTextWithFallback(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("生效值:个人未设 → 学校/平台级联(theme 出 JSON 对象),出参恰三字段")
|
||||
void effective_fallsToCascade() {
|
||||
stubCurrentRole(3);
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
|
||||
when(schSettingsService.readThemeWithFallback(3))
|
||||
.thenReturn(new ISchSettingsService.LayerValue("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":false}", 0));
|
||||
when(schSettingsService.readAvatarWithFallback(3))
|
||||
.thenReturn(new ISchSettingsService.LayerValue("text", 3));
|
||||
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
||||
|
||||
Map<String, Object> out = service.getEffectiveAppearance(10133);
|
||||
com.fasterxml.jackson.databind.JsonNode theme = (com.fasterxml.jackson.databind.JsonNode) out.get("theme");
|
||||
assertEquals("ink", theme.get("theme").asText(), "本校空回落平台档(hitSchId=0)");
|
||||
assertFalse(theme.get("sideDeep").asBoolean());
|
||||
assertTrue(theme.get("gradOn").asBoolean());
|
||||
assertEquals("text", out.get("avatar"), "本校已配直接生效(hitSchId=本校)");
|
||||
// 出参只三字段(logoText 已摘),不外露 my/sch 拆分
|
||||
assertEquals(3, out.size());
|
||||
assertTrue(out.containsKey("theme") && out.containsKey("avatar") && out.containsKey("logoUrl"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("生效值:个人脏值视同未配置 → 落级联;两级皆无 → null;无当前角色 → 平台层(0)")
|
||||
void effective_dirtyAndNoRole_fallsToPlatformLayer() {
|
||||
stubCurrentRole(null);
|
||||
UserAppearance ua = new UserAppearance();
|
||||
ua.setThemeState("default-theme");
|
||||
ua.setAvatarStyle("circle");
|
||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
|
||||
when(schSettingsService.readThemeWithFallback(0))
|
||||
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
|
||||
when(schSettingsService.readAvatarWithFallback(0))
|
||||
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
|
||||
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
||||
|
||||
Map<String, Object> out = service.getEffectiveAppearance(10133);
|
||||
assertNull(out.get("theme"), "脏值落级联且两级皆无 → null");
|
||||
assertNull(out.get("avatar"));
|
||||
assertNull(out.get("logoUrl"));
|
||||
assertEquals(3, out.size());
|
||||
verify(schSettingsService).readThemeWithFallback(0);
|
||||
verify(schSettingsService).readAvatarWithFallback(0);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ class MsgApprovalProxyServiceTest {
|
||||
assertEquals("张三", postQuery.getValue().get("operatorName"));
|
||||
|
||||
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), "办理成功后标记已读");
|
||||
}
|
||||
|
||||
@@ -180,6 +180,6 @@ class MsgApprovalProxyServiceTest {
|
||||
|
||||
assertFalse((Boolean) out.get("ok"));
|
||||
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.TargetSpec;
|
||||
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.entity.UserCurrentRole;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -20,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -44,27 +51,56 @@ import static org.mockito.Mockito.when;
|
||||
* 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时
|
||||
* 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从
|
||||
* 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 {
|
||||
|
||||
/** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */
|
||||
private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg";
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private YschOpenCall openCall;
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
private MsgCenterProperties properties;
|
||||
private MsgCenterProxyService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
openCall = mock(YschOpenCall.class);
|
||||
userCurrentRoleService = mock(IUserCurrentRoleService.class);
|
||||
properties = new MsgCenterProperties();
|
||||
properties.setUsermsgBaseUrl(PROD_BASE_URL);
|
||||
service = new MsgCenterProxyService(openCall, properties);
|
||||
service = new MsgCenterProxyService(openCall, properties, userCurrentRoleService);
|
||||
}
|
||||
|
||||
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 集合 */
|
||||
@@ -81,16 +117,131 @@ class MsgCenterProxyServiceTest {
|
||||
.thenReturn(json(arr.toString()));
|
||||
}
|
||||
|
||||
/** 应用字典 stub:/oapi/app/list 返回单分组单应用(appId 翻译 / countByApp 富化数据源) */
|
||||
private void stubAppDict(int appId, String appKey, String name) throws Exception {
|
||||
when(openCall.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("[{\"apps\":[{\"id\":" + appId + ",\"appKey\":\"" + appKey
|
||||
+ "\",\"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
|
||||
void list_targetSpecStripsUsermsgCtx_forPerTargetSign() {
|
||||
// 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);
|
||||
|
||||
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);
|
||||
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("/usermsg", spec.getValue().getBasePath(), "basePath=目标 ctx,AppSignInterceptor 据此剥离后签名");
|
||||
}
|
||||
@@ -101,29 +252,31 @@ class MsgCenterProxyServiceTest {
|
||||
// 自发消息无 jumpApp → 按发送方 appKey 走 by-key 注册表(严格二选一)
|
||||
stubUserbaseGet("/oapi/app/by-key/100019",
|
||||
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":2,\"content\":["
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":2,\"content\":["
|
||||
+ "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2,"
|
||||
+ "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\","
|
||||
+ "\"bizId\":\"123\",\"sendTime\":\"2026-09-13 10:00:00\",\"isRead\":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")
|
||||
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("2", query.getValue().get("pageIndex"));
|
||||
assertEquals("20", query.getValue().get("pageSize"));
|
||||
assertEquals("true", query.getValue().get("onlyUnread"));
|
||||
assertFalse(query.getValue().containsKey("schId"), "schId=null 不下发(向后兼容,验收 7)");
|
||||
|
||||
assertEquals(2, out.get("total"));
|
||||
java.util.List<?> rows = (java.util.List<?>) out.get("list");
|
||||
Map<?, ?> first = (Map<?, ?>) rows.get(0);
|
||||
assertEquals("http://localhost:8088#/task/123", first.get("jumpUrl"), "注册表 pc_url + # + 裸路径");
|
||||
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 不判权放行");
|
||||
assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名");
|
||||
Map<?, ?> second = (Map<?, ?>) rows.get(1);
|
||||
@@ -133,12 +286,12 @@ class MsgCenterProxyServiceTest {
|
||||
|
||||
@Test
|
||||
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);
|
||||
service.list(201, 0, 999, null, null, null, null, null);
|
||||
service.list(201, 0, 999, null, null, null, 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(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("100", query.getValue().get("pageSize"), "页长上限 100");
|
||||
assertEquals("false", query.getValue().get("onlyUnread"));
|
||||
@@ -147,25 +300,29 @@ class MsgCenterProxyServiceTest {
|
||||
@Test
|
||||
void list_appNameMappedFromAppKey() throws Exception {
|
||||
properties.getAppNames().put("100617", "通知公告");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\","
|
||||
+ "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}"));
|
||||
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\","
|
||||
+ "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}",
|
||||
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);
|
||||
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名");
|
||||
}
|
||||
|
||||
/** jumpApp 与 appKey 均空 → 注册表无源可查 → null(前端不渲染跳转),且不产生判权/查址出站 */
|
||||
/** jumpApp 与 appKey 均空 → 注册表查址无源可查 → null(前端不渲染跳转),且无 by-code/by-key 出站。
|
||||
* 应用字典(appId 翻译/行富化)2026-10-08 起每次 list 固定取一次,与查址无关。 */
|
||||
@Test
|
||||
void list_noRegistrySource_jumpUrlNull() throws Exception {
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}", MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写");
|
||||
assertEquals(Boolean.FALSE, first.get("showJump"));
|
||||
verify(openCall, never()).get(eq("userbase"), anyString(), anyMap(), eq(JsonNode.class));
|
||||
verify(openCall, never()).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class));
|
||||
verify(openCall, never()).get(eq("userbase"), contains("by-code"), isNull(), eq(JsonNode.class));
|
||||
verify(openCall, never()).get(eq("userbase"), contains("by-key"), isNull(), eq(JsonNode.class));
|
||||
verify(openCall, org.mockito.Mockito.times(1))
|
||||
.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class));
|
||||
}
|
||||
|
||||
// ===== Phase 2(自指地址优化):归属优先 + 绝对 URL 透传 =====
|
||||
@@ -177,10 +334,10 @@ class MsgCenterProxyServiceTest {
|
||||
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\","
|
||||
+ "\"pcUrl\":\"http://localhost:8077\"}");
|
||||
stubAuthorizedCodes("notice", "flow-center");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\","
|
||||
+ "\"isRead\":0}]}"));
|
||||
+ "\"isRead\":0}]}", MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
|
||||
"归属应用 jumpApp 主查 by-code");
|
||||
@@ -191,10 +348,10 @@ class MsgCenterProxyServiceTest {
|
||||
/** 存量兼容:改造前落库的绝对 URL 直接透传,不再拼基址(B5;双前缀缺陷即源于二次拼接) */
|
||||
@Test
|
||||
void list_absoluteJumpPath_passedThrough() throws Exception {
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":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();
|
||||
assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"),
|
||||
"绝对 URL 原样透传");
|
||||
@@ -206,9 +363,10 @@ class MsgCenterProxyServiceTest {
|
||||
void list_jumpAppAbsent_fallsBackToSendersAppKey() throws Exception {
|
||||
stubUserbaseGet("/oapi/app/by-key/100019",
|
||||
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"appKey\":\"100019\",\"isRead\":0}]}"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"appKey\":\"100019\",\"isRead\":0}]}",
|
||||
MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertEquals("http://localhost:8088#/task/9", first.get("jumpUrl"), "无归属声明回落发送方 appKey");
|
||||
assertEquals(Boolean.TRUE, first.get("showJump"));
|
||||
@@ -218,10 +376,10 @@ class MsgCenterProxyServiceTest {
|
||||
@Test
|
||||
void list_jumpAppMiss_noAppKeyFallback_jumpUrlNull() throws Exception {
|
||||
stubUserbaseGet("/oapi/app/by-code/unknown-app", null);
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\","
|
||||
+ "\"isRead\":0}]}"));
|
||||
+ "\"isRead\":0}]}", MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertNull(first.get("jumpUrl"));
|
||||
assertEquals(Boolean.FALSE, first.get("showJump"));
|
||||
@@ -233,9 +391,10 @@ class MsgCenterProxyServiceTest {
|
||||
stubUserbaseGet("/oapi/app/by-code/notice", null);
|
||||
stubUserbaseGet("/oapi/app/by-key/100006",
|
||||
"{\"id\":100006,\"appKey\":100006,\"name\":\"成绩管理\",\"pcUrl\":\"http://localhost:8090\"}");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
|
||||
MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertNull(first.get("jumpUrl"), "jumpApp 命中即只认注册表结果,空就是空");
|
||||
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/by-key/100006"), isNull(), eq(JsonNode.class));
|
||||
@@ -247,9 +406,10 @@ class MsgCenterProxyServiceTest {
|
||||
stubUserbaseGet("/oapi/app/by-code/notice",
|
||||
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077/\"}");
|
||||
stubAuthorizedCodes("notice");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
|
||||
MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
|
||||
"尾斜杠归一 + 无前导斜杠路径补斜杠");
|
||||
@@ -257,9 +417,9 @@ class MsgCenterProxyServiceTest {
|
||||
|
||||
@Test
|
||||
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"));
|
||||
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, ((java.util.List<?>) out.get("list")).size());
|
||||
}
|
||||
@@ -292,98 +452,151 @@ class MsgCenterProxyServiceTest {
|
||||
|
||||
@Test
|
||||
void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception {
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("3"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
|
||||
.thenReturn(3L);
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
|
||||
.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(1, ((java.util.List<?>) out.get("todos")).size());
|
||||
// appKey 属后端签名标识不下发(0e57022 口径收尾):待办行只出 count
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> todoRow = (Map<String, Object>) ((java.util.List<?>) out.get("todos")).get(0);
|
||||
assertFalse(todoRow.containsKey("appKey"), "待办角标不再下发 appKey");
|
||||
assertEquals(2, todoRow.get("count"));
|
||||
|
||||
// 未读数不可达单独降级为 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"));
|
||||
Map<String, Object> out2 = service.summary(201, 1);
|
||||
Map<String, Object> out2 = service.summary(201, null, 1);
|
||||
assertEquals(0L, out2.get("unreadCount"));
|
||||
assertEquals(1, ((java.util.List<?>) out2.get("todos")).size());
|
||||
}
|
||||
|
||||
/** /msg 页服务端筛选(消息对接 2026-09-30):msgType/appKey/isRead/keyword 透传,空值不下发 */
|
||||
/** /msg 页服务端筛选(消息对接 2026-09-30;appId 口径 2026-10-08):msgType/appId/isRead/keyword 透传,
|
||||
* 空值不下发。appId 不再由前端直传 appKey:先经应用字典翻译(去首尾空白),字典查无 → 空页且不出站 UserMsg。 */
|
||||
@Test
|
||||
void list_filtersPassedThrough() throws Exception {
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":0,\"content\":[]}"));
|
||||
stubAppDict(100019, "100019", "流程中心");
|
||||
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, 2, " 100019 ", 0, "请假");
|
||||
service.list(201, 1, 10, null, 2, " 100019 ", 0, "请假", 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(JsonNode.class));
|
||||
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
|
||||
assertEquals("2", query.getValue().get("msgType"));
|
||||
assertEquals("100019", query.getValue().get("appKey"), "appKey 去首尾空白后透传");
|
||||
assertEquals("100019", query.getValue().get("appKeys"), "appId 去空白经字典翻译为归属口径 appKeys 下行");
|
||||
assertFalse(query.getValue().containsKey("appKey"), "归属口径取代发送方 appKey 口径");
|
||||
assertEquals("0", query.getValue().get("isRead"));
|
||||
assertEquals("请假", query.getValue().get("keyword"));
|
||||
|
||||
// 全空筛选不出现新键(与历史调用逐字一致)
|
||||
service.list(201, 1, 10, null, null, " ", null, "");
|
||||
service.list(201, 1, 10, null, null, " ", null, "", null);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> query2 = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall, org.mockito.Mockito.times(2))
|
||||
.get(any(TargetSpec.class), eq("/oapi/msg/list"), query2.capture(), eq(JsonNode.class));
|
||||
.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("appKey"));
|
||||
assertFalse(query2.getAllValues().get(1).containsKey("isRead"));
|
||||
assertFalse(query2.getAllValues().get(1).containsKey("keyword"));
|
||||
}
|
||||
|
||||
/** appId 字典查无(授权回收等)→ 视作该应用无消息:空页,且不出站 UserMsg 列表 */
|
||||
@Test
|
||||
void list_appIdUnknownInDict_emptyPageNoUserMsgOutbound() throws Exception {
|
||||
// 字典未 stub → /oapi/app/list 返回 null → EMPTY
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
|
||||
Map<String, Object> out = service.list(201, 1, 10, null, null, "999999", null, null, null);
|
||||
assertEquals(0, out.get("total"));
|
||||
assertEquals(0, ((java.util.List<?>) out.get("list")).size());
|
||||
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class));
|
||||
}
|
||||
|
||||
/** 按应用计数(0e57022):UserMsg countByApp 条目经应用字典富化出 appId/appName,总数口径;
|
||||
* 字典查无 → appId/appName null(前端不展示);无 appKey 的行跳过。 */
|
||||
@Test
|
||||
void countByApp_enrichesWithRegistryDict() throws Exception {
|
||||
stubAppDict(100011, "100019", "流程中心");
|
||||
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
|
||||
.thenReturn(listDto("[{\"appKey\":\"100019\",\"total\":12,\"unread\":3},"
|
||||
+ "{\"appKey\":\"999999\",\"total\":5,\"unread\":1},"
|
||||
+ "{\"total\":2,\"unread\":0}]", AppMsgCount.class));
|
||||
java.util.List<Map<String, Object>> rows = service.countByApp(201, null);
|
||||
assertEquals(2, rows.size(), "无 appKey 行跳过");
|
||||
Map<String, Object> first = rows.get(0);
|
||||
assertEquals(100011, first.get("appId"));
|
||||
assertEquals("流程中心", first.get("appName"));
|
||||
assertEquals(12L, first.get("total"));
|
||||
assertEquals(3L, first.get("unread"));
|
||||
assertNull(rows.get(1).get("appId"), "字典查无 appId=null,行保留由前端隐藏");
|
||||
assertNull(rows.get(1).get("appName"));
|
||||
assertEquals(5L, rows.get(1).get("total"));
|
||||
}
|
||||
|
||||
/** countByApp 停机/未启用降级空列表(「按应用」清单空态,不 500) */
|
||||
@Test
|
||||
void countByApp_disabledOrDown_degradesEmpty() {
|
||||
properties.setUsermsgBaseUrl("");
|
||||
when(openCall.hasTarget("usermsg")).thenReturn(false);
|
||||
assertTrue(service.countByApp(201, null).isEmpty());
|
||||
|
||||
properties.setUsermsgBaseUrl(PROD_BASE_URL);
|
||||
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
|
||||
.thenThrow(new OpenApiException("down"));
|
||||
assertTrue(service.countByApp(201, null).isEmpty(), "UserMsg 不可达降级空,不抛出");
|
||||
}
|
||||
|
||||
/** 摘要优先走 /oapi/msg/summary(分类计数一次喂饱),且不再单查 unreadCount */
|
||||
@Test
|
||||
void summary_prefersSummaryEndpoint() throws Exception {
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"unreadCount\":5,\"sysUnread\":2,\"bizUnread\":1,\"approvalUnread\":2,"
|
||||
+ "\"total\":9,\"readCount\":4}"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
|
||||
.thenReturn(dto("{\"unreadCount\":5,\"sysUnread\":2,\"bizUnread\":1,\"approvalUnread\":2,"
|
||||
+ "\"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)))
|
||||
.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(2L, out.get("sysUnread"));
|
||||
assertEquals(1L, out.get("bizUnread"));
|
||||
assertEquals(2L, out.get("approvalUnread"));
|
||||
assertEquals(9L, out.get("total"));
|
||||
assertEquals(4L, out.get("readCount"));
|
||||
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class));
|
||||
assertEquals(3L, out.get("sysTotal"));
|
||||
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class));
|
||||
}
|
||||
|
||||
/** 旧版 UserMsg 无 summary 端点(404/异常)→ 降级 unreadCount 单查,分类字段缺省 */
|
||||
@Test
|
||||
void summary_legacyUserMsg_fallsBackToUnreadCount() throws Exception {
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(JsonNode.class)))
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
|
||||
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=404"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("7"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
|
||||
.thenReturn(7L);
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("[]"));
|
||||
|
||||
Map<String, Object> out = service.summary(201, 1);
|
||||
Map<String, Object> out = service.summary(201, 7, 1);
|
||||
assertEquals(7L, out.get("unreadCount"));
|
||||
assertFalse(out.containsKey("sysUnread"), "旧端点无分类计数,字段缺省由前端降级");
|
||||
}
|
||||
|
||||
@Test
|
||||
void read_sendsOwnerScopedPayload() {
|
||||
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));
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), query.capture(), eq(Long.class));
|
||||
assertEquals("7", query.getValue().get("schId"), "降级单查同样带当前身份学校口径");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabled_degradesWithoutOutbound() {
|
||||
properties.setUsermsgBaseUrl("");
|
||||
when(openCall.hasTarget("usermsg")).thenReturn(false);
|
||||
assertEquals(0, service.list(201, 1, 10, null, null, null, null, null).get("total"));
|
||||
service.read(201, java.util.Collections.singletonList(7L));
|
||||
assertEquals(0, service.list(201, 1, 10, null, null, null, null, null, null).get("total"));
|
||||
service.read(201, java.util.Collections.singletonList(7L), null);
|
||||
verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any());
|
||||
verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any());
|
||||
}
|
||||
@@ -393,9 +606,9 @@ class MsgCenterProxyServiceTest {
|
||||
// base-url 未配但服务目录有 usermsg(平台公共配置形态):按名调用仍可用
|
||||
properties.setUsermsgBaseUrl("");
|
||||
when(openCall.hasTarget("usermsg")).thenReturn(true);
|
||||
when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("5"));
|
||||
Map<String, Object> out = service.summary(201, null);
|
||||
when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
|
||||
.thenReturn(5L);
|
||||
Map<String, Object> out = service.summary(201, null, null);
|
||||
assertEquals(5L, out.get("unreadCount"));
|
||||
}
|
||||
|
||||
@@ -403,19 +616,19 @@ class MsgCenterProxyServiceTest {
|
||||
void summary_disabled_degradesToZero() {
|
||||
properties.setUsermsgBaseUrl("");
|
||||
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(0, ((java.util.List<?>) out.get("todos")).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void summary_todoQueryIncludesSchIdOnly_whenNotNull() throws Exception {
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("0"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
|
||||
.thenReturn(0L);
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("[]"));
|
||||
service.summary(201, null);
|
||||
service.summary(201, 3);
|
||||
service.summary(201, null, null);
|
||||
service.summary(201, null, 3);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall, org.mockito.Mockito.times(2))
|
||||
@@ -427,7 +640,7 @@ class MsgCenterProxyServiceTest {
|
||||
@Test
|
||||
void malformedBaseUrl_disabledNotCrash() {
|
||||
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 降级而非抛出");
|
||||
}
|
||||
|
||||
@@ -439,10 +652,92 @@ class MsgCenterProxyServiceTest {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ===== 消息归属子应用收口(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) */
|
||||
@Test
|
||||
void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception {
|
||||
@@ -450,9 +745,10 @@ class MsgCenterProxyServiceTest {
|
||||
"{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\","
|
||||
+ "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}");
|
||||
stubAuthorizedCodes("flow-center");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/task/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/task/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}",
|
||||
MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"),
|
||||
"丢 fragment + # 拼接,不产第二个 #");
|
||||
@@ -467,9 +763,9 @@ class MsgCenterProxyServiceTest {
|
||||
stubUserbaseGet("/oapi/app/by-code/notice",
|
||||
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
|
||||
stubAuthorizedCodes("flow-center");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0}]}", MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertEquals(Boolean.FALSE, first.get("showJump"), "无该应用权限 → 隐藏跳转入口");
|
||||
assertNull(first.get("jumpUrl"), "无权时 jumpUrl 同步置 null(双保险)");
|
||||
@@ -480,9 +776,9 @@ class MsgCenterProxyServiceTest {
|
||||
void list_jumpAppBlank_noAuthzOutbound() throws Exception {
|
||||
stubUserbaseGet("/oapi/app/by-key/100019",
|
||||
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/task/9\",\"appKey\":\"100019\",\"isRead\":0}]}"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"/task/9\",\"appKey\":\"100019\",\"isRead\":0}]}", MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertEquals(Boolean.TRUE, first.get("showJump"));
|
||||
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class));
|
||||
@@ -493,10 +789,10 @@ class MsgCenterProxyServiceTest {
|
||||
void list_authzUnreachable_absoluteUrlStillPasses() throws Exception {
|
||||
when(openCall.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class)))
|
||||
.thenThrow(new RuntimeException("connect timeout"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/1\",\"jumpApp\":\"notice\","
|
||||
+ "\"isRead\":0}]}"));
|
||||
+ "\"isRead\":0}]}", MsgListPage.class));
|
||||
Map<?, ?> first = firstRow();
|
||||
assertEquals(Boolean.TRUE, first.get("showJump"), "判权通道故障 → 放行");
|
||||
assertEquals("http://base.yunhaixingtong.com/notice/#/list/1", first.get("jumpUrl"));
|
||||
@@ -508,11 +804,12 @@ class MsgCenterProxyServiceTest {
|
||||
stubUserbaseGet("/oapi/app/by-code/notice",
|
||||
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
|
||||
stubAuthorizedCodes("notice");
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":2,\"content\":["
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":2,\"content\":["
|
||||
+ "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/1\",\"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);
|
||||
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/list/2\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
|
||||
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");
|
||||
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(0)).get("showJump"));
|
||||
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(1)).get("showJump"));
|
||||
@@ -525,11 +822,12 @@ class MsgCenterProxyServiceTest {
|
||||
void list_userbaseDown_shortCircuit_relativeJumpNull() throws Exception {
|
||||
when(openCall.get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class)))
|
||||
.thenThrow(new RuntimeException("connect timeout"));
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||
.thenReturn(json("{\"total\":2,\"content\":["
|
||||
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||
.thenReturn(dto("{\"total\":2,\"content\":["
|
||||
+ "{\"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}]}"));
|
||||
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
|
||||
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/task/2\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}]}",
|
||||
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");
|
||||
assertNull(((Map<?, ?>) rows.get(0)).get("jumpUrl"), "注册表不可达无兜底 → null");
|
||||
assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"));
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
import com.yunhai.userhome.repository.SchSettingsRepository;
|
||||
import com.yunhai.userhome.service.impl.SchSettingsServiceImpl;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* sch_settings 读侧级联单测(LOGO 文字轮 2026-10-09 / 壁纸三级默认轮 2026-10-09):
|
||||
* 本校未配置 → 平台层(schId=0)回落,LayerValue.hitSchId 标记实际命中层;两级皆空 → null。
|
||||
*/
|
||||
class SchSettingsServiceTest {
|
||||
|
||||
private SchSettingsRepository schSettingsRepository;
|
||||
private SchSettingsServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
schSettingsRepository = Mockito.mock(SchSettingsRepository.class);
|
||||
service = new SchSettingsServiceImpl();
|
||||
ReflectionTestUtils.setField(service, "schSettingsRepository", schSettingsRepository);
|
||||
}
|
||||
|
||||
private SchSettings row(Integer schId, String logoText, String wallpaperUrl) {
|
||||
SchSettings s = new SchSettings();
|
||||
s.setSchId(schId);
|
||||
s.setLogoText(logoText);
|
||||
s.setWallpaperUrl(wallpaperUrl);
|
||||
return s;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("默认壁纸:本校已配置 → 命中本校层(hitSchId=本校)")
|
||||
void wallpaperSchoolHit() {
|
||||
when(schSettingsRepository.findFirstBySchId(3))
|
||||
.thenReturn(Optional.of(row(3, null, "http://base.test/upl/sch.png")));
|
||||
ISchSettingsService.LayerValue hit = service.readWallpaperWithFallback(3);
|
||||
assertEquals("http://base.test/upl/sch.png", hit.getValue());
|
||||
assertEquals(3, hit.getHitSchId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("默认壁纸:本校未配置 → 回落平台层(hitSchId=0)")
|
||||
void wallpaperFallsToPlatform() {
|
||||
when(schSettingsRepository.findFirstBySchId(3)).thenReturn(Optional.empty());
|
||||
when(schSettingsRepository.findFirstBySchId(0))
|
||||
.thenReturn(Optional.of(row(0, null, "http://base.test/upl/platform.png")));
|
||||
ISchSettingsService.LayerValue hit = service.readWallpaperWithFallback(3);
|
||||
assertEquals("http://base.test/upl/platform.png", hit.getValue());
|
||||
assertEquals(0, hit.getHitSchId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("默认壁纸:本校空白 → 回落平台层,平台也未配置 → value=null、hitSchId=0(命中层口径)")
|
||||
void wallpaperBothEmpty() {
|
||||
when(schSettingsRepository.findFirstBySchId(3)).thenReturn(Optional.of(row(3, null, " ")));
|
||||
when(schSettingsRepository.findFirstBySchId(0)).thenReturn(Optional.empty());
|
||||
ISchSettingsService.LayerValue hit = service.readWallpaperWithFallback(3);
|
||||
assertNull(hit.getValue());
|
||||
assertEquals(0, hit.getHitSchId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("默认壁纸:schId 已是平台层(0)→ 不二次自查,直接返回该层值/null")
|
||||
void wallpaperPlatformLayerNoDoubleFallback() {
|
||||
when(schSettingsRepository.findFirstBySchId(0)).thenReturn(Optional.empty());
|
||||
ISchSettingsService.LayerValue hit = service.readWallpaperWithFallback(0);
|
||||
assertNull(hit.getValue());
|
||||
assertEquals(0, hit.getHitSchId());
|
||||
Mockito.verify(schSettingsRepository, Mockito.times(1)).findFirstBySchId(Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("LOGO 文字:本校空 → 平台回落(级联读同构回归)")
|
||||
void logoTextFallsToPlatform() {
|
||||
when(schSettingsRepository.findFirstBySchId(3)).thenReturn(Optional.empty());
|
||||
when(schSettingsRepository.findFirstBySchId(0))
|
||||
.thenReturn(Optional.of(row(0, "云海教育平台", null)));
|
||||
ISchSettingsService.LayerValue hit = service.readLogoTextWithFallback(3);
|
||||
assertEquals("云海教育平台", hit.getValue());
|
||||
assertEquals(0, hit.getHitSchId());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.yunhai.userhome.utils;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* ThemeCodec 单测(2026-10-08 下沉共享;2026-10-09 JSON 化行为冻结):
|
||||
* 存储形态 = 规范化 ThemeState JSON 串(固定字段序);旧紧凑四段仅读侧兼容自动转 JSON;
|
||||
* decode 宽松(脏值一律 null 视同未配置)、encode 从严(非法抛 DException)、往返一致。
|
||||
*/
|
||||
@DisplayName("ThemeCodec - JSON 编解码与紧凑兼容")
|
||||
class ThemeCodecTest {
|
||||
|
||||
private static final String TEAL_JSON =
|
||||
"{\"theme\":\"teal\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}";
|
||||
|
||||
@Test
|
||||
@DisplayName("decode:JSON 输入 → 规范化 JSON(字段序固定、布尔形态归一)")
|
||||
void decodeJson() {
|
||||
assertEquals(TEAL_JSON, ThemeCodec.decodeThemeState(TEAL_JSON));
|
||||
// 字段乱序/紧凑布尔(0/1)也归一为 false/true 规范形态
|
||||
assertEquals(TEAL_JSON, ThemeCodec.decodeThemeState(
|
||||
"{\"gradOn\":true,\"theme\":\"teal\",\"sideFollow\":true,\"sideDeep\":0}"));
|
||||
assertNull(ThemeCodec.decodeThemeState("{\"theme\":\"teal\"}")); // 字段不齐
|
||||
assertNull(ThemeCodec.decodeThemeState("{\"theme\":\"red\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decode:旧紧凑四段兼容 → 自动转 JSON(迁移期双读)")
|
||||
void decodeLegacyCompact() {
|
||||
assertEquals(TEAL_JSON, ThemeCodec.decodeThemeState("teal|0|1|1"));
|
||||
assertEquals("{\"theme\":\"ocean\",\"sideDeep\":true,\"gradOn\":false,\"sideFollow\":false}",
|
||||
ThemeCodec.decodeThemeState("ocean|1|0|0"));
|
||||
assertEquals(TEAL_JSON, ThemeCodec.decodeThemeState(" teal|0|1|1 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decode:脏值一律 null(旧档名/段数不对/开关非法/非 0 1/坏 JSON)")
|
||||
void decodeDirty() {
|
||||
assertNull(ThemeCodec.decodeThemeState(null));
|
||||
assertNull(ThemeCodec.decodeThemeState(""));
|
||||
assertNull(ThemeCodec.decodeThemeState("default-theme")); // findBySchId 兜底遗留值
|
||||
assertNull(ThemeCodec.decodeThemeState("red|0|1|1")); // 非法档名
|
||||
assertNull(ThemeCodec.decodeThemeState("teal|0|1")); // 段数不足
|
||||
assertNull(ThemeCodec.decodeThemeState("teal|0|1|1|1")); // 段数超出
|
||||
assertNull(ThemeCodec.decodeThemeState("teal|2|1|1")); // 开关非 0/1
|
||||
assertNull(ThemeCodec.decodeThemeState("teal|true|1|1")); // 开关非单字符
|
||||
assertNull(ThemeCodec.decodeThemeState("{not json")); // 坏 JSON
|
||||
assertNull(ThemeCodec.decodeThemeState("[1,2]")); // 非对象
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encode:校验从严 + 产出规范化 JSON(不再产紧凑);紧凑入参抛 DException")
|
||||
void encode() {
|
||||
assertEquals(TEAL_JSON, ThemeCodec.encodeThemeState(
|
||||
"{\"theme\":\"teal\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}"));
|
||||
// 0/1 布尔形态归一
|
||||
assertEquals(TEAL_JSON, ThemeCodec.encodeThemeState(
|
||||
"{\"theme\":\"teal\",\"sideDeep\":0,\"gradOn\":1,\"sideFollow\":1}"));
|
||||
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState(null));
|
||||
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState(""));
|
||||
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState("teal|0|1|1")); // 紧凑不再可写
|
||||
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState("{\"theme\":\"red\"}"));
|
||||
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState(
|
||||
"{\"theme\":\"teal\",\"sideDeep\":false}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encode→decode 往返一致(合法域内,含紧凑读侧转 JSON 闭环)")
|
||||
void roundTrip() {
|
||||
for (String theme : new String[]{"blue", "ocean", "ink", "violet", "teal"}) {
|
||||
for (int d = 0; d <= 1; d++) for (int g = 0; g <= 1; g++) for (int f = 0; f <= 1; f++) {
|
||||
String compact = theme + "|" + d + "|" + g + "|" + f;
|
||||
String json = ThemeCodec.decodeThemeState(compact);
|
||||
assertEquals(json, ThemeCodec.encodeThemeState(json), "JSON 往返自洽");
|
||||
assertEquals(json, ThemeCodec.decodeThemeState(json), "encode 产物可再读");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toJsonNode:JSON 串 → 节点(oapi 对象出参用);坏串 null")
|
||||
void toJsonNode() {
|
||||
assertEquals("teal", ThemeCodec.toJsonNode(TEAL_JSON).get("theme").asText());
|
||||
assertTrue(ThemeCodec.toJsonNode(TEAL_JSON).get("gradOn").asBoolean());
|
||||
assertNull(ThemeCodec.toJsonNode("{bad"));
|
||||
assertNull(ThemeCodec.toJsonNode(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isThemeKey / isAvatarStyle")
|
||||
void validators() {
|
||||
assertTrue(ThemeCodec.isThemeKey("blue"));
|
||||
assertFalse(ThemeCodec.isThemeKey("red"));
|
||||
assertFalse(ThemeCodec.isThemeKey(null));
|
||||
assertTrue(ThemeCodec.isAvatarStyle("text"));
|
||||
assertTrue(ThemeCodec.isAvatarStyle("silhouette"));
|
||||
assertFalse(ThemeCodec.isAvatarStyle("circle"));
|
||||
assertFalse(ThemeCodec.isAvatarStyle(null));
|
||||
assertNotEquals(0, ThemeCodec.THEME_KEYS.length);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user