feat(app): 应用身份统一 app_code/app_nature——五处基石判定收口 + by-code/by-key 开放端点(消息卡片方案 B1)

This commit is contained in:
chenxing
2026-09-28 12:44:26 +08:00
parent 3846ec21a9
commit 257ba2dd64
13 changed files with 487 additions and 15 deletions
@@ -0,0 +1,39 @@
package com.yunhai.userbase.constant;
/**
* 应用性质(application_info.app_nature)。<br>
* BASE=基石应用:平台基建身份(UserHome / 移动门户等),需要出站签名身份但不进门户开通/授权/模块管理;<br>
* BUSINESS=业务应用:门户可见,参与开通/授权。<br>
* 与 app_category 的「基础应用」分类无关——那边是"类型",这边是"性质"(两界面不同屏,存储层并存)。
*/
public enum AppNature { BASE, BUSINESS;
/**
* UserBase 自身的 appKey —— appKey 从 100000 起自增,"基石应用段"起点(application_bootstrap.sql)。<br>
* 它是验签的管理者,不拿自己的身份被验签,故不标 app_nature,由 {@link #isUserbaseSelf} 覆盖。
*/
public static final int USER_CENTER_APP_KEY = 100000;
/**
* 条件一:UserBase 自身(延续原「appKey &gt; 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);
}
/**
* 是否基石应用(不参与应用开通 / 授权 / 模块管理等业务功能)。<br>
* 两条件取「或」。注意 JPQL @Query 内无法调用本方法——Repository 三处为常量拼接的 SQL 形态,
* 语义单点仍在本枚举,改动须同步(含三值逻辑 NULL 分支)。
*/
public static boolean isBaseApp(Integer appKey, String appNature) {
return isUserbaseSelf(appKey) || isMarkedBase(appNature);
}
}
@@ -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));
}
/**
* 按技术标识查询应用信息(跳转查询键主用端点)。<br>
* app_code = projman app.name(如 notice/flow-center),即消息 {@code jump_app} 的值域;<br>
* 查不到返回 null(与 byId 契约一致)。返回视图清空 appSecret。
*
* @param appCode 技术标识(必填)
* @return 应用信息视图;不存在时返回 null
*/
@GetMapping("by-code/{appCode}")
public ApplicationInfoVo byCode(@PathVariable String appCode) {
return toVo(applicationInfoService.getByAppCode(appCode));
}
/**
* 按整型 appKey 查询应用信息(兼容兜底端点,存量自发场景按数字回落)。<br>
* ⚠️ 查的是 app_key 列而非主键 id——实测 id ≠ app_key(15 行中仅 2 行相等,
* 如流程中心 appKey=100009 的主键是 100011),按 id 查会把「流程中心」查成「课堂作业」。<br>
* 查不到返回 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;
}
@@ -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),跨系统稳定引用键。<br>
* 跳转查询键(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")
@@ -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),全库唯一,新增必填。<br>
* 编辑不强制(部分提交场景不回传则保持原值);值域为引用键,改名须盘点引用。
*/
@NotBlank(groups = ValidationGroups.Create.class, message = "技术标识不能为空")
@Size(max = 32, message = "技术标识长度不能超过32")
private String appCode;
/**
* 应用性质:BASE=基石 / BUSINESS=业务,新增必填。<br>
* 编辑不强制(不回传则保持原值);学校模式覆盖线不涉及本字段。
*/
@NotBlank(groups = ValidationGroups.Create.class, message = "应用性质不能为空")
@Size(max = 16, message = "应用性质长度不能超过16")
private String appNature;
/**
* 应用描述。<br>
*/
@@ -47,6 +47,16 @@ public class ApplicationInfoVo {
*/
private String appSecret;
/**
* 技术标识(= projman app.name,如 notice)。<br>
*/
private String appCode;
/**
* 应用性质:BASE=基石 / BUSINESS=业务。<br>
*/
private String appNature;
/**
* 应用前端类型列表。<br>
*/
@@ -17,7 +17,10 @@ import java.util.List;
* @description
*/
public interface ApplicationInfoRepository extends JpaRepository<ApplicationInfo, Integer>, JpaSpecificationExecutor<ApplicationInfo> {
@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<ApplicationInf
/**
* 按应用类型查询排序所需的精简视图(仅 id/name/sortNo),不分页。<br>
* 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。<br>
* 排除用户中心自身的基石应用(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<ApplicationInfoSortVo> findSortVoByCategoryId(@Param("categoryId") Integer categoryId);
/**
* 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。<br>
* 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。<br>
* 排除用户中心自身的基石应用(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<AppAuthzAppRow> findAllAuthzRows();
/**
* 按技术标识查单个应用(app_code = projman app.name,跳转查询键 /oapi/app/by-code 的数据面)。<br>
*
* @param appCode 技术标识(如 notice)
* @return 匹配的应用实体,无则 null
*/
ApplicationInfo findByAppCode(String appCode);
/** 技术标识唯一性校验(新增用)。 */
boolean existsByAppCode(String appCode);
/** 技术标识唯一性校验(编辑排除自身用)。 */
boolean existsByAppCodeAndIdNot(String appCode, Integer id);
}
@@ -28,6 +28,23 @@ public interface IApplicationInfoService {
*/
Integer findIdByAppKey(Integer appKey);
/**
* 按技术标识(app_code = projman app.name)查应用:/oapi/app/by-code 端点的数据面。<br>
*
* @param appCode 技术标识(如 notice)
* @return 应用实体;无则 null
*/
ApplicationInfo getByAppCode(String appCode);
/**
* 按整型 appKey 查应用(查 app_key 列,非主键 id):/oapi/app/by-key 兜底端点的数据面。<br>
* 复用 appKeyToId + appInfo 两级缓存。
*
* @param appKey 应用标识
* @return 应用实体;无则 null
*/
ApplicationInfo getByAppKey(Integer appKey);
/**
* 保存/更新应用信息(写后同步更新缓存)。
*/
@@ -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<ApplicationInfo> 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);
}
}
@@ -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<ApplicationInfo> root, CriteriaQuery<?> cq, CriteriaBuilder cb) {
List<Predicate> predicates = new ArrayList<Predicate>();
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();
@@ -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);
@@ -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 进授权范围)");
}
}
@@ -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 口径差异用例)。<br>
* 判定 = AppNature.isBaseApp(UserBase 自身 ∪ 显式 BASE);appKey=null 且未标记 BASE 的行
* 沿用原行为(appKey &gt; 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<ApplicationInfo> 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<AppRoleAuthzAppVo> result = appRoleService.findRoleModuleTree(7);
List<String> 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 无关");
}
}
@@ -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 阻塞项防回归)。<br>
* 关键契约: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());
}
}