diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fb1f62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Maven +target/ +!.mvn/wrapper/maven-wrapper.jar +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.properties + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.project +.classpath + +# OS +.DS_Store + +# Logs +*.log diff --git a/docs/T1-report.md b/docs/T1-report.md new file mode 100644 index 0000000..3687633 --- /dev/null +++ b/docs/T1-report.md @@ -0,0 +1,113 @@ +# T1 执行说明 · 表单中心工程骨架 + +> 任务书:《表单中心任务书-Qoder-20261014.md》v1.1 §T1|执行日期:2026-10-14|执行方:Qoder +> 前置事实:H1(projman 建项目)已完成——两仓已由 projman 注入 CI 与(后端)Dockerfile。 + +--- + +## 1. 做了什么 + +### 1.1 仓库与目录 + +| 仓 | 本地路径 | 远端 | +| --- | --- | --- | +| 后端 | `D:\SoftProject\yunhai\form-center\service` | `form-center/form-center-service.git` | +| 前端 | `D:\SoftProject\yunhai\form-center\front` | `form-center/form-center-front.git` | + +两仓均为 **clone 既有远端仓**(非本地 init),projman 注入的 `.gitea/workflows/build.yml`、 +`docker/Dockerfile`、`README.md` 天然在工作树与历史中,未被覆盖、未被删除。 + +### 1.2 后端(fork flow-center/service 骨架) + +- **包结构** `com.yunhai.formcenter`: + - `config/FormCorsConfiguration`(平移 `FlowCorsConfiguration`,键名换 `form-center.cors.allowed-origins`) + - `controller/api/FormBaseController`(见下) + - `controller/oapi`(T4 落契约 §9 两个读端点,本任务先声明包) + - `entity/BaseEntity`(自增主键 + 审计四件套;`id` 绝不外露,对外身份是 uuid/code——契约 §1) + - `common/FormCenterAuditListener`、`enums`、`model`、`repository`、`service` + - 空包以 `package-info.java` 声明(javadoc 里写明该包 T2/T4 装什么、受哪条契约约束) +- **pom**:照 flow-center 模板(Boot 2.7.18 / JDK8 / `yh-platform-dependencies` BOM / + `yh-spring-boot-starter` 聚合 / `yh-appearance-spring-boot-starter` / data-redis / cas-client-core 3.5.1 / + mysql-connector-j / lombok / starter-test);**剥 aviator**;**加 `com.alibaba:easyexcel:3.3.2`**; + `finalName=form-center`;surefire 收 `*Test.java` + `*IT.java`(CI 不跑单测→本地 `mvn test` 是门禁)。 +- **FormBaseController**:`GET /api/base/GetUserInfo`(会话用户)、`GET /api/base/GetUserMod`(本应用授权模块树, + 经 SDK `YschBaseOpenApi.getUserModule`,只做字段改名不组树,与其他子应用同口径), + **新增可选 `appCode` 参数**:等于本应用/缺省→本应用树;指向其它应用→**空树 + warn 日志**(H4 未上线,见问题单 P3)。 +- **application.yml 分层**(jar 内只留本地最小默认,生产值全部由 projman env 注入并被 env 压过): + - `server.port: 8086` 仅本地;不写 context-path(生产 `CONTEXT_PATH=/form-center`) + - datasource 指向本地 `bsch_formcenter`;`open-in-view: false`、`MySQL5InnoDBDialect`、时区 Asia/Shanghai + - **上传路径教训注释原样抄入**(notice/service 的「Linux 容器把 `D:/data/upload` 锚定成 + `/app/D:/data/upload` 写进可写层,每次部署附件全丢」)+ 本地兜底键 `file.upload-path: D:/data/upload` + **未删**,并注明 T3 的读取形态 `${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}` + - `yh.sso`/`yh.app-auth`(本地占位身份 100021 + 带引号 secret + `/oapi/*` 强制验签 + trust-registry)/ + `yh.open`(base-host 本地 UserBase;**明确禁止把 `services.userbase` 写进 jar**,沿用 2026-09-18 事故纪律) + - `management.health.redis.enabled: false`(Redis 抖动不得把 `/actuator/health` 拉 DOWN) + +### 1.3 前端(仅工程壳) + +`vite ^5` + `vue ^3.4` + TypeScript 最小壳:`index.html` / `src/main.ts` / `src/App.vue`(占位页)/ +`src/env.d.ts` / `vite.config.ts`(`base: "./"`、dev 端口 8089)/ `tsconfig{,.node}.json` / +`.npmrc`(npmmirror)/ `.gitignore` / **`package-lock.json`**(CI 用 `npm ci`,必须有锁文件)。 +脚本:`build = vite build`(CI 执行项)、`type-check = vue-tsc --noEmit`。 +A 类 loader / view-ui-plus 1.3.24 / alova / router / layout 属 **T5**,本任务刻意未接。 + +### 1.4 环境动作(本机副作用,供知悉) + +- 本机 MySQL 8.0.45(`D:\BaiduNetdiskDownload\mysql-8.0.45-winx64`)此前未运行,为完成「本地能启动」门禁 + **已拉起**(`--defaults-file=my.ini`,端口 3306),并 **新建库 `bsch_formcenter`**(utf8mb4 / utf8mb4_general_ci)。 + 两者均为本地开发环境常规动作;服务验证完未关,如需释放内存自行停止即可。 +- 未启动 CAS(8443)/UserBase(8060)/Redis,未触碰 160 共享环境(只做过一次只读可达性探测)。 + +--- + +## 2. 自验证据 + +| # | 门禁 | 命令 | 结果 | +| --- | --- | --- | --- | +| 1 | 后端编译+打包+单测 | `mvn -B -o clean package`(离线,本地仓库 `D:\SoftProject\yunhai\.m2`) | **BUILD SUCCESS**,`target/form-center.jar`(finalName 生效);`Tests run: 7, Failures: 0, Errors: 0` | +| 2 | 本地启动 | `java -jar target/form-center.jar --server.port=8086` | `Started FormCenterApplication in 9.472 seconds`;HikariPool-1 Start completed;`Initialized JPA EntityManagerFactory` | +| 3 | 健康探活 | `curl --noproxy '*' http://localhost:8086/actuator/health` | **HTTP 200** `{"status":"UP"}` | +| 4 | 鉴权墙(用户面) | `curl http://localhost:8086/api/base/GetUserInfo` | HTTP 302 → `http://localhost:8443/login?service=...%2Fapi%2Fbase%2FGetUserInfo`(`yh.sso.url-pattern=/api/*` 生效,`/api/appearance` 同墙) | +| 5 | 验签墙(对外面) | `curl http://localhost:8086/oapi/form/schema` | HTTP **401**(`yh.app-auth.server.enforce-patterns: [/oapi/*]` 生效) | +| 6 | 前端构建 | `npm run build` | **✓ built**,`dist/index.html` + `dist/assets/*`(zip 根含 index.html,满足 projman 前端发布要求) | +| 7 | 前端类型检查 | `npm run type-check`(vue-tsc --noEmit) | 无报错 | +| 8 | CI | 见 §4(推送后回填) | — | + +单测覆盖(`FormBaseControllerTest`,7 条,全部脱网/脱库可跑): +`toFrontendTree` 保层级与字段改名 / `modId` 取数字 custom_id / `children` 恒为数组 / 非数字 customId +(`fc:scope:all` 权限锚点,path 为空)回退主键且不炸 / null 与空入参不抛 NPE / +`isForeignTarget` 判定(null·空白·本应用·大小写·其它 appCode)/ +**target appCode 命中时返回空树且不触碰 SDK**(单测里 SDK 静态客户端未装配,误走本应用分支会直接抛异常)。 + +未达成项:**「GetUserInfo 返回会话用户」的登录态冒烟**——本地 CAS(8443) 与 UserBase(8060) 未启动, +且 GetUserMod 出树还依赖 H2(UserBase 的 form-center 应用行 + 模块行,用户侧待办)。 +按任务书 §0.1 规则①记缺口继续,取证改由上表 #3/#4/#5 + 单测承担 → 问题单 P1。 + +--- + +## 3. 问题单(本轮登记,供执行手册 §5 裁决) + +| # | 问题 | 本轮取值(最保守解读) | 影响面 | +| --- | --- | --- | --- | +| P1 | 任务书 T1 自验含「GetUserInfo 返回会话用户」,本地无 CAS/UserBase 实例,且 H2 应用行未建 | 不停顿;以 health 200 + `/api/*` 302 CAS 墙 + `/oapi/*` 401 墙 + 7 条单测替代取证。一键脚本已核实存在:`D:\c\脚本执行\cas\start-server.bat`、`D:\c\脚本执行\userbase\start-jar.bat`(jar 已构建),需要登录态时在 T2/T5 联调轮补跑 | 仅取证强度,不改设计 | +| P2 | target 参数名口径不一:执行手册 §1 写「可选 target **appKey**」,任务书 T1 与 H4 任务书写「**appCode**」 | 取 **appCode**(与 H4 的 UserBase `/oapi/user/modules` 参数、契约 §1 归属列 `app_code` 一致) | 参数名,前端 T8 需按此对齐 | +| P3 | SDK 无目标应用形态:`javap` 实测 `YschBaseOpenApi` 只有 `getUserModule(int userId, Integer schId)` | H4 未上线前,`appCode` 指向其它应用一律**空树 + warn**,不回落本应用树(回落会把「要 A 的菜单」伪装成「A 就这些菜单」);H4 上线后消费范式=`YschOpenCall.raw()` 直调(SDK 不动) | 快应用上下文 T8/T9 前置 | +| P4 | `appCode` 只加在 GetUserMod:GetUserInfo 无「树」语义,任务书把两端的参数写在一起 | 只 GetUserMod 加参数(T8 原文「模块树经 FormBaseController 带 target appCode」与此一致) | 无 | +| P5 | `entity/BaseEntity` 审计四件套含 `create_by/update_by`,契约 §10 DDL 草案九表只列 `create_time/update_time` | 保留基类形态(任务书 §0.4「实体继承 BaseEntity」+ 模板平移纪律);`ddl-auto=update` 会在自动建表时多两列 | 若 DDL 草案为唯一权威,T2 前需裁决:补列 or 精简基类 | +| P6 | 端口/身份号未在文档中为 form-center 定死(生产容器端口只从注入的 Dockerfile `EXPOSE 10020` 可见) | 本地 dev:service **8086**、front **8089**(顺延 notice 8101/8077、svc-hall 8087、flow-center 8085/8088);yml 不写生产端口,由 `SERVER_PORT`/`CONTEXT_PATH` env 注入 | 仅本地联调约定 | +| P7 | 本地占位 appKey 号段:100018=svc-hall、100019=flow-center、100020=work-leave(实测各仓 yml) | 取 **100021** + `app-secret: "form-center-dev-secret-100021"`(带引号防八进制解析);真实身份需 UserBase 注册后由 projman 注入,仓库内不落真凭据 | 上线前替换项 | +| P8 | `trusted-apps` 本地白名单暂无内容可列 | 先列 svc-hall/flow-center 两号(与 flow-center 本地兜底同口径);本应用 `/oapi` 的真实消费方在 T4 出现时补 | T4 | +| P9 | maven-compiler-plugin 3.10.1 对「只含 package-info.java 的包」打印 `[WARNING] Error creating missing package info classes`(NoSuchFileException,非致命,BUILD SUCCESS) | 不为此升插件版本(CI 用 `maven:3.6.3-jdk-8`,插件升级有依赖解析风险);T2 起这些包落入真实类即消失 | 观感/日志噪声 | +| P10 | 本地 MySQL 是 8.0.45,方案 §7 要求 **MySQL 5.7 兼容 SQL** | 用 `MySQL5InnoDBDialect` + utf8mb4;本地 8.0 跑通**不能**证明 5.7 兼容,T2 起 DDL/查询需按 5.7 复核(160 生产版本仍待核,见手册 §4.3) | T2~T4 | +| P11 | 前端壳未接平台约定(A 类 loader / view-ui-plus 1.3.24 / alova / router / layout) | 属 T5 范围,T1 只保 `npm ci + npm run build` 绿;`base: "./"` 已按 notice/flow-center 口径设好,保证 `/form-center/` 挂载可取静态资源 | T5 | +| P12 | 上传相关 yml 只落键、未落 `spring.servlet.multipart` 上限 | 属 T3 上传模块范围;**记档**:Spring 默认 `max-file-size=1MB` 会挡住契约 §3 的 ≤50MB 附件,T3 必须显式配 | T3 | +| P13 | 首推纪律写的是 `git merge origin/main --allow-unrelated-histories`(适用于本地 init 后合入注入文件) | 实际采用 clone,注入提交已在历史里,merge 为 `Already up to date` 空操作;**未用** `git reset` / `git add -A`,暂存按显式路径 | 无 | + +--- + +## 4. 推送与 CI(回填位) + +| 仓 | 提交 | 推送 | CI run | 结论 | +| --- | --- | --- | --- | --- | +| form-center-service | 待回填 | 待回填 | 待回填 | 待回填 | +| form-center-front | 待回填 | 待回填 | 待回填 | 待回填 | diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d853160 --- /dev/null +++ b/pom.xml @@ -0,0 +1,132 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + form-center + 0.1 + form-center + 表单中心(动态表单:定义与版本/答卷与检索/选项字典/数据范围/导出;对外契约=《表单中心契约-20261014.md》) + + 1.8 + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + com.yunhai.platform + yh-appearance-spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + com.mysql + mysql-connector-j + runtime + + + + + com.alibaba + easyexcel + 3.3.2 + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + form-center + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.formcenter.FormCenterApplication + + + + + repackage + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Test.java + **/*IT.java + + + + + + diff --git a/src/main/java/com/yunhai/formcenter/FormCenterApplication.java b/src/main/java/com/yunhai/formcenter/FormCenterApplication.java new file mode 100644 index 0000000..6d0befb --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/FormCenterApplication.java @@ -0,0 +1,17 @@ +package com.yunhai.formcenter; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 表单中心(app_code=form-center):动态表单的定义与版本、答卷与检索、选项字典、数据范围与导出。 + * 设计权威=《表单中心建设方案-20261013.md》v1.4,对外承诺=《表单中心契约-20261014.md》v1.0。 + * 一期边界(方案 §2.3):不做暂存草稿/联动跳题/计算字段/分步向导/专门打印,无 oapi 代提交。 + */ +@SpringBootApplication +public class FormCenterApplication { + + public static void main(String[] args) { + SpringApplication.run(FormCenterApplication.class, args); + } +} diff --git a/src/main/java/com/yunhai/formcenter/common/FormCenterAuditListener.java b/src/main/java/com/yunhai/formcenter/common/FormCenterAuditListener.java new file mode 100644 index 0000000..8c99a7a --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/common/FormCenterAuditListener.java @@ -0,0 +1,41 @@ +package com.yunhai.formcenter.common; + +import com.yunhai.formcenter.entity.BaseEntity; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * 审计字段自动填充(flow-center/Cms/svc-hall 同款)。 + * 账号取 ThreadLocal 中的 CAS 会话用户;定时任务/无会话场景为 null。 + */ +@Slf4j +@Component +public class FormCenterAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + String account = currentAccount(); + LocalDateTime now = LocalDateTime.now(); + entity.setCreateBy(account); + entity.setCreateTime(now); + entity.setUpdateBy(account); + entity.setUpdateTime(now); + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateBy(currentAccount()); + entity.setUpdateTime(LocalDateTime.now()); + } + + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } +} diff --git a/src/main/java/com/yunhai/formcenter/config/FormCorsConfiguration.java b/src/main/java/com/yunhai/formcenter/config/FormCorsConfiguration.java new file mode 100644 index 0000000..51e5616 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/config/FormCorsConfiguration.java @@ -0,0 +1,45 @@ +package com.yunhai.formcenter.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.Arrays; +import java.util.Collections; + +/** + * 本地开发跨端口 CORS(同 flow-center 拍板项#2 口径):本应用 front dev(8089) 直调 /api/* 属跨端口请求, + * 需带凭据的 CORS 头(CAS cookie 不隔离端口,免二次登录成立的前提)。 + * 同主域生产无跨端口问题,配置 allowed-origins 置空即整体关闭。 + */ +@Configuration +public class FormCorsConfiguration { + + private final String allowedOrigins; + + public FormCorsConfiguration(@Value("${form-center.cors.allowed-origins:}") String allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + @Bean + public FilterRegistrationBean formCorsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + if (allowedOrigins != null && !allowedOrigins.trim().isEmpty()) { + config.setAllowedOrigins(Arrays.asList(allowedOrigins.split("\\s*,\\s*"))); + config.setAllowedMethods(Collections.singletonList("*")); + config.setAllowedHeaders(Collections.singletonList("*")); + config.setAllowCredentials(true); + // 只对用户面生效;/oapi/* 是应用签名调用,不经浏览器 + source.registerCorsConfiguration("/api/**", config); + } + FilterRegistrationBean bean = new FilterRegistrationBean<>(new CorsFilter(source)); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return bean; + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java new file mode 100644 index 0000000..5f8d25e --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java @@ -0,0 +1,123 @@ +package com.yunhai.formcenter.controller.api; + +import com.yunhai.platform.openapi.base.YschBaseOpenApi; +import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 前端壳基础端点(对齐 UserBase BaseController 的 /api/base/* 形态):GetUserInfo / GetUserMod。 + *

