diff --git a/src/main/java/com/yunhai/userhome/config/WidgetCatalogSeeder.java b/src/main/java/com/yunhai/userhome/config/WidgetCatalogSeeder.java new file mode 100644 index 0000000..95a27c6 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/config/WidgetCatalogSeeder.java @@ -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(幂等)。 + * + *

背景:种子 INSERT 原先只在 {@code resources/sql/widget_catalog.sql} 里,代码无 seeding, + * 新环境首次启动时候选池恒为空。此处补一条启动期兜底:库为空才写入 3 条内置目录行。

+ * + *

三条种子与 {@code resources/sql/widget_catalog.sql} 用同一份常量(code / name / comp_type / scope_type / enabled), + * 仅补「目录行」,不补内容(内置组件内容在前端工程);{@code WidgetDataService} 白名单不动。

+ * + * @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); + } + } +} diff --git a/src/main/java/com/yunhai/userhome/controller/api/AppSettingController.java b/src/main/java/com/yunhai/userhome/controller/api/AppSettingController.java deleted file mode 100644 index 7dbdd43..0000000 --- a/src/main/java/com/yunhai/userhome/controller/api/AppSettingController.java +++ /dev/null @@ -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 对齐)。 - * - *

与原版差异点:写操作(应用 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 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 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 getTempList(BoaUser authUserWithDept, Integer appModId) { - File sourceFile = new File(FILE_PATH + "/tempUpload/" + appModId); - List 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 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 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 currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId()); - if (!currentRoleOpt.isPresent() || !OtherUtil.isAdminById(currentRoleOpt.get().getRoleId())) { - throw new BizException(ErrorCode.FORBIDDEN); - } - } -} diff --git a/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java b/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java index cccab2c..42765ea 100644 --- a/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java +++ b/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java @@ -285,10 +285,11 @@ public class UserSettingsController { public UserSettings getUserSettings(BoaUser authUserWithDept) { Optional 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 optional = userSettingsService.findUserSettings(authUserWithDept.getId()); - // 候选池真源 = 自建 widget_catalog(enabled=1,sch_id 按校过滤;NULL=全校) - List catalogList = widgetCatalogService.findEnabledForSchool(schId); + // 候选池真源 = 自建 widget_catalog(生效启用 + 可见范围命中本校 + 可见角色命中当前角色;name 已按显示名三级链合成) + List catalogList = widgetCatalogService.findEnabledForSchool(schId, roleId); if (optional.isPresent()) model = optional.get(); // 壁纸池一次取全:悬空自愈判定与兜底选择共用 List wallList = userSettingsService.findWallList(authUserWithDept.getId()); diff --git a/src/main/java/com/yunhai/userhome/controller/api/WidgetController.java b/src/main/java/com/yunhai/userhome/controller/api/WidgetController.java index a7154b0..ef1a77f 100644 --- a/src/main/java/com/yunhai/userhome/controller/api/WidgetController.java +++ b/src/main/java/com/yunhai/userhome/controller/api/WidgetController.java @@ -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; /** * 小组件控制器:候选池按需取内容 + 统一取数(登录侧),以及管理端候选池读写(管理员)。 * + *

写权限按「目录权 / 校级权」拆开:全局目录(新增 / 改内容 / 删除 / 三态可见范围 / 角色命名)仅超管; + * 校级覆盖(启用 / 可见角色 / 排序 / 别名)超管或校管,且校管只能写本校。

+ * *

真实路径按 {@code /{platform}} 展开为 {@code /api/getWidgetData} 等(见 Config.API_PATH)。

*/ @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 getWidgetContent(BoaUser authUserWithDept, + public Map getWidgetContent(BoaUser authUserWithDept, HttpServletResponse response, @RequestParam(value = "code", required = false) String code) { Map 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 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 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 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)); + } } diff --git a/src/main/java/com/yunhai/userhome/entity/AppCategory.java b/src/main/java/com/yunhai/userhome/entity/AppCategory.java deleted file mode 100644 index 2b994ff..0000000 --- a/src/main/java/com/yunhai/userhome/entity/AppCategory.java +++ /dev/null @@ -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 迁移)。 - * - *

roleType:1 校管理员 | 2 教师 | 3 学生(见 {@code enums.RoleType}); - * resDate 为资源更新时间戳,前端上传模板后经 UpdateResDate 刷新缓存。

