R1 先行批:question-center 后端(SB 2.7.18,23 单测全绿)+ 建表脚本 + 仓库说明
build / build-push (push) Successful in 51s

This commit is contained in:
chenxing
2026-10-22 10:13:00 +08:00
parent 380df08271
commit 907063a0b6
30 changed files with 2693 additions and 2 deletions
+19
View File
@@ -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
+47 -2
View File
@@ -1,3 +1,48 @@
# ysch-question-service
# question-center 问卷后端(ysch-question-service)
managed by projman
问卷应用 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 按此登记)。
+125
View File
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.yunhai</groupId>
<artifactId>question-center</artifactId>
<version>0.1</version>
<name>question-center</name>
<description>问卷应用(表单定义/答卷权威在 form-center,本应用管问卷业务:投放/投影回收/统计)</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 基础平台全家桶(聚合 starter):CAS 认证 + 应用间签名 + 统一响应 + OpenAPI 客户端(flow-center 同款) -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- 外观偏好同源读端点(yh-appearance /api/appearance,三态 proxy/local/off) -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-appearance-spring-boot-starter</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- 平台 auth starter 的 WeChatAuthAutoConfiguration 签名引用 StringRedisTemplate(optional,需自带) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- CAS 客户端(UserHome/svc-hall/flow-center 同款显式声明) -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 二维码仅前端生成,后端不需要 zxing;本工程无 aviator/流程引擎/回调依赖(模板裁剪) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 无外部依赖单测:投影幂等/游标推进/绑定 uk 用 H2 内存库直测 JPA 行为 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>question-center</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.yunhai.question.QuestionCenterApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
</project>
+66
View File
@@ -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 = '答卷投影行';
@@ -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);
}
}
@@ -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;
}
}
@@ -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<String, Object> biz(BizException e) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", e.getCode());
body.put("msg", e.getMessage());
body.put("data", null);
return body;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Map<String, Object> 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<String, Object> unknown(Exception e) {
log.error("[question] 未处理异常", e);
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", 6);
body.put("msg", "内部服务错误");
body.put("data", null);
return body;
}
}
@@ -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();
}
}
@@ -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<String, Object> 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<com.yunhai.question.entity.AnswerProjection> p = projectionService
.pageAnswers(q.getFormUuid(), PageRequest.of(Math.max(page - 1, 0), pageSize));
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("freshRows", fresh);
out.put("totalActive", projectionService.countActive(q.getFormUuid()));
return out;
}
/** 单份查看:答卷 values 原文 + schema 字段定义(前端按 key 对齐渲染) */
@GetMapping("/{id}/detail")
public Map<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("row", row);
out.put("fields", parseFields(q));
return out;
}
private Object parseFields(Questionnaire q) {
Map<String, Object> 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<Object>() {
});
} catch (Exception e) {
return java.util.Collections.emptyList();
}
}
}
@@ -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<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
if (authUserWithDept == null) {
return Collections.emptyList();
}
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
List<BoaModule> 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<String, Object> ping(BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException("未登录(ping 只验证登录态连通)");
}
Map<String, Object> 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<Map<String, Object>> toFrontendTree(List<BoaModule> modules) {
List<Map<String, Object>> result = new ArrayList<>();
for (BoaModule m : modules) {
Map<String, Object> 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();
}
}
@@ -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<Questionnaire> 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<String, Object> softDelete(BoaUser user, @PathVariable Long id) {
Long schId = adminGuard.requireSchool(user);
questionnaireService.softDelete(schId, id);
Map<String, Object> 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<Questionnaire> 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<String> codes;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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 出站客户端——<b>本应用唯一的 FC 出站点</b>(模板:flow-center FormCenterSchemaClient)。
* 只面向 HEAD 已提交的三个 OAPI 端点:/oapi/form/schema、/oapi/form/answer、/oapi/form/answers;
* 严禁引用 FC 工作区在途类(FormConsumerAccessService/AnswerDelivery* 等)。
*
* <p>异常语义(发布校验/投影拉取都依赖这组区分):</p>
* <ul>
* <li>{@link FormCenterRejectException}——FC 明确业务拒绝(信封 code!=0,如"表单尚未发布")。
* 平台 YschOpenCall 对 code!=0 抛 DException(message=FC 原文案),本客户端转译,<b>不重试</b>;</li>
* <li>{@link FormCenterUnavailableException}——网络/信封/形态异常,有限重试后抛出,可重试。</li>
* </ul>
*/
@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<Map<String, Object>> rows;
private final String nextCursor;
public AnswersPage(List<Map<String, Object>> rows, String nextCursor) {
this.rows = rows;
this.nextCursor = nextCursor;
}
public List<Map<String, Object>> 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<String, Object> fetchSchema(Long schId, String formUuid) {
Map<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(schId));
query.put("uuid", formUuid);
Map<String, Object> 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<String, String> 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<String, Object> data = call(ANSWERS_PATH, query);
if (data == null) {
throw new FormCenterUnavailableException("answers 返回空 data(uuid=" + formUuid + ")", null);
}
List<Map<String, Object>> rows = new ArrayList<>();
Object rawRows = data.get("rows");
if (rawRows instanceof Iterable) {
for (Object row : (Iterable<?>) rawRows) {
if (row instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> m = (Map<String, Object>) row;
rows.add(m);
}
}
}
Object next = data.get("nextCursor");
return new AnswersPage(rows, next == null ? null : String.valueOf(next));
}
/** 带有限重试的单次 GET:DException(FC 业务拒绝)直接转译不重试 */
private Map<String, Object> call(String path, Map<String, String> 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<Map<String, Object>> parseFields(Map<String, Object> schemaData) {
List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> opts = new ArrayList<>();
for (JsonNode opt : options) {
Map<String, Object> 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;
}
}
@@ -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<AnswerProjection, Long> {
/** 幂等 upsert 的查半边:按 uk 找既有行 */
AnswerProjection findByAnswerUuid(String answerUuid);
Page<AnswerProjection> 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<String> 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<Object[]> countGroupByStatus(@Param("formUuid") String formUuid);
}
@@ -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<PullCursor, String> {
}
@@ -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<Questionnaire, Long> {
Optional<Questionnaire> findByFormUuidAndIsDeletedFalse(String formUuid);
Optional<Questionnaire> findByIdAndIsDeletedFalse(Long id);
List<Questionnaire> findBySchIdAndIsDeletedFalseOrderByIdDesc(Long schId);
/** 我的问卷交集:FC fillable 返回 code 列表 ∩ 本校收集中问卷 */
List<Questionnaire> findBySchIdAndStatusAndIsDeletedFalseAndFormCodeIn(
Long schId, Integer status, Collection<String> formCodes);
long countBySchIdAndIsDeletedFalse(Long schId);
/** 投影定时扫描:全部收集中问卷(不限学校——游标按 form_uuid 全局隔离) */
List<Questionnaire> 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);
}
@@ -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,不解析不校验内容。
*
* <p>事务边界=每拉取页(rows 批次 + 游标推进同事务):中断最多重放一页,幂等 upsert
* 保证不产生重复行——这是"游标重放幂等"设计的关键。</p>
*/
@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<Questionnaire> 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<Map<String, Object>> 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<Map<String, Object>> rows, String nextCursor, PullCursor cursor) {
int fresh = 0;
for (Map<String, Object> 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<com.yunhai.question.entity.AnswerProjection> 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;
}
}
@@ -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<Questionnaire> list(Long schId) {
return questionnaireRepository.findBySchIdAndIsDeletedFalseOrderByIdDesc(schId);
}
/** 我的问卷:FC fillable(前端已按 appCode=question 过滤)返回 code 列表 ∩ 本校收集中问卷 */
public List<Questionnaire> myFillable(Long schId, Collection<String> 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<String, Object> 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<String, Object> 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<String, Object> schemaSnapshot(Questionnaire q) {
if (q.getSchemaSnapshot() == null || q.getSchemaSnapshot().isEmpty()) {
return java.util.Collections.emptyMap();
}
try {
return objectMapper.readValue(q.getSchemaSnapshot(), new TypeReference<Map<String, Object>>() {
});
} catch (Exception e) {
log.warn("[question] schema 快照解析失败:{}", e.toString());
return java.util.Collections.emptyMap();
}
}
}
@@ -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)。
*
* <p>真正的创建权把关在 FC 侧 {@code POST /api/form/admin/create}(FormAdminGuard:超管∪校管理员
* ∪管理员两表)——问卷前端第一步直调 FC,被 FC 拒则三步都走不到。本守卫只兜问卷侧写端点;
* 若联调发现"仅进 FC 管理员两表、无 2/7 角色"的账号被本守卫误拦,再评估接平台目录查询
* (R1 已知缺口,记录于开发报告)。</p>
*/
@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<Integer> 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<Integer> identityRoleIds(BoaUser user) {
List<Integer> 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);
}
}
@@ -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,全部在投影上算):
* <ul>
* <li>回收量(未删行数)与状态分布;</li>
* <li>选项题(select/radio/checkbox/multiselect)按题分布——<b>人数口径</b>:
* 一份答卷对一题的一个选项最多计 1,多选按答卷去重后逐选项计数
* (与 FC answer_index 的"选次口径"刻意可区分,响应里双口径同时给出);</li>
* <li>文本/数值题只提供明细列表(R1 不做分桶/均值)。</li>
* </ul>
* 题干/选项 label 从发布时钉版的 schema 快照解析。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StatsService {
private static final Set<String> OPTION_TYPES = new HashSet<>(Arrays.asList("select", "radio", "checkbox", "multiselect"));
private final AnswerProjectionRepository answerProjectionRepository;
private final QuestionnaireService questionnaireService;
private final ObjectMapper objectMapper;
public Map<String, Object> stats(Questionnaire q) {
Map<String, Object> out = new LinkedHashMap<>();
long total = answerProjectionRepository.countByFormUuidAndIsDeletedFalse(q.getFormUuid());
out.put("total", total);
List<Map<String, Object>> byStatus = new ArrayList<>();
for (Object[] row : answerProjectionRepository.countGroupByStatus(q.getFormUuid())) {
Map<String, Object> 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<Map<String, Object>> values = readValues(q.getFormUuid());
List<Map<String, Object>> fields = schemaFields(q);
List<Map<String, Object>> byField = new ArrayList<>();
List<Map<String, Object>> textDetail = new ArrayList<>();
for (Map<String, Object> 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<String, Object> 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<String, Object> optionFieldStats(String key, String label, Object optionsDef,
List<Map<String, Object>> values) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("key", key);
out.put("label", label);
out.put("caliber", "人数口径:一份答卷对一题的一个选项最多计 1(多选按答卷去重后逐选项计数)");
Map<String, String> labelByValue = new LinkedHashMap<>();
List<Map<String, Object>> 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<String, Object> item = new LinkedHashMap<>();
item.put("value", value);
item.put("label", optLabel);
optionList.add(item);
}
}
}
Map<String, Long> countByValue = new LinkedHashMap<>();
long answeredSheets = 0;
for (Map<String, Object> sheet : values) {
Object raw = sheet.get(key);
Set<String> picks = distinctPicks(raw);
if (picks.isEmpty()) {
continue;
}
answeredSheets++;
for (String v : picks) {
countByValue.merge(v, 1L, Long::sum);
}
}
for (Map<String, Object> 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<String, Long> e : countByValue.entrySet()) {
if (!labelByValue.containsKey(e.getKey())) {
Map<String, Object> 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<String> distinctPicks(Object raw) {
Set<String> 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<String> textItems(String key, List<Map<String, Object>> values) {
List<String> items = new ArrayList<>();
for (Map<String, Object> sheet : values) {
Object raw = sheet.get(key);
if (raw == null) {
continue;
}
if (raw instanceof Iterable) {
List<String> 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<Map<String, Object>> readValues(String formUuid) {
List<Map<String, Object>> out = new ArrayList<>();
for (String json : answerProjectionRepository.findActiveValuesJson(formUuid)) {
try {
out.add(objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {
}));
} catch (Exception e) {
log.warn("[question] 投影 values 解析失败(跳过一行):{}", e.toString());
}
}
return out;
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> schemaFields(Questionnaire q) {
Map<String, Object> snapshot = questionnaireService.schemaSnapshot(q);
Object groups = snapshot.get("groups");
List<Map<String, Object>> 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<String, Object> 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;
}
}
+88
View File
@@ -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
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> row(String uuid) {
Map<String, Object> r = new HashMap<>();
r.put("answer_uuid", uuid);
r.put("is_deleted", 0);
return r;
}
static Map<String, Object> group(String key, Map<String, Object>... fields) {
Map<String, Object> g = new HashMap<>();
g.put("key", key);
g.put("fields", Arrays.asList(fields));
return g;
}
static Map<String, Object> field(String key, String label, String type, Map<String, Object>... options) {
Map<String, Object> 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<String, Object> option(String label, String value) {
Map<String, Object> o = new HashMap<>();
o.put("label", label);
o.put("value", value);
return o;
}
}
@@ -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<String, Object> fcRow(String uuid, boolean deleted, Object... valuePairs) {
Map<String, Object> values = new LinkedHashMap<>();
for (int i = 0; i + 1 < valuePairs.length; i += 2) {
values.put(String.valueOf(valuePairs[i]), valuePairs[i + 1]);
}
Map<String, Object> 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<String, Object>... 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<String, Object> 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));
}
}
@@ -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<Questionnaire> 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<Questionnaire> 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.<String>emptyList()).size());
}
}
@@ -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)));
}
}
@@ -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<String, Object> 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<String, Object> stats = statsService.stats(questionnaireRepository
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
assertEquals(3L, stats.get("total"));
List<Map<String, Object>> byField = (List<Map<String, Object>>) stats.get("byField");
Map<String, Object> q2 = byField.stream()
.filter(f -> "q2".equals(f.get("key"))).findFirst().get();
// 人数口径:篮球=1 人、合唱=2 人、编程=0 人;answeredSheets=2(a3 未答不计分母)
assertEquals(2L, q2.get("answeredSheets"));
Map<String, Long> counts = new HashMap<>();
for (Map<String, Object> item : (List<Map<String, Object>>) 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<String, Object> stats = statsService.stats(questionnaireRepository
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
List<Map<String, Object>> byField = (List<Map<String, Object>>) stats.get("byField");
Map<String, Object> q2 = byField.stream()
.filter(f -> "q2".equals(f.get("key"))).findFirst().get();
Map<String, Long> counts = new HashMap<>();
for (Map<String, Object> item : (List<Map<String, Object>>) 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<String, Object> stats = statsService.stats(questionnaireRepository
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
List<Map<String, Object>> byField = (List<Map<String, Object>>) stats.get("byField");
Map<String, Object> q1 = byField.stream()
.filter(f -> "q1".equals(f.get("key"))).findFirst().get();
Map<String, Long> counts = new HashMap<>();
for (Map<String, Object> item : (List<Map<String, Object>>) 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<Map<String, Object>> textDetail = (List<Map<String, Object>>) stats.get("textDetail");
Map<String, Object> 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<String, Object> stats = statsService.stats(questionnaireRepository
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
assertEquals(1L, stats.get("total"), "墓碑行不进统计");
}
}
+21
View File
@@ -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