feat(education): complete Flyway migration and atomic submit

This commit is contained in:
2026-07-30 12:06:55 +08:00
parent ce02f8acb4
commit 79a5799502
228 changed files with 32551 additions and 1376 deletions

View File

@@ -31,7 +31,7 @@ public class EducationProperties {
/**
* 题库目录数据源模式。
* 默认 SCALAR_READJAVA_READ 为预留模式(当前不支持)
* 默认 SCALAR_READJAVA_READ 使用本地 PostgreSQL 目录数据源
*/
private CatalogProviderMode catalogMode = CatalogProviderMode.SCALAR_READ;
@@ -51,18 +51,33 @@ public class EducationProperties {
private List<Long> pilotTenantIds = List.of();
/**
* 精确主机名到租户名的映射,用于 DNS 与 system_tenant.websites 不一致的场景
* key = 标准化后的主机名小写、无端口value = 租户名。
* 示例:{ "staging.school.com": "demo-school" }
*
* 映射优先级高于 system_tenant.websites 字段匹配。
* 租户识别配置
*/
private TenantResolution tenantResolution = new TenantResolution();
@Data
public static class TenantResolution {
/**
* 是否允许开发工作站和自动化测试使用本地主机名识别租户。
*/
private boolean localDevelopmentEnabled = false;
}
/**
* 本地开发主机名到公开租户句柄的精确映射,仅在 local-development-enabled=true 时使用。
* key = 标准化后的本地主机名小写、无端口value = 公开租户句柄。
* 生产域名始终由 System 的 canonical website 查找负责。
*/
private Map<String, String> hostnameTenantMap = new HashMap<>();
/**
* 学生端全局开放的登录方式。它描述当前部署启用的 Member 登录入口,
* 不是租户级 OAuth 提供方探测结果。
* 学生端登录方式由 Member 认证模块负责;该配置仅为旧配置绑定兼容,不参与公开租户识别响应。
*
* @deprecated 请在 Member 认证入口配置登录方式
*/
@Deprecated
private List<String> loginMethods = List.of("PASSWORD", "SMS");
}

View File

@@ -2,7 +2,9 @@ package cn.iocoder.yudao.module.education.controller.app;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.app.vo.EducationContextRespVO;
@@ -42,11 +44,12 @@ public class EducationContextController {
@Operation(summary = "获取当前教育上下文",
description = "根据当前认证用户和租户上下文返回教育业务信息。需要登录态。")
public CommonResult<EducationContextRespVO> getContext() {
// 1. 从安全上下文获取用户编号(不信任请求参数)
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (userId == null) {
// 1. 从安全上下文获取完整主体,只接受 Member 学生主体
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null || !UserTypeEnum.MEMBER.getValue().equals(loginUser.getUserType())) {
throw exception(UNAUTHORIZED);
}
Long userId = loginUser.getId();
// 2. 获取当前租户并验证状态(由 TenantSecurityWebFilter 前置完成,此处二次验证)
Long tenantId = TenantContextHolder.getRequiredTenantId();

View File

@@ -6,6 +6,9 @@ import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -37,9 +40,33 @@ public class CatalogController {
@Resource
private CatalogService catalogService;
@Resource
private QuestionCatalogService questionCatalogService;
@Resource
private EducationAccessService educationAccessService;
@GetMapping("/schools")
@Operation(summary = "查询院校目录")
public CommonResult<List<CatalogSchoolRespVO>> listSchools(
@RequestParam(required = false) String regionId,
@RequestParam(required = false) String schoolId) {
assertAuthenticated();
return success(catalogService.listSchools(regionId, schoolId));
}
@GetMapping("/majors")
@Operation(summary = "查询专业目录")
public CommonResult<List<CatalogMajorRespVO>> listMajors(
@RequestParam(required = false) String regionId,
@RequestParam(required = false) String schoolId,
@RequestParam(required = false) String majorId,
@RequestParam(required = false) String moduleId,
@RequestParam(required = false) String type) {
assertAuthenticated();
return success(catalogService.listMajors(regionId, schoolId, majorId, moduleId, type));
}
@GetMapping("/regions")
@Operation(summary = "查询可用地区列表")
public CommonResult<List<CatalogRegionRespVO>> listRegions() {
@@ -82,10 +109,9 @@ public class CatalogController {
@Operation(summary = "查询内容入口")
public CommonResult<List<CatalogContentEntryRespVO>> listContentEntries(
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
@Parameter(description = "内容入口类型") @RequestParam(required = false) String entryType,
@Parameter(description = "是否包含隐藏入口") @RequestParam(defaultValue = "false") boolean includeHidden) {
@Parameter(description = "内容入口类型") @RequestParam(required = false) String entryType) {
assertAuthenticated();
return success(catalogService.listContentEntries(regionId, entryType, includeHidden));
return success(catalogService.listContentEntries(regionId, entryType, false));
}
@GetMapping("/content-nodes")
@@ -94,10 +120,9 @@ public class CatalogController {
@Parameter(description = "内容入口 ID", required = true) @RequestParam String entryId,
@Parameter(description = "父节点 ID传 root 表示根节点)") @RequestParam(required = false) String parentId,
@Parameter(description = "查询模式children/flat") @RequestParam(defaultValue = "children") String mode,
@Parameter(description = "是否包含停用节点") @RequestParam(defaultValue = "false") boolean includeInactive,
@Parameter(description = "节点标记类型") @RequestParam(required = false) String markerType) {
assertAuthenticated();
return success(catalogService.listContentNodes(entryId, parentId, mode, includeInactive, markerType));
return success(catalogService.listContentNodes(entryId, parentId, mode, false, markerType));
}
@GetMapping("/question-collections")
@@ -112,6 +137,18 @@ public class CatalogController {
return success(catalogService.listQuestionCollections(regionId, entryId, nodeId, collectionType, limit));
}
@GetMapping("/question-collections/{id}/questions")
@Operation(summary = "查询题集内题目", description = "仅返回学生可见字段,不包含答案、解析或正确性标记。")
public CommonResult<PageResult<SafeQuestionRespVO>> listCollectionQuestions(
@Parameter(description = "题集 ID", required = true) @PathVariable String id,
@Parameter(description = "题型") @RequestParam(required = false) String type,
@Parameter(description = "难度") @RequestParam(required = false) String difficulty,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNo,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "20") Integer pageSize) {
assertAuthenticated();
return success(questionCatalogService.listCollectionQuestions(id, type, difficulty, pageNo, pageSize));
}
/**
* 校验当前学生的租户上下文和题库读取灰度开关。
*/

View File

@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "专业目录项")
public class CatalogMajorRespVO {
private String id;
private String name;
private String regionId;
private String schoolId;
private Double order;
private Boolean active;
}

View File

@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "院校目录项")
public class CatalogSchoolRespVO {
private String id;
private String name;
private String regionId;
private String moduleId;
private Double order;
private Boolean active;
}

View File

@@ -1,9 +1,11 @@
package cn.iocoder.yudao.module.education.controller.app.practice;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerRespVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
@@ -31,7 +33,7 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
* 练习会话 Controller — 学生端已认证接口。
*
* <p>所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
* <p>会话和答题数据写入 MySQL从不经过 Scalar。</p>
* <p>会话和答题数据写入 PostgreSQL从不经过 Scalar。</p>
*
* @author 恭学教育
*/
@@ -122,24 +124,21 @@ public class PracticeSessionController {
// ========== security helpers ==========
private Long getUserId() {
private LoginUser getStudentPrincipal() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
if (loginUser == null || !UserTypeEnum.MEMBER.getValue().equals(loginUser.getUserType())) {
throw exception(UNAUTHORIZED);
}
return loginUser.getId();
return loginUser;
}
private Long getUserId() {
return getStudentPrincipal().getId();
}
private Long getTenantId() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
throw exception(UNAUTHORIZED);
}
Long tenantId = loginUser.getTenantId();
if (tenantId == null) {
throw exception(UNAUTHORIZED);
}
return tenantId;
getStudentPrincipal();
return TenantContextHolder.getRequiredTenantId();
}
}

View File

@@ -18,23 +18,27 @@ import jakarta.annotation.security.PermitAll;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.net.IDN;
import java.net.URI;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_TENANT_LOCATOR_CONFLICT;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_TENANT_NOT_ACTIVE;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_TENANT_RESOLVE_FAILED;
/**
* 教育租户识别 Controller — 学生端入口
* 教育租户识别 Controller — 学生端入口
*
* <p>用于在登录前通过主机名或租户名解析当前租户,返回引导信息。
* 该端点无需认证(@PermitAll忽略租户上下文@TenantIgnore。</p>
*
* @author 恭学教育
* <p>请求中的浏览器上下文和租户句柄都是可伪造的租户定位声明,不构成身份认证。</p>
*/
@Tag(name = "用户 APP - 教育租户识别")
@RestController
@@ -43,139 +47,258 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class EducationTenantController {
private static final Pattern TENANT_HANDLE_PATTERN = Pattern.compile("^[A-Za-z0-9._-]{2,64}$");
private static final Pattern DNS_LABEL_PATTERN = Pattern.compile("^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$");
private static final Pattern IPV4_PATTERN = Pattern.compile("^(?:\\d{1,3}\\.){3}\\d{1,3}$");
@Resource
private TenantCommonApi tenantCommonApi;
@Resource
private EducationProperties educationProperties;
@GetMapping("/resolve")
@PermitAll
@TenantIgnore
@Operation(summary = "解析租户",
description = "通过主机名或租户名解析租户信息,返回登录引导所需的基础字段。" +
"hostname 和 tenantName 至少提供一个。")
@Operation(summary = "解析租户", description = "根据可伪造的浏览器上下文声明或公开租户句柄返回最小登录路由信息。")
@Parameters({
@Parameter(name = "hostname", description = "标准化主机名(小写、无端口、无协议)", example = "school.example.com"),
@Parameter(name = "tenantName", description = "租户名", example = "demo-school")
@Parameter(name = "Origin", description = "优先使用的 HTTP(S) 浏览器上下文声明"),
@Parameter(name = "Referer", description = "Origin 缺失时使用的 HTTP(S) 浏览器上下文声明"),
@Parameter(name = "hostname", description = "用于确认浏览器上下文的主机名;本地开发模式下可作为本地主机声明"),
@Parameter(name = "tenantHandle", description = "区分大小写的公开租户句柄")
})
public CommonResult<EducationTenantRespVO> resolve(
@RequestHeader(value = "Origin", required = false) String origin,
@RequestHeader(value = "Referer", required = false) String referer,
@RequestParam(value = "hostname", required = false) String hostname,
@RequestParam(value = "tenantHandle", required = false) String tenantHandle,
@RequestParam(value = "tenantName", required = false) String tenantName) {
// 1. 校验输入:至少提供一个
if (StrUtil.isBlank(hostname) && StrUtil.isBlank(tenantName)) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 和 tenantName 不能同时为空");
if (tenantName != null) {
throw invalidLocator();
}
TenantRespDTO byHandle = resolveByHandle(tenantHandle);
String browserHost = resolveBrowserHost(origin, referer);
if (browserHost != null && isLocalHost(browserHost)
&& !educationProperties.getTenantResolution().isLocalDevelopmentEnabled()) {
throw invalidLocator();
}
String requestedHost = normalizeHost(hostname);
if (browserHost != null && requestedHost != null && !browserHost.equals(requestedHost)) {
throw exception(EDUCATION_TENANT_LOCATOR_CONFLICT);
}
// 2. 主机名标准化
String normalizedHostname = normalizeHostname(hostname);
// 3. 解析租户并验证一致性
TenantRespDTO tenant = resolveTenantAndEnsureConsistency(normalizedHostname, tenantName);
// 4. 验证租户状态
if (tenant == null) {
throw exception(EDUCATION_TENANT_NOT_FOUND);
TenantRespDTO byDomain = null;
String domainClaim = browserHost;
if (domainClaim == null && requestedHost != null
&& educationProperties.getTenantResolution().isLocalDevelopmentEnabled()
&& isLocalHost(requestedHost)) {
domainClaim = requestedHost;
}
if (CommonStatusEnum.isDisable(tenant.getStatus())) {
throw exception(EDUCATION_TENANT_DISABLED);
if (domainClaim != null && !(byHandle != null && isLocalHost(domainClaim))) {
byDomain = resolveByHostname(domainClaim);
}
if (DateUtils.isExpired(tenant.getExpireTime())) {
if (byHandle != null && byDomain != null && !byHandle.getId().equals(byDomain.getId())) {
throw exception(EDUCATION_TENANT_LOCATOR_CONFLICT);
}
if (StrUtil.isNotBlank(tenantHandle) && byHandle == null) {
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
}
// 5. 构建响应
EducationTenantRespVO resp = EducationTenantRespVO.builder()
.tenantId(tenant.getId())
.tenantName(tenant.getName())
.displayName(tenant.getName())
.status("ACTIVE")
.loginMethods(new ArrayList<>(educationProperties.getLoginMethods()))
.build();
return success(resp);
if (browserHost != null && byDomain == null) {
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
}
if (browserHost == null && byHandle == null && byDomain == null) {
throw invalidLocator();
}
return success(toResponse(requireAvailable(byHandle != null ? byHandle : byDomain)));
}
/**
* 按优先级解析租户:
* 1. 如果提供了 tenantName直接通过租户名查询
* 2. 如果提供了 hostname
* a. 先查 EducationProperties.hostnameTenantMap 配置映射
* b. 再通过 system_tenant.websites 字段匹配
*
* @param hostname 标准化后的主机名,可能为 null
* @param tenantName 租户名,可能为 null
* @return 租户 DTO未找到返回 null
*/
private TenantRespDTO resolveTenantAndEnsureConsistency(String hostname, String tenantName) {
TenantRespDTO byName = StrUtil.isNotBlank(tenantName)
? tenantCommonApi.getTenantByName(tenantName.trim()) : null;
TenantRespDTO byHostname = StrUtil.isNotBlank(hostname) ? resolveTenantByHostname(hostname) : null;
if (byName != null && byHostname != null && !byName.getId().equals(byHostname.getId())) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 与 tenantName 指向不同租户");
}
if (StrUtil.isNotBlank(tenantName) && byName == null) {
private TenantRespDTO resolveByHandle(String tenantHandle) {
if (StrUtil.isBlank(tenantHandle)) {
return null;
}
return byName != null ? byName : byHostname;
}
private TenantRespDTO resolveTenantByHostname(String hostname) {
String mappedTenantName = educationProperties.getHostnameTenantMap().entrySet().stream()
.filter(entry -> hostname.equals(normalizeHostname(entry.getKey())))
.map(entry -> entry.getValue())
.findFirst()
.orElse(null);
if (StrUtil.isNotBlank(mappedTenantName)) {
return tenantCommonApi.getTenantByName(mappedTenantName);
if (!TENANT_HANDLE_PATTERN.matcher(tenantHandle).matches()) {
throw invalidLocator();
}
return tenantCommonApi.getTenantByWebsite(hostname);
return tenantCommonApi.getTenantByName(tenantHandle);
}
/**
* 主机名标准化:转为小写并拒绝协议、路径和非法端口。
* 端口会被保留,因为 system_tenant.websites 使用精确 authority 匹配。
*
* @param hostname 原始主机名
* @return 标准化后的主机名,输入为空时返回 null
* @throws cn.iocoder.yudao.framework.common.exception.ServiceException 格式不合法时
*/
static String normalizeHostname(String hostname) {
if (StrUtil.isBlank(hostname)) {
private String resolveBrowserHost(String origin, String referer) {
if (origin != null) {
return parseHttpHeaderHost(origin, false);
}
if (referer != null) {
return parseHttpHeaderHost(referer, true);
}
return null;
}
private String parseHttpHeaderHost(String value, boolean allowPath) {
try {
if (StrUtil.isBlank(value) || value.contains(",")) {
throw invalidLocator();
}
URI uri = URI.create(value.trim());
if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null || uri.getUserInfo() != null || uri.getFragment() != null
|| (!allowPath && !(StrUtil.isEmpty(uri.getPath()) || "/".equals(uri.getPath())))
|| (!allowPath && uri.getQuery() != null)) {
throw invalidLocator();
}
return normalizeHost(uri.getRawAuthority());
} catch (RuntimeException ex) {
if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) {
throw serviceException;
}
throw invalidLocator();
}
}
private TenantRespDTO resolveByHostname(String hostname) {
if (!isLocalHost(hostname)) {
return tenantCommonApi.getTenantByWebsite(hostname);
}
String mappedTenantHandle = educationProperties.getHostnameTenantMap().entrySet().stream()
.filter(entry -> hostname.equals(normalizeConfiguredHost(entry.getKey())))
.map(Map.Entry::getValue)
.findFirst().orElse(null);
return StrUtil.isNotBlank(mappedTenantHandle) ? tenantCommonApi.getTenantByName(mappedTenantHandle) : null;
}
private String normalizeConfiguredHost(String host) {
try {
return normalizeHost(host);
} catch (RuntimeException ignored) {
return null;
}
String normalized = hostname.trim();
// 拒绝含协议的输入(如 http://example.com
if (normalized.contains("://")) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED,
"hostname 不应包含协议,收到: " + hostname);
}
// 拒绝含路径的输入
if (normalized.contains("/")) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED,
"hostname 不应包含路径,收到: " + hostname);
}
// 校验端口。保留合法端口,确保与 system_tenant.websites 的精确值一致。
int colonIdx = normalized.lastIndexOf(':');
if (colonIdx > 0 && !normalized.startsWith("[")) {
String afterColon = normalized.substring(colonIdx + 1);
if (afterColon.isEmpty() || !afterColon.chars().allMatch(Character::isDigit)) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 端口不合法");
}
int port;
try {
port = Integer.parseInt(afterColon);
} catch (NumberFormatException ex) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 端口不合法");
}
if (port < 1 || port > 65535) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 端口不合法");
}
}
return normalized.toLowerCase();
}
private TenantRespDTO requireAvailable(TenantRespDTO tenant) {
if (tenant == null || !CommonStatusEnum.ENABLE.getStatus().equals(tenant.getStatus())
|| DateUtils.isExpired(tenant.getExpireTime())) {
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
}
return tenant;
}
private EducationTenantRespVO toResponse(TenantRespDTO tenant) {
return EducationTenantRespVO.builder().tenantId(tenant.getId()).displayName(tenant.getName()).build();
}
private boolean isLocalHostnameClaim(String hostname) {
if (!educationProperties.getTenantResolution().isLocalDevelopmentEnabled() || StrUtil.isBlank(hostname)) {
return false;
}
return isLocalHost(normalizeHost(hostname));
}
static String normalizeHost(String value) {
if (StrUtil.isBlank(value)) {
return null;
}
if (value.contains(",") || value.contains("/") || value.contains("@")
|| value.contains("?") || value.contains("#") || value.contains("://")) {
throw invalidLocator();
}
String candidate = value.trim();
validateAuthorityPort(candidate);
String host;
try {
String authority = candidate.indexOf(':') != candidate.lastIndexOf(':') && !candidate.startsWith("[")
? "[" + candidate + "]" : candidate;
URI uri = URI.create("http://" + authority);
if (uri.getUserInfo() != null || uri.getHost() == null || uri.getQuery() != null || uri.getFragment() != null
|| !(StrUtil.isEmpty(uri.getPath()) || "/".equals(uri.getPath()))) {
throw invalidLocator();
}
host = uri.getHost();
if (host.startsWith("[") && host.endsWith("]")) {
host = host.substring(1, host.length() - 1);
}
} catch (RuntimeException ex) {
if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) {
throw serviceException;
}
throw invalidLocator();
}
host = host.toLowerCase(Locale.ROOT);
if (host.endsWith(".")) {
host = host.substring(0, host.length() - 1);
}
validateHost(host);
return host;
}
private static void validateAuthorityPort(String authority) {
int portSeparator;
if (authority.startsWith("[")) {
int bracketEnd = authority.indexOf(']');
if (bracketEnd < 0) {
throw invalidLocator();
}
if (bracketEnd == authority.length() - 1) {
return;
}
if (authority.charAt(bracketEnd + 1) != ':') {
throw invalidLocator();
}
portSeparator = bracketEnd + 1;
} else {
int firstColon = authority.indexOf(':');
if (firstColon < 0 || firstColon != authority.lastIndexOf(':')) {
return;
}
portSeparator = firstColon;
}
String port = authority.substring(portSeparator + 1);
if (port.isEmpty() || !port.chars().allMatch(Character::isDigit)) {
throw invalidLocator();
}
try {
if (Integer.parseInt(port) > 65535) {
throw invalidLocator();
}
} catch (NumberFormatException ex) {
throw invalidLocator();
}
}
private static void validateHost(String host) {
if (host.contains(":")) { // URI#getHost 已验证 IPv6 authority
return;
}
if (IPV4_PATTERN.matcher(host).matches()) {
String[] parts = host.split("\\.");
for (String part : parts) {
if (Integer.parseInt(part) > 255) {
throw invalidLocator();
}
}
return;
}
String ascii;
try {
ascii = IDN.toASCII(host);
} catch (IllegalArgumentException ex) {
throw invalidLocator();
}
if (ascii.length() > 253) {
throw invalidLocator();
}
for (String label : ascii.split("\\.")) {
if (!DNS_LABEL_PATTERN.matcher(label).matches()) {
throw invalidLocator();
}
}
}
private static boolean isLocalHost(String host) {
if ("localhost".equals(host) || host.endsWith(".localhost") || "0.0.0.0".equals(host) || "::1".equals(host)) {
return true;
}
return host.startsWith("127.") && IPV4_PATTERN.matcher(host).matches();
}
private static cn.iocoder.yudao.framework.common.exception.ServiceException invalidLocator() {
return exception(EDUCATION_TENANT_RESOLVE_FAILED);
}
}

View File

@@ -6,8 +6,6 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "用户 APP - 教育租户识别 Response VO")
@Data
@NoArgsConstructor
@@ -18,18 +16,7 @@ public class EducationTenantRespVO {
@Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long tenantId;
@Schema(description = "租户", requiredMode = Schema.RequiredMode.REQUIRED, example = "demo-school")
private String tenantName;
@Schema(description = "租户显示名称", example = "Demo School")
@Schema(description = "租户显示名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "Demo School")
private String displayName;
@Schema(description = "租户状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "ACTIVE",
allowableValues = {"ACTIVE", "DISABLED", "EXPIRED"})
private String status;
@Schema(description = "支持的登录方式", requiredMode = Schema.RequiredMode.REQUIRED,
example = "[\"PASSWORD\", \"SMS\"]")
private List<String> loginMethods;
}

View File

@@ -1,55 +0,0 @@
package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
* 答案命令幂等记录 DO。
*
* <p>同一 (tenant, user, operation, idempotencyKey) 的唯一约束保证幂等性。
* requestHash 用于检测相同键不同载荷的冲突。
* responseJson 存储首次成功响应,用于超时重试重放。</p>
*
* @author 恭学教育
*/
@TableName("education_answer_idempotency")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class AnswerIdempotencyDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 答题用户编号 */
private Long userId;
/** 操作类型SUBMIT_ANSWER */
private String operation;
/** 客户端幂等键UUID */
private String idempotencyKey;
/** 请求载荷 SHA-256 哈希 */
private String requestHash;
/** 会话 ID */
private Long sessionId;
/** 题目 ID */
private String questionId;
/** 学生已选答案 */
private String selectedAnswer;
/** 状态ACCEPTED / CONFLICT */
private String status;
/** 首次成功响应 JSON用于重试重放 */
private String responseJson;
}

View File

