feat(小组件管理改版): 后端校级覆盖三态+目录权/校级权拆分+内容ETag,退役应用管理(AppCategory全家)——配套已上线front 5a19a4e;含getWidgetData传参改Caller{userId,roleId,schId}(数据层WidgetDataService随下一提交);DDL见sql/widget_catalog_v2.sql(生产需手工执行);msg by-code换源已随9370429上线不在本提交

This commit is contained in:
chenxing
2026-10-13 12:27:12 +08:00
parent 9370429455
commit 927d4b14f8
17 changed files with 778 additions and 443 deletions
@@ -0,0 +1,75 @@
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}:与 resources/sql/widget_catalog.sql 的 INSERT 一一对应 */
private static final String[][] SEEDS = {
{"evaluate", "听评课", "1"},
{"syllabus", "课程表", "2"},
{"weekplan", "一周安排", "3"},
};
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.setEnabled(ENABLED);
catalog.setUpdateTime(new Date());
widgetCatalogRepository.save(catalog);
log.info("[widget] seeding 内置目录行:{}", code);
}
} catch (Exception e) {
// seeding 失败不阻断应用启动(候选池另有管理端入口补录)
log.error("[widget] 内置目录 seeding 失败,跳过", e);
}
}
}
@@ -1,243 +0,0 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.AppCategory;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.service.IAppCategoryService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.utils.FileUtil;
import com.yunhai.userhome.utils.OtherUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* 应用设置控制器(自 systemsetting AppSettingController + SysController 迁移,1:1 对齐)。
*
* <p>与原版差异点:写操作(应用 CRUD / UpdateResDate / 模板上传保存)校验调用者
* 为超管(type==1)或校管理员(roleId∈{2,5,7})——原版任何登录用户可写,因新入口仅管理员可见,收紧。
* 查询(分页列表 / FindAppCategory / GetTempList / downloadTemp)登录即可。
*/
@RestController
@RequestMapping(Config.API_PATH)
public class AppSettingController {
@Value("${FILE_PATH:./upload/}")
private String FILE_PATH;
@Value("${FILE_WEB_PATH:}")
private String FILE_WEB_PATH;
@Autowired
private IAppCategoryService appCategoryService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
// ==================== 应用类别 CRUD ====================
/** 新增/编辑应用(JSON body,1:1 对齐原 SaveAppCategory) */
@PostMapping(value = "SaveAppCategory", produces = "application/json")
public void saveAppCategory(BoaUser authUserWithDept, @RequestBody AppCategory appCategory) {
checkAdmin(authUserWithDept);
if (appCategory.getCDate() == null) appCategory.setCDate(new Date());
if (appCategory.getResDate() == null) appCategory.setResDate(new Date());
appCategory.setCId(authUserWithDept.getId());
appCategoryService.save(appCategory);
}
@PostMapping(value = "DelAppCategory", produces = "application/json")
public void delAppCategory(BoaUser authUserWithDept, Integer id) {
checkAdmin(authUserWithDept);
appCategoryService.deleteById(id);
}
@GetMapping(value = "GetAppCategoryList")
public Map getAppCategoryList(BoaUser authUserWithDept, Integer page, Integer count,
@RequestParam(value = "roleType") Integer roleType,
@RequestParam(value = "name", required = false) String name) {
Page<AppCategory> data = appCategoryService.findPageList(page, count, roleType, name);
Map map = new HashMap();
map.put("total", data.getTotalElements());
map.put("list", data.getContent());
return map;
}
@GetMapping(value = "FindAppCategory")
public Optional<AppCategory> findAppCategory(BoaUser authUserWithDept, Integer id) {
return appCategoryService.findById(id);
}
@PostMapping(value = "UpdateResDate", produces = "application/json")
public void updateResDate(BoaUser authUserWithDept, Integer id) {
checkAdmin(authUserWithDept);
appCategoryService.updateResDate(id);
}
// ==================== 模板管理(对齐原 SysController) ====================
/** 上传模板压缩包(zip/rar),解压覆盖到 tempUpload/&lt;appModId&gt;/,删除压缩包 */
@PostMapping(value = "tempUpload", produces = "application/json")
public void tempUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file, Integer appModId) {
checkAdmin(authUserWithDept);
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "请选择上传文件");
}
String originalName = file.getOriginalFilename();
String ext = originalName == null ? "" : originalName.substring(originalName.lastIndexOf(".")).toLowerCase(Locale.ROOT);
if (!".zip".equals(ext) && !".rar".equals(ext)) {
throw new BizException(ErrorCode.CUSTOM, "仅支持 zip/rar 格式");
}
// 不走图片 5MB 校验(模板包允许更大,依赖 multipart 配置),存到 tempUpload 子目录
String uploadPath = FILE_PATH + "/tempUpload/";
File fl = new File(uploadPath);
if (!fl.exists()) fl.mkdirs();
String fileName = FileUtil.uploadFile(file, uploadPath);
String filePath = "/upload/tempUpload/" + fileName;
File unzipFiles = new File(FILE_PATH + "/tempUpload/" + appModId);
if (!unzipFiles.exists()) unzipFiles.mkdirs();
else FileUtil.delAllFile(unzipFiles.getPath());
File zipFile = new File(filePath.replace("/upload", FILE_PATH));
try {
FileUtil.zipDecompress(zipFile, unzipFiles);
} catch (Exception e) {
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
} finally {
zipFile.delete();
}
}
/** 模板文件列表(name + 访问 path) */
@GetMapping(value = "GetTempList")
public List<Map> getTempList(BoaUser authUserWithDept, Integer appModId) {
File sourceFile = new File(FILE_PATH + "/tempUpload/" + appModId);
List<Map> result = new ArrayList<>();
if (sourceFile.exists() && sourceFile.isDirectory() && sourceFile.listFiles() != null) {
Arrays.stream(sourceFile.listFiles()).forEach(file -> {
Map map = new HashMap();
map.put("name", file.getName());
map.put("path", FILE_WEB_PATH + "/tempUpload/" + appModId + "/" + file.getName());
result.add(map);
});
}
return result;
}
/** 保存模板文件内容(新增 fileName 路径穿越校验,原版没有;content 走 JSON body 避免 URL 长度限制) */
@PostMapping(value = "SaveTempFile", produces = "application/json")
public void saveTempFile(BoaUser authUserWithDept, @RequestBody Map<String, Object> body) {
checkAdmin(authUserWithDept);
Object appModIdObj = body.get("appModId");
String fileName = body.get("fileName") == null ? null : body.get("fileName").toString();
String content = body.get("content") == null ? "" : body.get("content").toString();
if (appModIdObj == null) {
throw new BizException(ErrorCode.CUSTOM, "缺少appModId");
}
Integer appModId;
try {
appModId = Integer.parseInt(appModIdObj.toString());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.CUSTOM, "非法appModId");
}
if (fileName == null || fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
throw new BizException(ErrorCode.CUSTOM, "非法文件名");
}
File dir = new File(FILE_PATH + "/tempUpload/" + appModId);
if (!dir.exists()) dir.mkdirs();
File file = new File(FILE_PATH + "/tempUpload/" + appModId + "/" + fileName);
try {
if (!file.exists()) {
file.createNewFile();
}
FileOutputStream fos = new FileOutputStream(file);
fos.write(content.getBytes());
fos.flush();
fos.close();
} catch (IOException e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "保存失败");
}
}
/** 打包下载模板目录(zip 流式下载,临时 zip 关闭即删) */
@GetMapping(value = "downloadTemp", produces = "application/json;charset=UTF-8")
public ResponseEntity<InputStreamResource> downloadTemp(BoaUser authUserWithDept, Integer appModId, String name) {
File zipFilePath = new File(FILE_PATH + "/tempUpload/" + appModId);
if (!zipFilePath.exists() || !zipFilePath.isDirectory()) {
throw new BizException(ErrorCode.CUSTOM, "模板不存在");
}
String zipName = UUID.randomUUID().toString().substring(0, 8) + ".zip";
File zipFile = new File(FILE_PATH + "/tempUpload/" + zipName);
try {
FileUtil.zipCompress(zipFilePath, zipFile);
} catch (Exception e) {
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
}
String downloadName = (name == null || name.trim().isEmpty()) ? "template" : name;
try {
InputStream inputStream = new FileInputStream(zipFile) {
@Override
public void close() throws IOException {
super.close();
zipFile.delete();
}
};
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
headers.add("Content-Disposition",
"attachment; filename=" + new String((downloadName + ".zip").getBytes("gbk"), "iso8859-1"));
headers.add("Pragma", "no-cache");
headers.add("Expires", "0");
return ResponseEntity.ok().headers(headers)
.contentType(MediaType.parseMediaType("application/octet-stream"))
.body(new InputStreamResource(inputStream));
} catch (IOException e) {
zipFile.delete();
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "下载失败");
}
}
// ==================== 私有方法 ====================
/**
* 写操作权限校验:超管(type==1) 或 校管理员(roleId∈{2,5,7}),其余 403。
*/
private void checkAdmin(BoaUser authUserWithDept) {
if (authUserWithDept.getType() == 1) {
return;
}
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
if (!currentRoleOpt.isPresent() || !OtherUtil.isAdminById(currentRoleOpt.get().getRoleId())) {
throw new BizException(ErrorCode.FORBIDDEN);
}
}
}
@@ -285,10 +285,11 @@ public class UserSettingsController {
public UserSettings getUserSettings(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
UserSettings model = new UserSettings();
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
// 候选池真源 = 自建 widget_catalog(enabled=1,sch_id 按校过滤;NULL=全校)
List<WidgetCatalog> catalogList = widgetCatalogService.findEnabledForSchool(schId);
// 候选池真源 = 自建 widget_catalog(生效启用 + 可见范围命中本校 + 可见角色命中当前角色;name 已按显示名三级链合成)
List<WidgetCatalog> catalogList = widgetCatalogService.findEnabledForSchool(schId, roleId);
if (optional.isPresent()) model = optional.get();
// 壁纸池一次取全:悬空自愈判定与兜底选择共用
List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId());
@@ -6,11 +6,13 @@ 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;
@@ -18,6 +20,8 @@ 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;
@@ -26,6 +30,9 @@ import java.util.Optional;
/**
* 小组件控制器:候选池按需取内容 + 统一取数(登录侧),以及管理端候选池读写(管理员)。
*
* <p>写权限按「目录权 / 校级权」拆开:全局目录(新增 / 改内容 / 删除 / 三态可见范围 / 角色命名)仅超管;
* 校级覆盖(启用 / 可见角色 / 排序 / 别名)超管或校管,且校管只能写本校。</p>
*
* <p>真实路径按 {@code /{platform}} 展开为 {@code /api/getWidgetData} 等(见 Config.API_PATH)。</p>
*/
@RestController
@@ -48,7 +55,7 @@ public class WidgetController {
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, currentSchId(authUserWithDept));
Object data = widgetDataService.getData(code, params, resolveCaller(authUserWithDept));
if (data != null) {
return data;
}
@@ -59,7 +66,7 @@ public class WidgetController {
/** 仅 comp_type=2:按需取片段内容(不塞进 getUserSettings,避免每次进桌面下发全部 HTML) */
@GetMapping(value = "getWidgetContent", produces = "application/json")
public Map<String, Object> getWidgetContent(BoaUser authUserWithDept,
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", "");
@@ -77,38 +84,85 @@ public class WidgetController {
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", catalog.getContent() == null ? "" : catalog.getContent());
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) {
checkAdmin(authUserWithDept);
return widgetCatalogService.findAllForAdmin();
checkSchAdmin(authUserWithDept);
return widgetCatalogService.findEffectiveForAdmin(currentSchId(authUserWithDept), isSuperAdmin(authUserWithDept));
}
/** 管理端保存:content 走 JSON body(同 SaveTempFile 口径,规避 URL 长度限制) */
/** 管理端保存全局目录:content 走 JSON body(同 SaveTempFile 口径,规避 URL 长度限制) */
@PostMapping(value = "SaveWidgetCatalog", produces = "application/json")
public void saveWidgetCatalog(BoaUser authUserWithDept, @RequestBody WidgetCatalog widgetCatalog) {
checkAdmin(authUserWithDept);
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;
}
/**
* 写操作权限校验:超管(type==1) 或 校管理员(roleId∈{2,5,7}),其余拒绝——判定标准与
* AppSettingController.checkAdmin 一字不改(该方法是 private,不能直接调用)。
* 取数身份上下文(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 checkAdmin(BoaUser authUserWithDept) {
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;
}
@@ -117,4 +171,9 @@ public class WidgetController {
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));
}
}
@@ -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 {}
}
@@ -10,8 +10,10 @@ 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 地址概念。
@@ -46,10 +48,22 @@ public class WidgetCatalog {
@Column(name = "data_code", length = 32)
private String dataCode;
/** 下发范围:NULL = 全校可见(本期只留字段与过滤钩子,v1 全校一致) */
/** 下发范围:旧语义(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;
@Column(name = "sort", nullable = false)
private Integer sort = 0;
@@ -59,4 +73,20 @@ public class WidgetCatalog {
@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,61 @@
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;
@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);
}
@@ -3,6 +3,8 @@ 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;
@@ -17,4 +19,16 @@ public interface WidgetCatalogRepository extends JpaRepository<WidgetCatalog, In
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);
}
@@ -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);
}
@@ -1,22 +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 {
/** 候选池(登录侧):enabled=1 且对本校可见,按 sort 升序 */
List<WidgetCatalog> findEnabledForSchool(Integer schId);
/**
* 候选池(登录侧):可见范围命中 + 生效启用 + 角色命中,按生效 sort 升序。
*
* @param schId 当前登录校(用于 scope_type 点名判定与校级覆盖合成)
* @param roleId 当前登录角色 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生),用于 role_types 过滤
* @return 生效值视图(name 已按「校级别名 &gt; 角色命名 &gt; 名称」合成)
*/
List<WidgetCatalog> findEnabledForSchool(Integer schId, Integer roleId);
Optional<WidgetCatalog> findByCode(String code);
/** 下发范围判定:sch_id 为 NULL = 全校可见 */
/**
* 可见范围判定:scope_type = 0 全放;1 仅点名表命中可见;2 仅点名表未命中可见。
*
* @param catalog 目录行
* @param schId 当前登录校
* @return 命中返回 true
*/
boolean visibleToSchool(WidgetCatalog catalog, Integer schId);
/** 管理端列表:全量(含 content 与 update_time),鉴权在 Controller */
/** 管理端列表(全局原值,含 scopeSchIds 回显),鉴权在 Controller */
List<WidgetCatalog> findAllForAdmin();
/**
* 管理端按视角取列表:超管 = 全局原值;校管 = 本校生效值 + 别名 + 原名 + 覆盖标记。
*
* @param viewerSchId 访问者当前登录校
* @param superAdmin 访问者是否超管(BoaUser.type == 1)
* @return 视角化的目录列表
*/
List<WidgetCatalog> findEffectiveForAdmin(Integer viewerSchId, boolean superAdmin);
/**
* 保存目录行(仅超管调用),并整体重写 scope_type 的点名表。
*
* @param model 目录行(scopeSchIds 为点名的学校列表)
*/
void save(WidgetCatalog model);
/**
* 删除目录行,连带删点名表与校级覆盖行。
*
* @param id 目录 id
*/
void deleteById(Integer id);
Optional<WidgetSchoolSetting> findSchoolSetting(Integer widgetId, Integer schId);
/**
* 保存校级覆盖(仅本校)。schId 一律取登录校,忽略入参里的 schId,防越权。
* 四个可覆盖字段全空表示「清除覆盖」,直接删行。
*
* @param model 覆盖行(widgetId 必填)
* @param currentSchId 当前登录校
*/
void saveSchoolSetting(WidgetSchoolSetting model, Integer currentSchId);
/**
* 显示名三级链:校级别名 &gt; 角色专属命名 &gt; 组件名称。
*
* @param catalog 目录行
* @param schId 当前登录校
* @param roleId 当前登录角色
* @return 最终显示名
*/
String displayName(WidgetCatalog catalog, Integer schId, Integer roleId);
}
@@ -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);
}
}
@@ -1,38 +1,76 @@
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;
import java.util.stream.Collectors;
/**
* 小组件候选池服务(对齐 IUserSettingsService 分层:Controller 只做鉴权与出参组装,规则在此)。
*
* <p>生效值合成口径:生效值 = COALESCE(校级覆盖行, 全局值);显示名 = 校级别名 &gt; 角色专属命名 &gt; 组件名称;
* 可见性 = 生效启用 AND 可见范围命中 AND 可见角色命中。</p>
*/
@Service
public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
private static final Logger log = LoggerFactory.getLogger(WidgetCatalogServiceImpl.class);
private static final int COMP_TYPE_BUILTIN = 1;
private static final int COMP_TYPE_FRAGMENT = 2;
private static final int ENABLED = 1;
/** 下发范围:全部学校 */
private static final int SCOPE_ALL = 0;
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
@Autowired
private WidgetCatalogRepository widgetCatalogRepository;
@Autowired
private WidgetCatalogSchoolRepository widgetCatalogSchoolRepository;
@Autowired
private WidgetSchoolSettingRepository widgetSchoolSettingRepository;
@Override
public List<WidgetCatalog> findEnabledForSchool(Integer schId) {
return widgetCatalogRepository.findByEnabledOrderBySortAscIdAsc(ENABLED).stream()
.filter(model -> visibleToSchool(model, schId))
.collect(Collectors.toList());
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
@@ -45,15 +83,56 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
@Override
public boolean visibleToSchool(WidgetCatalog catalog, Integer schId) {
return catalog.getSchId() == null || catalog.getSchId().equals(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() {
return widgetCatalogRepository.findAllByOrderBySortAscIdAsc();
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.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");
@@ -69,11 +148,238 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
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);
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);
WidgetSchoolSetting exist = widgetSchoolSettingRepository
.findByWidgetIdAndSchId(model.getWidgetId(), currentSchId).orElse(null);
// 四个可覆盖字段全空 = 清除覆盖(删行,恢复沿用全局)
boolean clearing = model.getEnabled() == null && model.getRoleTypes() == null
&& model.getSortNo() == null && model.getAlias() == 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();
}
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.setUpdateTime(resolveUpdateTime(catalog, override));
return item;
}
private Map<Integer, WidgetSchoolSetting> loadOverrideMap(Integer schId) {
Map<Integer, WidgetSchoolSetting> map = new HashMap<>();
if (schId == null) {
return map;
}
for (WidgetSchoolSetting setting : widgetSchoolSettingRepository.findBySchId(schId)) {
map.put(setting.getWidgetId(), setting);
}
return map;
}
private void fillScopeSchIds(Collection<WidgetCatalog> catalogs) {
Map<Integer, List<Integer>> pointMap = loadPointSchoolMap(catalogs);
for (WidgetCatalog catalog : catalogs) {
List<Integer> ids = pointMap.get(catalog.getId());
catalog.setScopeSchIds(ids == null ? new ArrayList<>() : ids);
}
}
private Map<Integer, List<Integer>> loadPointSchoolMap(Collection<WidgetCatalog> catalogs) {
Map<Integer, List<Integer>> map = new HashMap<>();
List<Integer> widgetIds = new ArrayList<>();
for (WidgetCatalog catalog : catalogs) {
if (catalog.getId() != null) widgetIds.add(catalog.getId());
}
if (widgetIds.isEmpty()) {
return map;
}
for (WidgetCatalogSchool row : widgetCatalogSchoolRepository.findByWidgetIdIn(widgetIds)) {
map.computeIfAbsent(row.getWidgetId(), key -> new ArrayList<>()).add(row.getSchId());
}
return map;
}
/** 整体重写点名表:scope_type=0 或未选校时清空 */
private void rewritePointSchools(WidgetCatalog model) {
widgetCatalogSchoolRepository.deleteByWidgetId(model.getId());
if (model.getScopeType() == null || model.getScopeType() == SCOPE_ALL) {
return;
}
List<Integer> schIds = model.getScopeSchIds();
if (schIds == null || schIds.isEmpty()) {
return;
}
List<WidgetCatalogSchool> rows = new ArrayList<>();
for (Integer schId : schIds) {
if (schId == null) continue;
WidgetCatalogSchool row = new WidgetCatalogSchool();
row.setWidgetId(model.getId());
row.setSchId(schId);
rows.add(row);
}
if (!rows.isEmpty()) {
widgetCatalogSchoolRepository.saveAll(rows);
}
}
private Integer parseInt(String text) {
if (text == null) {
return null;
}
try {
return Integer.valueOf(text.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,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`;