- */ -@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 {} -} diff --git a/src/main/java/com/yunhai/userhome/entity/WidgetCatalog.java b/src/main/java/com/yunhai/userhome/entity/WidgetCatalog.java index 1425e83..cf8bbff 100644 --- a/src/main/java/com/yunhai/userhome/entity/WidgetCatalog.java +++ b/src/main/java/com/yunhai/userhome/entity/WidgetCatalog.java @@ -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 scopeSchIds; + + /** 管理端视图(校管视角):本校别名(widget_school_setting.alias,非持久化) */ + @Transient + private String alias; + + /** 管理端视图(校管视角):超管下发的全局名称(非持久化,用于弹窗回显原名) */ + @Transient + private String originalName; + + /** 管理端视图(校管视角):本校是否已有覆盖行(非持久化) */ + @Transient + private Boolean covered; } diff --git a/src/main/java/com/yunhai/userhome/entity/WidgetCatalogSchool.java b/src/main/java/com/yunhai/userhome/entity/WidgetCatalogSchool.java new file mode 100644 index 0000000..0a7f7eb --- /dev/null +++ b/src/main/java/com/yunhai/userhome/entity/WidgetCatalogSchool.java @@ -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):只存被点名的学校行。 + * + *

配合 {@link WidgetCatalog#getScopeType()}:1(白名单)= 表中有记录的学校可见; + * 2(黑名单)= 表中有记录的学校不可见。

+ * + * @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; +} diff --git a/src/main/java/com/yunhai/userhome/entity/WidgetSchoolSetting.java b/src/main/java/com/yunhai/userhome/entity/WidgetSchoolSetting.java new file mode 100644 index 0000000..ca0c1bb --- /dev/null +++ b/src/main/java/com/yunhai/userhome/entity/WidgetSchoolSetting.java @@ -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):一校一行。 + * + *

字段为 NULL 表示沿用全局值,生效值 = COALESCE(本行, widget_catalog 全局值); + * 「一键清除覆盖」= 删除本行。

+ * + * @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; +} diff --git a/src/main/java/com/yunhai/userhome/repository/AppCategoryRepository.java b/src/main/java/com/yunhai/userhome/repository/AppCategoryRepository.java deleted file mode 100644 index abc95c8..0000000 --- a/src/main/java/com/yunhai/userhome/repository/AppCategoryRepository.java +++ /dev/null @@ -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, JpaSpecificationExecutor { - - List findAllByRoleType(Integer roleType); -} diff --git a/src/main/java/com/yunhai/userhome/repository/WidgetCatalogRepository.java b/src/main/java/com/yunhai/userhome/repository/WidgetCatalogRepository.java index 26165c2..aebb1dc 100644 --- a/src/main/java/com/yunhai/userhome/repository/WidgetCatalogRepository.java +++ b/src/main/java/com/yunhai/userhome/repository/WidgetCatalogRepository.java @@ -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 findByEnabledOrderBySortAscIdAsc(Integer enabled); List 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 findVisibleForSchool(@Param("schId") Integer schId); } diff --git a/src/main/java/com/yunhai/userhome/repository/WidgetCatalogSchoolRepository.java b/src/main/java/com/yunhai/userhome/repository/WidgetCatalogSchoolRepository.java new file mode 100644 index 0000000..8be26fd --- /dev/null +++ b/src/main/java/com/yunhai/userhome/repository/WidgetCatalogSchoolRepository.java @@ -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 { + + /** 命中判定:某组件是否点名了某校(scope_type 白/黑名单共用) */ + boolean existsByWidgetIdAndSchId(Integer widgetId, Integer schId); + + List findByWidgetId(Integer widgetId); + + List findByWidgetIdIn(Collection widgetIds); + + void deleteByWidgetId(Integer widgetId); +} diff --git a/src/main/java/com/yunhai/userhome/repository/WidgetSchoolSettingRepository.java b/src/main/java/com/yunhai/userhome/repository/WidgetSchoolSettingRepository.java new file mode 100644 index 0000000..540da69 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/repository/WidgetSchoolSettingRepository.java @@ -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 { + + Optional findByWidgetIdAndSchId(Integer widgetId, Integer schId); + + List findBySchId(Integer schId); + + List findByWidgetId(Integer widgetId); + + void deleteByWidgetId(Integer widgetId); +} diff --git a/src/main/java/com/yunhai/userhome/service/IAppCategoryService.java b/src/main/java/com/yunhai/userhome/service/IAppCategoryService.java deleted file mode 100644 index c3cbd79..0000000 --- a/src/main/java/com/yunhai/userhome/service/IAppCategoryService.java +++ /dev/null @@ -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 findById(Integer id); - - List findByRoleType(Integer roleType); - - Page findPageList(Integer page, Integer count, Integer roleType, String name); - - void updateResDate(Integer id); -} diff --git a/src/main/java/com/yunhai/userhome/service/IWidgetCatalogService.java b/src/main/java/com/yunhai/userhome/service/IWidgetCatalogService.java index 5fa8902..c84813c 100644 --- a/src/main/java/com/yunhai/userhome/service/IWidgetCatalogService.java +++ b/src/main/java/com/yunhai/userhome/service/IWidgetCatalogService.java @@ -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 findEnabledForSchool(Integer schId); + /** + * 候选池(登录侧):可见范围命中 + 生效启用 + 角色命中,按生效 sort 升序。 + * + * @param schId 当前登录校(用于 scope_type 点名判定与校级覆盖合成) + * @param roleId 当前登录角色 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生),用于 role_types 过滤 + * @return 生效值视图(name 已按「校级别名 > 角色命名 > 名称」合成) + */ + List findEnabledForSchool(Integer schId, Integer roleId); Optional 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 findAllForAdmin(); + /** + * 管理端按视角取列表:超管 = 全局原值;校管 = 本校生效值 + 别名 + 原名 + 覆盖标记。 + * + * @param viewerSchId 访问者当前登录校 + * @param superAdmin 访问者是否超管(BoaUser.type == 1) + * @return 视角化的目录列表 + */ + List findEffectiveForAdmin(Integer viewerSchId, boolean superAdmin); + + /** + * 保存目录行(仅超管调用),并整体重写 scope_type 的点名表。 + * + * @param model 目录行(scopeSchIds 为点名的学校列表) + */ void save(WidgetCatalog model); + + /** + * 删除目录行,连带删点名表与校级覆盖行。 + * + * @param id 目录 id + */ + void deleteById(Integer id); + + Optional 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); } diff --git a/src/main/java/com/yunhai/userhome/service/impl/AppCategoryServiceImpl.java b/src/main/java/com/yunhai/userhome/service/impl/AppCategoryServiceImpl.java deleted file mode 100644 index e9bd7d8..0000000 --- a/src/main/java/com/yunhai/userhome/service/impl/AppCategoryServiceImpl.java +++ /dev/null @@ -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 findById(Integer id) { - return appCategoryRepository.findById(id); - } - - @Override - public List findByRoleType(Integer roleType) { - return appCategoryRepository.findAllByRoleType(roleType); - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - @Override - public Page 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 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 optionalAppCategory = findById(id); - if (!optionalAppCategory.isPresent()) { - throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR); - } - AppCategory model = optionalAppCategory.get(); - model.setResDate(new Date()); - appCategoryRepository.save(model); - } -} diff --git a/src/main/java/com/yunhai/userhome/service/impl/WidgetCatalogServiceImpl.java b/src/main/java/com/yunhai/userhome/service/impl/WidgetCatalogServiceImpl.java index 04fb931..da639e2 100644 --- a/src/main/java/com/yunhai/userhome/service/impl/WidgetCatalogServiceImpl.java +++ b/src/main/java/com/yunhai/userhome/service/impl/WidgetCatalogServiceImpl.java @@ -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 只做鉴权与出参组装,规则在此)。 + * + *

