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:
chenxing
2026-10-20 14:30:10 +08:00
parent 3dac43dcfc
commit c41ffebb95
8 changed files with 665 additions and 0 deletions
@@ -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&gt;=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 + "/";
}
}