t16:app 注册表——四业务字段零角色 + C17 CRUD 端点族 + 在用禁删 + 入口链接生成
- sql/t16_app_registry.sql:§17.2 建表(uk_app_code 全库唯一、form.app_code 逻辑引用不建 FK)。
本机核对:dev 库 ddl-auto 建的表与脚本列型逐列一致,且 Hibernate 尊重显式索引名(无漂移);
脚本另在临时库实跑一遍校验语法后已清理。
- 实体 AppRegistry:app_code/sch_id 均 updatable=false——code 是入口链接与 routing key 的组成段,发布即冻结。
- AppRegistryService 四条写侧规则:C10 格式 + 保留字 form-center(大小写变体也算撞名);
撞 uk 前先按 code 点名(在用=已存在 / 只剩软删行=编码不复用,都是可诊断 CUSTOM(5) 而不是库内 500,P125);
删除保护按 countLiveByAppCode 全库统计(code 全库唯一,他校还在用同样算在用,取窄的一边);
删除=实体 save 改标记(软删全仓保持实体写,bulk UPDATE 会绕过审计监听器)。
- 域规则:清单=本校∪平台级;改/删只动 sch_id==本次域的行,他校行按"app 不存在"回答。
超管建平台级需显式 schId=0,与两表端点同一条代查口径,零新增判据。守卫复用 requirePlatformAdmin(C4③)。
- 入口链接由后端生成:{context-path}/front/#/app/{code}/,本地无 context 段时回落 /#/app/{code}/
(同 host.config.ts 的推导),前端只补 origin。
- 新增 GET/POST/PUT/DELETE /api/form/admin/apps。
测试 AppRegistryIT(V59/V65/V69,8 例):反射断言持久字段恰好 appCode/name/schId/isDeleted 且无
role|perm|admin|user|auth 形态列名;保留字与 6 种非法 slug + 2/64 位边界;重复注册不撞库;
本校∪平台级清单与链接后缀;普通教师 FORBIDDEN、他校"app 不存在";改名只改 name;
草稿态即算在用(msg 带张数),表单软删后才让删;软删后 code 不复用。
门禁 mvn -B -o clean test → Tests run: 273, Failures: 0, Errors: 0。
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
-- ============================================================
|
||||
-- 表单中心 T16 · app 注册表 DDL(MySQL 5.7 兼容复核版)
|
||||
-- 范围:《表单中心契约v2》§5.2/§17.2 一笔 = 建表 app_registry
|
||||
-- 验收:V59(零角色字段)/ V65(uk(app_code) 全库唯一 + 保留字)/ V69(CRUD 与在用禁删)
|
||||
-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 Hibernate
|
||||
-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 t2/t3/t10 先例)。
|
||||
-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE;
|
||||
-- ③InnoDB、无物理外键——form.app_code 对注册表是<b>逻辑引用</b>(§5.2 明令"不校验存在性亦可建
|
||||
-- 表单",建 FK 等于把这条改写成强引用,快应用还没注册就先建表的老数据会直接进不来);
|
||||
-- ④本表无可空列(is_deleted/sch_id 都 NOT NULL,审计列按 P5 实态 DEFAULT NULL)。
|
||||
-- **零角色字段(D14/V59 红线)**:全表只有 app_code / name / sch_id / is_deleted 四个业务列
|
||||
-- + BaseEntity 四审计列。任何"谁能用这个 app""app 的管理员"之类的列都不许加——
|
||||
-- 把授权塞回注册表正是 v2 要出清的东西(§19 作废-1/2 的同一件事)。
|
||||
-- 唯一键口径:uk_app_code 是<b>全库唯一且不含 is_deleted</b>(§17.2 原文),所以软删后的 code
|
||||
-- 仍被占用、不可复用(这层落地差额登记为问题单 P125,服务侧给可诊断文案而不是撞库 500)。
|
||||
-- 非幂等:MySQL 5.7 无 ADD/CREATE IF NOT EXISTS 的评审友好写法(写了反而掩盖"表已存在但列不对"的漂移)。
|
||||
-- 执行前先 `SHOW TABLES LIKE 'app_registry'` 确认不存在;存量:生产零存量(本表 T16 才建),无需回填。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE `app_registry` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`app_code` VARCHAR(64) NOT NULL COMMENT '全局唯一人读 slug(C10 格式 ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$;保留字 form-center 不可注册)',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '应用显示名(入口链接要贴去 UserBase,名字与那边对照用)',
|
||||
`sch_id` BIGINT NOT NULL COMMENT '建立者会话校;0=平台级(仅超管可建,D14)',
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删标记;名下存在 is_deleted=0 的表单行时禁删(§5.2 在用禁删)',
|
||||
`create_by` VARCHAR(255) DEFAULT NULL,
|
||||
`create_time` DATETIME DEFAULT NULL,
|
||||
`update_by` VARCHAR(255) DEFAULT NULL,
|
||||
`update_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_app_code` (`app_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
COMMENT='快应用注册表(契约 v2 §5.2:四业务字段、零角色、无审核;form.app_code 为逻辑引用不建 FK)';
|
||||
|
||||
-- 自验(执行后逐条核对,V59/V65):
|
||||
-- SHOW CREATE TABLE app_registry\G -- 应含 UNIQUE KEY `uk_app_code` (`app_code`),且列只有上面这些
|
||||
-- SELECT COUNT(*) FROM information_schema.columns
|
||||
-- WHERE table_schema=DATABASE() AND table_name='app_registry'
|
||||
-- AND column_name REGEXP 'role|perm|admin|user_id'; -- 必须 0 行(V59 的机械判据)
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.yunhai.formcenter.controller.api;
|
||||
|
||||
import com.yunhai.formcenter.model.AppModels;
|
||||
import com.yunhai.formcenter.service.AppRegistryService;
|
||||
import com.yunhai.formcenter.service.FormAdminGuard;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 快应用注册表端点族(契约 v2 C17/§5.2,T16)。
|
||||
*
|
||||
* <pre>
|
||||
* GET /api/form/admin/apps → 本校 ∪ 平台级清单(每行附中心生成的 entryUrl)
|
||||
* POST /api/form/admin/apps → 建(appCode + name;超管可 schId 指定域,含 0)
|
||||
* PUT /api/form/admin/apps/{appCode} → 改名
|
||||
* DELETE /api/form/admin/apps/{appCode} → 软删(在用禁删)
|
||||
* </pre>
|
||||
*
|
||||
* <p>守卫与管理员两表同一把:{@link FormAdminGuard#requirePlatformAdmin}(校管理员 ∪ 超管,C4③)——
|
||||
* 模块行退役后"应用设置页进不进得来"就是这一个判据。寻址一律按 {@code appCode}(库内 id 不外露,V5),
|
||||
* 因此改名以外的写操作没有"按 id 改 code"的面。</p>
|
||||
*
|
||||
* <p>返回形态沿两表那组端点:写操作直接回<b>操作后的单行</b>,前端自己更新那一行即可——
|
||||
* 应用数量是个位数,不需要为了省一次刷新而把全量列表塞进每个写响应。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/form/admin/apps")
|
||||
@RequiredArgsConstructor
|
||||
public class AppRegistryController {
|
||||
|
||||
private final FormAdminGuard adminGuard;
|
||||
private final AppRegistryService appService;
|
||||
|
||||
/** 清单(超管可用 {@code schId} 显式换域看某校,与两表端点同一条代查口径) */
|
||||
@GetMapping
|
||||
public List<AppModels.AppView> list(BoaUser user, @RequestParam(required = false) Long schId) {
|
||||
return appService.list(adminGuard.requirePlatformAdmin(user, schId));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public AppModels.AppView create(BoaUser user, @RequestBody AppModels.CreateRequest body) {
|
||||
Long domain = adminGuard.requirePlatformAdmin(user, body == null ? null : body.getSchId());
|
||||
return appService.create(domain, body);
|
||||
}
|
||||
|
||||
@PutMapping("/{appCode}")
|
||||
public AppModels.AppView rename(BoaUser user,
|
||||
@PathVariable("appCode") String appCode,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody AppModels.UpdateRequest body) {
|
||||
Long domain = adminGuard.requirePlatformAdmin(user, schId);
|
||||
return appService.rename(domain, appCode, body);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{appCode}")
|
||||
public AppModels.AppView delete(BoaUser user,
|
||||
@PathVariable("appCode") String appCode,
|
||||
@RequestParam(required = false) Long schId) {
|
||||
Long domain = adminGuard.requirePlatformAdmin(user, schId);
|
||||
return appService.delete(domain, appCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.formcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 快应用注册表(契约 v2 §5.2/§17.2,T16)。
|
||||
*
|
||||
* <p><b>四个业务列,零角色列</b>(D14/V59 红线):这张表只回答"有哪些应用、叫什么名字、谁建的、删没删"。
|
||||
* "谁能进哪个应用"一律不在这张表里——它由 {@code DataScopeService} 按会话在该应用的表单上现算 ALL/SELF 档。
|
||||
* 一旦这里出现 {@code role}/{@code admin}/{@code user_id} 之类的列,就等于把 v2 出清掉的授权层
|
||||
* 从后门搬回来({@code AppRegistryIT} 用反射把这条钉成机械断言)。</p>
|
||||
*
|
||||
* <p>{@code app_code} 全库唯一({@code uk_app_code},<b>不含 is_deleted</b>):软删后 code 仍被占用,
|
||||
* 不复用(P125)。它是 oapi 游标拉取的域段与 MQ routing key 的组成段,因此 {@code updatable=false}——
|
||||
* 改名走 {@code name},code 一旦发布就等于对外契约的一部分(UserBase 那边贴的是含它的入口链接)。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "app_registry",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_app_code", columnNames = {"app_code"}))
|
||||
public class AppRegistry extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 全局唯一人读 slug(C10 格式;保留字 {@code form-center} 不可注册),发布后不改 */
|
||||
@Column(name = "app_code", nullable = false, length = 64, updatable = false)
|
||||
private String appCode;
|
||||
|
||||
/** 应用显示名 */
|
||||
@Column(name = "name", nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
/** 建立者会话校;0=平台级(仅超管可建) */
|
||||
@Column(name = "sch_id", nullable = false, updatable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 软删标记——唯一删除形态;在用(名下有未软删表单)禁删(§5.2) */
|
||||
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
|
||||
private Integer isDeleted = 0;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yunhai.formcenter.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 快应用注册表的出入参(契约 v2 C17/§5.2,T16)。
|
||||
*
|
||||
* <p>对外身份只有 {@code appCode}(人读 slug,也是入口链接与 MQ routing key 的组成段),
|
||||
* 库内 {@code id} 一律不外露(契约 §1/V5);因此 PUT/DELETE 都按 code 寻址而不是按 id。</p>
|
||||
*/
|
||||
public final class AppModels {
|
||||
|
||||
private AppModels() {
|
||||
}
|
||||
|
||||
/** 注册行:四个业务字段 + 中心生成的入口链接 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class AppView {
|
||||
private String appCode;
|
||||
private String name;
|
||||
/** 归属租户域;0=平台级(仅超管可建) */
|
||||
private Long schId;
|
||||
/** 应用入口链接(§5.2"由中心生成"),管理员复制后贴去 UserBase 建应用 */
|
||||
private String entryUrl;
|
||||
}
|
||||
|
||||
/** 建:app_code + name;{@code schId} 只有超管能用它指定目标域(校管理员一律按会话校落) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CreateRequest {
|
||||
private String appCode;
|
||||
private String name;
|
||||
private Long schId;
|
||||
}
|
||||
|
||||
/** 改:C17 只开改名(code 一旦发布就是对外坐标,不随改) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class UpdateRequest {
|
||||
private String name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.yunhai.formcenter.repository;
|
||||
|
||||
import com.yunhai.formcenter.entity.AppRegistry;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* app 注册表仓库(T16)。查询一律带租户域与软删过滤,唯一例外是
|
||||
* {@link #findByAppCode}——它要连软删行一起看,否则"code 曾被注册"会被撞成库内唯一键 500
|
||||
* 而不是一句可诊断的拒绝(P125)。
|
||||
*/
|
||||
public interface AppRegistryRepository extends JpaRepository<AppRegistry, Long> {
|
||||
|
||||
/** 按 code 定位(含软删行;{@code uk_app_code} 全库唯一,至多一行) */
|
||||
Optional<AppRegistry> findByAppCode(String appCode);
|
||||
|
||||
/** 清单取域:本校 ∪ 平台级(sch_id=0)的未删行——契约 C17"按会话域" */
|
||||
@Query("select a from AppRegistry a where a.schId in (:schIds) and a.isDeleted = 0 order by a.id asc")
|
||||
List<AppRegistry> findLiveInDomain(@Param("schIds") Collection<Long> schIds);
|
||||
}
|
||||
@@ -77,4 +77,13 @@ public interface FormRepository extends JpaRepository<Form, Long> {
|
||||
/** 按 uuid 批量取头表(T14 守卫:由答卷反查所属表单以判 ALL 档;uk_form_uuid 唯一) */
|
||||
@Query("select f from Form f where f.uuid in (:uuids)")
|
||||
List<Form> findByUuidIn(@Param("uuids") Collection<String> uuids);
|
||||
|
||||
/**
|
||||
* 某个 app_code 名下还有多少张<b>未软删</b>表单(T16 删除保护的"在用"判据,§5.2)。
|
||||
*
|
||||
* <p>不带 sch_id 条件是<b>有意的</b>:{@code uk_app_code} 是全库唯一,code 被删掉就等于对所有人
|
||||
* 都不可用,那么"他校还在用这个 code"同样构成在用——按窄的一边判(fail-closed,同 P117 的取向)。</p>
|
||||
*/
|
||||
@Query("select count(f) from Form f where f.appCode = :appCode and f.isDeleted = 0")
|
||||
long countLiveByAppCode(@Param("appCode") String appCode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.AppRegistry;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.model.AppModels;
|
||||
import com.yunhai.formcenter.repository.AppRegistryRepository;
|
||||
import com.yunhai.formcenter.repository.FormRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 快应用注册表读写(契约 v2 §5.2/C17,T16,验收 V59/V65/V69)。
|
||||
*
|
||||
* <p><b>这张表不答的问题比它答的更重要</b>:它不管"谁能用哪个应用"(零角色列,V59 红线)——
|
||||
* 应用里每张表单的可见范围都由 {@link DataScopeService} 按会话现算 ALL/SELF 档。注册只是
|
||||
* "给一个人读 slug 上户口",好让入口链接、oapi 游标域段与 MQ routing key 有个全局唯一的锚点。</p>
|
||||
*
|
||||
* <p>四条写侧规则:</p>
|
||||
* <ol>
|
||||
* <li><b>code 只进不改</b>:格式按 C10(小写 slug,2~64 位,不许连字符开头/结尾),保留字
|
||||
* {@code form-center} 拒绝;{@code uk_app_code} 全库唯一<b>且不含 is_deleted</b>,
|
||||
* 所以删过的 code 也不复用——撞键前先在库里点名,给可诊断文案而不是库内 500(P125);</li>
|
||||
* <li><b>域=会话域</b>:校管理员只能建/改/删本校行,平台级行({@code sch_id=0})只有超管显式
|
||||
* {@code schId=0} 时才动得了;他校行按"app 不存在"回答(与数据面他校同一条防探测口径);</li>
|
||||
* <li><b>在用禁删</b>:名下存在未软删表单(不分草稿/发布/停用)即 CUSTOM(5),msg 指点先迁先删;
|
||||
* 判据按 code 全库统计而不是按校,因为 code 本身是全库唯一的(窄的一边,同 P117 的取向);</li>
|
||||
* <li><b>删除=软删</b>:实体 save 改标记(全仓软删都保持实体写,bulk UPDATE 会绕过审计监听器)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppRegistryService {
|
||||
|
||||
/** C10:2~64 位小写 slug(首尾必须字母/数字,中间可带连字符)——比 form.code 的字符集严,因为它要进链接与 routing key */
|
||||
private static final Pattern APP_CODE_PATTERN = Pattern.compile("^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$");
|
||||
|
||||
private static final int NAME_MAX = 128;
|
||||
|
||||
private final AppRegistryRepository appRepository;
|
||||
private final FormRepository formRepository;
|
||||
|
||||
/** 部署上下文路径(生产 {@code CONTEXT_PATH=/form-center} 注入,本地为空)——入口链接的段前缀 */
|
||||
@Value("${server.servlet.context-path:}")
|
||||
private String contextPath;
|
||||
|
||||
// ==================== 读 ====================
|
||||
|
||||
/** 清单:本校 ∪ 平台级(C17"按会话域"),软删行不进 */
|
||||
public List<AppModels.AppView> list(Long domain) {
|
||||
List<AppModels.AppView> views = new ArrayList<>();
|
||||
for (AppRegistry row : appRepository.findLiveInDomain(Arrays.asList(domain, Form.PLATFORM_SCH_ID))) {
|
||||
views.add(toView(row));
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
// ==================== 写 ====================
|
||||
|
||||
/** 建:校管理员落会话校,超管可用 {@code schId} 指定目标域(含 0=平台级);无审核(D14) */
|
||||
@Transactional
|
||||
public AppModels.AppView create(Long domain, AppModels.CreateRequest req) {
|
||||
String appCode = requireCode(req == null ? null : req.getAppCode());
|
||||
String name = requireName(req == null ? null : req.getName());
|
||||
AppRegistry existing = appRepository.findByAppCode(appCode).orElse(null);
|
||||
if (existing != null) {
|
||||
throw new BizException(ErrorCode.CUSTOM, Integer.valueOf(0).equals(existing.getIsDeleted())
|
||||
? "app_code 已存在:" + appCode
|
||||
: "app_code 曾被注册且已删除,编码不复用(uk_app_code 全库唯一,请换 code):" + appCode);
|
||||
}
|
||||
AppRegistry row = new AppRegistry();
|
||||
row.setAppCode(appCode);
|
||||
row.setName(name);
|
||||
row.setSchId(domain);
|
||||
try {
|
||||
row = appRepository.saveAndFlush(row);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// 并发注册同一 code:uk 是正主,这里只补一句人话
|
||||
throw new BizException(ErrorCode.CUSTOM, "app_code 已存在(并发注册):" + appCode);
|
||||
}
|
||||
log.info("[app 注册] 新建 {}(sch_id={},name={})", appCode, domain, name);
|
||||
return toView(row);
|
||||
}
|
||||
|
||||
/** 改名(C17 只开这一件写:code 是对外坐标,不随改) */
|
||||
@Transactional
|
||||
public AppModels.AppView rename(Long domain, String rawAppCode, AppModels.UpdateRequest req) {
|
||||
AppRegistry row = requireInDomain(domain, rawAppCode);
|
||||
row.setName(requireName(req == null ? null : req.getName()));
|
||||
return toView(appRepository.save(row));
|
||||
}
|
||||
|
||||
/** 软删(在用禁删) */
|
||||
@Transactional
|
||||
public AppModels.AppView delete(Long domain, String rawAppCode) {
|
||||
AppRegistry row = requireInDomain(domain, rawAppCode);
|
||||
long inUse = formRepository.countLiveByAppCode(row.getAppCode());
|
||||
if (inUse > 0) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "app_code「" + row.getAppCode() + "」名下还有 " + inUse
|
||||
+ " 张未删除表单,先把表单迁走或删除再删注册行(契约 v2 §5.2 在用禁删)");
|
||||
}
|
||||
row.setIsDeleted(1);
|
||||
appRepository.save(row);
|
||||
log.info("[app 注册] 软删 {}(sch_id={})", row.getAppCode(), row.getSchId());
|
||||
return toView(row);
|
||||
}
|
||||
|
||||
// ==================== 校验与定位 ====================
|
||||
|
||||
/**
|
||||
* 按 code 定位<b>本域内</b>的在用行。
|
||||
*
|
||||
* <p>清单会显示平台级行,但改/删不动它({@code sch_id} 必须等于本次操作域);他校行与"不存在"
|
||||
* 同一口径回答——注册表按 code 寻址,code 是半公开的串,"存在但你不是它的主人"这句话本身就是信息。</p>
|
||||
*/
|
||||
private AppRegistry requireInDomain(Long domain, String rawAppCode) {
|
||||
String appCode = requireCode(rawAppCode);
|
||||
AppRegistry row = appRepository.findByAppCode(appCode)
|
||||
.filter(a -> Integer.valueOf(0).equals(a.getIsDeleted()))
|
||||
.orElse(null);
|
||||
if (row == null || !row.getSchId().equals(domain)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "app 不存在:" + appCode);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static String requireCode(String raw) {
|
||||
String clean = raw == null ? null : raw.trim();
|
||||
if (clean == null || clean.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "appCode 必填");
|
||||
}
|
||||
if (Form.CENTER_APP_CODE.equalsIgnoreCase(clean)) {
|
||||
// 大小写也算撞名:form.app_code 的缺省值就是这个串,注册它等于把中心表单划进某个应用
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "form-center 是中心保留的 app_code,不可注册");
|
||||
}
|
||||
if (!APP_CODE_PATTERN.matcher(clean).matches()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "appCode 需为 2~64 位小写 slug(^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$):" + clean);
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private static String requireName(String raw) {
|
||||
String clean = raw == null ? null : raw.trim();
|
||||
if (clean == null || clean.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "应用名称 name 必填");
|
||||
}
|
||||
if (clean.length() > NAME_MAX) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "应用名称过长(≤" + NAME_MAX + ")");
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private AppModels.AppView toView(AppRegistry row) {
|
||||
AppModels.AppView view = new AppModels.AppView();
|
||||
view.setAppCode(row.getAppCode());
|
||||
view.setName(row.getName());
|
||||
view.setSchId(row.getSchId());
|
||||
view.setEntryUrl(entryUrl(row.getAppCode()));
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用入口链接(§5.2"由中心生成",复制后贴去 UserBase 建应用)。
|
||||
*
|
||||
* <p>生产形态 {@code /form-center/front/#/app/{appCode}/};本地后端没有 context-path,前端跑在
|
||||
* 根路径上({@code host.config.ts} 的"seg.length>=2 才有 ctx 段"是同一条推导),这时不套
|
||||
* {@code /front},否则复制出来的链接在 dev 里 404。</p>
|
||||
*/
|
||||
private String entryUrl(String appCode) {
|
||||
String prefix = contextPath == null || contextPath.trim().isEmpty() ? "" : contextPath.trim();
|
||||
return prefix + (prefix.isEmpty() ? "/" : "/front") + "#/app/" + appCode + "/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.controller.api.AppRegistryController;
|
||||
import com.yunhai.formcenter.entity.AppRegistry;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.model.AppModels;
|
||||
import com.yunhai.formcenter.model.FormModels;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
|
||||
/**
|
||||
* app 注册表(契约 v2 §5.2/C10/C17,T16,验收 <b>V59/V65/V69</b>)。
|
||||
*
|
||||
* <p>三条红线各有一把机械断言:</p>
|
||||
* <ul>
|
||||
* <li><b>V59 零角色字段</b>——反射点名持久字段只允许四个业务列,任何 {@code role/perm/admin/userId}
|
||||
* 形态的列一旦出现就 FAIL。这条必须写成反射而不是注释,否则"顺手加一列授权"没人拦得住;</li>
|
||||
* <li><b>V65 全局唯一 + 保留字</b>——{@code form-center} 与不合 C10 的串都拒,重复注册给可诊断文案;</li>
|
||||
* <li><b>V69 在用禁删 + 端点族全通</b>——建/列/改/删四件事按会话域判,他校按"app 不存在"回答。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>打真库、{@code @Transactional} 回滚(沿 {@code AnswerTestSupport} 先例),入口走控制器而不是
|
||||
* 只调服务:守卫在控制器上,只测服务会把 C17 的"守卫=校管理员∪超管"整条漏掉。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
|
||||
@Transactional
|
||||
@DisplayName("app 注册表 - 四字段零角色、全局唯一、在用禁删(V59/V65/V69)")
|
||||
class AppRegistryIT extends AnswerTestSupport {
|
||||
|
||||
@Autowired
|
||||
private AppRegistryService appService;
|
||||
|
||||
@Autowired
|
||||
private AppRegistryController appController;
|
||||
|
||||
@Autowired
|
||||
private FormAdminGuard adminGuard;
|
||||
|
||||
// ==================== V59 零角色列 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V59 红线:实体持久字段恰好四个业务列,任何角色/授权列形态都不许出现")
|
||||
void registryCarriesOnlyFourBusinessColumns() {
|
||||
List<String> columns = new ArrayList<>();
|
||||
for (Field field : AppRegistry.class.getDeclaredFields()) {
|
||||
if (field.isSynthetic() || Modifier.isStatic(field.getModifiers())) {
|
||||
continue;
|
||||
}
|
||||
columns.add(field.getName());
|
||||
}
|
||||
assertThat(columns).containsExactlyInAnyOrder("appCode", "name", "schId", "isDeleted");
|
||||
assertThat(columns).noneMatch(name -> name.matches("(?i).*(role|perm|admin|user|auth).*"));
|
||||
}
|
||||
|
||||
// ==================== V65 格式、保留字与唯一 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V65:保留字 form-center(含大小写变体)与不合 C10 的串一律拒")
|
||||
void reservedWordAndBadSlugRejected() {
|
||||
assertCodeRejected("form-center", "保留");
|
||||
assertCodeRejected("Form-Center", "保留");
|
||||
assertCodeRejected("Survey", "slug");
|
||||
assertCodeRejected("a", "slug");
|
||||
assertCodeRejected("-lead", "slug");
|
||||
assertCodeRejected("trail-", "slug");
|
||||
assertCodeRejected("under_score", "slug");
|
||||
assertCodeRejected(repeat('x', 65), "slug");
|
||||
|
||||
// 合法形态:2 位、64 位、中间带连字符
|
||||
assertThat(service().create(SCH, request("ab", "两位码")).getAppCode()).isEqualTo("ab");
|
||||
assertThat(service().create(SCH, request("survey-2026", "演练应用")).getAppCode()).isEqualTo("survey-2026");
|
||||
assertThat(service().create(SCH, request(repeat('y', 64), "六十四位码")).getAppCode()).hasSize(64);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V65:同 code 重复注册给可诊断文案而不是撞库 500")
|
||||
void duplicateCodeIsRejectedNotCrashed() {
|
||||
service().create(SCH, request("v65-dup", "第一次"));
|
||||
|
||||
BizException again = catchThrowableOfType(
|
||||
() -> service().create(SCH, request("v65-dup", "第二次")), BizException.class);
|
||||
assertThat(again.getErrorCode()).isEqualTo(ErrorCode.CUSTOM);
|
||||
assertThat(again.getMessage()).contains("已存在").contains("v65-dup");
|
||||
}
|
||||
|
||||
// ==================== V69 域、清单与入口链接 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V69:校管理员建本校、超管可建平台级(schId=0);清单=本校∪平台级且每行带中心生成的入口链接")
|
||||
void schoolAdminOwnsSchoolRowsAndPlatformRowsAreListed() {
|
||||
AppModels.AppView mine = service().create(SCH, request("v69-school", "本校应用"));
|
||||
assertThat(mine.getSchId()).isEqualTo(SCH);
|
||||
|
||||
BoaUser root = user(1, SCH, 9);
|
||||
root.setType(1);
|
||||
// 超管显式 schId=0 才是平台级(不传=按会话校落,与两表端点同一条代查口径)
|
||||
AppModels.AppView platform = service().create(platformDomain(root), request("v69-platform", "平台应用"));
|
||||
assertThat(platform.getSchId()).isZero();
|
||||
|
||||
List<String> codes = service().list(SCH).stream()
|
||||
.map(AppModels.AppView::getAppCode).collect(Collectors.toList());
|
||||
assertThat(codes).contains("v69-school", "v69-platform");
|
||||
|
||||
assertThat(service().list(SCH).stream()
|
||||
.filter(a -> "v69-school".equals(a.getAppCode())).findFirst().get().getEntryUrl())
|
||||
.endsWith("/#/app/v69-school/");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V69:没有平台管理权的会话(普通教师)四把端点全拒;他校管理员动不了本校行,按\"app 不存在\"回答")
|
||||
void nonAdminIsForbiddenAndForeignRowIsNotFound() {
|
||||
service().create(SCH, request("v69-private", "本校应用"));
|
||||
|
||||
BoaUser teacher = plainUser(3);
|
||||
assertThatThrownBy(() -> controller().list(teacher, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.satisfies(e -> assertThat(((BizException) e).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN));
|
||||
|
||||
BoaUser otherSchoolAdmin = userAtOtherSchool();
|
||||
List<String> visible = controller().list(otherSchoolAdmin, null).stream()
|
||||
.map(AppModels.AppView::getAppCode).collect(Collectors.toList());
|
||||
assertThat(visible).doesNotContain("v69-private");
|
||||
|
||||
// 本校行对他是"不存在",不是"不是你的"(code 是半公开串,不给可枚举信息)
|
||||
assertThatThrownBy(() -> controller().rename(otherSchoolAdmin, "v69-private", null, name("改我")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("app 不存在");
|
||||
assertThatThrownBy(() -> controller().delete(otherSchoolAdmin, "v69-private", null))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("app 不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V69:改名只改 name,code 不动(它是入口链接与 routing key 的组成段)")
|
||||
void renameChangesOnlyTheName() {
|
||||
service().create(SCH, request("v69-rename", "旧名"));
|
||||
AppModels.AppView renamed = controller().rename(manager(), "v69-rename", null, name("新名"));
|
||||
assertThat(renamed.getName()).isEqualTo("新名");
|
||||
assertThat(renamed.getAppCode()).isEqualTo("v69-rename");
|
||||
|
||||
assertThatThrownBy(() -> controller().rename(manager(), "v69-rename", null, name(" ")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("必填");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V69 删除保护:名下有未软删表单(草稿也算在用)即 CUSTOM(5) 并指点先迁先删;表单软删后才让删")
|
||||
void deleteBlockedWhileFormsAreLive() {
|
||||
String code = "v69-inuse";
|
||||
service().create(SCH, request(code, "带表单的应用"));
|
||||
String formUuid = createForm(code, "v69_inuse_form");
|
||||
|
||||
BizException blocked = catchThrowableOfType(
|
||||
() -> controller().delete(manager(), code, null), BizException.class);
|
||||
assertThat(blocked.getErrorCode()).isEqualTo(ErrorCode.CUSTOM);
|
||||
assertThat(blocked.getMessage()).contains("1 张未删除表单").contains("先");
|
||||
|
||||
defService.softDelete(SCH, formUuid);
|
||||
assertThat(controller().delete(manager(), code, null).getAppCode()).isEqualTo(code);
|
||||
assertThat(service().list(SCH).stream().map(AppModels.AppView::getAppCode))
|
||||
.doesNotContain(code);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("P125:软删后的 code 不复用(uk_app_code 不含 is_deleted)——给可诊断拒绝而不是撞库")
|
||||
void deletedCodeIsStillOccupied() {
|
||||
String code = "v69-recycle";
|
||||
service().create(SCH, request(code, "一次性应用"));
|
||||
controller().delete(manager(), code, null);
|
||||
|
||||
BizException reused = catchThrowableOfType(
|
||||
() -> service().create(SCH, request(code, "换个名字重建")), BizException.class);
|
||||
assertThat(reused.getErrorCode()).isEqualTo(ErrorCode.CUSTOM);
|
||||
assertThat(reused.getMessage()).contains("不复用").contains(code);
|
||||
}
|
||||
|
||||
// ==================== 脚手架 ====================
|
||||
|
||||
private void assertCodeRejected(String code, String messagePart) {
|
||||
assertThatThrownBy(() -> service().create(SCH, request(code, "任意名")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining(messagePart);
|
||||
}
|
||||
|
||||
private Long platformDomain(BoaUser root) {
|
||||
return guard().requirePlatformAdmin(root, Form.PLATFORM_SCH_ID);
|
||||
}
|
||||
|
||||
/** 建一张属于该 app 的表单(只到草稿即可——"在用"的定义含草稿/发布/停用三态) */
|
||||
private String createForm(String appCode, String formCode) {
|
||||
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
|
||||
create.setAppCode(appCode);
|
||||
create.setCode(formCode);
|
||||
create.setName("测试 " + formCode);
|
||||
return defService.create(SCH, create, null).getFormUuid();
|
||||
}
|
||||
|
||||
private static AppModels.CreateRequest request(String appCode, String name) {
|
||||
AppModels.CreateRequest req = new AppModels.CreateRequest();
|
||||
req.setAppCode(appCode);
|
||||
req.setName(name);
|
||||
return req;
|
||||
}
|
||||
|
||||
private static AppModels.UpdateRequest name(String name) {
|
||||
AppModels.UpdateRequest req = new AppModels.UpdateRequest();
|
||||
req.setName(name);
|
||||
return req;
|
||||
}
|
||||
|
||||
private static String repeat(char c, int times) {
|
||||
StringBuilder sb = new StringBuilder(times);
|
||||
for (int i = 0; i < times; i++) {
|
||||
sb.append(c);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private AppRegistryService service() {
|
||||
return appService;
|
||||
}
|
||||
|
||||
private AppRegistryController controller() {
|
||||
return appController;
|
||||
}
|
||||
|
||||
private FormAdminGuard guard() {
|
||||
return adminGuard;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user