From c41ffebb95f18d180107512cfcce44ccf08ddc3d Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 20 Oct 2026 14:30:10 +0800 Subject: [PATCH] =?UTF-8?q?t16:app=20=E6=B3=A8=E5=86=8C=E8=A1=A8=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=9B=9B=E4=B8=9A=E5=8A=A1=E5=AD=97=E6=AE=B5=E9=9B=B6?= =?UTF-8?q?=E8=A7=92=E8=89=B2=20+=20C17=20CRUD=20=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E6=97=8F=20+=20=E5=9C=A8=E7=94=A8=E7=A6=81=E5=88=A0=20+=20?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E9=93=BE=E6=8E=A5=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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。 --- sql/t16_app_registry.sql | 39 +++ .../controller/api/AppRegistryController.java | 73 ++++++ .../yunhai/formcenter/entity/AppRegistry.java | 47 ++++ .../yunhai/formcenter/model/AppModels.java | 44 ++++ .../repository/AppRegistryRepository.java | 25 ++ .../formcenter/repository/FormRepository.java | 9 + .../service/AppRegistryService.java | 184 +++++++++++++ .../formcenter/service/AppRegistryIT.java | 244 ++++++++++++++++++ 8 files changed, 665 insertions(+) create mode 100644 sql/t16_app_registry.sql create mode 100644 src/main/java/com/yunhai/formcenter/controller/api/AppRegistryController.java create mode 100644 src/main/java/com/yunhai/formcenter/entity/AppRegistry.java create mode 100644 src/main/java/com/yunhai/formcenter/model/AppModels.java create mode 100644 src/main/java/com/yunhai/formcenter/repository/AppRegistryRepository.java create mode 100644 src/main/java/com/yunhai/formcenter/service/AppRegistryService.java create mode 100644 src/test/java/com/yunhai/formcenter/service/AppRegistryIT.java diff --git a/sql/t16_app_registry.sql b/sql/t16_app_registry.sql new file mode 100644 index 0000000..b7d05bc --- /dev/null +++ b/sql/t16_app_registry.sql @@ -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 对注册表是逻辑引用(§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 是全库唯一且不含 is_deleted(§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 的机械判据) diff --git a/src/main/java/com/yunhai/formcenter/controller/api/AppRegistryController.java b/src/main/java/com/yunhai/formcenter/controller/api/AppRegistryController.java new file mode 100644 index 0000000..b4b54a3 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/api/AppRegistryController.java @@ -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)。 + * + *
+ * 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}      → 软删(在用禁删)
+ * 
+ * + *

守卫与管理员两表同一把:{@link FormAdminGuard#requirePlatformAdmin}(校管理员 ∪ 超管,C4③)—— + * 模块行退役后"应用设置页进不进得来"就是这一个判据。寻址一律按 {@code appCode}(库内 id 不外露,V5), + * 因此改名以外的写操作没有"按 id 改 code"的面。

+ * + *

返回形态沿两表那组端点:写操作直接回操作后的单行,前端自己更新那一行即可—— + * 应用数量是个位数,不需要为了省一次刷新而把全量列表塞进每个写响应。

+ */ +@RestController +@RequestMapping("/api/form/admin/apps") +@RequiredArgsConstructor +public class AppRegistryController { + + private final FormAdminGuard adminGuard; + private final AppRegistryService appService; + + /** 清单(超管可用 {@code schId} 显式换域看某校,与两表端点同一条代查口径) */ + @GetMapping + public List 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); + } +} diff --git a/src/main/java/com/yunhai/formcenter/entity/AppRegistry.java b/src/main/java/com/yunhai/formcenter/entity/AppRegistry.java new file mode 100644 index 0000000..49621c3 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/AppRegistry.java @@ -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)。 + * + *

四个业务列,零角色列(D14/V59 红线):这张表只回答"有哪些应用、叫什么名字、谁建的、删没删"。 + * "谁能进哪个应用"一律不在这张表里——它由 {@code DataScopeService} 按会话在该应用的表单上现算 ALL/SELF 档。 + * 一旦这里出现 {@code role}/{@code admin}/{@code user_id} 之类的列,就等于把 v2 出清掉的授权层 + * 从后门搬回来({@code AppRegistryIT} 用反射把这条钉成机械断言)。

+ * + *

