From 257ba2dd643a357755816162d94ab6239711ec3f Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 28 Sep 2026 12:44:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E5=BA=94=E7=94=A8=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E7=BB=9F=E4=B8=80=20app=5Fcode/app=5Fnature=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=BA=94=E5=A4=84=E5=9F=BA=E7=9F=B3=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20+=20by-code/by-key=20=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=EF=BC=88=E6=B6=88=E6=81=AF=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=96=B9=E6=A1=88=20B1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/constant/AppNature.java | 39 ++++++ .../controller/oapi/AppController.java | 32 ++++- .../userbase/entity/ApplicationInfo.java | 13 ++ .../application/ApplicationInfoRequest.java | 17 +++ .../model/application/ApplicationInfoVo.java | 10 ++ .../repository/ApplicationInfoRepository.java | 29 ++++- .../service/IApplicationInfoService.java | 17 +++ .../userbase/service/impl/AppRoleService.java | 12 +- .../service/impl/ApplicationInfoService.java | 45 ++++++- .../sql/20260928_add_app_code_nature.sql | 45 +++++++ .../userbase/constant/AppNatureTest.java | 43 +++++++ .../AppRoleServiceBaseAppExcludeTest.java | 89 ++++++++++++++ .../impl/ApplicationInfoLookupTest.java | 111 ++++++++++++++++++ 13 files changed, 487 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/constant/AppNature.java create mode 100644 src/main/resources/sql/20260928_add_app_code_nature.sql create mode 100644 src/test/java/com/yunhai/userbase/constant/AppNatureTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/AppRoleServiceBaseAppExcludeTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoLookupTest.java diff --git a/src/main/java/com/yunhai/userbase/constant/AppNature.java b/src/main/java/com/yunhai/userbase/constant/AppNature.java new file mode 100644 index 0000000..7b330db --- /dev/null +++ b/src/main/java/com/yunhai/userbase/constant/AppNature.java @@ -0,0 +1,39 @@ +package com.yunhai.userbase.constant; + +/** + * 应用性质(application_info.app_nature)。
+ * BASE=基石应用:平台基建身份(UserHome / 移动门户等),需要出站签名身份但不进门户开通/授权/模块管理;
+ * BUSINESS=业务应用:门户可见,参与开通/授权。
+ * 与 app_category 的「基础应用」分类无关——那边是"类型",这边是"性质"(两界面不同屏,存储层并存)。 + */ +public enum AppNature { BASE, BUSINESS; + + /** + * UserBase 自身的 appKey —— appKey 从 100000 起自增,"基石应用段"起点(application_bootstrap.sql)。
+ * 它是验签的管理者,不拿自己的身份被验签,故不标 app_nature,由 {@link #isUserbaseSelf} 覆盖。 + */ + public static final int USER_CENTER_APP_KEY = 100000; + + /** + * 条件一:UserBase 自身(延续原「appKey > 100000」排除逻辑的取反语义,行为不变)。 + */ + public static boolean isUserbaseSelf(Integer appKey) { + return appKey != null && appKey <= USER_CENTER_APP_KEY; + } + + /** + * 条件二:显式标记的基石应用(UserHome / 移动门户等)。 + */ + public static boolean isMarkedBase(String appNature) { + return BASE.name().equals(appNature); + } + + /** + * 是否基石应用(不参与应用开通 / 授权 / 模块管理等业务功能)。
+ * 两条件取「或」。注意 JPQL @Query 内无法调用本方法——Repository 三处为常量拼接的 SQL 形态, + * 语义单点仍在本枚举,改动须同步(含三值逻辑 NULL 分支)。 + */ + public static boolean isBaseApp(Integer appKey, String appNature) { + return isUserbaseSelf(appKey) || isMarkedBase(appNature); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java index 4971bc0..549a075 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java @@ -43,7 +43,37 @@ public class AppController { */ @GetMapping("{appId}") public ApplicationInfoVo byId(@PathVariable Integer appId) { - ApplicationInfo info = applicationInfoService.getById(appId); + return toVo(applicationInfoService.getById(appId)); + } + + /** + * 按技术标识查询应用信息(跳转查询键主用端点)。
+ * app_code = projman app.name(如 notice/flow-center),即消息 {@code jump_app} 的值域;
+ * 查不到返回 null(与 byId 契约一致)。返回视图清空 appSecret。 + * + * @param appCode 技术标识(必填) + * @return 应用信息视图;不存在时返回 null + */ + @GetMapping("by-code/{appCode}") + public ApplicationInfoVo byCode(@PathVariable String appCode) { + return toVo(applicationInfoService.getByAppCode(appCode)); + } + + /** + * 按整型 appKey 查询应用信息(兼容兜底端点,存量自发场景按数字回落)。
+ * ⚠️ 查的是 app_key 列而非主键 id——实测 id ≠ app_key(15 行中仅 2 行相等, + * 如流程中心 appKey=100009 的主键是 100011),按 id 查会把「流程中心」查成「课堂作业」。
+ * 查不到返回 null;返回视图清空 appSecret。 + * + * @param appKey 应用标识(必填) + * @return 应用信息视图;不存在时返回 null + */ + @GetMapping("by-key/{appKey}") + public ApplicationInfoVo byKey(@PathVariable Integer appKey) { + return toVo(applicationInfoService.getByAppKey(appKey)); + } + + private ApplicationInfoVo toVo(ApplicationInfo info) { if (info == null) { return null; } diff --git a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java index 0b9f5d6..fd5ee3b 100644 --- a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java +++ b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java @@ -62,6 +62,19 @@ public class ApplicationInfo extends BaseEntity { @Column(name = "app_secret", nullable = false, length = 128) private String appSecret; + /** + * 技术标识 = projman app.name(如 notice/flow-center),跨系统稳定引用键。
+ * 跳转查询键(sys_message.jump_app 值域);引用方存的是字符串,改名须盘点引用。 + */ + @Column(name = "app_code", length = 32) + private String appCode; + + /** + * 应用性质:BASE=基石 / BUSINESS=业务(判定口径单点见 {@link com.yunhai.userbase.constant.AppNature})。 + */ + @Column(name = "app_nature", length = 16) + private String appNature; + //应用前端类型 @Convert(converter = ListIntegerJsonConverter.class) @Column(name = "clients", columnDefinition = "json") diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java index b08e756..db3c4c0 100644 --- a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java +++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java @@ -5,6 +5,7 @@ import lombok.Data; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; import java.util.List; /** @@ -34,6 +35,22 @@ public class ApplicationInfoRequest { @NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用类别不能为空") private Integer categoryId; + /** + * 技术标识(= projman app.name,如 notice),全库唯一,新增必填。
+ * 编辑不强制(部分提交场景不回传则保持原值);值域为引用键,改名须盘点引用。 + */ + @NotBlank(groups = ValidationGroups.Create.class, message = "技术标识不能为空") + @Size(max = 32, message = "技术标识长度不能超过32") + private String appCode; + + /** + * 应用性质:BASE=基石 / BUSINESS=业务,新增必填。
+ * 编辑不强制(不回传则保持原值);学校模式覆盖线不涉及本字段。 + */ + @NotBlank(groups = ValidationGroups.Create.class, message = "应用性质不能为空") + @Size(max = 16, message = "应用性质长度不能超过16") + private String appNature; + /** * 应用描述。
*/ diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java index 183db1f..719c3cd 100644 --- a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java +++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java @@ -47,6 +47,16 @@ public class ApplicationInfoVo { */ private String appSecret; + /** + * 技术标识(= projman app.name,如 notice)。
+ */ + private String appCode; + + /** + * 应用性质:BASE=基石 / BUSINESS=业务。
+ */ + private String appNature; + /** * 应用前端类型列表。
*/ diff --git a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java index a34acb3..0226dba 100644 --- a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java @@ -17,7 +17,10 @@ import java.util.List; * @description */ public interface ApplicationInfoRepository extends JpaRepository, JpaSpecificationExecutor { - @Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000") + // 基石应用排除 = AppNature.isBaseApp(appKey<=100000 ∪ app_nature='BASE')的 SQL 形态,常量拼接、语义单点见 AppNature。 + // app_nature 过渡期可空:`<> 'BASE'` 遇 NULL 求值为 NULL(非 TRUE)会整行漏掉,必须带 IS NULL 分支。 + @Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 " + + "AND (a.appNature IS NULL OR a.appNature <> 'BASE')") Integer countByCategoryId(@Param("categoryId") Integer id); boolean existsByName(String name); @@ -58,23 +61,39 @@ public interface ApplicationInfoRepository extends JpaRepository * 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。
- * 排除用户中心自身的基石应用(appKey=100000)。 + * 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。 * * @param categoryId 应用类型ID * @return 该类型下全部应用的排序视图,按 sortNo 升序(未排序排末尾),id 升序兜底 */ @Query("SELECT new com.yunhai.userbase.model.application.ApplicationInfoSortVo(a.id, a.name, a.sortNo) " + - "FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 ORDER BY a.sortNo ASC, a.id ASC") + "FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 " + + "AND (a.appNature IS NULL OR a.appNature <> 'BASE') ORDER BY a.sortNo ASC, a.id ASC") List findSortVoByCategoryId(@Param("categoryId") Integer categoryId); /** * 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。
* 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。
- * 排除用户中心自身的基石应用(appKey=100000)。 + * 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。 * * @return 全部应用的授权投影行 */ @Query("SELECT new com.yunhai.userbase.model.appauthz.AppAuthzAppRow(a.categoryId, a.id, a.name, a.pcIcon) " + - "FROM ApplicationInfo a WHERE a.appKey > 100000 ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC") + "FROM ApplicationInfo a WHERE a.appKey > 100000 " + + "AND (a.appNature IS NULL OR a.appNature <> 'BASE') ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC") List findAllAuthzRows(); + + /** + * 按技术标识查单个应用(app_code = projman app.name,跳转查询键 /oapi/app/by-code 的数据面)。
+ * + * @param appCode 技术标识(如 notice) + * @return 匹配的应用实体,无则 null + */ + ApplicationInfo findByAppCode(String appCode); + + /** 技术标识唯一性校验(新增用)。 */ + boolean existsByAppCode(String appCode); + + /** 技术标识唯一性校验(编辑排除自身用)。 */ + boolean existsByAppCodeAndIdNot(String appCode, Integer id); } diff --git a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java index c6c5fc6..2c0b611 100644 --- a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java @@ -28,6 +28,23 @@ public interface IApplicationInfoService { */ Integer findIdByAppKey(Integer appKey); + /** + * 按技术标识(app_code = projman app.name)查应用:/oapi/app/by-code 端点的数据面。
+ * + * @param appCode 技术标识(如 notice) + * @return 应用实体;无则 null + */ + ApplicationInfo getByAppCode(String appCode); + + /** + * 按整型 appKey 查应用(查 app_key 列,非主键 id):/oapi/app/by-key 兜底端点的数据面。
+ * 复用 appKeyToId + appInfo 两级缓存。 + * + * @param appKey 应用标识 + * @return 应用实体;无则 null + */ + ApplicationInfo getByAppKey(Integer appKey); + /** * 保存/更新应用信息(写后同步更新缓存)。 */ diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java index 2ce2694..94dd524 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java @@ -9,6 +9,7 @@ import com.yunhai.userbase.entity.SysGroup; import com.yunhai.userbase.entity.SysUser; import com.yunhai.userbase.entity.SysUserAppRoleRel; import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.constant.AppNature; import com.yunhai.userbase.event.AppAuthzChangedEvent; import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo; import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo; @@ -68,10 +69,6 @@ import java.util.Set; @Service public class AppRoleService implements IAppRoleService { - /** 用户中心自身基石应用的 appKey(application_info.appKey 从 100000 起自增,100000 即用户中心平台, - * 无应用类型;全应用授权范围须排除它——与 ApplicationInfoRepository「appKey > 100000」口径一致。 */ - private static final int USER_CENTER_APP_KEY = 100000; - /** 组根节点的 parentId 约定值(与 GroupAuthzService.ROOT_PARENT_ID 同语义)。 */ private static final int ROOT_PARENT_ID = 0; @@ -206,13 +203,14 @@ public class AppRoleService implements IAppRoleService { Objects.requireNonNull(roleId, "角色ID不能为空"); SysAppRole role = sysAppRoleRepository.findById(roleId) .orElseThrow(() -> new RuntimeException("角色不存在: " + roleId)); - // 授权范围:普通角色仅本应用;全应用角色为系统全部应用(排除用户中心自身基石应用, - // 它无应用类型,前端按分类分组时会落进「未分类」组展示,不属于可授权业务应用) + // 授权范围:普通角色仅本应用;全应用角色为系统全部应用(排除基石应用——口径 = + // AppNature.isBaseApp:UserBase 自身无应用类型会落「未分类」组,显式标记 BASE 的 + // 基建应用同样不属于可授权业务应用) List apps; if (Boolean.TRUE.equals(role.getAllAppFlag())) { apps = new ArrayList<>(); for (ApplicationInfo app : applicationInfoRepository.findAll()) { - if (app.getAppKey() == null || app.getAppKey() > USER_CENTER_APP_KEY) { + if (!AppNature.isBaseApp(app.getAppKey(), app.getAppNature())) { apps.add(app); } } diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index 9794e8b..c080064 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -3,6 +3,7 @@ package com.yunhai.userbase.service.impl; import com.yunhai.userbase.entity.AppModule; import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.entity.SysAppRole; +import com.yunhai.userbase.constant.AppNature; import com.yunhai.userbase.repository.AppModuleRepository; import com.yunhai.userbase.repository.ApplicationInfoRepository; import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository; @@ -98,6 +99,27 @@ public class ApplicationInfoService implements IApplicationInfoService { return app == null ? null : app.getId(); } + @Override + public ApplicationInfo getByAppCode(String appCode) { + if (StringUtils.isBlank(appCode)) { + return null; + } + return applicationInfoRepository.findByAppCode(appCode); + } + + /** + * 查的是 app_key 列而非主键 id(id ≠ app_key:流程中心 appKey=100009 主键是 100011), + * 走 findIdByAppKey + getById 两级缓存而非直查。 + */ + @Override + public ApplicationInfo getByAppKey(Integer appKey) { + if (appKey == null) { + return null; + } + Integer id = findIdByAppKey(appKey); + return id == null ? null : getById(id); + } + /** * 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。 */ @@ -128,6 +150,11 @@ public class ApplicationInfoService implements IApplicationInfoService { if (applicationInfoRepository.existsByName(request.getName())) { throw new DException("应用名称已存在"); } + // 技术标识唯一性(app_code = projman app.name,跳转查询键) + if (StringUtils.isNotBlank(request.getAppCode()) + && applicationInfoRepository.existsByAppCode(request.getAppCode())) { + throw new DException("技术标识已存在"); + } ApplicationInfo entity = new ApplicationInfo(); BeanUtils.copyProperties(request, entity); entity.setId(null); @@ -152,7 +179,19 @@ public class ApplicationInfoService implements IApplicationInfoService { } ApplicationInfo entity = applicationInfoRepository.findById(id) .orElseThrow(() -> new RuntimeException("应用不存在: " + id)); - BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret"); + if (StringUtils.isNotBlank(request.getAppCode()) + && applicationInfoRepository.existsByAppCodeAndIdNot(request.getAppCode(), id)) { + throw new DException("技术标识已存在"); + } + // appCode/appNature 空值不回写:部分提交场景(学校覆盖线只传 4 字段)copyProperties + // 会把 null 覆盖进主表;app_code 是引用键,清空即引用失效,仅显式提供时才更新 + BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret", "appCode", "appNature"); + if (StringUtils.isNotBlank(request.getAppCode())) { + entity.setAppCode(request.getAppCode()); + } + if (StringUtils.isNotBlank(request.getAppNature())) { + entity.setAppNature(request.getAppNature()); + } ApplicationInfo saved = applicationInfoRepository.save(entity); eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); } @@ -218,7 +257,9 @@ public class ApplicationInfoService implements IApplicationInfoService { @Override public Predicate toPredicate(Root root, CriteriaQuery cq, CriteriaBuilder cb) { List predicates = new ArrayList(); - predicates.add(cb.greaterThan(root.get("appKey"), 100000)); + // ④ 口径 = AppNature.isUserbaseSelf(仅排 UserBase 自身, Criteria 无法调 Java 方法,此为常量形态); + // 显式基石(app_nature=BASE)保留在列表供编辑/重置密钥——与 ①②③⑤ 的 isBaseApp 全排除口径不同,勿合并 + predicates.add(cb.greaterThan(root.get("appKey"), AppNature.USER_CENTER_APP_KEY)); if (query != null) { if (StringUtils.isNotEmpty(query.getName())) { String keyword = query.getName(); diff --git a/src/main/resources/sql/20260928_add_app_code_nature.sql b/src/main/resources/sql/20260928_add_app_code_nature.sql new file mode 100644 index 0000000..4b27906 --- /dev/null +++ b/src/main/resources/sql/20260928_add_app_code_nature.sql @@ -0,0 +1,45 @@ +-- ============================================================ +-- 2026-09-28 应用身份统一:app_code(技术标识)+ app_nature(应用性质) +-- 方案:《消息卡片直批与跳转基址统一方案-20260928.md》§4 / §11.4 +-- 库:160 生产 userbase 库(实测名 proj18;执行前 SHOW DATABASES 再核对) +-- 执行顺序不可颠倒:加列 → 回填 → 校验 → 收紧(最后才允许建 UserHome 行,另行立项) +-- 幂等约定:每段执行前先 SHOW COLUMNS FROM application_info LIKE '<列>'; 已存在则跳过该段。 +-- ============================================================ + +-- ---------- 1. 加列(可 NULL,过渡期约束靠应用层) ---------- +ALTER TABLE application_info + ADD COLUMN app_nature VARCHAR(16) NULL COMMENT '应用性质:BASE=基石 / BUSINESS=业务', + ADD COLUMN app_code VARCHAR(32) NULL COMMENT '技术标识=projman app.name(跳转查询键)', + ADD COLUMN miniprogram_url VARCHAR(255) NULL COMMENT '微信小程序应用地址'; + +-- ---------- 2. 回填 app_nature ---------- +-- 存量 15 行全为业务应用;UserBase 自身(100000)不标 BASE(由 appKey<=100000 条件一覆盖),统一 BUSINESS。 +UPDATE application_info SET app_nature = 'BUSINESS' WHERE app_nature IS NULL; + +-- ---------- 3. 回填 app_code ---------- +-- 3.1 自动段:projman.app.project_name = userbase.name 精确命中(实测 11 条)。 +-- ⚠️ 执行前核对 projman 库名(下方写的是仓库配置值 bsch_projman,160 实际名以 SHOW DATABASES 为准); +-- 若 projman 库不在同一 MySQL 实例,则先在 projman 侧查出 (project_name, name) 清单,改用 3.2 的逐行形式。 +UPDATE application_info u + JOIN bsch_projman.app p ON p.project_name = u.name + SET u.app_code = p.name + WHERE u.app_code IS NULL; + +-- 3.2 人工段:中文名与 projman 不对应(方案 §4.3 台账),显式指定 +UPDATE application_info SET app_code = 'userbase' WHERE name = '用户中心平台' AND app_code IS NULL; +UPDATE application_info SET app_code = 'userclass' WHERE name = '学校管理' AND app_code IS NULL; +UPDATE application_info SET app_code = 'docprint' WHERE name = '文印申请' AND app_code IS NULL; + +-- 3.3 孤儿:成绩管理无 projman 部署,保留 NULL 观察(用户已定,勿回填) + +-- ---------- 4. 校验(预期:总 15 行;app_code NULL 恰 1 行且为「成绩管理」;app_nature NULL 为 0) ---------- +SELECT COUNT(*) AS total_rows, + SUM(app_code IS NULL) AS code_null_cnt, + SUM(app_code IS NULL AND name <> '成绩管理') AS code_null_unexpected, + SUM(app_nature IS NULL) AS nature_null_cnt + FROM application_info; + +-- ---------- 5. 收紧 ---------- +-- app_code 加 UNIQUE(应用层 add/edit 已做重复校验,此处落库兜底)。 +-- NOT NULL 暂缓:成绩管理孤儿行按约定保留 NULL,待其定论(上线或删除)后再收紧。 +ALTER TABLE application_info ADD UNIQUE KEY uk_application_app_code (app_code); diff --git a/src/test/java/com/yunhai/userbase/constant/AppNatureTest.java b/src/test/java/com/yunhai/userbase/constant/AppNatureTest.java new file mode 100644 index 0000000..f3924ca --- /dev/null +++ b/src/test/java/com/yunhai/userbase/constant/AppNatureTest.java @@ -0,0 +1,43 @@ +package com.yunhai.userbase.constant; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * AppNature 判定语义单测(方案 §8 用例:基石应用两条件取「或」)。 + */ +class AppNatureTest { + + @Test + void isBaseApp_三档标准用例() { + assertTrue(AppNature.isBaseApp(100000, AppNature.BUSINESS.name()), "UserBase 自身:条件一命中"); + assertTrue(AppNature.isBaseApp(100015, AppNature.BASE.name()), "显式标记 BASE:条件二命中"); + assertFalse(AppNature.isBaseApp(100001, AppNature.BUSINESS.name()), "普通业务应用:两条件均不命中"); + } + + @Test + void isUserbaseSelf_null与边界() { + assertFalse(AppNature.isUserbaseSelf(null)); + assertTrue(AppNature.isUserbaseSelf(100000)); + assertFalse(AppNature.isUserbaseSelf(100001)); + // 理论不存在(appKey 自增起点 100000),防御性覆盖边界语义 + assertTrue(AppNature.isUserbaseSelf(99999)); + } + + @Test + void isMarkedBase_严格等值() { + assertTrue(AppNature.isMarkedBase("BASE")); + assertFalse(AppNature.isMarkedBase(null)); + assertFalse(AppNature.isMarkedBase("BUSINESS")); + assertFalse(AppNature.isMarkedBase("base"), "大小写敏感,与枚举 name 严格相等"); + } + + @Test + void isBaseApp_条件一或二任一命中() { + assertTrue(AppNature.isBaseApp(100000, null), "UserBase 自身无需标记"); + assertTrue(AppNature.isBaseApp(null, "BASE"), "appKey 缺失但显式标记 BASE 仍算基石"); + assertFalse(AppNature.isBaseApp(null, null), "双缺省按业务应用放行(⑤ 原行为:appKey null 进授权范围)"); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppRoleServiceBaseAppExcludeTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppRoleServiceBaseAppExcludeTest.java new file mode 100644 index 0000000..cd07ac8 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/AppRoleServiceBaseAppExcludeTest.java @@ -0,0 +1,89 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.SysAppRole; +import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo; +import com.yunhai.userbase.repository.AppModuleRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository; +import com.yunhai.userbase.repository.SysAppRoleRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +/** + * ⑤ 全应用角色授权范围基石排除单测(方案 §8 口径差异用例)。
+ * 判定 = AppNature.isBaseApp(UserBase 自身 ∪ 显式 BASE);appKey=null 且未标记 BASE 的行 + * 沿用原行为(appKey > 100000 的原始条件对 null 求值即入列)。 + */ +class AppRoleServiceBaseAppExcludeTest { + + @InjectMocks + private AppRoleService appRoleService; + + @Mock + private SysAppRoleRepository sysAppRoleRepository; + + @Mock + private ApplicationInfoRepository applicationInfoRepository; + + @Mock + private AppModuleRepository appModuleRepository; + + @Mock + private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + private ApplicationInfo app(int id, Integer appKey, String name, String nature) { + ApplicationInfo a = new ApplicationInfo(); + a.setId(id); + a.setAppKey(appKey); + a.setName(name); + a.setAppNature(nature); + return a; + } + + @Test + void findRoleModuleTree_全应用角色排除基石_保留业务与null缺省行() { + SysAppRole role = new SysAppRole(); + role.setId(7); + role.setAllAppFlag(true); + + List all = Arrays.asList( + app(1, 100000, "用户中心平台", null), // UserBase 自身 → 排除 + app(2, 100001, "通知公告", "BUSINESS"), // 业务 → 保留 + app(3, 100015, "用户主页", "BASE"), // 显式基石 → 排除(④ 与 ①②③⑤ 口径差异的关键行) + app(4, null, "遗留无key行", null), // 双缺省 → 原行为保留 + app(5, null, "标记BASE无key行", "BASE")); // 显式基石 → 排除 + when(sysAppRoleRepository.findById(7)).thenReturn(Optional.of(role)); + when(applicationInfoRepository.findAll()).thenReturn(all); + when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(Collections.emptyList()); + when(sysAppRoleModuleRelRepository.findByRoleId(7)).thenReturn(Collections.emptyList()); + + List result = appRoleService.findRoleModuleTree(7); + + List names = result.stream().collect( + java.util.stream.Collectors.mapping(AppRoleAuthzAppVo::getName, java.util.stream.Collectors.toList())); + assertTrue(names.contains("通知公告"), "业务应用必须可见"); + assertTrue(names.contains("遗留无key行"), "appKey=null 未标记行沿用原行为"); + assertFalse(names.contains("用户中心平台"), "UserBase 自身排除"); + assertFalse(names.contains("用户主页"), "显式 BASE 排除——授权范围不见基石"); + assertFalse(names.contains("标记BASE无key行"), "显式 BASE 排除与 appKey 无关"); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoLookupTest.java b/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoLookupTest.java new file mode 100644 index 0000000..e5df073 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoLookupTest.java @@ -0,0 +1,111 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * by-code / by-key 查询数据面单测(方案 §5.1 阻塞项防回归)。
+ * 关键契约:by-key 查的是 app_key 列而非主键 id——160 实测 id ≠ app_key + * (15 行中仅 2 行相等,流程中心 appKey=100009 的主键是 100011,id=100009 是课堂作业), + * 传错会把「流程中心」静默查成「课堂作业」。 + */ +class ApplicationInfoLookupTest { + + @InjectMocks + private ApplicationInfoService applicationInfoService; + + @Mock + private ApplicationInfoRepository applicationInfoRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + private ApplicationInfo flowCenter() { + ApplicationInfo app = new ApplicationInfo(); + app.setId(100011); + app.setAppKey(100009); + app.setName("流程中心"); + app.setAppCode("flow-center"); + return app; + } + + @Test + void getByAppKey_按appKey列查询_绝不按主键id() { + ApplicationInfo center = flowCenter(); + when(applicationInfoRepository.findByAppKey(100009)).thenReturn(center); + when(applicationInfoRepository.findById(100011)).thenReturn(Optional.of(center)); + + ApplicationInfo result = applicationInfoService.getByAppKey(100009); + + assertSame(center, result); + assertEquals("流程中心", result.getName()); + verify(applicationInfoRepository).findByAppKey(100009); + // 防回归核心:主键 id=100009 是「课堂作业」,按 id 查即静默跳错 + verify(applicationInfoRepository, never()).findById(100009); + } + + @Test + void getByAppKey_查无返回null() { + when(applicationInfoRepository.findByAppKey(999999)).thenReturn(null); + assertNull(applicationInfoService.getByAppKey(999999)); + assertNull(applicationInfoService.getByAppKey(null)); + } + + @Test + void getByAppCode_按技术标识查询() { + ApplicationInfo center = flowCenter(); + when(applicationInfoRepository.findByAppCode("flow-center")).thenReturn(center); + + assertSame(center, applicationInfoService.getByAppCode("flow-center")); + verify(applicationInfoRepository).findByAppCode("flow-center"); + } + + @Test + void getByAppCode_空白入参不落库() { + assertNull(applicationInfoService.getByAppCode(null)); + assertNull(applicationInfoService.getByAppCode(" ")); + verify(applicationInfoRepository, never()).findByAppCode(any()); + } + + @Test + void edit_空appCode不回写主表_防部分提交清空引用键() { + ApplicationInfo existing = flowCenter(); + when(applicationInfoRepository.findById(100011)).thenReturn(Optional.of(existing)); + when(applicationInfoRepository.existsByNameAndIdNot("流程中心", 100011)).thenReturn(false); + when(applicationInfoRepository.save(any())).thenReturn(existing); + + com.yunhai.userbase.model.application.ApplicationInfoRequest request = + new com.yunhai.userbase.model.application.ApplicationInfoRequest(); + request.setName("流程中心"); + // 模拟学校覆盖线/部分提交:不回传 appCode/appNature + request.setAppCode(null); + request.setAppNature(null); + + applicationInfoService.edit(100011, request); + + assertEquals("flow-center", existing.getAppCode(), "引用键不得被 null 覆盖清空"); + assertNull(existing.getAppNature(), "性质同样不得被 null 覆盖"); + verify(applicationInfoRepository, never()).existsByAppCodeAndIdNot(any(), any()); + } +}