R1 先行批:question-center 后端(SB 2.7.18,23 单测全绿)+ 建表脚本 + 仓库说明
build / build-push (push) Successful in 51s
build / build-push (push) Successful in 51s
This commit is contained in:
+19
@@ -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
|
||||||
@@ -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 按此登记)。
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"), "墓碑行不进统计");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user