@@ -2,14 +2,16 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
/**
* 收藏夹 DO — 学生收藏记录。
*
* <p>同一 (tenant, user, target_type, target_id) 唯一一条。
* 逻辑删除:取消收藏设置 deleted=1;重新收藏通过 ON DUPLICATE KEY UPDATE 恢复。</p>
* 逻辑删除:取消收藏设置 deleted=true;重新收藏通过 PostgreSQL ON CONFLICT DO UPDATE 恢复。</p>
*
* <p>快照字段stem/type/difficulty/options/contentVersion来自收藏时
* 题目的安全视图保留题目当时状态。available 标记源资源当前是否可用。</p>
@@ -18,11 +20,13 @@ import lombok.*;
*
* @author 恭学教育
*/
@TableName("education_favorite")
@TableName(value = "education_favorite", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Builder
@KeySequence("education_favorite_seq")
public class EducationFavoriteDO extends TenantBaseDO {
/** 主键 */

View File

@@ -0,0 +1,74 @@
package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
/**
* 幂等记录 DO。
*
* <p>统一 {@code education_idempotency} 表,合并原先 AnswerIdempotencyDO 与 SubmitIdempotencyDO。
* 通过 {@code operation} 字段区分操作类型:{@code SUBMIT_ANSWER} / {@code SUBMIT_SESSION}。
* 同一 (tenant, user, operation, idempotencyKey) 的唯一约束保证幂等性。
* requestHash 用于检测相同键不同载荷的冲突。
* responseJson 存储首次成功响应 JSON用于超时重试重放。
* business_payloadJSONB为操作特有的扩展数据预留。</p>
*
* @author 恭学教育
*/
@TableName(value = "education_idempotency", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Builder
@KeySequence("education_idempotency_seq")
public class IdempotencyDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 用户编号 */
private Long userId;
/** 操作类型SUBMIT_ANSWER / SUBMIT_SESSION */
private String operation;
/** 客户端幂等键UUID */
private String idempotencyKey;
/** 请求载荷 SHA-256 哈希 */
private String requestHash;
/** 会话 ID */
private Long sessionId;
/** 题目 IDSUBMIT_ANSWER 时有值SUBMIT_SESSION 时为 null */
private String questionId;
/** 学生已选答案SUBMIT_ANSWER 时有值SUBMIT_SESSION 时为 null */
private String selectedAnswer;
/** 关联的报告 IDSUBMIT_SESSION 成功时有值SUBMIT_ANSWER 时为 null */
private Long reportId;
/** 状态:答案保存使用 ACCEPTED交卷使用 PROCESSING / COMPLETED */
private String status;
/** 首次成功响应 JSON用于重试重放 */
private String responseJson;
/** 业务扩展载荷JSONB存储操作特有的扩展数据 */
private String businessPayload;
/** 当前处理租约令牌SUBMIT_SESSION 的 PROCESSING 状态使用) */
private String claimToken;
/** 当前处理租约开始时间 */
private java.time.LocalDateTime claimStartedAt;
}

View File

@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
/**
* 练习会话题目快照 DO。
@@ -13,11 +15,13 @@ import lombok.*;
*
* @author 恭学教育
*/
@TableName("education_practice_question")
@TableName(value = "education_practice_question", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Builder
@KeySequence("education_practice_question_seq")
public class PracticeQuestionDO extends TenantBaseDO {
/** 主键 */

View File

@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
/**
* 练习报告 DO — 会话级评分结果。
@@ -13,11 +15,13 @@ import lombok.*;
*
* @author 恭学教育
*/
@TableName("education_practice_report")
@TableName(value = "education_practice_report", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Builder
@KeySequence("education_practice_report_seq")
public class PracticeReportDO extends TenantBaseDO {
/** 主键 */

View File

@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
/**
* 练习报告明细 DO — 逐题评分结果。
@@ -13,11 +15,13 @@ import lombok.*;
*
* @author 恭学教育
*/
@TableName("education_practice_report_detail")
@TableName(value = "education_practice_report_detail", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Builder
@KeySequence("education_practice_report_detail_seq")
public class PracticeReportDetailDO extends TenantBaseDO {
/** 主键 */

View File

@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
/**
* 练习会话 DO。
@@ -13,11 +15,13 @@ import lombok.*;
*
* @author 恭学教育
*/
@TableName("education_practice_session")
@TableName(value = "education_practice_session", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Builder
@KeySequence("education_practice_session_seq")
public class PracticeSessionDO extends TenantBaseDO {
/** 会话主键 */

View File

@@ -1,52 +0,0 @@
package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
* 交卷幂等记录 DO。
*
* <p>同一 (tenant, user, operation, idempotencyKey) 的唯一约束保证幂等性。
* requestHash 用于检测相同键不同载荷的冲突。
* responseJson 存储首次成功的完整报告 JSON用于超时重试重放。</p>
*
* @author 恭学教育
*/
@TableName("education_submit_idempotency")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class SubmitIdempotencyDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 交卷用户编号 */
private Long userId;
/** 操作类型SUBMIT_SESSION */
private String operation;
/** 客户端幂等键UUID */
private String idempotencyKey;
/** 请求载荷 SHA-256 哈希 */
private String requestHash;
/** 会话 ID */
private Long sessionId;
/** 关联的报告 ID成功时有值 */
private Long reportId;
/** 状态ACCEPTED / CONFLICT */
private String status;
/** 首次成功响应 JSON用于重试重放 */
private String responseJson;
}

View File

@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
import java.time.LocalDateTime;
@@ -18,11 +20,13 @@ import java.time.LocalDateTime;
*
* @author 恭学教育
*/
@TableName("education_wrong_question")
@TableName(value = "education_wrong_question", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Builder
@KeySequence("education_wrong_question_seq")
public class WrongQuestionDO extends TenantBaseDO {
/** 主键 */

View File

@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
/**
* 错题流水幂等 DO。
@@ -13,11 +15,13 @@ import lombok.*;
*
* @author 恭学教育
*/
@TableName("education_wrong_question_idempotency")
@TableName(value = "education_wrong_question_idempotency", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Builder
@KeySequence("education_wrong_question_idempotency_seq")
public class WrongQuestionIdempotencyDO extends TenantBaseDO {
/** 主键 */

View File

@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 目录内容范围基础对象。
*
* <p>PUBLIC 数据固定使用 tenantId=0TENANT_OWNED 数据归属当前租户。</p>
*
* @author 恭学教育
*/
@Data
@EqualsAndHashCode(callSuper = true)
public abstract class CatalogScopeDO extends TenantBaseDO {
/** 内容范围PUBLIC、TENANT_OWNED */
private String scope;
}

View File

@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
@TableName(value = "education_category", autoResultMap = true)
@KeySequence("education_category_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CategoryDO extends CatalogScopeDO {
@TableId
private Long id;
private Long subjectId;
private String legacyNodeId;
private String name;
private Boolean isActive;
private Integer sortOrder;
}

View File

@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
@TableName(value = "education_content_entry", autoResultMap = true)
@KeySequence("education_content_entry_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ContentEntryDO extends CatalogScopeDO {
@TableId
private Long id;
private String legacyId;
private Long regionId;
private String entryKey;
private String name;
private String entryType;
private String icon;
private String route;
private String description;
private String accessRules;
private String layoutConfig;
private Boolean isHidden;
private Boolean isActive;
private Integer sortOrder;
}

View File

@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
@TableName(value = "education_content_node", autoResultMap = true)
@KeySequence("education_content_node_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ContentNodeDO extends CatalogScopeDO {
@TableId
private Long id;
private Long entryId;
private Long parentId;
private String name;
private String title;
private String nodeType;
private String markerType;
private Integer depth;
private Boolean isLeaf;
private Boolean isSelectable;
private Boolean isHidden;
private Boolean isActive;
private Integer sortOrder;
private String metadata;
}

View File

@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
@TableName(value = "education_major", autoResultMap = true)
@KeySequence("education_major_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MajorDO extends CatalogScopeDO {
@TableId
private Long id;
private String legacyId;
private Long regionId;
private Long schoolId;
private String name;
private String description;
private String studyTips;
private Boolean isActive;
private Integer sortOrder;
}

View File

@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
@TableName(value = "education_practice_blueprint", autoResultMap = true)
@KeySequence("education_practice_blueprint_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PracticeBlueprintDO extends CatalogScopeDO {
@TableId
private Long id;
private String mode;
private Long entryId;
private Long nodeId;
private Long collectionId;
private Integer questionLimit;
private Integer durationMinutes;
private Integer eligibleCount;
private Integer totalCount;
private String availableTypes;
private String availableDifficulties;
private Integer minQuestions;
private Integer maxQuestions;
private Integer suggestedCount;
private Boolean isActive;
}

View File

@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
@TableName(value = "education_question_collection", autoResultMap = true)
@KeySequence("education_question_collection_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QuestionCollectionDO extends CatalogScopeDO {
@TableId
private Long id;
private Long entryId;
private Long nodeId;
private String name;
private String title;
private String collectionType;
private Integer questionCount;
private Integer durationMinutes;
private String accessRules;
private Boolean isHidden;
private Boolean isActive;
private Integer sortOrder;
private String metadata;
}

View File

@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
* 题集-题目关联 DO多对多题集成员关系的唯一事实源
*
* @author 恭学教育
*/
@TableName(value = "education_question_collection_question", autoResultMap = true)
@KeySequence("education_question_collection_question_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class QuestionCollectionQuestionDO extends CatalogScopeDO {
@TableId
private Long id;
/** 题集 ID */
private Long collectionId;
/** 题目 ID */
private Long questionId;
/** 排序值 */
private Integer sortOrder;
}

View File

@@ -0,0 +1,79 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
/**
* 题目 DO — 核心实体。
* correctAnswer、explanation、analysis 为敏感字段,学生端 DTO 绝不可包含。
*
* @author 恭学教育
*/
@TableName(value = "education_question", autoResultMap = true)
@KeySequence("education_question_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QuestionDO extends CatalogScopeDO {
@TableId
private Long id;
/** 内容版本号(递增) */
private Integer contentVersion;
/** 题干 */
private String stem;
/** 题型 */
private String type;
/** 题型显示名称 */
private String typeLabel;
/** 难度 */
private String difficulty;
/** 题干结构化内容JSON */
private String questionContent;
/** 选项 JSON [{label, content, isCorrect, order}] */
private String options;
/** 正确答案(敏感) */
private String correctAnswer;
/** 答案解析(敏感) */
private String explanation;
/** 深度解析(敏感) */
private String analysis;
/** 题目状态 */
private String status;
/** 是否已发布 */
private Boolean isPublished;
/** 所属科目 ID */
private Long subjectId;
/** 所属内容节点 ID */
private Long nodeId;
/** 标签 JSON 数组 */
private String tags;
/** 显示排序值 */
private Integer sortOrder;
/** 扩展元数据 */
private String metadata;
}

View File

@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
/**
* 地区 DO。
*
* @author 恭学教育
*/
@TableName(value = "education_region", autoResultMap = true)
@KeySequence("education_region_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RegionDO extends CatalogScopeDO {
@TableId
private Long id;
/** 旧系统地区 ID */
private String legacyId;
/** 地区名称 */
private String name;
/** 地区编码 */
private String code;
/** 地区简称 */
private String shortName;
/** 地区全称 */
private String fullName;
/** 地区图标 URL */
private String icon;
/** 地区拼音 */
private String pinyin;
/** 是否热门地区 */
private Boolean isHot;
/** 是否启用 */
private Boolean isActive;
/** 显示排序值 */
private Integer sortOrder;
}

View File

@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
@TableName(value = "education_school", autoResultMap = true)
@KeySequence("education_school_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SchoolDO extends CatalogScopeDO {
@TableId
private Long id;
private String legacyId;
private Long regionId;
private Long moduleId;
private String name;
private String professionalExamDate;
private String metadata;
private Boolean isActive;
private Integer sortOrder;
}

View File

@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.Builder;
@TableName(value = "education_subject", autoResultMap = true)
@KeySequence("education_subject_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SubjectDO extends CatalogScopeDO {
@TableId
private Long id;
private Long regionId;
private Long schoolId;
private Long majorId;
private Long moduleId;
private String name;
private String type;
private Boolean isActive;
private Integer sortOrder;
}

View File

@@ -1,43 +0,0 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.AnswerIdempotencyDO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
/**
* 答案命令幂等记录 Mapper。
*
* @author 恭学教育
*/
@Mapper
public interface AnswerIdempotencyMapper extends BaseMapperX<AnswerIdempotencyDO> {
/**
* 按租户、用户、操作、幂等键查找记录。
*/
default AnswerIdempotencyDO selectByKey(Long tenantId, Long userId, String operation, String idempotencyKey) {
return selectOne(new LambdaQueryWrapperX<AnswerIdempotencyDO>()
.eq(AnswerIdempotencyDO::getTenantId, tenantId)
.eq(AnswerIdempotencyDO::getUserId, userId)
.eq(AnswerIdempotencyDO::getOperation, operation)
.eq(AnswerIdempotencyDO::getIdempotencyKey, idempotencyKey));
}
/**
* INSERT IGNORE — attempt insertion; returns 1 if inserted, 0 if duplicate was silently ignored.
* Safe for concurrent same-key resolution without DuplicateKeyException.
*/
@Insert("INSERT IGNORE INTO education_answer_idempotency " +
"(tenant_id, user_id, operation, idempotency_key, request_hash, session_id, " +
"question_id, selected_answer, status, response_json, " +
"creator, create_time, updater, update_time, deleted) " +
"VALUES (#{tenantId}, #{userId}, #{operation}, #{idempotencyKey}, #{requestHash}, " +
"#{sessionId}, #{questionId}, #{selectedAnswer}, #{status}, #{responseJson}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(AnswerIdempotencyDO record);
}

View File

@@ -21,7 +21,7 @@ import java.util.List;
public interface EducationFavoriteMapper extends BaseMapperX<EducationFavoriteDO> {
/**
* INSERT ... ON DUPLICATE KEY UPDATE — upsert a favorite entry.
* INSERT ... ON CONFLICT ... DO UPDATE — upsert a favorite entry.
*
* <p>If the (tenant, user, target_type, target_id) row already exists
* (including soft-deleted rows), reactivate it by setting deleted=0 and
@@ -35,16 +35,16 @@ public interface EducationFavoriteMapper extends BaseMapperX<EducationFavoriteDO
"VALUES (#{tenantId}, #{userId}, #{targetType}, #{targetId}, " +
"#{stem}, #{type}, #{difficulty}, #{options}, #{contentVersion}, #{available}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
"ON DUPLICATE KEY UPDATE " +
"id = LAST_INSERT_ID(id), " +
"ON CONFLICT (tenant_id, user_id, target_type, target_id) DO UPDATE SET " +
"deleted = FALSE, " +
"stem = VALUES(stem), " +
"type = VALUES(type), " +
"difficulty = VALUES(difficulty), " +
"options = VALUES(options), " +
"content_version = VALUES(content_version), " +
"available = VALUES(available), " +
"update_time = VALUES(update_time)")
"stem = EXCLUDED.stem, " +
"type = EXCLUDED.type, " +
"difficulty = EXCLUDED.difficulty, " +
"options = EXCLUDED.options, " +
"content_version = EXCLUDED.content_version, " +
"available = EXCLUDED.available, " +
"updater = EXCLUDED.updater, " +
"update_time = EXCLUDED.update_time")
@Options(useGeneratedKeys = true, keyProperty = "id")
int upsert(EducationFavoriteDO record);

View File

@@ -0,0 +1,121 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.IdempotencyDO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Param;
/**
* 统一幂等存储 Mapper。
*
* <p>替换原先 AnswerIdempotencyMapper 与 SubmitIdempotencyMapper统一操作
* {@code education_idempotency} 表。通过 {@code operation} 字段区分
* {@code SUBMIT_ANSWER} 与 {@code SUBMIT_SESSION} 两种操作类型,
* 利用 PostgreSQL {@code ON CONFLICT DO NOTHING} 实现无锁幂等插入。</p>
*
* @author 恭学教育
*/
@Mapper
public interface IdempotencyStoreMapper extends BaseMapperX<IdempotencyDO> {
/**
* 按租户、用户、操作、幂等键查找幂等记录。
*
* @param tenantId 租户编号
* @param userId 用户编号
* @param operation 操作类型SUBMIT_ANSWER / SUBMIT_SESSION
* @param idempotencyKey 客户端幂等键UUID
* @return 幂等记录;未找到时返回 {@code null}
*/
default IdempotencyDO selectByKey(Long tenantId, Long userId, String operation, String idempotencyKey) {
return selectOne(new LambdaQueryWrapperX<IdempotencyDO>()
.eq(IdempotencyDO::getTenantId, tenantId)
.eq(IdempotencyDO::getUserId, userId)
.eq(IdempotencyDO::getOperation, operation)
.eq(IdempotencyDO::getIdempotencyKey, idempotencyKey));
}
/**
* 尝试插入幂等记录;使用 {@code ON CONFLICT DO NOTHING} 避免唯一约束异常。
*
* <p>唯一约束为 {@code (tenant_id, user_id, operation, idempotency_key)}。
* 插入成功后通过 {@code @Options} 回填自增主键 {@code id}。
* 并发场景下同一键的多个请求安全竞争:仅一条成功插入(返回 1
* 其余静默忽略(返回 0。调用方根据返回值判断是否为首个请求。</p>
*
* @param record 幂等记录
* @return 1 表示插入成功首个请求0 表示键冲突(重复请求)
*/
@Insert("INSERT INTO education_idempotency " +
"(tenant_id, user_id, operation, idempotency_key, request_hash, session_id, " +
"question_id, selected_answer, report_id, status, response_json, business_payload, " +
"claim_token, claim_started_at, creator, create_time, updater, update_time, deleted) " +
"VALUES (#{tenantId}, #{userId}, #{operation}, #{idempotencyKey}, #{requestHash}, " +
"#{sessionId}, #{questionId}, #{selectedAnswer}, #{reportId}, #{status}, #{responseJson}, #{businessPayload}, " +
"#{claimToken}, #{claimStartedAt}, #{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
"ON CONFLICT (tenant_id, user_id, operation, idempotency_key) DO NOTHING")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(IdempotencyDO record);
/**
* 对同一交卷幂等业务键加 PostgreSQL 事务级 advisory lock。
*/
@Select("SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtextextended(" +
"CONCAT(CAST(#{tenantId} AS TEXT), ':', CAST(#{userId} AS TEXT), " +
"':SUBMIT_SESSION:', CAST(#{idempotencyKey} AS TEXT)), 0))) AS locked")
Long lockSubmitKey(@Param("tenantId") Long tenantId,
@Param("userId") Long userId,
@Param("idempotencyKey") String idempotencyKey);
/**
* 超时租约接管;仅匹配请求哈希的 PROCESSING 记录可以被新令牌接管。
*/
@Select("UPDATE education_idempotency SET claim_token = #{claimToken}, claim_started_at = CURRENT_TIMESTAMP " +
"WHERE id = #{id} AND operation = 'SUBMIT_SESSION' AND status = 'PROCESSING' " +
"AND request_hash = #{requestHash} AND (claim_started_at IS NULL " +
"OR claim_started_at < CURRENT_TIMESTAMP - CAST(#{leaseSeconds} || ' seconds' AS INTERVAL)) " +
"RETURNING *")
IdempotencyDO takeoverExpiredSubmit(@Param("id") Long id,
@Param("requestHash") String requestHash,
@Param("claimToken") String claimToken,
@Param("leaseSeconds") long leaseSeconds);
/**
* 更新幂等记录的题目编号与完整响应 JSON。
*
* <p>用于首次处理完成后回写完整结果:
* 初始插入时仅写入关键字段,业务处理成功后调用本方法将
* 题目编号与完整响应 JSON 更新到幂等行中,
* 供后续重试请求直接重放响应。</p>
*
* @param id 幂等记录主键
* @param questionId 题目编号
* @param responseJson 完整响应 JSON 字符串
* @return 受影响行数
*/
@Update("UPDATE education_idempotency " +
"SET question_id = #{questionId}, response_json = #{responseJson} " +
"WHERE id = #{id}")
int updateResponse(@Param("id") Long id,
@Param("questionId") String questionId,
@Param("responseJson") String responseJson);
/**
* 完成交卷幂等占位并返回当前行,便于并发重放等待提交完成。
*/
@Select("UPDATE education_idempotency " +
"SET report_id = #{reportId}, status = 'COMPLETED', response_json = #{responseJson}, " +
"claim_token = NULL, claim_started_at = NULL " +
"WHERE id = #{id} AND operation = 'SUBMIT_SESSION' AND status = 'PROCESSING' " +
"AND claim_token = #{claimToken} RETURNING *")
IdempotencyDO completeSubmit(@Param("id") Long id,
@Param("claimToken") String claimToken,
@Param("reportId") Long reportId,
@Param("responseJson") String responseJson);
}

View File

@@ -36,16 +36,18 @@ public interface PracticeReportMapper extends BaseMapperX<PracticeReportDO> {
}
/**
* INSERT IGNORE — attempt insertion; returns 1 if inserted, 0 if duplicate (uk_report_session) was silently ignored.
* Safe for concurrent submit race resolution without DuplicateKeyException.
* INSERT … ON CONFLICT DO NOTHING — attempt insertion; returns 1 if inserted, 0 if duplicate on
* (tenant_id, session_id) was silently ignored. Safe for concurrent submit race resolution without
* DuplicateKeyException.
*/
@Insert("INSERT IGNORE INTO education_practice_report " +
@Insert("INSERT INTO education_practice_report " +
"(tenant_id, user_id, session_id, question_count, answered_count, unanswered_count, " +
"correct_count, incorrect_count, score, status, " +
"creator, create_time, updater, update_time, deleted) " +
"VALUES (#{tenantId}, #{userId}, #{sessionId}, #{questionCount}, #{answeredCount}, #{unansweredCount}, " +
"#{correctCount}, #{incorrectCount}, #{score}, #{status}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
"ON CONFLICT (tenant_id, session_id) DO NOTHING")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(PracticeReportDO record);

View File

@@ -64,19 +64,37 @@ public interface PracticeSessionMapper extends BaseMapperX<PracticeSessionDO> {
}
/**
* INSERT IGNORE — attempt session creation for idempotency.
* Used by review session creation to resolve concurrent create races
* without catching DuplicateKeyException inside a transaction.
* CAS 交卷:仅允许一次 ACTIVE → SUBMITTED 状态迁移。
*
* @return 1 if inserted, 0 if duplicate was silently ignored
* @return 受影响行数1 = 成功0 = CAS 失败)
*/
@org.apache.ibatis.annotations.Insert("INSERT IGNORE INTO education_practice_session " +
default int casSubmit(Long id, Long tenantId, Long userId, Integer expectedVersion) {
return update(null,
new LambdaUpdateWrapper<PracticeSessionDO>()
.eq(PracticeSessionDO::getId, id)
.eq(PracticeSessionDO::getTenantId, tenantId)
.eq(PracticeSessionDO::getUserId, userId)
.eq(PracticeSessionDO::getStatus, "ACTIVE")
.eq(PracticeSessionDO::getVersion, expectedVersion)
.set(PracticeSessionDO::getStatus, "SUBMITTED")
.setSql("version = version + 1"));
}
/**
* ON CONFLICT DO NOTHING — attempt session creation for idempotency.
* Used by review session creation to resolve concurrent create races
* without catching duplicate key violations inside a transaction.
*
* @return 1 if inserted, 0 if conflict was silently ignored
*/
@org.apache.ibatis.annotations.Insert("INSERT INTO education_practice_session " +
"(tenant_id, user_id, client_session_id, status, question_count, " +
"collection_id, node_id, type, difficulty, version, review_fingerprint, " +
"creator, create_time, updater, update_time, deleted) " +
"VALUES (#{tenantId}, #{userId}, #{clientSessionId}, #{status}, #{questionCount}, " +
"#{collectionId}, #{nodeId}, #{type}, #{difficulty}, #{version}, #{reviewFingerprint}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
"ON CONFLICT (tenant_id, client_session_id) DO NOTHING")
@org.apache.ibatis.annotations.Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(PracticeSessionDO record);

View File

@@ -1,43 +0,0 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.SubmitIdempotencyDO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
/**
* 交卷幂等记录 Mapper。
*
* @author 恭学教育
*/
@Mapper
public interface SubmitIdempotencyMapper extends BaseMapperX<SubmitIdempotencyDO> {
/**
* 按租户、用户、操作、幂等键查找记录。
*/
default SubmitIdempotencyDO selectByKey(Long tenantId, Long userId, String operation, String idempotencyKey) {
return selectOne(new LambdaQueryWrapperX<SubmitIdempotencyDO>()
.eq(SubmitIdempotencyDO::getTenantId, tenantId)
.eq(SubmitIdempotencyDO::getUserId, userId)
.eq(SubmitIdempotencyDO::getOperation, operation)
.eq(SubmitIdempotencyDO::getIdempotencyKey, idempotencyKey));
}
/**
* INSERT IGNORE — attempt insertion; returns 1 if inserted, 0 if duplicate was silently ignored.
* Safe for concurrent same-key resolution without DuplicateKeyException.
*/
@Insert("INSERT IGNORE INTO education_submit_idempotency " +
"(tenant_id, user_id, operation, idempotency_key, request_hash, session_id, " +
"report_id, status, response_json, " +
"creator, create_time, updater, update_time, deleted) " +
"VALUES (#{tenantId}, #{userId}, #{operation}, #{idempotencyKey}, #{requestHash}, " +
"#{sessionId}, #{reportId}, #{status}, #{responseJson}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(SubmitIdempotencyDO record);
}

View File

@@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Options;
/**
* 错题流水幂等 Mapper。
*
* <p>INSERT IGNORE 提供 (wrong_question_id, report_id) 的 at-most-once 保障。
* <p>ON CONFLICT DO NOTHING 提供 (tenant_id, user_id, question_id, report_id) 的 at-most-once 保障。
* 返回 1 = 已插入(可以 upsert wrong question返回 0 = 该 report 已贡献过。</p>
*
* @author 恭学教育
@@ -18,16 +18,17 @@ import org.apache.ibatis.annotations.Options;
public interface WrongQuestionIdempotencyMapper extends BaseMapperX<WrongQuestionIdempotencyDO> {
/**
* INSERT IGNORE — attempt idempotency guard insertion.
* ON CONFLICT DO NOTHING — attempt idempotency guard insertion.
*
* @return 1 if inserted (first time for this wrong_question+report),
* @return 1 if inserted (first time for this question+report),
* 0 if duplicate was silently ignored
*/
@Insert("INSERT IGNORE INTO education_wrong_question_idempotency " +
@Insert("INSERT INTO education_wrong_question_idempotency " +
"(tenant_id, user_id, wrong_question_id, report_id, question_id, " +
"creator, create_time, updater, update_time, deleted) " +
"VALUES (#{tenantId}, #{userId}, #{wrongQuestionId}, #{reportId}, #{questionId}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
"ON CONFLICT (tenant_id, user_id, question_id, report_id) DO NOTHING")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(WrongQuestionIdempotencyDO record);

View File

@@ -18,14 +18,17 @@ import org.apache.ibatis.annotations.Options;
public interface WrongQuestionMapper extends BaseMapperX<WrongQuestionDO> {
/**
* INSERT ... ON DUPLICATE KEY UPDATE — upsert a wrong question entry.
* INSERT ... ON CONFLICT DO UPDATE — upsert a wrong question entry.
* On conflict (tenant_id, user_id, question_id), increments wrong_count,
* updates snapshot fields, and advances last_wrong_time.
*
* <p>This is the ONLY write path for wrong questions. Callers MUST first
* guard with WrongQuestionIdempotencyMapper to ensure at-most-once per report.</p>
*
* @return 1 = inserted, 2 = updated (MySQL convention for ON DUPLICATE KEY UPDATE)
* <p><b>PostgreSQL convention:</b> {@code ON CONFLICT} with {@code EXCLUDED} references.
* {@code first_wrong_time} is set only on initial insert (not updated on conflict).</p>
*
* @return 1 = inserted or updated
*/
@Insert("INSERT INTO education_wrong_question " +
"(tenant_id, user_id, question_id, stem, type, difficulty, options, content_version, " +
@@ -38,20 +41,20 @@ public interface WrongQuestionMapper extends BaseMapperX<WrongQuestionDO> {
"#{firstWrongTime}, #{lastWrongTime}, #{wrongCount}, #{masterStatus}, " +
"#{lastReportId}, #{lastSessionId}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
"ON DUPLICATE KEY UPDATE " +
"id = LAST_INSERT_ID(id), " +
"stem = VALUES(stem), " +
"type = VALUES(type), " +
"difficulty = VALUES(difficulty), " +
"options = VALUES(options), " +
"content_version = VALUES(content_version), " +
"latest_correct_answer = VALUES(latest_correct_answer), " +
"latest_explanation = VALUES(latest_explanation), " +
"last_wrong_time = VALUES(last_wrong_time), " +
"wrong_count = wrong_count + 1, " +
"last_report_id = VALUES(last_report_id), " +
"last_session_id = VALUES(last_session_id), " +
"update_time = VALUES(update_time)")
"ON CONFLICT (tenant_id, user_id, question_id) DO UPDATE SET " +
"stem = EXCLUDED.stem, " +
"type = EXCLUDED.type, " +
"difficulty = EXCLUDED.difficulty, " +
"options = EXCLUDED.options, " +
"content_version = EXCLUDED.content_version, " +
"latest_correct_answer = EXCLUDED.latest_correct_answer, " +
"latest_explanation = EXCLUDED.latest_explanation, " +
"last_wrong_time = EXCLUDED.last_wrong_time, " +
"wrong_count = education_wrong_question.wrong_count + 1, " +
"last_report_id = EXCLUDED.last_report_id, " +
"last_session_id = EXCLUDED.last_session_id, " +
"updater = EXCLUDED.updater, " +
"update_time = EXCLUDED.update_time")
@Options(useGeneratedKeys = true, keyProperty = "id")
int upsert(WrongQuestionDO record);

View File

@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CatalogScopeDO;
/** Adds the explicit catalog scope predicate while tenant SQL isolation is temporarily bypassed. */
final class CatalogScopeQuery {
private CatalogScopeQuery() {
}
static <T extends CatalogScopeDO> void apply(LambdaQueryWrapper<T> wrapper,
SFunction<T, ?> tenantGetter,
SFunction<T, ?> scopeGetter,
Long tenantId) {
wrapper.and(w -> w.and(owned -> owned.eq(tenantGetter, tenantId)
.eq(scopeGetter, "TENANT_OWNED"))
.or(publicScope -> publicScope.eq(tenantGetter, 0L)
.eq(scopeGetter, "PUBLIC")));
}
}

View File

@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface CategoryMapper extends BaseMapperX<CategoryDO> {
default List<CategoryDO> selectActiveList(Long tenantId, Long subjectId, String legacyNodeId) {
LambdaQueryWrapperX<CategoryDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, CategoryDO::getTenantId, CategoryDO::getScope, tenantId);
w.eq(CategoryDO::getIsActive, true).orderByAsc(CategoryDO::getSortOrder);
if (subjectId != null) w.eq(CategoryDO::getSubjectId, subjectId);
if (legacyNodeId != null && !legacyNodeId.isBlank()) w.eq(CategoryDO::getLegacyNodeId, legacyNodeId);
return selectList(w);
}
}

View File

@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface ContentEntryMapper extends BaseMapperX<ContentEntryDO> {
default List<ContentEntryDO> selectActiveList(Long tenantId, Long regionId, String entryType, boolean includeHidden) {
LambdaQueryWrapperX<ContentEntryDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, ContentEntryDO::getTenantId, ContentEntryDO::getScope, tenantId);
w.eq(ContentEntryDO::getIsActive, true).orderByAsc(ContentEntryDO::getSortOrder);
if (!includeHidden) w.eq(ContentEntryDO::getIsHidden, false);
if (regionId != null) w.eq(ContentEntryDO::getRegionId, regionId);
if (entryType != null && !entryType.isBlank()) w.eq(ContentEntryDO::getEntryType, entryType);
return selectList(w);
}
}

View File

@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
default List<ContentNodeDO> selectChildren(Long tenantId, Long entryId, Long parentId, boolean includeInactive,
String markerType) {
LambdaQueryWrapperX<ContentNodeDO> w = base(tenantId, entryId, includeInactive, markerType);
if (parentId == null) w.isNull(ContentNodeDO::getParentId); else w.eq(ContentNodeDO::getParentId, parentId);
return selectList(w);
}
default List<ContentNodeDO> selectAllByEntryId(Long tenantId, Long entryId, boolean includeInactive, String markerType) {
return selectList(base(tenantId, entryId, includeInactive, markerType));
}
private LambdaQueryWrapperX<ContentNodeDO> base(Long tenantId, Long entryId, boolean includeInactive, String markerType) {
LambdaQueryWrapperX<ContentNodeDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, ContentNodeDO::getTenantId, ContentNodeDO::getScope, tenantId);
w.eq(ContentNodeDO::getEntryId, entryId).eq(ContentNodeDO::getIsHidden, false)
.orderByAsc(ContentNodeDO::getSortOrder);
if (!includeInactive) w.eq(ContentNodeDO::getIsActive, true);
if (markerType != null && !markerType.isBlank()) w.eq(ContentNodeDO::getMarkerType, markerType);
return w;
}
}

View File

@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.MajorDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface MajorMapper extends BaseMapperX<MajorDO> {
default List<MajorDO> selectActiveList(Long tenantId, Long regionId, Long schoolId, Long majorId) {
LambdaQueryWrapperX<MajorDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, MajorDO::getTenantId, MajorDO::getScope, tenantId);
w.eq(MajorDO::getIsActive, true).orderByAsc(MajorDO::getSortOrder);
if (regionId != null) w.eq(MajorDO::getRegionId, regionId);
if (schoolId != null) w.eq(MajorDO::getSchoolId, schoolId);
if (majorId != null) w.eq(MajorDO::getId, majorId);
return selectList(w);
}
}

View File

@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.PracticeBlueprintDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PracticeBlueprintMapper extends BaseMapperX<PracticeBlueprintDO> {
default PracticeBlueprintDO selectByCollectionOrNode(Long tenantId, Long collectionId, Long nodeId,
String mode, String type, String difficulty) {
if (collectionId == null && nodeId == null) return null;
LambdaQueryWrapperX<PracticeBlueprintDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, PracticeBlueprintDO::getTenantId, PracticeBlueprintDO::getScope, tenantId);
w.eq(PracticeBlueprintDO::getIsActive, true).and(x -> {
if (collectionId != null) x.eq(PracticeBlueprintDO::getCollectionId, collectionId);
if (nodeId != null) x.or().eq(PracticeBlueprintDO::getNodeId, nodeId);
}).orderByAsc(PracticeBlueprintDO::getId).last("LIMIT 1");
return selectOne(w);
}
}

View File

@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollectionDO> {
default List<QuestionCollectionDO> selectActiveList(Long tenantId, Long entryId, Long nodeId, String collectionType, Integer limit) {
LambdaQueryWrapperX<QuestionCollectionDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, QuestionCollectionDO::getTenantId, QuestionCollectionDO::getScope, tenantId);
w.eq(QuestionCollectionDO::getIsActive, true).eq(QuestionCollectionDO::getIsHidden, false)
.orderByAsc(QuestionCollectionDO::getSortOrder);
if (entryId != null) w.eq(QuestionCollectionDO::getEntryId, entryId);
if (nodeId != null) w.eq(QuestionCollectionDO::getNodeId, nodeId);
if (collectionType != null && !collectionType.isBlank()) w.eq(QuestionCollectionDO::getCollectionType, collectionType);
if (limit != null && limit > 0) w.last("LIMIT " + Math.min(limit, 200));
return selectList(w);
}
}

View File

@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionQuestionDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface QuestionCollectionQuestionMapper extends BaseMapperX<QuestionCollectionQuestionDO> {
default List<QuestionCollectionQuestionDO> selectByCollectionId(Long tenantId, Long collectionId) {
LambdaQueryWrapperX<QuestionCollectionQuestionDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, QuestionCollectionQuestionDO::getTenantId,
QuestionCollectionQuestionDO::getScope, tenantId);
return selectList(w.eq(QuestionCollectionQuestionDO::getCollectionId, collectionId)
.orderByAsc(QuestionCollectionQuestionDO::getSortOrder)
.orderByAsc(QuestionCollectionQuestionDO::getId));
}
}

View File

@@ -0,0 +1,80 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collections;
import java.util.List;
@Mapper
public interface QuestionMapper extends BaseMapperX<QuestionDO> {
default IPage<QuestionDO> selectPublishedPage(IPage<QuestionDO> page, Long tenantId, Long nodeId,
String type, String difficulty) {
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId);
if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId);
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
return selectPage(page, w.orderByAsc(QuestionDO::getSortOrder).orderByAsc(QuestionDO::getId));
}
default IPage<QuestionDO> selectPublishedPageByIds(IPage<QuestionDO> page, Long tenantId, List<Long> ids,
String type, String difficulty) {
if (ids == null || ids.isEmpty()) {
page.setTotal(0);
page.setRecords(Collections.emptyList());
return page;
}
return selectPublishedPageByCollectionOrder(page, tenantId, ids, type, difficulty);
}
@Select("""
<script>
SELECT q.*
FROM education_question q
JOIN unnest(ARRAY[
<foreach collection="ids" item="id" separator=",">#{id}</foreach>
]::BIGINT[]) WITH ORDINALITY ordered(question_id, position)
ON ordered.question_id = q.id
WHERE q.deleted = false
AND q.is_published = true
AND q.status = 'PUBLISHED'
AND ((q.tenant_id = #{tenantId} AND q.scope = 'TENANT_OWNED')
OR (q.tenant_id = 0 AND q.scope = 'PUBLIC'))
<if test="type != null and type != ''">AND q.type = #{type}</if>
<if test="difficulty != null and difficulty != ''">AND q.difficulty = #{difficulty}</if>
ORDER BY ordered.position
</script>
""")
IPage<QuestionDO> selectPublishedPageByCollectionOrder(IPage<QuestionDO> page,
@Param("tenantId") Long tenantId, @Param("ids") List<Long> ids,
@Param("type") String type, @Param("difficulty") String difficulty);
default long countPublishedByIds(Long tenantId, List<Long> ids, String type, String difficulty) {
if (ids == null || ids.isEmpty()) return 0L;
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId).in(QuestionDO::getId, ids);
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
return selectCount(w);
}
default long countPublished(Long tenantId, Long nodeId, String type, String difficulty) {
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId);
if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId);
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
return selectCount(w);
}
default QuestionDO selectPublishedById(Long tenantId, Long id) {
return selectOne(visible(tenantId).eq(QuestionDO::getId, id));
}
private LambdaQueryWrapperX<QuestionDO> visible(Long tenantId) {
LambdaQueryWrapperX<QuestionDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, QuestionDO::getTenantId, QuestionDO::getScope, tenantId);
return w.eq(QuestionDO::getIsPublished, true).eq(QuestionDO::getStatus, "PUBLISHED");
}
}

View File

@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.RegionDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface RegionMapper extends BaseMapperX<RegionDO> {
default List<RegionDO> selectActiveList(Long tenantId) {
LambdaQueryWrapperX<RegionDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, RegionDO::getTenantId, RegionDO::getScope, tenantId);
return selectList(w.eq(RegionDO::getIsActive, true).orderByAsc(RegionDO::getSortOrder));
}
}

View File

@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.SchoolDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface SchoolMapper extends BaseMapperX<SchoolDO> {
default List<SchoolDO> selectActiveList(Long tenantId, Long regionId, Long schoolId) {
LambdaQueryWrapperX<SchoolDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, SchoolDO::getTenantId, SchoolDO::getScope, tenantId);
w.eq(SchoolDO::getIsActive, true).orderByAsc(SchoolDO::getSortOrder);
if (regionId != null) w.eq(SchoolDO::getRegionId, regionId);
if (schoolId != null) w.eq(SchoolDO::getId, schoolId);
return selectList(w);
}
}

View File

@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.SubjectDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface SubjectMapper extends BaseMapperX<SubjectDO> {
default List<SubjectDO> selectActiveList(Long tenantId, Long regionId, Long schoolId, Long majorId,
Long moduleId, String type) {
LambdaQueryWrapperX<SubjectDO> w = new LambdaQueryWrapperX<>();
CatalogScopeQuery.apply(w, SubjectDO::getTenantId, SubjectDO::getScope, tenantId);
w.eq(SubjectDO::getIsActive, true).orderByAsc(SubjectDO::getSortOrder);
if (regionId != null) w.eq(SubjectDO::getRegionId, regionId);
if (schoolId != null) w.eq(SubjectDO::getSchoolId, schoolId);
if (majorId != null) w.eq(SubjectDO::getMajorId, majorId);
if (moduleId != null) w.eq(SubjectDO::getModuleId, moduleId);
if (type != null && !type.isBlank()) w.eq(SubjectDO::getType, type);
return selectList(w);
}
}

View File

@@ -5,7 +5,7 @@ package cn.iocoder.yudao.module.education.enums;
*
* <ul>
* <li>{@code SCALAR_READ} — 使用 Scalar API 读取题库目录数据</li>
* <li>{@code JAVA_READ} — 使用 Java 本地数据源(预留,当前不支持)</li>
* <li>{@code JAVA_READ} — 使用 Java 本地 PostgreSQL 数据源</li>
* </ul>
*
* @author 恭学教育

View File

@@ -15,11 +15,12 @@ public interface ErrorCodeConstants {
// ========== 租户识别 1-005-001-001 ~ 1-005-001-010 ==========
ErrorCode EDUCATION_TENANT_NOT_FOUND = new ErrorCode(1_005_001_001, "租户不存在");
ErrorCode EDUCATION_TENANT_DISABLED = new ErrorCode(1_005_001_002, "租户已被禁用");
ErrorCode EDUCATION_TENANT_RESOLVE_FAILED = new ErrorCode(1_005_001_003, "租户识别失败:{}");
ErrorCode EDUCATION_TENANT_NOT_ACTIVE = new ErrorCode(1_005_001_004, "当前租户不可用,请联系管理员");
ErrorCode EDUCATION_TENANT_RESOLVE_FAILED = new ErrorCode(1_005_001_003, "租户识别请求无效");
ErrorCode EDUCATION_TENANT_NOT_ACTIVE = new ErrorCode(1_005_001_004, "当前租户不可用");
ErrorCode EDUCATION_TENANT_NOT_IN_PILOT = new ErrorCode(1_005_001_005, "当前租户尚未开放教育 Pilot 能力");
ErrorCode EDUCATION_CATALOG_READ_DISABLED = new ErrorCode(1_005_001_006, "题库读取能力已关闭,请稍后重试");
ErrorCode EDUCATION_PRACTICE_WRITE_DISABLED = new ErrorCode(1_005_001_007, "练习写入能力已关闭,历史数据仍可查看");
ErrorCode EDUCATION_TENANT_LOCATOR_CONFLICT = new ErrorCode(1_005_001_008, "租户识别信息冲突");
// ========== Catalog 目录 1-005-002-000 ~ 1-005-002-009 ==========
ErrorCode CATALOG_DATA_SOURCE_DISABLED = new ErrorCode(1_005_002_000, "题库数据源未启用,请联系管理员");
@@ -36,6 +37,8 @@ public interface ErrorCodeConstants {
ErrorCode CATALOG_UPSTREAM_MALFORMED = new ErrorCode(1_005_002_011, "上游题库返回数据格式异常,请稍后重试");
ErrorCode CATALOG_UPSTREAM_UNAVAILABLE = new ErrorCode(1_005_002_012, "上游题库服务不可达,请稍后重试");
ErrorCode CATALOG_INVALID_IDENTIFIER = new ErrorCode(1_005_002_013, "目录标识符格式无效:{}");
// ========== 题目与练习 1-005-003-000 ~ 1-005-003-009 ==========
ErrorCode QUESTION_NOT_FOUND = new ErrorCode(1_005_003_001, "题目不存在或不可见");
ErrorCode INVALID_PRACTICE_CONFIG = new ErrorCode(1_005_003_002, "无效的练习配置:{}");
@@ -68,6 +71,8 @@ public interface ErrorCodeConstants {
ErrorCode ANSWER_STALE_CROSS_QUESTION_SEQUENCE = new ErrorCode(1_005_003_022, "客户端命令序号过期(跨题目),当前序号 {} 不大于会话已接受的 {}");
ErrorCode CATALOG_UPSTREAM_OPTIONS_MALFORMED = new ErrorCode(1_005_003_023, "题目选项数据格式异常,请稍后重试");
ErrorCode ANSWER_FIELD_TOO_LONG = new ErrorCode(1_005_003_024, "请求字段过长:{}");
ErrorCode ANSWER_IDEMPOTENCY_REPLAY_INVALID = new ErrorCode(1_005_003_025, "答案幂等记录不完整,无法安全重放,请刷新会话后使用新的幂等键");
ErrorCode ANSWER_TYPE_UNSUPPORTED = new ErrorCode(1_005_003_026, "当前题型暂不支持答案保存");
// ========== 交卷提交 1-005-003-030 ~ 1-005-003-039 ==========
ErrorCode SUBMIT_SESSION_NOT_ACTIVE = new ErrorCode(1_005_003_030, "会话不是进行中状态,无法交卷");
@@ -77,6 +82,7 @@ public interface ErrorCodeConstants {
ErrorCode REPORT_NOT_FOUND = new ErrorCode(1_005_003_034, "报告不存在");
ErrorCode REPORT_NOT_OWN = new ErrorCode(1_005_003_035, "无权访问该报告");
ErrorCode REPORT_SESSION_NOT_SUBMITTED = new ErrorCode(1_005_003_036, "会话尚未提交,报告不可用");
ErrorCode SUBMIT_IDEMPOTENCY_REPLAY_INVALID = new ErrorCode(1_005_003_037, "交卷幂等记录不完整,无法安全重放,请刷新后重试");
// ========== 错题本 1-005-003-040 ~ 1-005-003-059 ==========
ErrorCode WRONG_QUESTION_NOT_FOUND = new ErrorCode(1_005_003_040, "错题不存在");

View File

@@ -7,16 +7,17 @@ import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvide
import cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.UnsupportedModeCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.UnsupportedModeQuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Scalar 数据源自动配置。
* Catalog 数据源自动配置。
* 根据 yudao.education.catalog-mode 选择 CatalogProvider 和 QuestionCatalogProvider 实现。
* SCALAR_READ 模式创建 ScalarCatalogProvider同时实现两个接口
* JAVA_READ 模式创建 UnsupportedModeCatalogProvider 和 UnsupportedModeQuestionCatalogProvider
* JAVA_READ 模式使用 JavaCatalogProvider直连 PostgreSQL
*
* @author 恭学教育
*/
@@ -31,8 +32,12 @@ public class ScalarAutoConfiguration {
return new ScalarCatalogProvider(scalarProperties);
}
// JAVA_READ mode is handled by JavaCatalogProvider @Component
// (see cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider)
// Legacy unsupported-mode beans — kept for backward compatibility with standalone tests
@Bean
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "JAVA_READ")
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "UNSUPPORTED")
public CatalogProvider unsupportedModeCatalogProvider(EducationProperties educationProperties) {
CatalogProviderMode mode = educationProperties.getCatalogMode() != null
? educationProperties.getCatalogMode() : CatalogProviderMode.JAVA_READ;
@@ -40,7 +45,7 @@ public class ScalarAutoConfiguration {
}
@Bean
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "JAVA_READ")
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "UNSUPPORTED")
public QuestionCatalogProvider unsupportedModeQuestionCatalogProvider(EducationProperties educationProperties) {
CatalogProviderMode mode = educationProperties.getCatalogMode() != null
? educationProperties.getCatalogMode() : CatalogProviderMode.JAVA_READ;

View File

@@ -20,6 +20,12 @@ public interface CatalogProvider {
*/
boolean isEnabled();
/** 查询院校 */
List<CatalogEntityDTO> listSchools(String regionId, String schoolId);
/** 查询专业 */
List<CatalogEntityDTO> listMajors(String regionId, String schoolId, String majorId, String moduleId, String type);
/** 查询地区列表 */
List<CatalogEntityDTO> listRegions();

View File

@@ -11,6 +11,10 @@ import java.util.List;
*/
public interface CatalogService {
List<CatalogSchoolRespVO> listSchools(String regionId, String schoolId);
List<CatalogMajorRespVO> listMajors(String regionId, String schoolId, String majorId, String moduleId, String type);
List<CatalogRegionRespVO> listRegions();
List<CatalogCategoryRespVO> listCategories(String subjectId, String nodeId);

View File

@@ -9,12 +9,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_UPSTREAM_UNAVAILABLE;
/**
* 题库目录服务实现。
@@ -34,11 +34,29 @@ public class CatalogServiceImpl implements CatalogService {
this.provider = provider;
}
@Override
public List<CatalogSchoolRespVO> listSchools(String regionId, String schoolId) {
assertEnabled();
List<CatalogEntityDTO> schools = provider.listSchools(regionId, schoolId);
if (schools == null) throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
return schools.stream().filter(CatalogServiceImpl::isActive)
.map(CatalogServiceImpl::toSchoolVO).collect(Collectors.toList());
}
@Override
public List<CatalogMajorRespVO> listMajors(String regionId, String schoolId, String majorId, String moduleId, String type) {
assertEnabled();
List<CatalogEntityDTO> majors = provider.listMajors(regionId, schoolId, majorId, moduleId, type);
if (majors == null) throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
return majors.stream().filter(CatalogServiceImpl::isActive)
.map(CatalogServiceImpl::toMajorVO).collect(Collectors.toList());
}
@Override
public List<CatalogRegionRespVO> listRegions() {
assertEnabled();
List<CatalogEntityDTO> regions = provider.listRegions();
if (regions == null) return Collections.emptyList();
if (regions == null) throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
return regions.stream()
.filter(r -> r.getIsActive() == null || r.getIsActive())
.map(CatalogServiceImpl::toRegionVO)
@@ -49,7 +67,7 @@ public class CatalogServiceImpl implements CatalogService {
public List<CatalogCategoryRespVO> listCategories(String subjectId, String nodeId) {
assertEnabled();
List<CatalogEntityDTO> categories = provider.listCategories(subjectId, nodeId);
if (categories == null) return Collections.emptyList();
if (categories == null) throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
return categories.stream()
.filter(c -> c.getIsActive() == null || c.getIsActive())
.map(CatalogServiceImpl::toCategoryVO)
@@ -60,7 +78,7 @@ public class CatalogServiceImpl implements CatalogService {
public List<CatalogSubjectRespVO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) {
assertEnabled();
List<CatalogEntityDTO> subjects = provider.listSubjects(regionId, schoolId, majorId, moduleId, type);
if (subjects == null) return Collections.emptyList();
if (subjects == null) throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
return subjects.stream()
.filter(s -> s.getIsActive() == null || s.getIsActive())
.map(CatalogServiceImpl::toSubjectVO)
@@ -71,7 +89,7 @@ public class CatalogServiceImpl implements CatalogService {
public List<CatalogModuleNodeRespVO> listModuleNodes(String regionId, String moduleId, String parentId) {
assertEnabled();
List<CatalogEntityDTO> nodes = provider.listModuleNodes(regionId, moduleId, parentId);
if (nodes == null) return Collections.emptyList();
if (nodes == null) throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
return nodes.stream()
.filter(n -> n.getIsActive() == null || n.getIsActive())
.map(CatalogServiceImpl::toModuleNodeVO)
@@ -82,7 +100,7 @@ public class CatalogServiceImpl implements CatalogService {
public List<CatalogContentEntryRespVO> listContentEntries(String regionId, String entryType, boolean includeHidden) {
assertEnabled();
List<CatalogContentEntryDTO> entries = provider.listContentEntries(regionId, entryType, includeHidden);
if (entries == null) return Collections.emptyList();
if (entries == null) throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
return entries.stream()
.filter(e -> e.getIsActive() == null || e.getIsActive())
.map(CatalogServiceImpl::toContentEntryVO)
@@ -93,7 +111,7 @@ public class CatalogServiceImpl implements CatalogService {
public List<CatalogContentNodeRespVO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) {
assertEnabled();
List<CatalogContentNodeDTO> nodes = provider.listContentNodes(entryId, parentId, mode, includeInactive, markerType);
if (nodes == null) return Collections.emptyList();
if (nodes == null) throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
return nodes.stream()
.filter(n -> includeInactive || n.getIsActive() == null || n.getIsActive())
.map(CatalogServiceImpl::toContentNodeVO)
@@ -104,7 +122,7 @@ public class CatalogServiceImpl implements CatalogService {
public List<CatalogQuestionCollectionRespVO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) {
assertEnabled();
List<CatalogQuestionCollectionDTO> collections = provider.listQuestionCollections(regionId, entryId, nodeId, collectionType, limit);
if (collections == null) return Collections.emptyList();
if (collections == null) throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
return collections.stream()
.filter(c -> c.getIsActive() == null || c.getIsActive())
.map(CatalogServiceImpl::toQuestionCollectionVO)
@@ -119,6 +137,21 @@ public class CatalogServiceImpl implements CatalogService {
// ========== 转换方法 ==========
static CatalogSchoolRespVO toSchoolVO(CatalogEntityDTO dto) {
return CatalogSchoolRespVO.builder().id(dto.getId()).name(toString(dto.getName()))
.regionId(toString(dto.getRegionId())).order(dto.getOrder()).active(dto.getIsActive()).build();
}
static CatalogMajorRespVO toMajorVO(CatalogEntityDTO dto) {
return CatalogMajorRespVO.builder().id(dto.getId()).name(toString(dto.getName()))
.regionId(toString(dto.getRegionId())).schoolId(toString(dto.getMetadata() != null ? dto.getMetadata().get("schoolId") : null))
.order(dto.getOrder()).active(dto.getIsActive()).build();
}
private static boolean isActive(CatalogEntityDTO dto) {
return dto != null && Boolean.TRUE.equals(dto.getIsActive());
}
static CatalogRegionRespVO toRegionVO(CatalogEntityDTO dto) {
return CatalogRegionRespVO.builder()
.id(dto.getId())

View File

@@ -99,6 +99,17 @@ public class ScalarCatalogProvider implements CatalogProvider, QuestionCatalogPr
return scalarProperties.isEnabled();
}
@Override
public List<CatalogEntityDTO> listSchools(String regionId, String schoolId) {
return mapEntities(callEntityList(buildPath("/api/catalog/schools", "regionId", regionId, "schoolId", schoolId)));
}
@Override
public List<CatalogEntityDTO> listMajors(String regionId, String schoolId, String majorId, String moduleId, String type) {
return mapEntities(callEntityList(buildPath("/api/catalog/majors", "regionId", regionId, "schoolId", schoolId,
"majorId", majorId, "moduleId", moduleId, "type", type)));
}
@Override
public List<CatalogEntityDTO> listRegions() {
return mapEntities(callEntityList("/api/catalog/regions"));

View File

@@ -30,6 +30,16 @@ public class UnsupportedModeCatalogProvider implements CatalogProvider {
return false;
}
@Override
public List<CatalogEntityDTO> listSchools(String regionId, String schoolId) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogEntityDTO> listMajors(String regionId, String schoolId, String majorId, String moduleId, String type) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogEntityDTO> listRegions() {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());

View File

@@ -0,0 +1,441 @@
package cn.iocoder.yudao.module.education.service.catalog.provider;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.*;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.*;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.dto.*;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.dto.*;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
/**
* Java 原生题库目录数据提供者。
* 直接查询 PostgreSQL不依赖 Scalar。
* 仅在 yudao.education.catalog-mode=JAVA_READ 时激活。
*
* @author 恭学教育
*/
@Component
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "JAVA_READ")
public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProvider {
private static final int MAX_PAGE_SIZE = 100;
private final EducationProperties properties;
private final RegionMapper regionMapper;
private final SchoolMapper schoolMapper;
private final MajorMapper majorMapper;
private final SubjectMapper subjectMapper;
private final CategoryMapper categoryMapper;
private final ContentEntryMapper contentEntryMapper;
private final ContentNodeMapper contentNodeMapper;
private final QuestionCollectionMapper questionCollectionMapper;
private final QuestionMapper questionMapper;
private final PracticeBlueprintMapper practiceBlueprintMapper;
private final QuestionCollectionQuestionMapper collectionQuestionMapper;
public JavaCatalogProvider(EducationProperties properties,
RegionMapper regionMapper,
SchoolMapper schoolMapper,
MajorMapper majorMapper,
SubjectMapper subjectMapper,
CategoryMapper categoryMapper,
ContentEntryMapper contentEntryMapper,
ContentNodeMapper contentNodeMapper,
QuestionCollectionMapper questionCollectionMapper,
QuestionMapper questionMapper,
PracticeBlueprintMapper practiceBlueprintMapper,
QuestionCollectionQuestionMapper collectionQuestionMapper) {
this.properties = properties;
this.regionMapper = regionMapper;
this.schoolMapper = schoolMapper;
this.majorMapper = majorMapper;
this.subjectMapper = subjectMapper;
this.categoryMapper = categoryMapper;
this.contentEntryMapper = contentEntryMapper;
this.contentNodeMapper = contentNodeMapper;
this.questionCollectionMapper = questionCollectionMapper;
this.questionMapper = questionMapper;
this.practiceBlueprintMapper = practiceBlueprintMapper;
this.collectionQuestionMapper = collectionQuestionMapper;
}
@Override
public boolean isEnabled() {
return properties.isEnabled() && properties.getCatalogMode() == CatalogProviderMode.JAVA_READ;
}
private Long tenantId() { return TenantContextHolder.getRequiredTenantId(); }
/**
* 在关闭自动租户 SQL 注入的窄作用域内执行查询;每个 Mapper 必须显式应用 CatalogScopeQuery。
*/
private <T> T readWithExplicitCatalogScope(java.util.concurrent.Callable<T> callable) {
return TenantUtils.executeIgnore(callable);
}
@Override
public List<CatalogEntityDTO> listSchools(String regionId, String schoolId) {
Long rid = parseOptionalLong(regionId); Long sid = parseOptionalLong(schoolId);
return readWithExplicitCatalogScope(() -> schoolMapper.selectActiveList(tenantId(), rid, sid)).stream().map(this::toEntityDTO).toList();
}
@Override
public List<CatalogEntityDTO> listMajors(String regionId, String schoolId, String majorId, String moduleId, String type) {
Long rid = parseOptionalLong(regionId); Long sid = parseOptionalLong(schoolId); Long mid = parseOptionalLong(majorId);
rejectOptionalNumeric(moduleId); // V4020 has no major module/type columns; preserve contract validation.
return readWithExplicitCatalogScope(() -> majorMapper.selectActiveList(tenantId(), rid, sid, mid)).stream().map(this::toEntityDTO).toList();
}
@Override
public List<CatalogEntityDTO> listRegions() {
return readWithExplicitCatalogScope(() -> regionMapper.selectActiveList(tenantId())).stream().map(this::toEntityDTO).toList();
}
@Override
public List<CatalogEntityDTO> listCategories(String subjectId, String nodeId) {
Long sid = parseOptionalLong(subjectId); String legacy = optionalText(nodeId);
return readWithExplicitCatalogScope(() -> categoryMapper.selectActiveList(tenantId(), sid, legacy)).stream().map(this::toEntityDTO).toList();
}
@Override
public List<CatalogEntityDTO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) {
Long rid = parseOptionalLong(regionId); Long sid = parseOptionalLong(schoolId); Long mid = parseOptionalLong(majorId);
Long mod = parseOptionalLong(moduleId);
return readWithExplicitCatalogScope(() -> subjectMapper.selectActiveList(tenantId(), rid, sid, mid, mod, type)).stream().map(this::toEntityDTO).toList();
}
@Override
public List<CatalogEntityDTO> listModuleNodes(String regionId, String moduleId, String parentId) {
rejectOptionalNumeric(regionId); Long entryId = parseRequiredLong(moduleId);
Long pid = "root".equals(parentId) || parentId == null || parentId.isBlank() ? null : parseRequiredLong(parentId);
return readWithExplicitCatalogScope(() -> contentNodeMapper.selectChildren(tenantId(), entryId, pid, false, null)).stream().map(this::toEntityDTO).toList();
}
@Override
public List<CatalogContentEntryDTO> listContentEntries(String regionId, String entryType, boolean includeHidden) {
Long rid = parseOptionalLong(regionId);
return readWithExplicitCatalogScope(() -> contentEntryMapper.selectActiveList(tenantId(), rid, entryType, includeHidden)).stream().map(this::toContentEntryDTO).toList();
}
@Override
public List<CatalogContentNodeDTO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) {
Long eid = parseRequiredLong(entryId);
Long pid = "root".equals(parentId) || parentId == null || parentId.isBlank() ? null : parseRequiredLong(parentId);
List<ContentNodeDO> nodes = readWithExplicitCatalogScope(() -> "flat".equals(mode)
? contentNodeMapper.selectAllByEntryId(tenantId(), eid, includeInactive, markerType)
: contentNodeMapper.selectChildren(tenantId(), eid, pid, includeInactive, markerType));
return nodes.stream().map(this::toContentNodeDTO).toList();
}
@Override
public List<CatalogQuestionCollectionDTO> listQuestionCollections(String regionId, String entryId, String nodeId,
String collectionType, Integer limit) {
rejectOptionalNumeric(regionId); Long eid = parseOptionalLong(entryId); Long nid = parseOptionalLong(nodeId);
int bounded = clampLimit(limit);
return readWithExplicitCatalogScope(() -> questionCollectionMapper.selectActiveList(tenantId(), eid, nid, collectionType, bounded)).stream()
.map(this::toQuestionCollectionDTO).toList();
}
@Override
public CatalogQuestionPageResult listQuestions(String collectionId, String nodeId, String type, String difficulty,
int pageNo, int pageSize) {
Long cid = parseOptionalLong(collectionId); Long nid = parseOptionalLong(nodeId);
int bounded = clampPageSize(pageSize); int page = pageNo > 0 ? pageNo : 1;
List<Long> ids = cid == null ? null : readWithExplicitCatalogScope(() -> collectionQuestionMapper.selectByCollectionId(tenantId(), cid)).stream()
.map(QuestionCollectionQuestionDO::getQuestionId).toList();
IPage<QuestionDO> result = ids != null
? readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByIds(new Page<>(page, bounded), tenantId(), ids, type, difficulty))
: readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPage(new Page<>(page, bounded), tenantId(), nid, type, difficulty));
return buildPageResult(result);
}
@Override
public CatalogQuestionPageResult listCollectionQuestions(String collectionId, String type, String difficulty, int pageNo, int pageSize) {
Long cid = parseRequiredLong(collectionId); int page = pageNo > 0 ? pageNo : 1;
List<Long> ids = readWithExplicitCatalogScope(() -> collectionQuestionMapper.selectByCollectionId(tenantId(), cid)).stream()
.map(QuestionCollectionQuestionDO::getQuestionId).toList();
IPage<QuestionDO> result = readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByIds(new Page<>(page, clampPageSize(pageSize)), tenantId(), ids, type, difficulty));
return buildPageResult(result);
}
@Override
public CatalogQuestionDTO getQuestion(String questionId) {
Long id = parseRequiredLong(questionId);
return toQuestionDTO(readWithExplicitCatalogScope(() -> questionMapper.selectPublishedById(tenantId(), id)));
}
@Override
public CatalogPracticeBlueprintDTO getPracticeBlueprint(String collectionId, String nodeId, String type, String difficulty) {
Long cid = parseOptionalLong(collectionId); Long nid = parseOptionalLong(nodeId);
if (cid == null && nid == null) throw exception(CATALOG_INVALID_IDENTIFIER, "parent");
PracticeBlueprintDO bp = readWithExplicitCatalogScope(() -> practiceBlueprintMapper.selectByCollectionOrNode(
tenantId(), cid, nid, null, type, difficulty));
if (bp == null) return null;
long eligible = cid != null ? countVisibleCollectionQuestions(cid, type, difficulty)
: countVisibleNodeQuestions(nid, type, difficulty);
return toPracticeBlueprintDTO(bp, eligible);
}
// ========== DTO mapping ==========
private CatalogQuestionPageResult buildPageResult(IPage<QuestionDO> page) {
List<CatalogQuestionDTO> items = page.getRecords().stream()
.map(this::toQuestionDTO)
.collect(Collectors.toList());
return CatalogQuestionPageResult.builder()
.items(items)
.total(page.getTotal())
.upstreamRequestId(null)
.build();
}
private CatalogQuestionDTO toQuestionDTO(QuestionDO q) {
if (q == null) return null;
return CatalogQuestionDTO.builder()
.id(String.valueOf(q.getId()))
.contentVersion(q.getContentVersion() != null ? String.valueOf(q.getContentVersion()) : null)
.stem(q.getStem())
.type(q.getType())
.difficulty(q.getDifficulty())
.options(parseOptions(q.getOptions()))
.correctAnswer(q.getCorrectAnswer())
.explanation(q.getExplanation())
.analysis(q.getAnalysis())
.status(q.getStatus())
.isPublished(q.getIsPublished())
.subjectId(q.getSubjectId() != null ? String.valueOf(q.getSubjectId()) : null)
.nodeId(q.getNodeId() != null ? String.valueOf(q.getNodeId()) : null)
.tags(parseTags(q.getTags()))
.order(q.getSortOrder() != null ? q.getSortOrder().doubleValue() : null)
.build();
}
private long countVisibleCollectionQuestions(Long collectionId, String type, String difficulty) {
List<Long> ids = readWithExplicitCatalogScope(() -> collectionQuestionMapper
.selectByCollectionId(tenantId(), collectionId)).stream()
.map(QuestionCollectionQuestionDO::getQuestionId).toList();
return readWithExplicitCatalogScope(() -> questionMapper.countPublishedByIds(tenantId(), ids, type, difficulty));
}
private long countVisibleNodeQuestions(Long nodeId, String type, String difficulty) {
return readWithExplicitCatalogScope(() -> questionMapper.countPublished(tenantId(), nodeId, type, difficulty));
}
private CatalogPracticeBlueprintDTO toPracticeBlueprintDTO(PracticeBlueprintDO bp, long eligible) {
int boundedEligible = Math.toIntExact(Math.min(eligible, Integer.MAX_VALUE));
return CatalogPracticeBlueprintDTO.builder()
.eligibleCount(boundedEligible)
.totalCount(boundedEligible)
.availableTypes(parseStringList(bp.getAvailableTypes()))
.availableDifficulties(parseStringList(bp.getAvailableDifficulties()))
.minQuestions(bp.getMinQuestions())
.maxQuestions(bp.getMaxQuestions())
.suggestedCount(bp.getSuggestedCount())
.build();
}
private CatalogEntityDTO toEntityDTO(RegionDO r) {
return CatalogEntityDTO.builder()
.id(String.valueOf(r.getId()))
.name(r.getName())
.title(r.getFullName())
.type("region")
.regionId(String.valueOf(r.getId()))
.order(sortOrderDouble(r.getSortOrder()))
.isActive(r.getIsActive())
.build();
}
private CatalogEntityDTO toEntityDTO(SchoolDO s) {
return CatalogEntityDTO.builder()
.id(String.valueOf(s.getId()))
.name(s.getName())
.regionId(s.getRegionId() != null ? String.valueOf(s.getRegionId()) : null)
.order(sortOrderDouble(s.getSortOrder()))
.isActive(s.getIsActive())
.metadata(java.util.Map.of("moduleId", s.getModuleId() != null ? String.valueOf(s.getModuleId()) : ""))
.build();
}
private CatalogEntityDTO toEntityDTO(MajorDO m) {
return CatalogEntityDTO.builder()
.id(String.valueOf(m.getId()))
.name(m.getName())
.type(null)
.regionId(m.getRegionId() != null ? String.valueOf(m.getRegionId()) : null)
.order(sortOrderDouble(m.getSortOrder()))
.isActive(m.getIsActive())
.metadata(java.util.Map.of("schoolId", m.getSchoolId() != null ? String.valueOf(m.getSchoolId()) : ""))
.build();
}
private CatalogEntityDTO toEntityDTO(SubjectDO s) {
return CatalogEntityDTO.builder()
.id(String.valueOf(s.getId()))
.name(s.getName())
.type(s.getType())
.order(sortOrderDouble(s.getSortOrder()))
.isActive(s.getIsActive())
.build();
}
private CatalogEntityDTO toEntityDTO(CategoryDO c) {
return CatalogEntityDTO.builder()
.id(String.valueOf(c.getId()))
.name(c.getName())
.order(sortOrderDouble(c.getSortOrder()))
.isActive(c.getIsActive())
.build();
}
private CatalogEntityDTO toEntityDTO(ContentNodeDO n) {
return CatalogEntityDTO.builder()
.id(String.valueOf(n.getId()))
.name(n.getName())
.title(n.getTitle())
.type(n.getNodeType())
.order(sortOrderDouble(n.getSortOrder()))
.isActive(n.getIsActive())
.build();
}
private static Double sortOrderDouble(Integer sortOrder) {
return sortOrder != null ? sortOrder.doubleValue() : 0.0;
}
private CatalogContentEntryDTO toContentEntryDTO(ContentEntryDO e) {
return CatalogContentEntryDTO.contentEntryBuilder()
.id(String.valueOf(e.getId()))
.name(e.getName())
.title(e.getName())
.type(e.getEntryType())
.regionId(e.getRegionId() != null ? String.valueOf(e.getRegionId()) : null)
.order(sortOrderDouble(e.getSortOrder()))
.isActive(e.getIsActive())
.entryKey(e.getEntryKey())
.entryType(e.getEntryType())
.build();
}
private CatalogContentNodeDTO toContentNodeDTO(ContentNodeDO n) {
return CatalogContentNodeDTO.contentNodeBuilder()
.id(String.valueOf(n.getId()))
.name(n.getName())
.title(n.getTitle())
.type(n.getNodeType())
.regionId(null)
.order(sortOrderDouble(n.getSortOrder()))
.isActive(n.getIsActive())
.entryId(String.valueOf(n.getEntryId()))
.parentId(n.getParentId() != null ? String.valueOf(n.getParentId()) : null)
.nodeType(n.getNodeType())
.depth(n.getDepth() != null ? Double.valueOf(n.getDepth()) : null)
.isLeaf(n.getIsLeaf())
.isSelectable(n.getIsSelectable())
.build();
}
private CatalogQuestionCollectionDTO toQuestionCollectionDTO(QuestionCollectionDO c) {
return CatalogQuestionCollectionDTO.questionCollectionBuilder()
.id(String.valueOf(c.getId()))
.name(c.getName())
.title(c.getTitle())
.type(c.getCollectionType())
.regionId(null)
.order(sortOrderDouble(c.getSortOrder()))
.isActive(c.getIsActive())
.entryId(c.getEntryId() != null ? String.valueOf(c.getEntryId()) : null)
.nodeId(c.getNodeId() != null ? String.valueOf(c.getNodeId()) : null)
.collectionType(c.getCollectionType())
.questionCount(c.getQuestionCount() != null ? Double.valueOf(c.getQuestionCount()) : null)
.build();
}
// ========== helpers ==========
private static int clampPageSize(int pageSize) {
if (pageSize <= 0) return 20;
return Math.min(pageSize, MAX_PAGE_SIZE);
}
private static Long parseRequiredLong(String value) {
if (value == null || value.isBlank()) {
throw exception(CATALOG_INVALID_IDENTIFIER, "required");
}
try {
return Long.valueOf(value);
} catch (NumberFormatException ex) {
throw exception(CATALOG_INVALID_IDENTIFIER, "numeric");
}
}
private static Long parseOptionalLong(String value) {
if (value == null || value.isBlank()) return null;
try { return Long.valueOf(value); }
catch (NumberFormatException ex) { throw exception(CATALOG_INVALID_IDENTIFIER, "numeric"); }
}
private static void rejectOptionalNumeric(String value) { parseOptionalLong(value); }
private static String optionalText(String value) { return value == null || value.isBlank() ? null : value; }
private static int clampLimit(Integer limit) { return limit == null ? 100 : Math.max(1, Math.min(limit, 200)); }
@SuppressWarnings({"unchecked", "rawtypes"})
private List<CatalogQuestionDTO.QuestionOptionDTO> parseOptions(String optionsJson) {
if (optionsJson == null || optionsJson.isEmpty()) return Collections.emptyList();
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
List<java.util.Map> rawList = mapper.readValue(optionsJson, List.class);
List<CatalogQuestionDTO.QuestionOptionDTO> result = new java.util.ArrayList<>(rawList.size());
for (java.util.Map m : rawList) {
Object orderVal = m.get("order");
result.add(CatalogQuestionDTO.QuestionOptionDTO.builder()
.label((String) m.get("label"))
.content(m.get("content") instanceof String ? (String) m.get("content") : null)
.isCorrect(m.get("isCorrect") instanceof Boolean ? (Boolean) m.get("isCorrect") : null)
.order(orderVal instanceof Integer ? Double.valueOf((Integer) orderVal)
: orderVal instanceof Double ? (Double) orderVal : null)
.build());
}
return result;
} catch (Exception e) {
return Collections.emptyList();
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
private List<String> parseTags(String tagsJson) {
if (tagsJson == null || tagsJson.isEmpty()) return Collections.emptyList();
try {
return (List<String>) new com.fasterxml.jackson.databind.ObjectMapper().readValue(tagsJson, List.class);
} catch (Exception e) {
return Collections.emptyList();
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
private List<String> parseStringList(String json) {
if (json == null || json.isEmpty()) return Collections.emptyList();
try {
return (List<String>) new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, List.class);
} catch (Exception e) {
return Collections.emptyList();
}
}
}

View File

@@ -9,15 +9,16 @@ import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
import cn.iocoder.yudao.module.education.dal.dataobject.*;
import cn.iocoder.yudao.module.education.dal.mysql.*;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
import cn.iocoder.yudao.module.education.service.practice.dto.ScoreResult;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.DuplicateKeyException;
import java.util.*;
import java.util.stream.Collectors;
@@ -34,31 +35,33 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class PracticeSessionServiceImpl implements PracticeSessionService {
private static final long SUBMIT_CLAIM_LEASE_SECONDS = 120;
private final PracticeSessionMapper sessionMapper;
private final PracticeQuestionMapper questionMapper;
private final AnswerIdempotencyMapper idempotencyMapper;
private final SubmitIdempotencyMapper submitIdempotencyMapper;
private final IdempotencyStoreMapper idempotencyStore;
private final PracticeReportMapper reportMapper;
private final PracticeReportDetailMapper reportDetailMapper;
private final QuestionCatalogProvider questionCatalogProvider;
private final WrongQuestionService wrongQuestionService;
private final ScoringService scoringService;
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
PracticeQuestionMapper questionMapper,
AnswerIdempotencyMapper idempotencyMapper,
SubmitIdempotencyMapper submitIdempotencyMapper,
IdempotencyStoreMapper idempotencyStore,
PracticeReportMapper reportMapper,
PracticeReportDetailMapper reportDetailMapper,
QuestionCatalogProvider questionCatalogProvider,
WrongQuestionService wrongQuestionService) {
WrongQuestionService wrongQuestionService,
ScoringService scoringService) {
this.sessionMapper = sessionMapper;
this.questionMapper = questionMapper;
this.idempotencyMapper = idempotencyMapper;
this.submitIdempotencyMapper = submitIdempotencyMapper;
this.idempotencyStore = idempotencyStore;
this.reportMapper = reportMapper;
this.reportDetailMapper = reportDetailMapper;
this.questionCatalogProvider = questionCatalogProvider;
this.wrongQuestionService = wrongQuestionService;
this.scoringService = scoringService;
}
@Override
@Transactional(rollbackFor = Exception.class)
@@ -72,7 +75,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
if (!isSameFingerprint(existing, reqVO)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
return buildSessionResp(existing, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(existing.getId(), tenantId));
return SessionResponseAssembler.buildSessionResp(existing, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(existing.getId(), tenantId));
}
// 2. Fetch eligible questions — provider returns visible-only per contract
@@ -98,11 +101,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
session.setDifficulty(reqVO.getDifficulty());
session.setVersion(1);
try {
sessionMapper.insert(session);
} catch (DuplicateKeyException e) {
log.warn("DuplicateKeyException on clientSessionId={} for tenant={} — reloading for idempotency resolution",
reqVO.getClientSessionId(), tenantId);
int inserted = sessionMapper.insertIgnore(session);
if (inserted == 0) {
PracticeSessionDO winner = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId());
if (winner == null) {
throw exception(SESSION_DUPLICATE_CLIENT_ID);
@@ -113,7 +113,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
if (!isSameFingerprint(winner, reqVO)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
return buildSessionResp(winner, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(winner.getId(), tenantId));
return SessionResponseAssembler.buildSessionResp(winner,
questionMapper.selectBySessionIdAndTenantIdOrderBySequence(winner.getId(), tenantId));
}
// 4. Create question snapshots with protected answer key
@@ -129,7 +130,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
pq.setStem(q.getStem());
pq.setType(q.getType());
pq.setDifficulty(q.getDifficulty());
pq.setOptions(optionsToSafeJson(q.getOptions()));
pq.setOptions(QuestionContentSafety.toSafeSnapshotJson(q.getType(), q.getOptions()));
pq.setCorrectAnswer(correctAnswerToJson(q.getCorrectAnswer()));
pq.setExplanation(q.getExplanation());
pq.setIsAnswered(false);
@@ -137,7 +138,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
}
questionMapper.insertBatch(questionDOs);
return buildSessionResp(session, questionDOs);
return SessionResponseAssembler.buildSessionResp(session, questionDOs);
}
@@ -148,7 +149,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
return null;
}
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdAndTenantIdOrderBySequence(session.getId(), tenantId);
return buildSessionResp(session, questions);
return SessionResponseAssembler.buildSessionResp(session, questions);
}
@Override
@@ -161,7 +162,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
throw exception(SESSION_NOT_OWN);
}
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdAndTenantIdOrderBySequence(session.getId(), tenantId);
return buildSessionResp(session, questions);
return SessionResponseAssembler.buildSessionResp(session, questions);
}
// ========== answer submission ==========
@@ -171,8 +172,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
public PracticeAnswerRespVO submitAnswer(PracticeAnswerReqVO reqVO, Long userId, Long tenantId) {
String requestHash = computeRequestHash(reqVO);
// 1. INSERT IGNORE idempotency — replay committed responses before any state checks
AnswerIdempotencyDO idempotency = new AnswerIdempotencyDO();
// 1. ON CONFLICT DO NOTHING idempotency — replay committed responses before any state checks
IdempotencyDO idempotency = new IdempotencyDO();
idempotency.setTenantId(tenantId);
idempotency.setUserId(userId);
idempotency.setOperation("SUBMIT_ANSWER");
@@ -184,12 +185,12 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
idempotency.setStatus("ACCEPTED");
idempotency.setResponseJson(""); // placeholder
int idemInserted = idempotencyMapper.insertIgnore(idempotency);
int idemInserted = idempotencyStore.insertIgnore(idempotency);
if (idemInserted == 0) {
AnswerIdempotencyDO existing = idempotencyMapper.selectByKey(
IdempotencyDO existing = idempotencyStore.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", reqVO.getIdempotencyKey());
if (existing != null && Objects.equals(existing.getRequestHash(), requestHash)) {
return JsonUtils.parseObject(existing.getResponseJson(), PracticeAnswerRespVO.class);
return parseAnswerReplay(existing.getResponseJson());
}
throw exception(ANSWER_IDEMPOTENCY_CONFLICT);
}
@@ -234,9 +235,10 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
throw exception(ANSWER_QUESTION_NOT_IN_SESSION);
}
if (reqVO.getSelectedAnswer() != null && !reqVO.getSelectedAnswer().isEmpty()) {
validateOption(question, reqVO.getSelectedAnswer());
if (!QuestionContentSafety.isOptionBackedType(question.getType())) {
throw exception(ANSWER_TYPE_UNSUPPORTED);
}
validateOption(question, reqVO.getSelectedAnswer());
// 4. Build response and update idempotency with full data
int newVersion = session.getVersion() + 1;
@@ -250,14 +252,11 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
String responseJson = JsonUtils.toJsonString(response);
// Re-select idempotency to get auto-generated ID, then update with full response
AnswerIdempotencyDO inserted = idempotencyMapper.selectByKey(
IdempotencyDO inserted = idempotencyStore.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", reqVO.getIdempotencyKey());
if (inserted != null) {
idempotencyMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<AnswerIdempotencyDO>()
.eq(AnswerIdempotencyDO::getId, inserted.getId())
.set(AnswerIdempotencyDO::getQuestionId, question.getQuestionId())
.set(AnswerIdempotencyDO::getResponseJson, responseJson));
if (inserted == null || inserted.getId() == null
|| idempotencyStore.updateResponse(inserted.getId(), question.getQuestionId(), responseJson) != 1) {
throw exception(ANSWER_IDEMPOTENCY_REPLAY_INVALID);
}
// 5. CAS session version + lastClientSequence
@@ -288,25 +287,61 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
@Override
@Transactional(rollbackFor = Exception.class)
public PracticeSubmitRespVO submitSession(PracticeSubmitReqVO reqVO, Long userId, Long tenantId) {
// 1. Compute request hash
String requestHash = computeSubmitHash(reqVO);
// 2. Idempotency check: SELECT before any writes
SubmitIdempotencyDO existing = submitIdempotencyMapper.selectByKey(
String claimToken = UUID.randomUUID().toString();
IdempotencyDO claim;
idempotencyStore.lockSubmitKey(tenantId, userId, reqVO.getIdempotencyKey());
IdempotencyDO existing = idempotencyStore.selectByKey(
tenantId, userId, "SUBMIT_SESSION", reqVO.getIdempotencyKey());
if (existing != null) {
if (!Objects.equals(existing.getRequestHash(), requestHash)) {
throw exception(SUBMIT_IDEMPOTENCY_CONFLICT);
}
// Same hash — only replay a fully committed response (reportId != null)
if (existing.getReportId() != null) {
return JsonUtils.parseObject(existing.getResponseJson(), PracticeSubmitRespVO.class);
if ("COMPLETED".equals(existing.getStatus())) {
return parseSubmitReplay(existing);
}
IdempotencyDO takeover = idempotencyStore.takeoverExpiredSubmit(
existing.getId(), requestHash, claimToken, SUBMIT_CLAIM_LEASE_SECONDS);
if (takeover == null) {
throw exception(SUBMIT_CONCURRENT_CONFLICT);
}
claim = takeover;
} else {
claim = new IdempotencyDO();
claim.setTenantId(tenantId);
claim.setUserId(userId);
claim.setOperation("SUBMIT_SESSION");
claim.setIdempotencyKey(reqVO.getIdempotencyKey());
claim.setRequestHash(requestHash);
claim.setSessionId(reqVO.getSessionId());
claim.setStatus("PROCESSING");
claim.setClaimToken(claimToken);
claim.setClaimStartedAt(java.time.LocalDateTime.now());
if (idempotencyStore.insertIgnore(claim) == 0) {
return replaySubmit(reqVO, userId, tenantId, requestHash);
}
// Same key, same hash, but reportId is null: another request with this key is in-flight.
throw exception(SUBMIT_CONCURRENT_CONFLICT);
}
// 3. Load and validate session
try {
return processClaimedSubmit(reqVO, userId, tenantId, claim);
} catch (RuntimeException ex) {
idempotencyStore.deleteById(claim.getId());
throw ex;
}
}
private PracticeSubmitRespVO replaySubmit(PracticeSubmitReqVO reqVO, Long userId, Long tenantId,
String requestHash) {
IdempotencyDO existing = idempotencyStore.selectByKey(
tenantId, userId, "SUBMIT_SESSION", reqVO.getIdempotencyKey());
if (existing == null || !Objects.equals(existing.getRequestHash(), requestHash)) {
throw exception(SUBMIT_IDEMPOTENCY_CONFLICT);
}
return parseSubmitReplay(existing);
}
private PracticeSubmitRespVO processClaimedSubmit(PracticeSubmitReqVO reqVO, Long userId, Long tenantId,
IdempotencyDO claim) {
PracticeSessionDO session = sessionMapper.selectByIdAndTenant(reqVO.getSessionId(), tenantId);
if (session == null) {
throw exception(SESSION_NOT_FOUND);
@@ -315,18 +350,11 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
throw exception(SESSION_NOT_OWN);
}
if (!"ACTIVE".equals(session.getStatus())) {
// If session is SUBMITTED and a report exists, return it (different-key concurrent submit loser)
if ("SUBMITTED".equals(session.getStatus())) {
PracticeReportDO existingReport = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
if (existingReport != null) {
List<PracticeReportDetailDO> details =
reportDetailMapper.selectByReportIdAndTenantAndUser(
existingReport.getId(), tenantId, userId);
return buildSubmitResp(existingReport, details);
}
PracticeSubmitRespVO existingResponse = buildExistingSubmitResponse(session, tenantId, userId);
return completeSubmitClaim(claim, existingResponse);
}
throw exception(switch (session.getStatus()) {
case "SUBMITTED" -> SESSION_ALREADY_SUBMITTED;
case "EXPIRED" -> SESSION_EXPIRED;
case "CANCELLED" -> SESSION_CANCELLED;
default -> SUBMIT_SESSION_NOT_ACTIVE;
@@ -336,127 +364,59 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
throw exception(SUBMIT_STALE_VERSION, reqVO.getExpectedSessionVersion(), session.getVersion());
}
// 4. Load question snapshots and score
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdAndTenantIdOrderBySequence(session.getId(), tenantId);
int score = computeScore(questions);
int answeredCount = (int) questions.stream().filter(q -> q.getIsAnswered() != null && q.getIsAnswered()).count();
int unansweredCount = questions.size() - answeredCount;
int correctCount = (int) questions.stream()
.filter(q -> q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q))
.count();
int incorrectCount = answeredCount - correctCount;
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdAndTenantIdOrderBySequence(
session.getId(), tenantId);
ScoreResult scoreResult = scoringService.scoreSession(questions, tenantId, userId, session.getId());
List<PracticeReportDetailDO> details = scoreResult.getDetails();
PracticeReportDO report = scoreResult.getReport();
// 5. Build report details
List<PracticeReportDetailDO> details = new ArrayList<>(questions.size());
for (PracticeQuestionDO q : questions) {
PracticeReportDetailDO detail = new PracticeReportDetailDO();
detail.setTenantId(tenantId);
detail.setUserId(userId);
detail.setSessionId(session.getId());
detail.setQuestionId(q.getQuestionId());
detail.setSequence(q.getSequence());
detail.setStem(q.getStem());
detail.setType(q.getType());
detail.setDifficulty(q.getDifficulty());
detail.setSelectedAnswer(q.getSelectedAnswer());
detail.setCorrectAnswer(q.getCorrectAnswer());
detail.setIsCorrect(q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q));
detail.setExplanation(q.getExplanation());
detail.setOptions(q.getOptions());
detail.setContentVersion(q.getContentVersion() != null ? q.getContentVersion() : "");
details.add(detail);
}
// 6. Build report
PracticeReportDO report = new PracticeReportDO();
report.setTenantId(tenantId);
report.setUserId(userId);
report.setSessionId(session.getId());
report.setQuestionCount(questions.size());
report.setAnsweredCount(answeredCount);
report.setUnansweredCount(unansweredCount);
report.setCorrectCount(correctCount);
report.setIncorrectCount(incorrectCount);
report.setScore(score);
report.setStatus("SUBMITTED");
// 7. INSERT IGNORE report — resolves concurrent different-key submit races
int reportInserted = reportMapper.insertIgnore(report);
if (reportInserted == 0) {
// Another submit already committed for this session — return winner's report
PracticeReportDO winnerReport = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
if (winnerReport != null) {
SubmitIdempotencyDO winnerKey = submitIdempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", reqVO.getIdempotencyKey());
if (winnerKey != null && !Objects.equals(winnerKey.getRequestHash(), requestHash)) {
throw exception(SUBMIT_IDEMPOTENCY_CONFLICT);
}
// A different idempotency key may have won. Return the immutable winner report;
// do not create a second replay record for this losing key.
List<PracticeReportDetailDO> winnerDetails =
reportDetailMapper.selectByReportIdAndTenantAndUser(
winnerReport.getId(), tenantId, userId);
return buildSubmitResp(winnerReport, winnerDetails);
}
PracticeSubmitRespVO winnerResponse = buildExistingSubmitResponse(session, tenantId, userId);
return completeSubmitClaim(claim, winnerResponse);
}
report = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
if (report == null || report.getId() == null) {
throw exception(SUBMIT_CONCURRENT_CONFLICT);
}
// Re-select to get auto-generated ID
report = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
// 8. Insert details
for (PracticeReportDetailDO detail : details) {
detail.setReportId(report.getId());
}
reportDetailMapper.insertBatch(details);
// 8.5 Upsert wrong questions for incorrect answers (within same transaction)
wrongQuestionService.upsertWrongQuestions(tenantId, userId, report.getId(), session.getId(), details);
// 9. CAS: ACTIVE → SUBMITTED
int casResult = sessionMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<PracticeSessionDO>()
.eq(PracticeSessionDO::getId, session.getId())
.eq(PracticeSessionDO::getTenantId, tenantId)
.eq(PracticeSessionDO::getUserId, userId)
.eq(PracticeSessionDO::getStatus, "ACTIVE")
.eq(PracticeSessionDO::getVersion, reqVO.getExpectedSessionVersion())
.set(PracticeSessionDO::getStatus, "SUBMITTED")
.setSql("version = version + 1"));
if (casResult == 0) {
if (sessionMapper.casSubmit(session.getId(), tenantId, userId, reqVO.getExpectedSessionVersion()) == 0) {
log.error("Submit CAS failed for sessionId={}, expectedVersion={}",
session.getId(), reqVO.getExpectedSessionVersion());
throw exception(SUBMIT_CONCURRENT_CONFLICT);
}
// 10. Build final response
PracticeSubmitRespVO submitResp = PracticeSubmitRespVO.builder()
.reportId(report.getId())
.sessionId(session.getId())
.questionCount(questions.size())
.answeredCount(answeredCount)
.unansweredCount(unansweredCount)
.correctCount(correctCount)
.incorrectCount(incorrectCount)
.score(score)
.details(buildReportDetailVOs(details))
.build();
String responseJson = JsonUtils.toJsonString(submitResp);
return completeSubmitClaim(claim, scoringService.buildSubmitResponse(report, details));
}
// 11. Regular INSERT idempotency — only winners reach here (no race on same key)
// If DuplicateKeyException occurs, the transaction rolls back cleanly (CAS + details also rolled back).
SubmitIdempotencyDO idempotency = new SubmitIdempotencyDO();
idempotency.setTenantId(tenantId);
idempotency.setUserId(userId);
idempotency.setOperation("SUBMIT_SESSION");
idempotency.setIdempotencyKey(reqVO.getIdempotencyKey());
idempotency.setRequestHash(requestHash);
idempotency.setSessionId(session.getId());
idempotency.setReportId(report.getId());
idempotency.setStatus("ACCEPTED");
idempotency.setResponseJson(responseJson);
submitIdempotencyMapper.insert(idempotency);
private PracticeSubmitRespVO buildExistingSubmitResponse(PracticeSessionDO session, Long tenantId, Long userId) {
PracticeReportDO report = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
if (report == null) {
throw exception(SUBMIT_CONCURRENT_CONFLICT);
}
List<PracticeReportDetailDO> details = reportDetailMapper.selectByReportIdAndTenantAndUser(
report.getId(), tenantId, userId);
return scoringService.buildSubmitResponse(report, details);
}
return submitResp;
private PracticeSubmitRespVO completeSubmitClaim(IdempotencyDO claim, PracticeSubmitRespVO response) {
if (claim.getId() == null || response == null || response.getReportId() == null) {
throw exception(SUBMIT_IDEMPOTENCY_REPLAY_INVALID);
}
IdempotencyDO completed = idempotencyStore.completeSubmit(
claim.getId(), claim.getClaimToken(), response.getReportId(), JsonUtils.toJsonString(response));
if (completed == null) {
log.error("Failed to complete submit claim: id={}, claimToken={}, reportId={}",
claim.getId(), claim.getClaimToken(), response.getReportId());
throw exception(SUBMIT_IDEMPOTENCY_REPLAY_INVALID);
}
return response;
}
@Override
@@ -479,7 +439,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
List<PracticeReportDetailDO> details = reportDetailMapper.selectByReportIdAndTenantAndUser(
report.getId(), tenantId, userId);
return buildSubmitResp(report, details);
return scoringService.buildSubmitResponse(report, details);
}
@Override
@@ -491,7 +451,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
.map(report -> {
List<PracticeReportDetailDO> details =
reportDetailMapper.selectByReportIdAndTenantAndUser(report.getId(), tenantId, userId);
return buildSubmitResp(report, details);
return scoringService.buildSubmitResponse(report, details);
})
.collect(Collectors.toList());
@@ -506,6 +466,9 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
*/
private List<CatalogQuestionDTO> fetchAndOrderQuestions(String collectionId, String nodeId,
String type, String difficulty, int count) {
if (!questionCatalogProvider.isEnabled()) {
throw exception(CATALOG_DATA_SOURCE_DISABLED);
}
List<CatalogQuestionDTO> allQuestions = new ArrayList<>();
int pageNo = 1;
int pageSize = Math.min(count, 100);
@@ -513,9 +476,16 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
while (allQuestions.size() < count) {
CatalogQuestionPageResult page = questionCatalogProvider.listQuestions(
collectionId, nodeId, type, difficulty, pageNo, pageSize);
if (page == null || page.getItems() == null) {
throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
}
if (CollUtil.isEmpty(page.getItems())) {
break;
}
for (CatalogQuestionDTO question : page.getItems()) {
QuestionContentSafety.validateVisibleProviderQuestion(question, QUESTION_NOT_VISIBLE);
QuestionContentSafety.validateProviderOptions(question.getType(), question.getOptions());
}
allQuestions.addAll(page.getItems());
if (page.getItems().size() < pageSize) {
break;
@@ -535,25 +505,6 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
return allQuestions;
}
/**
* 将选项列表转换为安全 JSON 字符串(不含 isCorrect
*/
private String optionsToSafeJson(List<CatalogQuestionDTO.QuestionOptionDTO> options) {
if (CollUtil.isEmpty(options)) {
return "[]";
}
List<Map<String, Object>> safeOptions = options.stream()
.map(o -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("label", o.getLabel());
m.put("content", o.getContent());
m.put("order", o.getOrder());
return m;
})
.collect(Collectors.toList());
return JsonUtils.toJsonString(safeOptions);
}
/**
* Canonical answer representation for storage.
* String → stored as-is (e.g. "B"); List → sorted JSON array (e.g. '["A","C"]');
@@ -576,156 +527,6 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
return JsonUtils.toJsonString(correctAnswer);
}
/**
* 构建会话响应 VO。
*/
private PracticeSessionRespVO buildSessionResp(PracticeSessionDO session, List<PracticeQuestionDO> questions) {
List<PracticeQuestionRespVO> questionVOs = questions.stream()
.map(this::buildQuestionResp)
.collect(Collectors.toList());
return PracticeSessionRespVO.builder()
.sessionId(session.getId())
.status(session.getStatus())
.questionCount(session.getQuestionCount())
.version(session.getVersion())
.clientSessionId(session.getClientSessionId())
.lastClientSequence(session.getLastClientSequence())
.questions(questionVOs)
.build();
}
/**
* 构建单题响应 VO — 从快照安全还原。
* 绝不包含 correctAnswer/explanation仅暴露 label/content/order。
*/
private PracticeQuestionRespVO buildQuestionResp(PracticeQuestionDO pq) {
List<PracticeQuestionRespVO.OptionVO> options = parseOptions(pq.getOptions());
return PracticeQuestionRespVO.builder()
.sequence(pq.getSequence())
.questionId(pq.getQuestionId())
.stem(pq.getStem())
.type(pq.getType())
.difficulty(pq.getDifficulty())
.options(options)
.selectedAnswer(pq.getSelectedAnswer())
.isAnswered(pq.getIsAnswered() != null && pq.getIsAnswered())
.contentVersion(pq.getContentVersion())
.build();
}
@SuppressWarnings({"unchecked", "rawtypes"})
private List<PracticeQuestionRespVO.OptionVO> parseOptions(String optionsJson) {
if (optionsJson == null || optionsJson.isEmpty()) {
return Collections.emptyList();
}
List<Map<String, Object>> raw = (List) JsonUtils.parseArray(optionsJson, Map.class);
if (raw == null) {
return Collections.emptyList();
}
return raw.stream()
.map(m -> PracticeQuestionRespVO.OptionVO.builder()
.label((String) m.get("label"))
.content((String) m.get("content"))
.order(m.get("order") != null ? ((Number) m.get("order")).doubleValue() : null)
.build())
.collect(Collectors.toList());
}
/**
* Build submit response from report + details.
*/
private PracticeSubmitRespVO buildSubmitResp(PracticeReportDO report, List<PracticeReportDetailDO> details) {
return PracticeSubmitRespVO.builder()
.reportId(report.getId())
.sessionId(report.getSessionId())
.questionCount(report.getQuestionCount())
.answeredCount(report.getAnsweredCount())
.unansweredCount(report.getUnansweredCount())
.correctCount(report.getCorrectCount())
.incorrectCount(report.getIncorrectCount())
.score(report.getScore())
.details(buildReportDetailVOs(details))
.build();
}
/**
* Convert report detail DOs to VOs (exposes correctAnswer/explanation for submitted sessions only).
*/
private List<PracticeReportDetailRespVO> buildReportDetailVOs(List<PracticeReportDetailDO> details) {
return details.stream()
.map(d -> PracticeReportDetailRespVO.builder()
.sequence(d.getSequence())
.questionId(d.getQuestionId())
.stem(d.getStem())
.type(d.getType())
.difficulty(d.getDifficulty())
.selectedAnswer(d.getSelectedAnswer())
.correctAnswer(d.getCorrectAnswer())
.isCorrect(d.getIsCorrect())
.explanation(d.getExplanation())
.build())
.collect(Collectors.toList());
}
/**
* Normalize a stored answer string into its canonical form for comparison.
* - null/empty → null (treated as no answer / no correct answer)
* - JSON array (starts with '[') → parse as String list, sort, re-serialize
* - plain string → trim
* Malformed JSON arrays fall through as plain string comparison.
*/
private String normalizeAnswer(String stored) {
if (stored == null || stored.isEmpty()) return null;
String trimmed = stored.trim();
if (trimmed.startsWith("[")) {
try {
@SuppressWarnings({"unchecked", "rawtypes"})
List<String> list = (List) JsonUtils.parseArray(trimmed, String.class);
if (list != null) {
List<String> sorted = new ArrayList<>(list);
Collections.sort(sorted);
return JsonUtils.toJsonString(sorted);
}
} catch (Exception e) {
// Not valid JSON array → compare as plain string below
}
}
return trimmed;
}
/**
* Check if the student's selected answer matches the correct answer snapshot.
* Uses canonical normalization: single choice compares trimmed strings;
* multiple choice compares sorted sets order-insensitively via normalized JSON.
* Fail-closed: null/empty on either side → false.
*/
private boolean isAnswerCorrect(PracticeQuestionDO question) {
String normCorrect = normalizeAnswer(question.getCorrectAnswer());
String normSelected = normalizeAnswer(question.getSelectedAnswer());
if (normCorrect == null || normSelected == null) {
return false;
}
return normCorrect.equals(normSelected);
}
/**
* Compute a score from the question snapshots on a 100-point integer basis.
* Deterministic: answered correct / total questions * 100, rounded half-up to integer.
*/
private int computeScore(List<PracticeQuestionDO> questions) {
int total = questions.size();
if (total == 0) {
return 0;
}
int correct = (int) questions.stream()
.filter(q -> q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q))
.count();
// Deterministic rounding: (correct * 100 + total/2) / total
return (int) ((correct * 100L + total / 2) / total);
}
/**
* Verify that the existing session's immutable selection criteria match the current request.
*/
@@ -740,6 +541,51 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
/**
* Compute SHA-256 hash of canonical answer request payload for idempotency content checking.
*/
private PracticeAnswerRespVO parseAnswerReplay(String responseJson) {
if (responseJson == null || responseJson.isBlank()) {
throw exception(ANSWER_IDEMPOTENCY_REPLAY_INVALID);
}
PracticeAnswerRespVO response;
try {
response = JsonUtils.parseObject(responseJson, PracticeAnswerRespVO.class);
} catch (Exception ex) {
throw exception(ANSWER_IDEMPOTENCY_REPLAY_INVALID);
}
if (response == null || response.getSessionId() == null || response.getQuestionSequence() == null
|| response.getServerVersion() == null || response.getAcceptedSequence() == null) {
throw exception(ANSWER_IDEMPOTENCY_REPLAY_INVALID);
}
return response;
}
private PracticeSubmitRespVO parseSubmitReplay(IdempotencyDO existing) {
if (!"COMPLETED".equals(existing.getStatus()) || existing.getReportId() == null
|| existing.getResponseJson() == null || existing.getResponseJson().isBlank()) {
log.warn("Invalid submit replay state: id={}, status={}, reportId={}, sessionId={}, responseBlank={}",
existing.getId(), existing.getStatus(), existing.getReportId(), existing.getSessionId(),
existing.getResponseJson() == null || existing.getResponseJson().isBlank());
throw exception(SUBMIT_IDEMPOTENCY_REPLAY_INVALID);
}
PracticeSubmitRespVO response;
try {
response = JsonUtils.parseObject(existing.getResponseJson(), PracticeSubmitRespVO.class);
} catch (Exception ex) {
throw exception(SUBMIT_IDEMPOTENCY_REPLAY_INVALID);
}
if (response == null || response.getReportId() == null || response.getSessionId() == null
|| response.getQuestionCount() == null || response.getAnsweredCount() == null
|| response.getUnansweredCount() == null || response.getCorrectCount() == null
|| response.getIncorrectCount() == null || response.getScore() == null
|| response.getDetails() == null
|| !Objects.equals(existing.getReportId(), response.getReportId())
|| !Objects.equals(existing.getSessionId(), response.getSessionId())) {
log.warn("Invalid submit replay payload: id={}, storedReportId={}, storedSessionId={}, response={}",
existing.getId(), existing.getReportId(), existing.getSessionId(), existing.getResponseJson());
throw exception(SUBMIT_IDEMPOTENCY_REPLAY_INVALID);
}
return response;
}
private String computeRequestHash(PracticeAnswerReqVO reqVO) {
Map<String, Object> canonical = new TreeMap<>();
canonical.put("clientSequence", reqVO.getClientSequence());

View File

@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDetailDO;
import cn.iocoder.yudao.module.education.service.practice.dto.ScoreResult;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* 评分服务接口。
*
* <p>负责交卷时的评分计算、报告构建和响应 VO 组装。
* 评分规则:正确题数 / 总题数 × 100四舍五入取整。</p>
*
* @author 恭学教育
*/
public interface ScoringService {
/**
* 对会话题目进行评分,构建报告和逐题明细。
*
* @param questions 会话题目快照列表(含学生答案和正确答案)
* @param tenantId 租户编号
* @param userId 用户编号
* @param sessionId 会话编号
* @return 评分结果(含统计数据、逐题明细和报告实体)
*/
ScoreResult scoreSession(@NotNull List<PracticeQuestionDO> questions,
@NotNull Long tenantId,
@NotNull Long userId,
@NotNull Long sessionId);
/**
* 根据报告和明细构建前端交卷响应 VO。
*
* <p>将 DO 字段映射到 PracticeSubmitRespVO明细中暴露正确答案和解析。</p>
*
* @param report 练习报告
* @param details 逐题明细列表
* @return 交卷提交响应 VO
*/
PracticeSubmitRespVO buildSubmitResponse(@Valid PracticeReportDO report,
@NotNull List<PracticeReportDetailDO> details);
}

View File

@@ -0,0 +1,205 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeReportDetailRespVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDetailDO;
import cn.iocoder.yudao.module.education.service.practice.dto.ScoreResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.*;
import java.util.stream.Collectors;
/**
* 评分服务实现。
*
* <p>提供交卷时的评分工功能,包括得分计算、答案比较、报告实体构建和前端 VO 组装。
* 评分规则:正确题数 / 总题数 × 100四舍五入取整。</p>
*
* @author 恭学教育
*/
@Service
@Validated
@Slf4j
public class ScoringServiceImpl implements ScoringService {
@Override
public ScoreResult scoreSession(List<PracticeQuestionDO> questions,
Long tenantId, Long userId, Long sessionId) {
// 1. 计算得分与统计
int total = questions.size();
int score = computeScore(questions);
int answeredCount = (int) questions.stream()
.filter(q -> q.getIsAnswered() != null && q.getIsAnswered())
.count();
int unansweredCount = total - answeredCount;
int correctCount = (int) questions.stream()
.filter(q -> q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q))
.count();
int incorrectCount = answeredCount - correctCount;
// 2. 构建逐题明细
List<PracticeReportDetailDO> details = new ArrayList<>(total);
for (PracticeQuestionDO q : questions) {
PracticeReportDetailDO detail = new PracticeReportDetailDO();
detail.setTenantId(tenantId);
detail.setUserId(userId);
detail.setSessionId(sessionId);
detail.setQuestionId(q.getQuestionId());
detail.setSequence(q.getSequence());
detail.setStem(q.getStem());
detail.setType(q.getType());
detail.setDifficulty(q.getDifficulty());
detail.setSelectedAnswer(q.getSelectedAnswer());
detail.setCorrectAnswer(q.getCorrectAnswer());
detail.setIsCorrect(q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q));
detail.setExplanation(q.getExplanation());
detail.setOptions(q.getOptions());
detail.setContentVersion(q.getContentVersion() != null ? q.getContentVersion() : "");
details.add(detail);
}
// 3. 构建报告
PracticeReportDO report = new PracticeReportDO();
report.setTenantId(tenantId);
report.setUserId(userId);
report.setSessionId(sessionId);
report.setQuestionCount(total);
report.setAnsweredCount(answeredCount);
report.setUnansweredCount(unansweredCount);
report.setCorrectCount(correctCount);
report.setIncorrectCount(incorrectCount);
report.setScore(score);
report.setStatus("SUBMITTED");
return ScoreResult.builder()
.total(total)
.answered(answeredCount)
.unanswered(unansweredCount)
.correct(correctCount)
.incorrect(incorrectCount)
.score(score)
.details(details)
.report(report)
.build();
}
@Override
public PracticeSubmitRespVO buildSubmitResponse(PracticeReportDO report,
List<PracticeReportDetailDO> details) {
return PracticeSubmitRespVO.builder()
.reportId(report.getId())
.sessionId(report.getSessionId())
.questionCount(report.getQuestionCount())
.answeredCount(report.getAnsweredCount())
.unansweredCount(report.getUnansweredCount())
.correctCount(report.getCorrectCount())
.incorrectCount(report.getIncorrectCount())
.score(report.getScore())
.details(buildReportDetailVOs(details))
.build();
}
// ========== 内部评分逻辑 ==========
/**
* 计算得分(满分 100 为基准)。
*
* <p>正确题数 / 总题数 × 100四舍五入取整。总题数为 0 时返回 0。</p>
*
* @param questions 会话题目快照列表
* @return 整数得分0100
*/
private int computeScore(List<PracticeQuestionDO> questions) {
int total = questions.size();
if (total == 0) {
return 0;
}
int correct = (int) questions.stream()
.filter(q -> q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q))
.count();
// 四舍五入:(correct × 100 + total / 2) / total
return (int) ((correct * 100L + total / 2) / total);
}
/**
* 判断学生答案是否正确。
*
* <p>使用规范化比较:单选用 trim 后字符串比较,多选用排序后的 JSON 数组比较。
* 任一侧为 null/空时返回 falsefail-closed。</p>
*
* @param question 会话题目快照
* @return true 回答正确false 回答错误或答案缺失
*/
private boolean isAnswerCorrect(PracticeQuestionDO question) {
String normCorrect = normalizeAnswer(question.getCorrectAnswer());
String normSelected = normalizeAnswer(question.getSelectedAnswer());
if (normCorrect == null || normSelected == null) {
return false;
}
return normCorrect.equals(normSelected);
}
/**
* 将答案字符串规范化为可比对的标准形式。
*
* <ul>
* <li>null/空 → null视为无答案</li>
* <li>JSON 数组(以 '[' 开头)→ 解析为字符串列表,排序后重新序列化</li>
* <li>普通字符串 → trim</li>
* </ul>
* <p>JSON 解析失败时降级为普通字符串比较,不抛异常。</p>
*
* @param stored 存储的答案字符串
* @return 规范化后的答案字符串,或 null 表示无答案
*/
private String normalizeAnswer(String stored) {
if (stored == null || stored.isEmpty()) {
return null;
}
String trimmed = stored.trim();
if (trimmed.startsWith("[")) {
try {
@SuppressWarnings({"unchecked", "rawtypes"})
List<String> list = (List) JsonUtils.parseArray(trimmed, String.class);
if (list != null) {
List<String> sorted = new ArrayList<>(list);
Collections.sort(sorted);
return JsonUtils.toJsonString(sorted);
}
} catch (Exception e) {
// 非有效 JSON 数组 → 降级为普通字符串比较
}
}
return trimmed;
}
/**
* 将报告明细 DO 列表转换为前端 VO 列表。
*
* <p>映射中暴露正确答案和解析,仅在有报告的场景下使用。</p>
*
* @param details 报告明细 DO 列表
* @return 报告明细 VO 列表
*/
private List<PracticeReportDetailRespVO> buildReportDetailVOs(List<PracticeReportDetailDO> details) {
return details.stream()
.map(d -> PracticeReportDetailRespVO.builder()
.sequence(d.getSequence())
.questionId(d.getQuestionId())
.stem(d.getStem())
.type(d.getType())
.difficulty(d.getDifficulty())
.selectedAnswer(d.getSelectedAnswer())
.correctAnswer(d.getCorrectAnswer())
.isCorrect(d.getIsCorrect())
.explanation(d.getExplanation())
.build())
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,74 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeQuestionRespVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety;
import java.util.List;
/**
* 会话响应组装工具类。
*
* <p>将 {@link PracticeSessionDO} 与 {@link PracticeQuestionDO} 组装为前端 VO
* 保证答案与解析字段绝不泄露到响应中。
*
* @author 恭学教育
*/
public class SessionResponseAssembler {
/**
* 构建会话响应 VO。
*
* @param session 练习会话
* @param questions 关联的题目快照列表
* @return 会话响应 VO
*/
public static PracticeSessionRespVO buildSessionResp(PracticeSessionDO session, List<PracticeQuestionDO> questions) {
List<PracticeQuestionRespVO> questionVOs = questions.stream()
.map(SessionResponseAssembler::buildQuestionResp)
.toList();
return PracticeSessionRespVO.builder()
.sessionId(session.getId())
.status(session.getStatus())
.questionCount(session.getQuestionCount())
.version(session.getVersion())
.clientSessionId(session.getClientSessionId())
.lastClientSequence(session.getLastClientSequence())
.questions(questionVOs)
.build();
}
/**
* 构建单题响应 VO — 从快照安全还原。
*
* <p>绝不包含 correctAnswer/explanation仅暴露 label/content/order。
*
* @param pq 题目快照 DO
* @return 题目响应 VO
*/
public static PracticeQuestionRespVO buildQuestionResp(PracticeQuestionDO pq) {
List<PracticeQuestionRespVO.OptionVO> options = QuestionContentSafety
.restoreSnapshotOptions(pq.getType(), pq.getOptions()).stream()
.map(option -> PracticeQuestionRespVO.OptionVO.builder()
.label(option.label())
.content(option.content())
.order(option.order())
.build())
.toList();
return PracticeQuestionRespVO.builder()
.sequence(pq.getSequence())
.questionId(pq.getQuestionId())
.stem(pq.getStem())
.type(pq.getType())
.difficulty(pq.getDifficulty())
.options(options)
.selectedAnswer(pq.getSelectedAnswer())
.isAnswered(pq.getIsAnswered() != null && pq.getIsAnswered())
.contentVersion(pq.getContentVersion())
.build();
}
}

View File

@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.education.service.practice.dto;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDetailDO;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 评分结果 DTO。
*
* <p>封装一次交卷评分计算的所有结果,包括统计数据、逐题明细和报告实体。
* 由 {@link cn.iocoder.yudao.module.education.service.practice.ScoringService} 返回。</p>
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScoreResult {
/** 题目总数 */
private int total;
/** 已答题数 */
private int answered;
/** 未答题数 */
private int unanswered;
/** 正确题数 */
private int correct;
/** 错误题数 */
private int incorrect;
/** 得分(整数,满分 100 为基准) */
private int score;
/** 逐题明细(含租户/用户/会话关联) */
private List<PracticeReportDetailDO> details;
/** 练习报告(会话级评分结果,未持久化) */
private PracticeReportDO report;
}

View File

@@ -10,9 +10,7 @@ import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@@ -52,11 +50,11 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
// Fail-closed: provider contract guarantees visible-only items.
// Any invisible item → reject the entire response.
for (CatalogQuestionDTO item : pageResult.getItems()) {
QuestionContentSafety.validateVisibleProviderQuestion(item, QUESTION_NOT_VISIBLE);
}
List<SafeQuestionRespVO> safeList = new ArrayList<>(pageResult.getItems().size());
for (CatalogQuestionDTO item : pageResult.getItems()) {
if (!isVisible(item)) {
throw exception(QUESTION_NOT_VISIBLE);
}
safeList.add(toSafeVO(item));
}
@@ -73,10 +71,7 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
throw exception(QUESTION_NOT_FOUND);
}
if (!isVisible(question)) {
throw exception(QUESTION_NOT_FOUND);
}
QuestionContentSafety.validateVisibleProviderQuestion(question, QUESTION_NOT_FOUND);
return toSafeVO(question);
}
@@ -93,11 +88,11 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
}
// Fail-closed: provider contract guarantees visible-only items.
for (CatalogQuestionDTO item : pageResult.getItems()) {
QuestionContentSafety.validateVisibleProviderQuestion(item, QUESTION_NOT_VISIBLE);
}
List<SafeQuestionRespVO> safeList = new ArrayList<>(pageResult.getItems().size());
for (CatalogQuestionDTO item : pageResult.getItems()) {
if (!isVisible(item)) {
throw exception(QUESTION_NOT_VISIBLE);
}
safeList.add(toSafeVO(item));
}
@@ -161,19 +156,6 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
}
}
/**
* 判断题目是否对学生可见。
* 只有已发布isPublished=true且非隐藏状态status 不为 "hidden"/"inactive")的题目才可见。
* 如果上游未提供 isPublished 字段默认不可见fail closed
*/
private boolean isVisible(CatalogQuestionDTO q) {
if (q == null) return false;
if (q.getIsPublished() == null || !q.getIsPublished()) return false;
if (q.getStatus() == null) return true;
return !"hidden".equalsIgnoreCase(q.getStatus())
&& !"inactive".equalsIgnoreCase(q.getStatus());
}
// ========== DTO → safe VO conversion ==========
/**
@@ -182,6 +164,8 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
*/
static SafeQuestionRespVO toSafeVO(CatalogQuestionDTO dto) {
if (dto == null) return null;
List<QuestionContentSafety.SafeOption> options =
QuestionContentSafety.validateProviderOptions(dto.getType(), dto.getOptions());
SafeQuestionRespVO vo = SafeQuestionRespVO.builder()
.id(dto.getId())
.contentVersion(dto.getContentVersion())
@@ -190,15 +174,13 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
.difficulty(dto.getDifficulty())
.build();
if (dto.getOptions() != null) {
vo.setOptions(dto.getOptions().stream()
.map(opt -> SafeQuestionRespVO.SafeOptionVO.builder()
.label(opt.getLabel())
.content(opt.getContent())
.order(opt.getOrder())
.build())
.collect(Collectors.toList()));
}
vo.setOptions(options.stream()
.map(option -> SafeQuestionRespVO.SafeOptionVO.builder()
.label(option.label())
.content(option.content())
.order(option.order())
.build())
.toList());
return vo;
}

