feat(education): add question favorites

This commit is contained in:
2026-07-28 00:21:35 +08:00
parent 12676dfbec
commit 93f02df68c
19 changed files with 2245 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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, "收藏记录不存在");
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
});
}
}
}