feat(education): add question favorites
This commit is contained in:
22
sql/mysql/education/007-education-favorite-rollback.sql
Normal file
22
sql/mysql/education/007-education-favorite-rollback.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- =============================================
|
||||
-- Education 模块 — 收藏夹 DDL Rollback
|
||||
-- Migration: 007
|
||||
-- =============================================
|
||||
-- IMPORTANT: This is a documentation-only rollback.
|
||||
-- No DROP/ALTER/DELETE statements are executed. The favorite
|
||||
-- table is provenance-safe: it only accumulates user preference
|
||||
-- data. Dropping this table would lose student favorites with
|
||||
-- no recovery path.
|
||||
--
|
||||
-- What this migration created:
|
||||
-- - education_favorite (new table)
|
||||
--
|
||||
-- Manual rollback requires:
|
||||
-- 1. Verified database backup before rollback
|
||||
-- 2. Operator approval (DBA sign-off)
|
||||
-- 3. Provenance of all favorite records preserved (exported)
|
||||
-- 4. Soft-delete via deleted = b'1' before any hard drop
|
||||
--
|
||||
-- These tables are NOT deleted by this script. Favorite history is
|
||||
-- retained; if deletion is required by external policy, consult
|
||||
-- the DBA for a verified rollback procedure.
|
||||
77
sql/mysql/education/007-education-favorite.sql
Normal file
77
sql/mysql/education/007-education-favorite.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- =============================================
|
||||
-- Education 模块 — 收藏夹 DDL
|
||||
-- Ticket #10: 学生收藏题目
|
||||
-- Migration: 007
|
||||
-- Prerequisites: 000-education-schema.sql (base tables)
|
||||
-- =============================================
|
||||
|
||||
-- =============================================
|
||||
-- Preconditions
|
||||
-- =============================================
|
||||
-- Operator is expected to verify:
|
||||
-- SELECT COUNT(*) FROM information_schema.tables
|
||||
-- WHERE table_schema = DATABASE()
|
||||
-- AND table_name = 'education_favorite';
|
||||
-- Result MUST be 0 before executing this migration.
|
||||
|
||||
-- =============================================
|
||||
-- 收藏表
|
||||
-- =============================================
|
||||
-- Purpose: Student favorites for questions with safe snapshots.
|
||||
-- Each (tenant, user, target_type, target_id) is a unique entry.
|
||||
-- Logical deletion: setting deleted=1 marks as unfavorited.
|
||||
-- Re-adding after deletion reactivates the row via ON DUPLICATE KEY UPDATE.
|
||||
--
|
||||
-- target_type values: 'QUESTION' (extensible enum)
|
||||
--
|
||||
-- Snapshot fields (stem, type, difficulty, options, content_version):
|
||||
-- populated at creation time from the visible question's safe fields.
|
||||
-- These snapshots preserve the question state as it appeared when favorited,
|
||||
-- and remain stable even if the source question later changes or becomes unavailable.
|
||||
--
|
||||
-- available flag:
|
||||
-- FALSE when the source question becomes hidden/unpublished after being
|
||||
-- favorited. Existing favorites with available=FALSE remain listable but
|
||||
-- display an "unavailable" indicator. New favorites cannot be created for
|
||||
-- unavailable resources.
|
||||
--
|
||||
-- Indexes:
|
||||
-- uk_tenant_user_target — per (tenant, user, target_type, target_id) uniqueness.
|
||||
-- INSERT ... ON DUPLICATE KEY UPDATE is the primary reactivation path.
|
||||
-- idx_tenant_user — covers listing queries filtered by current tenant+user.
|
||||
-- idx_tenant_user_target_type — covers target-type-filtered listing.
|
||||
CREATE TABLE `education_favorite` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
|
||||
`user_id` BIGINT NOT NULL COMMENT '学生用户编号',
|
||||
`target_type` VARCHAR(32) NOT NULL COMMENT '目标类型:QUESTION',
|
||||
`target_id` VARCHAR(64) NOT NULL COMMENT '目标 ID(题目 ID)',
|
||||
-- safe snapshot fields
|
||||
`stem` TEXT DEFAULT NULL COMMENT '题干快照',
|
||||
`type` VARCHAR(32) DEFAULT NULL COMMENT '题型快照',
|
||||
`difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照',
|
||||
`options` JSON DEFAULT NULL COMMENT '选项快照 JSON(不含 isCorrect)',
|
||||
`content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '题目内容版本',
|
||||
-- availability
|
||||
`available` BIT(1) NOT NULL DEFAULT b'1' COMMENT '源资源是否可用',
|
||||
-- audit
|
||||
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tenant_user_target` (`tenant_id`, `user_id`, `target_type`, `target_id`),
|
||||
KEY `idx_tenant_user` (`tenant_id`, `user_id`),
|
||||
KEY `idx_tenant_user_target_type` (`tenant_id`, `user_id`, `target_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-收藏夹';
|
||||
|
||||
-- =============================================
|
||||
-- Post-migration verification queries
|
||||
-- =============================================
|
||||
-- Verify new table exists:
|
||||
-- SHOW CREATE TABLE education_favorite;
|
||||
-- Verify unique key is enforced:
|
||||
-- SHOW INDEX FROM education_favorite WHERE Key_name = 'uk_tenant_user_target';
|
||||
-- Verify no orphan data (should be 0 after fresh migration):
|
||||
-- SELECT COUNT(*) FROM education_favorite;
|
||||
@@ -0,0 +1,108 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.favorite;
|
||||
|
||||
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.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.favorite.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.favorite.FavoriteService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 收藏夹 Controller — 学生端已认证接口。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Tag(name = "用户 APP - 收藏夹")
|
||||
@RestController
|
||||
@RequestMapping("/education")
|
||||
@Validated
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class FavoriteController {
|
||||
|
||||
@Resource
|
||||
private FavoriteService favoriteService;
|
||||
|
||||
// ========== 收藏操作 ==========
|
||||
|
||||
@PostMapping("/favorite/create")
|
||||
@Operation(summary = "收藏题目(幂等)",
|
||||
description = "收藏指定题目并保存安全快照。重复收藏返回已有记录不报错。"
|
||||
+ "重新收藏已取消的记录将恢复。验证题目存在且可见。")
|
||||
public CommonResult<FavoritePageItemRespVO> create(@Valid @RequestBody FavoriteCreateReqVO reqVO) {
|
||||
return success(favoriteService.create(reqVO, getUserId(), getTenantId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/favorite/delete")
|
||||
@Operation(summary = "取消收藏(幂等)",
|
||||
description = "取消收藏,通过 id 或 (targetType + targetId) 删除。重复取消返回成功。逻辑删除。")
|
||||
public CommonResult<Boolean> delete(@Valid @RequestBody FavoriteDeleteReqVO reqVO) {
|
||||
favoriteService.delete(reqVO, getUserId(), getTenantId());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
// ========== 查询操作 ==========
|
||||
|
||||
@GetMapping("/favorite/page")
|
||||
@Operation(summary = "分页查询收藏列表",
|
||||
description = "按当前用户分页查询收藏记录,支持按目标类型筛选,按收藏时间降序。")
|
||||
public CommonResult<PageResult<FavoritePageItemRespVO>> page(@Valid FavoritePageReqVO reqVO) {
|
||||
return success(favoriteService.page(reqVO, getUserId(), getTenantId()));
|
||||
}
|
||||
|
||||
@PostMapping("/favorite/status")
|
||||
@Operation(summary = "批量查询题目收藏状态",
|
||||
description = "传入题目 ID 列表(最多 100 个),返回已收藏的题目 ID 列表。用于前端图标同步。")
|
||||
public CommonResult<FavoriteStatusRespVO> status(@Valid @RequestBody FavoriteStatusReqVO reqVO) {
|
||||
List<String> ids = reqVO.getQuestionIds().stream()
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
// Validate individual ID length
|
||||
for (String id : ids) {
|
||||
if (id.length() > 128) {
|
||||
throw new ServiceException(BAD_REQUEST.getCode(), "题目 ID 长度不能超过 128 个字符");
|
||||
}
|
||||
}
|
||||
return success(favoriteService.status(ids, getUserId(), getTenantId()));
|
||||
}
|
||||
|
||||
// ========== security helpers ==========
|
||||
|
||||
private Long getUserId() {
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser == null) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
return loginUser.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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
|
||||
|
||||
import cn.iocoder.yudao.module.education.enums.FavoriteTargetType;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 收藏创建请求 VO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Schema(description = "用户 APP - 收藏创建请求")
|
||||
@Data
|
||||
public class FavoriteCreateReqVO {
|
||||
|
||||
@Schema(description = "目标类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "QUESTION")
|
||||
@NotBlank(message = "目标类型不能为空")
|
||||
private String targetType;
|
||||
|
||||
@Schema(description = "目标 ID(题目 ID)", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001")
|
||||
@NotBlank(message = "目标 ID 不能为空")
|
||||
@Size(max = 64, message = "目标 ID 长度不能超过 64 个字符")
|
||||
private String targetId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 收藏删除请求 VO — 支持按 (targetType + targetId) 或按 id 删除。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Schema(description = "用户 APP - 收藏删除请求")
|
||||
@Data
|
||||
public class FavoriteDeleteReqVO {
|
||||
|
||||
@Schema(description = "收藏记录 ID(与 targetType+targetId 二选一)", example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "目标类型", example = "QUESTION")
|
||||
private String targetType;
|
||||
|
||||
@Schema(description = "目标 ID(题目 ID)", example = "q-001")
|
||||
private String targetId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 收藏列表项响应 VO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Schema(description = "收藏列表项")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FavoritePageItemRespVO {
|
||||
|
||||
@Schema(description = "收藏记录 ID", example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "目标类型", example = "QUESTION")
|
||||
private String targetType;
|
||||
|
||||
@Schema(description = "目标 ID(题目 ID)", example = "q-001")
|
||||
private String targetId;
|
||||
|
||||
@Schema(description = "题干快照", example = "1+1=?")
|
||||
private String stem;
|
||||
|
||||
@Schema(description = "题型", example = "choice")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "难度", example = "easy")
|
||||
private String difficulty;
|
||||
|
||||
@Schema(description = "选项列表(不含 isCorrect)")
|
||||
private List<OptionVO> options;
|
||||
|
||||
@Schema(description = "内容版本", example = "v1")
|
||||
private String contentVersion;
|
||||
|
||||
@Schema(description = "源资源是否可用", example = "true")
|
||||
private Boolean available;
|
||||
|
||||
@Schema(description = "收藏时间", example = "2026-07-27T10:00:00")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 安全选项 VO — 不含 isCorrect。
|
||||
*/
|
||||
@Schema(description = "安全选项")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public static class OptionVO {
|
||||
@Schema(description = "选项标签", example = "A")
|
||||
private String label;
|
||||
|
||||
@Schema(description = "选项内容", example = "2")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "排序", example = "1.0")
|
||||
private Double order;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 收藏分页查询请求 VO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Schema(description = "用户 APP - 收藏分页查询请求")
|
||||
@Data
|
||||
public class FavoritePageReqVO {
|
||||
|
||||
@Schema(description = "页码", example = "1")
|
||||
@Min(value = 1, message = "页码最小为 1")
|
||||
@Max(value = 10000, message = "页码最大为 10000")
|
||||
@NotNull(message = "页码不能为空")
|
||||
private Integer pageNo = 1;
|
||||
|
||||
@Schema(description = "每页条数", example = "10")
|
||||
@Min(value = 1, message = "每页条数最小为 1")
|
||||
@Max(value = 100, message = "每页条数最大为 100")
|
||||
@NotNull(message = "每页条数不能为空")
|
||||
private Integer pageSize = 10;
|
||||
|
||||
@Schema(description = "目标类型筛选(留空=全部)", example = "QUESTION")
|
||||
private String targetType;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 收藏状态批量查询请求 VO — 替换原有逗号分隔字符串参数。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Schema(description = "用户 APP - 收藏状态批量查询请求")
|
||||
@Data
|
||||
public class FavoriteStatusReqVO {
|
||||
|
||||
@Schema(description = "题目 ID 列表,最大 100 个", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"q-001\",\"q-002\"]")
|
||||
@NotEmpty(message = "题目 ID 列表不能为空")
|
||||
@Size(max = 100, message = "单次最多查询 100 个题目")
|
||||
private List<String> questionIds;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 收藏状态响应 VO — 批量查询指定题目的收藏状态。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Schema(description = "用户 APP - 收藏状态响应")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FavoriteStatusRespVO {
|
||||
|
||||
@Schema(description = "已收藏的目标 ID 列表", example = "[\"q-001\", \"q-003\"]")
|
||||
private List<String> favoritedIds;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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, target_type, target_id) 唯一一条。
|
||||
* 逻辑删除:取消收藏设置 deleted=1;重新收藏通过 ON DUPLICATE KEY UPDATE 恢复。</p>
|
||||
*
|
||||
* <p>快照字段(stem/type/difficulty/options/contentVersion)来自收藏时
|
||||
* 题目的安全视图,保留题目当时状态。available 标记源资源当前是否可用。</p>
|
||||
*
|
||||
* <p>target_type 当前仅支持 QUESTION,使用枚举类型预留扩展。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_favorite")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class EducationFavoriteDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 学生用户编号 */
|
||||
private Long userId;
|
||||
|
||||
/** 目标类型(QUESTION) */
|
||||
private String targetType;
|
||||
|
||||
/** 目标 ID(题目 ID) */
|
||||
private String targetId;
|
||||
|
||||
/** 题干快照 */
|
||||
private String stem;
|
||||
|
||||
/** 题型快照 */
|
||||
private String type;
|
||||
|
||||
/** 难度快照 */
|
||||
private String difficulty;
|
||||
|
||||
/** 选项快照 JSON(不含 isCorrect) */
|
||||
private String options;
|
||||
|
||||
/** 题目内容版本 */
|
||||
private String contentVersion;
|
||||
|
||||
/** 源资源是否可见 */
|
||||
private Boolean available;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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.EducationFavoriteDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 收藏夹 Mapper。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Mapper
|
||||
public interface EducationFavoriteMapper extends BaseMapperX<EducationFavoriteDO> {
|
||||
|
||||
/**
|
||||
* INSERT ... ON DUPLICATE KEY 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
|
||||
* updating snapshots. The unique key covers all states (deleted or not),
|
||||
* so soft-deleted rows are reactivated on re-favorite.</p>
|
||||
*/
|
||||
@Insert("INSERT INTO education_favorite " +
|
||||
"(tenant_id, user_id, target_type, target_id, " +
|
||||
"stem, type, difficulty, options, content_version, available, " +
|
||||
"creator, create_time, updater, update_time, deleted) " +
|
||||
"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), " +
|
||||
"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)")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int upsert(EducationFavoriteDO record);
|
||||
|
||||
/**
|
||||
* 按租户、用户、目标类型和目标 ID 查找收藏记录(含已删除,用于 reactivation 检查)。
|
||||
*/
|
||||
default EducationFavoriteDO selectByTenantUserTarget(Long tenantId, Long userId,
|
||||
String targetType, String targetId) {
|
||||
return selectOne(new LambdaQueryWrapperX<EducationFavoriteDO>()
|
||||
.eq(EducationFavoriteDO::getTenantId, tenantId)
|
||||
.eq(EducationFavoriteDO::getUserId, userId)
|
||||
.eq(EducationFavoriteDO::getTargetType, targetType)
|
||||
.eq(EducationFavoriteDO::getTargetId, targetId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按租户、用户、目标类型和目标 ID 查找未删除的收藏记录。
|
||||
*/
|
||||
default EducationFavoriteDO selectActiveByTenantUserTarget(Long tenantId, Long userId,
|
||||
String targetType, String targetId) {
|
||||
return selectOne(new LambdaQueryWrapperX<EducationFavoriteDO>()
|
||||
.eq(EducationFavoriteDO::getTenantId, tenantId)
|
||||
.eq(EducationFavoriteDO::getUserId, userId)
|
||||
.eq(EducationFavoriteDO::getTargetType, targetType)
|
||||
.eq(EducationFavoriteDO::getTargetId, targetId)
|
||||
.eq(EducationFavoriteDO::getDeleted, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按租户和用户分页查询收藏列表(仅未删除),按更新时间降序。
|
||||
*
|
||||
* @param targetType 可选目标类型筛选(null = 全部)
|
||||
*/
|
||||
default IPage<EducationFavoriteDO> selectPageByTenantAndUser(IPage<EducationFavoriteDO> page,
|
||||
Long tenantId, Long userId,
|
||||
String targetType) {
|
||||
LambdaQueryWrapperX<EducationFavoriteDO> wrapper = new LambdaQueryWrapperX<EducationFavoriteDO>()
|
||||
.eq(EducationFavoriteDO::getTenantId, tenantId)
|
||||
.eq(EducationFavoriteDO::getUserId, userId)
|
||||
.eq(EducationFavoriteDO::getDeleted, false)
|
||||
.orderByDesc(EducationFavoriteDO::getUpdateTime);
|
||||
if (targetType != null && !targetType.isEmpty()) {
|
||||
wrapper.eq(EducationFavoriteDO::getTargetType, targetType);
|
||||
}
|
||||
return selectPage(page, wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按租户、用户和目标 ID 列表查询未删除的收藏记录(用于状态批量查询)。
|
||||
*/
|
||||
default List<EducationFavoriteDO> selectActiveByTenantUserAndTargetIds(Long tenantId, Long userId,
|
||||
String targetType,
|
||||
Collection<String> targetIds) {
|
||||
return selectList(new LambdaQueryWrapperX<EducationFavoriteDO>()
|
||||
.eq(EducationFavoriteDO::getTenantId, tenantId)
|
||||
.eq(EducationFavoriteDO::getUserId, userId)
|
||||
.eq(EducationFavoriteDO::getTargetType, targetType)
|
||||
.in(EducationFavoriteDO::getTargetId, targetIds)
|
||||
.eq(EducationFavoriteDO::getDeleted, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑删除收藏记录 — 设置 deleted=1。
|
||||
*
|
||||
* @return 受影响行数
|
||||
*/
|
||||
default int softDeleteByTenantUserTarget(Long tenantId, Long userId,
|
||||
String targetType, String targetId) {
|
||||
return update(null,
|
||||
new LambdaUpdateWrapper<EducationFavoriteDO>()
|
||||
.eq(EducationFavoriteDO::getTenantId, tenantId)
|
||||
.eq(EducationFavoriteDO::getUserId, userId)
|
||||
.eq(EducationFavoriteDO::getTargetType, targetType)
|
||||
.eq(EducationFavoriteDO::getTargetId, targetId)
|
||||
.eq(EducationFavoriteDO::getDeleted, false)
|
||||
.set(EducationFavoriteDO::getDeleted, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 逻辑删除收藏记录(带所有权校验)。
|
||||
*
|
||||
* @return 受影响行数
|
||||
*/
|
||||
default int softDeleteByIdAndTenantAndUser(Long id, Long tenantId, Long userId) {
|
||||
return update(null,
|
||||
new LambdaUpdateWrapper<EducationFavoriteDO>()
|
||||
.eq(EducationFavoriteDO::getId, id)
|
||||
.eq(EducationFavoriteDO::getTenantId, tenantId)
|
||||
.eq(EducationFavoriteDO::getUserId, userId)
|
||||
.eq(EducationFavoriteDO::getDeleted, false)
|
||||
.set(EducationFavoriteDO::getDeleted, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 更新 available 标记。
|
||||
*
|
||||
* @param id 记录 ID
|
||||
* @param available 源资源是否可用
|
||||
* @return 受影响行数
|
||||
*/
|
||||
default int updateAvailable(Long id, Boolean available) {
|
||||
return update(null,
|
||||
new LambdaUpdateWrapper<EducationFavoriteDO>()
|
||||
.eq(EducationFavoriteDO::getId, id)
|
||||
.set(EducationFavoriteDO::getAvailable, available));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -82,4 +82,10 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode WRONG_QUESTION_REVIEW_IDS_EMPTY = new ErrorCode(1_005_003_043, "复习题目 ID 列表不能为空");
|
||||
ErrorCode WRONG_QUESTION_REVIEW_NOT_ALL_OWNED = new ErrorCode(1_005_003_044, "部分错题 ID 不属于当前用户或不存在");
|
||||
ErrorCode WRONG_QUESTION_REVIEW_IDS_TOO_MANY = new ErrorCode(1_005_003_045, "单次复习题目数不能超过 {}");
|
||||
|
||||
// ========== 收藏夹 1-005-003-060 ~ 1-005-003-069 ==========
|
||||
ErrorCode FAVORITE_TARGET_NOT_FOUND = new ErrorCode(1_005_003_060, "收藏目标不存在或不可见");
|
||||
ErrorCode FAVORITE_TARGET_TYPE_INVALID = new ErrorCode(1_005_003_061, "不支持的收藏目标类型:{}");
|
||||
ErrorCode FAVORITE_ALREADY_EXISTS = new ErrorCode(1_005_003_062, "已收藏,无需重复操作");
|
||||
ErrorCode FAVORITE_NOT_FOUND = new ErrorCode(1_005_003_063, "收藏记录不存在");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.iocoder.yudao.module.education.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* 收藏目标类型枚举。
|
||||
*
|
||||
* <p>当前仅支持题目(QUESTION),预留扩展接口。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum FavoriteTargetType {
|
||||
|
||||
QUESTION("QUESTION", "题目");
|
||||
|
||||
private final String code;
|
||||
private final String label;
|
||||
|
||||
/**
|
||||
* 根据 code 查找枚举值。
|
||||
*/
|
||||
public static FavoriteTargetType fromCode(String code) {
|
||||
for (FavoriteTargetType type : values()) {
|
||||
if (type.code.equals(code)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cn.iocoder.yudao.module.education.service.favorite;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.controller.app.favorite.vo.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 收藏夹服务接口。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
public interface FavoriteService {
|
||||
|
||||
/**
|
||||
* 收藏目标(幂等)。
|
||||
*
|
||||
* <p>验证目标存在且可见,快照安全字段。重复收藏返回已有记录,不抛异常。
|
||||
* 重新收藏已取消的记录将恢复(设置 deleted=0)。</p>
|
||||
*
|
||||
* @param reqVO 创建请求
|
||||
* @param userId 当前学生用户
|
||||
* @param tenantId 当前租户
|
||||
* @return 收藏记录
|
||||
*/
|
||||
FavoritePageItemRespVO create(FavoriteCreateReqVO reqVO, Long userId, Long tenantId);
|
||||
|
||||
/**
|
||||
* 取消收藏(幂等)。
|
||||
*
|
||||
* <p>通过 id 或 (targetType + targetId) 定位记录。重复取消返回成功。
|
||||
* 逻辑删除,设置 deleted=1。</p>
|
||||
*
|
||||
* @param reqVO 删除请求
|
||||
* @param userId 当前学生用户
|
||||
* @param tenantId 当前租户
|
||||
*/
|
||||
void delete(FavoriteDeleteReqVO reqVO, Long userId, Long tenantId);
|
||||
|
||||
/**
|
||||
* 分页查询当前用户的收藏列表(仅未删除)。
|
||||
*
|
||||
* @param reqVO 分页请求
|
||||
* @param userId 当前学生用户
|
||||
* @param tenantId 当前租户
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<FavoritePageItemRespVO> page(FavoritePageReqVO reqVO, Long userId, Long tenantId);
|
||||
|
||||
/**
|
||||
* 批量查询题目收藏状态。
|
||||
*
|
||||
* @param questionIds 题目 ID 列表
|
||||
* @param userId 当前学生用户
|
||||
* @param tenantId 当前租户
|
||||
* @return 状态响应(含已收藏的题目 ID 列表)
|
||||
*/
|
||||
FavoriteStatusRespVO status(List<String> questionIds, Long userId, Long tenantId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
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.common.util.json.JsonUtils;
|
||||
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;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.EducationFavoriteMapper;
|
||||
import cn.iocoder.yudao.module.education.enums.FavoriteTargetType;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
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.*;
|
||||
|
||||
/**
|
||||
* 收藏夹服务实现。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class FavoriteServiceImpl implements FavoriteService {
|
||||
|
||||
private final EducationFavoriteMapper favoriteMapper;
|
||||
private final QuestionCatalogService questionCatalogService;
|
||||
|
||||
public FavoriteServiceImpl(EducationFavoriteMapper favoriteMapper,
|
||||
QuestionCatalogService questionCatalogService) {
|
||||
this.favoriteMapper = favoriteMapper;
|
||||
this.questionCatalogService = questionCatalogService;
|
||||
}
|
||||
|
||||
// ========== Create ==========
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public FavoritePageItemRespVO create(FavoriteCreateReqVO reqVO, Long userId, Long tenantId) {
|
||||
// 1. Validate target type
|
||||
FavoriteTargetType targetType = FavoriteTargetType.fromCode(reqVO.getTargetType());
|
||||
if (targetType == null) {
|
||||
throw exception(FAVORITE_TARGET_TYPE_INVALID, reqVO.getTargetType());
|
||||
}
|
||||
|
||||
String targetId = reqVO.getTargetId();
|
||||
|
||||
// 2. Check if already favorited (active)
|
||||
EducationFavoriteDO existing = favoriteMapper.selectActiveByTenantUserTarget(
|
||||
tenantId, userId, targetType.getCode(), targetId);
|
||||
if (existing != null) {
|
||||
// Validate that the source is still available before returning the existing record.
|
||||
// If it became unavailable, reject the duplicate add.
|
||||
// The availability flag will be updated on next page() refresh.
|
||||
try {
|
||||
questionCatalogService.getQuestion(targetId);
|
||||
// Source is still available — ensure the record reflects that
|
||||
if (!Boolean.TRUE.equals(existing.getAvailable())) {
|
||||
favoriteMapper.updateAvailable(existing.getId(), true);
|
||||
existing.setAvailable(true);
|
||||
}
|
||||
return toPageItem(existing);
|
||||
} catch (ServiceException e) {
|
||||
if (isQuestionGone(e)) {
|
||||
// Source became unavailable — reject the add.
|
||||
// The availability flag will be updated on next page() refresh.
|
||||
throw exception(FAVORITE_TARGET_NOT_FOUND);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate target exists and is visible, snapshot safe fields
|
||||
SafeQuestionRespVO question;
|
||||
try {
|
||||
question = questionCatalogService.getQuestion(targetId);
|
||||
} catch (ServiceException e) {
|
||||
if (isQuestionGone(e)) {
|
||||
throw exception(FAVORITE_TARGET_NOT_FOUND);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// If we reach here, the question exists and is visible
|
||||
|
||||
// 4. Upsert — handles reactivation of soft-deleted rows
|
||||
EducationFavoriteDO fav = new EducationFavoriteDO();
|
||||
fav.setTenantId(tenantId);
|
||||
fav.setUserId(userId);
|
||||
fav.setTargetType(targetType.getCode());
|
||||
fav.setTargetId(targetId);
|
||||
fav.setStem(question.getStem());
|
||||
fav.setType(question.getType());
|
||||
fav.setDifficulty(question.getDifficulty());
|
||||
fav.setOptions(toOptionsJson(question.getOptions()));
|
||||
fav.setContentVersion(question.getContentVersion() != null ? question.getContentVersion() : "");
|
||||
fav.setAvailable(true);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
fav.setCreator(String.valueOf(userId));
|
||||
fav.setCreateTime(now);
|
||||
fav.setUpdater(String.valueOf(userId));
|
||||
fav.setUpdateTime(now);
|
||||
|
||||
favoriteMapper.upsert(fav);
|
||||
|
||||
// 5. Re-read to get the resolved ID
|
||||
EducationFavoriteDO saved = favoriteMapper.selectActiveByTenantUserTarget(
|
||||
tenantId, userId, targetType.getCode(), targetId);
|
||||
return toPageItem(saved);
|
||||
}
|
||||
|
||||
// ========== Delete ==========
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(FavoriteDeleteReqVO reqVO, Long userId, Long tenantId) {
|
||||
int affected;
|
||||
if (reqVO.getId() != null) {
|
||||
// Delete by ID with ownership check
|
||||
affected = favoriteMapper.softDeleteByIdAndTenantAndUser(reqVO.getId(), tenantId, userId);
|
||||
} else if (reqVO.getTargetType() != null && reqVO.getTargetId() != null) {
|
||||
// Delete by (targetType, targetId)
|
||||
FavoriteTargetType targetType = FavoriteTargetType.fromCode(reqVO.getTargetType());
|
||||
if (targetType == null) {
|
||||
throw exception(FAVORITE_TARGET_TYPE_INVALID, reqVO.getTargetType());
|
||||
}
|
||||
affected = favoriteMapper.softDeleteByTenantUserTarget(
|
||||
tenantId, userId, targetType.getCode(), reqVO.getTargetId());
|
||||
} else {
|
||||
// Neither id nor (targetType + targetId) provided
|
||||
throw exception(FAVORITE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Idempotent: if affected == 0, the record was already deleted or doesn't exist
|
||||
// This is not an error — repeat delete returns stable success
|
||||
}
|
||||
|
||||
// ========== Page ==========
|
||||
|
||||
@Override
|
||||
public PageResult<FavoritePageItemRespVO> page(FavoritePageReqVO reqVO, Long userId, Long tenantId) {
|
||||
// 1. Validate targetType if provided
|
||||
if (reqVO.getTargetType() != null && !reqVO.getTargetType().isBlank()) {
|
||||
if (FavoriteTargetType.fromCode(reqVO.getTargetType()) == null) {
|
||||
throw exception(FAVORITE_TARGET_TYPE_INVALID, reqVO.getTargetType());
|
||||
}
|
||||
}
|
||||
|
||||
IPage<EducationFavoriteDO> page = favoriteMapper.selectPageByTenantAndUser(
|
||||
new Page<>(reqVO.getPageNo(), reqVO.getPageSize()),
|
||||
tenantId, userId, reqVO.getTargetType());
|
||||
|
||||
// 2. Refresh availability for the returned favorites
|
||||
refreshAvailability(page.getRecords());
|
||||
|
||||
List<FavoritePageItemRespVO> list = page.getRecords().stream()
|
||||
.map(this::toPageItem)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new PageResult<>(list, page.getTotal());
|
||||
}
|
||||
|
||||
// ========== Status ==========
|
||||
|
||||
@Override
|
||||
public FavoriteStatusRespVO status(List<String> questionIds, Long userId, Long tenantId) {
|
||||
if (questionIds == null || questionIds.isEmpty()) {
|
||||
return FavoriteStatusRespVO.builder()
|
||||
.favoritedIds(Collections.emptyList())
|
||||
.build();
|
||||
}
|
||||
|
||||
List<EducationFavoriteDO> favorites = favoriteMapper.selectActiveByTenantUserAndTargetIds(
|
||||
tenantId, userId, FavoriteTargetType.QUESTION.getCode(), questionIds);
|
||||
|
||||
List<String> favoritedIds = favorites.stream()
|
||||
.map(EducationFavoriteDO::getTargetId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return FavoriteStatusRespVO.builder()
|
||||
.favoritedIds(favoritedIds)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ========== Internal helpers ==========
|
||||
|
||||
private FavoritePageItemRespVO toPageItem(EducationFavoriteDO fav) {
|
||||
return FavoritePageItemRespVO.builder()
|
||||
.id(fav.getId())
|
||||
.targetType(fav.getTargetType())
|
||||
.targetId(fav.getTargetId())
|
||||
.stem(fav.getStem())
|
||||
.type(fav.getType())
|
||||
.difficulty(fav.getDifficulty())
|
||||
.options(parseOptions(fav.getOptions()))
|
||||
.contentVersion(fav.getContentVersion())
|
||||
.available(fav.getAvailable())
|
||||
.createTime(fav.getCreateTime())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String toOptionsJson(List<SafeQuestionRespVO.SafeOptionVO> options) {
|
||||
if (options == null || options.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return JsonUtils.toJsonString(options);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private List<FavoritePageItemRespVO.OptionVO> parseOptions(String optionsJson) {
|
||||
if (optionsJson == null || optionsJson.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
List<Map<String, Object>> raw = (List) JsonUtils.parseArray(optionsJson, Map.class);
|
||||
if (raw == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return raw.stream()
|
||||
.map(m -> FavoritePageItemRespVO.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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 ServiceException 是否表示题目已不可用(不存在或隐藏)。
|
||||
* 仅捕获 QUESTION_NOT_FOUND / QUESTION_NOT_VISIBLE;其他上游异常不在此处静默。
|
||||
*/
|
||||
private boolean isQuestionGone(ServiceException e) {
|
||||
return QUESTION_NOT_FOUND.getCode().equals(e.getCode())
|
||||
|| QUESTION_NOT_VISIBLE.getCode().equals(e.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新收藏列表中每道题的可用性状态。
|
||||
*
|
||||
* <p>对返回的收藏记录按 targetId 去重后,逐个调用
|
||||
* {@link QuestionCatalogService#getQuestion(String)} 判断源资源是否仍可见。
|
||||
* 如果题目不存在或不可见,将 available 标记为 false 并持久化;
|
||||
* 如果题目恢复可见,将 available 标记为 true 并持久化。
|
||||
* 其他上游异常(网络错误、超时等)直接传播,不静默标记为不可用。</p>
|
||||
*/
|
||||
private void refreshAvailability(List<EducationFavoriteDO> favorites) {
|
||||
if (favorites == null || favorites.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate: avoid duplicate getQuestion calls within one request
|
||||
Set<String> targetIds = favorites.stream()
|
||||
.map(EducationFavoriteDO::getTargetId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
for (String targetId : targetIds) {
|
||||
boolean currentlyAvailable;
|
||||
try {
|
||||
questionCatalogService.getQuestion(targetId);
|
||||
currentlyAvailable = true;
|
||||
} catch (ServiceException e) {
|
||||
if (isQuestionGone(e)) {
|
||||
currentlyAvailable = false;
|
||||
} else {
|
||||
throw e; // Other upstream failures propagate
|
||||
}
|
||||
}
|
||||
|
||||
// Update DB if availability changed for any record with this targetId
|
||||
final boolean finalAvailable = currentlyAvailable;
|
||||
favorites.stream()
|
||||
.filter(f -> targetId.equals(f.getTargetId())
|
||||
&& !Objects.equals(finalAvailable, f.getAvailable()))
|
||||
.forEach(f -> {
|
||||
favoriteMapper.updateAvailable(f.getId(), finalAvailable);
|
||||
f.setAvailable(finalAvailable);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.favorite;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
|
||||
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.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
|
||||
import cn.iocoder.yudao.module.education.controller.app.favorite.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.favorite.FavoriteService;
|
||||
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.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/**
|
||||
* FavoriteController HTTP seam test — uses standalone MockMvc with real controller
|
||||
* wiring and the production {@link GlobalExceptionHandler} to prove route mapping,
|
||||
* authentication, query param forwarding, answer-field absence in JSON,
|
||||
* and error handling through the HTTP layer.
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
class FavoriteControllerHttpTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private FavoriteService favoriteService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
favoriteService = mock(FavoriteService.class);
|
||||
FavoriteController controller = new FavoriteController();
|
||||
try {
|
||||
var field = FavoriteController.class.getDeclaredField("favoriteService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, favoriteService);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
// Use the real GlobalExceptionHandler to match production behavior
|
||||
ApiErrorLogCommonApi apiErrorLogApi = mock(ApiErrorLogCommonApi.class);
|
||||
GlobalExceptionHandler realHandler = new GlobalExceptionHandler("test", apiErrorLogApi);
|
||||
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setControllerAdvice(realHandler)
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ========== POST /education/favorite/create ==========
|
||||
|
||||
@Test
|
||||
void shouldCreateFavorite() throws Exception {
|
||||
setLoginUser(100L);
|
||||
FavoritePageItemRespVO expected = FavoritePageItemRespVO.builder()
|
||||
.id(1L).targetType("QUESTION").targetId("q-001")
|
||||
.stem("Test question?").type("choice").difficulty("easy")
|
||||
.options(List.of(FavoritePageItemRespVO.OptionVO.builder()
|
||||
.label("A").content("Option A").order(1.0).build()))
|
||||
.contentVersion("v1").available(true)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
when(favoriteService.create(any(), eq(100L), eq(1L))).thenReturn(expected);
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/education/favorite/create")
|
||||
.contentType("application/json")
|
||||
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-001\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.id").value(1))
|
||||
.andExpect(jsonPath("$.data.targetType").value("QUESTION"))
|
||||
.andExpect(jsonPath("$.data.targetId").value("q-001"))
|
||||
.andReturn();
|
||||
|
||||
String json = result.getResponse().getContentAsString();
|
||||
assertFalse(json.contains("correctAnswer"), "HTTP response must not contain correctAnswer");
|
||||
assertFalse(json.contains("\"answer\""), "HTTP response must not contain answer");
|
||||
assertFalse(json.contains("explanation"), "HTTP response must not contain explanation");
|
||||
assertFalse(json.contains("isCorrect"), "HTTP response must not contain isCorrect");
|
||||
assertTrue(json.contains("\"stem\""), "HTTP response should contain stem");
|
||||
assertTrue(json.contains("\"options\""), "HTTP response should contain options");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnUnauthorizedCodeForFavoriteCreateWithoutAuth() throws Exception {
|
||||
// Production: GlobalExceptionHandler returns HTTP 200 with error code in body
|
||||
mockMvc.perform(post("/education/favorite/create")
|
||||
.contentType("application/json")
|
||||
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-001\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectFavoriteForInvisibleQuestion() throws Exception {
|
||||
setLoginUser(100L);
|
||||
when(favoriteService.create(any(), eq(100L), eq(1L)))
|
||||
.thenThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()));
|
||||
|
||||
mockMvc.perform(post("/education/favorite/create")
|
||||
.contentType("application/json")
|
||||
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-hidden\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(QUESTION_NOT_FOUND.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectFavoriteWithInvalidTargetType() throws Exception {
|
||||
setLoginUser(100L);
|
||||
when(favoriteService.create(any(), eq(100L), eq(1L)))
|
||||
.thenThrow(new ServiceException(FAVORITE_TARGET_TYPE_INVALID.getCode(),
|
||||
FAVORITE_TARGET_TYPE_INVALID.getMsg()));
|
||||
|
||||
mockMvc.perform(post("/education/favorite/create")
|
||||
.contentType("application/json")
|
||||
.content("{\"targetType\":\"INVALID\",\"targetId\":\"q-001\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(FAVORITE_TARGET_TYPE_INVALID.getCode()));
|
||||
}
|
||||
|
||||
// ========== DELETE /education/favorite/delete ==========
|
||||
|
||||
@Test
|
||||
void shouldDeleteFavoriteById() throws Exception {
|
||||
setLoginUser(100L);
|
||||
doNothing().when(favoriteService).delete(any(), eq(100L), eq(1L));
|
||||
|
||||
mockMvc.perform(delete("/education/favorite/delete")
|
||||
.contentType("application/json")
|
||||
.content("{\"id\":1}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteFavoriteByTargetTypeAndTargetId() throws Exception {
|
||||
setLoginUser(100L);
|
||||
doNothing().when(favoriteService).delete(any(), eq(100L), eq(1L));
|
||||
|
||||
mockMvc.perform(delete("/education/favorite/delete")
|
||||
.contentType("application/json")
|
||||
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-001\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnUnauthorizedCodeForFavoriteDeleteWithoutAuth() throws Exception {
|
||||
mockMvc.perform(delete("/education/favorite/delete")
|
||||
.contentType("application/json")
|
||||
.content("{\"id\":1}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnSuccessForRepeatDelete() throws Exception {
|
||||
setLoginUser(100L);
|
||||
doNothing().when(favoriteService).delete(any(), eq(100L), eq(1L));
|
||||
|
||||
mockMvc.perform(delete("/education/favorite/delete")
|
||||
.contentType("application/json")
|
||||
.content("{\"id\":1}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
|
||||
// ========== GET /education/favorite/page ==========
|
||||
|
||||
@Test
|
||||
void shouldPageFavorites() throws Exception {
|
||||
setLoginUser(100L);
|
||||
PageResult<FavoritePageItemRespVO> pageResult = new PageResult<>(
|
||||
List.of(FavoritePageItemRespVO.builder()
|
||||
.id(1L).targetType("QUESTION").targetId("q-001")
|
||||
.stem("Q1").type("choice").difficulty("easy")
|
||||
.contentVersion("v1").available(true)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build()),
|
||||
1L
|
||||
);
|
||||
when(favoriteService.page(any(), eq(100L), eq(1L))).thenReturn(pageResult);
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/education/favorite/page")
|
||||
.param("pageNo", "1")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].targetId").value("q-001"))
|
||||
.andReturn();
|
||||
|
||||
String json = result.getResponse().getContentAsString();
|
||||
assertFalse(json.contains("correctAnswer"), "page response must not contain correctAnswer");
|
||||
assertFalse(json.contains("explanation"), "page response must not contain explanation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldForwardTargetTypeFilter() throws Exception {
|
||||
setLoginUser(100L);
|
||||
when(favoriteService.page(any(), eq(100L), eq(1L))).thenReturn(
|
||||
new PageResult<>(Collections.emptyList(), 0L));
|
||||
|
||||
mockMvc.perform(get("/education/favorite/page")
|
||||
.param("pageNo", "1")
|
||||
.param("pageSize", "20")
|
||||
.param("targetType", "QUESTION"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
verify(favoriteService).page(argThat(req ->
|
||||
"QUESTION".equals(req.getTargetType())), eq(100L), eq(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnUnauthorizedCodeForFavoritePageWithoutAuth() throws Exception {
|
||||
mockMvc.perform(get("/education/favorite/page")
|
||||
.param("pageNo", "1")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
// ========== Finding 4: Page defensive bounds ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectPageNoBelowMinimum() throws Exception {
|
||||
setLoginUser(100L);
|
||||
|
||||
mockMvc.perform(get("/education/favorite/page")
|
||||
.param("pageNo", "0")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("页码最小为 1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPageNoAboveMaximum() throws Exception {
|
||||
setLoginUser(100L);
|
||||
|
||||
mockMvc.perform(get("/education/favorite/page")
|
||||
.param("pageNo", "10001")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("页码最大为 10000")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPageSizeAboveMaximum() throws Exception {
|
||||
setLoginUser(100L);
|
||||
|
||||
mockMvc.perform(get("/education/favorite/page")
|
||||
.param("pageNo", "1")
|
||||
.param("pageSize", "101"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("每页条数最大为 100")));
|
||||
}
|
||||
|
||||
// ========== Finding 5: Page invalid targetType ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnTargetTypeInvalidForPageWithUnsupportedTargetType() throws Exception {
|
||||
setLoginUser(100L);
|
||||
when(favoriteService.page(any(), eq(100L), eq(1L)))
|
||||
.thenThrow(new ServiceException(FAVORITE_TARGET_TYPE_INVALID.getCode(), "不支持的收藏目标类型:INVALID"));
|
||||
|
||||
mockMvc.perform(get("/education/favorite/page")
|
||||
.param("pageNo", "1")
|
||||
.param("pageSize", "10")
|
||||
.param("targetType", "INVALID"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(FAVORITE_TARGET_TYPE_INVALID.getCode()));
|
||||
}
|
||||
|
||||
// ========== POST /education/favorite/status ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnFavoriteStatus() throws Exception {
|
||||
setLoginUser(100L);
|
||||
FavoriteStatusRespVO status = FavoriteStatusRespVO.builder()
|
||||
.favoritedIds(List.of("q-001", "q-003"))
|
||||
.build();
|
||||
when(favoriteService.status(anyList(), eq(100L), eq(1L))).thenReturn(status);
|
||||
|
||||
mockMvc.perform(post("/education/favorite/status")
|
||||
.contentType("application/json")
|
||||
.content("{\"questionIds\":[\"q-001\",\"q-002\",\"q-003\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.favoritedIds[0]").value("q-001"))
|
||||
.andExpect(jsonPath("$.data.favoritedIds[1]").value("q-003"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyStatusForEmptyList() throws Exception {
|
||||
setLoginUser(100L);
|
||||
// @NotEmpty validation on questionIds will reject empty list at the controller level
|
||||
// with BAD_REQUEST, since @Valid @RequestBody is enforced
|
||||
mockMvc.perform(post("/education/favorite/status")
|
||||
.contentType("application/json")
|
||||
.content("{\"questionIds\":[]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnUnauthorizedCodeForFavoriteStatusWithoutAuth() throws Exception {
|
||||
mockMvc.perform(post("/education/favorite/status")
|
||||
.contentType("application/json")
|
||||
.content("{\"questionIds\":[\"q-001\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
// ========== Finding 3: Batch status bounds ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectStatusWithMoreThan100Ids() throws Exception {
|
||||
setLoginUser(100L);
|
||||
|
||||
// Build 101 IDs
|
||||
StringBuilder ids = new StringBuilder("[");
|
||||
for (int i = 0; i < 101; i++) {
|
||||
if (i > 0) ids.append(",");
|
||||
ids.append("\"q-").append(String.format("%03d", i)).append("\"");
|
||||
}
|
||||
ids.append("]");
|
||||
|
||||
mockMvc.perform(post("/education/favorite/status")
|
||||
.contentType("application/json")
|
||||
.content("{\"questionIds\":" + ids + "}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("最多查询 100 个题目")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectStatusWithOversizedId() throws Exception {
|
||||
setLoginUser(100L);
|
||||
|
||||
String longId = "q-" + "x".repeat(130);
|
||||
|
||||
mockMvc.perform(post("/education/favorite/status")
|
||||
.contentType("application/json")
|
||||
.content("{\"questionIds\":[\"" + longId + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("长度不能超过 128")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectStatusWithBlankId() throws Exception {
|
||||
setLoginUser(100L);
|
||||
|
||||
mockMvc.perform(post("/education/favorite/status")
|
||||
.contentType("application/json")
|
||||
.content("{\"questionIds\":[\"q-001\",\" \",\"q-002\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
// Blank IDs are filtered (trim → empty → filtered), and remaining valid IDs are processed
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeduplicateStatusIds() throws Exception {
|
||||
setLoginUser(100L);
|
||||
FavoriteStatusRespVO status = FavoriteStatusRespVO.builder()
|
||||
.favoritedIds(List.of("q-001"))
|
||||
.build();
|
||||
when(favoriteService.status(anyList(), eq(100L), eq(1L))).thenReturn(status);
|
||||
|
||||
mockMvc.perform(post("/education/favorite/status")
|
||||
.contentType("application/json")
|
||||
.content("{\"questionIds\":[\"q-001\",\"q-001\",\"q-001\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
// Verify service received deduplicated list (only one "q-001")
|
||||
verify(favoriteService).status(argThat(list -> list.size() == 1 && list.contains("q-001")),
|
||||
eq(100L), eq(1L));
|
||||
}
|
||||
|
||||
// ========== Answer field negative assertions ==========
|
||||
|
||||
@Test
|
||||
void shouldNeverLeakAnswerInAnyFavoriteEndpoint() throws Exception {
|
||||
setLoginUser(100L);
|
||||
|
||||
FavoritePageItemRespVO item = FavoritePageItemRespVO.builder()
|
||||
.id(1L).targetType("QUESTION").targetId("q-001")
|
||||
.stem("Full question").type("choice").difficulty("easy")
|
||||
.options(List.of(
|
||||
FavoritePageItemRespVO.OptionVO.builder()
|
||||
.label("A").content("Right").order(1.0).build(),
|
||||
FavoritePageItemRespVO.OptionVO.builder()
|
||||
.label("B").content("Wrong").order(2.0).build()
|
||||
))
|
||||
.contentVersion("v1").available(true)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
when(favoriteService.create(any(), eq(100L), eq(1L))).thenReturn(item);
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/education/favorite/create")
|
||||
.contentType("application/json")
|
||||
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-001\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
String json = result.getResponse().getContentAsString();
|
||||
assertFalse(json.contains("correctAnswer"), "create: must not leak correctAnswer");
|
||||
assertFalse(json.contains("\"answer\""), "create: must not leak answer");
|
||||
assertFalse(json.contains("explanation"), "create: must not leak explanation");
|
||||
assertFalse(json.contains("analysis"), "create: must not leak analysis");
|
||||
assertFalse(json.contains("isCorrect"), "create: must not leak isCorrect");
|
||||
assertTrue(json.contains("\"stem\":\"Full question\""), "create: should contain stem");
|
||||
assertTrue(json.contains("\"label\":\"A\""), "create: should contain option label");
|
||||
}
|
||||
|
||||
// ========== helpers ==========
|
||||
|
||||
private void setLoginUser(Long userId) {
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setId(userId);
|
||||
loginUser.setTenantId(1L);
|
||||
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
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.controller.app.favorite.vo.*;
|
||||
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.EducationFavoriteDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.EducationFavoriteMapper;
|
||||
import cn.iocoder.yudao.module.education.enums.FavoriteTargetType;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* FavoriteService test — real DB (H2) backing all favorite assertions.
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Import(FavoriteServiceImpl.class)
|
||||
public class FavoriteServiceImplTest extends BaseDbUnitTest {
|
||||
|
||||
@Resource
|
||||
private FavoriteService favoriteService;
|
||||
|
||||
@Resource
|
||||
private EducationFavoriteMapper favoriteMapper;
|
||||
|
||||
@MockitoBean
|
||||
private QuestionCatalogService questionCatalogService;
|
||||
|
||||
private final Long userId = 100L;
|
||||
private final Long tenantId = 1L;
|
||||
private final Long otherUserId = 999L;
|
||||
private final Long otherTenantId = 999L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Default mock: question exists and is visible
|
||||
SafeQuestionRespVO defaultQuestion = SafeQuestionRespVO.builder()
|
||||
.id("q-001")
|
||||
.contentVersion("v1")
|
||||
.stem("Test question?")
|
||||
.type("choice")
|
||||
.difficulty("easy")
|
||||
.options(List.of(
|
||||
SafeQuestionRespVO.SafeOptionVO.builder()
|
||||
.label("A").content("Option A").order(1.0).build(),
|
||||
SafeQuestionRespVO.SafeOptionVO.builder()
|
||||
.label("B").content("Option B").order(2.0).build()
|
||||
))
|
||||
.build();
|
||||
lenient().when(questionCatalogService.getQuestion("q-001")).thenReturn(defaultQuestion);
|
||||
lenient().when(questionCatalogService.getQuestion("q-002")).thenReturn(
|
||||
SafeQuestionRespVO.builder()
|
||||
.id("q-002")
|
||||
.contentVersion("v2")
|
||||
.stem("Another question?")
|
||||
.type("fill")
|
||||
.difficulty("hard")
|
||||
.build()
|
||||
);
|
||||
lenient().when(questionCatalogService.getQuestion("q-hidden"))
|
||||
.thenThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()));
|
||||
}
|
||||
|
||||
// ========== helpers ==========
|
||||
|
||||
private void assertFavoriteActive(Long tenantId, Long userId, String targetId) {
|
||||
EducationFavoriteDO active = favoriteMapper.selectActiveByTenantUserTarget(
|
||||
tenantId, userId, FavoriteTargetType.QUESTION.getCode(), targetId);
|
||||
assertNotNull(active, "favorite should be active");
|
||||
assertFalse(active.getDeleted());
|
||||
}
|
||||
|
||||
private void assertFavoriteNotActive(Long tenantId, Long userId, String targetId) {
|
||||
EducationFavoriteDO active = favoriteMapper.selectActiveByTenantUserTarget(
|
||||
tenantId, userId, FavoriteTargetType.QUESTION.getCode(), targetId);
|
||||
assertNull(active, "favorite should not be active");
|
||||
}
|
||||
|
||||
// ========== Create: basic ==========
|
||||
|
||||
@Test
|
||||
void shouldCreateFavorite() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
|
||||
FavoritePageItemRespVO resp = favoriteService.create(req, userId, tenantId);
|
||||
|
||||
assertNotNull(resp.getId());
|
||||
assertEquals("QUESTION", resp.getTargetType());
|
||||
assertEquals("q-001", resp.getTargetId());
|
||||
assertEquals("Test question?", resp.getStem());
|
||||
assertEquals("choice", resp.getType());
|
||||
assertEquals("easy", resp.getDifficulty());
|
||||
assertEquals(Boolean.TRUE, resp.getAvailable());
|
||||
assertNotNull(resp.getCreateTime());
|
||||
assertFalse(resp.getOptions().isEmpty());
|
||||
assertEquals("A", resp.getOptions().get(0).getLabel());
|
||||
|
||||
// DB verification
|
||||
assertFavoriteActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Create: duplicate idempotent ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnExistingOnDuplicateAdd() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
|
||||
FavoritePageItemRespVO first = favoriteService.create(req, userId, tenantId);
|
||||
FavoritePageItemRespVO second = favoriteService.create(req, userId, tenantId);
|
||||
|
||||
assertEquals(first.getId(), second.getId(), "duplicate create must return same record");
|
||||
assertEquals(first.getStem(), second.getStem());
|
||||
|
||||
// Only one active row
|
||||
assertFavoriteActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Create: concurrent duplicate ==========
|
||||
|
||||
@Test
|
||||
void shouldConvergeOnConcurrentDuplicateCreate() throws Exception {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
AtomicReference<FavoritePageItemRespVO> r1 = new AtomicReference<>();
|
||||
AtomicReference<FavoritePageItemRespVO> r2 = new AtomicReference<>();
|
||||
|
||||
Thread t1 = new Thread(() -> {
|
||||
try { ready.countDown(); go.await();
|
||||
r1.set(favoriteService.create(req, userId, tenantId)); }
|
||||
catch (Exception ignored) {}
|
||||
});
|
||||
Thread t2 = new Thread(() -> {
|
||||
try { ready.countDown(); go.await();
|
||||
r2.set(favoriteService.create(req, userId, tenantId)); }
|
||||
catch (Exception ignored) {}
|
||||
});
|
||||
|
||||
t1.start(); t2.start();
|
||||
ready.await(); go.countDown();
|
||||
t1.join(10000); t2.join(10000);
|
||||
|
||||
assertNotNull(r1.get(), "thread 1 must get a response");
|
||||
assertNotNull(r2.get(), "thread 2 must get a response");
|
||||
assertEquals(r1.get().getId(), r2.get().getId(), "concurrent creates must converge");
|
||||
|
||||
// Only one active row
|
||||
assertFavoriteActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Create: invisible target rejected ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectInvisibleTarget() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-hidden");
|
||||
assertServiceException(
|
||||
() -> favoriteService.create(req, userId, tenantId),
|
||||
FAVORITE_TARGET_NOT_FOUND);
|
||||
// No record created
|
||||
assertFavoriteNotActive(tenantId, userId, "q-hidden");
|
||||
}
|
||||
|
||||
// ========== Create: invalid target type ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidTargetType() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("INVALID");
|
||||
req.setTargetId("q-001");
|
||||
|
||||
assertServiceException(
|
||||
() -> favoriteService.create(req, userId, tenantId),
|
||||
FAVORITE_TARGET_TYPE_INVALID, "INVALID");
|
||||
}
|
||||
|
||||
// ========== Delete: basic ==========
|
||||
|
||||
@Test
|
||||
void shouldDeleteFavorite() {
|
||||
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
|
||||
createReq.setTargetType("QUESTION");
|
||||
createReq.setTargetId("q-001");
|
||||
FavoritePageItemRespVO created = favoriteService.create(createReq, userId, tenantId);
|
||||
|
||||
// Delete by id
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setId(created.getId());
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
|
||||
// Verify not active
|
||||
assertFavoriteNotActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Delete: by targetType+targetId ==========
|
||||
|
||||
@Test
|
||||
void shouldDeleteByTargetTypeAndTargetId() {
|
||||
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
|
||||
createReq.setTargetType("QUESTION");
|
||||
createReq.setTargetId("q-001");
|
||||
favoriteService.create(createReq, userId, tenantId);
|
||||
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setTargetType("QUESTION");
|
||||
deleteReq.setTargetId("q-001");
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
|
||||
assertFavoriteNotActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Delete: repeat delete idempotent ==========
|
||||
|
||||
@Test
|
||||
void shouldRepeatDeleteIdempotently() {
|
||||
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
|
||||
createReq.setTargetType("QUESTION");
|
||||
createReq.setTargetId("q-001");
|
||||
FavoritePageItemRespVO created = favoriteService.create(createReq, userId, tenantId);
|
||||
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setId(created.getId());
|
||||
|
||||
// First delete
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
assertFavoriteNotActive(tenantId, userId, "q-001");
|
||||
|
||||
// Second delete — should not throw
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
assertFavoriteNotActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Delete: repeat delete by targetType+targetId ==========
|
||||
|
||||
@Test
|
||||
void shouldRepeatDeleteByTargetTypeAndTargetIdIdempotently() {
|
||||
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
|
||||
createReq.setTargetType("QUESTION");
|
||||
createReq.setTargetId("q-001");
|
||||
favoriteService.create(createReq, userId, tenantId);
|
||||
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setTargetType("QUESTION");
|
||||
deleteReq.setTargetId("q-001");
|
||||
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
favoriteService.delete(deleteReq, userId, tenantId); // no error
|
||||
|
||||
assertFavoriteNotActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Delete: cross-user isolation ==========
|
||||
|
||||
@Test
|
||||
void shouldNotDeleteOtherUserFavorite() {
|
||||
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
|
||||
createReq.setTargetType("QUESTION");
|
||||
createReq.setTargetId("q-001");
|
||||
FavoritePageItemRespVO created = favoriteService.create(createReq, userId, tenantId);
|
||||
|
||||
// Other user tries to delete
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setId(created.getId());
|
||||
favoriteService.delete(deleteReq, otherUserId, tenantId); // no error, no effect
|
||||
|
||||
// Original user's favorite still active
|
||||
assertFavoriteActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Delete: cross-tenant isolation ==========
|
||||
|
||||
@Test
|
||||
void shouldNotDeleteOtherTenantFavorite() {
|
||||
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
|
||||
createReq.setTargetType("QUESTION");
|
||||
createReq.setTargetId("q-001");
|
||||
FavoritePageItemRespVO created = favoriteService.create(createReq, userId, tenantId);
|
||||
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setId(created.getId());
|
||||
favoriteService.delete(deleteReq, userId, otherTenantId); // no error, no effect
|
||||
|
||||
assertFavoriteActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Re-add after delete ==========
|
||||
|
||||
@Test
|
||||
void shouldReAddAfterDelete() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
|
||||
// Create
|
||||
FavoritePageItemRespVO first = favoriteService.create(req, userId, tenantId);
|
||||
|
||||
// Delete
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setId(first.getId());
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
assertFavoriteNotActive(tenantId, userId, "q-001");
|
||||
|
||||
// Re-add
|
||||
FavoritePageItemRespVO reAdded = favoriteService.create(req, userId, tenantId);
|
||||
|
||||
assertNotNull(reAdded);
|
||||
assertEquals(first.getId(), reAdded.getId(), "re-add must reactivate same row");
|
||||
assertEquals(Boolean.TRUE, reAdded.getAvailable());
|
||||
|
||||
// Verify active in DB
|
||||
assertFavoriteActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
// ========== Page: bounds ==========
|
||||
|
||||
@Test
|
||||
void shouldPaginateFavoritesByTenantAndUser() {
|
||||
// Insert 5 favorites
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
String qid = "q-" + String.format("%03d", i);
|
||||
when(questionCatalogService.getQuestion(qid)).thenReturn(
|
||||
SafeQuestionRespVO.builder()
|
||||
.id(qid).contentVersion("v1").stem("Q " + i)
|
||||
.type("choice").difficulty("easy").build()
|
||||
);
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId(qid);
|
||||
favoriteService.create(req, userId, tenantId);
|
||||
}
|
||||
|
||||
// Page 1, size 3
|
||||
FavoritePageReqVO pageReq1 = new FavoritePageReqVO();
|
||||
pageReq1.setPageNo(1); pageReq1.setPageSize(3);
|
||||
PageResult<FavoritePageItemRespVO> page1 = favoriteService.page(pageReq1, userId, tenantId);
|
||||
assertEquals(5, page1.getTotal());
|
||||
assertEquals(3, page1.getList().size());
|
||||
|
||||
// Page 2, size 3
|
||||
FavoritePageReqVO pageReq2 = new FavoritePageReqVO();
|
||||
pageReq2.setPageNo(2); pageReq2.setPageSize(3);
|
||||
PageResult<FavoritePageItemRespVO> page2 = favoriteService.page(pageReq2, userId, tenantId);
|
||||
assertEquals(2, page2.getList().size());
|
||||
|
||||
// Filter by QUESTION type
|
||||
FavoritePageReqVO filtered = new FavoritePageReqVO();
|
||||
filtered.setPageNo(1); filtered.setPageSize(10); filtered.setTargetType("QUESTION");
|
||||
PageResult<FavoritePageItemRespVO> filteredPage = favoriteService.page(filtered, userId, tenantId);
|
||||
assertEquals(5, filteredPage.getTotal());
|
||||
}
|
||||
|
||||
// ========== Page: cross-user isolation ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyPageForOtherUser() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
favoriteService.create(req, userId, tenantId);
|
||||
|
||||
FavoritePageReqVO pageReq = new FavoritePageReqVO();
|
||||
pageReq.setPageNo(1); pageReq.setPageSize(10);
|
||||
PageResult<FavoritePageItemRespVO> otherPage = favoriteService.page(pageReq, otherUserId, tenantId);
|
||||
assertEquals(0, otherPage.getTotal());
|
||||
}
|
||||
|
||||
// ========== Page: cross-tenant isolation ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyPageForOtherTenant() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
favoriteService.create(req, userId, tenantId);
|
||||
|
||||
FavoritePageReqVO pageReq = new FavoritePageReqVO();
|
||||
pageReq.setPageNo(1); pageReq.setPageSize(10);
|
||||
PageResult<FavoritePageItemRespVO> otherPage = favoriteService.page(pageReq, userId, otherTenantId);
|
||||
assertEquals(0, otherPage.getTotal());
|
||||
}
|
||||
|
||||
// ========== Page: deleted excluded ==========
|
||||
|
||||
@Test
|
||||
void shouldExcludeDeletedFromPage() {
|
||||
FavoriteCreateReqVO req1 = new FavoriteCreateReqVO();
|
||||
req1.setTargetType("QUESTION");
|
||||
req1.setTargetId("q-001");
|
||||
favoriteService.create(req1, userId, tenantId);
|
||||
|
||||
when(questionCatalogService.getQuestion("q-002")).thenReturn(
|
||||
SafeQuestionRespVO.builder()
|
||||
.id("q-002").contentVersion("v1").stem("Q2")
|
||||
.type("choice").difficulty("easy").build()
|
||||
);
|
||||
FavoriteCreateReqVO req2 = new FavoriteCreateReqVO();
|
||||
req2.setTargetType("QUESTION");
|
||||
req2.setTargetId("q-002");
|
||||
FavoritePageItemRespVO created2 = favoriteService.create(req2, userId, tenantId);
|
||||
|
||||
// Delete first
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setTargetType("QUESTION");
|
||||
deleteReq.setTargetId("q-001");
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
|
||||
// Page should only show q-002
|
||||
FavoritePageReqVO pageReq = new FavoritePageReqVO();
|
||||
pageReq.setPageNo(1); pageReq.setPageSize(10);
|
||||
PageResult<FavoritePageItemRespVO> result = favoriteService.page(pageReq, userId, tenantId);
|
||||
assertEquals(1, result.getTotal());
|
||||
assertEquals("q-002", result.getList().get(0).getTargetId());
|
||||
}
|
||||
|
||||
// ========== Status: batch check ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnFavoritedIds() {
|
||||
// Favorite q-001 and q-003, not q-002
|
||||
FavoriteCreateReqVO req1 = new FavoriteCreateReqVO();
|
||||
req1.setTargetType("QUESTION");
|
||||
req1.setTargetId("q-001");
|
||||
favoriteService.create(req1, userId, tenantId);
|
||||
|
||||
when(questionCatalogService.getQuestion("q-003")).thenReturn(
|
||||
SafeQuestionRespVO.builder()
|
||||
.id("q-003").contentVersion("v1").stem("Q3")
|
||||
.type("choice").difficulty("easy").build()
|
||||
);
|
||||
FavoriteCreateReqVO req3 = new FavoriteCreateReqVO();
|
||||
req3.setTargetType("QUESTION");
|
||||
req3.setTargetId("q-003");
|
||||
favoriteService.create(req3, userId, tenantId);
|
||||
|
||||
FavoriteStatusRespVO status = favoriteService.status(
|
||||
List.of("q-001", "q-002", "q-003", "q-004"), userId, tenantId);
|
||||
|
||||
assertEquals(2, status.getFavoritedIds().size());
|
||||
assertTrue(status.getFavoritedIds().contains("q-001"));
|
||||
assertTrue(status.getFavoritedIds().contains("q-003"));
|
||||
assertFalse(status.getFavoritedIds().contains("q-002"));
|
||||
}
|
||||
|
||||
// ========== Status: cross-user isolation ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyStatusForOtherUser() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
favoriteService.create(req, userId, tenantId);
|
||||
|
||||
FavoriteStatusRespVO status = favoriteService.status(
|
||||
List.of("q-001"), otherUserId, tenantId);
|
||||
|
||||
assertTrue(status.getFavoritedIds().isEmpty());
|
||||
}
|
||||
|
||||
// ========== Status: deleted excluded ==========
|
||||
|
||||
@Test
|
||||
void shouldExcludeDeletedFromStatus() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
favoriteService.create(req, userId, tenantId);
|
||||
|
||||
// Delete
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setTargetType("QUESTION");
|
||||
deleteReq.setTargetId("q-001");
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
|
||||
FavoriteStatusRespVO status = favoriteService.status(
|
||||
List.of("q-001"), userId, tenantId);
|
||||
|
||||
assertTrue(status.getFavoritedIds().isEmpty(),
|
||||
"deleted favorites should not appear in status");
|
||||
}
|
||||
|
||||
// ========== Status: empty list ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleEmptyStatusQuery() {
|
||||
FavoriteStatusRespVO status = favoriteService.status(List.of(), userId, tenantId);
|
||||
assertNotNull(status.getFavoritedIds());
|
||||
assertTrue(status.getFavoritedIds().isEmpty());
|
||||
|
||||
FavoriteStatusRespVO statusNull = favoriteService.status(null, userId, tenantId);
|
||||
assertNotNull(statusNull.getFavoritedIds());
|
||||
assertTrue(statusNull.getFavoritedIds().isEmpty());
|
||||
}
|
||||
|
||||
// ========== No answer leak: response JSON ==========
|
||||
|
||||
@Test
|
||||
void shouldNotContainAnswerFieldsInResponse() {
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
|
||||
FavoritePageItemRespVO resp = favoriteService.create(req, userId, tenantId);
|
||||
|
||||
// Structural guarantee: FavoritePageItemRespVO has no correctAnswer/explanation/isCorrect fields
|
||||
// This is enforced at the type level — the class doesn't have these fields.
|
||||
// Verify options don't contain isCorrect
|
||||
for (FavoritePageItemRespVO.OptionVO opt : resp.getOptions()) {
|
||||
// OptionVO only has label, content, order — no isCorrect field
|
||||
assertNotNull(opt.getLabel());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Finding 1: Availability refresh in page ==========
|
||||
|
||||
@Test
|
||||
void shouldShowAvailableFalseWhenSourceBecomesUnavailable() {
|
||||
// Favorite q-001 successfully
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
FavoritePageItemRespVO created = favoriteService.create(req, userId, tenantId);
|
||||
assertTrue(created.getAvailable());
|
||||
|
||||
// Now make q-001 unavailable
|
||||
when(questionCatalogService.getQuestion("q-001"))
|
||||
.thenThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()));
|
||||
|
||||
// Page should still list the favorite but with available=false
|
||||
FavoritePageReqVO pageReq = new FavoritePageReqVO();
|
||||
pageReq.setPageNo(1); pageReq.setPageSize(10);
|
||||
PageResult<FavoritePageItemRespVO> page = favoriteService.page(pageReq, userId, tenantId);
|
||||
assertEquals(1, page.getTotal());
|
||||
FavoritePageItemRespVO item = page.getList().get(0);
|
||||
assertEquals("q-001", item.getTargetId());
|
||||
assertFalse(item.getAvailable(), "available should be false when source is gone");
|
||||
|
||||
// Snapshot must be preserved
|
||||
assertEquals("Test question?", item.getStem());
|
||||
assertEquals("choice", item.getType());
|
||||
assertEquals("easy", item.getDifficulty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldShowAvailableTrueWhenSourceReturns() {
|
||||
// Create favorite, then make source unavailable, then restore
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
FavoritePageItemRespVO created = favoriteService.create(req, userId, tenantId);
|
||||
assertTrue(created.getAvailable());
|
||||
|
||||
// Make unavailable — use doThrow to avoid triggering previous stubs
|
||||
doThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()))
|
||||
.when(questionCatalogService).getQuestion("q-001");
|
||||
|
||||
FavoritePageReqVO pageReq = new FavoritePageReqVO();
|
||||
pageReq.setPageNo(1); pageReq.setPageSize(10);
|
||||
PageResult<FavoritePageItemRespVO> page1 = favoriteService.page(pageReq, userId, tenantId);
|
||||
assertFalse(page1.getList().get(0).getAvailable());
|
||||
|
||||
// Restore availability — use doReturn to avoid triggering previous stubs
|
||||
doReturn(SafeQuestionRespVO.builder()
|
||||
.id("q-001").contentVersion("v1").stem("Test question?")
|
||||
.type("choice").difficulty("easy")
|
||||
.options(List.of(
|
||||
SafeQuestionRespVO.SafeOptionVO.builder()
|
||||
.label("A").content("Option A").order(1.0).build()
|
||||
))
|
||||
.build())
|
||||
.when(questionCatalogService).getQuestion("q-001");
|
||||
|
||||
PageResult<FavoritePageItemRespVO> page2 = favoriteService.page(pageReq, userId, tenantId);
|
||||
assertTrue(page2.getList().get(0).getAvailable(), "available should be true when source returns");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMarkUnavailableOnUpstreamError() {
|
||||
// Create favorite
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
favoriteService.create(req, userId, tenantId);
|
||||
|
||||
// Make getQuestion throw a non-gone error (e.g., upstream timeout)
|
||||
when(questionCatalogService.getQuestion("q-001"))
|
||||
.thenThrow(new ServiceException(CATALOG_UPSTREAM_TIMEOUT.getCode(), CATALOG_UPSTREAM_TIMEOUT.getMsg()));
|
||||
|
||||
// Page should propagate the error, not silently mark unavailable
|
||||
FavoritePageReqVO pageReq = new FavoritePageReqVO();
|
||||
pageReq.setPageNo(1); pageReq.setPageSize(10);
|
||||
assertThrows(ServiceException.class, () ->
|
||||
favoriteService.page(pageReq, userId, tenantId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeduplicateGetQuestionCallsInRefresh() {
|
||||
// Create two favorites for the same question ID (only one active row, but simulate)
|
||||
// Actually, unique constraint prevents duplicates. Let's create one favorite and
|
||||
// verify the refresh doesn't double-call for the same targetId.
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
favoriteService.create(req, userId, tenantId);
|
||||
|
||||
// Clear invocation counts to verify refresh behavior
|
||||
clearInvocations(questionCatalogService);
|
||||
when(questionCatalogService.getQuestion("q-001")).thenReturn(
|
||||
SafeQuestionRespVO.builder()
|
||||
.id("q-001").contentVersion("v1").stem("Test question?")
|
||||
.type("choice").difficulty("easy").build()
|
||||
);
|
||||
|
||||
FavoritePageReqVO pageReq = new FavoritePageReqVO();
|
||||
pageReq.setPageNo(1); pageReq.setPageSize(10);
|
||||
favoriteService.page(pageReq, userId, tenantId);
|
||||
|
||||
// getQuestion should only be called once for q-001 (deduplication)
|
||||
verify(questionCatalogService, times(1)).getQuestion("q-001");
|
||||
}
|
||||
|
||||
// ========== Finding 2: Duplicate create with source validation ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectDuplicateCreateWhenSourceBecameUnavailable() {
|
||||
// Create favorite
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
FavoritePageItemRespVO created = favoriteService.create(req, userId, tenantId);
|
||||
assertTrue(created.getAvailable());
|
||||
|
||||
// Make source unavailable
|
||||
when(questionCatalogService.getQuestion("q-001"))
|
||||
.thenThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()));
|
||||
|
||||
// Duplicate create should now reject
|
||||
FavoriteCreateReqVO dupReq = new FavoriteCreateReqVO();
|
||||
dupReq.setTargetType("QUESTION");
|
||||
dupReq.setTargetId("q-001");
|
||||
assertServiceException(
|
||||
() -> favoriteService.create(dupReq, userId, tenantId),
|
||||
FAVORITE_TARGET_NOT_FOUND);
|
||||
|
||||
// Verify the existing record still exists but has available=false via page()
|
||||
FavoritePageReqVO pageReq = new FavoritePageReqVO();
|
||||
pageReq.setPageNo(1); pageReq.setPageSize(10);
|
||||
PageResult<FavoritePageItemRespVO> page = favoriteService.page(pageReq, userId, tenantId);
|
||||
assertEquals(1, page.getTotal(), "existing favorite should still be listable");
|
||||
FavoritePageItemRespVO item = page.getList().get(0);
|
||||
assertEquals("q-001", item.getTargetId());
|
||||
assertFalse(item.getAvailable(), "available should be false after source became unavailable");
|
||||
// Snapshot preserved
|
||||
assertEquals("Test question?", item.getStem());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowCreateWhenSourceReturnsAfterBeingUnavailable() {
|
||||
// Create favorite
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
favoriteService.create(req, userId, tenantId);
|
||||
|
||||
// Delete it
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setTargetType("QUESTION");
|
||||
deleteReq.setTargetId("q-001");
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
|
||||
// Make source unavailable
|
||||
doThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()))
|
||||
.when(questionCatalogService).getQuestion("q-001");
|
||||
|
||||
// Try to re-create — should fail
|
||||
assertServiceException(
|
||||
() -> favoriteService.create(req, userId, tenantId),
|
||||
FAVORITE_TARGET_NOT_FOUND);
|
||||
|
||||
// Restore source
|
||||
doReturn(SafeQuestionRespVO.builder()
|
||||
.id("q-001").contentVersion("v2").stem("Updated question?")
|
||||
.type("choice").difficulty("easy").build())
|
||||
.when(questionCatalogService).getQuestion("q-001");
|
||||
|
||||
// Re-create should succeed with available=true
|
||||
FavoritePageItemRespVO reAdded = favoriteService.create(req, userId, tenantId);
|
||||
assertTrue(reAdded.getAvailable(), "re-added favorite should have available=true");
|
||||
assertEquals("Updated question?", reAdded.getStem());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReAddAfterDeleteIncludingAvailabilityCheck() {
|
||||
// Standard re-add case: create, delete, re-add
|
||||
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
|
||||
req.setTargetType("QUESTION");
|
||||
req.setTargetId("q-001");
|
||||
|
||||
FavoritePageItemRespVO first = favoriteService.create(req, userId, tenantId);
|
||||
|
||||
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
|
||||
deleteReq.setId(first.getId());
|
||||
favoriteService.delete(deleteReq, userId, tenantId);
|
||||
assertFavoriteNotActive(tenantId, userId, "q-001");
|
||||
|
||||
// Re-add — source is still available
|
||||
FavoritePageItemRespVO reAdded = favoriteService.create(req, userId, tenantId);
|
||||
|
||||
assertNotNull(reAdded);
|
||||
assertEquals(first.getId(), reAdded.getId(), "re-add must reactivate same row");
|
||||
assertTrue(reAdded.getAvailable());
|
||||
assertFavoriteActive(tenantId, userId, "q-001");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
DELETE FROM education_favorite;
|
||||
DELETE FROM education_wrong_question_idempotency;
|
||||
DELETE FROM education_wrong_question;
|
||||
|
||||
|
||||
@@ -200,3 +200,29 @@ CREATE TABLE IF NOT EXISTS "education_wrong_question_idempotency" (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_wq_idem_report" ON "education_wrong_question_idempotency" ("report_id");
|
||||
|
||||
|
||||
-- Ticket #10: favorites
|
||||
CREATE TABLE IF NOT EXISTS "education_favorite" (
|
||||
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"target_type" VARCHAR(32) NOT NULL,
|
||||
"target_id" VARCHAR(64) NOT NULL,
|
||||
"stem" CLOB DEFAULT NULL,
|
||||
"type" VARCHAR(32) DEFAULT NULL,
|
||||
"difficulty" VARCHAR(32) DEFAULT NULL,
|
||||
"options" CLOB DEFAULT NULL,
|
||||
"content_version" VARCHAR(64) NOT NULL DEFAULT '',
|
||||
"available" BIT 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" BIT NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uk_tenant_user_target" UNIQUE ("tenant_id", "user_id", "target_type", "target_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_fav_tenant_user" ON "education_favorite" ("tenant_id", "user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_fav_tenant_user_target_type" ON "education_favorite" ("tenant_id", "user_id", "target_type");
|
||||
Reference in New Issue
Block a user