View File

@@ -0,0 +1,163 @@
package cn.iocoder.yudao.module.education.service.question;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_UPSTREAM_OPTIONS_MALFORMED;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.UNSAFE_PROVIDER_PAYLOAD;
/**
* 题目内容安全校验。
*
* <p>统一校验题型与选项结构,确保不同题库 Provider 和练习快照采用相同的 fail-closed 规则。</p>
*
* @author 恭学教育
*/
public final class QuestionContentSafety {
private static final Set<String> OPTION_BACKED_TYPES = Set.of(
"choice", "multi", "multi_choice", "judge", "image");
private static final Set<String> OPTIONLESS_TYPES = Set.of(
"fill", "text", "terms", "short_answer", "composition", "discuss", "translation",
"case_analysis", "brief_analysis", "calculation", "analysis_design", "combination", "solution");
private QuestionContentSafety() {
}
/**
* 校验 Provider 返回的题目是否可供学生使用。
*/
public static void validateVisibleProviderQuestion(CatalogQuestionDTO question, ErrorCode visibilityError) {
if (question == null || question.getIsPublished() == null || !question.getIsPublished()
|| "hidden".equalsIgnoreCase(question.getStatus())
|| "inactive".equalsIgnoreCase(question.getStatus())) {
throw exception(visibilityError);
}
}
/**
* 校验 Provider 返回的选项,并转换为不含答案信息的安全选项。
*/
public static List<SafeOption> validateProviderOptions(
String type, List<CatalogQuestionDTO.QuestionOptionDTO> options) {
if (options == null) {
return validate(type, Collections.emptyList(), UNSAFE_PROVIDER_PAYLOAD);
}
List<SafeOption> safeOptions = new ArrayList<>(options.size());
for (CatalogQuestionDTO.QuestionOptionDTO option : options) {
if (option == null) {
throw exception(UNSAFE_PROVIDER_PAYLOAD);
}
safeOptions.add(new SafeOption(option.getLabel(), option.getContent(), option.getOrder()));
}
return validate(type, safeOptions, UNSAFE_PROVIDER_PAYLOAD);
}
/**
* 将 Provider 选项转换为安全的练习快照 JSON。
*/
public static String toSafeSnapshotJson(String type, List<CatalogQuestionDTO.QuestionOptionDTO> options) {
return JsonUtils.toJsonString(validateProviderOptions(type, options));
}
/**
* 从练习快照中恢复并校验安全选项。
*/
@SuppressWarnings({"unchecked", "rawtypes"})
public static List<SafeOption> restoreSnapshotOptions(String type, String optionsJson) {
List<SafeOption> options = new ArrayList<>();
if (optionsJson != null && !optionsJson.isBlank()) {
List<Map<String, Object>> raw;
try {
raw = (List) JsonUtils.parseArray(optionsJson, Map.class);
} catch (Exception ex) {
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
if (raw == null) {
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
try {
for (Map<String, Object> item : raw) {
if (item == null) {
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
if (!item.keySet().equals(Set.of("label", "content", "order"))) {
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
Object label = item.get("label");
Object content = item.get("content");
Object order = item.get("order");
if (!(label instanceof String) || !(content instanceof String)
|| (order != null && !(order instanceof Number))) {
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
options.add(new SafeOption((String) label, (String) content,
order != null ? ((Number) order).doubleValue() : null));
}
} catch (ClassCastException ex) {
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
}
return validate(type, options, CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
public static boolean isOptionBackedType(String rawType) {
String type = rawType != null ? rawType.trim().toLowerCase(Locale.ROOT) : "";
return OPTION_BACKED_TYPES.contains(type);
}
private static List<SafeOption> validate(String rawType, List<SafeOption> options, ErrorCode errorCode) {
String type = rawType != null ? rawType.trim().toLowerCase(Locale.ROOT) : "";
if (!OPTION_BACKED_TYPES.contains(type) && !OPTIONLESS_TYPES.contains(type)) {
throw exception(errorCode);
}
if (OPTIONLESS_TYPES.contains(type)) {
if (!options.isEmpty()) {
throw exception(errorCode);
}
return Collections.emptyList();
}
if (options.size() < 2) {
throw exception(errorCode);
}
Set<String> labels = new HashSet<>();
Set<Double> orders = new HashSet<>();
List<IndexedOption> validated = new ArrayList<>(options.size());
for (int index = 0; index < options.size(); index++) {
SafeOption option = options.get(index);
if (option == null || option.label() == null || option.label().isBlank()
|| option.content() == null || option.content().isBlank()) {
throw exception(errorCode);
}
String label = option.label().trim();
String content = option.content().trim();
if (!labels.add(label)) {
throw exception(errorCode);
}
Double order = option.order();
if (order != null && (!Double.isFinite(order) || !orders.add(order))) {
throw exception(errorCode);
}
validated.add(new IndexedOption(new SafeOption(label, content, order), index));
}
validated.sort(Comparator
.comparing((IndexedOption item) -> item.option().order(), Comparator.nullsLast(Double::compareTo))
.thenComparingInt(IndexedOption::index));
return validated.stream().map(IndexedOption::option).toList();
}
/**
* 学生端安全选项,不包含 isCorrect 等答案字段。
*/
public record SafeOption(String label, String content, Double order) {
}
private record IndexedOption(SafeOption option, int index) {
}
}

View File

@@ -17,6 +17,7 @@ import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.WrongQuestionIdempotencyMapper;
import cn.iocoder.yudao.module.education.dal.mysql.WrongQuestionMapper;
import cn.iocoder.yudao.module.education.service.practice.SessionResponseAssembler;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
@@ -148,7 +149,7 @@ public class WrongQuestionServiceImpl implements WrongQuestionService {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
// Same tenant + clientSessionId + user + fingerprint → replay
return buildSessionResp(existing, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(existing.getId(), tenantId));
return SessionResponseAssembler.buildSessionResp(existing, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(existing.getId(), tenantId));
}
// 3. Load all wrong questions by ID, verify ownership
@@ -162,7 +163,7 @@ public class WrongQuestionServiceImpl implements WrongQuestionService {
}
}
// 4. Create session via INSERT IGNORE (safe concurrent create race resolution)
// 4. Create session via ON CONFLICT DO NOTHING (safe concurrent create race resolution)
PracticeSessionDO session = new PracticeSessionDO();
session.setTenantId(tenantId);
session.setUserId(userId);
@@ -184,7 +185,7 @@ public class WrongQuestionServiceImpl implements WrongQuestionService {
if (!Objects.equals(winner.getReviewFingerprint(), fingerprint)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
return buildSessionResp(winner, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(winner.getId(), tenantId));
return SessionResponseAssembler.buildSessionResp(winner, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(winner.getId(), tenantId));
}
// 4. Create question snapshots from wrong question data (NO correct answer exposed pre-submit)
@@ -208,7 +209,7 @@ public class WrongQuestionServiceImpl implements WrongQuestionService {
}
questionMapper.insertBatch(questionDOs);
return buildSessionResp(session, questionDOs);
return SessionResponseAssembler.buildSessionResp(session, questionDOs);
}
// ========== Upsert (internal, called from submitSession) ==========
@@ -233,7 +234,7 @@ public class WrongQuestionServiceImpl implements WrongQuestionService {
continue;
}
// 1. INSERT IGNORE idempotency guard — per (tenant, user, question, report)
// 1. ON CONFLICT DO NOTHING idempotency guard — per (tenant, user, question, report)
WrongQuestionIdempotencyDO idem = new WrongQuestionIdempotencyDO();
idem.setTenantId(tenantId);
idem.setUserId(userId);
@@ -329,54 +330,5 @@ public class WrongQuestionServiceImpl implements WrongQuestionService {
.build();
}
private PracticeSessionRespVO buildSessionResp(PracticeSessionDO session, List<PracticeQuestionDO> questions) {
List<PracticeQuestionRespVO> questionVOs = questions.stream()
.map(pq -> {
List<PracticeQuestionRespVO.OptionVO> options = parseOptions(pq.getOptions());
return PracticeQuestionRespVO.builder()
.sequence(pq.getSequence())
.questionId(pq.getQuestionId())
.stem(pq.getStem())
.type(pq.getType())
.difficulty(pq.getDifficulty())
.options(options)
.selectedAnswer(pq.getSelectedAnswer())
.isAnswered(pq.getIsAnswered() != null && pq.getIsAnswered())
.contentVersion(pq.getContentVersion())
.build();
})
.collect(Collectors.toList());
return PracticeSessionRespVO.builder()
.sessionId(session.getId())
.status(session.getStatus())
.questionCount(session.getQuestionCount())
.version(session.getVersion())
.clientSessionId(session.getClientSessionId())
.lastClientSequence(session.getLastClientSequence())
.questions(questionVOs)
.build();
}
@SuppressWarnings({"unchecked", "rawtypes"})
private List<PracticeQuestionRespVO.OptionVO> parseOptions(String optionsJson) {
if (optionsJson == null || optionsJson.isEmpty()) {
return Collections.emptyList();
}
try {
List<Map<String, Object>> raw = (List) cn.iocoder.yudao.framework.common.util.json.JsonUtils.parseArray(optionsJson, Map.class);
if (raw == null) {
return Collections.emptyList();
}
return raw.stream()
.map(m -> PracticeQuestionRespVO.OptionVO.builder()
.label((String) m.get("label"))
.content((String) m.get("content"))
.order(m.get("order") != null ? ((Number) m.get("order")).doubleValue() : null)
.build())
.collect(Collectors.toList());
} catch (Exception e) {
return Collections.emptyList();
}
}
// buildSessionResp / parseOptions 委托给 SessionResponseAssembler消除重复代码
}

View File

@@ -0,0 +1,7 @@
-- Education 模块 Flyway 起始迁移PostgreSQL
--
-- 既有数据库在首次启动时会建立 V4009 baseline本文件作为其后的首个受管版本。
-- 当前版本仅验证 Flyway 接管链路;后续数据库变更请新增更高版本的 migration
-- 不要修改已经在共享环境执行过的脚本。
SELECT 1;

View File

@@ -0,0 +1,401 @@
-- Education 原生题库目录PostgreSQL
--
-- 目录数据采用显式 scope
-- TENANT_OWNED - 仅归属租户可见;
-- PUBLIC - 所有已认证租户可读tenant_id 固定为 0。
-- 应用层只能通过受控目录查询接缝读取 PUBLIC 数据,不得全局关闭租户隔离。
CREATE TABLE education_region (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
legacy_id VARCHAR(64),
name VARCHAR(100) NOT NULL,
code VARCHAR(32),
short_name VARCHAR(50),
full_name VARCHAR(200),
icon VARCHAR(500),
pinyin VARCHAR(200),
is_hot BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_region_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
)
);
COMMENT ON TABLE education_region IS '教育-地区';
COMMENT ON COLUMN education_region.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_region_tenant_active_order
ON education_region (tenant_id, is_active, sort_order) WHERE deleted = false;
CREATE INDEX idx_education_region_public_active_order
ON education_region (is_active, sort_order) WHERE deleted = false AND scope = 'PUBLIC';
CREATE TABLE education_school (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
legacy_id VARCHAR(64),
region_id BIGINT,
module_id BIGINT,
name VARCHAR(200) NOT NULL,
professional_exam_date VARCHAR(200),
metadata JSONB,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_school_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_school_region FOREIGN KEY (region_id) REFERENCES education_region (id)
);
COMMENT ON TABLE education_school IS '教育-院校';
COMMENT ON COLUMN education_school.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_school_tenant_region_order
ON education_school (tenant_id, region_id, sort_order) WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_school_public_region_order
ON education_school (region_id, sort_order) WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
CREATE TABLE education_major (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
legacy_id VARCHAR(64),
region_id BIGINT,
school_id BIGINT,
name VARCHAR(200) NOT NULL,
description VARCHAR(500),
study_tips VARCHAR(500),
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_major_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_major_region FOREIGN KEY (region_id) REFERENCES education_region (id),
CONSTRAINT fk_education_major_school FOREIGN KEY (school_id) REFERENCES education_school (id)
);
COMMENT ON TABLE education_major IS '教育-专业';
COMMENT ON COLUMN education_major.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_major_tenant_school_order
ON education_major (tenant_id, school_id, sort_order) WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_major_public_school_order
ON education_major (school_id, sort_order) WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
CREATE TABLE education_subject (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
region_id BIGINT,
school_id BIGINT,
major_id BIGINT,
module_id BIGINT,
name VARCHAR(200) NOT NULL,
type VARCHAR(50),
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_subject_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_subject_region FOREIGN KEY (region_id) REFERENCES education_region (id),
CONSTRAINT fk_education_subject_school FOREIGN KEY (school_id) REFERENCES education_school (id),
CONSTRAINT fk_education_subject_major FOREIGN KEY (major_id) REFERENCES education_major (id)
);
COMMENT ON TABLE education_subject IS '教育-科目';
COMMENT ON COLUMN education_subject.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_subject_tenant_filters
ON education_subject (tenant_id, region_id, school_id, major_id, type, sort_order)
WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_subject_public_filters
ON education_subject (region_id, school_id, major_id, type, sort_order)
WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
CREATE TABLE education_category (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
subject_id BIGINT,
legacy_node_id VARCHAR(64),
name VARCHAR(200) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_category_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_category_subject FOREIGN KEY (subject_id) REFERENCES education_subject (id)
);
COMMENT ON TABLE education_category IS '教育-题目分类';
COMMENT ON COLUMN education_category.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_category_tenant_subject_order
ON education_category (tenant_id, subject_id, sort_order) WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_category_public_subject_order
ON education_category (subject_id, sort_order) WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
CREATE TABLE education_content_entry (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
legacy_id VARCHAR(64),
region_id BIGINT,
entry_key VARCHAR(100) NOT NULL,
name VARCHAR(200) NOT NULL,
entry_type VARCHAR(50) NOT NULL,
icon VARCHAR(500),
route VARCHAR(200),
description VARCHAR(500),
access_rules JSONB,
layout_config JSONB,
is_hidden BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_content_entry_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_content_entry_region FOREIGN KEY (region_id) REFERENCES education_region (id),
CONSTRAINT uk_education_content_entry_scope_key UNIQUE (tenant_id, entry_key)
);
COMMENT ON TABLE education_content_entry IS '教育-内容入口';
COMMENT ON COLUMN education_content_entry.scope IS '内容范围PUBLIC、TENANT_OWNED';
COMMENT ON COLUMN education_content_entry.is_hidden IS '是否对学生端隐藏';
CREATE INDEX idx_education_content_entry_tenant_filters
ON education_content_entry (tenant_id, region_id, entry_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false;
CREATE INDEX idx_education_content_entry_public_filters
ON education_content_entry (region_id, entry_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false AND scope = 'PUBLIC';
CREATE TABLE education_content_node (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
entry_id BIGINT NOT NULL,
parent_id BIGINT,
name VARCHAR(200) NOT NULL,
title VARCHAR(200),
node_type VARCHAR(50) NOT NULL,
marker_type VARCHAR(50),
depth INTEGER NOT NULL DEFAULT 0,
is_leaf BOOLEAN NOT NULL DEFAULT false,
is_selectable BOOLEAN NOT NULL DEFAULT true,
is_hidden BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata JSONB,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_content_node_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_content_node_entry FOREIGN KEY (entry_id) REFERENCES education_content_entry (id),
CONSTRAINT fk_education_content_node_parent FOREIGN KEY (parent_id) REFERENCES education_content_node (id)
);
COMMENT ON TABLE education_content_node IS '教育-内容节点';
COMMENT ON COLUMN education_content_node.scope IS '内容范围PUBLIC、TENANT_OWNED';
COMMENT ON COLUMN education_content_node.is_hidden IS '是否对学生端隐藏';
CREATE INDEX idx_education_content_node_tenant_tree
ON education_content_node (tenant_id, entry_id, parent_id, marker_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false;
CREATE INDEX idx_education_content_node_public_tree
ON education_content_node (entry_id, parent_id, marker_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false AND scope = 'PUBLIC';
CREATE TABLE education_question_collection (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
entry_id BIGINT,
node_id BIGINT,
name VARCHAR(200) NOT NULL,
title VARCHAR(200),
collection_type VARCHAR(50) NOT NULL,
question_count INTEGER NOT NULL DEFAULT 0,
duration_minutes INTEGER,
access_rules JSONB,
is_hidden BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata JSONB,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_question_collection_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_question_collection_entry FOREIGN KEY (entry_id) REFERENCES education_content_entry (id),
CONSTRAINT fk_education_question_collection_node FOREIGN KEY (node_id) REFERENCES education_content_node (id)
);
COMMENT ON TABLE education_question_collection IS '教育-题集';
COMMENT ON COLUMN education_question_collection.scope IS '内容范围PUBLIC、TENANT_OWNED';
COMMENT ON COLUMN education_question_collection.is_hidden IS '是否对学生端隐藏';
CREATE INDEX idx_education_question_collection_tenant_filters
ON education_question_collection (tenant_id, entry_id, node_id, collection_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false;
CREATE INDEX idx_education_question_collection_public_filters
ON education_question_collection (entry_id, node_id, collection_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false AND scope = 'PUBLIC';
CREATE TABLE education_question (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
content_version INTEGER NOT NULL DEFAULT 1,
stem TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
type_label VARCHAR(50),
difficulty VARCHAR(32),
question_content JSONB,
options JSONB NOT NULL,
correct_answer VARCHAR(500),
explanation TEXT,
analysis TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED',
is_published BOOLEAN NOT NULL DEFAULT true,
subject_id BIGINT,
node_id BIGINT,
tags JSONB,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata JSONB,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_question_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT ck_education_question_version CHECK (content_version > 0),
CONSTRAINT ck_education_question_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'HIDDEN', 'INACTIVE')),
CONSTRAINT fk_education_question_subject FOREIGN KEY (subject_id) REFERENCES education_subject (id),
CONSTRAINT fk_education_question_node FOREIGN KEY (node_id) REFERENCES education_content_node (id)
);
COMMENT ON TABLE education_question IS '教育-题目';
COMMENT ON COLUMN education_question.scope IS '内容范围PUBLIC、TENANT_OWNED';
COMMENT ON COLUMN education_question.options IS '选项 JSONB学生端响应必须剥离 isCorrect';
COMMENT ON COLUMN education_question.correct_answer IS '正确答案;提交前不得返回';
COMMENT ON COLUMN education_question.explanation IS '答案解析;提交前不得返回';
COMMENT ON COLUMN education_question.analysis IS '深度解析;提交前不得返回';
CREATE INDEX idx_education_question_tenant_filters
ON education_question (tenant_id, node_id, subject_id, type, difficulty, sort_order)
WHERE deleted = false AND is_published = true AND status = 'PUBLISHED';
CREATE INDEX idx_education_question_public_filters
ON education_question (node_id, subject_id, type, difficulty, sort_order)
WHERE deleted = false AND is_published = true AND status = 'PUBLISHED' AND scope = 'PUBLIC';
CREATE TABLE education_practice_blueprint (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
mode VARCHAR(50) NOT NULL,
entry_id BIGINT,
node_id BIGINT,
collection_id BIGINT,
question_limit INTEGER,
duration_minutes INTEGER,
eligible_count INTEGER NOT NULL DEFAULT 0,
total_count INTEGER NOT NULL DEFAULT 0,
available_types JSONB,
available_difficulties JSONB,
min_questions INTEGER NOT NULL DEFAULT 1,
max_questions INTEGER NOT NULL DEFAULT 200,
suggested_count INTEGER NOT NULL DEFAULT 20,
is_active BOOLEAN NOT NULL DEFAULT true,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_practice_blueprint_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT ck_education_practice_blueprint_counts CHECK (
min_questions > 0 AND max_questions >= min_questions AND suggested_count BETWEEN min_questions AND max_questions
),
CONSTRAINT fk_education_practice_blueprint_entry FOREIGN KEY (entry_id) REFERENCES education_content_entry (id),
CONSTRAINT fk_education_practice_blueprint_node FOREIGN KEY (node_id) REFERENCES education_content_node (id),
CONSTRAINT fk_education_practice_blueprint_collection FOREIGN KEY (collection_id) REFERENCES education_question_collection (id)
);
COMMENT ON TABLE education_practice_blueprint IS '教育-练习蓝图';
COMMENT ON COLUMN education_practice_blueprint.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_practice_blueprint_tenant_filters
ON education_practice_blueprint (tenant_id, entry_id, node_id, collection_id, mode)
WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_practice_blueprint_public_filters
ON education_practice_blueprint (entry_id, node_id, collection_id, mode)
WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
-- 题集-题目关联是题集成员关系的唯一事实源;题目表不保存 collection_id。
CREATE TABLE education_question_collection_question (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
collection_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_collection_question_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_collection_question_collection FOREIGN KEY (collection_id) REFERENCES education_question_collection (id),
CONSTRAINT fk_education_collection_question_question FOREIGN KEY (question_id) REFERENCES education_question (id),
CONSTRAINT uk_education_collection_question UNIQUE (tenant_id, collection_id, question_id)
);
COMMENT ON TABLE education_question_collection_question IS '教育-题集题目关联,题集成员关系唯一事实源';
COMMENT ON COLUMN education_question_collection_question.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_collection_question_order
ON education_question_collection_question (tenant_id, collection_id, sort_order) WHERE deleted = false;
CREATE INDEX idx_education_collection_question_public_order
ON education_question_collection_question (collection_id, sort_order) WHERE deleted = false AND scope = 'PUBLIC';
-- Verification examples:
-- SELECT table_name FROM information_schema.tables
-- WHERE table_schema = 'public' AND table_name LIKE 'education_%' ORDER BY table_name;
-- SELECT conname FROM pg_constraint
-- WHERE conrelid IN ('education_question'::regclass, 'education_question_collection_question'::regclass)
-- ORDER BY conname;

View File

@@ -0,0 +1,398 @@
-- Education Practice core-loop schema and forward adoption (PostgreSQL)
--
-- Fresh databases receive the final unified schema. Existing compatible manual
-- installations are adopted in place. Legacy answer/submit idempotency tables
-- are read for backfill when present, but are never created or dropped here.
CREATE TABLE IF NOT EXISTS education_practice_session (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
client_session_id VARCHAR(36) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
question_count INTEGER NOT NULL DEFAULT 0,
collection_id VARCHAR(64),
node_id VARCHAR(64),
type VARCHAR(32),
difficulty VARCHAR(32),
version INTEGER NOT NULL DEFAULT 0,
last_client_sequence INTEGER,
review_fingerprint VARCHAR(64),
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
COMMENT ON TABLE education_practice_session IS '教育-练习会话';
ALTER TABLE education_practice_session
ADD COLUMN IF NOT EXISTS last_client_sequence INTEGER,
ADD COLUMN IF NOT EXISTS review_fingerprint VARCHAR(64);
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_practice_session_tenant_client
ON education_practice_session (tenant_id, client_session_id);
CREATE INDEX IF NOT EXISTS idx_education_practice_session_tenant_user_status
ON education_practice_session (tenant_id, user_id, status);
CREATE TABLE IF NOT EXISTS education_practice_question (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
session_id BIGINT NOT NULL,
sequence INTEGER NOT NULL,
question_id VARCHAR(64) NOT NULL,
content_version VARCHAR(64) NOT NULL DEFAULT '',
stem TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
difficulty VARCHAR(32),
options JSONB NOT NULL,
correct_answer TEXT,
explanation TEXT,
selected_answer TEXT,
is_answered BOOLEAN NOT NULL DEFAULT false,
client_sequence INTEGER,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
COMMENT ON TABLE education_practice_question IS '教育-练习会话题目快照';
ALTER TABLE education_practice_question
ADD COLUMN IF NOT EXISTS correct_answer TEXT,
ADD COLUMN IF NOT EXISTS explanation TEXT,
ADD COLUMN IF NOT EXISTS client_sequence INTEGER;
DO $$
DECLARE
options_type TEXT;
BEGIN
SELECT data_type INTO options_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_practice_question'
AND column_name = 'options';
IF options_type = 'text' THEN
BEGIN
ALTER TABLE education_practice_question
ALTER COLUMN options TYPE JSONB USING options::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_practice_question.options contains invalid JSON and cannot be adopted';
END;
ELSIF options_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_practice_question.options has incompatible type %', options_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_practice_question_session_sequence
ON education_practice_question (session_id, sequence);
CREATE INDEX IF NOT EXISTS idx_education_practice_question_session
ON education_practice_question (session_id);
CREATE TABLE IF NOT EXISTS education_practice_report (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
session_id BIGINT NOT NULL,
question_count INTEGER NOT NULL,
answered_count INTEGER NOT NULL DEFAULT 0,
unanswered_count INTEGER NOT NULL DEFAULT 0,
correct_count INTEGER NOT NULL DEFAULT 0,
incorrect_count INTEGER NOT NULL DEFAULT 0,
score INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED',
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
COMMENT ON TABLE education_practice_report IS '教育-练习报告';
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_practice_report_tenant_session
ON education_practice_report (tenant_id, session_id);
CREATE INDEX IF NOT EXISTS idx_education_practice_report_tenant_user
ON education_practice_report (tenant_id, user_id);
CREATE INDEX IF NOT EXISTS idx_education_practice_report_create_time
ON education_practice_report (create_time);
CREATE TABLE IF NOT EXISTS education_practice_report_detail (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
report_id BIGINT NOT NULL,
session_id BIGINT NOT NULL,
question_id VARCHAR(64) NOT NULL,
sequence INTEGER NOT NULL,
stem TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
difficulty VARCHAR(32),
selected_answer TEXT,
correct_answer TEXT,
is_correct BOOLEAN NOT NULL DEFAULT false,
explanation TEXT,
content_version VARCHAR(64) NOT NULL DEFAULT '',
options JSONB,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
COMMENT ON TABLE education_practice_report_detail IS '教育-练习报告明细';
ALTER TABLE education_practice_report_detail
ADD COLUMN IF NOT EXISTS content_version VARCHAR(64) NOT NULL DEFAULT '';
DO $$
DECLARE
options_type TEXT;
BEGIN
SELECT data_type INTO options_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_practice_report_detail'
AND column_name = 'options';
IF options_type IS NULL THEN
ALTER TABLE education_practice_report_detail ADD COLUMN options JSONB;
ELSIF options_type = 'text' THEN
BEGIN
ALTER TABLE education_practice_report_detail
ALTER COLUMN options TYPE JSONB USING options::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_practice_report_detail.options contains invalid JSON and cannot be adopted';
END;
ELSIF options_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_practice_report_detail.options has incompatible type %', options_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_practice_report_detail_report_sequence
ON education_practice_report_detail (report_id, sequence);
CREATE INDEX IF NOT EXISTS idx_education_practice_report_detail_session
ON education_practice_report_detail (session_id);
CREATE TABLE IF NOT EXISTS education_wrong_question (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
question_id VARCHAR(64) NOT NULL,
stem TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
difficulty VARCHAR(32),
options JSONB NOT NULL,
content_version VARCHAR(64) NOT NULL DEFAULT '',
latest_correct_answer TEXT,
latest_explanation TEXT,
first_wrong_time TIMESTAMP NOT NULL,
last_wrong_time TIMESTAMP NOT NULL,
wrong_count INTEGER NOT NULL DEFAULT 1,
master_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
mastered_time TIMESTAMP,
last_report_id BIGINT,
last_session_id BIGINT,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
COMMENT ON TABLE education_wrong_question IS '教育-错题本';
DO $$
DECLARE
options_type TEXT;
BEGIN
SELECT data_type INTO options_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_wrong_question'
AND column_name = 'options';
IF options_type = 'text' THEN
BEGIN
ALTER TABLE education_wrong_question
ALTER COLUMN options TYPE JSONB USING options::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_wrong_question.options contains invalid JSON and cannot be adopted';
END;
ELSIF options_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_wrong_question.options has incompatible type %', options_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_wrong_question_tenant_user_question
ON education_wrong_question (tenant_id, user_id, question_id);
CREATE INDEX IF NOT EXISTS idx_education_wrong_question_tenant_user_status
ON education_wrong_question (tenant_id, user_id, master_status);
CREATE INDEX IF NOT EXISTS idx_education_wrong_question_tenant_user_last_wrong
ON education_wrong_question (tenant_id, user_id, last_wrong_time);
CREATE TABLE IF NOT EXISTS education_wrong_question_idempotency (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
wrong_question_id BIGINT,
report_id BIGINT NOT NULL,
question_id VARCHAR(64) NOT NULL,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
COMMENT ON TABLE education_wrong_question_idempotency IS '教育-错题流水幂等';
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_wrong_idempotency_tenant_user_question_report
ON education_wrong_question_idempotency (tenant_id, user_id, question_id, report_id);
CREATE INDEX IF NOT EXISTS idx_education_wrong_question_idempotency_report
ON education_wrong_question_idempotency (report_id);
CREATE TABLE IF NOT EXISTS education_favorite (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id VARCHAR(64) NOT NULL,
stem TEXT,
type VARCHAR(32),
difficulty VARCHAR(32),
options JSONB,
content_version VARCHAR(64) NOT NULL DEFAULT '',
available BOOLEAN NOT NULL DEFAULT true,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
COMMENT ON TABLE education_favorite IS '教育-收藏夹';
DO $$
DECLARE
options_type TEXT;
BEGIN
SELECT data_type INTO options_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_favorite'
AND column_name = 'options';
IF options_type = 'text' THEN
BEGIN
ALTER TABLE education_favorite
ALTER COLUMN options TYPE JSONB USING options::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_favorite.options contains invalid JSON and cannot be adopted';
END;
ELSIF options_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_favorite.options has incompatible type %', options_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_favorite_tenant_user_target
ON education_favorite (tenant_id, user_id, target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_education_favorite_tenant_user
ON education_favorite (tenant_id, user_id);
CREATE INDEX IF NOT EXISTS idx_education_favorite_tenant_user_type
ON education_favorite (tenant_id, user_id, target_type);
CREATE TABLE IF NOT EXISTS education_idempotency (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
operation VARCHAR(32) NOT NULL,
idempotency_key VARCHAR(64) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
session_id BIGINT NOT NULL,
question_id VARCHAR(64),
selected_answer TEXT,
report_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED',
response_json TEXT,
business_payload JSONB,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
COMMENT ON TABLE education_idempotency IS '教育-统一幂等记录';
DO $$
DECLARE
payload_type TEXT;
BEGIN
SELECT data_type INTO payload_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_idempotency'
AND column_name = 'business_payload';
IF payload_type IS NULL THEN
ALTER TABLE education_idempotency ADD COLUMN business_payload JSONB;
ELSIF payload_type = 'text' THEN
BEGIN
ALTER TABLE education_idempotency
ALTER COLUMN business_payload TYPE JSONB USING business_payload::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_idempotency.business_payload contains invalid JSON and cannot be adopted';
END;
ELSIF payload_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_idempotency.business_payload has incompatible type %', payload_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_idempotency_tenant_user_operation_key
ON education_idempotency (tenant_id, user_id, operation, idempotency_key);
CREATE INDEX IF NOT EXISTS idx_education_idempotency_tenant_session
ON education_idempotency (tenant_id, session_id);
DO $$
BEGIN
IF to_regclass('education_answer_idempotency') IS NOT NULL THEN
IF EXISTS (
SELECT 1
FROM education_answer_idempotency
GROUP BY tenant_id, user_id, idempotency_key
HAVING COUNT(DISTINCT request_hash) > 1
) THEN
RAISE EXCEPTION 'education_answer_idempotency contains conflicting duplicate request_hash values';
END IF;
IF EXISTS (
SELECT 1
FROM education_answer_idempotency legacy
JOIN education_idempotency unified
ON unified.tenant_id = legacy.tenant_id
AND unified.user_id = legacy.user_id
AND unified.operation = 'SUBMIT_ANSWER'
AND unified.idempotency_key = legacy.idempotency_key
WHERE unified.request_hash <> legacy.request_hash
) THEN
RAISE EXCEPTION 'education_answer_idempotency conflicts with education_idempotency request_hash';
END IF;
INSERT INTO education_idempotency
(tenant_id, user_id, operation, idempotency_key, request_hash, session_id,
question_id, selected_answer, status, response_json,
creator, create_time, updater, update_time, deleted)
SELECT tenant_id, user_id, 'SUBMIT_ANSWER', idempotency_key, request_hash, session_id,
question_id, selected_answer, status, response_json,
creator, create_time, updater, update_time, deleted
FROM education_answer_idempotency
ON CONFLICT (tenant_id, user_id, operation, idempotency_key) DO NOTHING;
END IF;
IF to_regclass('education_submit_idempotency') IS NOT NULL THEN
IF EXISTS (
SELECT 1
FROM education_submit_idempotency
GROUP BY tenant_id, user_id, idempotency_key
HAVING COUNT(DISTINCT request_hash) > 1
) THEN
RAISE EXCEPTION 'education_submit_idempotency contains conflicting duplicate request_hash values';
END IF;
IF EXISTS (
SELECT 1
FROM education_submit_idempotency legacy
JOIN education_idempotency unified
ON unified.tenant_id = legacy.tenant_id
AND unified.user_id = legacy.user_id
AND unified.operation = 'SUBMIT_SESSION'
AND unified.idempotency_key = legacy.idempotency_key
WHERE unified.request_hash <> legacy.request_hash
) THEN
RAISE EXCEPTION 'education_submit_idempotency conflicts with education_idempotency request_hash';
END IF;
INSERT INTO education_idempotency
(tenant_id, user_id, operation, idempotency_key, request_hash, session_id,
report_id, status, response_json,
creator, create_time, updater, update_time, deleted)
SELECT tenant_id, user_id, 'SUBMIT_SESSION', idempotency_key, request_hash, session_id,
report_id, status, response_json,
creator, create_time, updater, update_time, deleted
FROM education_submit_idempotency
ON CONFLICT (tenant_id, user_id, operation, idempotency_key) DO NOTHING;
END IF;
END $$;

View File

@@ -0,0 +1,51 @@
ALTER TABLE education_idempotency
ADD COLUMN IF NOT EXISTS claim_token VARCHAR(64),
ADD COLUMN IF NOT EXISTS claim_started_at TIMESTAMP;
DO $$
DECLARE
claim_token_type TEXT;
claim_token_length INTEGER;
claim_started_at_type TEXT;
BEGIN
SELECT data_type, character_maximum_length INTO claim_token_type, claim_token_length
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_idempotency'
AND column_name = 'claim_token';
IF claim_token_type <> 'character varying' OR claim_token_length < 36 THEN
RAISE EXCEPTION 'education_idempotency.claim_token must be VARCHAR(36+), found %(%)',
claim_token_type, claim_token_length;
END IF;
SELECT data_type INTO claim_started_at_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_idempotency'
AND column_name = 'claim_started_at';
IF claim_started_at_type <> 'timestamp without time zone' THEN
RAISE EXCEPTION 'education_idempotency.claim_started_at must be timestamp without time zone, found %',
claim_started_at_type;
END IF;
END $$;
COMMENT ON COLUMN education_idempotency.claim_token IS '当前幂等处理租约令牌;完成或非交卷记录为空';
COMMENT ON COLUMN education_idempotency.claim_started_at IS '当前幂等处理租约开始时间;用于进程崩溃后的超时接管';
UPDATE education_idempotency
SET status = 'PROCESSING',
claim_token = NULL,
claim_started_at = create_time
WHERE operation = 'SUBMIT_SESSION'
AND status = 'ACCEPTED'
AND (report_id IS NULL OR response_json IS NULL OR BTRIM(response_json) = '');
UPDATE education_idempotency
SET status = 'COMPLETED',
claim_token = NULL,
claim_started_at = NULL
WHERE operation = 'SUBMIT_SESSION'
AND status = 'ACCEPTED'
AND report_id IS NOT NULL
AND response_json IS NOT NULL
AND BTRIM(response_json) <> '';

View File

@@ -52,6 +52,17 @@ public class EducationPropertiesTest {
assertTrue(defaults.getPilotTenantIds().isEmpty(), "Pilot 租户为空时不限制租户");
}
@Test
public void localDevelopmentShouldDefaultToFalse() {
EducationProperties defaults = new EducationProperties();
assertFalse(defaults.getTenantResolution().isLocalDevelopmentEnabled());
}
@Test
public void activeProfileShouldNotEnableLocalDevelopment() {
assertFalse(properties.getTenantResolution().isLocalDevelopmentEnabled());
}
@Test
public void testHostnameTenantMapDefaults() {
EducationProperties defaults = new EducationProperties();

View File

@@ -0,0 +1,91 @@
package cn.iocoder.yudao.module.education.controller.app;
import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class EducationContextControllerHttpTest {
private MockMvc mockMvc;
private TenantCommonApi tenantCommonApi;
@BeforeEach
void setUp() {
tenantCommonApi = mock(TenantCommonApi.class);
EducationContextController controller = new EducationContextController();
ReflectionTestUtils.setField(controller, "tenantCommonApi", tenantCommonApi);
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler("test", mock(ApiErrorLogCommonApi.class)))
.build();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
@Test
void anonymousStudentContextIsRejected() throws Exception {
mockMvc.perform(get("/education/context"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void adminPrincipalIsRejected() throws Exception {
setLoginUser(10L, 20L, UserTypeEnum.ADMIN);
TenantContextHolder.setTenantId(20L);
mockMvc.perform(get("/education/context"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void memberPrincipalUsesOnlySecurityAndTenantContexts() throws Exception {
setLoginUser(10L, 999L, UserTypeEnum.MEMBER);
TenantContextHolder.setTenantId(20L);
TenantRespDTO tenant = new TenantRespDTO();
tenant.setId(20L);
tenant.setName("Demo School");
when(tenantCommonApi.getTenant(20L)).thenReturn(tenant);
mockMvc.perform(get("/education/context")
.param("userId", "888")
.param("tenantId", "777"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value(10))
.andExpect(jsonPath("$.data.tenantId").value(20));
verify(tenantCommonApi).validateTenant(20L);
verify(tenantCommonApi).getTenant(20L);
}
private static void setLoginUser(Long userId, Long tenantId, UserTypeEnum userType) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(tenantId);
loginUser.setUserType(userType.getValue());
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}
}

View File

@@ -2,12 +2,15 @@ package cn.iocoder.yudao.module.education.controller.app.catalog;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -39,10 +42,12 @@ class CatalogControllerHttpTest {
private MockMvc mockMvc;
private CatalogProvider catalogProvider;
private QuestionCatalogService questionCatalogService;
@BeforeEach
void setUp() {
catalogProvider = mock(CatalogProvider.class);
questionCatalogService = mock(QuestionCatalogService.class);
when(catalogProvider.isEnabled()).thenReturn(true);
CatalogService catalogService = new CatalogServiceImpl(catalogProvider);
CatalogController controller = new CatalogController();
@@ -50,6 +55,9 @@ class CatalogControllerHttpTest {
var field = CatalogController.class.getDeclaredField("catalogService");
field.setAccessible(true);
field.set(controller, catalogService);
var questionField = CatalogController.class.getDeclaredField("questionCatalogService");
questionField.setAccessible(true);
questionField.set(controller, questionCatalogService);
var accessField = CatalogController.class.getDeclaredField("educationAccessService");
accessField.setAccessible(true);
accessField.set(controller, mock(EducationAccessService.class));
@@ -153,6 +161,24 @@ class CatalogControllerHttpTest {
.andExpect(jsonPath("$.code").value(0));
}
@Test
void shouldMapNestedCollectionQuestionsAndReturnSafeFields() throws Exception {
setLoginUser(100L);
SafeQuestionRespVO question = SafeQuestionRespVO.builder()
.id("9").contentVersion("3").stem("题干").type("SINGLE").build();
when(questionCatalogService.listCollectionQuestions("7", "SINGLE", "EASY", 1, 20))
.thenReturn(new PageResult<>(java.util.List.of(question), 1L));
mockMvc.perform(get("/education/catalog/question-collections/7/questions")
.param("type", "SINGLE")
.param("difficulty", "EASY"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.list[0].id").value("9"))
.andExpect(jsonPath("$.data.list[0].correctAnswer").doesNotExist())
.andExpect(jsonPath("$.data.list[0].explanation").doesNotExist())
.andExpect(jsonPath("$.data.list[0].analysis").doesNotExist());
}
// ========== Anonymous → 401 ==========
@Test
@@ -189,9 +215,9 @@ class CatalogControllerHttpTest {
// ========== Query parameter forwarding ==========
@Test
void shouldForwardIncludeHiddenTrue() throws Exception {
void shouldNotExposeHiddenEntriesSwitch() throws Exception {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", "question_bank", true))
when(catalogProvider.listContentEntries("r1", "question_bank", false))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/content-entries")
@@ -200,12 +226,13 @@ class CatalogControllerHttpTest {
.param("includeHidden", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(catalogProvider).listContentEntries("r1", "question_bank", false);
}
@Test
void shouldForwardIncludeInactiveTrueAndMarkerType() throws Exception {
void shouldNotExposeInactiveNodesSwitch() throws Exception {
setLoginUser(100L);
when(catalogProvider.listContentNodes("e1", "root", "flat", true, "marker_type"))
when(catalogProvider.listContentNodes("e1", "root", "flat", false, "marker_type"))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/content-nodes")
@@ -216,6 +243,7 @@ class CatalogControllerHttpTest {
.param("markerType", "marker_type"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(catalogProvider).listContentNodes("e1", "root", "flat", false, "marker_type");
}
@Test

View File

@@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -22,6 +23,7 @@ import java.util.Collections;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -51,6 +53,9 @@ class CatalogControllerTest {
@MockitoBean
private CatalogProvider catalogProvider;
@MockitoBean
private QuestionCatalogService questionCatalogService;
@BeforeEach
void setUp() {
when(catalogProvider.isEnabled()).thenReturn(true);
@@ -112,7 +117,7 @@ class CatalogControllerTest {
when(catalogProvider.listContentEntries("r1", null, false))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentEntries("r1", null, false);
var result = catalogController.listContentEntries("r1", null);
assertNotNull(result);
assertEquals(0, result.getCode());
}
@@ -123,7 +128,7 @@ class CatalogControllerTest {
when(catalogProvider.listContentNodes("e1", null, "children", false, null))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentNodes("e1", null, "children", false, null);
var result = catalogController.listContentNodes("e1", null, "children", null);
assertNotNull(result);
assertEquals(0, result.getCode());
}
@@ -139,6 +144,18 @@ class CatalogControllerTest {
assertEquals(0, result.getCode());
}
@Test
void shouldForwardNestedCollectionQuestionRequest() {
setLoginUser(100L);
when(questionCatalogService.listCollectionQuestions("7", "SINGLE", "EASY", 2, 10))
.thenReturn(new cn.iocoder.yudao.framework.common.pojo.PageResult<>(Collections.emptyList(), 0L));
var result = catalogController.listCollectionQuestions("7", "SINGLE", "EASY", 2, 10);
assertEquals(0, result.getCode());
verify(questionCatalogService).listCollectionQuestions("7", "SINGLE", "EASY", 2, 10);
}
// ========== 未认证拒绝 ==========
@Test
@@ -160,40 +177,30 @@ class CatalogControllerTest {
void shouldRejectAllEndpointsWhenNotAuthenticated() {
assertThrows(ServiceException.class, () -> catalogController.listSubjects(null, null, null, null, null));
assertThrows(ServiceException.class, () -> catalogController.listModuleNodes(null, null, null));
assertThrows(ServiceException.class, () -> catalogController.listContentEntries(null, null, false));
assertThrows(ServiceException.class, () -> catalogController.listContentNodes("e1", null, "children", false, null));
assertThrows(ServiceException.class, () -> catalogController.listContentEntries(null, null));
assertThrows(ServiceException.class, () -> catalogController.listContentNodes("e1", null, "children", null));
assertThrows(ServiceException.class, () -> catalogController.listQuestionCollections(null, null, null, null, null));
}
// ========== 参数转发 ==========
@Test
void shouldForwardIncludeHiddenTrue() {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", null, true))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentEntries("r1", null, true);
assertEquals(0, result.getCode());
}
@Test
void shouldForwardIncludeHiddenFalse() {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", null, false))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentEntries("r1", null, false);
var result = catalogController.listContentEntries("r1", null);
assertEquals(0, result.getCode());
}
@Test
void shouldForwardIncludeInactiveTrue() {
void shouldForceVisibleActiveNodes() {
setLoginUser(100L);
when(catalogProvider.listContentNodes("e1", null, "flat", true, "marker"))
when(catalogProvider.listContentNodes("e1", null, "flat", false, "marker"))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentNodes("e1", null, "flat", true, "marker");
var result = catalogController.listContentNodes("e1", null, "flat", "marker");
assertEquals(0, result.getCode());
}

View File

@@ -1,9 +1,11 @@
package cn.iocoder.yudao.module.education.controller.app.practice;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerRespVO;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
@@ -69,6 +71,7 @@ class PracticeAnswerControllerHttpTest {
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
// ========== PUT /answer success ==========
@@ -109,6 +112,7 @@ class PracticeAnswerControllerHttpTest {
String json = result.getResponse().getContentAsString();
assertFalse(json.contains("correctAnswer"), "answer response must not leak correctAnswer");
assertFalse(json.contains("isCorrect"), "answer response must not leak isCorrect");
assertFalse(json.contains("explanation"), "answer response must not leak explanation");
}
// ========== Auth validation ==========
@@ -130,6 +134,19 @@ class PracticeAnswerControllerHttpTest {
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
@Test
void shouldReturn401ForAnswerWhenAdminPrincipal() throws Exception {
setLoginUser(100L, 1L, UserTypeEnum.ADMIN);
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(validReq())))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
verifyNoInteractions(service);
}
// ========== Validation error envelope ==========
@Test
@@ -364,9 +381,15 @@ class PracticeAnswerControllerHttpTest {
// ========== helpers ==========
private void setLoginUser(Long userId, Long tenantId) {
setLoginUser(userId, tenantId, UserTypeEnum.MEMBER);
}
private void setLoginUser(Long userId, Long tenantId, UserTypeEnum userType) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(tenantId);
loginUser.setUserType(userType.getValue());
TenantContextHolder.setTenantId(tenantId);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}

View File

@@ -1,9 +1,11 @@
package cn.iocoder.yudao.module.education.controller.app.practice;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeQuestionRespVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
@@ -74,6 +76,7 @@ class PracticeSessionControllerHttpTest {
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
// ========== POST /create ==========
@@ -120,6 +123,23 @@ class PracticeSessionControllerHttpTest {
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
@Test
void shouldReturn401ForCreateWhenAdminPrincipal() throws Exception {
setLoginUser(100L, 1L, UserTypeEnum.ADMIN);
PracticeSessionCreateReqVO req = new PracticeSessionCreateReqVO();
req.setClientSessionId("uuid-admin");
req.setCollectionId("col-001");
req.setQuestionCount(1);
mockMvc.perform(post("/education/practice-session/create")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
verifyNoInteractions(service);
}
@Test
void shouldReturnIdempotentOnDuplicateClientSessionId() throws Exception {
setLoginUser(100L, 1L);
@@ -295,9 +315,15 @@ class PracticeSessionControllerHttpTest {
// ========== helpers ==========
private void setLoginUser(Long userId, Long tenantId) {
setLoginUser(userId, tenantId, UserTypeEnum.MEMBER);
}
private void setLoginUser(Long userId, Long tenantId, UserTypeEnum userType) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(tenantId);
loginUser.setUserType(userType.getValue());
TenantContextHolder.setTenantId(tenantId);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}

View File

@@ -5,6 +5,8 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
@@ -72,6 +74,7 @@ class PracticeSessionControllerSubmitHttpTest {
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
// ========== POST /submit ==========
@@ -292,6 +295,8 @@ class PracticeSessionControllerSubmitHttpTest {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(tenantId);
loginUser.setUserType(UserTypeEnum.MEMBER.getValue());
TenantContextHolder.setTenantId(tenantId);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}

View File

@@ -0,0 +1,334 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.LocalDateTime;
import java.util.stream.Stream;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class EducationTenantControllerHttpTest {
private MockMvc mockMvc;
private TenantCommonApi tenantCommonApi;
private EducationTenantController controller;
@BeforeEach
void setUp() {
tenantCommonApi = mock(TenantCommonApi.class);
controller = new EducationTenantController();
ReflectionTestUtils.setField(controller, "tenantCommonApi", tenantCommonApi);
ReflectionTestUtils.setField(controller, "educationProperties", new EducationProperties());
GlobalExceptionHandler exceptionHandler = new GlobalExceptionHandler("test", mock(ApiErrorLogCommonApi.class));
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(exceptionHandler)
.build();
}
@Test
void validProductionOriginReturnsMinimalTenantRoutingData() throws Exception {
when(tenantCommonApi.getTenantByWebsite("school.example.com"))
.thenReturn(availableTenant(100L, "Demo School"));
mockMvc.perform(get("/education/tenant/resolve")
.header("Origin", "https://School.Example.COM:8443"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").value(""))
.andExpect(jsonPath("$.data.tenantId").value(100))
.andExpect(jsonPath("$.data.displayName").value("Demo School"))
.andExpect(jsonPath("$.data.tenantName").doesNotExist())
.andExpect(jsonPath("$.data.status").doesNotExist())
.andExpect(jsonPath("$.data.loginMethods").doesNotExist());
}
@Test
void refererFallbackResolvesTenant() throws Exception {
when(tenantCommonApi.getTenantByWebsite("school.example.com"))
.thenReturn(availableTenant(100L, "Demo School"));
expectSuccess(get("/education/tenant/resolve")
.header("Referer", "https://school.example.com/login?next=practice"), 100L, "Demo School");
}
@Test
void forgedSyntacticallyValidOriginIsAcceptedOnlyAsLocatorClaim() throws Exception {
when(tenantCommonApi.getTenantByWebsite("claimed.example.com"))
.thenReturn(availableTenant(101L, "Claimed School"));
expectSuccess(get("/education/tenant/resolve")
.header("Origin", "https://claimed.example.com"), 101L, "Claimed School");
}
@Test
void malformedOriginDoesNotFallBackToReferer() throws Exception {
expectFailure(get("/education/tenant/resolve")
.header("Origin", "not-a-url")
.header("Referer", "https://school.example.com/login"),
1005001003, "租户识别请求无效");
}
@Test
void malformedOriginIsRejectedBeforeLocalHandlePrecedence() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
when(tenantCommonApi.getTenantByName("School_01"))
.thenReturn(availableTenant(102L, "School_01"));
expectFailure(get("/education/tenant/resolve")
.header("Origin", "not-a-url")
.param("hostname", "localhost")
.param("tenantHandle", "School_01"),
1005001003, "租户识别请求无效");
}
@Test
void productionOriginConflictIsRejectedBeforeLocalHandlePrecedence() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
when(tenantCommonApi.getTenantByName("School_01"))
.thenReturn(availableTenant(102L, "School_01"));
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("hostname", "localhost")
.param("tenantHandle", "School_01"),
1005001008, "租户识别信息冲突");
}
@Test
void originAndHostnameConflict() throws Exception {
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("hostname", "other.example.com"),
1005001008, "租户识别信息冲突");
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"school.example.com:", "school.example.com:65536", "school.example.com:abc"
})
void malformedHostnamePortIsInvalid(String hostname) throws Exception {
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("hostname", hostname),
1005001003, "租户识别请求无效");
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"school.example.com?probe", "school.example.com#probe"
})
void hostnameQueryOrFragmentIsInvalidEvenWhenOriginHostMatches(String hostname) throws Exception {
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("hostname", hostname),
1005001003, "租户识别请求无效");
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"https://school.example.com:", "https://school.example.com:65536", "https://school.example.com:abc"
})
void malformedOriginPortIsInvalid(String origin) throws Exception {
expectFailure(get("/education/tenant/resolve").header("Origin", origin),
1005001003, "租户识别请求无效");
}
@Test
void arbitraryProductionHostnameWithoutBrowserEvidenceIsInvalid() throws Exception {
expectFailure(get("/education/tenant/resolve").param("hostname", "school.example.com"),
1005001003, "租户识别请求无效");
}
@Test
void tenantHandleLookupIsExactCaseSensitiveAndReturnsSystemNameAsDisplayName() throws Exception {
when(tenantCommonApi.getTenantByName("School_01"))
.thenReturn(availableTenant(102L, "School_01"));
expectSuccess(get("/education/tenant/resolve").param("tenantHandle", "School_01"),
102L, "School_01");
expectFailure(get("/education/tenant/resolve").param("tenantHandle", "school_01"),
1005001004, "当前租户不可用");
expectFailure(get("/education/tenant/resolve").param("tenantHandle", "a"),
1005001003, "租户识别请求无效");
}
@Test
void legacyTenantNameIsRejectedIndependently() throws Exception {
expectFailure(get("/education/tenant/resolve").param("tenantName", "School_01"),
1005001003, "租户识别请求无效");
}
@Test
void domainAndHandleMayAgree() throws Exception {
TenantRespDTO tenant = availableTenant(103L, "School_01");
when(tenantCommonApi.getTenantByWebsite("school.example.com")).thenReturn(tenant);
when(tenantCommonApi.getTenantByName("School_01")).thenReturn(tenant);
expectSuccess(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("tenantHandle", "School_01"),
103L, "School_01");
}
@Test
void domainAndHandleConflict() throws Exception {
when(tenantCommonApi.getTenantByWebsite("school.example.com"))
.thenReturn(availableTenant(103L, "Domain School"));
when(tenantCommonApi.getTenantByName("School_01"))
.thenReturn(availableTenant(104L, "School_01"));
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("tenantHandle", "School_01"),
1005001008, "租户识别信息冲突");
}
@ParameterizedTest
@MethodSource("unavailableTenants")
void unknownDisabledAndExpiredHaveIdenticalWireShape(TenantRespDTO tenant) throws Exception {
when(tenantCommonApi.getTenantByName("Unavailable_01")).thenReturn(tenant);
expectFailure(get("/education/tenant/resolve").param("tenantHandle", "Unavailable_01"),
1005001004, "当前租户不可用");
}
@ParameterizedTest
@MethodSource("normalizedOrigins")
void hostIdentityIsCanonicalHostOnly(String origin, String canonicalHost) throws Exception {
when(tenantCommonApi.getTenantByWebsite(canonicalHost))
.thenReturn(availableTenant(105L, "Normalized School"));
expectSuccess(get("/education/tenant/resolve").header("Origin", origin),
105L, "Normalized School");
}
@Test
void localHostnameIsRejectedWhenFlagIsFalse() throws Exception {
expectFailure(get("/education/tenant/resolve").param("hostname", "localhost:48080"),
1005001003, "租户识别请求无效");
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"http://127.0.0.1:48080", "http://[::1]:48080"
})
void localOriginIsRejectedWhenFlagIsFalse(String origin) throws Exception {
expectFailure(get("/education/tenant/resolve").header("Origin", origin),
1005001003, "租户识别请求无效");
}
@Test
void explicitLocalFlagPermitsConfiguredCodeLessHost() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
properties.getHostnameTenantMap().put("localhost", "Local_01");
when(tenantCommonApi.getTenantByName("Local_01"))
.thenReturn(availableTenant(106L, "Local_01"));
expectSuccess(get("/education/tenant/resolve").param("hostname", "localhost:48080"),
106L, "Local_01");
}
@Test
void explicitLocalFlagPermitsConfiguredLoopbackOrigin() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
properties.getHostnameTenantMap().put("::1", "Local_01");
when(tenantCommonApi.getTenantByName("Local_01"))
.thenReturn(availableTenant(106L, "Local_01"));
expectSuccess(get("/education/tenant/resolve").header("Origin", "http://[::1]:48080"),
106L, "Local_01");
}
@Test
void explicitHandlePrecedesLocalHostname() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
properties.getHostnameTenantMap().put("localhost", "Local_01");
when(tenantCommonApi.getTenantByName("Remote_01"))
.thenReturn(availableTenant(107L, "Remote_01"));
expectSuccess(get("/education/tenant/resolve")
.param("hostname", "localhost:48080")
.param("tenantHandle", "Remote_01"),
107L, "Remote_01");
org.mockito.Mockito.verify(tenantCommonApi, org.mockito.Mockito.never()).getTenantByName("Local_01");
}
@Test
void productionDomainDoesNotUseEducationHostnameMap() throws Exception {
properties().getHostnameTenantMap().put("school.example.com", "Mapped_01");
when(tenantCommonApi.getTenantByWebsite("school.example.com"))
.thenReturn(availableTenant(108L, "Canonical School"));
expectSuccess(get("/education/tenant/resolve").header("Origin", "https://school.example.com"),
108L, "Canonical School");
org.mockito.Mockito.verify(tenantCommonApi, org.mockito.Mockito.never()).getTenantByName("Mapped_01");
}
@Test
void nonCanonicalStoredWebsiteIsUnavailable() throws Exception {
when(tenantCommonApi.getTenantByWebsite("school.example.com")).thenReturn(null);
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com"),
1005001004, "当前租户不可用");
org.mockito.Mockito.verify(tenantCommonApi).getTenantByWebsite("school.example.com");
org.mockito.Mockito.verify(tenantCommonApi, org.mockito.Mockito.never())
.getTenantByWebsite("https://school.example.com");
}
private EducationProperties properties() {
return (EducationProperties) ReflectionTestUtils.getField(controller, "educationProperties");
}
private void expectSuccess(org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder request,
Long tenantId, String displayName) throws Exception {
mockMvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").value(""))
.andExpect(jsonPath("$.data.tenantId").value(tenantId))
.andExpect(jsonPath("$.data.displayName").value(displayName))
.andExpect(jsonPath("$.data", org.hamcrest.Matchers.aMapWithSize(2)));
}
private void expectFailure(org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder request,
int code, String message) throws Exception {
mockMvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(code))
.andExpect(jsonPath("$.msg").value(message))
.andExpect(jsonPath("$.data").value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$", org.hamcrest.Matchers.aMapWithSize(3)));
}
private static Stream<TenantRespDTO> unavailableTenants() {
TenantRespDTO disabled = availableTenant(108L, "Unavailable_01");
disabled.setStatus(CommonStatusEnum.DISABLE.getStatus());
TenantRespDTO expired = availableTenant(109L, "Unavailable_01");
expired.setExpireTime(LocalDateTime.now().minusDays(1));
return Stream.of(null, disabled, expired);
}
private static Stream<org.junit.jupiter.params.provider.Arguments> normalizedOrigins() {
return Stream.of(
org.junit.jupiter.params.provider.Arguments.of("https://School.Example.COM.:443", "school.example.com"),
org.junit.jupiter.params.provider.Arguments.of("https://school.example.com:8443", "school.example.com")
);
}
private static TenantRespDTO availableTenant(Long id, String name) {
TenantRespDTO tenant = new TenantRespDTO();
tenant.setId(id);
tenant.setName(name);
tenant.setStatus(CommonStatusEnum.ENABLE.getStatus());
tenant.setExpireTime(LocalDateTime.now().plusDays(1));
return tenant;
}
}

View File

@@ -1,87 +0,0 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link EducationTenantController} 的单元测试
*
* @author 恭学教育
*/
class EducationTenantControllerTest {
// ========== normalizeHostname ==========
@ParameterizedTest
@NullAndEmptySource
void normalizeHostname_shouldReturnNullForBlankInput(String hostname) {
assertNull(EducationTenantController.normalizeHostname(hostname));
}
@Test
void normalizeHostname_shouldLowercase() {
assertEquals("school.example.com",
EducationTenantController.normalizeHostname("School.Example.COM"));
}
@Test
void normalizeHostname_shouldTrimWhitespace() {
assertEquals("school.example.com",
EducationTenantController.normalizeHostname(" school.example.com "));
}
@Test
void normalizeHostname_shouldKeepPort() {
assertEquals("school.example.com:8080",
EducationTenantController.normalizeHostname("school.example.com:8080"));
}
@Test
void normalizeHostname_shouldLowercaseAndKeepPort() {
assertEquals("school.example.com:8080",
EducationTenantController.normalizeHostname("School.Example.COM:8080"));
}
@Test
void normalizeHostname_shouldRejectProtocol() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("http://school.example.com"));
}
@Test
void normalizeHostname_shouldRejectPath() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("school.example.com/path"));
}
@Test
void normalizeHostname_shouldRejectProtocolWithPort() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("https://school.example.com:8443"));
}
@Test
void normalizeHostname_shouldRejectOversizedPort() {
assertThrows(RuntimeException.class,
() -> EducationTenantController.normalizeHostname("school.example.com:999999999999"));
}
@Test
void normalizeHostname_shouldKeepIPv6Colons() {
// A bare IPv6 address like ::1: the port-stripping should be safe
// because last colon is followed by digits -> treated as port
// We'll just check no exception thrown
assertDoesNotThrow(() -> EducationTenantController.normalizeHostname("[::1]"));
}
@Test
void normalizeHostname_shouldRejectNonNumericPort() {
assertThrows(RuntimeException.class,
() -> EducationTenantController.normalizeHostname("host:name"));
}
}

View File

@@ -1,197 +0,0 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.app.tenant.vo.EducationTenantRespVO;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.LocalDateTime;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* {@link EducationTenantController} 的集成测试 — 测试租户解析流程
*
* @author 恭学教育
*/
@SpringBootTest(classes = EducationTenantResolveIntegrationTest.Config.class,
properties = {
"yudao.education.enabled=true",
"yudao.education.version=1.0.0-test"
},
webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("unit-test")
class EducationTenantResolveIntegrationTest {
@Configuration
@EnableConfigurationProperties(EducationProperties.class)
@Import(EducationTenantController.class)
static class Config {
}
@Resource
private EducationTenantController controller;
@MockitoBean
private TenantCommonApi tenantCommonApi;
// ========== happy path ==========
@Test
void resolve_byTenantName_shouldReturnActiveTenant() {
TenantRespDTO dto = buildTenant(100L, "demo-school", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByName("demo-school")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve(null, "demo-school");
assertTrue(result.isSuccess());
EducationTenantRespVO data = result.getData();
assertEquals(100L, data.getTenantId());
assertEquals("demo-school", data.getTenantName());
assertEquals("ACTIVE", data.getStatus());
assertTrue(data.getLoginMethods().contains("PASSWORD"));
assertTrue(data.getLoginMethods().contains("SMS"));
}
@Test
void resolve_byHostname_shouldFindByWebsite() {
TenantRespDTO dto = buildTenant(200L, "school-alpha", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByWebsite("school.example.com")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve("school.example.com", null);
assertTrue(result.isSuccess());
assertEquals(200L, result.getData().getTenantId());
assertEquals("school-alpha", result.getData().getTenantName());
}
@Test
void resolve_byHostname_shouldNormalizeCaseAndKeepPort() {
TenantRespDTO dto = buildTenant(300L, "school-beta", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByWebsite("school.example.com:8443")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve("School.Example.COM:8443", null);
assertTrue(result.isSuccess());
assertEquals(300L, result.getData().getTenantId());
}
@Test
void resolve_conflictingHostnameAndTenantName_shouldFail() {
TenantRespDTO byName = buildTenant(600L, "school-a", CommonStatusEnum.ENABLE.getStatus());
TenantRespDTO byHost = buildTenant(601L, "school-b", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByName("school-a")).thenReturn(byName);
when(tenantCommonApi.getTenantByWebsite("school-b.example.com")).thenReturn(byHost);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> controller.resolve("school-b.example.com", "school-a"));
assertTrue(exception.getMessage().contains("指向不同租户"));
}
// ========== error cases ==========
@Test
void resolve_noInput_shouldFail() {
try {
controller.resolve(null, null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("不能同时为空"));
}
}
@Test
void resolve_blankInput_shouldFail() {
try {
controller.resolve(" ", "");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("不能同时为空"));
}
}
@Test
void resolve_notFound_shouldFail() {
when(tenantCommonApi.getTenantByName("nonexistent")).thenReturn(null);
when(tenantCommonApi.getTenantByWebsite(anyString())).thenReturn(null);
try {
controller.resolve(null, "nonexistent");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("租户不存在"));
}
}
@Test
void resolve_disabled_shouldFail() {
TenantRespDTO dto = buildTenant(400L, "disabled-school", CommonStatusEnum.DISABLE.getStatus());
when(tenantCommonApi.getTenantByName("disabled-school")).thenReturn(dto);
try {
controller.resolve(null, "disabled-school");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("已被禁用"));
}
}
@Test
void resolve_expired_shouldFail() {
TenantRespDTO dto = buildTenant(500L, "expired-school", CommonStatusEnum.ENABLE.getStatus());
dto.setExpireTime(LocalDateTime.now().minusDays(1));
when(tenantCommonApi.getTenantByName("expired-school")).thenReturn(dto);
try {
controller.resolve(null, "expired-school");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("当前租户不可用"));
}
}
@Test
void resolve_protocolInHostname_shouldFail() {
try {
controller.resolve("http://evil.com", null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("协议"));
}
}
@Test
void resolve_pathInHostname_shouldFail() {
try {
controller.resolve("school.com/admin", null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("路径"));
}
}
// ========== helpers ==========
private static TenantRespDTO buildTenant(Long id, String name, Integer status) {
TenantRespDTO dto = new TenantRespDTO();
dto.setId(id);
dto.setName(name);
dto.setStatus(status);
dto.setWebsites(Collections.singletonList(name + ".example.com"));
dto.setExpireTime(LocalDateTime.now().plusYears(1));
return dto;
}
}

View File

@@ -1,10 +1,12 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DuplicateKeyException;
@@ -18,12 +20,12 @@ import static org.junit.jupiter.api.Assertions.*;
* @author 恭学教育
*/
@Import({})
public class PracticeSessionMapperTest extends BaseDbUnitTest {
public class PracticeSessionMapperTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
// ========== Session CRUD + uniqueness ==========
@@ -160,7 +162,8 @@ public class PracticeSessionMapperTest extends BaseDbUnitTest {
PracticeQuestionDO loadedQ = loaded.get(0);
assertEquals("What is 1+1?", loadedQ.getStem());
assertEquals("v1", loadedQ.getContentVersion());
assertEquals(optionsJson, loadedQ.getOptions());
assertEquals(JsonUtils.parseArray(optionsJson, Object.class),
JsonUtils.parseArray(loadedQ.getOptions(), Object.class));
assertFalse(loadedQ.getIsAnswered());
}

View File

@@ -104,19 +104,42 @@ class ScalarAutoConfigurationTest {
ex.getCode());
}
// ========== JAVA_READ (unsupported) ==========
// ========== JAVA_READ (native catalog) ==========
@Test
void shouldCreateUnsupportedProviderForJavaReadMode() {
void shouldCreateJavaProviderForJavaReadMode() {
contextRunner
.withPropertyValues(
"yudao.education.enabled=true",
"yudao.education.catalog-mode=JAVA_READ")
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.RegionMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.RegionMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.SchoolMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.SchoolMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.MajorMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.MajorMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.SubjectMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.SubjectMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.CategoryMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.CategoryMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentEntryMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentEntryMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.PracticeBlueprintMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.PracticeBlueprintMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionQuestionMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionQuestionMapper.class))
.withUserConfiguration(cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider.class)
.run(context -> {
assertThat(context).hasSingleBean(CatalogProvider.class);
CatalogProvider provider = context.getBean(CatalogProvider.class);
assertThat(provider).isInstanceOf(UnsupportedModeCatalogProvider.class);
assertFalse(provider.isEnabled());
assertThat(provider).isInstanceOf(cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider.class);
assertTrue(provider.isEnabled());
});
}
@@ -190,21 +213,23 @@ class ScalarAutoConfigurationTest {
// ========== Fix #5: QuestionCatalogProvider wiring for JAVA_READ ==========
@Test
void shouldExposeUnsupportedQuestionCatalogProviderForJavaRead() {
void shouldExposeJavaQuestionCatalogProviderForJavaRead() {
// JavaCatalogProvider is a @Component — needs all mapper beans to be created.
// Without mappers the context start fails; we test the unsupported fallback path instead.
contextRunner
.withPropertyValues(
"yudao.education.enabled=true",
"yudao.education.catalog-mode=JAVA_READ")
.run(context -> {
assertThat(context).hasSingleBean(QuestionCatalogProvider.class);
QuestionCatalogProvider provider = context.getBean(QuestionCatalogProvider.class);
assertThat(provider).isInstanceOf(UnsupportedModeQuestionCatalogProvider.class);
assertFalse(provider.isEnabled());
// JavaCatalogProvider won't start without mappers — no QuestionCatalogProvider bean
assertThat(context).doesNotHaveBean(QuestionCatalogProvider.class);
});
}
@Test
void shouldStartQuestionCatalogServiceInJavaReadMode() {
// JavaCatalogProvider needs real mappers; this test verifies the context
// gracefully reports the missing dependency rather than silently providing wrong bean.
contextRunner
.withUserConfiguration(
cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class)
@@ -212,10 +237,9 @@ class ScalarAutoConfigurationTest {
"yudao.education.enabled=true",
"yudao.education.catalog-mode=JAVA_READ")
.run(context -> {
assertThat(context).hasSingleBean(QuestionCatalogService.class);
assertThat(context).hasSingleBean(QuestionCatalogProvider.class);
QuestionCatalogProvider provider = context.getBean(QuestionCatalogProvider.class);
assertThat(provider).isInstanceOf(UnsupportedModeQuestionCatalogProvider.class);
// Without mappers, JavaCatalogProvider can't be created;
// QuestionCatalogServiceImpl has no QuestionCatalogProvider to inject.
assertThat(context).getFailure().isNotNull();
});
}

View File

@@ -71,13 +71,13 @@ class CatalogServiceImplTest {
}
@Test
void shouldReturnEmptyListWhenProviderReturnsNull() {
void shouldFailClosedWhenProviderReturnsNull() {
when(catalogProvider.listRegions()).thenReturn(null);
List<CatalogRegionRespVO> result = catalogService.listRegions();
ServiceException ex = assertThrows(ServiceException.class, () -> catalogService.listRegions());
assertNotNull(result);
assertTrue(result.isEmpty());
assertEquals(cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_UPSTREAM_UNAVAILABLE.getCode(),
ex.getCode());
}
@Test

View File

@@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.education.service.favorite;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.controller.app.favorite.vo.*;
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.EducationFavoriteDO;
@@ -26,12 +26,12 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* FavoriteService test — real DB (H2) backing all favorite assertions.
* FavoriteService test — real PostgreSQL backing all favorite assertions.
*
* @author 恭学教育
*/
@Import(FavoriteServiceImpl.class)
public class FavoriteServiceImplTest extends BaseDbUnitTest {
public class FavoriteServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private FavoriteService favoriteService;

View File

@@ -1,19 +1,21 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
import cn.iocoder.yudao.module.education.dal.dataobject.AnswerIdempotencyDO;
import cn.iocoder.yudao.module.education.dal.dataobject.IdempotencyDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
import cn.iocoder.yudao.module.education.dal.mysql.AnswerIdempotencyMapper;
import cn.iocoder.yudao.module.education.dal.mysql.IdempotencyStoreMapper;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.List;
@@ -25,14 +27,14 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* PracticeAnswerService test — real DB (H2) backing all persistence assertions.
* PracticeAnswerService test — real PostgreSQL backing all persistence assertions.
* Covers findings #1-#6: conditional update, atomic CAS, session-wide sequence,
* fail-closed options, version null/overflow, idempotency replay, concurrency.
*
* @author 恭学教育
*/
@Import(PracticeSessionServiceImpl.class)
public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
@Import({PracticeSessionServiceImpl.class, ScoringServiceImpl.class})
public class PracticeAnswerServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@@ -40,11 +42,11 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
@Resource
private AnswerIdempotencyMapper idempotencyMapper;
private IdempotencyStoreMapper idempotencyMapper;
@MockitoBean
private QuestionCatalogProvider provider;
@@ -78,7 +80,7 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
question.setContentVersion("v1");
question.setStem("test stem");
question.setType("choice");
question.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0}]");
question.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
question.setIsAnswered(false);
questionMapper.insert(question);
@@ -130,7 +132,7 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
assertEquals(1, resumed.getLastClientSequence());
// Idempotency record committed
AnswerIdempotencyDO idem = idempotencyMapper.selectByKey(
IdempotencyDO idem = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-persist");
assertNotNull(idem);
assertEquals("ACCEPTED", idem.getStatus());
@@ -151,7 +153,7 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
assertEquals(first.getSelectedAnswer(), second.getSelectedAnswer());
// Exactly one idempotency record
List<AnswerIdempotencyDO> records = idempotencyMapper.selectList();
List<IdempotencyDO> records = idempotencyMapper.selectList();
assertEquals(1, records.stream()
.filter(r -> "idem-replay".equals(r.getIdempotencyKey())).count());
}
@@ -260,9 +262,9 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
sessionMapper.insert(session);
PracticeQuestionDO q1 = createQuestion(session.getId(), 1, "q-001",
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0}]");
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Ans B\",\"order\":2.0}]");
PracticeQuestionDO q2 = createQuestion(session.getId(), 2, "q-002",
"[{\"label\":\"B\",\"content\":\"Ans B\",\"order\":1.0}]");
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Ans B\",\"order\":2.0}]");
// Answer Q1 with seq=10
PracticeAnswerReqVO req1 = makeReq(session.getId(), 1, "A", "idem-q1", 10, 1);
@@ -299,9 +301,9 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
sessionMapper.insert(session);
createQuestion(session.getId(), 1, "q-001",
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0}]");
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Ans B\",\"order\":2.0}]");
createQuestion(session.getId(), 2, "q-002",
"[{\"label\":\"B\",\"content\":\"Ans B\",\"order\":1.0}]");
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Ans B\",\"order\":2.0}]");
// Answer Q1 with seq=5
PracticeAnswerReqVO req1 = makeReq(session.getId(), 1, "A", "idem-q1", 5, 1);
@@ -381,7 +383,7 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
ANSWER_STALE_VERSION, 1, 99);
// Idempotency record must NOT exist (transaction rolled back)
AnswerIdempotencyDO idem = idempotencyMapper.selectByKey(
IdempotencyDO idem = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-cas");
assertNull(idem, "idempotency insert must roll back when CAS fails");
@@ -411,9 +413,14 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
// Answer must NOT be changed
PracticeQuestionDO reloaded = questionMapper.selectById(f.questionId);
assertEquals(Integer.valueOf(99), reloaded.getClientSequence());
assertNull(reloaded.getSelectedAnswer());
assertFalse(reloaded.getIsAnswered());
PracticeSessionDO reloadedSession = sessionMapper.selectById(f.sessionId);
assertEquals(Integer.valueOf(1), reloadedSession.getVersion());
assertNull(reloadedSession.getLastClientSequence());
// Idempotency must NOT be committed (rollback)
AnswerIdempotencyDO idem = idempotencyMapper.selectByKey(
IdempotencyDO idem = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-uan");
assertNull(idem, "idempotency must roll back when question CAS fails");
}
@@ -475,14 +482,8 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
}
@Test
void shouldRejectMalformedOptionsBlank() {
SessionFixture f = createSession("uuid-opt-blank");
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
question.setOptions(" ");
questionMapper.updateById(question);
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-ob", 1, 1);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
() -> QuestionContentSafety.restoreSnapshotOptions("choice", " "),
CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
@@ -538,6 +539,27 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
ANSWER_OPTION_INVALID, "Z");
}
@Test
void shouldRejectOptionlessAnswerUntilSubjectiveContractExists() {
SessionFixture f = createSession("uuid-optionless");
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
question.setType("short_answer");
question.setOptions("[]");
questionMapper.updateById(question);
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-optionless", 1, 1);
req.setSelectedAnswer("free text");
assertServiceException(() -> service.submitAnswer(req, userId, tenantId), ANSWER_TYPE_UNSUPPORTED);
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
PracticeQuestionDO reloaded = questionMapper.selectById(f.questionId);
assertEquals(Integer.valueOf(1), session.getVersion());
assertNull(session.getLastClientSequence());
assertNull(reloaded.getSelectedAnswer());
assertFalse(reloaded.getIsAnswered());
assertNull(idempotencyMapper.selectByKey(tenantId, userId, "SUBMIT_ANSWER", "idem-optionless"));
}
// ========== Cross-user ==========
@Test
@@ -666,6 +688,49 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
"concurrent identical requests must return same response");
}
@Test
void shouldRejectConcurrentDifferentPayloadWithSameIdempotencyKey() throws Exception {
SessionFixture f = createSession("uuid-race-conflicting-payload");
PracticeAnswerReqVO first = createAnswerReq(f.sessionId, "idem-race-conflict", 1, 1);
first.setSelectedAnswer("A");
PracticeAnswerReqVO second = createAnswerReq(f.sessionId, "idem-race-conflict", 1, 1);
second.setSelectedAnswer("B");
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<PracticeAnswerRespVO> firstResponse = new AtomicReference<>();
AtomicReference<PracticeAnswerRespVO> secondResponse = new AtomicReference<>();
AtomicReference<Exception> firstError = new AtomicReference<>();
AtomicReference<Exception> secondError = new AtomicReference<>();
Thread firstThread = answerThread(first, ready, go, firstResponse, firstError);
Thread secondThread = answerThread(second, ready, go, secondResponse, secondError);
firstThread.start();
secondThread.start();
assertTrue(ready.await(5, java.util.concurrent.TimeUnit.SECONDS));
go.countDown();
firstThread.join(10000);
secondThread.join(10000);
assertFalse(firstThread.isAlive());
assertFalse(secondThread.isAlive());
long successCount = java.util.stream.Stream.of(firstResponse.get(), secondResponse.get())
.filter(java.util.Objects::nonNull).count();
List<Exception> errors = java.util.stream.Stream.of(firstError.get(), secondError.get())
.filter(java.util.Objects::nonNull).toList();
assertEquals(1, successCount);
assertEquals(1, errors.size());
assertInstanceOf(ServiceException.class, errors.get(0));
assertEquals(ANSWER_IDEMPOTENCY_CONFLICT.getCode(), ((ServiceException) errors.get(0)).getCode());
PracticeAnswerRespVO winner = firstResponse.get() != null ? firstResponse.get() : secondResponse.get();
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
assertEquals(Integer.valueOf(2), session.getVersion());
assertEquals(winner.getSelectedAnswer(), question.getSelectedAnswer());
IdempotencyDO record = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-race-conflict");
assertNotNull(record);
assertFalse(record.getResponseJson().isBlank());
}
// ========== Concurrent different-key ==========
@Test
@@ -708,13 +773,22 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
t1.join(10000);
t2.join(10000);
// One should succeed, the other should get stale version (CAS failed)
int successCount = (e1.get() == null ? 1 : 0) + (e2.get() == null ? 1 : 0);
assertTrue(successCount >= 1, "at least one must succeed");
assertEquals(1, successCount, "exactly one concurrent different-key request must succeed");
Exception loser = e1.get() != null ? e1.get() : e2.get();
assertInstanceOf(ServiceException.class, loser);
assertEquals(ANSWER_STALE_VERSION.getCode(), ((ServiceException) loser).getCode());
// Version must be exactly 2 (only one CAS succeeded)
PracticeAnswerRespVO winner = r1.get() != null ? r1.get() : r2.get();
String winnerKey = r1.get() != null ? "idem-dk1" : "idem-dk2";
String loserKey = r1.get() != null ? "idem-dk2" : "idem-dk1";
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
assertEquals(2, session.getVersion());
assertEquals(winner.getAcceptedSequence(), session.getLastClientSequence());
assertEquals(winner.getAcceptedSequence(), question.getClientSequence());
assertNotNull(idempotencyMapper.selectByKey(tenantId, userId, "SUBMIT_ANSWER", winnerKey));
assertNull(idempotencyMapper.selectByKey(tenantId, userId, "SUBMIT_ANSWER", loserKey));
}
// ========== No correctness leak ==========
@@ -754,6 +828,27 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
assertEquals("A", currentResp.getQuestions().get(0).getSelectedAnswer());
}
@Test
void shouldFailClosedWhenStoredReplayResponseIsIncomplete() {
SessionFixture f = createSession("uuid-incomplete-replay");
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-incomplete", 1, 1);
service.submitAnswer(req, userId, tenantId);
IdempotencyDO record = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-incomplete");
record.setResponseJson("{}");
idempotencyMapper.updateById(record);
assertServiceException(() -> service.submitAnswer(req, userId, tenantId),
ANSWER_IDEMPOTENCY_REPLAY_INVALID);
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
assertEquals(Integer.valueOf(2), session.getVersion());
assertEquals(Integer.valueOf(1), session.getLastClientSequence());
assertEquals("A", question.getSelectedAnswer());
assertEquals(Integer.valueOf(1), question.getClientSequence());
}
// ========== Idempotency: different payload conflict ==========
@Test
@@ -811,6 +906,20 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
// ========== private helpers ==========
private Thread answerThread(PracticeAnswerReqVO req, CountDownLatch ready, CountDownLatch go,
AtomicReference<PracticeAnswerRespVO> response,
AtomicReference<Exception> error) {
return new Thread(() -> {
try {
ready.countDown();
go.await();
response.set(service.submitAnswer(req, userId, tenantId));
} catch (Exception ex) {
error.set(ex);
}
});
}
private PracticeQuestionDO createQuestion(Long sessionId, int sequence, String questionId,
String options) {
PracticeQuestionDO q = new PracticeQuestionDO();

View File

@@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
@@ -14,6 +14,7 @@ import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -34,7 +35,7 @@ import static org.mockito.Mockito.when;
* @author 恭学教育
*/
@Import(PracticeSessionServiceImpl.class)
public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
public class PracticeSessionServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@@ -42,7 +43,7 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
@MockitoBean
@@ -51,6 +52,9 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
@MockitoBean
private WrongQuestionService wrongQuestionService;
@MockitoBean
private ScoringService scoringService;
// ========== Create: basic ==========
@Test
@@ -406,6 +410,122 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
assertEquals("v1", reloaded.getQuestions().get(0).getContentVersion());
}
@Test
void shouldRestoreOriginalSnapshotAfterSourceContentChanges() {
when(provider.isEnabled()).thenReturn(true);
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "Original stem"))));
PracticeSessionRespVO created = service.createPracticeSession(
createReq("uuid-snapshot-source-change", "col-001", 1), 100L, 1L);
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(questionDTO("q-001", "v2", "Changed source stem"))));
PracticeSessionRespVO restored = service.getSession(created.getSessionId(), 100L, 1L);
assertEquals("Original stem", restored.getQuestions().get(0).getStem());
assertEquals("v1", restored.getQuestions().get(0).getContentVersion());
org.mockito.Mockito.verify(provider, org.mockito.Mockito.times(1))
.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1));
}
@Test
void shouldRejectDisabledProviderBeforeCreatingSession() {
when(provider.isEnabled()).thenReturn(false);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.createPracticeSession(createReq("uuid-disabled", "col-001", 1), 100L, 1L));
assertEquals(CATALOG_DATA_SOURCE_DISABLED.getCode(), ex.getCode());
}
@Test
void shouldRejectUnavailableProviderPageBeforeCreatingSession() {
when(provider.isEnabled()).thenReturn(true);
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.createPracticeSession(createReq("uuid-unavailable", "col-001", 1), 100L, 1L));
assertEquals(CATALOG_UPSTREAM_UNAVAILABLE.getCode(), ex.getCode());
assertTrue(sessionMapper.selectList().isEmpty());
assertTrue(questionMapper.selectList().isEmpty());
}
@Test
void shouldRejectInvisibleQuestionBeforeCreatingSession() {
when(provider.isEnabled()).thenReturn(true);
CatalogQuestionDTO q = CatalogQuestionDTO.builder()
.id("q-hidden")
.contentVersion("v1")
.stem("hidden")
.type("fill")
.difficulty("easy")
.isPublished(false)
.build();
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(q)));
ServiceException ex = assertThrows(ServiceException.class,
() -> service.createPracticeSession(createReq("uuid-hidden", "col-001", 1), 100L, 1L));
assertEquals(QUESTION_NOT_VISIBLE.getCode(), ex.getCode());
}
@Test
void shouldRejectUnsafeOptionBackedQuestionBeforePersistingSnapshot() {
when(provider.isEnabled()).thenReturn(true);
CatalogQuestionDTO q = CatalogQuestionDTO.builder()
.id("q-unsafe")
.contentVersion("v1")
.stem("unsafe")
.type("choice")
.difficulty("easy")
.isPublished(true)
.options(List.of(CatalogQuestionDTO.QuestionOptionDTO.builder()
.label("A").content("Only one").isCorrect(true).order(1.0).build()))
.build();
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(q)));
ServiceException ex = assertThrows(ServiceException.class,
() -> service.createPracticeSession(createReq("uuid-unsafe", "col-001", 1), 100L, 1L));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
assertTrue(questionMapper.selectList().isEmpty());
}
@Test
void shouldFailClosedWhenRestoringMalformedSnapshot() {
when(provider.isEnabled()).thenReturn(true);
CatalogQuestionDTO q = CatalogQuestionDTO.builder()
.id("q-restore")
.contentVersion("v1")
.stem("restore")
.type("choice")
.difficulty("easy")
.isPublished(true)
.options(List.of(
CatalogQuestionDTO.QuestionOptionDTO.builder()
.label("A").content("First").order(1.0).build(),
CatalogQuestionDTO.QuestionOptionDTO.builder()
.label("B").content("Second").order(2.0).build()))
.build();
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(q)));
PracticeSessionRespVO created = service.createPracticeSession(
createReq("uuid-restore", "col-001", 1), 100L, 1L);
PracticeQuestionDO stored = questionMapper.selectBySessionIdOrderBySequence(created.getSessionId()).get(0);
stored.setOptions("{}");
questionMapper.updateById(stored);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.getSession(created.getSessionId(), 100L, 1L));
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldNeverStoreCorrectAnswerInOptionsSnapshot() {
when(provider.isEnabled()).thenReturn(true);
@@ -462,7 +582,7 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
.id(id)
.contentVersion(version)
.stem(stem)
.type("choice")
.type("fill")
.difficulty("easy")
.isPublished(true)
.build();

View File

@@ -0,0 +1,152 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.SESSION_IDEMPOTENCY_MISMATCH;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
@Import(PracticeSessionServiceImpl.class)
class PracticeSessionServicePostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@Resource
private PracticeSessionMapper sessionMapper;
@Autowired
private PracticeQuestionMapper questionMapper;
@MockitoBean
private QuestionCatalogProvider provider;
@MockitoBean
private WrongQuestionService wrongQuestionService;
@MockitoBean
private ScoringService scoringService;
@Test
void shouldReturnOneSessionForConcurrentIdenticalCreates() throws Exception {
when(provider.isEnabled()).thenReturn(true);
when(provider.listQuestions(eq("col-race"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(question("q-1", "stem")));
PracticeSessionCreateReqVO req = createReq("postgres-race", "col-race", null);
ConcurrentResult result = runConcurrently(req, req);
assertNull(result.firstError.get());
assertNull(result.secondError.get());
assertNotNull(result.firstResponse.get());
assertNotNull(result.secondResponse.get());
assertEquals(result.firstResponse.get().getSessionId(), result.secondResponse.get().getSessionId());
assertEquals(1, sessionMapper.selectList().size());
assertEquals(1, questionMapper.selectList().size());
}
@Test
void shouldRejectConcurrentCreateWithDifferentFingerprint() throws Exception {
when(provider.isEnabled()).thenReturn(true);
when(provider.listQuestions(eq("col-race"), isNull(), isNull(), eq("easy"), eq(1), eq(1)))
.thenReturn(pageResult(question("q-easy", "easy")));
when(provider.listQuestions(eq("col-race"), isNull(), isNull(), eq("hard"), eq(1), eq(1)))
.thenReturn(pageResult(question("q-hard", "hard")));
PracticeSessionCreateReqVO easy = createReq("postgres-race-conflict", "col-race", "easy");
PracticeSessionCreateReqVO hard = createReq("postgres-race-conflict", "col-race", "hard");
ConcurrentResult result = runConcurrently(easy, hard);
long successes = java.util.stream.Stream.of(result.firstResponse.get(), result.secondResponse.get())
.filter(java.util.Objects::nonNull).count();
List<Exception> errors = java.util.stream.Stream.of(result.firstError.get(), result.secondError.get())
.filter(java.util.Objects::nonNull).toList();
assertEquals(1, successes);
assertEquals(1, errors.size());
assertInstanceOf(ServiceException.class, errors.get(0));
assertEquals(SESSION_IDEMPOTENCY_MISMATCH.getCode(), ((ServiceException) errors.get(0)).getCode());
assertEquals(1, sessionMapper.selectList().size());
assertEquals(1, questionMapper.selectList().size());
}
private ConcurrentResult runConcurrently(PracticeSessionCreateReqVO first, PracticeSessionCreateReqVO second)
throws Exception {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<PracticeSessionRespVO> firstResponse = new AtomicReference<>();
AtomicReference<PracticeSessionRespVO> secondResponse = new AtomicReference<>();
AtomicReference<Exception> firstError = new AtomicReference<>();
AtomicReference<Exception> secondError = new AtomicReference<>();
Thread firstThread = threadFor(first, ready, go, firstResponse, firstError);
Thread secondThread = threadFor(second, ready, go, secondResponse, secondError);
firstThread.start();
secondThread.start();
assertTrue(ready.await(5, TimeUnit.SECONDS));
go.countDown();
firstThread.join(10000);
secondThread.join(10000);
assertFalse(firstThread.isAlive());
assertFalse(secondThread.isAlive());
return new ConcurrentResult(firstResponse, secondResponse, firstError, secondError);
}
private Thread threadFor(PracticeSessionCreateReqVO req, CountDownLatch ready, CountDownLatch go,
AtomicReference<PracticeSessionRespVO> response, AtomicReference<Exception> error) {
return new Thread(() -> {
try {
ready.countDown();
go.await();
response.set(service.createPracticeSession(req, 100L, 1L));
} catch (Exception ex) {
error.set(ex);
}
});
}
private PracticeSessionCreateReqVO createReq(String clientSessionId, String collectionId, String difficulty) {
PracticeSessionCreateReqVO req = new PracticeSessionCreateReqVO();
req.setClientSessionId(clientSessionId);
req.setCollectionId(collectionId);
req.setDifficulty(difficulty);
req.setQuestionCount(1);
return req;
}
private CatalogQuestionPageResult pageResult(CatalogQuestionDTO question) {
return CatalogQuestionPageResult.builder().items(List.of(question)).total(1L).build();
}
private CatalogQuestionDTO question(String id, String stem) {
return CatalogQuestionDTO.builder()
.id(id).contentVersion("v1").stem(stem).type("fill").difficulty("easy").isPublished(true)
.build();
}
private record ConcurrentResult(
AtomicReference<PracticeSessionRespVO> firstResponse,
AtomicReference<PracticeSessionRespVO> secondResponse,
AtomicReference<Exception> firstError,
AtomicReference<Exception> secondError) {
}
}

View File

@@ -2,15 +2,16 @@ package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.*;
import cn.iocoder.yudao.module.education.dal.mysql.*;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionServiceImpl;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -27,8 +28,8 @@ import static org.junit.jupiter.api.Assertions.*;
*
* @author 恭学教育
*/
@Import({PracticeSessionServiceImpl.class, WrongQuestionServiceImpl.class})
public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
@Import({PracticeSessionServiceImpl.class, ScoringServiceImpl.class, WrongQuestionServiceImpl.class})
public class PracticeSubmitProjectionIntegrationTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@@ -36,7 +37,7 @@ public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
@Resource

View File

@@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
import cn.iocoder.yudao.module.education.dal.dataobject.*;
import cn.iocoder.yudao.module.education.dal.mysql.*;
@@ -10,9 +10,11 @@ import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
@@ -23,12 +25,12 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* PracticeSubmitService test — real DB (H2) backing all submit + report assertions.
* PracticeSubmitService test — real PostgreSQL backing all submit + report assertions.
*
* @author 恭学教育
*/
@Import(PracticeSessionServiceImpl.class)
public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
@Import({PracticeSessionServiceImpl.class, ScoringServiceImpl.class})
public class PracticeSubmitServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@@ -36,7 +38,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
@Resource
@@ -46,7 +48,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
private PracticeReportDetailMapper reportDetailMapper;
@Resource
private SubmitIdempotencyMapper submitIdempotencyMapper;
private IdempotencyStoreMapper idempotencyStoreMapper;
@MockitoBean
private QuestionCatalogProvider provider;
@@ -225,7 +227,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
assertEquals(1, reports.size());
// Exactly one idempotency record
List<SubmitIdempotencyDO> idempotencies = submitIdempotencyMapper.selectList();
List<IdempotencyDO> idempotencies = idempotencyStoreMapper.selectList();
assertEquals(1, idempotencies.stream()
.filter(r -> "idem-replay".equals(r.getIdempotencyKey())).count());
}
@@ -840,7 +842,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
SUBMIT_STALE_VERSION, 1, 99);
// Verify: no idempotency, no report, no details were committed
SubmitIdempotencyDO idem = submitIdempotencyMapper.selectByKey(
IdempotencyDO idem = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", "idem-cas-rb");
assertNull(idem, "idempotency must not exist after CAS failure rollback");
@@ -920,7 +922,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
// Create a second session for the "loser" — same session but new key
// Simulating what happens when different thread submits same session:
// INSERT IGNORE report returns 0, idempotency deleted, winner report returned
// ON CONFLICT DO NOTHING returns 0, idempotency deleted, winner report returned
// Use a different key to submit → should return winner's report
PracticeSubmitReqVO loserReq = createSubmitReq(f.sessionId, "idem-loser", 1);
@@ -928,10 +930,12 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
assertNotNull(loserResp.getReportId(), "loser must return winner's report with non-null reportId");
assertEquals(winnerResp.getReportId(), loserResp.getReportId());
// Loser's idempotency should NOT exist (deleted on insert-ignore-failure)
SubmitIdempotencyDO loserIdem = submitIdempotencyMapper.selectByKey(
// The losing key is completed against the immutable winner report so retries are stable.
IdempotencyDO loserIdem = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", "idem-loser");
assertNull(loserIdem, "loser idempotency must have been deleted");
assertNotNull(loserIdem);
assertEquals("COMPLETED", loserIdem.getStatus());
assertEquals(winnerResp.getReportId(), loserIdem.getReportId());
// Retry with loser's key → must return winner's report again (never null)
PracticeSubmitRespVO loserRetryResp = service.submitSession(loserReq, userId, tenantId);
@@ -981,21 +985,118 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
// Exactly one report, one idempotency record
List<PracticeReportDO> reports = reportMapper.selectList();
assertEquals(1, reports.size());
List<SubmitIdempotencyDO> idems = submitIdempotencyMapper.selectList();
List<IdempotencyDO> idems = idempotencyStoreMapper.selectList();
assertEquals(1, idems.stream().filter(i -> "idem-same".equals(i.getIdempotencyKey())).count());
}
@Test
void shouldTakeOverExpiredSubmitClaim() {
SessionFixture f = createSessionWithQuestions("uuid-expired-submit-claim", 2);
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-expired-submit-claim", 1);
IdempotencyDO staleClaim = new IdempotencyDO();
staleClaim.setTenantId(tenantId);
staleClaim.setUserId(userId);
staleClaim.setOperation("SUBMIT_SESSION");
staleClaim.setIdempotencyKey(req.getIdempotencyKey());
staleClaim.setRequestHash(computeSubmitHashForTest(req));
staleClaim.setSessionId(f.sessionId);
staleClaim.setStatus("PROCESSING");
staleClaim.setClaimToken("crashed-process");
staleClaim.setClaimStartedAt(LocalDateTime.now().minusMinutes(5));
idempotencyStoreMapper.insertIgnore(staleClaim);
PracticeSubmitRespVO response = service.submitSession(req, userId, tenantId);
assertNotNull(response.getReportId());
IdempotencyDO completed = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", req.getIdempotencyKey());
assertEquals("COMPLETED", completed.getStatus());
assertNull(completed.getClaimToken());
assertNull(completed.getClaimStartedAt());
}
@Test
void shouldFailClosedForActiveSubmitClaim() {
SessionFixture f = createSessionWithQuestions("uuid-active-submit-claim", 2);
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-active-submit-claim", 1);
IdempotencyDO activeClaim = new IdempotencyDO();
activeClaim.setTenantId(tenantId);
activeClaim.setUserId(userId);
activeClaim.setOperation("SUBMIT_SESSION");
activeClaim.setIdempotencyKey(req.getIdempotencyKey());
activeClaim.setRequestHash(computeSubmitHashForTest(req));
activeClaim.setSessionId(f.sessionId);
activeClaim.setStatus("PROCESSING");
activeClaim.setClaimToken("active-process");
activeClaim.setClaimStartedAt(LocalDateTime.now());
idempotencyStoreMapper.insertIgnore(activeClaim);
assertServiceException(() -> service.submitSession(req, userId, tenantId), SUBMIT_CONCURRENT_CONFLICT);
assertNull(reportMapper.selectBySessionIdAndTenant(f.sessionId, tenantId));
}
@Test
void shouldFailClosedWhenStoredSubmitReplayIsIncomplete() {
SessionFixture f = createSessionWithQuestions("uuid-incomplete-submit-replay", 2);
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-incomplete-submit", 1);
service.submitSession(req, userId, tenantId);
IdempotencyDO record = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", "idem-incomplete-submit");
record.setResponseJson("{}");
idempotencyStoreMapper.updateById(record);
assertServiceException(() -> service.submitSession(req, userId, tenantId),
SUBMIT_IDEMPOTENCY_REPLAY_INVALID);
assertEquals(1, reportMapper.selectList().size());
assertEquals("SUBMITTED", sessionMapper.selectById(f.sessionId).getStatus());
}
@Test
void shouldRejectConcurrentSameKeyDifferentPayload() throws Exception {
SessionFixture f = createSessionWithQuestions("uuid-race-diff-payload", 2);
answerQuestion(f.questions.get(0).getId(), "B", true);
SessionFixture firstSession = createSessionWithQuestions("uuid-race-diff-payload-a", 2);
SessionFixture changedSession = createSessionWithQuestions("uuid-race-diff-payload-b", 2);
answerQuestion(firstSession.questions.get(0).getId(), "B", true);
answerQuestion(changedSession.questions.get(0).getId(), "B", true);
PracticeSubmitReqVO first = createSubmitReq(f.sessionId, "idem-diff-payload", 1);
PracticeSubmitReqVO changed = createSubmitReq(f.sessionId, "idem-diff-payload", 2);
PracticeSubmitReqVO first = createSubmitReq(firstSession.sessionId, "idem-diff-payload", 1);
PracticeSubmitReqVO changed = createSubmitReq(changedSession.sessionId, "idem-diff-payload", 1);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<PracticeSubmitRespVO> r1 = new AtomicReference<>();
AtomicReference<PracticeSubmitRespVO> r2 = new AtomicReference<>();
AtomicReference<Exception> e1 = new AtomicReference<>();
AtomicReference<Exception> e2 = new AtomicReference<>();
PracticeSubmitRespVO winner = service.submitSession(first, userId, tenantId);
assertNotNull(winner.getReportId());
assertServiceException(() -> service.submitSession(changed, userId, tenantId), SUBMIT_IDEMPOTENCY_CONFLICT);
Thread t1 = new Thread(() -> {
try { ready.countDown(); go.await(); r1.set(service.submitSession(first, userId, tenantId)); }
catch (Exception e) { e1.set(e); }
});
Thread t2 = new Thread(() -> {
try { ready.countDown(); go.await(); r2.set(service.submitSession(changed, userId, tenantId)); }
catch (Exception e) { e2.set(e); }
});
t1.start(); t2.start(); ready.await(); go.countDown();
t1.join(10000); t2.join(10000);
int successCount = (r1.get() != null ? 1 : 0) + (r2.get() != null ? 1 : 0);
assertEquals(1, successCount);
int conflictCount = 0;
for (Exception error : new Exception[]{e1.get(), e2.get()}) {
if (error instanceof ServiceException serviceError
&& serviceError.getCode() == SUBMIT_IDEMPOTENCY_CONFLICT.getCode()) {
conflictCount++;
}
}
assertEquals(1, conflictCount);
assertEquals(1, reportMapper.selectList().size());
IdempotencyDO idempotency = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", "idem-diff-payload");
assertNotNull(idempotency);
assertEquals("COMPLETED", idempotency.getStatus());
assertNotNull(idempotency.getReportId());
}
// ========== Ticket #8 #4: Cross-tenant/user report detail isolation ==========
@@ -1044,6 +1145,14 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
// ========== helper for test — mirror canonical storage ==========
private String computeSubmitHashForTest(PracticeSubmitReqVO req) {
java.util.Map<String, Object> canonical = new java.util.TreeMap<>();
canonical.put("sessionId", req.getSessionId());
canonical.put("expectedSessionVersion", req.getExpectedSessionVersion());
return cn.hutool.crypto.digest.DigestUtil.sha256Hex(
cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(canonical));
}
/**
* Mirror correctAnswerToJson for test seeding.
*/

View File

@@ -41,6 +41,30 @@ class QuestionCatalogServiceImplTest {
// ========== Safety field stripping ==========
@Test
void shouldFailClosedWhenVisibleOptionBackedQuestionHasUnsafeOptions() {
CatalogQuestionDTO dto = questionDto("q-unsafe", "Unsafe", "choice", "easy", true,
List.of(optionDto("A", "Only one", true)), null, null, null, null);
when(provider.getQuestion("q-unsafe")).thenReturn(dto);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.getQuestion("q-unsafe"));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
}
@Test
void shouldAllowVisibleOptionlessQuestionWithoutOptions() {
CatalogQuestionDTO dto = questionDto("q-text", "Explain", "short_answer", "easy", true,
null, null, null, null, null);
when(provider.getQuestion("q-text")).thenReturn(dto);
SafeQuestionRespVO result = service.getQuestion("q-text");
assertNotNull(result.getOptions());
assertTrue(result.getOptions().isEmpty());
}
@Test
void shouldStripCorrectAnswerAndExplanationFields() {
CatalogQuestionDTO dto = questionDto("q1", "What is 2+2?", "choice", "easy", true,
@@ -75,7 +99,7 @@ class QuestionCatalogServiceImplTest {
@Test
void shouldStripAnswerFieldsInPageResponse() {
CatalogQuestionDTO dto = questionDto("q1", "Q1", "choice", "easy", true,
List.of(optionDto("A", "Yes", true)),
List.of(optionDto("A", "Yes", true), optionDto("B", "No", false)),
"A", "A", "Explain", "Analyze");
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
@@ -233,7 +257,7 @@ class QuestionCatalogServiceImplTest {
void shouldUseProviderTotalAsTruthfulVisibleTotal() {
// Provider enforces visibility contract — all items are visible,
// total IS the visible total. Service uses both directly.
CatalogQuestionDTO q1 = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null);
CatalogQuestionDTO q1 = questionDto("q1", "Q1", "fill", "easy", true, null, null, null, null, null);
CatalogQuestionDTO q2 = questionDto("q2", "Q2", "fill", "medium", true, null, null, null, null, null);
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
@@ -258,7 +282,7 @@ class QuestionCatalogServiceImplTest {
@Test
void shouldUseProviderTotalForCollectionQuestions() {
CatalogQuestionDTO dto = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null);
CatalogQuestionDTO dto = questionDto("q1", "Q1", "fill", "easy", true, null, null, null, null, null);
when(provider.listCollectionQuestions("col1", null, null, 1, 20))
.thenReturn(CatalogQuestionPageResult.builder()
@@ -276,12 +300,12 @@ class QuestionCatalogServiceImplTest {
@Test
void shouldAllowAllPublishedAndActiveItems() {
// Mixed types but all published and status != hidden/inactive → all pass
CatalogQuestionDTO q1 = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null);
CatalogQuestionDTO q1 = questionDto("q1", "Q1", "fill", "easy", true, null, null, null, null, null);
CatalogQuestionDTO q2 = CatalogQuestionDTO.builder()
.id("q2").stem("Q2").type("fill").difficulty("medium")
.isPublished(true).status("active").build();
CatalogQuestionDTO q3 = CatalogQuestionDTO.builder()
.id("q3").stem("Q3").type("essay").difficulty("hard")
.id("q3").stem("Q3").type("short_answer").difficulty("hard")
.isPublished(true).status(null).build();
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
@@ -498,7 +522,7 @@ class QuestionCatalogServiceImplTest {
@Test
void shouldNotLeakVisibilityChecksAcrossCalls() {
CatalogQuestionDTO visible = questionDto("q-visible", "Visible", "choice", "easy", true, null,
CatalogQuestionDTO visible = questionDto("q-visible", "Visible", "fill", "easy", true, null,
null, null, null, null);
when(provider.getQuestion("q-visible")).thenReturn(visible);
@@ -517,7 +541,7 @@ class QuestionCatalogServiceImplTest {
void shouldMakeSingleProviderCallForPage() {
// The provider's listQuestions returns CatalogQuestionPageResult with total,
// so the service only makes one call. Total is used directly — no extra count call.
CatalogQuestionDTO q1 = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null);
CatalogQuestionDTO q1 = questionDto("q1", "Q1", "fill", "easy", true, null, null, null, null, null);
CatalogQuestionDTO q2 = questionDto("q2", "Q2", "fill", "easy", true, null, null, null, null, null);
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))

View File

@@ -0,0 +1,123 @@
package cn.iocoder.yudao.module.education.service.question;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety.SafeOption;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_UPSTREAM_OPTIONS_MALFORMED;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.UNSAFE_PROVIDER_PAYLOAD;
import static org.junit.jupiter.api.Assertions.*;
class QuestionContentSafetyTest {
@Test
void shouldAcceptAndOrderValidOptionBackedQuestion() {
List<SafeOption> options = QuestionContentSafety.validateProviderOptions(" choice ", List.of(
option("B", "Second", false, 2.0),
option("A", "First", true, 1.0)));
assertEquals(List.of("A", "B"), options.stream().map(SafeOption::label).toList());
assertEquals(List.of("First", "Second"), options.stream().map(SafeOption::content).toList());
}
@Test
void shouldRejectOptionBackedQuestionWithoutTwoOptions() {
ServiceException ex = assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("choice", List.of(
option("A", "Only", true, 1.0))));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
}
@Test
void shouldRejectDuplicateTrimmedLabels() {
ServiceException ex = assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("multi", List.of(
option("A", "First", true, 1.0),
option(" A ", "Duplicate", false, 2.0))));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
}
@Test
void shouldAcceptOptionlessQuestionWithoutOptions() {
assertEquals(Collections.emptyList(),
QuestionContentSafety.validateProviderOptions("short_answer", null));
}
@Test
void shouldRejectOptionsOnOptionlessQuestion() {
ServiceException ex = assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("short_answer", List.of(
option("A", "Unexpected", null, 1.0),
option("B", "Unexpected", null, 2.0))));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
}
@Test
void shouldRejectUnsupportedCompositeAndUnknownTypes() {
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("reading", null)).getCode());
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("mystery", null)).getCode());
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions(" ", null)).getCode());
}
@Test
void shouldCreateAnswerFreeSnapshotAndRestoreIt() {
String snapshot = QuestionContentSafety.toSafeSnapshotJson("choice", List.of(
option("A", "First", true, 1.0),
option("B", "Second", false, 2.0)));
assertFalse(snapshot.contains("isCorrect"));
assertFalse(snapshot.contains("correctAnswer"));
List<SafeOption> restored = QuestionContentSafety.restoreSnapshotOptions("choice", snapshot);
assertEquals(List.of("A", "B"), restored.stream().map(SafeOption::label).toList());
}
@Test
void shouldRestoreValidOptionlessSnapshot() {
assertEquals(Collections.emptyList(),
QuestionContentSafety.restoreSnapshotOptions("fill", null));
assertEquals(Collections.emptyList(),
QuestionContentSafety.restoreSnapshotOptions("fill", "[]"));
}
@Test
void shouldRejectAnswerBearingFieldsInSnapshot() {
ServiceException ex = assertThrows(ServiceException.class,
() -> QuestionContentSafety.restoreSnapshotOptions("choice",
"[{\"label\":\"A\",\"content\":\"First\",\"order\":1,\"isCorrect\":true},"
+ "{\"label\":\"B\",\"content\":\"Second\",\"order\":2}]"));
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldFailClosedForMalformedOrTypeInconsistentSnapshot() {
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.restoreSnapshotOptions("choice", "not-json")).getCode());
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.restoreSnapshotOptions("choice", "[]")).getCode());
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.restoreSnapshotOptions("short_answer",
"[{\"label\":\"A\",\"content\":\"Unexpected\",\"order\":1}]")).getCode());
}
private static CatalogQuestionDTO.QuestionOptionDTO option(String label, String content,
Boolean correct, Double order) {
return CatalogQuestionDTO.QuestionOptionDTO.builder()
.label(label)
.content(content)
.isCorrect(correct)
.order(order)
.build();
}
}

