Compare commits
13
Commits
977b5c90a4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f7a2eeea | ||
|
|
d7ea119996 | ||
|
|
d2cf718b51 | ||
|
|
920408dd0f | ||
|
|
1f31612609 | ||
|
|
ad28149731 | ||
|
|
71c571f884 | ||
|
|
0099e5df2b | ||
|
|
927d4b14f8 | ||
|
|
9370429455 | ||
|
|
175dacc9ad | ||
|
|
29d32eed6e | ||
|
|
47f9e3f751 |
@@ -25,7 +25,10 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl(alpine)
|
- name: 安装 git / curl(alpine)
|
||||||
run: apk add --no-cache git curl
|
run: |
|
||||||
|
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||||
|
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl
|
||||||
|
|
||||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
@@ -55,18 +58,24 @@ jobs:
|
|||||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker rm -f mavenbuild 2>/dev/null || true
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||||
docker run -d --name mavenbuild \
|
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||||
|
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||||
|
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||||
|
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||||
|
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||||
|
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||||
|
docker run -d --name "$BUILD_CONTAINER" \
|
||||||
--network infra-infra-net \
|
--network infra-infra-net \
|
||||||
-v /opt/docker/projman-m2:/root/.m2 \
|
-v /opt/docker/projman-m2:/root/.m2 \
|
||||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||||
docker cp "${PWD}/." mavenbuild:/build/
|
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||||
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||||
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
|
docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
|
||||||
mkdir -p "${PWD}/target"
|
mkdir -p "${PWD}/target"
|
||||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
|
||||||
docker rm -f mavenbuild
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
|
|
||||||
- name: 构建并推送镜像
|
- name: 构建并推送镜像
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.yunhai.userhome.config;
|
||||||
|
|
||||||
|
import com.yunhai.userhome.entity.WidgetCatalog;
|
||||||
|
import com.yunhai.userhome.repository.WidgetCatalogRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组件内置目录行启动 seeding(幂等)。
|
||||||
|
*
|
||||||
|
* <p>背景:种子 INSERT 原先只在 {@code resources/sql/widget_catalog.sql} 里,代码无 seeding,
|
||||||
|
* 新环境首次启动时候选池恒为空。此处补一条启动期兜底:库为空才写入 3 条内置目录行。</p>
|
||||||
|
*
|
||||||
|
* <p>三条种子与 {@code resources/sql/widget_catalog.sql} 用同一份常量(code / name / comp_type / scope_type / enabled),
|
||||||
|
* 仅补「目录行」,不补内容(内置组件内容在前端工程);{@code WidgetDataService} 白名单不动。</p>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026-10-11
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WidgetCatalogSeeder implements CommandLineRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WidgetCatalogSeeder.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {code, name, sort, linkAppCode, linkPath}:与 resources/sql/widget_catalog.sql 的 INSERT 一一对应。
|
||||||
|
* linkAppCode/linkPath 为外跳入口默认值(**只存子应用 code + 路由,不存绝对 URL**,域名由应用注册表解析);
|
||||||
|
* 留空 = 该组件不出外跳入口,管理员可在管理台改。
|
||||||
|
*/
|
||||||
|
private static final String[][] SEEDS = {
|
||||||
|
{"evaluate", "听评课", "1", null, null},
|
||||||
|
{"syllabus", "课程表", "2", null, null},
|
||||||
|
{"weekplan", "一周安排", "3", "work-week-plan", "/weekplan"},
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final int COMP_TYPE_BUILTIN = 1;
|
||||||
|
|
||||||
|
private static final int SCOPE_ALL = 0;
|
||||||
|
|
||||||
|
private static final int ENABLED = 1;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WidgetCatalogRepository widgetCatalogRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
try {
|
||||||
|
// 幂等:库非空即整体跳过,避免管理员下线过的内置组件在重启后被重新种回
|
||||||
|
if (widgetCatalogRepository.count() > 0) {
|
||||||
|
log.info("[widget] widget_catalog 非空,跳过内置目录 seeding");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String[] seed : SEEDS) {
|
||||||
|
String code = seed[0];
|
||||||
|
if (widgetCatalogRepository.findByCode(code).isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
WidgetCatalog catalog = new WidgetCatalog();
|
||||||
|
catalog.setCode(code);
|
||||||
|
catalog.setName(seed[1]);
|
||||||
|
catalog.setCompType(COMP_TYPE_BUILTIN);
|
||||||
|
catalog.setScopeType(SCOPE_ALL);
|
||||||
|
catalog.setSort(Integer.valueOf(seed[2]));
|
||||||
|
catalog.setLinkAppCode(seed[3]);
|
||||||
|
catalog.setLinkPath(seed[4]);
|
||||||
|
catalog.setEnabled(ENABLED);
|
||||||
|
catalog.setUpdateTime(new Date());
|
||||||
|
widgetCatalogRepository.save(catalog);
|
||||||
|
log.info("[widget] seeding 内置目录行:{}", code);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// seeding 失败不阻断应用启动(候选池另有管理端入口补录)
|
||||||
|
log.error("[widget] 内置目录 seeding 失败,跳过", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
package com.yunhai.userhome.controller.api;
|
|
||||||
|
|
||||||
import com.yunhai.platform.apistd.BizException;
|
|
||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
|
||||||
import com.yunhai.platform.utils.model.BoaUser;
|
|
||||||
import com.yunhai.userhome.config.Config;
|
|
||||||
import com.yunhai.userhome.entity.AppCategory;
|
|
||||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
|
||||||
import com.yunhai.userhome.service.IAppCategoryService;
|
|
||||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
|
||||||
import com.yunhai.userhome.utils.FileUtil;
|
|
||||||
import com.yunhai.userhome.utils.OtherUtil;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.core.io.InputStreamResource;
|
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用设置控制器(自 systemsetting AppSettingController + SysController 迁移,1:1 对齐)。
|
|
||||||
*
|
|
||||||
* <p>与原版差异点:写操作(应用 CRUD / UpdateResDate / 模板上传保存)校验调用者
|
|
||||||
* 为超管(type==1)或校管理员(roleId∈{2,5,7})——原版任何登录用户可写,因新入口仅管理员可见,收紧。
|
|
||||||
* 查询(分页列表 / FindAppCategory / GetTempList / downloadTemp)登录即可。
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping(Config.API_PATH)
|
|
||||||
public class AppSettingController {
|
|
||||||
|
|
||||||
@Value("${FILE_PATH:./upload/}")
|
|
||||||
private String FILE_PATH;
|
|
||||||
|
|
||||||
@Value("${FILE_WEB_PATH:}")
|
|
||||||
private String FILE_WEB_PATH;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private IAppCategoryService appCategoryService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private IUserCurrentRoleService userCurrentRoleService;
|
|
||||||
|
|
||||||
// ==================== 应用类别 CRUD ====================
|
|
||||||
|
|
||||||
/** 新增/编辑应用(JSON body,1:1 对齐原 SaveAppCategory) */
|
|
||||||
@PostMapping(value = "SaveAppCategory", produces = "application/json")
|
|
||||||
public void saveAppCategory(BoaUser authUserWithDept, @RequestBody AppCategory appCategory) {
|
|
||||||
checkAdmin(authUserWithDept);
|
|
||||||
if (appCategory.getCDate() == null) appCategory.setCDate(new Date());
|
|
||||||
if (appCategory.getResDate() == null) appCategory.setResDate(new Date());
|
|
||||||
appCategory.setCId(authUserWithDept.getId());
|
|
||||||
appCategoryService.save(appCategory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(value = "DelAppCategory", produces = "application/json")
|
|
||||||
public void delAppCategory(BoaUser authUserWithDept, Integer id) {
|
|
||||||
checkAdmin(authUserWithDept);
|
|
||||||
appCategoryService.deleteById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(value = "GetAppCategoryList")
|
|
||||||
public Map getAppCategoryList(BoaUser authUserWithDept, Integer page, Integer count,
|
|
||||||
@RequestParam(value = "roleType") Integer roleType,
|
|
||||||
@RequestParam(value = "name", required = false) String name) {
|
|
||||||
Page<AppCategory> data = appCategoryService.findPageList(page, count, roleType, name);
|
|
||||||
Map map = new HashMap();
|
|
||||||
map.put("total", data.getTotalElements());
|
|
||||||
map.put("list", data.getContent());
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(value = "FindAppCategory")
|
|
||||||
public Optional<AppCategory> findAppCategory(BoaUser authUserWithDept, Integer id) {
|
|
||||||
return appCategoryService.findById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(value = "UpdateResDate", produces = "application/json")
|
|
||||||
public void updateResDate(BoaUser authUserWithDept, Integer id) {
|
|
||||||
checkAdmin(authUserWithDept);
|
|
||||||
appCategoryService.updateResDate(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 模板管理(对齐原 SysController) ====================
|
|
||||||
|
|
||||||
/** 上传模板压缩包(zip/rar),解压覆盖到 tempUpload/<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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -78,13 +78,13 @@ public class UserController {
|
|||||||
* 当前用户可访问的应用全集(跨仓 UserBase 实时拉取,按应用类型分组)。
|
* 当前用户可访问的应用全集(跨仓 UserBase 实时拉取,按应用类型分组)。
|
||||||
* 前端据此:渲染分类 tab(含「全部」)+ 图标网格;常用区由 getUsedApp 独立提供。
|
* 前端据此:渲染分类 tab(含「全部」)+ 图标网格;常用区由 getUsedApp 独立提供。
|
||||||
*
|
*
|
||||||
* @param schId 兼容旧前端入参(yh-platform getUserApp 当前按 userId 全局权限过滤,schId 暂不参与)
|
* @param schId 学校上下文:透传 UserBase 按该校单点开通集合收窄(为空则由服务端按当前身份/主归属学校兜底)
|
||||||
* @param userId 指定用户(缺省用登录用户)
|
* @param userId 指定用户(缺省用登录用户)
|
||||||
*/
|
*/
|
||||||
@GetMapping("GetUserApp")
|
@GetMapping("GetUserApp")
|
||||||
public JsonNode getUserApp(BoaUser authUserWithDept, Integer schId,
|
public JsonNode getUserApp(BoaUser authUserWithDept, Integer schId,
|
||||||
@RequestParam(name = "userId", required = false) Integer userId) {
|
@RequestParam(name = "userId", required = false) Integer userId) {
|
||||||
return withIconKeys(appRemoteService.getUserApps(authUserWithDept));
|
return withIconKeys(appRemoteService.getUserApps(authUserWithDept, schId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,10 +119,15 @@ public class UserController {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。 */
|
/**
|
||||||
|
* 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。<br>
|
||||||
|
* schId 透传(可选):与 GetUserApp 同口径按学校收窄全集,避免「列表已收窄、单个查询仍全集」的不一致;
|
||||||
|
* 为空则由 UserBase 按当前身份/主归属学校兜底。
|
||||||
|
*/
|
||||||
@GetMapping("GetAppInfoById")
|
@GetMapping("GetAppInfoById")
|
||||||
public BoaApp getAppInfoById(BoaUser authUserWithDept, @RequestParam(name = "id", required = false) Integer id) {
|
public BoaApp getAppInfoById(BoaUser authUserWithDept, Integer schId,
|
||||||
return appRemoteService.getAppById(authUserWithDept, id).orElse(null);
|
@RequestParam(name = "id", required = false) Integer id) {
|
||||||
|
return appRemoteService.getAppById(authUserWithDept, id, schId).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前用户角色身份列表(跨仓 UserBase,含 学校/角色/子节点)。 */
|
/** 当前用户角色身份列表(跨仓 UserBase,含 学校/角色/子节点)。 */
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import com.yunhai.userhome.service.AppIconKeyService;
|
|||||||
import com.yunhai.userhome.service.ISchSettingsService;
|
import com.yunhai.userhome.service.ISchSettingsService;
|
||||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||||
import com.yunhai.userhome.service.IUserSettingsService;
|
import com.yunhai.userhome.service.IUserSettingsService;
|
||||||
|
import com.yunhai.userhome.service.IWidgetCatalogService;
|
||||||
import com.yunhai.userhome.service.UsedAppService;
|
import com.yunhai.userhome.service.UsedAppService;
|
||||||
|
import com.yunhai.userhome.service.WidgetLinkResolver;
|
||||||
import com.yunhai.userhome.utils.EnumUtil;
|
import com.yunhai.userhome.utils.EnumUtil;
|
||||||
import com.yunhai.userhome.utils.FileUtil;
|
import com.yunhai.userhome.utils.FileUtil;
|
||||||
import com.yunhai.userhome.utils.OtherUtil;
|
import com.yunhai.userhome.utils.OtherUtil;
|
||||||
@@ -34,11 +36,6 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。
|
* 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。
|
||||||
*
|
|
||||||
* <p>待确认接口(已写入待确认清单):
|
|
||||||
* <ul>
|
|
||||||
* <li>getUserSettings 中 bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping(Config.API_PATH)
|
@RequestMapping(Config.API_PATH)
|
||||||
@@ -62,12 +59,18 @@ public class UserSettingsController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UsedAppService usedAppService;
|
private UsedAppService usedAppService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WidgetLinkResolver widgetLinkResolver;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private OpenApiUserClient openApiUserClient;
|
private OpenApiUserClient openApiUserClient;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AppIconKeyService appIconKeyService;
|
private AppIconKeyService appIconKeyService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IWidgetCatalogService widgetCatalogService;
|
||||||
|
|
||||||
// ==================== 壁纸 ====================
|
// ==================== 壁纸 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -286,11 +289,11 @@ public class UserSettingsController {
|
|||||||
public UserSettings getUserSettings(BoaUser authUserWithDept) {
|
public UserSettings getUserSettings(BoaUser authUserWithDept) {
|
||||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||||
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
|
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
|
||||||
|
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
|
||||||
UserSettings model = new UserSettings();
|
UserSettings model = new UserSettings();
|
||||||
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
|
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
|
||||||
// TODO 待确认:bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认
|
// 候选池真源 = 自建 widget_catalog(生效启用 + 可见范围命中本校 + 可见角色命中当前角色;name 已按显示名三级链合成)
|
||||||
// List<BoaApp> appPageList = bschBaseOpenApi.getPcGrzxPage(authUserWithDept.getId(), schId);
|
List<WidgetCatalog> catalogList = widgetCatalogService.findEnabledForSchool(schId, roleId);
|
||||||
List<BoaApp> appPageList = Collections.emptyList();
|
|
||||||
if (optional.isPresent()) model = optional.get();
|
if (optional.isPresent()) model = optional.get();
|
||||||
// 壁纸池一次取全:悬空自愈判定与兜底选择共用
|
// 壁纸池一次取全:悬空自愈判定与兜底选择共用
|
||||||
List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId());
|
List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId());
|
||||||
@@ -303,12 +306,20 @@ public class UserSettingsController {
|
|||||||
if (model.getWallpaperUrl() == null || model.getWallpaperUrl().isEmpty()) {
|
if (model.getWallpaperUrl() == null || model.getWallpaperUrl().isEmpty()) {
|
||||||
model.setWallpaperUrl(resolveFallbackWallpaper(authUserWithDept, schId, wallList));
|
model.setWallpaperUrl(resolveFallbackWallpaper(authUserWithDept, schId, wallList));
|
||||||
}
|
}
|
||||||
List<Map> mapList = appPageList.stream().map(boaApp -> {
|
// id/appId 同值:对齐既有 widgetAppIds 的存取与面板排序链路;不再有 url
|
||||||
|
List<Map> mapList = catalogList.stream().map(catalog -> {
|
||||||
Map map = new HashMap<>();
|
Map map = new HashMap<>();
|
||||||
map.put("id", boaApp.getId());
|
map.put("id", catalog.getId());
|
||||||
map.put("appId", boaApp.getId());
|
map.put("appId", catalog.getId());
|
||||||
map.put("name", boaApp.getName());
|
map.put("name", catalog.getName());
|
||||||
map.put("url", boaApp.getWebAddr());
|
map.put("code", catalog.getCode());
|
||||||
|
map.put("compType", catalog.getCompType());
|
||||||
|
// 外跳入口:库里只存「子应用 code + 路由」,绝对地址按应用注册表 pc_url 解析后下发;
|
||||||
|
// 解析不出(未配置 / 查无 / 注册表不可达)则不下发,前端不渲染入口
|
||||||
|
String overviewUrl = widgetLinkResolver.resolve(catalog.getLinkAppCode(), catalog.getLinkPath());
|
||||||
|
if (overviewUrl != null) {
|
||||||
|
map.put("overviewUrl", overviewUrl);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}).collect(Collectors.toList());
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.yunhai.userhome.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.platform.apistd.BizException;
|
||||||
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
import com.yunhai.userhome.config.Config;
|
||||||
|
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||||
|
import com.yunhai.userhome.entity.WidgetCatalog;
|
||||||
|
import com.yunhai.userhome.entity.WidgetSchoolSetting;
|
||||||
|
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||||
|
import com.yunhai.userhome.service.IWidgetCatalogService;
|
||||||
|
import com.yunhai.userhome.service.WidgetDataService;
|
||||||
|
import com.yunhai.userhome.utils.OtherUtil;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.util.DigestUtils;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组件控制器:候选池按需取内容 + 统一取数(登录侧),以及管理端候选池读写(管理员)。
|
||||||
|
*
|
||||||
|
* <p>写权限按「目录权 / 校级权」拆开:全局目录(新增 / 改内容 / 删除 / 三态可见范围 / 角色命名)仅超管;
|
||||||
|
* 校级覆盖(启用 / 可见角色 / 排序 / 别名)超管或校管,且校管只能写本校。</p>
|
||||||
|
*
|
||||||
|
* <p>真实路径按 {@code /{platform}} 展开为 {@code /api/getWidgetData} 等(见 Config.API_PATH)。</p>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(Config.API_PATH)
|
||||||
|
public class WidgetController {
|
||||||
|
|
||||||
|
private static final int COMP_TYPE_FRAGMENT = 2;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IWidgetCatalogService widgetCatalogService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WidgetDataService widgetDataService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IUserCurrentRoleService userCurrentRoleService;
|
||||||
|
|
||||||
|
/** 统一取数入口:params 为 urlencoded 查询串;未配置的数据键回空数据,不抛异常 */
|
||||||
|
@GetMapping(value = "getWidgetData", produces = "application/json")
|
||||||
|
public Object getWidgetData(BoaUser authUserWithDept,
|
||||||
|
@RequestParam(value = "code", required = false) String code,
|
||||||
|
@RequestParam(value = "params", required = false) String params) {
|
||||||
|
Object data = widgetDataService.getData(code, params, resolveCaller(authUserWithDept));
|
||||||
|
if (data != null) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
Map<String, Object> empty = new HashMap<>();
|
||||||
|
empty.put("msg", "该小工具暂无可用数据源:" + code);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅 comp_type=2:按需取片段内容(不塞进 getUserSettings,避免每次进桌面下发全部 HTML) */
|
||||||
|
@GetMapping(value = "getWidgetContent", produces = "application/json")
|
||||||
|
public Map<String, Object> getWidgetContent(BoaUser authUserWithDept, HttpServletResponse response,
|
||||||
|
@RequestParam(value = "code", required = false) String code) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("content", "");
|
||||||
|
Optional<WidgetCatalog> optional = widgetCatalogService.findByCode(code);
|
||||||
|
if (!optional.isPresent()) {
|
||||||
|
result.put("msg", "小工具不存在:" + code);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
WidgetCatalog catalog = optional.get();
|
||||||
|
if (!widgetCatalogService.visibleToSchool(catalog, currentSchId(authUserWithDept))) {
|
||||||
|
result.put("msg", "该小工具未对本校下发");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (catalog.getCompType() == null || catalog.getCompType() != COMP_TYPE_FRAGMENT) {
|
||||||
|
result.put("msg", "内置组件的内容在 front 工程里,无按需取内容这条路:" + code);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
String content = catalog.getContent() == null ? "" : catalog.getContent();
|
||||||
|
result.put("code", catalog.getCode());
|
||||||
|
result.put("name", catalog.getName());
|
||||||
|
result.put("compType", catalog.getCompType());
|
||||||
|
result.put("dataCode", catalog.getDataCode());
|
||||||
|
result.put("content", content);
|
||||||
|
// 内容 hash 派生 ETag:内容或更新时间变更即换标签,便于前端条件请求复用
|
||||||
|
response.setHeader("ETag", "\"" + contentHash(content, catalog.getUpdateTime()) + "\"");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理端列表(超管 / 校管):按视角返回——超管=全局原值,校管=本校生效值 + 原名 + 覆盖标记 */
|
||||||
|
@GetMapping(value = "GetWidgetCatalogList", produces = "application/json")
|
||||||
|
public List<WidgetCatalog> getWidgetCatalogList(BoaUser authUserWithDept) {
|
||||||
|
checkSchAdmin(authUserWithDept);
|
||||||
|
return widgetCatalogService.findEffectiveForAdmin(currentSchId(authUserWithDept), isSuperAdmin(authUserWithDept));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理端保存全局目录:content 走 JSON body(同 SaveTempFile 口径,规避 URL 长度限制) */
|
||||||
|
@PostMapping(value = "SaveWidgetCatalog", produces = "application/json")
|
||||||
|
public void saveWidgetCatalog(BoaUser authUserWithDept, @RequestBody WidgetCatalog widgetCatalog) {
|
||||||
|
checkSuperAdmin(authUserWithDept);
|
||||||
|
widgetCatalogService.save(widgetCatalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除全局目录行,连带删可见范围点名表与校级覆盖行 */
|
||||||
|
@PostMapping(value = "DeleteWidgetCatalog", produces = "application/json")
|
||||||
|
public void deleteWidgetCatalog(BoaUser authUserWithDept, @RequestParam(value = "id") Integer id) {
|
||||||
|
checkSuperAdmin(authUserWithDept);
|
||||||
|
widgetCatalogService.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取本校覆盖行(编辑回显):只认当前登录校 */
|
||||||
|
@GetMapping(value = "GetWidgetSchoolSetting", produces = "application/json")
|
||||||
|
public WidgetSchoolSetting getWidgetSchoolSetting(BoaUser authUserWithDept,
|
||||||
|
@RequestParam(value = "widgetId") Integer widgetId) {
|
||||||
|
checkSchAdmin(authUserWithDept);
|
||||||
|
return widgetCatalogService.findSchoolSetting(widgetId, currentSchId(authUserWithDept)).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存本校覆盖:schId 强制取当前登录校,不接受前端传入 */
|
||||||
|
@PostMapping(value = "SaveWidgetSchoolSetting", produces = "application/json")
|
||||||
|
public void saveWidgetSchoolSetting(BoaUser authUserWithDept, @RequestBody WidgetSchoolSetting setting) {
|
||||||
|
checkSchAdmin(authUserWithDept);
|
||||||
|
widgetCatalogService.saveSchoolSetting(setting, currentSchId(authUserWithDept));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer currentSchId(BoaUser authUserWithDept) {
|
||||||
|
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||||
|
return currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取数身份上下文(userid / roleId / schId 全部会话推导,前端不可传):
|
||||||
|
* userid = 平台用户ID(BoaUser.id);roleId/schId = 当前激活身份(UserBase /oapi/identity/current)。
|
||||||
|
*/
|
||||||
|
private WidgetDataService.Caller resolveCaller(BoaUser authUserWithDept) {
|
||||||
|
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||||
|
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
|
||||||
|
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
|
||||||
|
return new WidgetDataService.Caller(authUserWithDept.getId(), roleId, schId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSuperAdmin(BoaUser authUserWithDept) {
|
||||||
|
return authUserWithDept.getType() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录权校验:仅超管(type==1)可写全局目录 / 删除 / 三态可见范围 / 角色命名 / 登记内置。
|
||||||
|
* 注:平台 yh-api-std 的异常处理器无 @ResponseStatus,拒绝表现为 HTTP 200 + 信封 code=3(非 500)。
|
||||||
|
*/
|
||||||
|
private void checkSuperAdmin(BoaUser authUserWithDept) {
|
||||||
|
if (authUserWithDept.getType() != 1) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校级权校验:超管或校管理员(roleId∈{2,5,7}),其余拒绝 */
|
||||||
|
private void checkSchAdmin(BoaUser authUserWithDept) {
|
||||||
|
if (authUserWithDept.getType() == 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||||
|
if (!currentRoleOpt.isPresent() || !OtherUtil.isAdminById(currentRoleOpt.get().getRoleId())) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String contentHash(String content, java.util.Date updateTime) {
|
||||||
|
long stamp = updateTime == null ? 0L : updateTime.getTime();
|
||||||
|
return DigestUtils.md5DigestAsHex((stamp + "\n" + content).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ import java.util.Map;
|
|||||||
* 强制(/oapi/* 无签名 401);appKey 准入归网关台账/限流管,本应用不维护 per-app 白名单。
|
* 强制(/oapi/* 无签名 401);appKey 准入归网关台账/限流管,本应用不维护 per-app 白名单。
|
||||||
*
|
*
|
||||||
* <p>出参 = 当前用户【生效值】{@code {theme, avatar, logoUrl}}(个人 → 学校 → 平台级联合成,
|
* <p>出参 = 当前用户【生效值】{@code {theme, avatar, logoUrl}}(个人 → 学校 → 平台级联合成,
|
||||||
* 层级知识留在 UserHome 内部不外露;null=未配置,消费端 init 侧清对应本地键回落默认观感)。
|
* 层级知识留在 UserHome 内部不外露;null=未配置,消费端 init 侧清对应本地键回落默认观感;
|
||||||
|
* logoText 已于 2026-10-09 定稿摘除——子应用不再消费 LOGO 文字,门户走 /api/schLogoText 自有链)。
|
||||||
* 返回裸 payload(平台 oapi 无信封包装),消费端整体透传给浏览器。</p>
|
* 返回裸 payload(平台 oapi 无信封包装),消费端整体透传给浏览器。</p>
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
package com.yunhai.userhome.entity;
|
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
import javax.persistence.Column;
|
|
||||||
import javax.persistence.Entity;
|
|
||||||
import javax.persistence.GeneratedValue;
|
|
||||||
import javax.persistence.GenerationType;
|
|
||||||
import javax.persistence.Id;
|
|
||||||
import javax.persistence.Table;
|
|
||||||
import javax.validation.constraints.NotNull;
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 桌面应用类别(自 systemsetting.app_category 1:1 迁移)。
|
|
||||||
*
|
|
||||||
* <p>roleType:1 校管理员 | 2 教师 | 3 学生(见 {@code enums.RoleType});
|
|
||||||
* resDate 为资源更新时间戳,前端上传模板后经 UpdateResDate 刷新缓存。</p>
|
|
||||||
*/
|
|
||||||
@AllArgsConstructor
|
|
||||||
@NoArgsConstructor
|
|
||||||
@Data
|
|
||||||
@Entity
|
|
||||||
@Table(name = "app_category")
|
|
||||||
public class AppCategory {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
@NotNull(groups = Update.class)
|
|
||||||
private Integer id;
|
|
||||||
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
@Column(name = "api_url", length = 500)
|
|
||||||
private String apiUrl;
|
|
||||||
|
|
||||||
@Column(name = "data_key")
|
|
||||||
private String dataKey;
|
|
||||||
|
|
||||||
/** 接口参数 JSON 数组字符串([{k,v,type}]) */
|
|
||||||
@Column(name = "params_key", columnDefinition = "TEXT")
|
|
||||||
private String paramsKey;
|
|
||||||
|
|
||||||
@Column(name = "role_type")
|
|
||||||
private Integer roleType;
|
|
||||||
|
|
||||||
@Column(name = "c_id")
|
|
||||||
private Integer cId;
|
|
||||||
|
|
||||||
@Column(name = "c_date")
|
|
||||||
private Date cDate = new Date();
|
|
||||||
|
|
||||||
/** 资源更新时间戳 */
|
|
||||||
@Column(name = "res_date")
|
|
||||||
private Date resDate = new Date();
|
|
||||||
|
|
||||||
public @interface Update {}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.yunhai.userhome.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.GenerationType;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.persistence.Transient;
|
||||||
|
import javax.persistence.UniqueConstraint;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组件候选池(widget_catalog 表):内容自持,全表无 html 地址概念。
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "widget_catalog", uniqueConstraints = @UniqueConstraint(columnNames = "code"))
|
||||||
|
public class WidgetCatalog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
/** 内置组件 = 前端 widgetRegistry 的键;自定义片段 = 自定义标识 */
|
||||||
|
@Column(name = "code", nullable = false, length = 32)
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 64)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** 1 = front 工程内置组件(内容在代码库,本行不存 content);2 = 自定义 HTML 片段(内容在 content) */
|
||||||
|
@Column(name = "comp_type", nullable = false)
|
||||||
|
private Integer compType = 1;
|
||||||
|
|
||||||
|
/** 仅 comp_type=2:片段 HTML 源码 */
|
||||||
|
@Column(name = "content", columnDefinition = "mediumtext")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/** 片段取数用的数据键,须在 WidgetDataService 白名单内 */
|
||||||
|
@Column(name = "data_code", length = 32)
|
||||||
|
private String dataCode;
|
||||||
|
|
||||||
|
/** 下发范围:旧语义(NULL = 全校可见)已被 scopeType 取代,字段保留仅为兼容存量数据,读侧不再使用 */
|
||||||
|
@Column(name = "sch_id")
|
||||||
|
private Integer schId;
|
||||||
|
|
||||||
|
/** 下发范围三态:0 = 全部学校 1 = 白名单(仅点名的学校可见) 2 = 黑名单(点名的学校不可见) */
|
||||||
|
@Column(name = "scope_type", nullable = false)
|
||||||
|
private Integer scopeType = 0;
|
||||||
|
|
||||||
|
/** 可见角色:逗号分隔 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生);NULL 或空 = 全角色 */
|
||||||
|
@Column(name = "role_types", length = 16)
|
||||||
|
private String roleTypes;
|
||||||
|
|
||||||
|
/** 角色专属命名(JSON 文本,如 {"2":"全校课表"},键 = RoleTypeEnum);读侧 Jackson 解析,失败按无角色命名降级 */
|
||||||
|
@Column(name = "role_alias", length = 255)
|
||||||
|
private String roleAlias;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外跳目标子应用:应用注册表(UserBase /oapi/app/by-code/{code})的 app_code,如 work-week-plan。
|
||||||
|
* NULL = 该组件不显示外跳入口。地址由 linkPath + 注册表 pc_url 解析而成,**不落库绝对 URL**。
|
||||||
|
*/
|
||||||
|
@Column(name = "link_app_code", length = 32)
|
||||||
|
private String linkAppCode;
|
||||||
|
|
||||||
|
/** 外跳路由(hash 路由,如 /weekplan);拼接口径同消息中心 jumpUrl:归一化 pc_url + "#" + 本路径 */
|
||||||
|
@Column(name = "link_path", length = 128)
|
||||||
|
private String linkPath;
|
||||||
|
|
||||||
|
@Column(name = "sort", nullable = false)
|
||||||
|
private Integer sort = 0;
|
||||||
|
|
||||||
|
/** 1 = 进候选池,0 = 下线 */
|
||||||
|
@Column(name = "enabled", nullable = false)
|
||||||
|
private Integer enabled = 1;
|
||||||
|
|
||||||
|
@Column(name = "update_time")
|
||||||
|
private Date updateTime;
|
||||||
|
|
||||||
|
/** 管理端视图:scopeType=1/2 时点名的学校 id 列表(非持久化,随 SaveWidgetCatalog 一并提交) */
|
||||||
|
@Transient
|
||||||
|
private List<Integer> scopeSchIds;
|
||||||
|
|
||||||
|
/** 管理端视图(校管视角):本校别名(widget_school_setting.alias,非持久化) */
|
||||||
|
@Transient
|
||||||
|
private String alias;
|
||||||
|
|
||||||
|
/** 管理端视图(校管视角):超管下发的全局名称(非持久化,用于弹窗回显原名) */
|
||||||
|
@Transient
|
||||||
|
private String originalName;
|
||||||
|
|
||||||
|
/** 管理端视图(校管视角):本校是否已有覆盖行(非持久化) */
|
||||||
|
@Transient
|
||||||
|
private Boolean covered;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.yunhai.userhome.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.GenerationType;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.persistence.UniqueConstraint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组件可见范围点名表(widget_catalog_school):只存被点名的学校行。
|
||||||
|
*
|
||||||
|
* <p>配合 {@link WidgetCatalog#getScopeType()}:1(白名单)= 表中有记录的学校可见;
|
||||||
|
* 2(黑名单)= 表中有记录的学校不可见。</p>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026-10-11
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "widget_catalog_school",
|
||||||
|
uniqueConstraints = @UniqueConstraint(name = "uk_widget_school", columnNames = {"widget_id", "sch_id"}))
|
||||||
|
public class WidgetCatalogSchool {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
/** widget_catalog.id */
|
||||||
|
@Column(name = "widget_id", nullable = false)
|
||||||
|
private Integer widgetId;
|
||||||
|
|
||||||
|
/** 被点名的学校 id */
|
||||||
|
@Column(name = "sch_id", nullable = false)
|
||||||
|
private Integer schId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.yunhai.userhome.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.GenerationType;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.persistence.UniqueConstraint;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组件校级覆盖层(widget_school_setting):一校一行。
|
||||||
|
*
|
||||||
|
* <p>字段为 NULL 表示沿用全局值,生效值 = COALESCE(本行, widget_catalog 全局值);
|
||||||
|
* 「一键清除覆盖」= 删除本行。</p>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026-10-11
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "widget_school_setting",
|
||||||
|
uniqueConstraints = @UniqueConstraint(name = "uk_widget_sch", columnNames = {"widget_id", "sch_id"}))
|
||||||
|
public class WidgetSchoolSetting {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(name = "widget_id", nullable = false)
|
||||||
|
private Integer widgetId;
|
||||||
|
|
||||||
|
@Column(name = "sch_id", nullable = false)
|
||||||
|
private Integer schId;
|
||||||
|
|
||||||
|
/** NULL = 沿用全局 enabled */
|
||||||
|
@Column(name = "enabled")
|
||||||
|
private Integer enabled;
|
||||||
|
|
||||||
|
/** NULL = 沿用全局 role_types */
|
||||||
|
@Column(name = "role_types", length = 16)
|
||||||
|
private String roleTypes;
|
||||||
|
|
||||||
|
/** NULL = 沿用全局 sort */
|
||||||
|
@Column(name = "sort_no")
|
||||||
|
private Integer sortNo;
|
||||||
|
|
||||||
|
/** 本校别名,NULL = 沿用全局 name */
|
||||||
|
@Column(name = "alias", length = 64)
|
||||||
|
private String alias;
|
||||||
|
|
||||||
|
/** 本校覆盖的外跳子应用 code,NULL = 沿用全局 link_app_code */
|
||||||
|
@Column(name = "link_app_code", length = 32)
|
||||||
|
private String linkAppCode;
|
||||||
|
|
||||||
|
/** 本校覆盖的外跳路由,NULL = 沿用全局 link_path */
|
||||||
|
@Column(name = "link_path", length = 128)
|
||||||
|
private String linkPath;
|
||||||
|
|
||||||
|
@Column(name = "update_time")
|
||||||
|
private Date updateTime;
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.yunhai.userhome.repository;
|
|
||||||
|
|
||||||
import com.yunhai.userhome.entity.AppCategory;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public interface AppCategoryRepository extends JpaRepository<AppCategory, Integer>, JpaSpecificationExecutor<AppCategory> {
|
|
||||||
|
|
||||||
List<AppCategory> findAllByRoleType(Integer roleType);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.yunhai.userhome.repository;
|
||||||
|
|
||||||
|
import com.yunhai.userhome.entity.WidgetCatalog;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WidgetCatalogRepository extends JpaRepository<WidgetCatalog, Integer>,
|
||||||
|
JpaSpecificationExecutor<WidgetCatalog> {
|
||||||
|
|
||||||
|
Optional<WidgetCatalog> findByCode(String code);
|
||||||
|
|
||||||
|
List<WidgetCatalog> findByEnabledOrderBySortAscIdAsc(Integer enabled);
|
||||||
|
|
||||||
|
List<WidgetCatalog> findAllByOrderBySortAscIdAsc();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某校「可见范围命中」的目录行(不含启用状态、角色过滤与校级覆盖,由服务层合成)。
|
||||||
|
* 一处 SQL 决定 scope_type 语义:0 全放;1 仅点名表命中;2 仅点名表未命中。
|
||||||
|
*/
|
||||||
|
@Query("select w from WidgetCatalog w where coalesce(w.scopeType, 0) = 0 "
|
||||||
|
+ "or (coalesce(w.scopeType, 0) = 1 and exists "
|
||||||
|
+ "(select 1 from WidgetCatalogSchool s where s.widgetId = w.id and s.schId = :schId)) "
|
||||||
|
+ "or (coalesce(w.scopeType, 0) = 2 and not exists "
|
||||||
|
+ "(select 1 from WidgetCatalogSchool s where s.widgetId = w.id and s.schId = :schId)) "
|
||||||
|
+ "order by w.sort asc, w.id asc")
|
||||||
|
List<WidgetCatalog> findVisibleForSchool(@Param("schId") Integer schId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.yunhai.userhome.repository;
|
||||||
|
|
||||||
|
import com.yunhai.userhome.entity.WidgetCatalogSchool;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组件可见范围点名表访问。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026-10-11
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface WidgetCatalogSchoolRepository extends JpaRepository<WidgetCatalogSchool, Integer> {
|
||||||
|
|
||||||
|
/** 命中判定:某组件是否点名了某校(scope_type 白/黑名单共用) */
|
||||||
|
boolean existsByWidgetIdAndSchId(Integer widgetId, Integer schId);
|
||||||
|
|
||||||
|
List<WidgetCatalogSchool> findByWidgetId(Integer widgetId);
|
||||||
|
|
||||||
|
List<WidgetCatalogSchool> findByWidgetIdIn(Collection<Integer> widgetIds);
|
||||||
|
|
||||||
|
void deleteByWidgetId(Integer widgetId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.yunhai.userhome.repository;
|
||||||
|
|
||||||
|
import com.yunhai.userhome.entity.WidgetSchoolSetting;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组件校级覆盖层访问。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026-10-11
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface WidgetSchoolSettingRepository extends JpaRepository<WidgetSchoolSetting, Integer> {
|
||||||
|
|
||||||
|
Optional<WidgetSchoolSetting> findByWidgetIdAndSchId(Integer widgetId, Integer schId);
|
||||||
|
|
||||||
|
List<WidgetSchoolSetting> findBySchId(Integer schId);
|
||||||
|
|
||||||
|
List<WidgetSchoolSetting> findByWidgetId(Integer widgetId);
|
||||||
|
|
||||||
|
void deleteByWidgetId(Integer widgetId);
|
||||||
|
}
|
||||||
@@ -16,9 +16,31 @@ import java.util.Optional;
|
|||||||
*/
|
*/
|
||||||
public interface AppRemoteService {
|
public interface AppRemoteService {
|
||||||
|
|
||||||
/** 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。 */
|
/**
|
||||||
|
* 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。
|
||||||
|
* 不带学校上下文:由 UserBase 侧按「当前激活身份学校 → 主归属学校」兜底推导生效学校。
|
||||||
|
*/
|
||||||
List<BoaAppType> getUserApps(BoaUser user);
|
List<BoaAppType> getUserApps(BoaUser user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按学校上下文取用户可访问应用全集(2026-10-12 学校单点开通硬闸门贯通)。<br>
|
||||||
|
* {@code schoolId} 非空 → UserBase 按该校自身单点开通集合收窄;为空 → 服务端按身份/主归属兜底。
|
||||||
|
*
|
||||||
|
* @param user 登录用户
|
||||||
|
* @param schoolId 学校上下文(可为 null)
|
||||||
|
*/
|
||||||
|
List<BoaAppType> getUserApps(BoaUser user, Integer schoolId);
|
||||||
|
|
||||||
/** 按应用ID取单个应用(在已拉取的全集中查找,避免额外跨仓请求)。 */
|
/** 按应用ID取单个应用(在已拉取的全集中查找,避免额外跨仓请求)。 */
|
||||||
Optional<BoaApp> getAppById(BoaUser user, Integer appId);
|
Optional<BoaApp> getAppById(BoaUser user, Integer appId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按应用ID取单个应用,并按学校上下文收窄全集(与 {@link #getUserApps(BoaUser, Integer)} 同口径,
|
||||||
|
* 避免「列表已按校收窄、单个查询仍全集」的不一致)。
|
||||||
|
*
|
||||||
|
* @param user 登录用户
|
||||||
|
* @param appId 应用ID
|
||||||
|
* @param schoolId 学校上下文(可为 null)
|
||||||
|
*/
|
||||||
|
Optional<BoaApp> getAppById(BoaUser user, Integer appId, Integer schoolId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package com.yunhai.userhome.service;
|
|
||||||
|
|
||||||
import com.yunhai.userhome.entity.AppCategory;
|
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface IAppCategoryService {
|
|
||||||
|
|
||||||
void save(AppCategory appCategory);
|
|
||||||
|
|
||||||
void deleteById(Integer id);
|
|
||||||
|
|
||||||
Optional<AppCategory> findById(Integer id);
|
|
||||||
|
|
||||||
List<AppCategory> findByRoleType(Integer roleType);
|
|
||||||
|
|
||||||
Page<AppCategory> findPageList(Integer page, Integer count, Integer roleType, String name);
|
|
||||||
|
|
||||||
void updateResDate(Integer id);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.yunhai.userhome.service;
|
||||||
|
|
||||||
|
import com.yunhai.userhome.entity.WidgetCatalog;
|
||||||
|
import com.yunhai.userhome.entity.WidgetSchoolSetting;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组件候选池服务。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026-10-11
|
||||||
|
*/
|
||||||
|
public interface IWidgetCatalogService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 候选池(登录侧):可见范围命中 + 生效启用 + 角色命中,按生效 sort 升序。
|
||||||
|
*
|
||||||
|
* @param schId 当前登录校(用于 scope_type 点名判定与校级覆盖合成)
|
||||||
|
* @param roleId 当前登录角色 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生),用于 role_types 过滤
|
||||||
|
* @return 生效值视图(name 已按「校级别名 > 角色命名 > 名称」合成)
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -101,6 +101,102 @@ public class MsgCenterProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* by-code 解析条目(UserBase /oapi/app/by-code/{appCode},值域=消息 jump_app)。
|
||||||
|
* appId=注册表主键 id(2026-10-13 160 实测与 /oapi/app/list 同体系:notice 主键=100001、
|
||||||
|
* 流程中心主键=100011/app_key=100009,无混体系风险)。
|
||||||
|
*/
|
||||||
|
private static final class ByCodeEntry {
|
||||||
|
final int appId;
|
||||||
|
final String appKey;
|
||||||
|
final String name;
|
||||||
|
final String pcUrl;
|
||||||
|
final String code;
|
||||||
|
|
||||||
|
ByCodeEntry(int appId, String appKey, String name, String pcUrl, String code) {
|
||||||
|
this.appId = appId;
|
||||||
|
this.appKey = appKey;
|
||||||
|
this.name = name;
|
||||||
|
this.pcUrl = pcUrl;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查无哨兵(ConcurrentHashMap 不允许 null 值):脏 code 不重复出站 */
|
||||||
|
private static final ByCodeEntry CODE_MISS = new ByCodeEntry(-1, null, null, null, null);
|
||||||
|
|
||||||
|
/** by-code 缓存本体与时间戳(TTL 对齐 {@link #APP_DICT_TTL_MS}) */
|
||||||
|
private final java.util.concurrent.ConcurrentHashMap<String, ByCodeEntry> byCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
private final java.util.concurrent.ConcurrentHashMap<String, Long> byCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* by-code 应用缓存(TTL 对齐 {@link #APP_DICT_TTL_MS}):一次调用同时取 name/appId/pcUrl,
|
||||||
|
* 消掉逐行 by-code 查址的 N+1(旧实现每行现查)。UserBase 不可达复用
|
||||||
|
* {@link #userbaseDownUntil} 短路窗口;返回 null=不可达(调用方降级),返回
|
||||||
|
* {@link #CODE_MISS}=查无(回落发送方口径)。
|
||||||
|
*/
|
||||||
|
private ByCodeEntry lookupByCode(String appCode) {
|
||||||
|
if (appCode == null || appCode.isEmpty()) {
|
||||||
|
return CODE_MISS;
|
||||||
|
}
|
||||||
|
Long at = byCodeAt.get(appCode);
|
||||||
|
boolean fresh = at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS;
|
||||||
|
if (fresh) {
|
||||||
|
ByCodeEntry cached = byCodeCache.get(appCode);
|
||||||
|
return cached == null ? CODE_MISS : cached;
|
||||||
|
}
|
||||||
|
if (userbaseDown()) {
|
||||||
|
ByCodeEntry stale = byCodeCache.get(appCode);
|
||||||
|
return stale == null ? CODE_MISS : stale;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-code/" + appCode, null, JsonNode.class);
|
||||||
|
userbaseDownUntil = 0L;
|
||||||
|
ByCodeEntry entry = app != null && app.hasNonNull("id")
|
||||||
|
? new ByCodeEntry(app.get("id").asInt(),
|
||||||
|
app.hasNonNull("appKey") ? String.valueOf(app.get("appKey").asInt()) : null,
|
||||||
|
text(app, "name"), text(app, "pcUrl"), text(app, "appCode"))
|
||||||
|
: CODE_MISS;
|
||||||
|
byCodeCache.put(appCode, entry);
|
||||||
|
byCodeAt.put(appCode, System.currentTimeMillis());
|
||||||
|
return entry;
|
||||||
|
} catch (Exception e) {
|
||||||
|
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
|
||||||
|
log.warn("[msgcenter] UserBase by-code 解析不可达(降级回落发送方口径)code={}:{}", appCode, e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** appKey→app_code 反查缓存(list 的 appId 双值翻译用;空串值=查无,退化为单值过滤) */
|
||||||
|
private final java.util.concurrent.ConcurrentHashMap<String, String> appCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
private final java.util.concurrent.ConcurrentHashMap<String, Long> appCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** appKey→app_code 反查(/oapi/app/by-key,VO 含 appCode):首次查询后缓存,查无/不可达返回 null */
|
||||||
|
private String appCodeByKey(String appKey) {
|
||||||
|
if (appKey == null || appKey.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Long at = appCodeAt.get(appKey);
|
||||||
|
if (at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS) {
|
||||||
|
return appCodeCache.get(appKey);
|
||||||
|
}
|
||||||
|
if (appCodeCache.containsKey(appKey) && userbaseDown()) {
|
||||||
|
return appCodeCache.get(appKey);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-key/" + appKey, null, JsonNode.class);
|
||||||
|
userbaseDownUntil = 0L;
|
||||||
|
String code = app == null || app.isNull() ? null : text(app, "appCode");
|
||||||
|
appCodeCache.put(appKey, code == null ? "" : code);
|
||||||
|
appCodeAt.put(appKey, System.currentTimeMillis());
|
||||||
|
return StringUtils.hasText(code) ? code : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
|
||||||
|
log.warn("[msgcenter] UserBase by-key 反查 app_code 不可达(退化单值过滤)appKey={}:{}", appKey, e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用字典(UserBase /oapi/app/list 按用户授权全量,TTL {@link #APP_DICT_TTL_MS})。
|
* 应用字典(UserBase /oapi/app/list 按用户授权全量,TTL {@link #APP_DICT_TTL_MS})。
|
||||||
* 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 appSecret,仅取 id/appKey/name。
|
* 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 appSecret,仅取 id/appKey/name。
|
||||||
@@ -237,7 +333,14 @@ public class MsgCenterProxyService {
|
|||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
return empty; // 字典查无此应用:视作该应用无消息
|
return empty; // 字典查无此应用:视作该应用无消息
|
||||||
}
|
}
|
||||||
query.setAppKey(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);
|
||||||
|
}
|
||||||
|
query.setAppKeys(appKeys);
|
||||||
}
|
}
|
||||||
query.setIsRead(isRead);
|
query.setIsRead(isRead);
|
||||||
query.setKeyword(StringUtils.hasText(keyword) ? keyword.trim() : null);
|
query.setKeyword(StringUtils.hasText(keyword) ? keyword.trim() : null);
|
||||||
@@ -327,7 +430,9 @@ public class MsgCenterProxyService {
|
|||||||
/**
|
/**
|
||||||
* 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName:
|
* 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName:
|
||||||
* [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。
|
* [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。
|
||||||
* appId 取应用字典(按用户授权列表),字典查无(如授权已回收的来源应用)时 appId=null,前端不展示。
|
* UserMsg 桶值为归属口径 code-or-key 混合(代发=业务方 app_code、自发=app_key):
|
||||||
|
* 逐桶先 byCode 解析、未命中回落字典 byKey(发送方),解析出应用后按 appId 合并 total/unread
|
||||||
|
* (同一应用的自发桶与代发货桶并成一类目)。双查无的桶跳过(与旧「字典查无即丢」一致)。
|
||||||
* schId=当前身份学校口径(null=全部)。停机降级空列表。
|
* schId=当前身份学校口径(null=全部)。停机降级空列表。
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> countByApp(Integer userId, Integer schId) {
|
public List<Map<String, Object>> countByApp(Integer userId, Integer schId) {
|
||||||
@@ -341,20 +446,49 @@ public class MsgCenterProxyService {
|
|||||||
return empty;
|
return empty;
|
||||||
}
|
}
|
||||||
AppDict dict = appDict(userId);
|
AppDict dict = appDict(userId);
|
||||||
List<Map<String, Object>> out = new ArrayList<>();
|
Map<Object, Map<String, Object>> merged = new LinkedHashMap<>();
|
||||||
for (AppMsgCount a : counts) {
|
for (AppMsgCount a : counts) {
|
||||||
String appKey = a == null ? null : a.getAppKey();
|
String bucket = a == null ? null : a.getAppKey();
|
||||||
if (appKey == null) {
|
if (bucket == null || bucket.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
AppDictEntry entry = dict.byKey.get(appKey);
|
AppDictEntry entry = dict.byKey.get(bucket);
|
||||||
Map<String, Object> row = new LinkedHashMap<>();
|
// 纯数字桶值不可能是 app_code,免一次注定查无的出站;
|
||||||
row.put("appId", entry == null ? null : entry.appId);
|
// 数字脏桶(字典查无)维持现状——行保留、appId=null 由前端隐藏
|
||||||
row.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name);
|
ByCodeEntry codeEntry = entry == null && !bucket.matches("\\d+")
|
||||||
row.put("total", a.getTotal());
|
? lookupByCode(bucket) : null;
|
||||||
row.put("unread", a.getUnread());
|
Integer appId;
|
||||||
out.add(row);
|
String name;
|
||||||
|
if (entry != null) {
|
||||||
|
appId = entry.appId;
|
||||||
|
name = entry.name;
|
||||||
|
} else if (codeEntry != null && codeEntry != CODE_MISS) {
|
||||||
|
appId = codeEntry.appId;
|
||||||
|
name = codeEntry.name;
|
||||||
|
} else if (codeEntry == CODE_MISS) {
|
||||||
|
continue; // code 脏桶双查无:丢弃,与改造前一致
|
||||||
|
} else {
|
||||||
|
appId = null;
|
||||||
|
name = properties.getAppNames().get(bucket);
|
||||||
}
|
}
|
||||||
|
// 合桶键=注册表主键 appId:自发桶(byKey)与代发桶(byCode)同应用并成一类目
|
||||||
|
Map<String, Object> row = merged.get(appId);
|
||||||
|
if (row == null) {
|
||||||
|
row = new LinkedHashMap<>();
|
||||||
|
row.put("appId", appId);
|
||||||
|
row.put("appName", name);
|
||||||
|
row.put("total", 0L);
|
||||||
|
row.put("unread", 0L);
|
||||||
|
merged.put(appId, row);
|
||||||
|
}
|
||||||
|
row.put("total", (Long) row.get("total") + a.getTotal());
|
||||||
|
row.put("unread", (Long) row.get("unread") + a.getUnread());
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> out = new ArrayList<>(merged.values());
|
||||||
|
out.sort((x, y) -> {
|
||||||
|
int c = Long.compare((Long) y.get("total"), (Long) x.get("total"));
|
||||||
|
return c != 0 ? c : Long.compare((Long) y.get("unread"), (Long) x.get("unread"));
|
||||||
|
});
|
||||||
return out;
|
return out;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[msgcenter] UserMsg 按应用计数不可达(降级空)userId={}:{}", userId, e.toString());
|
log.warn("[msgcenter] UserMsg 按应用计数不可达(降级空)userId={}:{}", userId, e.toString());
|
||||||
@@ -451,8 +585,9 @@ public class MsgCenterProxyService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。
|
* MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。
|
||||||
* 应用标识对前端只出 appId/appName(应用字典富化):appKey 属后端签名标识不下发;
|
* 应用标识对前端只出 appId/appName:归属换源(页面属主优先)——jumpApp(值域=app_code)可解析时
|
||||||
* 字典查无(授权已回收等)appId/appName 为 null,前端按类型标签降级。
|
* 出参指向业务方应用,为空/查无/不可达回落发送方 appKey(字典 byKey,再落手工名称表)。
|
||||||
|
* appKey 属后端签名标识不下发;两口径均查无时 appId/appName 为 null,前端按类型标签降级。
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) {
|
private Map<String, Object> rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) {
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
@@ -463,10 +598,33 @@ public class MsgCenterProxyService {
|
|||||||
out.put("jumpType", row.getJumpType());
|
out.put("jumpType", row.getJumpType());
|
||||||
out.put("jumpPath", row.getJumpPath());
|
out.put("jumpPath", row.getJumpPath());
|
||||||
out.put("jumpApp", row.getJumpApp());
|
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();
|
String appKey = row.getAppKey();
|
||||||
AppDictEntry entry = appKey == null ? null : dict.byKey.get(appKey);
|
entry = appKey == null ? null : dict.byKey.get(appKey);
|
||||||
out.put("appId", entry == null ? null : entry.appId);
|
if (entry != null) {
|
||||||
out.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name);
|
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("bizType", row.getBizType());
|
||||||
out.put("bizId", row.getBizId());
|
out.put("bizId", row.getBizId());
|
||||||
out.put("sendTime", row.getSendTime());
|
out.put("sendTime", row.getSendTime());
|
||||||
@@ -475,9 +633,8 @@ public class MsgCenterProxyService {
|
|||||||
// showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改);
|
// showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改);
|
||||||
// jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行
|
// jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行
|
||||||
Boolean allowed = null;
|
Boolean allowed = null;
|
||||||
String jumpApp = row.getJumpApp();
|
if (jumpUrl != null && StringUtils.hasText(ownerCode)) {
|
||||||
if (jumpUrl != null && StringUtils.hasText(jumpApp)) {
|
allowed = authz.authorized(ownerCode);
|
||||||
allowed = authz.authorized(jumpApp.trim());
|
|
||||||
}
|
}
|
||||||
boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed);
|
boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed);
|
||||||
if (Boolean.FALSE.equals(allowed)) {
|
if (Boolean.FALSE.equals(allowed)) {
|
||||||
@@ -531,9 +688,14 @@ public class MsgCenterProxyService {
|
|||||||
return base + "#" + p;
|
return base + "#" + p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册表 pc_url 查询(by-code 主查)。UserBase 不可达/查无 → null,失败进短路窗口 */
|
/**
|
||||||
|
* 注册表 pc_url 查询(by-code 主查):走 by-code 缓存,一次调用同取 name 与 pcUrl
|
||||||
|
* (消掉旧逐行现查的 N+1)。不可达短路窗口内沿用已缓存值(旧 pcUrl 拼接语义不变,
|
||||||
|
* 查不到/无缓存才隐藏跳转入口)。
|
||||||
|
*/
|
||||||
private String pcUrlByCode(String appCode) {
|
private String pcUrlByCode(String appCode) {
|
||||||
return lookupPcUrl("/oapi/app/by-code/" + appCode);
|
ByCodeEntry entry = lookupByCode(appCode.trim());
|
||||||
|
return entry == null || entry == CODE_MISS ? null : entry.pcUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册表 pc_url 查询(发送方 appKey 回落,appKey 是 app_key 列非主键 id) */
|
/** 注册表 pc_url 查询(发送方 appKey 回落,appKey 是 app_key 列非主键 id) */
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -89,9 +89,11 @@ public class AppearanceServiceImpl implements IAppearanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源;2026-10-09 JSON 化+LOGO 文字):
|
* 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源;2026-10-09 JSON 化;
|
||||||
* {@code {theme, avatar, logoText, logoUrl}}——theme 为【JSON 对象】(个人自选 → 学校默认 → 平台默认(schId=0)
|
* 同日定稿摘除 logoText——子应用 header 定稿只出 logo 图,文字仅门户消费且走 /api/schLogoText 自有链,
|
||||||
* 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);avatar 为生效样式;logoText 本校→平台级联;
|
* init 侧对缺失字段按 null 语义自动清 yh-logo-text 本地键):
|
||||||
|
* {@code {theme, avatar, logoUrl}}——theme 为【JSON 对象】(个人自选 → 学校默认 → 平台默认(schId=0)
|
||||||
|
* 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);avatar 为生效样式;
|
||||||
* logoUrl 为本校 type=1 → 平台 type=0 择优。
|
* logoUrl 为本校 type=1 → 平台 type=0 择优。
|
||||||
*
|
*
|
||||||
* <p>schId 解析走本地 user_current_role(oapi 入参只有 userId,不引 identity 跨仓):roleId 当前角色学校,
|
* <p>schId 解析走本地 user_current_role(oapi 入参只有 userId,不引 identity 跨仓):roleId 当前角色学校,
|
||||||
@@ -103,7 +105,6 @@ public class AppearanceServiceImpl implements IAppearanceService {
|
|||||||
Map<String, Object> out = new HashMap<>();
|
Map<String, Object> out = new HashMap<>();
|
||||||
out.put("theme", null);
|
out.put("theme", null);
|
||||||
out.put("avatar", null);
|
out.put("avatar", null);
|
||||||
out.put("logoText", null);
|
|
||||||
out.put("logoUrl", null);
|
out.put("logoUrl", null);
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
return out;
|
return out;
|
||||||
@@ -132,10 +133,9 @@ public class AppearanceServiceImpl implements IAppearanceService {
|
|||||||
if (avatar == null) {
|
if (avatar == null) {
|
||||||
avatar = schSettingsService.readAvatarWithFallback(schId).getValue();
|
avatar = schSettingsService.readAvatarWithFallback(schId).getValue();
|
||||||
}
|
}
|
||||||
out.put("logoText", schSettingsService.readLogoTextWithFallback(schId).getValue());
|
|
||||||
out.put("logoUrl", resolveLogoUrl(schId));
|
out.put("logoUrl", resolveLogoUrl(schId));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 学校/平台段降级:theme/avatar/logoText/logoUrl 保持已得值或 null
|
// 学校/平台段降级:theme/avatar/logoUrl 保持已得值或 null
|
||||||
}
|
}
|
||||||
// theme 出 JSON 对象(2026-10-09 契约);值已过 decodeThemeState 校验,解析失败按 null 降级
|
// theme 出 JSON 对象(2026-10-09 契约);值已过 decodeThemeState 校验,解析失败按 null 降级
|
||||||
out.put("theme", theme == null ? null : ThemeCodec.toJsonNode(theme));
|
out.put("theme", theme == null ? null : ThemeCodec.toJsonNode(theme));
|
||||||
|
|||||||
@@ -21,19 +21,35 @@ public class OpenApiAppRemoteServiceImpl implements AppRemoteService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<BoaAppType> getUserApps(BoaUser user) {
|
public List<BoaAppType> getUserApps(BoaUser user) {
|
||||||
|
return getUserApps(user, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BoaAppType> getUserApps(BoaUser user, Integer schoolId) {
|
||||||
if (user == null || user.getId() == null) {
|
if (user == null || user.getId() == null) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
// PC 浏览器端;UserBase 侧已按 userId 权限过滤(组开通 ∪ 角色模块所属应用,超管全量)
|
// 前端 getCurSchId 对无学校身份(超管/平台账号)下发哨兵 -1(store: schId || -1),非合法学校主键;
|
||||||
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER);
|
// UserBase 2026-10-12 硬闸门对显式 schoolId 先过 assertSchoolId,-1 会被拒("学校分组不存在"),
|
||||||
|
// 且超管豁免(isSuperAdmin bypass)在校验之后根本走不到——边界归一为 null 交服务端按激活身份/主归属兜底。
|
||||||
|
if (schoolId != null && schoolId <= 0) {
|
||||||
|
schoolId = null;
|
||||||
|
}
|
||||||
|
// PC 浏览器端;schoolId 透传 UserBase 做学校单点开通收窄(为空则服务端按身份/主归属兜底)
|
||||||
|
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER, schoolId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<BoaApp> getAppById(BoaUser user, Integer appId) {
|
public Optional<BoaApp> getAppById(BoaUser user, Integer appId) {
|
||||||
|
return getAppById(user, appId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<BoaApp> getAppById(BoaUser user, Integer appId, Integer schoolId) {
|
||||||
if (appId == null) {
|
if (appId == null) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
return getUserApps(user).stream()
|
return getUserApps(user, schoolId).stream()
|
||||||
.filter(t -> t.getApps() != null)
|
.filter(t -> t.getApps() != null)
|
||||||
.flatMap(t -> t.getApps().stream())
|
.flatMap(t -> t.getApps().stream())
|
||||||
.filter(a -> appId.equals(a.getId()))
|
.filter(a -> appId.equals(a.getId()))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,26 @@ yh:
|
|||||||
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
|
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
|
||||||
base-host: http://base.yunhaixingtong.com
|
base-host: http://base.yunhaixingtong.com
|
||||||
base-path: /userbase
|
base-path: /userbase
|
||||||
|
call:
|
||||||
|
# YschCall 直调目标目录(寻址优先级高于 yh.open.services 与 PlatformService 枚举兜底)。
|
||||||
|
# acasyllabu:课程表 oapi(小组件 syllabus 取数)。base-path 即目标 ctx,
|
||||||
|
# 客户端签名按该 ctx 剥离(待签 target = /oapi/timetable/Get?...,不含 /aca-syllabu)。
|
||||||
|
# ⚠️ 真实 ctx = /aca-syllabu(带横线,20261013 生产实测 401 定位);手册写的 /acasyllabu 有误。
|
||||||
|
# 本地联调:AcaSyllabu 在 8109 且无 ctx ⇒ 覆盖 base-host: http://localhost:8109、base-path 留空。
|
||||||
|
# base-host 指 open-gateway(开放网关,容器网地址):20261015 切门收口,oapi 统一经网关
|
||||||
|
# 验签/转发/台账;签名口径不变(网关 canonical 与 starter 逐字节一致)。base-path 即
|
||||||
|
# 网关 target 段(= 目标 ctx)。
|
||||||
|
targets:
|
||||||
|
acasyllabu:
|
||||||
|
base-host: http://open-gateway-service:10013
|
||||||
|
base-path: /aca-syllabu
|
||||||
|
# workplan:一周安排 oapi(小组件 weekplan 取数)。base-path 即目标 ctx,
|
||||||
|
# 客户端签名按该 ctx 剥离(待签 target = /oapi/workplan/Get?...,不含 /work-week-plan)。
|
||||||
|
# 真实 ctx = /work-week-plan(20261015 网关签名 200 实测,网关注册表已登记该 target)。
|
||||||
|
# 本地联调:WorkWeekPlan 在 8110 且无 ctx ⇒ 覆盖 base-host: http://localhost:8110、base-path 留空。
|
||||||
|
workplan:
|
||||||
|
base-host: http://open-gateway-service:10013
|
||||||
|
base-path: /work-week-plan
|
||||||
# env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见
|
# env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见
|
||||||
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
|
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
|
||||||
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
|
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 小组件候选池 widget_catalog(UserHome小组件内置化落地方案-20261009 §3.1)
|
||||||
|
-- 候选池真源由旧 bsch 开放接口换成本地表;内容自持,全表没有 url / html 地址字段。
|
||||||
|
-- comp_type=1 → 内容在 front 工程(内置 Vue 组件),本表只登记键;
|
||||||
|
-- comp_type=2 → 内容在 content(管理端贴/传,srcdoc 渲染)。
|
||||||
|
-- dev 由 JPA ddl-auto=update 依实体自动建表;测试/生产等关闭 ddl-auto 的环境手工执行本文件。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `widget_catalog` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`code` VARCHAR(32) NOT NULL COMMENT '组件键:内置=前端注册表键,自定义=自定义标识',
|
||||||
|
`name` VARCHAR(64) NOT NULL COMMENT '展示名(候选池文案 / 面板标题)',
|
||||||
|
`comp_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=front 内置组件 2=自定义 HTML 片段',
|
||||||
|
`content` MEDIUMTEXT NULL COMMENT 'comp_type=2 的 HTML 源码;内置类型恒空',
|
||||||
|
`data_code` VARCHAR(32) NULL COMMENT '片段取数用的数据键(须在 WidgetDataService 白名单内)',
|
||||||
|
`sch_id` INT NULL COMMENT '下发范围:NULL=全校可见(本期全校一致,字段与过滤钩子先就位)',
|
||||||
|
`sort` INT NOT NULL DEFAULT 0 COMMENT '候选池排序',
|
||||||
|
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1=进候选池 0=下线',
|
||||||
|
`update_time` DATETIME NULL COMMENT '内容最后修改时间(管理端展示)',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_widget_catalog_code` (`code`)
|
||||||
|
) ENGINE = InnoDB
|
||||||
|
DEFAULT CHARSET = utf8mb4
|
||||||
|
COMMENT = '小组件候选池(内容自持,无外部 html 托管依赖)';
|
||||||
|
|
||||||
|
-- 初始化三个存量组件(判定规则 §9:全部内置,content 留空)
|
||||||
|
-- code 上有唯一键,用 INSERT IGNORE 保证脚本可重复执行
|
||||||
|
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
|
||||||
|
VALUES ('evaluate', '听评课', 1, NULL, NULL, NULL, 1, 1, NOW());
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
|
||||||
|
VALUES ('syllabus', '课程表', 1, NULL, NULL, NULL, 2, 1, NOW());
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
|
||||||
|
VALUES ('weekplan', '一周安排', 1, NULL, NULL, NULL, 3, 1, NOW());
|
||||||
|
|
||||||
|
-- 回滚:
|
||||||
|
-- DROP TABLE IF EXISTS `widget_catalog`;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 小组件管理改版 v2(doc/小组件管理改版-执行方案.md §3)
|
||||||
|
-- dev 由 JPA ddl-auto=update 依实体自动加列/建表;本文件供关闭 ddl-auto 的测试/生产环境手工执行一次。
|
||||||
|
-- 幂等性:CREATE TABLE 用 IF NOT EXISTS;ALTER ADD COLUMN 在 MySQL 8 无 IF NOT EXISTS,
|
||||||
|
-- 重复执行会报 1060 Duplicate column name,属预期,按需手动跳过即可。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ---------- 1. widget_catalog 加 3 列 ----------
|
||||||
|
ALTER TABLE `widget_catalog`
|
||||||
|
ADD COLUMN `scope_type` TINYINT NOT NULL DEFAULT 0
|
||||||
|
COMMENT '下发范围:0=全部学校 1=白名单(仅点名的学校可见) 2=黑名单(点名的学校不可见)',
|
||||||
|
ADD COLUMN `role_types` VARCHAR(16) NULL
|
||||||
|
COMMENT '可见角色:逗号分隔 RoleTypeEnum(1教师/2校领导/3学生);NULL/空=全角色',
|
||||||
|
ADD COLUMN `role_alias` VARCHAR(255) NULL
|
||||||
|
COMMENT '角色专属命名(JSON 文本):{"2":"全校课表"},键=RoleTypeEnum;读侧按当前角色取值';
|
||||||
|
|
||||||
|
-- sch_id 旧语义(NULL=全校 / 单校等值)由 scope_type 取代,标记弃用:列保留以兼容存量数据,读侧不再使用。
|
||||||
|
|
||||||
|
-- ---------- 2. 新增点名表(配合 scope_type 白/黑名单) ----------
|
||||||
|
CREATE TABLE IF NOT EXISTS `widget_catalog_school` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`widget_id` INT NOT NULL COMMENT 'widget_catalog.id',
|
||||||
|
`sch_id` INT NOT NULL COMMENT '被点名的学校',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_widget_school` (`widget_id`, `sch_id`)
|
||||||
|
) ENGINE = InnoDB
|
||||||
|
DEFAULT CHARSET = utf8mb4
|
||||||
|
COMMENT = '小组件可见范围点名表(配合 scope_type 白/黑名单)';
|
||||||
|
|
||||||
|
-- ---------- 3. 新增校级覆盖层 ----------
|
||||||
|
CREATE TABLE IF NOT EXISTS `widget_school_setting` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`widget_id` INT NOT NULL,
|
||||||
|
`sch_id` INT NOT NULL,
|
||||||
|
`enabled` TINYINT NULL COMMENT 'NULL=沿用全局',
|
||||||
|
`role_types` VARCHAR(16) NULL COMMENT 'NULL=沿用全局',
|
||||||
|
`sort_no` INT NULL COMMENT 'NULL=沿用全局',
|
||||||
|
`alias` VARCHAR(64) NULL COMMENT '本校别名,NULL=沿用全局 name',
|
||||||
|
`update_time` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_widget_sch` (`widget_id`, `sch_id`)
|
||||||
|
) ENGINE = InnoDB
|
||||||
|
DEFAULT CHARSET = utf8mb4
|
||||||
|
COMMENT = '小组件校级覆盖(一键清除=删行)';
|
||||||
|
|
||||||
|
-- 存量 3 条内置目录行的 scope_type / role_types / role_alias 保持默认(0 / NULL / NULL)即「全部学校 + 全角色」。
|
||||||
|
|
||||||
|
-- 回滚:
|
||||||
|
-- ALTER TABLE `widget_catalog` DROP COLUMN `scope_type`, DROP COLUMN `role_types`, DROP COLUMN `role_alias`;
|
||||||
|
-- DROP TABLE IF EXISTS `widget_catalog_school`;
|
||||||
|
-- DROP TABLE IF EXISTS `widget_school_setting`;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 小组件外跳配置 v3(「查看概览」等外跳入口)
|
||||||
|
-- dev 由 JPA ddl-auto=update 依实体自动加列;本文件供关闭 ddl-auto 的测试/生产环境手工执行一次。
|
||||||
|
-- 幂等性:MySQL 8 的 ALTER ADD COLUMN 无 IF NOT EXISTS,重复执行报 1060 Duplicate column
|
||||||
|
-- name,属预期,按需手动跳过即可。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ---------- 1. widget_catalog 加 2 列(全局默认值) ----------
|
||||||
|
ALTER TABLE `widget_catalog`
|
||||||
|
ADD COLUMN `link_app_code` VARCHAR(32) NULL
|
||||||
|
COMMENT '外跳目标子应用:应用注册表 app_code(UserBase /oapi/app/by-code);NULL=不显示外跳入口',
|
||||||
|
ADD COLUMN `link_path` VARCHAR(128) NULL
|
||||||
|
COMMENT '外跳路由(hash 路由,如 /weekplan);地址=注册表 pc_url 归一化 + "#" + 本路径';
|
||||||
|
|
||||||
|
-- ---------- 2. widget_school_setting 加 2 列(校级覆盖,NULL=沿用全局) ----------
|
||||||
|
ALTER TABLE `widget_school_setting`
|
||||||
|
ADD COLUMN `link_app_code` VARCHAR(32) NULL COMMENT 'NULL=沿用全局 link_app_code',
|
||||||
|
ADD COLUMN `link_path` VARCHAR(128) NULL COMMENT 'NULL=沿用全局 link_path';
|
||||||
|
|
||||||
|
-- 说明:
|
||||||
|
-- 1) 库里**只存「子应用 code + 路由」**,不落绝对 URL —— 域名一律来自应用注册表
|
||||||
|
-- (UserBase /oapi/app/by-code/{appCode} → pc_url),换环境/换域名只改注册表。
|
||||||
|
-- 2) 两列必须成对配置(只填一个会被服务端拒绝),都留空 = 不显示外跳入口。
|
||||||
|
-- 3) 生效口径:COALESCE(校级覆盖行, 全局值),与 alias / sort / enabled 同口径。
|
||||||
|
|
||||||
|
-- ---------- 3.(可选)给「一周安排」开箱配上外跳 ----------
|
||||||
|
-- 也可在管理台「小组件管理 → 一周安排 → 配置 → 外跳入口」里填,效果等价。
|
||||||
|
-- UPDATE `widget_catalog` SET `link_app_code` = 'work-week-plan', `link_path` = '/weekplan' WHERE `code` = 'weekplan';
|
||||||
|
|
||||||
|
-- 回滚:
|
||||||
|
-- ALTER TABLE `widget_catalog` DROP COLUMN `link_app_code`, DROP COLUMN `link_path`;
|
||||||
|
-- ALTER TABLE `widget_school_setting` DROP COLUMN `link_app_code`, DROP COLUMN `link_path`;
|
||||||
@@ -257,14 +257,15 @@ class AppearanceServiceTest {
|
|||||||
assertTrue(theme.get("sideDeep").asBoolean());
|
assertTrue(theme.get("sideDeep").asBoolean());
|
||||||
assertFalse(theme.get("gradOn").asBoolean());
|
assertFalse(theme.get("gradOn").asBoolean());
|
||||||
assertEquals("silhouette", out.get("avatar"));
|
assertEquals("silhouette", out.get("avatar"));
|
||||||
assertNull(out.get("logoText"));
|
assertFalse(out.containsKey("logoText"), "logoText 已定稿摘除(子应用不消费 LOGO 文字)");
|
||||||
assertNull(out.get("logoUrl"));
|
assertNull(out.get("logoUrl"));
|
||||||
verify(schSettingsService, never()).readThemeWithFallback(any());
|
verify(schSettingsService, never()).readThemeWithFallback(any());
|
||||||
verify(schSettingsService, never()).readAvatarWithFallback(any());
|
verify(schSettingsService, never()).readAvatarWithFallback(any());
|
||||||
|
verify(schSettingsService, never()).readLogoTextWithFallback(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("生效值:个人未设 → 学校/平台级联(theme 出 JSON 对象)+ logoText 本校→平台级联")
|
@DisplayName("生效值:个人未设 → 学校/平台级联(theme 出 JSON 对象),出参恰三字段")
|
||||||
void effective_fallsToCascade() {
|
void effective_fallsToCascade() {
|
||||||
stubCurrentRole(3);
|
stubCurrentRole(3);
|
||||||
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
|
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
|
||||||
@@ -272,8 +273,6 @@ class AppearanceServiceTest {
|
|||||||
.thenReturn(new ISchSettingsService.LayerValue("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":false}", 0));
|
.thenReturn(new ISchSettingsService.LayerValue("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":false}", 0));
|
||||||
when(schSettingsService.readAvatarWithFallback(3))
|
when(schSettingsService.readAvatarWithFallback(3))
|
||||||
.thenReturn(new ISchSettingsService.LayerValue("text", 3));
|
.thenReturn(new ISchSettingsService.LayerValue("text", 3));
|
||||||
when(schSettingsService.readLogoTextWithFallback(3))
|
|
||||||
.thenReturn(new ISchSettingsService.LayerValue("云海教育平台", 0));
|
|
||||||
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
Map<String, Object> out = service.getEffectiveAppearance(10133);
|
Map<String, Object> out = service.getEffectiveAppearance(10133);
|
||||||
@@ -282,11 +281,9 @@ class AppearanceServiceTest {
|
|||||||
assertFalse(theme.get("sideDeep").asBoolean());
|
assertFalse(theme.get("sideDeep").asBoolean());
|
||||||
assertTrue(theme.get("gradOn").asBoolean());
|
assertTrue(theme.get("gradOn").asBoolean());
|
||||||
assertEquals("text", out.get("avatar"), "本校已配直接生效(hitSchId=本校)");
|
assertEquals("text", out.get("avatar"), "本校已配直接生效(hitSchId=本校)");
|
||||||
assertEquals("云海教育平台", out.get("logoText"), "logoText 本校空→平台层");
|
// 出参只三字段(logoText 已摘),不外露 my/sch 拆分
|
||||||
// 出参只四字段,不外露 my/sch 拆分
|
assertEquals(3, out.size());
|
||||||
assertEquals(4, out.size());
|
assertTrue(out.containsKey("theme") && out.containsKey("avatar") && out.containsKey("logoUrl"));
|
||||||
assertTrue(out.containsKey("theme") && out.containsKey("avatar")
|
|
||||||
&& out.containsKey("logoText") && out.containsKey("logoUrl"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -301,16 +298,13 @@ class AppearanceServiceTest {
|
|||||||
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
|
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
|
||||||
when(schSettingsService.readAvatarWithFallback(0))
|
when(schSettingsService.readAvatarWithFallback(0))
|
||||||
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
|
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
|
||||||
when(schSettingsService.readLogoTextWithFallback(0))
|
|
||||||
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
|
|
||||||
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
Map<String, Object> out = service.getEffectiveAppearance(10133);
|
Map<String, Object> out = service.getEffectiveAppearance(10133);
|
||||||
assertNull(out.get("theme"), "脏值落级联且两级皆无 → null");
|
assertNull(out.get("theme"), "脏值落级联且两级皆无 → null");
|
||||||
assertNull(out.get("avatar"));
|
assertNull(out.get("avatar"));
|
||||||
assertNull(out.get("logoText"));
|
|
||||||
assertNull(out.get("logoUrl"));
|
assertNull(out.get("logoUrl"));
|
||||||
assertEquals(4, out.size());
|
assertEquals(3, out.size());
|
||||||
verify(schSettingsService).readThemeWithFallback(0);
|
verify(schSettingsService).readThemeWithFallback(0);
|
||||||
verify(schSettingsService).readAvatarWithFallback(0);
|
verify(schSettingsService).readAvatarWithFallback(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ class MsgCenterProxyServiceTest {
|
|||||||
+ "\",\"name\":\"" + name + "\"}]}]"));
|
+ "\",\"name\":\"" + name + "\"}]}]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 应用字典 stub(多应用形态):合桶/双值翻译验证两桶同归一应用 */
|
||||||
|
private void stubAppDictRaw(String groupsJson) throws Exception {
|
||||||
|
when(openCall.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class)))
|
||||||
|
.thenReturn(json(groupsJson));
|
||||||
|
}
|
||||||
|
|
||||||
// ===== R2:读取口径 schId 必须来自当前激活身份(严禁会话主学校) =====
|
// ===== R2:读取口径 schId 必须来自当前激活身份(严禁会话主学校) =====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -482,7 +488,8 @@ class MsgCenterProxyServiceTest {
|
|||||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||||
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
|
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
|
||||||
assertEquals("2", query.getValue().get("msgType"));
|
assertEquals("2", query.getValue().get("msgType"));
|
||||||
assertEquals("100019", query.getValue().get("appKey"), "appId 去空白经字典翻译为 appKey 下行");
|
assertEquals("100019", query.getValue().get("appKeys"), "appId 去空白经字典翻译为归属口径 appKeys 下行");
|
||||||
|
assertFalse(query.getValue().containsKey("appKey"), "归属口径取代发送方 appKey 口径");
|
||||||
assertEquals("0", query.getValue().get("isRead"));
|
assertEquals("0", query.getValue().get("isRead"));
|
||||||
assertEquals("请假", query.getValue().get("keyword"));
|
assertEquals("请假", query.getValue().get("keyword"));
|
||||||
|
|
||||||
@@ -649,6 +656,88 @@ class MsgCenterProxyServiceTest {
|
|||||||
return (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
|
return (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 消息归属子应用收口(2026-10-13):出参归属换源、countByApp 合桶、list 双值翻译 =====
|
||||||
|
|
||||||
|
private void stubMsgList(String contentJson) throws Exception {
|
||||||
|
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||||
|
.thenReturn(dto("{\"total\":1,\"content\":" + contentJson + "}", MsgListPage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换源三态①:jumpApp 可解析 → appId/appName 指向业务方应用(页面属主优先于发送方) */
|
||||||
|
@Test
|
||||||
|
void list_jumpAppResolvable_ownershipRewrittenToBusinessApp() throws Exception {
|
||||||
|
stubAppDict(100011, "100009", "流程中心");
|
||||||
|
stubUserbaseGet("/oapi/app/by-code/notice",
|
||||||
|
"{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\",\"appCode\":\"notice\",\"pcUrl\":\"http://localhost:8077\"}");
|
||||||
|
stubMsgList("[{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/detail?serid=59\","
|
||||||
|
+ "\"appKey\":\"100009\",\"jumpApp\":\"notice\",\"bizType\":\"flow.task\",\"isRead\":0}]");
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals(100001, first.get("appId"), "归属出参跟页面属主,不跟发送方");
|
||||||
|
assertEquals("通知公告", first.get("appName"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换源三态②:jumpApp 脏值查无 → 回落发送方字典口径(现状对齐,appId/appName 走 byKey) */
|
||||||
|
@Test
|
||||||
|
void list_jumpAppDirty_fallsBackToSenderDictEntry() throws Exception {
|
||||||
|
stubAppDict(100011, "100009", "流程中心");
|
||||||
|
stubUserbaseGet("/oapi/app/by-code/svc_item", null);
|
||||||
|
stubMsgList("[{\"msgId\":1,\"appKey\":\"100009\",\"jumpApp\":\"svc_item\",\"bizType\":\"flow.task\",\"isRead\":0}]");
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals(100011, first.get("appId"), "脏 jumpApp 回落发送方 appKey 字典");
|
||||||
|
assertEquals("流程中心", first.get("appName"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换源三态③:jumpApp 为空(办结/催办按拍板留流程中心)→ 不调 by-code,直接发送方口径 */
|
||||||
|
@Test
|
||||||
|
void list_jumpAppAbsent_noByCodeCall_senderOwnership() throws Exception {
|
||||||
|
stubAppDict(100011, "100009", "流程中心");
|
||||||
|
stubMsgList("[{\"msgId\":1,\"appKey\":\"100009\",\"jumpApp\":null,\"bizType\":\"flow.result\",\"isRead\":0}]");
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals(100011, first.get("appId"));
|
||||||
|
assertEquals("流程中心", first.get("appName"));
|
||||||
|
verify(openCall, never()).get(eq("userbase"), contains("/oapi/app/by-code/"), isNull(), eq(JsonNode.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** countByApp 合桶:notice 代发桶 + 100001 自发桶解析到同一注册表主键 → 并一行、计数相加 */
|
||||||
|
@Test
|
||||||
|
void countByApp_mergesCodeAndKeyBucketsIntoOneApp() throws Exception {
|
||||||
|
stubAppDict(100001, "100001", "通知公告");
|
||||||
|
stubUserbaseGet("/oapi/app/by-code/notice",
|
||||||
|
"{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\",\"appCode\":\"notice\"}");
|
||||||
|
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
|
||||||
|
.thenReturn(listDto("[{\"appKey\":\"notice\",\"total\":2,\"unread\":2},"
|
||||||
|
+ "{\"appKey\":\"100001\",\"total\":1,\"unread\":0},"
|
||||||
|
+ "{\"appKey\":\"100009\",\"total\":3,\"unread\":1}]", AppMsgCount.class));
|
||||||
|
|
||||||
|
java.util.List<Map<String, Object>> rows = service.countByApp(201, null);
|
||||||
|
|
||||||
|
assertEquals(2, rows.size(), "notice 与 100001 两桶合并为通知公告一类目");
|
||||||
|
Map<String, Object> notice = rows.stream().filter(r -> Integer.valueOf(100001).equals(r.get("appId")))
|
||||||
|
.findFirst().orElseThrow(IllegalStateException::new);
|
||||||
|
assertEquals("通知公告", notice.get("appName"));
|
||||||
|
assertEquals(3L, notice.get("total"));
|
||||||
|
assertEquals(2L, notice.get("unread"));
|
||||||
|
assertEquals(3L, rows.get(0).get("total"), "合并后仍按总数降序");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** list 的 appId 筛选翻译改双值:app_key + app_code 下行 appKeys,不再下行 appKey */
|
||||||
|
@Test
|
||||||
|
void list_appIdFilter_translatesToDualOwnerValues() throws Exception {
|
||||||
|
stubAppDictRaw("[{\"apps\":[{\"id\":100001,\"appKey\":\"100001\",\"name\":\"通知公告\"},"
|
||||||
|
+ "{\"id\":100011,\"appKey\":\"100009\",\"name\":\"流程中心\"}]}]");
|
||||||
|
stubUserbaseGet("/oapi/app/by-key/100001", "{\"id\":100001,\"appKey\":100001,\"appCode\":\"notice\"}");
|
||||||
|
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
|
||||||
|
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
|
||||||
|
|
||||||
|
service.list(201, 1, 10, null, null, "100001", null, null, null);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||||
|
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
|
||||||
|
assertEquals("100001,notice", query.getValue().get("appKeys"), "自发桶+代发桶一次筛全");
|
||||||
|
assertFalse(query.getValue().containsKey("appKey"), "归属口径取代发送方口径");
|
||||||
|
}
|
||||||
|
|
||||||
/** 注册表命中:pc_url 含 #/route fragment → 丢 fragment、保 index.html、拼 #,无双 hash(§3.2) */
|
/** 注册表命中:pc_url 含 #/route fragment → 丢 fragment、保 index.html、拼 #,无双 hash(§3.2) */
|
||||||
@Test
|
@Test
|
||||||
void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception {
|
void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception {
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user