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());
+ }
+}