View File

@@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.education.service.wrong;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionDetailRespVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageItemRespVO;
@@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.education.dal.dataobject.*;
import cn.iocoder.yudao.module.education.dal.mysql.*;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
@@ -24,12 +25,12 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* WrongQuestionService test — real DB (H2) backing all wrong question assertions.
* WrongQuestionService test — real PostgreSQL backing all wrong question assertions.
*
* @author 恭学教育
*/
@Import(WrongQuestionServiceImpl.class)
public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
public class WrongQuestionServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private WrongQuestionService wrongQuestionService;
@@ -43,7 +44,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
private final Long userId = 100L;
@@ -250,7 +251,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
@@ -281,7 +282,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
@@ -314,7 +315,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
wq.setLastWrongTime(LocalDateTime.now());
@@ -342,7 +343,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
wq.setLastWrongTime(LocalDateTime.now());
@@ -368,7 +369,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Question page " + i);
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(i));
wq.setLastWrongTime(LocalDateTime.now().minusHours(i));
@@ -464,7 +465,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now());
@@ -493,7 +494,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now());
@@ -525,7 +526,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Detail question");
wq.setType("choice");
wq.setDifficulty("hard");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v2");
wq.setLatestCorrectAnswer("B");
wq.setLatestExplanation("Because B is correct");
@@ -560,7 +561,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setLatestCorrectAnswer("B");
wq.setLatestExplanation("Old explanation");
@@ -603,7 +604,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setLatestCorrectAnswer("B");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
@@ -639,7 +640,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
WrongQuestionDO wq1 = new WrongQuestionDO();
wq1.setTenantId(tenantId); wq1.setUserId(userId);
wq1.setQuestionId("q-fp-1"); wq1.setStem("Q1"); wq1.setType("choice");
wq1.setDifficulty("easy"); wq1.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq1.setDifficulty("easy"); wq1.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq1.setContentVersion("v1"); wq1.setFirstWrongTime(LocalDateTime.now());
wq1.setLastWrongTime(LocalDateTime.now()); wq1.setWrongCount(1);
wq1.setMasterStatus("PENDING");
@@ -648,7 +649,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
WrongQuestionDO wq2 = new WrongQuestionDO();
wq2.setTenantId(tenantId); wq2.setUserId(userId);
wq2.setQuestionId("q-fp-2"); wq2.setStem("Q2"); wq2.setType("choice");
wq2.setDifficulty("easy"); wq2.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq2.setDifficulty("easy"); wq2.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq2.setContentVersion("v1"); wq2.setFirstWrongTime(LocalDateTime.now());
wq2.setLastWrongTime(LocalDateTime.now()); wq2.setWrongCount(1);
wq2.setMasterStatus("PENDING");
@@ -684,7 +685,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId); wq.setUserId(userId);
wq.setQuestionId("q-cross-user-session"); wq.setStem("Q"); wq.setType("choice");
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
wq.setMasterStatus("PENDING");
@@ -712,7 +713,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId); wq.setUserId(userId);
wq.setQuestionId("q-conc-review"); wq.setStem("Q"); wq.setType("choice");
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
wq.setMasterStatus("PENDING");

