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/README.md b/README.md index bf94e23..288f516 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,48 @@ -# ysch-question-service +# question-center 问卷后端(ysch-question-service) -managed by projman \ No newline at end of file +问卷应用 R1「先行跑通」后端:Spring Boot 2.7.18,包名 `com.yunhai.question`,端口 8090,库 `ysch_question`。链路:**新建问卷(自动挂接 FC 表单)→ iframe 内用 FC 设计器编辑 → 发布投放(链接+二维码)→ FC 填写页提交 → 投影拉取 → 回收明细与基础统计**。表单定义/版本/校验/答卷权威全在 form-center(FC),本服务零改动 FC;提交入口永远在 FC 填写页 `#/f/{code}`(不建第二提交入口)。 + +配套前端:`ysch-question-front`(Vue3+TS+Vite5,端口 8091)。 + +``` +├── sql/question_r1.sql 建表脚本(questionnaire / pull_cursor / answer_projection) +└── src/ 主代码 + 无外部依赖单测(H2+mock,23 例:投影幂等/游标推进/人数口径/绑定 uk) +``` + +## 前置依赖(联调) + +| 依赖 | 地址 | 说明 | +|---|---|---| +| form-center 后端/前端 | localhost:8086 / 8089 | /oapi 三端点 + 设计器 iframe / 填写页 `#/f/{code}` | +| MySQL | localhost:3306 | 建库 `ysch_question`(执行 `sql/question_r1.sql`;开发期 ddl-auto=update 可自动建表) | +| Redis | localhost:6379 | 平台 starter 密钥链(trust-registry) | +| CAS/UserBase | localhost:8443 / 8060 | 登录与模块下发(local-debug 形态,同 flow-center) | +| appKey | UserBase | **question-center appKey=100020 已注册(2026-10-22)**;FC 验签走 trust-registry 密钥链,FC 本地 trusted-apps 仅停摆兜底、可不配 | +| AppRegistry | FC 管理页 | 校管理员在 FC 管理面登记 `question` 应用(appCode 恒 `question`,不写代码注册) | + +## 启动(本地 dev) + +```bash +# 1) MySQL 建库 +mysql -uroot -p123456 < sql/question_r1.sql + +# 2) 启动(:8090) +mvn -o spring-boot:run +# 健康探针:登录态 curl http://localhost:8090/api/question/ping → 200 {pong,user,shcId};未登录 → 302 CAS +``` + +## 关键配置(src/main/resources/application.yml) + +- `yh.app-auth.app-key: 100020`(UserBase 已注册;FC 侧经 trust-registry 密钥链验签,secret 两端须一致) +- `yh.open.services.form-center: http://localhost:8086`(FormCenterClient 唯一出站点) +- `question.projection.fixed-delay-ms: 30000` + +## 测试 + +```bash +mvn -o test # 23 例单测(H2/mock,无外部依赖) +``` + +## 红线(沿任务书 §7) + +不迁旧数据;不做家长问卷;不跨校;不建第二答卷提交入口;不改 form-center/flow-center/svc-hall/Notice/UserBase/BasePlatform;不 import FC 在途未提交类(FormConsumerAccessService/AnswerDelivery*/SubmissionContext* 等);OAPI 只用已提交的 schema/answer/answers 三端点;`appCode` 恒为 `question`(2026-10-22 改定;FC AppRegistry 按此登记)。 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..082d496 --- /dev/null +++ b/pom.xml @@ -0,0 +1,125 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + question-center + 0.1 + question-center + 问卷应用(表单定义/答卷权威在 form-center,本应用管问卷业务:投放/投影回收/统计) + + 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 + 1.0.0-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + com.mysql + mysql-connector-j + runtime + + + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.h2database + h2 + test + + + + + question-center + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.question.QuestionCenterApplication + + + + + repackage + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Test.java + + + + + + diff --git a/sql/question_r1.sql b/sql/question_r1.sql new file mode 100644 index 0000000..6455dfc --- /dev/null +++ b/sql/question_r1.sql @@ -0,0 +1,66 @@ +-- ===================================================================== +-- 问卷应用 question-center R1 建表脚本(库:ysch_question) +-- 表:questionnaire / pull_cursor / answer_projection +-- 与实体(ddl-auto=update 开发期自动建表)保持同构;生产用本脚本 + ddl-auto=none +-- ===================================================================== + +CREATE DATABASE IF NOT EXISTS ysch_question DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +USE ysch_question; + +-- --------------------------------------------------------------------- +-- 问卷主表:一份问卷 = 一个 FC 表单(1:1) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `questionnaire` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `sch_id` BIGINT NOT NULL COMMENT '学校域', + `form_uuid` VARCHAR(40) NOT NULL COMMENT 'FC form.uuid(uk 兜底防重复绑定)', + `form_code` VARCHAR(64) NULL COMMENT 'FC form.code:填写链接 #/f/{code} 入口', + `title_snapshot` VARCHAR(128) NULL COMMENT 'FC form.name 快照(以 FC 为准可刷新)', + `form_version` INT NULL COMMENT '发布核验时钉版的版本号;NULL=从未发布', + `status` INT NOT NULL DEFAULT 0 COMMENT '0=草稿 1=收集中 2=已结束(问卷投放态)', + `description` LONGTEXT NULL COMMENT 'JSON:{description,cover,thanks}', + `anonymous` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '受众含 ANONYMOUS', + `schema_snapshot` LONGTEXT NULL COMMENT '发布时钉版的 FC schema 快照(统计渲染用)', + `publish_time` DATETIME NULL, + `finish_time` DATETIME NULL, + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + `is_deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_questionnaire_form_uuid` (`form_uuid`), + KEY `idx_questionnaire_sch_status` (`sch_id`, `status`, `is_deleted`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '问卷主表'; + +-- --------------------------------------------------------------------- +-- 投影拉取游标:每表单一行(冻结表单 append-only answer_uuid 游标) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pull_cursor` ( + `form_uuid` VARCHAR(40) NOT NULL COMMENT 'FC form.uuid', + `cursor` VARCHAR(40) NULL COMMENT '已拉到的最后一条 answer_uuid(FC nextCursor)', + `seen_count` BIGINT NOT NULL DEFAULT 0 COMMENT '对账:累计首见行数(含墓碑)', + `last_pull_at` DATETIME NULL, + `last_status` VARCHAR(255) NULL COMMENT 'OK / FAILED:原因', + PRIMARY KEY (`form_uuid`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '答卷投影拉取游标'; + +-- --------------------------------------------------------------------- +-- 答卷投影行:uk answer_uuid 幂等 upsert;墓碑翻 is_deleted 不删行 +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `answer_projection` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `answer_uuid` VARCHAR(40) NOT NULL COMMENT 'FC answer_uuid(uk)', + `form_uuid` VARCHAR(40) NOT NULL, + `values_json` LONGTEXT NOT NULL COMMENT 'FC 行 values 原文 JSON(不解析不校验)', + `submitter_id` INT NULL COMMENT 'FC userId;0=匿名行', + `subject_id` INT NULL COMMENT '被代填人;自填=本人、匿名=NULL', + `status` VARCHAR(32) NULL COMMENT 'FC 答卷状态', + `is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=FC 侧墓碑(软删)', + `fc_create_time` VARCHAR(32) NULL COMMENT 'FC 行 create_time 原文', + `fc_update_time` VARCHAR(32) NULL, + `pulled_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_answer_projection_uuid` (`answer_uuid`), + KEY `idx_answer_projection_form` (`form_uuid`, `is_deleted`, `fc_create_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '答卷投影行'; diff --git a/src/main/java/com/yunhai/question/QuestionCenterApplication.java b/src/main/java/com/yunhai/question/QuestionCenterApplication.java new file mode 100644 index 0000000..ce82f93 --- /dev/null +++ b/src/main/java/com/yunhai/question/QuestionCenterApplication.java @@ -0,0 +1,19 @@ +package com.yunhai.question; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 问卷应用(question-center):表单定义/答卷权威在 form-center(FC),本应用只做问卷业务—— + * 问卷 ↔ FC 表单 1:1 绑定登记、投放(链接/二维码)、答卷投影回收(/oapi/form/answers 游标增量)、 + * 基础统计(人数口径)。提交入口永远在 FC 填写页(#/f/{code}),本应用不建第二提交入口。 + */ +@EnableScheduling +@SpringBootApplication +public class QuestionCenterApplication { + + public static void main(String[] args) { + SpringApplication.run(QuestionCenterApplication.class, args); + } +} diff --git a/src/main/java/com/yunhai/question/common/BizException.java b/src/main/java/com/yunhai/question/common/BizException.java new file mode 100644 index 0000000..8d69eeb --- /dev/null +++ b/src/main/java/com/yunhai/question/common/BizException.java @@ -0,0 +1,23 @@ +package com.yunhai.question.common; + +import lombok.Getter; + +/** 业务异常:平台统一响应链(yh-spring-boot-starter)按 code!=0 包信封返回前端 */ +@Getter +public class BizException extends RuntimeException { + + /** 平台信封业务错误码(对齐 form-center ErrorCode.CUSTOM=1 的习惯:非 0 即业务拒绝) */ + public static final int CODE_BIZ = 1; + public static final int CODE_PARAM = 4; + + private final int code; + + public BizException(String message) { + this(CODE_BIZ, message); + } + + public BizException(int code, String message) { + super(message); + this.code = code; + } +} diff --git a/src/main/java/com/yunhai/question/common/GlobalExceptionHandler.java b/src/main/java/com/yunhai/question/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..0d91a64 --- /dev/null +++ b/src/main/java/com/yunhai/question/common/GlobalExceptionHandler.java @@ -0,0 +1,45 @@ +package com.yunhai.question.common; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 兜底异常 → 平台信封形状 {code,msg,data}。正常路径(CAS 登录跳转信封等)由平台 starter + * 统一响应链处理,这里只兜业务侧异常,保证前端拿到的永远是 BschResponse 形状。 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BizException.class) + public Map biz(BizException e) { + Map body = new LinkedHashMap<>(); + body.put("code", e.getCode()); + body.put("msg", e.getMessage()); + body.put("data", null); + return body; + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public Map invalid(MethodArgumentNotValidException e) { + String msg = e.getBindingResult().getFieldErrors().stream() + .map(f -> f.getField() + ":" + f.getDefaultMessage()) + .findFirst().orElse("参数错误"); + return biz(new BizException(BizException.CODE_PARAM, msg)); + } + + @ExceptionHandler(Exception.class) + public Map unknown(Exception e) { + log.error("[question] 未处理异常", e); + Map body = new LinkedHashMap<>(); + body.put("code", 6); + body.put("msg", "内部服务错误"); + body.put("data", null); + return body; + } +} diff --git a/src/main/java/com/yunhai/question/common/QuestionAuditListener.java b/src/main/java/com/yunhai/question/common/QuestionAuditListener.java new file mode 100644 index 0000000..3888172 --- /dev/null +++ b/src/main/java/com/yunhai/question/common/QuestionAuditListener.java @@ -0,0 +1,41 @@ +package com.yunhai.question.common; + +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.question.entity.BaseEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * 审计字段自动填充(flow-center FlowCenterAuditListener 同款)。 + * 账号取 ThreadLocal 中的 CAS 会话用户;定时投影等无会话场景为 null。 + */ +@Slf4j +@Component +public class QuestionAuditListener { + + @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/question/controller/api/AnswerController.java b/src/main/java/com/yunhai/question/controller/api/AnswerController.java new file mode 100644 index 0000000..000b39e --- /dev/null +++ b/src/main/java/com/yunhai/question/controller/api/AnswerController.java @@ -0,0 +1,113 @@ +package com.yunhai.question.controller.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.question.common.BizException; +import com.yunhai.question.entity.Questionnaire; +import com.yunhai.question.service.ProjectionService; +import com.yunhai.question.service.QuestionnaireService; +import com.yunhai.question.service.SchoolAdminGuard; +import com.yunhai.question.service.StatsService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 回收明细 + 基础统计 + 手动投影拉取(管理面,校管理员守卫)。 + */ +@RestController +@RequestMapping("/api/question/answers") +@RequiredArgsConstructor +public class AnswerController { + + private final QuestionnaireService questionnaireService; + private final ProjectionService projectionService; + private final StatsService statsService; + private final SchoolAdminGuard adminGuard; + private final ObjectMapper objectMapper; + + /** 分页回收明细(未删投影行;单份查看由前端用 valuesJson + schema 渲染) */ + @GetMapping("/{id}/list") + public Map list(BoaUser user, @PathVariable Long id, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int pageSize) { + Long schId = adminGuard.requireSchool(user); + Questionnaire q = questionnaireService.requireOwned(schId, id); + if (pageSize < 1 || pageSize > 100) { + pageSize = 10; + } + Page p = projectionService + .pageAnswers(q.getFormUuid(), PageRequest.of(Math.max(page - 1, 0), pageSize)); + Map out = new LinkedHashMap<>(); + out.put("total", p.getTotalElements()); + out.put("page", p.getNumber() + 1); + out.put("pageSize", pageSize); + out.put("rows", p.getContent()); + return out; + } + + /** 基础统计(人数口径,全部在投影上算) */ + @GetMapping("/{id}/stats") + public Map stats(BoaUser user, @PathVariable Long id) { + Long schId = adminGuard.requireSchool(user); + Questionnaire q = questionnaireService.requireOwned(schId, id); + if (q.getFormVersion() == null) { + throw new BizException("问卷尚未发布(无钉版 schema,统计需要先发布)"); + } + return statsService.stats(q); + } + + /** 手动触发一次投影拉取(定时之外的即时回收入口) */ + @PostMapping("/{id}/pull") + public Map pull(BoaUser user, @PathVariable Long id) { + Long schId = adminGuard.requireSchool(user); + Questionnaire q = questionnaireService.requireOwned(schId, id); + int fresh = projectionService.pullOnce(q.getSchId(), q.getFormUuid()); + Map out = new LinkedHashMap<>(); + out.put("freshRows", fresh); + out.put("totalActive", projectionService.countActive(q.getFormUuid())); + return out; + } + + /** 单份查看:答卷 values 原文 + schema 字段定义(前端按 key 对齐渲染) */ + @GetMapping("/{id}/detail") + public Map detail(BoaUser user, @PathVariable Long id, + @RequestParam("answerUuid") String answerUuid) { + Long schId = adminGuard.requireSchool(user); + Questionnaire q = questionnaireService.requireOwned(schId, id); + com.yunhai.question.entity.AnswerProjection row = projectionService + .findAnswer(q.getFormUuid(), answerUuid); + if (row == null) { + throw new BizException("答卷不存在:" + answerUuid); + } + Map out = new LinkedHashMap<>(); + out.put("row", row); + out.put("fields", parseFields(q)); + return out; + } + + private Object parseFields(Questionnaire q) { + Map snapshot = questionnaireService.schemaSnapshot(q); + Object groups = snapshot.get("groups"); + if (groups == null) { + return java.util.Collections.emptyList(); + } + try { + return objectMapper.readValue(objectMapper.writeValueAsString(groups), + new TypeReference() { + }); + } catch (Exception e) { + return java.util.Collections.emptyList(); + } + } +} diff --git a/src/main/java/com/yunhai/question/controller/api/QuestionBaseController.java b/src/main/java/com/yunhai/question/controller/api/QuestionBaseController.java new file mode 100644 index 0000000..1269b30 --- /dev/null +++ b/src/main/java/com/yunhai/question/controller/api/QuestionBaseController.java @@ -0,0 +1,90 @@ +package com.yunhai.question.controller.api; + +import com.yunhai.platform.openapi.base.YschBaseOpenApi; +import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.question.common.BizException; +import org.springframework.web.bind.annotation.GetMapping; +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; + +/** + * 前端壳基础端点 + R1.1 连通探针(flow-center FlowBaseController 同款形态)。 + */ +@RestController +public class QuestionBaseController { + + @GetMapping("/api/base/GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + /** 当前用户已授权模块树(左侧菜单):平台 YschBaseOpenApi.getUserModule 下发,本侧只改字段名 */ + @GetMapping("/api/base/GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + 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(); + } + return toFrontendTree(modules); + } + + /** + * R1.1 连通探针:返回登录用户与学校(登录态 200;未登录由 CAS 过滤器 302 拦在前)。 + */ + @GetMapping("/api/question/ping") + public Map ping(BoaUser user) { + if (user == null || user.getId() == null) { + throw new BizException("未登录(ping 只验证登录态连通)"); + } + Map out = new LinkedHashMap<>(); + out.put("pong", true); + out.put("app", "question-center"); + out.put("userId", user.getId()); + out.put("userName", user.getName()); + Integer schId = user.getCurrentRole() != null ? user.getCurrentRole().getSchId() : null; + out.put("schId", schId); + out.put("roleIds", user.getCurrentRole() != null && user.getCurrentRole().getRoleId() != null + ? Collections.singletonList(user.getCurrentRole().getRoleId()) + : Collections.emptyList()); + return out; + } + + static List> toFrontendTree(List modules) { + List> result = new ArrayList<>(); + 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 优先 customId(对上 front router meta.modId 约定),非数字回退主键 id */ + 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/question/controller/api/QuestionnaireController.java b/src/main/java/com/yunhai/question/controller/api/QuestionnaireController.java new file mode 100644 index 0000000..735cda3 --- /dev/null +++ b/src/main/java/com/yunhai/question/controller/api/QuestionnaireController.java @@ -0,0 +1,141 @@ +package com.yunhai.question.controller.api; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.question.common.BizException; +import com.yunhai.question.entity.Questionnaire; +import com.yunhai.question.service.QuestionnaireService; +import com.yunhai.question.service.SchoolAdminGuard; +import lombok.RequiredArgsConstructor; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 问卷管理端(仅校管理员∪超管可建——FC 侧 create 守卫天然把关第一步,本侧登记步同样过守卫)。 + */ +@RestController +@RequestMapping("/api/question/questionnaire") +@RequiredArgsConstructor +public class QuestionnaireController { + + private final QuestionnaireService questionnaireService; + private final SchoolAdminGuard adminGuard; + + public static class RegisterRequest { + @NotBlank(message = "formUuid 不能为空") + public String formUuid; + public String formCode; + @NotBlank(message = "问卷标题不能为空") + public String title; + public Boolean anonymous; + /** JSON 字符串:{description,cover,thanks} */ + public String description; + } + + public static class DescriptionRequest { + public String description; + } + + /** 列表(本校全部未删问卷) */ + @GetMapping("/list") + public List list(BoaUser user) { + Long schId = adminGuard.requireSchool(user); + return questionnaireService.list(schId); + } + + /** + * 第三步绑定登记(幂等三步的收口;重复提交返回既有行)。 + */ + @PostMapping + public Questionnaire register(BoaUser user, @Valid @RequestBody RegisterRequest body) { + Long schId = adminGuard.requireSchool(user); + boolean anonymous = Boolean.TRUE.equals(body.anonymous); + return questionnaireService.register(schId, body.formUuid.trim(), body.formCode, + body.title, anonymous, body.description); + } + + @GetMapping("/{id}") + public Questionnaire detail(BoaUser user, @PathVariable Long id) { + Long schId = adminGuard.requireSchool(user); + return questionnaireService.requireOwned(schId, id); + } + + /** 问卷说明/封面/致谢(JSON 文本,仅问卷侧元数据) */ + @PostMapping("/{id}/description") + public Questionnaire updateDescription(BoaUser user, @PathVariable Long id, + @RequestBody DescriptionRequest body) { + Long schId = adminGuard.requireSchool(user); + Questionnaire q = questionnaireService.requireOwned(schId, id); + q.setDescription(body == null ? null : body.description); + return questionnaireService.save(q); + } + + /** 发布投放:fetchSchema 核验 FC 已发布→钉版+status=1 */ + @PostMapping("/{id}/publish") + public Questionnaire publish(BoaUser user, @PathVariable Long id) { + Long schId = adminGuard.requireSchool(user); + return questionnaireService.publish(schId, id); + } + + /** 结束投放:status=2(FC 侧是否 disable 由前端另行确认调用 FC 端点) */ + @PostMapping("/{id}/finish") + public Questionnaire finish(BoaUser user, @PathVariable Long id) { + Long schId = adminGuard.requireSchool(user); + return questionnaireService.finish(schId, id); + } + + /** 恢复收集 */ + @PostMapping("/{id}/reopen") + public Questionnaire reopen(BoaUser user, @PathVariable Long id) { + Long schId = adminGuard.requireSchool(user); + return questionnaireService.reopen(schId, id); + } + + /** 软删问卷行(FC 表单不动) */ + @PostMapping("/{id}/delete") + public Map softDelete(BoaUser user, @PathVariable Long id) { + Long schId = adminGuard.requireSchool(user); + questionnaireService.softDelete(schId, id); + Map out = new LinkedHashMap<>(); + out.put("deleted", true); + return out; + } + + /** 编辑容器打开时刷新 FC name/code 快照并返回详情 */ + @GetMapping("/{id}/refresh") + public Questionnaire refresh(BoaUser user, @PathVariable Long id) { + Long schId = adminGuard.requireSchool(user); + Questionnaire q = questionnaireService.requireOwned(schId, id); + return questionnaireService.save(questionnaireService.refreshFromFc(q)); + } + + /** + * 我的问卷交集:前端把 FC fillable(appCode=question) 的 code 列表 POST 过来, + * 服务端取 ∩ 本校收集中问卷。未登录教师/学生同样可达(无校管理员守卫)。 + */ + @PostMapping("/my") + public List myFillable(BoaUser user, @RequestBody MyCodesRequest body) { + if (user == null || user.getId() == null) { + throw new BizException("未登录"); + } + Integer schIdInt = user.getCurrentRole() != null ? user.getCurrentRole().getSchId() : null; + if (schIdInt == null) { + throw new BizException("当前会话无学校上下文"); + } + return questionnaireService.myFillable(schIdInt.longValue(), + body == null ? null : body.codes); + } + + public static class MyCodesRequest { + public List codes; + } +} diff --git a/src/main/java/com/yunhai/question/entity/AnswerProjection.java b/src/main/java/com/yunhai/question/entity/AnswerProjection.java new file mode 100644 index 0000000..28ae2d5 --- /dev/null +++ b/src/main/java/com/yunhai/question/entity/AnswerProjection.java @@ -0,0 +1,70 @@ +package com.yunhai.question.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.Lob; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 答卷投影行:uk answer_uuid 幂等 upsert。values 原样存 JSON(不解析不校验内容), + * 统计在投影上按人数口径计算;墓碑行(FC is_deleted=1)只翻 is_deleted 不删行。 + */ +@Entity +@Getter +@Setter +@Table(name = "answer_projection", uniqueConstraints = { + @UniqueConstraint(name = "uk_answer_projection_uuid", columnNames = "answer_uuid") +}, indexes = { + @Index(name = "idx_answer_projection_form", columnList = "form_uuid,is_deleted,fc_create_time") +}) +public class AnswerProjection { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "answer_uuid", nullable = false, length = 40) + private String answerUuid; + + @Column(name = "form_uuid", nullable = false, length = 40) + private String formUuid; + + /** FC 行 values 原文 JSON(snake_case 键原样透传) */ + @Lob + @Column(name = "values_json", nullable = false) + private String valuesJson; + + /** 提交人 FC userId;0=匿名行 */ + @Column(name = "submitter_id") + private Integer submitterId; + + /** 被代填人;自填=本人、匿名=NULL */ + @Column(name = "subject_id") + private Integer subjectId; + + /** FC 答卷状态(statusDefs 默认"待处理/已处理") */ + @Column(name = "status", length = 32) + private String status; + + /** 1=FC 侧墓碑(软删),统计与明细默认排除 */ + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = Boolean.FALSE; + + @Column(name = "fc_create_time", length = 32) + private String fcCreateTime; + + @Column(name = "fc_update_time", length = 32) + private String fcUpdateTime; + + @Column(name = "pulled_at") + private LocalDateTime pulledAt; +} diff --git a/src/main/java/com/yunhai/question/entity/BaseEntity.java b/src/main/java/com/yunhai/question/entity/BaseEntity.java new file mode 100644 index 0000000..925bd3c --- /dev/null +++ b/src/main/java/com/yunhai/question/entity/BaseEntity.java @@ -0,0 +1,43 @@ +package com.yunhai.question.entity; + +import com.yunhai.question.common.QuestionAuditListener; +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; + +/** + * 实体公共基类:自增主键 + 审计四件套(QuestionAuditListener 自动填充,flow-center 同款)。 + * 多租户键 sch_id 不在基类——业务实体自带列。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(QuestionAuditListener.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/question/entity/PullCursor.java b/src/main/java/com/yunhai/question/entity/PullCursor.java new file mode 100644 index 0000000..a45789d --- /dev/null +++ b/src/main/java/com/yunhai/question/entity/PullCursor.java @@ -0,0 +1,40 @@ +package com.yunhai.question.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 投影拉取游标:每表单一行(uk form_uuid)。冻结表单(建卷时 frozen=1)走 append-only + * answer_uuid 游标 + 墓碑;after 游标持久化,重放安全(同游标返回同窗口或其后子集)。 + */ +@Entity +@Getter +@Setter +@Table(name = "pull_cursor") +public class PullCursor { + + @Id + @Column(name = "form_uuid", length = 40) + private String formUuid; + + /** 已拉取到的最后一条 answer_uuid(FC nextCursor 持久化);null=从未拉取 */ + @Column(name = "cursor", length = 40) + private String cursor; + + /** 对账用:累计见过的行数(含墓碑)——与投影表总量(含已删)漂移即告警 */ + @Column(name = "seen_count", nullable = false) + private Long seenCount = 0L; + + @Column(name = "last_pull_at") + private LocalDateTime lastPullAt; + + /** OK / FAILED:原因(拉取异常记因,下一轮覆盖) */ + @Column(name = "last_status", length = 255) + private String lastStatus; +} diff --git a/src/main/java/com/yunhai/question/entity/Questionnaire.java b/src/main/java/com/yunhai/question/entity/Questionnaire.java new file mode 100644 index 0000000..45a9282 --- /dev/null +++ b/src/main/java/com/yunhai/question/entity/Questionnaire.java @@ -0,0 +1,79 @@ +package com.yunhai.question.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Lob; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 问卷主表:一份问卷 = 一个 FC 表单(1:1,uk form_uuid 兜底防重复绑定)。 + * 表单定义(schema/版本/校验)与答卷权威全在 FC;本行只存问卷业务状态与展示冗余。 + */ +@Entity +@Getter +@Setter +@Table(name = "questionnaire", uniqueConstraints = { + @UniqueConstraint(name = "uk_questionnaire_form_uuid", columnNames = "form_uuid") +}, indexes = { + @Index(name = "idx_questionnaire_sch_status", columnList = "sch_id,status,is_deleted") +}) +public class Questionnaire extends BaseEntity { + + public static final int STATUS_DRAFT = 0; + public static final int STATUS_COLLECTING = 1; + public static final int STATUS_FINISHED = 2; + + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** FC form.uuid(32hex),创建 FC 表单成功后第三步登记;uk 兜底:重复提交不产生重复行 */ + @Column(name = "form_uuid", nullable = false, length = 40) + private String formUuid; + + /** FC form.code:生成填写链接 #/f/{code} 的入口(fillable 行无 uuid,交集/入口都按 code) */ + @Column(name = "form_code", length = 64) + private String formCode; + + /** FC form.name 快照(列表展示冗余,以 FC 为准可刷新) */ + @Column(name = "title_snapshot", length = 128) + private String titleSnapshot; + + /** 当前发布版本(发布核验经 fetchSchema 通过时写入;null=从未发布) */ + @Column(name = "form_version") + private Integer formVersion; + + /** 0=草稿 1=收集中 2=已结束(问卷侧投放态,与 FC form.status 是两回事) */ + @Column(name = "status", nullable = false) + private Integer status; + + /** JSON:{description,cover,thanks} 问卷说明/封面/致谢文案 */ + @Lob + @Column(name = "description") + private String description; + + /** 受众含 ANONYMOUS 时为 true(从 FC config 受众推断,登记时由前端传入) */ + @Column(name = "anonymous", nullable = false) + private Boolean anonymous; + + /** 发布核验时钉版的 schema 快照(题干/选项 label 缓存,统计渲染用) */ + @Lob + @Column(name = "schema_snapshot") + private String schemaSnapshot; + + /** 投放链接生成时间(发布成功时写) */ + @Column(name = "publish_time") + private LocalDateTime publishTime; + + /** 结束投放时间 */ + @Column(name = "finish_time") + private LocalDateTime finishTime; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = Boolean.FALSE; +} diff --git a/src/main/java/com/yunhai/question/fc/FormCenterClient.java b/src/main/java/com/yunhai/question/fc/FormCenterClient.java new file mode 100644 index 0000000..ac99ab2 --- /dev/null +++ b/src/main/java/com/yunhai/question/fc/FormCenterClient.java @@ -0,0 +1,186 @@ +package com.yunhai.question.fc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.utils.exception.DException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * form-center 出站客户端——本应用唯一的 FC 出站点(模板:flow-center FormCenterSchemaClient)。 + * 只面向 HEAD 已提交的三个 OAPI 端点:/oapi/form/schema、/oapi/form/answer、/oapi/form/answers; + * 严禁引用 FC 工作区在途类(FormConsumerAccessService/AnswerDelivery* 等)。 + * + *