生效值合成口径:生效值 = COALESCE(校级覆盖行, 全局值);显示名 = 校级别名 > 角色专属命名 > 组件名称; + * 可见性 = 生效启用 AND 可见范围命中 AND 可见角色命中。

*/ @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 findEnabledForSchool(Integer schId) { - return widgetCatalogRepository.findByEnabledOrderBySortAscIdAsc(ENABLED).stream() - .filter(model -> visibleToSchool(model, schId)) - .collect(Collectors.toList()); + public List findEnabledForSchool(Integer schId, Integer roleId) { + Map overrideMap = loadOverrideMap(schId); + List 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 findAllForAdmin() { - return widgetCatalogRepository.findAllByOrderBySortAscIdAsc(); + List list = widgetCatalogRepository.findAllByOrderBySortAscIdAsc(); + fillScopeSchIds(list); + return list; } @Override + public List findEffectiveForAdmin(Integer viewerSchId, boolean superAdmin) { + if (superAdmin || viewerSchId == null) { + return findAllForAdmin(); + } + Map overrideMap = loadOverrideMap(viewerSchId); + List 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 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 loadOverrideMap(Integer schId) { + Map 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 catalogs) { + Map> pointMap = loadPointSchoolMap(catalogs); + for (WidgetCatalog catalog : catalogs) { + List ids = pointMap.get(catalog.getId()); + catalog.setScopeSchIds(ids == null ? new ArrayList<>() : ids); + } + } + + private Map> loadPointSchoolMap(Collection catalogs) { + Map> map = new HashMap<>(); + List 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 schIds = model.getScopeSchIds(); + if (schIds == null || schIds.isEmpty()) { + return; + } + List 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; + } } } diff --git a/src/main/resources/sql/widget_catalog_v2.sql b/src/main/resources/sql/widget_catalog_v2.sql new file mode 100644 index 0000000..3fdc2c9 --- /dev/null +++ b/src/main/resources/sql/widget_catalog_v2.sql @@ -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`;