{@code app_code} 全库唯一({@code uk_app_code},不含 is_deleted):软删后 code 仍被占用, + * 不复用(P125)。它是 oapi 游标拉取的域段与 MQ routing key 的组成段,因此 {@code updatable=false}—— + * 改名走 {@code name},code 一旦发布就等于对外契约的一部分(UserBase 那边贴的是含它的入口链接)。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/formcenter/model/AppModels.java b/src/main/java/com/yunhai/formcenter/model/AppModels.java new file mode 100644 index 0000000..da191eb --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/AppModels.java @@ -0,0 +1,44 @@ +package com.yunhai.formcenter.model; + +import lombok.Getter; +import lombok.Setter; + +/** + * 快应用注册表的出入参(契约 v2 C17/§5.2,T16)。 + * + *

对外身份只有 {@code appCode}(人读 slug,也是入口链接与 MQ routing key 的组成段), + * 库内 {@code id} 一律不外露(契约 §1/V5);因此 PUT/DELETE 都按 code 寻址而不是按 id。

+ */ +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; + } +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AppRegistryRepository.java b/src/main/java/com/yunhai/formcenter/repository/AppRegistryRepository.java new file mode 100644 index 0000000..7a811d8 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/AppRegistryRepository.java @@ -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 { + + /** 按 code 定位(含软删行;{@code uk_app_code} 全库唯一,至多一行) */ + Optional 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 findLiveInDomain(@Param("schIds") Collection schIds); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java index a87080a..be36b0e 100644 --- a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java @@ -77,4 +77,13 @@ public interface FormRepository extends JpaRepository { /** 按 uuid 批量取头表(T14 守卫:由答卷反查所属表单以判 ALL 档;uk_form_uuid 唯一) */ @Query("select f from Form f where f.uuid in (:uuids)") List
findByUuidIn(@Param("uuids") Collection uuids); + + /** + * 某个 app_code 名下还有多少张未软删表单(T16 删除保护的"在用"判据,§5.2)。 + * + *

不带 sch_id 条件是有意的:{@code uk_app_code} 是全库唯一,code 被删掉就等于对所有人 + * 都不可用,那么"他校还在用这个 code"同样构成在用——按窄的一边判(fail-closed,同 P117 的取向)。

+ */ + @Query("select count(f) from Form f where f.appCode = :appCode and f.isDeleted = 0") + long countLiveByAppCode(@Param("appCode") String appCode); } diff --git a/src/main/java/com/yunhai/formcenter/service/AppRegistryService.java b/src/main/java/com/yunhai/formcenter/service/AppRegistryService.java new file mode 100644 index 0000000..7e095a9 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AppRegistryService.java @@ -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)。 + * + *

这张表不答的问题比它答的更重要:它不管"谁能用哪个应用"(零角色列,V59 红线)—— + * 应用里每张表单的可见范围都由 {@link DataScopeService} 按会话现算 ALL/SELF 档。注册只是 + * "给一个人读 slug 上户口",好让入口链接、oapi 游标域段与 MQ routing key 有个全局唯一的锚点。

+ * + *

四条写侧规则:

+ *
    + *
  1. code 只进不改:格式按 C10(小写 slug,2~64 位,不许连字符开头/结尾),保留字 + * {@code form-center} 拒绝;{@code uk_app_code} 全库唯一且不含 is_deleted, + * 所以删过的 code 也不复用——撞键前先在库里点名,给可诊断文案而不是库内 500(P125);
  2. + *
  3. 域=会话域:校管理员只能建/改/删本校行,平台级行({@code sch_id=0})只有超管显式 + * {@code schId=0} 时才动得了;他校行按"app 不存在"回答(与数据面他校同一条防探测口径);
  4. + *
  5. 在用禁删:名下存在未软删表单(不分草稿/发布/停用)即 CUSTOM(5),msg 指点先迁先删; + * 判据按 code 全库统计而不是按校,因为 code 本身是全库唯一的(窄的一边,同 P117 的取向);
  6. + *
  7. 删除=软删:实体 save 改标记(全仓软删都保持实体写,bulk UPDATE 会绕过审计监听器)。
  8. + *
+ */ +@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 list(Long domain) { + List 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 定位本域内的在用行。 + * + *

清单会显示平台级行,但改/删不动它({@code sch_id} 必须等于本次操作域);他校行与"不存在" + * 同一口径回答——注册表按 code 寻址,code 是半公开的串,"存在但你不是它的主人"这句话本身就是信息。

+ */ + 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 建应用)。 + * + *

生产形态 {@code /form-center/front/#/app/{appCode}/};本地后端没有 context-path,前端跑在 + * 根路径上({@code host.config.ts} 的"seg.length>=2 才有 ctx 段"是同一条推导),这时不套 + * {@code /front},否则复制出来的链接在 dev 里 404。

+ */ + private String entryUrl(String appCode) { + String prefix = contextPath == null || contextPath.trim().isEmpty() ? "" : contextPath.trim(); + return prefix + (prefix.isEmpty() ? "/" : "/front") + "#/app/" + appCode + "/"; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/AppRegistryIT.java b/src/test/java/com/yunhai/formcenter/service/AppRegistryIT.java new file mode 100644 index 0000000..55f8333 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AppRegistryIT.java @@ -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,验收 V59/V65/V69)。 + * + *

三条红线各有一把机械断言:

+ *
    + *
  • V59 零角色字段——反射点名持久字段只允许四个业务列,任何 {@code role/perm/admin/userId} + * 形态的列一旦出现就 FAIL。这条必须写成反射而不是注释,否则"顺手加一列授权"没人拦得住;
  • + *
  • V65 全局唯一 + 保留字——{@code form-center} 与不合 C10 的串都拒,重复注册给可诊断文案;
  • + *
  • V69 在用禁删 + 端点族全通——建/列/改/删四件事按会话域判,他校按"app 不存在"回答。
  • + *
+ * + *

打真库、{@code @Transactional} 回滚(沿 {@code AnswerTestSupport} 先例),入口走控制器而不是 + * 只调服务:守卫在控制器上,只测服务会把 C17 的"守卫=校管理员∪超管"整条漏掉。

+ */ +@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 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 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 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; + } +}