GetUserMod 与其他子应用(Notice / svc-hall / 事项库 Cms / flow-center)统一走平台下发:经 SDK + * {@code YschBaseOpenApi.getUserModule} 取当前用户在本应用({@code yh.app-auth.app-key})下的 + * 已授权模块树,层级由平台契约 {@code BoaModule.children} 承载,本侧仅映射为前端 layout + * 期望的字段形状({@code page/modId/children})。 + *

target appCode(快应用上下文,方案 §6.2 / 任务书 T8):{@code /app/{appCode}/} 需要的是 + * 那个快应用的模块树,而不是表单中心自己的。它依赖 UserBase {@code /oapi/user/modules} 的 + * 可选 appCode 参数(跨项目触点 H4),SDK 侧目前只有 + * {@code getUserModule(int userId, Integer schId)} 一个形态、无 appCode 入参。 + * H4 上线前本端点对该请求返回空树并打 warn——不静默返回本应用菜单, + * 那会把「请求 A 应用的树」伪装成「A 应用就是这些菜单」。 + */ +@Slf4j +@RestController +public class FormBaseController { + + /** 本应用 app_code(手册 §1 定死:app_code = name = ctx = 目录 key = jump_app)。 */ + private static final String APP_CODE = "form-center"; + + /** + * appCode 是否指向「别的应用」(= 需要 H4 的目标应用模块树)。缺省/空白/等于本应用都算本应用。 + * 包级可见:供同包单测锁定判定,不依赖 SDK 静态客户端。 + */ + static boolean isForeignTarget(String appCode) { + String target = appCode == null ? "" : appCode.trim(); + return !target.isEmpty() && !APP_CODE.equalsIgnoreCase(target); + } + + @GetMapping("/api/base/GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + /** + * 当前用户已授权模块树(左侧菜单)。 + *

依赖 UserBase 侧数据:本应用的 {@code app_module} 行({@code path} 须逐字对上 + * front 路由,见 T5 的 router/routes.ts)+ 角色/组授权,二者缺一即返回空菜单。 + * 这里不保留本地 MENUS 兜底——静默回退会把「平台未配模块/未授权」伪装成正常, + * 与其他子应用口径一致,配置缺失应表现为空菜单并被发现。 + * + * @param appCode 可选,目标应用的 app_code。缺省或等于 {@code form-center} 时返回本应用的树; + * 指向其它应用时走 H4(UserBase 侧尚未上线)——返回空树并打 warn。 + */ + @GetMapping("/api/base/GetUserMod") + public List> getUserMod(BoaUser authUserWithDept, + @RequestParam(required = false) String appCode) { + if (isForeignTarget(appCode)) { + log.warn("[GetUserMod] 请求目标应用模块树 appCode={},UserBase /oapi/user/modules 的 " + + "appCode 参数(H4)未上线,SDK 无对应入参,按缺口返回空树", appCode); + return Collections.emptyList(); + } + if (authUserWithDept == null) { + return Collections.emptyList(); + } + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + List modules = YschBaseOpenApi.getUserModule(authUserWithDept.getId(), schId); + if (modules == null || modules.isEmpty()) { + return Collections.emptyList(); + } + // 用户中心已按 BoaModule.children 下发模块树:本地不再重组层级,仅做前端字段改名映射 + return toFrontendTree(modules); + } + + /** + * BoaModule 树 → 前端 layout 期望的字段形状(递归,只改名不组树;Notice/svc-hall/Cms 同款口径)。
+ * 层级与兄弟顺序均由用户中心决定(customId 升序),本侧不重排;叶子 children 为空列表。
+ * 包级可见而非 private:供同包单测直接覆盖映射,避免为测试改写 {@code YschBaseOpenApi} 全局静态客户端。 + */ + static List> toFrontendTree(List modules) { + List> result = new ArrayList<>(); + if (modules == null || modules.isEmpty()) { + return result; + } + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", resolveModId(m)); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", toFrontendTree(m.getChildren())); + result.add(node); + } + return result; + } + + /** + * modId 取值:优先 {@code customId}(数值),以对上 front {@code router/routes.ts} 的 + * {@code meta.modId} 约定;customId 非数字或缺失时回退主键 id。 + * 注意:不能直接取主键 id——那会让前端 modId 兜底匹配永久失配(path 匹配仍可用)。 + * 表单中心的 {@code fc:scope:all}(数据范围权限锚点,契约 §8)是非数字 customId, + * 且 path 为空、不进导航,故只由后端扫描授权集合消费,不依赖本映射结果。 + */ + private static Object resolveModId(BoaModule m) { + String customId = m.getCustomId(); + if (customId != null && customId.trim().matches("\\d+")) { + return Integer.valueOf(customId.trim()); + } + return m.getId(); + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/package-info.java b/src/main/java/com/yunhai/formcenter/controller/oapi/package-info.java new file mode 100644 index 0000000..26163a6 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/package-info.java @@ -0,0 +1,6 @@ +/** + * 对外签名面(/oapi/**,appKey 三件套验签,见 application.yml 的 yh.app-auth.server.enforce-patterns)。 + * T4 落地契约 §9 的两个读端点:{@code GET /oapi/form/schema}(内联 options + valueSchema)与 + * {@code GET /oapi/form/answer}。一期无 oapi 代提交(方案 §2.3 边界,答卷身份一律会话推导)。 + */ +package com.yunhai.formcenter.controller.oapi; diff --git a/src/main/java/com/yunhai/formcenter/entity/BaseEntity.java b/src/main/java/com/yunhai/formcenter/entity/BaseEntity.java new file mode 100644 index 0000000..e4ca810 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/BaseEntity.java @@ -0,0 +1,45 @@ +package com.yunhai.formcenter.entity; + +import com.yunhai.formcenter.common.FormCenterAuditListener; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.EntityListeners; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.MappedSuperclass; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 所有实体的公共基类:自增主键 + 审计四件套(FormCenterAuditListener 自动填充)。 + * 契约 §1:{@code id} 只做库内定位、**绝不外露**——对外身份是 uuid(数据面)与 code(配置期/入口寻址)。 + * 多租户键 sch_id 不在基类——各业务实体自带列(Notice/flow-center 先例,语义不同不混入); + * 无物理外键、MySQL 5.7 兼容(方案 §7 工程约束)。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(FormCenterAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "create_by", updatable = false) + private String createBy; + + @Column(name = "create_time", updatable = false) + private LocalDateTime createTime; + + @Column(name = "update_by") + private String updateBy; + + @Column(name = "update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/formcenter/enums/package-info.java b/src/main/java/com/yunhai/formcenter/enums/package-info.java new file mode 100644 index 0000000..bb7eb70 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/enums/package-info.java @@ -0,0 +1,5 @@ +/** + * 表单中心枚举域(T2 起填充):表单生命周期状态、面向人群 audience、限次范围 limitScope、 + * 控件类型白名单(契约 §2:14 类冻结 / 一期放行 11 类)、答卷状态与操作类型。 + */ +package com.yunhai.formcenter.enums; diff --git a/src/main/java/com/yunhai/formcenter/model/package-info.java b/src/main/java/com/yunhai/formcenter/model/package-info.java new file mode 100644 index 0000000..6e7f447 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/package-info.java @@ -0,0 +1,5 @@ +/** + * 表单中心出参/入参模型域(T2 起填充):schema 文档与字段模型、值文档、 + * 列表两段式出参、统计出参、契约 §4 的机读 valueSchema 片段。 + */ +package com.yunhai.formcenter.model; diff --git a/src/main/java/com/yunhai/formcenter/repository/package-info.java b/src/main/java/com/yunhai/formcenter/repository/package-info.java new file mode 100644 index 0000000..22f5e89 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/package-info.java @@ -0,0 +1,6 @@ +/** + * 表单中心仓储域(T2 起填充):form / form_version / answer / answer_index / + * status_log / answer_op_log / fill_quota / form_dict / form_dict_item(契约 §10 九表)。 + * 检索铁律(方案 §5.5):两段式列表禁 SELECT values、keyset 仅按提交时间序、覆盖索引化。 + */ +package com.yunhai.formcenter.repository; diff --git a/src/main/java/com/yunhai/formcenter/service/package-info.java b/src/main/java/com/yunhai/formcenter/service/package-info.java new file mode 100644 index 0000000..c808b26 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/package-info.java @@ -0,0 +1,6 @@ +/** + * 表单中心服务域(T2 起填充):定义域(发布/升版/复制/字典/schema 校验器)、 + * 答卷域(提交幂等/限次/截止/软删留痕)、数据范围解析(契约 §8 矩阵 + fc:scope:all)、 + * 检索统计与导出、上传模块(FILE_PATH/FILE_WEB_PATH,值只存 {id,fileName,key})。 + */ +package com.yunhai.formcenter.service; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..a46e636 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,116 @@ +server: + port: 8086 + # 本地无 context-path(同 flow-center dev / Notice dev);生产经 CONTEXT_PATH=/form-center 注入, + # Caddy 只反代 /form-center/api/* 与 /form-center/oapi/*,页面走前端静态。 + # 端口/context-path 一律不写生产值:projman 用 SERVER_PORT、CONTEXT_PATH 环境变量注入(env 压过本文件)。 + +spring: + application: + name: form-center + datasource: + # 本地开发库;生产 DB 连接由 projman 平台供给(SPRING_DATASOURCE_* env 注入,本文件值被压过) + url: jdbc:mysql://localhost:3306/bsch_formcenter?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + minimum-idle: 5 + maximum-pool-size: 20 + jpa: + hibernate: + # 开发期 update 自动建表;生产环境建议 none + 升级脚本(sql/ 目录,见 T2 起) + ddl-auto: update + show-sql: false + open-in-view: false + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate: + jdbc: + time_zone: Asia/Shanghai + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + +# ===== 文件上传存储路径 ===== +# 教训注释自 notice/service application.yml 原样抄入(2026-10 事故): +# 仅供本地 Windows 开发兜底;生产读 FILE_PATH(平台统一,共享配置 application-platform.yml 一份 +# /data/upload,子应用不再各自维护)。 +# 切勿在生产只依赖本键:历史教训是 Linux 容器把 D:/data/upload +# 锚定成 /app/D:/data/upload 写进容器可写层,每次更新部署附件全部丢失(2026-10)。 +# 读取形态(T3 上传模块实现):${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}} +# —— FILE_PATH 由 projman 注入且持久卷固定挂 /data/upload,本键只在不注入 env 的本地生效,不可删。 +file: + upload-path: D:/data/upload + +# ===== 基础平台 yh 配置(flow-center/Notice/svc-hall 同款三件套)===== +yh: + auth: + enabled: true + sso: + # 本地全链测试:指向 local-debug 的 CAS(8443 根路径,RSA+AJAX 登录页);生产由平台注入 + # SSO_SERVER_URL_PREFIX / SSO_CLIENT_HOST_URL 覆盖(projman 按平台设置现算,本文件不要写生产值) + server-url-prefix: http://localhost:8443 + # 本地 dev 指向后端直连(脚本化登录 service=client-host-url+请求路径) + client-host-url: http://localhost:8086 + allow-headers: all + allow-origins: all + # 用户面与管理面全在 /api/* 下;本应用无匿名面。 + # 业务方调用走 /oapi/*(appKey 签名,RequireAppSignFilter 强制 401) + url-pattern: /api/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用)——UserBase 注册后分配(进白名单),占位值上线前必须替换, + # 生产由 projman 注入 YH_APP_AUTH_APP_KEY / APP_SECRET,禁止明文入库仓库。 + # 本地占位 appKey=100021(svc-hall=100018 → flow-center=100019 → work-leave=100020 顺延); + # 联调时需在 UserBase(及对端 trusted-apps)侧同步此占位对。 + # 注意:占位 secret 必须带引号且含字母——裸 32 个 0 会被 YAML 按八进制整数解析成 "0",SignUtil 校验直接 500 + app-key: 100021 + app-secret: "form-center-dev-secret-100021" + # 提供方强制验签(两级模型 §4.1):/oapi/** 无签名三件套即 401(starter 版 RequireAppSignFilter)。 + # T1 暂无 /oapi 端点,声明保留(T4 对外读接口落地即用,不出现「先上线后端点无门」的空窗) + server: + enforce-patterns: [/oapi/*] + # 中央密钥链(两级模型 §4.2):trusted-apps 先(本地确定性兜底),未命中走 SignKeyResolver + # (Redis sign:app:* → Caffeine → UserBase 兜底端点)——任何在 UserBase 注册过的 appKey 自动可验 + trust-registry: true + # 入站验签白名单:只列本地需要直调本应用 /oapi 的对端,联调时按实际补充(号段同各应用本地占位)。 + # 开 trust-registry 后此表仍保留为主兜底(Redis 停摆时白名单确定性工作) + trusted-apps: + "100018": "svc-hall-dev-secret-100018" + "100019": "flow-center-dev-secret-100019" + open: + # 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本不写; + # base-host 是「服务目录三级兜底」的输入(userbase 目标 = base-host + 枚举 ctx /userbase): + # 本地指向 local-debug 的 UserBase;生产由平台注入 YH_OPEN_BASE_HOST 覆盖(base.yunhaixingtong.com)。 + # ⚠️ 绝不能把 services.userbase=http://localhost:8060 之类的本地覆盖写进本文件(同 flow-center 纪律): + # yh.open.services.{key} 是目录第 2 级,平台注入的 base-host 只喂第 3 级兜底、压不过它 —— + # 写进 jar 会让部署实例去连容器内的 localhost:8060(2026-09-18 事故:Connection refused → + # 所有 /api/* 报 code=6 内部服务错误)。本地联调请运行时注入: + # YH_OPEN_SERVICES_USERBASE=http://localhost:8060 mvn -o spring-boot:run + base-host: http://localhost:8060 + base-path: "" + +# ===== 表单中心专属配置 ===== +form-center: + # 本地开发跨端口 CORS:本应用 front dev(8089) 直调 /api/* 需带凭据的 CORS 头 + # (CAS cookie 不隔离端口,免二次登录成立的前提);同主域生产置空即整体关闭 + cors: + allowed-origins: http://localhost:8089 + +logging: + level: + com.yunhai.formcenter: debug + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never + health: + redis: + # 引 data-redis 仅服务于 trust-registry 密钥链;关健康指标防 Redis 停摆拉 DOWN /actuator/health + # (组合链 trusted-apps 先行兜底,Redis 不可用时验签仍确定性工作) + enabled: false diff --git a/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java b/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java new file mode 100644 index 0000000..cac82f2 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java @@ -0,0 +1,113 @@ +package com.yunhai.formcenter.controller.api; + +import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * GetUserMod 映射与 target appCode 判定单测(本地 {@code mvn test} 即门禁,CI 不跑单测)。
+ * 映射部分锁定三个易错点:modId 取数字 custom_id、children 恒为数组(前端无判空递归)、 + * path 缺失归空串。appCode 部分锁定「指向别的应用时绝不打 SDK」—— + * 单测里 SDK 静态客户端未装配,误走本应用分支会直接抛异常而不是返回空树。 + */ +class FormBaseControllerTest { + + @Test + void toFrontendTree_shouldKeepServerHierarchyAndRenameFields() { + BoaModule parent = module(501, "100", "/admin/forms", 1); + BoaModule child = module(502, "101", "/admin/data", 2); + parent.setChildren(new ArrayList<>(Collections.singletonList(child))); + BoaModule otherRoot = module(503, "105", "/my", 1); + + List> tree = FormBaseController.toFrontendTree(Arrays.asList(parent, otherRoot)); + + assertEquals(2, tree.size(), "顶层只应有两个根,子节点不得浮到顶层"); + Map first = tree.get(0); + assertEquals("/admin/forms", first.get("page"), "path → page(前端按 page 匹配菜单高亮)"); + assertEquals(100, first.get("modId"), "modId 必须取数字 custom_id 而非主键 id"); + assertEquals(501, first.get("id"), "原始主键仍经 id 字段透出"); + + @SuppressWarnings("unchecked") + List> children = (List>) first.get("children"); + assertEquals(1, children.size(), "层级应原样保留"); + assertEquals(101, children.get(0).get("modId")); + } + + @Test + void toFrontendTree_shouldAlwaysEmitChildrenAsArray() { + BoaModule configuredLeaf = module(601, "102", "/admin/dicts", 1); + configuredLeaf.setChildren(new ArrayList<>()); + BoaModule notExpanded = module(602, "103", "/admin/stats", 1); // 上游未展开 → children 为 null + + List> tree = FormBaseController.toFrontendTree(Arrays.asList(configuredLeaf, notExpanded)); + + for (Map node : tree) { + assertNotNull(node.get("children"), "children 不得为 null,前端要无判空递归"); + assertTrue(((List) node.get("children")).isEmpty(), "无子节点时应为空列表"); + } + } + + @Test + void toFrontendTree_shouldFallBackToPrimaryKeyWhenCustomIdNotNumeric() { + // 数据范围权限锚点 fc:scope:all(契约 §8)就是非数字 customId:它 path 为空、不进导航, + // 只由后端扫描授权集合消费,这里只保证映射不炸、modId 退回主键。 + BoaModule scopeAnchor = module(701, "fc:scope:all", null, 1); + + List> tree = FormBaseController.toFrontendTree(Collections.singletonList(scopeAnchor)); + + assertEquals(701, tree.get(0).get("modId"), "custom_id 非数字时回退模块主键 id"); + assertEquals("", tree.get(0).get("page"), "path 缺失应给空串而非 null(前端字符串比较不判空)"); + } + + @Test + void toFrontendTree_shouldTolerateEmptyAndNullInput() { + assertTrue(FormBaseController.toFrontendTree(Collections.emptyList()).isEmpty()); + assertTrue(FormBaseController.toFrontendTree(null).isEmpty(), "上游返回 null 不得抛 NPE"); + } + + @Test + void isForeignTarget_shouldOnlyTreatOtherAppCodesAsForeign() { + assertFalse(FormBaseController.isForeignTarget(null), "缺省=本应用"); + assertFalse(FormBaseController.isForeignTarget(" "), "空白=缺省"); + assertFalse(FormBaseController.isForeignTarget("form-center"), "显式等于本应用仍走本应用分支"); + assertFalse(FormBaseController.isForeignTarget(" Form-Center "), "比对忽略大小写与首尾空白"); + assertTrue(FormBaseController.isForeignTarget("honor"), "快应用 app_code 才是目标应用分支"); + } + + @Test + void getUserMod_shouldReturnEmptyTreeForForeignTargetWithoutTouchingSdk() { + BoaUser user = new BoaUser(); + user.setId(10133); + + List> tree = new FormBaseController().getUserMod(user, "honor"); + + assertTrue(tree.isEmpty(), "H4(UserBase appCode 参数)未上线,目标应用一律空树"); + } + + @Test + void getUserMod_shouldReturnEmptyTreeWithoutSessionUser() { + assertTrue(new FormBaseController().getUserMod(null, null).isEmpty()); + } + + private static BoaModule module(Integer id, String customId, String path, Integer level) { + BoaModule m = new BoaModule(); + m.setId(id); + m.setCustomId(customId); + m.setName("模块" + customId); + m.setPath(path); + m.setLevel(level); + m.setSortNo(level * 10); + return m; + } +}