feat(app): 应用身份统一 app_code/app_nature——五处基石判定收口 + by-code/by-key 开放端点(消息卡片方案 B1)
This commit is contained in:
@@ -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 > 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 > 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user