diff --git a/sql/mysql/education/007-education-favorite-rollback.sql b/sql/mysql/education/007-education-favorite-rollback.sql new file mode 100644 index 00000000..cc3f32e2 --- /dev/null +++ b/sql/mysql/education/007-education-favorite-rollback.sql @@ -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. diff --git a/sql/mysql/education/007-education-favorite.sql b/sql/mysql/education/007-education-favorite.sql new file mode 100644 index 00000000..e85e7c61 --- /dev/null +++ b/sql/mysql/education/007-education-favorite.sql @@ -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; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/FavoriteController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/FavoriteController.java new file mode 100644 index 00000000..21bc92b2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/FavoriteController.java @@ -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 create(@Valid @RequestBody FavoriteCreateReqVO reqVO) { + return success(favoriteService.create(reqVO, getUserId(), getTenantId())); + } + + @DeleteMapping("/favorite/delete") + @Operation(summary = "取消收藏(幂等)", + description = "取消收藏,通过 id 或 (targetType + targetId) 删除。重复取消返回成功。逻辑删除。") + public CommonResult delete(@Valid @RequestBody FavoriteDeleteReqVO reqVO) { + favoriteService.delete(reqVO, getUserId(), getTenantId()); + return success(true); + } + + // ========== 查询操作 ========== + + @GetMapping("/favorite/page") + @Operation(summary = "分页查询收藏列表", + description = "按当前用户分页查询收藏记录,支持按目标类型筛选,按收藏时间降序。") + public CommonResult> page(@Valid FavoritePageReqVO reqVO) { + return success(favoriteService.page(reqVO, getUserId(), getTenantId())); + } + + @PostMapping("/favorite/status") + @Operation(summary = "批量查询题目收藏状态", + description = "传入题目 ID 列表(最多 100 个),返回已收藏的题目 ID 列表。用于前端图标同步。") + public CommonResult status(@Valid @RequestBody FavoriteStatusReqVO reqVO) { + List 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; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteCreateReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteCreateReqVO.java new file mode 100644 index 00000000..26d4c3e7 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteCreateReqVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteDeleteReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteDeleteReqVO.java new file mode 100644 index 00000000..c6731c74 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteDeleteReqVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoritePageItemRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoritePageItemRespVO.java new file mode 100644 index 00000000..7b0f4f09 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoritePageItemRespVO.java @@ -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 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; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoritePageReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoritePageReqVO.java new file mode 100644 index 00000000..762bff31 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoritePageReqVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteStatusReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteStatusReqVO.java new file mode 100644 index 00000000..1d28ad3b --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteStatusReqVO.java @@ -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 questionIds; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteStatusRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteStatusRespVO.java new file mode 100644 index 00000000..96480da3 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/favorite/vo/FavoriteStatusRespVO.java @@ -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 favoritedIds; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/EducationFavoriteDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/EducationFavoriteDO.java new file mode 100644 index 00000000..353bbf7b --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/EducationFavoriteDO.java @@ -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 — 学生收藏记录。 + * + *

同一 (tenant, user, target_type, target_id) 唯一一条。 + * 逻辑删除:取消收藏设置 deleted=1;重新收藏通过 ON DUPLICATE KEY UPDATE 恢复。

+ * + *

快照字段(stem/type/difficulty/options/contentVersion)来自收藏时 + * 题目的安全视图,保留题目当时状态。available 标记源资源当前是否可用。

+ * + *

target_type 当前仅支持 QUESTION,使用枚举类型预留扩展。