异常语义(发布校验/投影拉取都依赖这组区分):

+ *
    + *
  • {@link FormCenterRejectException}——FC 明确业务拒绝(信封 code!=0,如"表单尚未发布")。 + * 平台 YschOpenCall 对 code!=0 抛 DException(message=FC 原文案),本客户端转译,不重试;
  • + *
  • {@link FormCenterUnavailableException}——网络/信封/形态异常,有限重试后抛出,可重试。
  • + *
+ */ +@Slf4j +@Component +public class FormCenterClient { + + static final String TARGET = "form-center"; + static final String SCHEMA_PATH = "/oapi/form/schema"; + static final String ANSWERS_PATH = "/oapi/form/answers"; + static final int MAX_ATTEMPTS = 2; + + private final YschOpenCall openCall; + private final ObjectMapper objectMapper; + + public FormCenterClient(YschOpenCall openCall, ObjectMapper objectMapper) { + this.openCall = openCall; + this.objectMapper = objectMapper; + } + + /** FC 明确业务拒绝(重试无意义):发布校验被拦、表单不存在等 */ + public static class FormCenterRejectException extends RuntimeException { + public FormCenterRejectException(String message) { + super(message); + } + } + + /** FC 不可达/信封异常(可重试) */ + public static class FormCenterUnavailableException extends RuntimeException { + public FormCenterUnavailableException(String message, Throwable cause) { + super(message, cause); + } + } + + /** /oapi/form/answers 拉取页:{rows(原样 Map 列表), nextCursor} */ + public static class AnswersPage { + private final List> rows; + private final String nextCursor; + + public AnswersPage(List> rows, String nextCursor) { + this.rows = rows; + this.nextCursor = nextCursor; + } + + public List> getRows() { + return rows; + } + + public String getNextCursor() { + return nextCursor; + } + } + + /** + * 发布核验:按 uuid 拉当前已发布版 schema(含 groups 完整正文——题干/选项 label 缓存用, + * 不做 flow-center 式字段收窄)。未发布/停用时 FC 返回业务拒绝 → + * {@link FormCenterRejectException}(文案点名"尚未发布/已停用")。 + * + * @return FC SchemaView 的 data(formUuid/version/code/appCode/audience/groups…) + */ + public Map fetchSchema(Long schId, String formUuid) { + Map query = new HashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("uuid", formUuid); + Map data = call(SCHEMA_PATH, query); + if (data == null) { + throw new FormCenterUnavailableException("schema 返回空 data(uuid=" + formUuid + ")", null); + } + return data; + } + + /** + * 答卷增量拉取(契约 v2 §6.1,冻结表单 append-only + 墓碑): + * {@code GET /oapi/form/answers?schId=&form_uuid=&after=&limit=}。 + * + * @param after 上次游标(null=从头拉);nextCursor=null 表示到当前窗口末尾 + */ + public AnswersPage pullAnswers(Long schId, String formUuid, String after, Integer limit) { + Map query = new HashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("form_uuid", formUuid); + if (after != null && !after.trim().isEmpty()) { + query.put("after", after); + } + if (limit != null) { + query.put("limit", String.valueOf(limit)); + } + Map data = call(ANSWERS_PATH, query); + if (data == null) { + throw new FormCenterUnavailableException("answers 返回空 data(uuid=" + formUuid + ")", null); + } + List> rows = new ArrayList<>(); + Object rawRows = data.get("rows"); + if (rawRows instanceof Iterable) { + for (Object row : (Iterable) rawRows) { + if (row instanceof Map) { + @SuppressWarnings("unchecked") + Map m = (Map) row; + rows.add(m); + } + } + } + Object next = data.get("nextCursor"); + return new AnswersPage(rows, next == null ? null : String.valueOf(next)); + } + + /** 带有限重试的单次 GET:DException(FC 业务拒绝)直接转译不重试 */ + private Map call(String path, Map query) { + RuntimeException last = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + return openCall.get(TARGET, path, query, Map.class); + } catch (DException e) { + throw new FormCenterRejectException(String.valueOf(e.getMessage())); + } catch (RuntimeException e) { + last = e; + log.warn("[question] form-center {} 调用失败(第 {}/{} 次):{}", path, attempt, MAX_ATTEMPTS, e.toString()); + } catch (Exception e) { + last = new IllegalStateException(e.toString(), e); + log.warn("[question] form-center {} 调用异常(第 {}/{} 次):{}", path, attempt, MAX_ATTEMPTS, e.toString()); + } + } + throw new FormCenterUnavailableException( + "form-center " + path + " 调用失败(已重试 " + MAX_ATTEMPTS + " 次)", last); + } + + /** 统计用:schema 正文 → 字段清单 [{key,label,type,options:[{label,value}]}](groups 扁平化) */ + public List> parseFields(Map schemaData) { + List> fields = new ArrayList<>(); + JsonNode groups = schemaData.get("groups") == null ? null + : objectMapper.valueToTree(schemaData.get("groups")); + if (groups == null || !groups.isArray()) { + return fields; + } + for (JsonNode group : groups) { + JsonNode groupFields = group.get("fields"); + if (groupFields == null || !groupFields.isArray()) { + continue; + } + for (JsonNode f : groupFields) { + Map field = new LinkedHashMap<>(); + field.put("key", f.path("key").asText(null)); + field.put("label", f.path("label").asText(null)); + field.put("type", f.path("type").asText(null)); + field.put("required", f.path("required").asBoolean(false)); + JsonNode options = f.get("options"); + if (options != null && options.isArray() && options.size() > 0) { + List> opts = new ArrayList<>(); + for (JsonNode opt : options) { + Map o = new LinkedHashMap<>(); + o.put("label", opt.path("label").asText(null)); + o.put("value", opt.path("value").asText(null)); + opts.add(o); + } + field.put("options", opts); + } + fields.add(field); + } + } + return fields; + } +} diff --git a/src/main/java/com/yunhai/question/repository/AnswerProjectionRepository.java b/src/main/java/com/yunhai/question/repository/AnswerProjectionRepository.java new file mode 100644 index 0000000..43e69d0 --- /dev/null +++ b/src/main/java/com/yunhai/question/repository/AnswerProjectionRepository.java @@ -0,0 +1,33 @@ +package com.yunhai.question.repository; + +import com.yunhai.question.entity.AnswerProjection; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface AnswerProjectionRepository extends JpaRepository { + + /** 幂等 upsert 的查半边:按 uk 找既有行 */ + AnswerProjection findByAnswerUuid(String answerUuid); + + Page findByFormUuidAndIsDeletedFalseOrderByFcCreateTimeDescIdDesc( + String formUuid, Pageable pageable); + + long countByFormUuidAndIsDeletedFalse(String formUuid); + + /** 对账口径:投影表全量(含墓碑),应等于游标 seen_count */ + long countByFormUuid(String formUuid); + + /** 统计用:未删行全量 values(人数口径在内存计算,R1 量级可控;超大卷后续再下推 SQL) */ + @Query("select p.valuesJson from AnswerProjection p " + + "where p.formUuid = :formUuid and p.isDeleted = false") + List findActiveValuesJson(@Param("formUuid") String formUuid); + + @Query("select p.status, count(p) from AnswerProjection p " + + "where p.formUuid = :formUuid and p.isDeleted = false group by p.status") + List countGroupByStatus(@Param("formUuid") String formUuid); +} diff --git a/src/main/java/com/yunhai/question/repository/PullCursorRepository.java b/src/main/java/com/yunhai/question/repository/PullCursorRepository.java new file mode 100644 index 0000000..0334dae --- /dev/null +++ b/src/main/java/com/yunhai/question/repository/PullCursorRepository.java @@ -0,0 +1,7 @@ +package com.yunhai.question.repository; + +import com.yunhai.question.entity.PullCursor; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PullCursorRepository extends JpaRepository { +} diff --git a/src/main/java/com/yunhai/question/repository/QuestionnaireRepository.java b/src/main/java/com/yunhai/question/repository/QuestionnaireRepository.java new file mode 100644 index 0000000..d093fc2 --- /dev/null +++ b/src/main/java/com/yunhai/question/repository/QuestionnaireRepository.java @@ -0,0 +1,35 @@ +package com.yunhai.question.repository; + +import com.yunhai.question.entity.Questionnaire; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +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; + +public interface QuestionnaireRepository extends JpaRepository { + + Optional findByFormUuidAndIsDeletedFalse(String formUuid); + + Optional findByIdAndIsDeletedFalse(Long id); + + List findBySchIdAndIsDeletedFalseOrderByIdDesc(Long schId); + + /** 我的问卷交集:FC fillable 返回 code 列表 ∩ 本校收集中问卷 */ + List findBySchIdAndStatusAndIsDeletedFalseAndFormCodeIn( + Long schId, Integer status, Collection formCodes); + + long countBySchIdAndIsDeletedFalse(Long schId); + + /** 投影定时扫描:全部收集中问卷(不限学校——游标按 form_uuid 全局隔离) */ + List findByStatusAndIsDeletedFalse(Integer status); + + /** 软删(唯一删除形态,对齐 FC 纪律) */ + @Modifying + @Query("update Questionnaire q set q.isDeleted = true, q.updateTime = CURRENT_TIMESTAMP " + + "where q.id = :id and q.isDeleted = false") + int softDelete(@Param("id") Long id); +} diff --git a/src/main/java/com/yunhai/question/service/ProjectionService.java b/src/main/java/com/yunhai/question/service/ProjectionService.java new file mode 100644 index 0000000..63f0520 --- /dev/null +++ b/src/main/java/com/yunhai/question/service/ProjectionService.java @@ -0,0 +1,219 @@ +package com.yunhai.question.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.question.entity.AnswerProjection; +import com.yunhai.question.entity.PullCursor; +import com.yunhai.question.entity.Questionnaire; +import com.yunhai.question.fc.FormCenterClient; +import com.yunhai.question.repository.AnswerProjectionRepository; +import com.yunhai.question.repository.PullCursorRepository; +import com.yunhai.question.repository.QuestionnaireRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 答卷投影(任务书 §4.5):FC /oapi/form/answers 冻结表单 append-only 游标增量拉取, + * 逐行幂等 upsert(uk answer_uuid);墓碑行(FC is_deleted=1)翻 is_deleted 不删行; + * after 游标持久化;投影总量(含墓碑)与游标 seen_count 漂移即告警日志(TASK_3 搁置期的 + * 一致性兜底)。values 原样存 JSON,不解析不校验内容。 + * + *

