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,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 + "/";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user