+ * + * @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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/EducationFavoriteMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/EducationFavoriteMapper.java new file mode 100644 index 00000000..91137921 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/EducationFavoriteMapper.java @@ -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 { + + /** + * INSERT ... ON DUPLICATE KEY UPDATE — upsert a favorite entry. + * + *

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.

+ */ + @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() + .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() + .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 selectPageByTenantAndUser(IPage page, + Long tenantId, Long userId, + String targetType) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .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 selectActiveByTenantUserAndTargetIds(Long tenantId, Long userId, + String targetType, + Collection targetIds) { + return selectList(new LambdaQueryWrapperX() + .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() + .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() + .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() + .eq(EducationFavoriteDO::getId, id) + .set(EducationFavoriteDO::getAvailable, available)); + } + + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 2b42006f..bb9a414e 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -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, "收藏记录不存在"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/FavoriteTargetType.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/FavoriteTargetType.java new file mode 100644 index 00000000..8d41c6ad --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/FavoriteTargetType.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.education.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 收藏目标类型枚举。 + * + *

当前仅支持题目(QUESTION),预留扩展接口。

+ * + * @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; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/favorite/FavoriteService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/favorite/FavoriteService.java new file mode 100644 index 00000000..866c2821 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/favorite/FavoriteService.java @@ -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 { + + /** + * 收藏目标(幂等)。 + * + *

验证目标存在且可见,快照安全字段。重复收藏返回已有记录,不抛异常。 + * 重新收藏已取消的记录将恢复(设置 deleted=0)。

+ * + * @param reqVO 创建请求 + * @param userId 当前学生用户 + * @param tenantId 当前租户 + * @return 收藏记录 + */ + FavoritePageItemRespVO create(FavoriteCreateReqVO reqVO, Long userId, Long tenantId); + + /** + * 取消收藏(幂等)。 + * + *

通过 id 或 (targetType + targetId) 定位记录。重复取消返回成功。 + * 逻辑删除,设置 deleted=1。

+ * + * @param reqVO 删除请求 + * @param userId 当前学生用户 + * @param tenantId 当前租户 + */ + void delete(FavoriteDeleteReqVO reqVO, Long userId, Long tenantId); + + /** + * 分页查询当前用户的收藏列表(仅未删除)。 + * + * @param reqVO 分页请求 + * @param userId 当前学生用户 + * @param tenantId 当前租户 + * @return 分页结果 + */ + PageResult page(FavoritePageReqVO reqVO, Long userId, Long tenantId); + + /** + * 批量查询题目收藏状态。 + * + * @param questionIds 题目 ID 列表 + * @param userId 当前学生用户 + * @param tenantId 当前租户 + * @return 状态响应(含已收藏的题目 ID 列表) + */ + FavoriteStatusRespVO status(List questionIds, Long userId, Long tenantId); + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/favorite/FavoriteServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/favorite/FavoriteServiceImpl.java new file mode 100644 index 00000000..1e519a48 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/favorite/FavoriteServiceImpl.java @@ -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 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 page = favoriteMapper.selectPageByTenantAndUser( + new Page<>(reqVO.getPageNo(), reqVO.getPageSize()), + tenantId, userId, reqVO.getTargetType()); + + // 2. Refresh availability for the returned favorites + refreshAvailability(page.getRecords()); + + List list = page.getRecords().stream() + .map(this::toPageItem) + .collect(Collectors.toList()); + + return new PageResult<>(list, page.getTotal()); + } + + // ========== Status ========== + + @Override + public FavoriteStatusRespVO status(List questionIds, Long userId, Long tenantId) { + if (questionIds == null || questionIds.isEmpty()) { + return FavoriteStatusRespVO.builder() + .favoritedIds(Collections.emptyList()) + .build(); + } + + List favorites = favoriteMapper.selectActiveByTenantUserAndTargetIds( + tenantId, userId, FavoriteTargetType.QUESTION.getCode(), questionIds); + + List 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 options) { + if (options == null || options.isEmpty()) { + return null; + } + return JsonUtils.toJsonString(options); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private List parseOptions(String optionsJson) { + if (optionsJson == null || optionsJson.isEmpty()) { + return Collections.emptyList(); + } + try { + List> 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()); + } + + /** + * 刷新收藏列表中每道题的可用性状态。 + * + *

对返回的收藏记录按 targetId 去重后,逐个调用 + * {@link QuestionCatalogService#getQuestion(String)} 判断源资源是否仍可见。 + * 如果题目不存在或不可见,将 available 标记为 false 并持久化; + * 如果题目恢复可见,将 available 标记为 true 并持久化。 + * 其他上游异常(网络错误、超时等)直接传播,不静默标记为不可用。

+ */ + private void refreshAvailability(List favorites) { + if (favorites == null || favorites.isEmpty()) { + return; + } + + // Deduplicate: avoid duplicate getQuestion calls within one request + Set 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); + }); + } + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/favorite/FavoriteControllerHttpTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/favorite/FavoriteControllerHttpTest.java new file mode 100644 index 00000000..ec227dad --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/favorite/FavoriteControllerHttpTest.java @@ -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 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()); + } + +} + diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/favorite/FavoriteServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/favorite/FavoriteServiceImplTest.java new file mode 100644 index 00000000..6c8bd30c --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/favorite/FavoriteServiceImplTest.java @@ -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 r1 = new AtomicReference<>(); + AtomicReference 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 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 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 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 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 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 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 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 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 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 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"); + } + + +} diff --git a/yudao-module-education/src/test/resources/sql/clean.sql b/yudao-module-education/src/test/resources/sql/clean.sql index 14fa0883..2579294a 100644 --- a/yudao-module-education/src/test/resources/sql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/clean.sql @@ -1,3 +1,4 @@ +DELETE FROM education_favorite; DELETE FROM education_wrong_question_idempotency; DELETE FROM education_wrong_question; diff --git a/yudao-module-education/src/test/resources/sql/create_tables.sql b/yudao-module-education/src/test/resources/sql/create_tables.sql index baa0f9ab..6bb434c3 100644 --- a/yudao-module-education/src/test/resources/sql/create_tables.sql +++ b/yudao-module-education/src/test/resources/sql/create_tables.sql @@ -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"); \ No newline at end of file