事务边界=每拉取页(rows 批次 + 游标推进同事务):中断最多重放一页,幂等 upsert + * 保证不产生重复行——这是"游标重放幂等"设计的关键。

+ */ +@Slf4j +@Service +public class ProjectionService { + + private final QuestionnaireRepository questionnaireRepository; + private final PullCursorRepository pullCursorRepository; + private final AnswerProjectionRepository answerProjectionRepository; + private final FormCenterClient formCenterClient; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + + private final int maxPagesPerForm; + private final int pageLimit; + + public ProjectionService(QuestionnaireRepository questionnaireRepository, + PullCursorRepository pullCursorRepository, + AnswerProjectionRepository answerProjectionRepository, + FormCenterClient formCenterClient, + ObjectMapper objectMapper, + TransactionTemplate transactionTemplate, + @Value("${question.projection.max-pages-per-form:200}") int maxPagesPerForm, + @Value("${question.projection.page-limit:500}") int pageLimit) { + this.questionnaireRepository = questionnaireRepository; + this.pullCursorRepository = pullCursorRepository; + this.answerProjectionRepository = answerProjectionRepository; + this.formCenterClient = formCenterClient; + this.objectMapper = objectMapper; + this.transactionTemplate = transactionTemplate; + this.maxPagesPerForm = maxPagesPerForm; + this.pageLimit = pageLimit; + } + + /** 定时全量扫描(question.projection.fixed-delay-ms,默认 30s;设大值即近关闭) */ + @Scheduled(fixedDelayString = "${question.projection.fixed-delay-ms:30000}") + public void scheduledPull() { + List collecting = questionnaireRepository + .findByStatusAndIsDeletedFalse(Questionnaire.STATUS_COLLECTING); + for (Questionnaire q : collecting) { + try { + pullOnce(q.getSchId(), q.getFormUuid()); + } catch (Exception e) { + log.warn("[question] 投影拉取失败:问卷 id={} uuid={} {}", q.getId(), q.getFormUuid(), e.toString()); + } + } + } + + /** + * 单表单一次增量拉取(手动触发也走这里):循环翻页直到 nextCursor=null; + * + * @return 本次新拉取行数(含墓碑;首见行数) + */ + public int pullOnce(Long schId, String formUuid) { + PullCursor cursor = pullCursorRepository.findById(formUuid).orElseGet(() -> { + PullCursor c = new PullCursor(); + c.setFormUuid(formUuid); + c.setSeenCount(0L); + return c; + }); + int totalNew = 0; + try { + String after = cursor.getCursor(); + for (int page = 0; page < maxPagesPerForm; page++) { + FormCenterClient.AnswersPage result = formCenterClient.pullAnswers(schId, formUuid, after, pageLimit); + final String nextCursor = result.getNextCursor(); + final List> rows = result.getRows(); + int newRows = transactionTemplate.execute(status -> upsertPage(formUuid, rows, nextCursor, cursor)); + totalNew += newRows; + if (nextCursor == null || rows.isEmpty()) { + break; + } + after = nextCursor; + } + cursor.setLastPullAt(LocalDateTime.now()); + cursor.setLastStatus("OK"); + pullCursorRepository.save(cursor); + checkReconciliation(formUuid, cursor); + } catch (FormCenterClient.FormCenterRejectException e) { + cursor.setLastPullAt(LocalDateTime.now()); + cursor.setLastStatus("FAILED:" + truncate(e.getMessage())); + pullCursorRepository.save(cursor); + throw e; + } catch (RuntimeException e) { + cursor.setLastPullAt(LocalDateTime.now()); + cursor.setLastStatus("FAILED:" + truncate(e.toString())); + pullCursorRepository.save(cursor); + throw e; + } + return totalNew; + } + + /** + * 一页的原子 upsert + 游标推进(事务内)。首见行才累计 seen_count——同窗口重放(FC C9 + * 语义)不重复计数。返回首见行数。 + */ + int upsertPage(String formUuid, List> rows, String nextCursor, PullCursor cursor) { + int fresh = 0; + for (Map row : rows) { + String answerUuid = str(row.get("answer_uuid")); + if (answerUuid == null || answerUuid.isEmpty()) { + log.warn("[question] 拉取行缺 answer_uuid,跳过:form={} row keys={}", formUuid, row.keySet()); + continue; + } + AnswerProjection projection = answerProjectionRepository.findByAnswerUuid(answerUuid); + boolean isNew = projection == null; + if (isNew) { + projection = new AnswerProjection(); + projection.setAnswerUuid(answerUuid); + projection.setFormUuid(formUuid); + } + projection.setValuesJson(toJson(row.get("values"))); + projection.setSubmitterId(intOrNull(row.get("submitter_id"))); + projection.setSubjectId(intOrNull(row.get("subject_id"))); + projection.setStatus(str(row.get("status"))); + boolean tombstone = intOrZero(row.get("is_deleted")) == 1; + projection.setIsDeleted(tombstone); + projection.setFcCreateTime(str(row.get("create_time"))); + projection.setFcUpdateTime(str(row.get("update_time"))); + projection.setPulledAt(LocalDateTime.now()); + answerProjectionRepository.save(projection); + if (isNew) { + fresh++; + } + } + if (nextCursor != null) { + cursor.setCursor(nextCursor); + } + cursor.setSeenCount(cursor.getSeenCount() + fresh); + return fresh; + } + + /** 对账兜底(TASK_3 搁置期):投影表全量(含墓碑)应恒等于游标 seen_count,漂移告警 */ + void checkReconciliation(String formUuid, PullCursor cursor) { + long projected = answerProjectionRepository.countByFormUuid(formUuid); + if (projected != cursor.getSeenCount()) { + log.warn("[question] 投影对账漂移:form={} 游标累计 seen={} 投影总量(含墓碑)={} " + + "——疑似投影表被外部改动或游标被回拨,请人工核对", formUuid, cursor.getSeenCount(), projected); + } + } + + private String toJson(Object value) { + if (value == null) { + return "{}"; + } + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + // values 原样透传:序列化失败也不猜内容,包一层错误占位(统计会按缺失处理) + return "{}"; + } + } + + // ==================== 明细/统计数据访问(转发仓储,控制器不直连仓储) ==================== + + public org.springframework.data.domain.Page pageAnswers( + String formUuid, org.springframework.data.domain.Pageable pageable) { + return answerProjectionRepository + .findByFormUuidAndIsDeletedFalseOrderByFcCreateTimeDescIdDesc(formUuid, pageable); + } + + public long countActive(String formUuid) { + return answerProjectionRepository.countByFormUuidAndIsDeletedFalse(formUuid); + } + + public com.yunhai.question.entity.AnswerProjection findAnswer(String formUuid, String answerUuid) { + com.yunhai.question.entity.AnswerProjection row = answerProjectionRepository.findByAnswerUuid(answerUuid); + if (row == null || !formUuid.equals(row.getFormUuid())) { + return null; + } + return row; + } + + private static String str(Object v) { + return v == null ? null : String.valueOf(v); + } + + private static Integer intOrNull(Object v) { + return v == null ? null : Integer.parseInt(String.valueOf(v)); + } + + private static int intOrZero(Object v) { + return v == null ? 0 : Integer.parseInt(String.valueOf(v)); + } + + private static String truncate(String s) { + if (s == null) { + return ""; + } + return s.length() > 200 ? s.substring(0, 200) : s; + } +} diff --git a/src/main/java/com/yunhai/question/service/QuestionnaireService.java b/src/main/java/com/yunhai/question/service/QuestionnaireService.java new file mode 100644 index 0000000..669ec09 --- /dev/null +++ b/src/main/java/com/yunhai/question/service/QuestionnaireService.java @@ -0,0 +1,191 @@ +package com.yunhai.question.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.question.common.BizException; +import com.yunhai.question.entity.Questionnaire; +import com.yunhai.question.fc.FormCenterClient; +import com.yunhai.question.repository.QuestionnaireRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 问卷业务服务。新建链路是"幂等三步"(任务书 §4.1):前端先以登录会话直调 FC + * {@code POST /api/form/admin/create}(appCode=question)与 {@code POST /api/form/admin/config} + * (frozen=1+受众/限次/截止),第三步才落本表——uk form_uuid 兜底防重复:重复提交第三步 + * 返回既有行,不产生重复记录(孤儿 FC 表单由前端"未挂接对账页"只读展示)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class QuestionnaireService { + + private final QuestionnaireRepository questionnaireRepository; + private final FormCenterClient formCenterClient; + private final ObjectMapper objectMapper; + + // ==================== 登记与查询 ==================== + + /** + * 第三步绑定登记(幂等):同 form_uuid 已存在且未删 → 直接返回既有行(不重复登记)。 + */ + @Transactional + public Questionnaire register(Long schId, String formUuid, String formCode, String title, + boolean anonymous, String descriptionJson) { + Questionnaire existing = questionnaireRepository.findByFormUuidAndIsDeletedFalse(formUuid).orElse(null); + if (existing != null) { + return existing; + } + Questionnaire q = new Questionnaire(); + q.setSchId(schId); + q.setFormUuid(formUuid); + q.setFormCode(formCode); + q.setTitleSnapshot(title); + q.setStatus(Questionnaire.STATUS_DRAFT); + q.setAnonymous(anonymous); + q.setDescription(descriptionJson); + return questionnaireRepository.save(q); + } + + public Questionnaire requireOwned(Long schId, Long id) { + Questionnaire q = questionnaireRepository.findByIdAndIsDeletedFalse(id) + .orElseThrow(() -> new BizException("问卷不存在:" + id)); + if (!schId.equals(q.getSchId())) { + throw new BizException("问卷不存在:" + id); + } + return q; + } + + public List list(Long schId) { + return questionnaireRepository.findBySchIdAndIsDeletedFalseOrderByIdDesc(schId); + } + + /** 我的问卷:FC fillable(前端已按 appCode=question 过滤)返回 code 列表 ∩ 本校收集中问卷 */ + public List myFillable(Long schId, Collection formCodes) { + if (formCodes == null || formCodes.isEmpty()) { + return java.util.Collections.emptyList(); + } + return questionnaireRepository + .findBySchIdAndStatusAndIsDeletedFalseAndFormCodeIn(schId, Questionnaire.STATUS_COLLECTING, formCodes); + } + + /** 编辑容器打开时刷新 FC form.name 快照(title 以 FC 为准) */ + @Transactional + public Questionnaire refreshFromFc(Questionnaire q) { + try { + Map schema = formCenterClient.fetchSchema(q.getSchId(), q.getFormUuid()); + if (schema.get("code") != null) { + q.setFormCode(String.valueOf(schema.get("code"))); + } + } catch (FormCenterClient.FormCenterRejectException e) { + // 未发布阶段 fetchSchema 会拒绝("表单尚未发布")——草稿期正常,仅记录 + log.debug("[question] 刷新快照被 FC 拒绝(草稿期正常):{}", e.getMessage()); + } catch (FormCenterClient.FormCenterUnavailableException e) { + log.warn("[question] 刷新快照失败:{}", e.getMessage()); + } + return q; + } + + // ==================== 发布与结束 ==================== + + /** + * 发布投放(任务书 §4.3):经 fetchSchema 核验 FC 已发布 → 钉版 form_version、缓存 + * schema 快照(统计渲染用)、status=1。FC 未发布 → FormCenterRejectException 转业务异常拦下。 + */ + @Transactional + public Questionnaire publish(Long schId, Long id) { + Questionnaire q = requireOwned(schId, id); + if (q.getStatus() == Questionnaire.STATUS_COLLECTING) { + throw new BizException("问卷已在收集中,无需重复发布"); + } + Map schema; + try { + schema = formCenterClient.fetchSchema(schId, q.getFormUuid()); + } catch (FormCenterClient.FormCenterRejectException e) { + throw new BizException("发布核验未通过(FC 侧:" + e.getMessage() + ")——请先在 FC 设计器里保存并发布表单"); + } catch (FormCenterClient.FormCenterUnavailableException e) { + throw new BizException("发布核验失败:form-center 不可达,请稍后重试"); + } + Object version = schema.get("version"); + if (version == null) { + throw new BizException("发布核验失败:FC 未返回版本号"); + } + q.setFormVersion(Integer.parseInt(String.valueOf(version))); + q.setSchemaSnapshot(toJson(schema)); + q.setStatus(Questionnaire.STATUS_COLLECTING); + q.setPublishTime(LocalDateTime.now()); + return questionnaireRepository.save(q); + } + + /** + * 结束投放:问卷 status=2(前端入口隐藏并提示已结束)。可选同步调 FC disable—— + * 注意 FC 停用是硬门禁(填报入口直接断),只作用于问卷侧标记不足以挡 FC 直链提交的场景。 + */ + @Transactional + public Questionnaire finish(Long schId, Long id) { + Questionnaire q = requireOwned(schId, id); + if (q.getStatus() != Questionnaire.STATUS_COLLECTING) { + throw new BizException("仅收集中问卷可结束投放"); + } + q.setStatus(Questionnaire.STATUS_FINISHED); + q.setFinishTime(LocalDateTime.now()); + return questionnaireRepository.save(q); + } + + /** 恢复收集(status=2 → 1;FC 侧是否仍发布由打开填写页时自行校验) */ + @Transactional + public Questionnaire reopen(Long schId, Long id) { + Questionnaire q = requireOwned(schId, id); + if (q.getStatus() != Questionnaire.STATUS_FINISHED) { + throw new BizException("仅已结束问卷可恢复收集"); + } + if (q.getFormVersion() == null) { + throw new BizException("问卷从未发布过,无法恢复"); + } + q.setStatus(Questionnaire.STATUS_COLLECTING); + q.setFinishTime(null); + return questionnaireRepository.save(q); + } + + /** 软删(对齐 FC 纪律:无物理删除入口;FC 侧表单不动) */ + @Transactional + public void softDelete(Long schId, Long id) { + Questionnaire q = requireOwned(schId, id); + questionnaireRepository.softDelete(q.getId()); + } + + /** 控制器直存(description 修改等浅改动) */ + @Transactional + public Questionnaire save(Questionnaire q) { + return questionnaireRepository.save(q); + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + throw new BizException("schema 快照序列化失败"); + } + } + + /** 供统计/明细读 schema 快照 */ + public Map schemaSnapshot(Questionnaire q) { + if (q.getSchemaSnapshot() == null || q.getSchemaSnapshot().isEmpty()) { + return java.util.Collections.emptyMap(); + } + try { + return objectMapper.readValue(q.getSchemaSnapshot(), new TypeReference>() { + }); + } catch (Exception e) { + log.warn("[question] schema 快照解析失败:{}", e.toString()); + return java.util.Collections.emptyMap(); + } + } +} diff --git a/src/main/java/com/yunhai/question/service/SchoolAdminGuard.java b/src/main/java/com/yunhai/question/service/SchoolAdminGuard.java new file mode 100644 index 0000000..c5c5409 --- /dev/null +++ b/src/main/java/com/yunhai/question/service/SchoolAdminGuard.java @@ -0,0 +1,76 @@ +package com.yunhai.question.service; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.question.common.BizException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 问卷管理面轻量守卫:超管 ∪ 校领导/管理员角色(roleId ∈ {2,7},UserBase 平台标准角色, + * 语义对齐 FC FormAccessPolicy.SCHOOL_LEADER_ROLES)。 + * + *

真正的创建权把关在 FC 侧 {@code POST /api/form/admin/create}(FormAdminGuard:超管∪校管理员 + * ∪管理员两表)——问卷前端第一步直调 FC,被 FC 拒则三步都走不到。本守卫只兜问卷侧写端点; + * 若联调发现"仅进 FC 管理员两表、无 2/7 角色"的账号被本守卫误拦,再评估接平台目录查询 + * (R1 已知缺口,记录于开发报告)。

+ */ +@Service +public class SchoolAdminGuard { + + /** UserBase UserTypeEnum.SUPER_ADMIN(同 FC FormAccessPolicy.SUPER_ADMIN_TYPE) */ + static final int SUPER_ADMIN_TYPE = 1; + /** 平台标准角色:校领导(2)/管理员(7)——对齐 FC SCHOOL_LEADER_ROLES */ + static final Set SCHOOL_LEADER_ROLES = new HashSet<>(Arrays.asList(2, 7)); + + /** 要求登录 + 校管理员/超管;返回会话学校域 */ + public Long requireSchool(BoaUser user) { + if (user == null || user.getId() == null) { + throw new BizException("未登录"); + } + if (!isSchoolAdmin(user)) { + throw new BizException("无问卷管理权:需要校管理员或超管"); + } + Long schId = sessionSchId(user); + if (schId == null) { + throw new BizException("当前会话无学校上下文"); + } + return schId; + } + + public boolean isSchoolAdmin(BoaUser user) { + if (user == null) { + return false; + } + if (user.getType() != null && user.getType() == SUPER_ADMIN_TYPE) { + return true; + } + for (Integer roleId : identityRoleIds(user)) { + if (roleId != null && SCHOOL_LEADER_ROLES.contains(roleId)) { + return true; + } + } + return false; + } + + static List identityRoleIds(BoaUser user) { + List ids = new ArrayList<>(); + if (user.getRoleIds() != null) { + ids.addAll(user.getRoleIds()); + } + Integer current = user.getCurrentRole() == null ? null : user.getCurrentRole().getRoleId(); + if (current != null && !ids.contains(current)) { + ids.add(current); + } + return ids; + } + + static Long sessionSchId(BoaUser user) { + Integer schId = user == null || user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId(); + return schId == null ? null : Long.valueOf(schId); + } +} diff --git a/src/main/java/com/yunhai/question/service/StatsService.java b/src/main/java/com/yunhai/question/service/StatsService.java new file mode 100644 index 0000000..0b33fb2 --- /dev/null +++ b/src/main/java/com/yunhai/question/service/StatsService.java @@ -0,0 +1,221 @@ +package com.yunhai.question.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.question.entity.Questionnaire; +import com.yunhai.question.repository.AnswerProjectionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 基础统计(任务书 §4.6,全部在投影上算): + *
    + *
  • 回收量(未删行数)与状态分布;
  • + *
  • 选项题(select/radio/checkbox/multiselect)按题分布——人数口径: + * 一份答卷对一题的一个选项最多计 1,多选按答卷去重后逐选项计数 + * (与 FC answer_index 的"选次口径"刻意可区分,响应里双口径同时给出);
  • + *
  • 文本/数值题只提供明细列表(R1 不做分桶/均值)。
  • + *
+ * 题干/选项 label 从发布时钉版的 schema 快照解析。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class StatsService { + + private static final Set OPTION_TYPES = new HashSet<>(Arrays.asList("select", "radio", "checkbox", "multiselect")); + + private final AnswerProjectionRepository answerProjectionRepository; + private final QuestionnaireService questionnaireService; + private final ObjectMapper objectMapper; + + public Map stats(Questionnaire q) { + Map out = new LinkedHashMap<>(); + long total = answerProjectionRepository.countByFormUuidAndIsDeletedFalse(q.getFormUuid()); + out.put("total", total); + + List> byStatus = new ArrayList<>(); + for (Object[] row : answerProjectionRepository.countGroupByStatus(q.getFormUuid())) { + Map s = new LinkedHashMap<>(); + s.put("status", row[0] == null ? "" : String.valueOf(row[0])); + s.put("count", row[1]); + byStatus.add(s); + } + out.put("byStatus", byStatus); + + // 选项题人数口径分布(一份答卷一选项最多计 1) + List> values = readValues(q.getFormUuid()); + List> fields = schemaFields(q); + List> byField = new ArrayList<>(); + List> textDetail = new ArrayList<>(); + for (Map field : fields) { + String key = String.valueOf(field.get("key")); + String type = String.valueOf(field.get("type")); + String label = String.valueOf(field.get("label")); + if (OPTION_TYPES.contains(type)) { + byField.add(optionFieldStats(key, label, field.get("options"), values)); + } else if (!"upload".equals(type) && !"image".equals(type)) { + // 文本/数值/日期/电话等:明细列表 + Map d = new LinkedHashMap<>(); + d.put("key", key); + d.put("label", label); + d.put("type", type); + d.put("items", textItems(key, values)); + textDetail.add(d); + } + } + out.put("byField", byField); + out.put("textDetail", textDetail); + return out; + } + + /** 单选题分布:items[{value,label,count,percent}] + distinctRespondents(有效答卷数) */ + private Map optionFieldStats(String key, String label, Object optionsDef, + List> values) { + Map out = new LinkedHashMap<>(); + out.put("key", key); + out.put("label", label); + out.put("caliber", "人数口径:一份答卷对一题的一个选项最多计 1(多选按答卷去重后逐选项计数)"); + + Map labelByValue = new LinkedHashMap<>(); + List> optionList = new ArrayList<>(); + if (optionsDef instanceof Iterable) { + for (Object o : (Iterable) optionsDef) { + if (o instanceof Map) { + String value = String.valueOf(((Map) o).get("value")); + String optLabel = String.valueOf(((Map) o).get("label")); + labelByValue.put(value, optLabel); + Map item = new LinkedHashMap<>(); + item.put("value", value); + item.put("label", optLabel); + optionList.add(item); + } + } + } + Map countByValue = new LinkedHashMap<>(); + long answeredSheets = 0; + for (Map sheet : values) { + Object raw = sheet.get(key); + Set picks = distinctPicks(raw); + if (picks.isEmpty()) { + continue; + } + answeredSheets++; + for (String v : picks) { + countByValue.merge(v, 1L, Long::sum); + } + } + for (Map item : optionList) { + long count = countByValue.getOrDefault(String.valueOf(item.get("value")), 0L); + item.put("count", count); + item.put("percent", answeredSheets == 0 ? 0 + : Math.round(count * 1000.0 / answeredSheets) / 10.0); + } + // schema 外的杂值(理论不该有:FC 值校验闭集)也带出,防统计黑洞 + for (Map.Entry e : countByValue.entrySet()) { + if (!labelByValue.containsKey(e.getKey())) { + Map item = new LinkedHashMap<>(); + item.put("value", e.getKey()); + item.put("label", e.getKey()); + item.put("count", e.getValue()); + item.put("percent", answeredSheets == 0 ? 0 + : Math.round(e.getValue() * 1000.0 / answeredSheets) / 10.0); + optionList.add(item); + } + } + out.put("answeredSheets", answeredSheets); + out.put("items", optionList); + return out; + } + + /** 一份答卷该题的去重选项集(值可能是标量或数组;非预期形态按空处理) */ + static Set distinctPicks(Object raw) { + Set picks = new java.util.LinkedHashSet<>(); + if (raw instanceof Iterable) { + for (Object v : (Iterable) raw) { + if (v != null) { + picks.add(String.valueOf(v)); + } + } + } else if (raw != null) { + picks.add(String.valueOf(raw)); + } + return picks; + } + + private List textItems(String key, List> values) { + List items = new ArrayList<>(); + for (Map sheet : values) { + Object raw = sheet.get(key); + if (raw == null) { + continue; + } + if (raw instanceof Iterable) { + List parts = new ArrayList<>(); + for (Object v : (Iterable) raw) { + if (v != null) { + parts.add(String.valueOf(v)); + } + } + if (!parts.isEmpty()) { + items.add(String.join("、", parts)); + } + } else { + items.add(String.valueOf(raw)); + } + } + return items; + } + + private List> readValues(String formUuid) { + List> out = new ArrayList<>(); + for (String json : answerProjectionRepository.findActiveValuesJson(formUuid)) { + try { + out.add(objectMapper.readValue(json, new TypeReference>() { + })); + } catch (Exception e) { + log.warn("[question] 投影 values 解析失败(跳过一行):{}", e.toString()); + } + } + return out; + } + + @SuppressWarnings("unchecked") + private List> schemaFields(Questionnaire q) { + Map snapshot = questionnaireService.schemaSnapshot(q); + Object groups = snapshot.get("groups"); + List> fields = new ArrayList<>(); + if (groups == null) { + return fields; + } + JsonNode nodes = objectMapper.valueToTree(groups); + if (!nodes.isArray()) { + return fields; + } + for (JsonNode group : nodes) { + JsonNode fs = group.path("fields"); + if (!fs.isArray()) { + continue; + } + for (JsonNode f : fs) { + Map field = new LinkedHashMap<>(); + field.put("key", f.path("key").asText(null)); + field.put("label", f.path("label").asText(null)); + field.put("type", f.path("type").asText(null)); + field.put("options", f.has("options") ? objectMapper.convertValue(f.get("options"), List.class) : null); + fields.add(field); + } + } + return fields; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..4313d24 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,88 @@ +server: + port: 8090 + # 本地无 context-path(同 flow-center dev);生产经 CONTEXT_PATH=/question 注入 + +spring: + application: + name: question-center + datasource: + url: jdbc:mysql://localhost:3306/ysch_question?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 自动建表(与 sql/question_r1.sql 对照校验);生产建议 none + 脚本 + 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 + +# ===== 基础平台 yh 配置(flow-center 同款三件套)===== +yh: + auth: + enabled: true + sso: + # 本地全链测试指向 local-debug CAS(8443);生产改回 sso.yunhaixingtong.com + server-url-prefix: http://localhost:8443 + client-host-url: http://localhost:8090 + allow-headers: all + allow-origins: all + # 用户面与管理面全在 /api/* 下;本应用无匿名面 + url-pattern: /api/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用)——appKey=100020(svc-hall=100018、flow-center=100019、 + # form-center=100021 顺延),已于 2026-10-22 在 UserBase 注册;FC 验签走 trust-registry + # 密钥链(trusted-apps→Redis→UserBase 兜底),FC 本地 trusted-apps 仅停摆兜底、可不配。 + # secret 须与 UserBase 登记值一致,必须带引号且含字母 + app-key: 100020 + app-secret: "question-center-dev-secret-100020" + # 本应用自身无 /oapi 提供面(不对外),enforce-patterns 留空即可 + trust-registry: true + trusted-apps: {} + open: + # 本地指向 local-debug 的 UserBase;生产由平台注入 YH_OPEN_BASE_HOST 覆盖 + base-host: http://localhost:8060 + base-path: "" + services: + # 问卷后端唯一的 FC 出站点(FormCenterClient)指向本地 FC 后端 + form-center: http://localhost:8086 + +# ===== 问卷应用专属配置 ===== +question: + # 答卷投影:定时全量扫描(每张收集中问卷按游标增量拉取)+ 手动触发 + projection: + # 定时间隔毫秒(任务书 §4.5:约 30s;要"关闭"定时就设超大值,如 86400000) + fixed-delay-ms: 30000 + # 单表单单次循环最多翻页数(防 FC 异常数据导致长循环) + max-pages-per-form: 200 + # 每页 limit(FC 上限 2000,缺省 500) + page-limit: 500 + +logging: + level: + com.yunhai.question: debug + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never + health: + redis: + # 引 data-redis 仅服务于平台密钥链;关健康指标防 Redis 停摆拉 DOWN /actuator/health + enabled: false diff --git a/src/test/java/com/yunhai/question/fc/FormCenterClientTest.java b/src/test/java/com/yunhai/question/fc/FormCenterClientTest.java new file mode 100644 index 0000000..2d7fc42 --- /dev/null +++ b/src/test/java/com/yunhai/question/fc/FormCenterClientTest.java @@ -0,0 +1,138 @@ +package com.yunhai.question.fc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.utils.exception.DException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * FormCenterClient 单元测试(mock YschOpenCall): + * ① 业务拒绝(DException=FC 信封 code!=0)不重试直接转 Reject; + * ② 网络/信封异常重试 2 次后转 Unavailable;③ answers 分页解析(rows/nextCursor/after 透传)。 + */ +class FormCenterClientTest { + + private YschOpenCall openCall; + private FormCenterClient client; + + @BeforeEach + void setUp() { + openCall = mock(YschOpenCall.class); + client = new FormCenterClient(openCall, new ObjectMapper()); + } + + @Test + void fetchSchemaReturnsData() { + Map data = new HashMap<>(); + data.put("formUuid", "U1"); + data.put("version", 2); + data.put("groups", Arrays.asList( + group("base", field("q1", "你的性别", "radio", + option("男", "m"), option("女", "f"))))); + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data); + + Map got = client.fetchSchema(27L, "U1"); + + assertEquals("U1", got.get("formUuid")); + assertEquals(1, client.parseFields(got).size()); + assertEquals("radio", client.parseFields(got).get(0).get("type")); + } + + @Test + void fcBusinessRejectIsNotRetried() { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenThrow(new DException("表单尚未发布:q-001")); + + FormCenterClient.FormCenterRejectException e = assertThrows( + FormCenterClient.FormCenterRejectException.class, + () -> client.fetchSchema(27L, "U1")); + assertTrue(e.getMessage().contains("表单尚未发布")); + verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + + @Test + void networkFailureRetriesTwiceThenUnavailable() { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenThrow(new RuntimeException("connect refused")); + + assertThrows(FormCenterClient.FormCenterUnavailableException.class, + () -> client.fetchSchema(27L, "U1")); + verify(openCall, times(FormCenterClient.MAX_ATTEMPTS)) + .get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + + @Test + @SuppressWarnings("unchecked") + void pullAnswersParsesRowsAndCursor() { + Map page = new HashMap<>(); + page.put("rows", Arrays.asList(row("a1"), row("a2"))); + page.put("nextCursor", "a2"); + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(page); + + FormCenterClient.AnswersPage got = client.pullAnswers(27L, "U1", null, 500); + + assertEquals(2, got.getRows().size()); + assertEquals("a2", got.getNextCursor()); + assertEquals("a1", got.getRows().get(0).get("answer_uuid")); + } + + @Test + void pullAnswersEndOfWindowHasNullCursor() { + Map page = new HashMap<>(); + page.put("rows", Arrays.asList()); + page.put("nextCursor", null); + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(page); + + FormCenterClient.AnswersPage got = client.pullAnswers(27L, "U1", "a2", 500); + + assertEquals(null, got.getNextCursor()); + assertTrue(got.getRows().isEmpty()); + } + + private static Map row(String uuid) { + Map r = new HashMap<>(); + r.put("answer_uuid", uuid); + r.put("is_deleted", 0); + return r; + } + + static Map group(String key, Map... fields) { + Map g = new HashMap<>(); + g.put("key", key); + g.put("fields", Arrays.asList(fields)); + return g; + } + + static Map field(String key, String label, String type, Map... options) { + Map f = new HashMap<>(); + f.put("key", key); + f.put("label", label); + f.put("type", type); + f.put("options", Arrays.asList(options)); + return f; + } + + static Map option(String label, String value) { + Map o = new HashMap<>(); + o.put("label", label); + o.put("value", value); + return o; + } +} diff --git a/src/test/java/com/yunhai/question/service/ProjectionServiceTest.java b/src/test/java/com/yunhai/question/service/ProjectionServiceTest.java new file mode 100644 index 0000000..e59f068 --- /dev/null +++ b/src/test/java/com/yunhai/question/service/ProjectionServiceTest.java @@ -0,0 +1,208 @@ +package com.yunhai.question.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.question.entity.PullCursor; +import com.yunhai.question.entity.Questionnaire; +import com.yunhai.question.fc.FormCenterClient; +import com.yunhai.question.repository.AnswerProjectionRepository; +import com.yunhai.question.repository.PullCursorRepository; +import com.yunhai.question.repository.QuestionnaireRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 投影服务单测(H2 真库 + mock FormCenterClient)——任务书点名的四件之二/三: + * 游标推进、投影幂等 upsert(重放同窗口不产生重复行、不重复累计 seen_count)、墓碑翻位。 + */ +@DataJpaTest +class ProjectionServiceTest { + + private static final Long SCH_ID = 990001L; + private static final String FORM_UUID = "f0123456789abcdef0123456789abcde"; + + @Autowired + private QuestionnaireRepository questionnaireRepository; + @Autowired + private PullCursorRepository pullCursorRepository; + @Autowired + private AnswerProjectionRepository answerProjectionRepository; + @Autowired + private PlatformTransactionManager txManager; + + private FormCenterClient fc; + private ProjectionService service; + + @BeforeEach + void setUp() { + fc = mock(FormCenterClient.class); + service = new ProjectionService(questionnaireRepository, pullCursorRepository, + answerProjectionRepository, fc, new ObjectMapper(), + new TransactionTemplate(txManager), 10, 100); + } + + private Questionnaire questionnaire(String formUuid) { + Questionnaire q = new Questionnaire(); + q.setSchId(SCH_ID); + q.setFormUuid(formUuid); + q.setFormCode("q-001"); + q.setTitleSnapshot("测试问卷"); + q.setStatus(Questionnaire.STATUS_COLLECTING); + q.setAnonymous(false); + return questionnaireRepository.save(q); + } + + /** FC 行(snake_case 闭集) */ + private static Map fcRow(String uuid, boolean deleted, Object... valuePairs) { + Map values = new LinkedHashMap<>(); + for (int i = 0; i + 1 < valuePairs.length; i += 2) { + values.put(String.valueOf(valuePairs[i]), valuePairs[i + 1]); + } + Map row = new HashMap<>(); + row.put("answer_uuid", uuid); + row.put("form_uuid", FORM_UUID); + row.put("submitter_id", 101); + row.put("subject_id", null); + row.put("status", "待处理"); + row.put("values", values); + row.put("create_time", "2026-10-21 10:00:00"); + row.put("update_time", "2026-10-21 10:00:00"); + row.put("is_deleted", deleted ? 1 : 0); + return row; + } + + /** after=afterArg 的页(rows 可空) */ + private void stubPage(String afterArg, String nextCursor, Map... rows) { + when(fc.pullAnswers(any(), anyString(), eq(afterArg), anyInt())) + .thenReturn(new FormCenterClient.AnswersPage(Arrays.asList(rows), nextCursor)); + } + + @Test + void advancesCursorAcrossPages() { + questionnaire(FORM_UUID); + stubPage(null, "a1", fcRow("a1", false, "q1", "m")); + stubPage("a1", "a2", fcRow("a2", false, "q1", "f")); + stubPage("a2", null); + + service.pullOnce(SCH_ID, FORM_UUID); + assertEquals("a2", pullCursorRepository.findById(FORM_UUID).get().getCursor()); + assertEquals(2, answerProjectionRepository.countByFormUuid(FORM_UUID)); + + // 第二轮:游标从 a2 起,只拉增量 + stubPage("a2", "a3", fcRow("a3", false, "q1", "m")); + stubPage("a3", null); + int fresh = service.pullOnce(SCH_ID, FORM_UUID); + assertEquals(1, fresh); + assertEquals("a3", pullCursorRepository.findById(FORM_UUID).get().getCursor()); + assertEquals(3, answerProjectionRepository.countByFormUuid(FORM_UUID)); + // 增量轮从 a2 起——验证游标确已推进(a2 之前的窗口未再请求) + verify(fc, times(1)).pullAnswers(any(), anyString(), eq((String) null), anyInt()); + verify(fc, times(1)).pullAnswers(any(), anyString(), eq("a1"), anyInt()); + verify(fc, times(2)).pullAnswers(any(), anyString(), eq("a2"), anyInt()); + } + + @Test + void replaySameWindowIsIdempotent() { + questionnaire(FORM_UUID); + stubPage(null, "a1", fcRow("a1", false, "q1", "m")); + stubPage("a1", null); + service.pullOnce(SCH_ID, FORM_UUID); + + // 模拟游标回拨(运维/异常后重放):从头再拉同窗口 + 新行 a2——a1 重放不算新行 + PullCursor rollback = pullCursorRepository.findById(FORM_UUID).orElseThrow(AssertionError::new); + rollback.setCursor(null); + pullCursorRepository.saveAndFlush(rollback); + stubPage(null, "a2", fcRow("a1", false, "q1", "m"), fcRow("a2", false, "q1", "f")); + stubPage("a2", null); + int fresh = service.pullOnce(SCH_ID, FORM_UUID); + + assertEquals(1, fresh); + assertEquals(2, answerProjectionRepository.countByFormUuid(FORM_UUID)); + PullCursor cursor = pullCursorRepository.findById(FORM_UUID).get(); + assertEquals(2L, cursor.getSeenCount(), "重放行不重复累计 seen_count"); + assertEquals("a2", cursor.getCursor()); + } + + @Test + void tombstoneFlipsIsDeletedWithoutDroppingRow() { + questionnaire(FORM_UUID); + stubPage(null, "a1", fcRow("a1", false, "q1", "m")); + stubPage("a1", null); + service.pullOnce(SCH_ID, FORM_UUID); + assertEquals(1, answerProjectionRepository.countByFormUuidAndIsDeletedFalse(FORM_UUID)); + + // FC 侧软删 → 墓碑行:同一 answer_uuid,is_deleted=1 + stubPage("a1", null, fcRow("a1", true, "q1", "m")); + service.pullOnce(SCH_ID, FORM_UUID); + + assertEquals(0, answerProjectionRepository.countByFormUuidAndIsDeletedFalse(FORM_UUID)); + assertEquals(1, answerProjectionRepository.countByFormUuid(FORM_UUID), "墓碑不删行"); + assertTrue(answerProjectionRepository.findByAnswerUuid("a1").getIsDeleted()); + assertEquals(1L, pullCursorRepository.findById(FORM_UUID).get().getSeenCount(), + "墓碑是已见行的翻位,不计新行"); + } + + @Test + void updatedRowKeepsSingleRow() { + questionnaire(FORM_UUID); + stubPage(null, "a1", fcRow("a1", false, "q1", "m")); + stubPage("a1", null); + service.pullOnce(SCH_ID, FORM_UUID); + + // 状态被 FC 侧更新后重放:同 uuid 更新而非插入 + Map updated = fcRow("a1", false, "q1", "f"); + updated.put("status", "已处理"); + stubPage("a1", null, updated); + service.pullOnce(SCH_ID, FORM_UUID); + + assertEquals(1, answerProjectionRepository.countByFormUuid(FORM_UUID)); + assertEquals("已处理", answerProjectionRepository.findByAnswerUuid("a1").getStatus()); + assertEquals(1L, pullCursorRepository.findById(FORM_UUID).get().getSeenCount()); + } + + @Test + void firstPullWithEmptyWindowInitializesCursor() { + questionnaire(FORM_UUID); + stubPage(null, null); + + int fresh = service.pullOnce(SCH_ID, FORM_UUID); + + assertEquals(0, fresh); + PullCursor cursor = pullCursorRepository.findById(FORM_UUID).get(); + assertNull(cursor.getCursor(), "空窗口不推进游标(null=从未有行)"); + assertEquals("OK", cursor.getLastStatus()); + } + + @Test + void reconciliationWarnsOnSeenCountDrift() { + questionnaire(FORM_UUID); + stubPage(null, "a1", fcRow("a1", false, "q1", "m")); + stubPage("a1", null); + service.pullOnce(SCH_ID, FORM_UUID); + + // 模拟投影表被外部删行:seen_count=1 但投影总量=0 → 漂移告警路径(不抛错) + answerProjectionRepository.deleteAll(); + service.checkReconciliation(FORM_UUID, pullCursorRepository.findById(FORM_UUID).get()); + assertEquals(0, answerProjectionRepository.countByFormUuid(FORM_UUID)); + } +} diff --git a/src/test/java/com/yunhai/question/service/QuestionnaireServiceTest.java b/src/test/java/com/yunhai/question/service/QuestionnaireServiceTest.java new file mode 100644 index 0000000..c0a15cb --- /dev/null +++ b/src/test/java/com/yunhai/question/service/QuestionnaireServiceTest.java @@ -0,0 +1,82 @@ +package com.yunhai.question.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.question.entity.Questionnaire; +import com.yunhai.question.fc.FormCenterClient; +import com.yunhai.question.repository.QuestionnaireRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; + +/** + * 绑定登记幂等单测(H2 真库——uk form_uuid 是数据库级约束,mock 测不出真实行为): + * 重复提交第三步不产生重复行;发布被 FC 拒(未发布)时拦截。 + */ +@DataJpaTest +class QuestionnaireServiceTest { + + private static final Long SCH_ID = 990001L; + + @Autowired + private QuestionnaireRepository questionnaireRepository; + + private FormCenterClient fc; + private QuestionnaireService service; + + @BeforeEach + void setUp() { + fc = mock(FormCenterClient.class); + service = new QuestionnaireService(questionnaireRepository, fc, new ObjectMapper()); + } + + @Test + void registerIsIdempotentOnFormUuid() { + Questionnaire first = service.register(SCH_ID, + "u0123456789abcdef0123456789abcde", "q-001", "食堂满意度调查", false, null); + Questionnaire second = service.register(SCH_ID, + "u0123456789abcdef0123456789abcde", "q-001", "食堂满意度调查", false, null); + + assertSame(first, second, "重复登记返回既有行"); + assertEquals(1, questionnaireRepository.count()); + assertEquals(Questionnaire.STATUS_DRAFT, second.getStatus()); + } + + @Test + void registerKeepsSchoolsIsolated() { + service.register(SCH_ID, "u0123456789abcdef0123456789abcde", "q-001", "本校问卷", false, null); + service.register(990002L, "u1123456789abcdef0123456789abcde", "q-002", "他校问卷", false, null); + + List mine = service.list(SCH_ID); + assertEquals(1, mine.size()); + assertEquals("u0123456789abcdef0123456789abcde", mine.get(0).getFormUuid()); + } + + @Test + void myFillableIntersectsByFormCode() { + Questionnaire draft1 = service.register(SCH_ID, "u0123456789abcdef0123456789abcde", "q-001", "问卷一", false, null); + Questionnaire draft2 = service.register(SCH_ID, "u1123456789abcdef0123456789abcde", "q-002", "问卷二", false, null); + draft1.setStatus(Questionnaire.STATUS_COLLECTING); + draft2.setStatus(Questionnaire.STATUS_COLLECTING); + questionnaireRepository.save(draft1); + questionnaireRepository.save(draft2); + + // FC fillable 只回了 q-002/q-999:交集后仅问卷二可见 + List got = service.myFillable(SCH_ID, Arrays.asList("q-002", "q-999")); + assertEquals(1, got.size()); + assertEquals("q-002", got.get(0).getFormCode()); + } + + @Test + void myFillableWithNoCodesReturnsEmpty() { + service.register(SCH_ID, "u0123456789abcdef0123456789abcde", "q-001", "问卷一", false, null); + assertEquals(0, service.myFillable(SCH_ID, java.util.Collections.emptyList()).size()); + } +} diff --git a/src/test/java/com/yunhai/question/service/SchoolAdminGuardTest.java b/src/test/java/com/yunhai/question/service/SchoolAdminGuardTest.java new file mode 100644 index 0000000..dc1f41a --- /dev/null +++ b/src/test/java/com/yunhai/question/service/SchoolAdminGuardTest.java @@ -0,0 +1,52 @@ +package com.yunhai.question.service; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.question.common.BizException; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** 校管理员守卫单测:超管(type=1)/校领导(2)/管理员(7) 放行,普通教师/学生/未登录拒绝 */ +class SchoolAdminGuardTest { + + private final SchoolAdminGuard guard = new SchoolAdminGuard(); + + private BoaUser user(Integer id, Integer type, Integer schId, Integer... roleIds) { + BoaUser user = mock(BoaUser.class, RETURNS_DEEP_STUBS); + when(user.getId()).thenReturn(id); + when(user.getType()).thenReturn(type); + when(user.getRoleIds()).thenReturn(roleIds.length == 0 ? null + : new java.util.ArrayList<>(Arrays.asList(roleIds))); + when(user.getCurrentRole().getSchId()).thenReturn(schId); + return user; + } + + @Test + void superAdminPasses() { + assertEquals(990001L, guard.requireSchool(user(1, 1, 990001))); + } + + @Test + void schoolLeaderAndAdminRolePass() { + assertEquals(990001L, guard.requireSchool(user(9, 2, 990001, 2))); + assertEquals(990001L, guard.requireSchool(user(10, 2, 990001, 1, 7))); + } + + @Test + void plainTeacherAndStudentRejected() { + assertThrows(BizException.class, () -> guard.requireSchool(user(11, 2, 990001, 1))); + assertThrows(BizException.class, () -> guard.requireSchool(user(12, 2, 990001, 3))); + } + + @Test + void anonymousAndNoSchoolContextRejected() { + assertThrows(BizException.class, () -> guard.requireSchool(user(null, null, null))); + assertThrows(BizException.class, () -> guard.requireSchool(user(13, 1, null, 2))); + } +} diff --git a/src/test/java/com/yunhai/question/service/StatsServiceTest.java b/src/test/java/com/yunhai/question/service/StatsServiceTest.java new file mode 100644 index 0000000..3e8bf52 --- /dev/null +++ b/src/test/java/com/yunhai/question/service/StatsServiceTest.java @@ -0,0 +1,175 @@ +package com.yunhai.question.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.question.entity.AnswerProjection; +import com.yunhai.question.entity.Questionnaire; +import com.yunhai.question.fc.FormCenterClient; +import com.yunhai.question.repository.AnswerProjectionRepository; +import com.yunhai.question.repository.QuestionnaireRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +/** + * 基础统计单测(H2 真库)——任务书点名的"人数口径统计": + * 一份答卷对一题的一个选项最多计 1,多选按答卷去重后逐选项计数(与选次口径可区分)。 + */ +@DataJpaTest +class StatsServiceTest { + + private static final String FORM_UUID = "s0123456789abcdef0123456789abcde"; + + @Autowired + private QuestionnaireRepository questionnaireRepository; + @Autowired + private AnswerProjectionRepository answerProjectionRepository; + + private StatsService statsService; + + @BeforeEach + void setUp() { + QuestionnaireService questionnaireService = new QuestionnaireService( + questionnaireRepository, mock(FormCenterClient.class), new ObjectMapper()); + statsService = new StatsService(answerProjectionRepository, questionnaireService, new ObjectMapper()); + } + + private Questionnaire published() { + Questionnaire q = new Questionnaire(); + q.setSchId(990001L); + q.setFormUuid(FORM_UUID); + q.setFormCode("q-001"); + q.setTitleSnapshot("统计测试卷"); + q.setStatus(Questionnaire.STATUS_COLLECTING); + q.setAnonymous(false); + q.setFormVersion(3); + // schema 快照:单选 q1(radio) + 多选 q2(checkbox) + 文本 q3(text) + q.setSchemaSnapshot("{\"formUuid\":\"" + FORM_UUID + "\",\"version\":3,\"groups\":[{\"key\":\"g1\",\"fields\":[" + + "{\"key\":\"q1\",\"label\":\"年级\",\"type\":\"radio\",\"required\":true,\"options\":[" + + "{\"label\":\"高一\",\"value\":\"g1v1\"},{\"label\":\"高二\",\"value\":\"g1v2\"}]}," + + "{\"key\":\"q2\",\"label\":\"喜欢的社团(多选)\",\"type\":\"checkbox\",\"options\":[" + + "{\"label\":\"篮球\",\"value\":\"shetuan_basketball\"},{\"label\":\"合唱\",\"value\":\"shetuan_chorus\"},{\"label\":\"编程\",\"value\":\"shetuan_coding\"}]}," + + "{\"key\":\"q3\",\"label\":\"建议\",\"type\":\"textarea\"}" + + "]}]}"); + return questionnaireRepository.save(q); + } + + private void answer(String uuid, Object q1, Object q2, String q3) { + AnswerProjection p = new AnswerProjection(); + p.setAnswerUuid(uuid); + p.setFormUuid(FORM_UUID); + Map values = new HashMap<>(); + values.put("q1", q1); + values.put("q2", q2); + values.put("q3", q3); + try { + p.setValuesJson(new ObjectMapper().writeValueAsString(values)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + p.setSubmitterId(101); + p.setStatus("待处理"); + p.setIsDeleted(false); + p.setFcCreateTime("2026-10-21 10:00:00"); + answerProjectionRepository.save(p); + } + + @Test + @SuppressWarnings("unchecked") + void multiSelectCountsDistinctRespondentsPerOption() { + published(); + // 卷A:q2 选 [篮球,合唱];卷B:q2 选 [合唱];卷C:q2 未答 + answer("a1", "g1v1", Arrays.asList("shetuan_basketball", "shetuan_chorus"), "很好"); + answer("a2", "g1v2", Arrays.asList("shetuan_chorus"), null); + answer("a3", "g1v1", null, null); + + Map stats = statsService.stats(questionnaireRepository + .findByFormUuidAndIsDeletedFalse(FORM_UUID).get()); + + assertEquals(3L, stats.get("total")); + + List> byField = (List>) stats.get("byField"); + Map q2 = byField.stream() + .filter(f -> "q2".equals(f.get("key"))).findFirst().get(); + + // 人数口径:篮球=1 人、合唱=2 人、编程=0 人;answeredSheets=2(a3 未答不计分母) + assertEquals(2L, q2.get("answeredSheets")); + Map counts = new HashMap<>(); + for (Map item : (List>) q2.get("items")) { + counts.put(String.valueOf(item.get("value")), ((Number) item.get("count")).longValue()); + } + assertEquals(1L, counts.get("shetuan_basketball"), "篮球:仅卷A计 1 人"); + assertEquals(2L, counts.get("shetuan_chorus"), "合唱:卷A+卷B 各计 1 人(人数口径,非选次——选次也是 2)"); + assertEquals(0L, counts.get("shetuan_coding")); + } + + @Test + @SuppressWarnings("unchecked") + void multiSelectSameOptionTwiceInOneSheetCountsOnce() { + published(); + // 一份答卷的 q2 里同选项出现两次(异常数据防御):人数口径仍计 1 + answer("a1", "g1v1", Arrays.asList("shetuan_basketball", "shetuan_basketball"), null); + + Map stats = statsService.stats(questionnaireRepository + .findByFormUuidAndIsDeletedFalse(FORM_UUID).get()); + List> byField = (List>) stats.get("byField"); + Map q2 = byField.stream() + .filter(f -> "q2".equals(f.get("key"))).findFirst().get(); + Map counts = new HashMap<>(); + for (Map item : (List>) q2.get("items")) { + counts.put(String.valueOf(item.get("value")), ((Number) item.get("count")).longValue()); + } + assertEquals(1L, counts.get("shetuan_basketball"), "同卷同选项去重后计 1"); + } + + @Test + @SuppressWarnings("unchecked") + void radioCountsByScalarAndTextDetailListed() { + published(); + answer("a1", "g1v1", null, "建议一"); + answer("a2", "g1v2", null, "建议二"); + + Map stats = statsService.stats(questionnaireRepository + .findByFormUuidAndIsDeletedFalse(FORM_UUID).get()); + List> byField = (List>) stats.get("byField"); + Map q1 = byField.stream() + .filter(f -> "q1".equals(f.get("key"))).findFirst().get(); + Map counts = new HashMap<>(); + for (Map item : (List>) q1.get("items")) { + counts.put(String.valueOf(item.get("value")), ((Number) item.get("count")).longValue()); + } + assertEquals(1L, counts.get("g1v1")); + assertEquals(1L, counts.get("g1v2")); + + List> textDetail = (List>) stats.get("textDetail"); + Map q3 = textDetail.stream() + .filter(f -> "q3".equals(f.get("key"))).findFirst().get(); + assertEquals(Arrays.asList("建议一", "建议二"), q3.get("items")); + } + + @Test + @SuppressWarnings("unchecked") + void tombstoneRowsAreExcluded() { + published(); + answer("a1", "g1v1", null, null); + AnswerProjection tombstone = new AnswerProjection(); + tombstone.setAnswerUuid("a2"); + tombstone.setFormUuid(FORM_UUID); + tombstone.setValuesJson("{\"q1\":\"g1v2\"}"); + tombstone.setSubmitterId(102); + tombstone.setIsDeleted(true); + answerProjectionRepository.save(tombstone); + + Map stats = statsService.stats(questionnaireRepository + .findByFormUuidAndIsDeletedFalse(FORM_UUID).get()); + assertEquals(1L, stats.get("total"), "墓碑行不进统计"); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..9bcbdbc --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,21 @@ +# 单测环境:H2 内存库(MySQL 兼容模式),无 MySQL/Redis/FC 外部依赖 +spring: + datasource: + url: jdbc:h2:mem:questiontest;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE + driver-class-name: org.h2.Driver + username: sa + password: "" + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + properties: + hibernate: + jdbc: + time_zone: Asia/Shanghai + +question: + projection: + fixed-delay-ms: 86400000 + max-pages-per-form: 10 + page-limit: 100