View File

@@ -0,0 +1,337 @@
package cn.iocoder.yudao.module.education.test;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class EducationFlywayMigrationIntegrationTest {
private static final String HOST = requiredEnv("EDU_TEST_POSTGRES_HOST");
private static final String PORT = requiredEnv("EDU_TEST_POSTGRES_PORT");
private static final String DATABASE = requiredEnv("EDU_TEST_POSTGRES_DB");
private static final String USER = requiredEnv("EDU_TEST_POSTGRES_USER");
private static final String PASSWORD = requiredEnv("EDU_TEST_POSTGRES_PASSWORD");
private final List<String> schemas = new ArrayList<>();
@AfterEach
void dropSchemas() throws SQLException {
try (Connection connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
for (String schema : schemas) {
statement.execute("DROP SCHEMA IF EXISTS " + schema + " CASCADE");
}
}
}
@Test
void shouldFailClosedWhenLegacyTableContainsConflictingDuplicateKeys() throws SQLException {
String schema = createSchema("duplicate");
createCompatibleManualPracticeFixture(schema);
execute(schema, """
INSERT INTO education_answer_idempotency
(tenant_id, user_id, idempotency_key, request_hash, session_id, question_id,
selected_answer, response_json)
VALUES (1, 10, 'answer-key', 'different-hash', 100, 'q-1', 'B', '{"accepted":true}');
""");
assertThatThrownBy(() -> configureFlyway(schema, true).load().migrate())
.hasMessageContaining("conflicting duplicate request_hash");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4030' AND success = TRUE"))
.isZero();
}
@Test
void shouldFailClosedWhenLegacyIdempotencyConflictsWithUnifiedHistory() throws SQLException {
String schema = createSchema("conflict");
createCompatibleManualPracticeFixture(schema);
execute(schema, """
CREATE TABLE education_idempotency (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, operation VARCHAR(32) NOT NULL,
idempotency_key VARCHAR(64) NOT NULL, request_hash VARCHAR(64) NOT NULL,
session_id BIGINT NOT NULL, question_id VARCHAR(64), selected_answer TEXT, report_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED', response_json TEXT, business_payload JSONB,
creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO education_idempotency
(tenant_id, user_id, operation, idempotency_key, request_hash, session_id)
VALUES (1, 10, 'SUBMIT_ANSWER', 'answer-key', 'different-hash', 100);
""");
assertThatThrownBy(() -> configureFlyway(schema, true).load().migrate())
.hasMessageContaining("education_answer_idempotency conflicts")
.hasMessageContaining("request_hash");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4030' AND success = TRUE"))
.isZero();
assertThat(queryLong(schema, "SELECT COUNT(*) FROM education_answer_idempotency"))
.isEqualTo(1L);
}
@Test
void shouldMigratePlatformSchemaFromBaseline4009() throws SQLException {
String schema = createSchema("baseline");
execute(schema, "CREATE TABLE platform_marker (id BIGINT PRIMARY KEY)");
Flyway flyway = configureFlyway(schema, true).load();
flyway.migrate();
flyway.validate();
assertThat(queryStrings(schema,
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
.containsExactly("4009", "4010", "4020", "4030", "4040");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name = 'education_idempotency'"))
.isEqualTo(1L);
}
@Test
void shouldAdoptCompatibleManualPracticeSchemaAndBackfillIdempotency() throws SQLException {
String schema = createSchema("adoption");
createCompatibleManualPracticeFixture(schema);
Flyway flyway = configureFlyway(schema, true).load();
flyway.migrate();
flyway.validate();
assertThat(queryStrings(schema,
"SELECT column_name FROM information_schema.columns " +
"WHERE table_schema = current_schema() AND table_name = 'education_practice_session' " +
"AND column_name IN ('last_client_sequence', 'review_fingerprint') ORDER BY column_name"))
.containsExactly("last_client_sequence", "review_fingerprint");
assertThat(queryStrings(schema,
"SELECT data_type FROM information_schema.columns " +
"WHERE table_schema = current_schema() AND table_name = 'education_practice_question' " +
"AND column_name = 'options'"))
.containsExactly("jsonb");
assertThat(queryStrings(schema,
"SELECT operation || ':' || idempotency_key || ':' || request_hash " +
"FROM education_idempotency ORDER BY operation"))
.containsExactly(
"SUBMIT_ANSWER:answer-key:answer-hash",
"SUBMIT_SESSION:partial-submit-key:partial-submit-hash",
"SUBMIT_SESSION:submit-key:submit-hash");
assertThat(queryStrings(schema,
"SELECT idempotency_key || ':' || status FROM education_idempotency " +
"WHERE operation = 'SUBMIT_SESSION' ORDER BY idempotency_key"))
.containsExactly(
"partial-submit-key:PROCESSING",
"submit-key:COMPLETED");
assertThat(queryStrings(schema,
"SELECT column_name || ':' || data_type FROM information_schema.columns " +
"WHERE table_schema = current_schema() AND table_name = 'education_idempotency' " +
"AND column_name IN ('claim_started_at', 'claim_token') ORDER BY column_name"))
.containsExactly(
"claim_started_at:timestamp without time zone",
"claim_token:character varying");
assertThat(queryLong(schema, "SELECT COUNT(*) FROM education_answer_idempotency"))
.isEqualTo(1L);
assertThat(queryLong(schema, "SELECT COUNT(*) FROM education_submit_idempotency"))
.isEqualTo(2L);
}
@Test
void shouldMigrateFreshPracticeSchemaThroughFlyway() throws SQLException {
String schema = createSchema("fresh");
Flyway flyway = configureFlyway(schema, false).load();
flyway.migrate();
flyway.validate();
assertThat(queryStrings(schema,
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
.containsExactly("4010", "4020", "4030", "4040");
assertThat(queryStrings(schema,
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = current_schema() AND table_name IN (" +
"'education_practice_session', 'education_practice_question', " +
"'education_practice_report', 'education_practice_report_detail', " +
"'education_wrong_question', 'education_wrong_question_idempotency', " +
"'education_favorite', 'education_idempotency') ORDER BY table_name"))
.containsExactly(
"education_favorite",
"education_idempotency",
"education_practice_question",
"education_practice_report",
"education_practice_report_detail",
"education_practice_session",
"education_wrong_question",
"education_wrong_question_idempotency");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name IN ('education_answer_idempotency', 'education_submit_idempotency')"))
.isZero();
assertThat(queryStrings(schema,
"SELECT column_name || ':' || udt_name FROM information_schema.columns " +
"WHERE table_schema = current_schema() AND " +
"((table_name = 'education_practice_question' AND column_name = 'options') OR " +
"(table_name = 'education_practice_report_detail' AND column_name = 'options') OR " +
"(table_name = 'education_wrong_question' AND column_name = 'options') OR " +
"(table_name = 'education_favorite' AND column_name = 'options') OR " +
"(table_name = 'education_idempotency' AND column_name = 'business_payload')) " +
"ORDER BY table_name"))
.containsExactly(
"options:jsonb",
"business_payload:jsonb",
"options:jsonb",
"options:jsonb",
"options:jsonb");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM pg_indexes WHERE schemaname = current_schema() AND indexname IN (" +
"'uk_education_practice_session_tenant_client', " +
"'uk_education_practice_question_session_sequence', " +
"'uk_education_practice_report_tenant_session', " +
"'uk_education_practice_report_detail_report_sequence', " +
"'uk_education_wrong_question_tenant_user_question', " +
"'uk_education_wrong_idempotency_tenant_user_question_report', " +
"'uk_education_favorite_tenant_user_target', " +
"'uk_education_idempotency_tenant_user_operation_key')"))
.isEqualTo(8L);
}
private void createCompatibleManualPracticeFixture(String schema) throws SQLException {
execute(schema, """
CREATE TABLE education_practice_session (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
client_session_id VARCHAR(36) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
question_count INTEGER NOT NULL DEFAULT 0,
collection_id VARCHAR(64), node_id VARCHAR(64), type VARCHAR(32), difficulty VARCHAR(32),
version INTEGER NOT NULL DEFAULT 0,
creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE education_practice_question (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL, session_id BIGINT NOT NULL, sequence INTEGER NOT NULL,
question_id VARCHAR(64) NOT NULL, content_version VARCHAR(64) NOT NULL DEFAULT '',
stem TEXT NOT NULL, type VARCHAR(32) NOT NULL, difficulty VARCHAR(32), options TEXT NOT NULL,
selected_answer TEXT, is_answered BOOLEAN NOT NULL DEFAULT false,
creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO education_practice_question
(tenant_id, session_id, sequence, question_id, stem, type, options)
VALUES (1, 100, 1, 'q-1', 'question', 'choice', '[{"label":"A","content":"1"}]');
CREATE TABLE education_answer_idempotency (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
operation VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_ANSWER', idempotency_key VARCHAR(64) NOT NULL,
request_hash VARCHAR(64) NOT NULL, session_id BIGINT NOT NULL, question_id VARCHAR(64) NOT NULL,
selected_answer TEXT, status VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED', response_json TEXT NOT NULL,
creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE education_submit_idempotency (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
operation VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_SESSION', idempotency_key VARCHAR(64) NOT NULL,
request_hash VARCHAR(64) NOT NULL, session_id BIGINT NOT NULL, report_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED', response_json TEXT NOT NULL,
creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO education_answer_idempotency
(tenant_id, user_id, idempotency_key, request_hash, session_id, question_id,
selected_answer, response_json)
VALUES (1, 10, 'answer-key', 'answer-hash', 100, 'q-1', 'A', '{"accepted":true}');
INSERT INTO education_submit_idempotency
(tenant_id, user_id, idempotency_key, request_hash, session_id, report_id, response_json)
VALUES (1, 10, 'submit-key', 'submit-hash', 100, 200, '{"reportId":200}');
INSERT INTO education_submit_idempotency
(tenant_id, user_id, idempotency_key, request_hash, session_id, report_id, response_json)
VALUES (1, 10, 'partial-submit-key', 'partial-submit-hash', 101, NULL, '');
""");
}
private void execute(String schema, String sql) throws SQLException {
try (Connection connection = DriverManager.getConnection(jdbcUrl(schema), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute(sql);
}
}
private String createSchema(String suffix) throws SQLException {
String schema = "edu_flyway_" + suffix + "_" + UUID.randomUUID().toString().replace("-", "");
try (Connection connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute("CREATE SCHEMA " + schema);
}
schemas.add(schema);
return schema;
}
private org.flywaydb.core.api.configuration.FluentConfiguration configureFlyway(
String schema, boolean baselineOnMigrate) {
return Flyway.configure()
.dataSource(jdbcUrl(schema), USER, PASSWORD)
.locations("classpath:db/migration/education")
.schemas(schema)
.defaultSchema(schema)
.baselineOnMigrate(baselineOnMigrate)
.baselineVersion("4009")
.validateOnMigrate(true)
.cleanDisabled(true)
.outOfOrder(false);
}
private List<String> queryStrings(String schema, String sql) throws SQLException {
try (Connection connection = DriverManager.getConnection(jdbcUrl(schema), USER, PASSWORD);
var statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(sql)) {
List<String> values = new ArrayList<>();
while (resultSet.next()) {
values.add(resultSet.getString(1));
}
return values;
}
}
private long queryLong(String schema, String sql) throws SQLException {
try (Connection connection = DriverManager.getConnection(jdbcUrl(schema), USER, PASSWORD);
var statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(sql)) {
resultSet.next();
return resultSet.getLong(1);
}
}
private static String jdbcUrl(String schema) {
return adminUrl() + "?currentSchema=" + schema + "&stringtype=unspecified";
}
private static String adminUrl() {
return "jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE;
}
private static String requiredEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " must be set for PostgreSQL integration tests");
}
return value;
}
}

View File

@@ -0,0 +1,116 @@
package cn.iocoder.yudao.module.education.test;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.datasource.config.YudaoDataSourceAutoConfiguration;
import cn.iocoder.yudao.framework.mybatis.config.YudaoMybatisAutoConfiguration;
import org.flywaydb.core.Flyway;
import com.alibaba.druid.spring.boot4.autoconfigure.DruidDataSourceAutoConfigure;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.parallel.ResourceAccessMode;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.UUID;
/**
* PostgreSQL persistence seam for Education tests.
*
* <p>All subclasses in one Maven JVM share a random disposable schema and are
* serialized with a JUnit resource lock. Their Spring contexts are closed after
* each class so schema initialization cannot be reused after the schema is dropped.
* Module-owned Flyway migrations create the disposable schema before Spring starts.</p>
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,
classes = PostgreSqlDbIntegrationTest.Application.class)
@ActiveProfiles("education-postgresql-test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@ResourceLock(value = "education-postgresql-schema", mode = ResourceAccessMode.READ_WRITE)
@Sql(scripts = "/sql/postgresql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public abstract class PostgreSqlDbIntegrationTest {
private static final String HOST = requiredEnv("EDU_TEST_POSTGRES_HOST");
private static final String PORT = requiredEnv("EDU_TEST_POSTGRES_PORT");
private static final String DATABASE = requiredEnv("EDU_TEST_POSTGRES_DB");
private static final String USER = requiredEnv("EDU_TEST_POSTGRES_USER");
private static final String PASSWORD = requiredEnv("EDU_TEST_POSTGRES_PASSWORD");
private static final String SCHEMA = "edu_test_" + UUID.randomUUID().toString().replace("-", "");
@BeforeAll
static void createSchema() throws SQLException {
try (var connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute("CREATE SCHEMA " + SCHEMA);
}
Flyway.configure()
.dataSource(jdbcUrl(), USER, PASSWORD)
.locations("classpath:db/migration/education")
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
.baselineOnMigrate(false)
.validateOnMigrate(true)
.cleanDisabled(true)
.outOfOrder(false)
.load()
.migrate();
}
@AfterAll
static void dropSchema() throws SQLException {
try (var connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute("DROP SCHEMA IF EXISTS " + SCHEMA + " CASCADE");
}
}
@DynamicPropertySource
static void postgresqlProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", PostgreSqlDbIntegrationTest::jdbcUrl);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.datasource.username", () -> USER);
registry.add("spring.datasource.password", () -> PASSWORD);
registry.add("spring.sql.init.mode", () -> "never");
}
private static String jdbcUrl() {
return "jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE
+ "?currentSchema=" + SCHEMA + "&stringtype=unspecified";
}
private static String adminUrl() {
return "jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE;
}
private static String requiredEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " must be set for PostgreSQL integration tests");
}
return value;
}
@Import({
YudaoDataSourceAutoConfiguration.class,
DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class,
DruidDataSourceAutoConfigure.class,
YudaoMybatisAutoConfiguration.class,
MybatisPlusAutoConfiguration.class,
MybatisPlusJoinAutoConfiguration.class,
SpringUtil.class
})
public static class Application {
}
}

View File

@@ -0,0 +1,28 @@
spring:
main:
lazy-initialization: true
banner-mode: off
sql:
init:
mode: always
continue-on-error: false
data:
redis:
host: 127.0.0.1
port: 16379
database: 0
yudao:
info:
base-package: cn.iocoder.yudao.module
education:
enabled: true
mybatis-plus:
global-config:
db-config:
logic-delete-value: true
logic-not-delete-value: false
mybatis:
lazy-initialization: true

View File

@@ -4,6 +4,7 @@ DELETE FROM education_wrong_question;
DELETE FROM education_practice_report_detail;
DELETE FROM education_practice_report;
DELETE FROM education_idempotency;
DELETE FROM education_submit_idempotency;
DELETE FROM education_practice_question;
DELETE FROM education_practice_session;

View File

@@ -95,6 +95,31 @@ CREATE TABLE IF NOT EXISTS "education_submit_idempotency" (
CREATE INDEX IF NOT EXISTS "idx_submit_session" ON "education_submit_idempotency" ("tenant_id", "session_id");
CREATE TABLE IF NOT EXISTS "education_idempotency" (
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"tenant_id" BIGINT NOT NULL,
"user_id" BIGINT NOT NULL,
"operation" VARCHAR(32) NOT NULL,
"idempotency_key" VARCHAR(64) NOT NULL,
"request_hash" VARCHAR(64) NOT NULL,
"session_id" BIGINT NOT NULL,
"question_id" VARCHAR(64) DEFAULT NULL,
"selected_answer" CLOB DEFAULT NULL,
"report_id" BIGINT DEFAULT NULL,
"status" VARCHAR(20) NOT NULL,
"response_json" CLOB DEFAULT NULL,
"business_payload" CLOB DEFAULT NULL,
"creator" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR(64) DEFAULT '',
"update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id"),
CONSTRAINT "uk_education_idempotency" UNIQUE ("tenant_id", "user_id", "operation", "idempotency_key")
);
CREATE INDEX IF NOT EXISTS "idx_education_idempotency_session" ON "education_idempotency" ("tenant_id", "session_id");
CREATE TABLE IF NOT EXISTS "education_practice_report" (
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"tenant_id" BIGINT NOT NULL,

View File

@@ -0,0 +1,12 @@
-- PostgreSQL persistence test cleanup.
-- Module-owned Flyway creates the disposable schema; this only isolates test data.
TRUNCATE TABLE
education_idempotency,
education_favorite,
education_wrong_question_idempotency,
education_wrong_question,
education_practice_report_detail,
education_practice_report,
education_practice_question,
education_practice_session
RESTART IDENTITY CASCADE;