diff --git a/heritage-modules/heritage-portal/pom.xml b/heritage-modules/heritage-portal/pom.xml
new file mode 100644
index 0000000..e5c6077
--- /dev/null
+++ b/heritage-modules/heritage-portal/pom.xml
@@ -0,0 +1,56 @@
+
+
+ 4.0.0
+
+ org.leocoder.heritage
+ heritage-backend
+ ${revision}
+ ../../pom.xml
+
+
+
+ heritage-portal
+ heritage-portal
+ 前台业务模块
+
+
+
+
+ org.leocoder.heritage
+ heritage-common
+ ${revision}
+
+
+
+ org.leocoder.heritage
+ heritage-resultex
+ ${revision}
+
+
+
+ org.leocoder.heritage
+ heritage-sa-token
+ ${revision}
+
+
+
+ org.leocoder.heritage
+ heritage-oper-logs
+ ${revision}
+
+
+
+ org.leocoder.heritage
+ heritage-oss
+ ${revision}
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+
+
+
+
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/auth/HrtAuthController.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/auth/HrtAuthController.java
new file mode 100644
index 0000000..2b561d5
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/auth/HrtAuthController.java
@@ -0,0 +1,67 @@
+package org.leocoder.heritage.portal.controller.auth;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.domain.model.vo.portal.HrtLoginResultVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtLoginVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtRegisterVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtUserInfoVo;
+import org.leocoder.heritage.portal.service.auth.HrtAuthService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * @author Leocoder
+ * @description [前台用户认证控制器]
+ */
+@Tag(name = "前台用户认证", description = "前台用户注册、登录、登出等认证功能")
+@Validated
+@RequestMapping("/api/auth")
+@RequiredArgsConstructor
+@RestController
+public class HrtAuthController {
+
+ private final HrtAuthService hrtAuthService;
+
+ /**
+ * @description [用户注册]
+ * @author Leocoder
+ */
+ @Operation(summary = "用户注册", description = "前台用户注册接口")
+ @PostMapping("/register")
+ public String register(@Validated @RequestBody HrtRegisterVo vo) {
+ return hrtAuthService.register(vo);
+ }
+
+ /**
+ * @description [用户登录]
+ * @author Leocoder
+ */
+ @Operation(summary = "用户登录", description = "前台用户登录接口")
+ @PostMapping("/login")
+ public HrtLoginResultVo login(@Validated @RequestBody HrtLoginVo vo) {
+ return hrtAuthService.login(vo);
+ }
+
+ /**
+ * @description [用户登出]
+ * @author Leocoder
+ */
+ @Operation(summary = "用户登出", description = "前台用户登出接口")
+ @PostMapping("/logout")
+ public String logout() {
+ return hrtAuthService.logout();
+ }
+
+ /**
+ * @description [获取当前登录用户信息]
+ * @author Leocoder
+ */
+ @Operation(summary = "获取用户信息", description = "获取当前登录用户的详细信息")
+ @GetMapping("/userInfo")
+ public HrtUserInfoVo getUserInfo() {
+ return hrtAuthService.getUserInfo();
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/comment/HrtCommentController.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/comment/HrtCommentController.java
new file mode 100644
index 0000000..59bff30
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/comment/HrtCommentController.java
@@ -0,0 +1,68 @@
+package org.leocoder.heritage.portal.controller.comment;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+import org.leocoder.heritage.portal.service.comment.HrtCommentService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * @author Leocoder
+ * @description [前台评论控制器]
+ */
+@Tag(name = "前台评论管理", description = "用户评论非遗项目、传承人、新闻等功能,支持回复和点赞")
+@Validated
+@RequestMapping("/api/comment")
+@RequiredArgsConstructor
+@RestController
+public class HrtCommentController {
+
+ private final HrtCommentService hrtCommentService;
+
+ /**
+ * @description [发表评论]
+ * @author Leocoder
+ */
+ @Operation(summary = "发表评论", description = "发表评论或回复,支持一级评论和二级回复")
+ @PostMapping("/add")
+ public String addComment(@Validated @RequestBody CommentAddVo vo) {
+ return hrtCommentService.addComment(vo);
+ }
+
+ /**
+ * @description [删除自己的评论]
+ * @author Leocoder
+ */
+ @Operation(summary = "删除评论", description = "删除自己发表的评论,同时会删除该评论下的所有回复")
+ @DeleteMapping("/delete/{commentId}")
+ public String deleteComment(
+ @Parameter(description = "评论ID") @PathVariable Long commentId
+ ) {
+ return hrtCommentService.deleteComment(commentId);
+ }
+
+ /**
+ * @description [查看评论列表]
+ * @author Leocoder
+ */
+ @Operation(summary = "查看评论列表", description = "根据目标类型和ID分页查询评论列表,包含回复和点赞状态")
+ @GetMapping("/list")
+ public IPage listComments(@Validated CommentQueryVo vo) {
+ return hrtCommentService.listComments(vo);
+ }
+
+ /**
+ * @description [我的评论列表]
+ * @author Leocoder
+ */
+ @Operation(summary = "我的评论列表", description = "分页查询当前用户的评论列表,支持按类型筛选")
+ @GetMapping("/myList")
+ public IPage myCommentList(@Validated MyCommentQueryVo vo) {
+ return hrtCommentService.myCommentList(vo);
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/event/HrtEventController.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/event/HrtEventController.java
new file mode 100644
index 0000000..9702ade
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/event/HrtEventController.java
@@ -0,0 +1,80 @@
+package org.leocoder.heritage.portal.controller.event;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+import org.leocoder.heritage.portal.service.event.HrtEventService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * @author Leocoder
+ * @description [前台活动管理控制器]
+ */
+@Tag(name = "前台活动管理", description = "活动列表、详情、报名、取消报名等功能")
+@Validated
+@RequestMapping("/api/event")
+@RequiredArgsConstructor
+@RestController
+public class HrtEventController {
+
+ private final HrtEventService hrtEventService;
+
+ /**
+ * @description [活动列表]
+ * @author Leocoder
+ */
+ @Operation(summary = "活动列表", description = "分页查询活动列表,支持状态筛选和关键词搜索")
+ @GetMapping("/list")
+ public IPage listEvents(@Validated EventQueryVo vo) {
+ return hrtEventService.listEvents(vo);
+ }
+
+ /**
+ * @description [活动详情]
+ * @author Leocoder
+ */
+ @Operation(summary = "活动详情", description = "查看活动详情,自动增加浏览量,包含报名状态和是否可报名")
+ @GetMapping("/detail/{eventId}")
+ public EventDetailVo getEventDetail(
+ @Parameter(description = "活动ID") @PathVariable Long eventId
+ ) {
+ return hrtEventService.getEventDetail(eventId);
+ }
+
+ /**
+ * @description [报名参加活动]
+ * @author Leocoder
+ */
+ @Operation(summary = "报名活动", description = "报名参加活动,需验证报名时间和人数限制")
+ @PostMapping("/register")
+ public String registerEvent(@Validated @RequestBody EventRegistrationVo vo) {
+ return hrtEventService.registerEvent(vo);
+ }
+
+ /**
+ * @description [取消报名]
+ * @author Leocoder
+ */
+ @Operation(summary = "取消报名", description = "取消已报名的活动,活动开始后无法取消")
+ @PostMapping("/cancel/{eventId}")
+ public String cancelRegistration(
+ @Parameter(description = "活动ID") @PathVariable Long eventId
+ ) {
+ return hrtEventService.cancelRegistration(eventId);
+ }
+
+ /**
+ * @description [我报名的活动]
+ * @author Leocoder
+ */
+ @Operation(summary = "我的报名", description = "分页查询当前用户报名的活动列表,支持按状态筛选")
+ @GetMapping("/myRegistrations")
+ public IPage myRegistrations(@Validated MyEventRegistrationVo vo) {
+ return hrtEventService.myRegistrations(vo);
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/favorite/HrtFavoriteController.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/favorite/HrtFavoriteController.java
new file mode 100644
index 0000000..e5c36c9
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/favorite/HrtFavoriteController.java
@@ -0,0 +1,71 @@
+package org.leocoder.heritage.portal.controller.favorite;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.domain.model.vo.portal.FavoriteItemVo;
+import org.leocoder.heritage.domain.model.vo.portal.FavoriteOperateVo;
+import org.leocoder.heritage.domain.model.vo.portal.FavoriteQueryVo;
+import org.leocoder.heritage.portal.service.favorite.HrtFavoriteService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * @author Leocoder
+ * @description [前台收藏控制器]
+ */
+@Tag(name = "前台收藏管理", description = "用户收藏非遗项目、传承人、新闻等功能")
+@Validated
+@RequestMapping("/api/favorite")
+@RequiredArgsConstructor
+@RestController
+public class HrtFavoriteController {
+
+ private final HrtFavoriteService hrtFavoriteService;
+
+ /**
+ * @description [添加收藏]
+ * @author Leocoder
+ */
+ @Operation(summary = "添加收藏", description = "收藏非遗项目、传承人或新闻资讯")
+ @PostMapping("/add")
+ public String addFavorite(@Validated @RequestBody FavoriteOperateVo vo) {
+ return hrtFavoriteService.addFavorite(vo);
+ }
+
+ /**
+ * @description [取消收藏]
+ * @author Leocoder
+ */
+ @Operation(summary = "取消收藏", description = "取消已收藏的内容")
+ @PostMapping("/cancel")
+ public String cancelFavorite(@Validated @RequestBody FavoriteOperateVo vo) {
+ return hrtFavoriteService.cancelFavorite(vo);
+ }
+
+ /**
+ * @description [我的收藏列表]
+ * @author Leocoder
+ */
+ @Operation(summary = "我的收藏列表", description = "分页查询当前用户的收藏列表,支持按类型筛选")
+ @GetMapping("/myList")
+ public IPage myFavoriteList(@Validated FavoriteQueryVo vo) {
+ return hrtFavoriteService.myFavoriteList(vo);
+ }
+
+ /**
+ * @description [检查是否已收藏]
+ * @author Leocoder
+ */
+ @Operation(summary = "检查是否已收藏", description = "用于前端判断收藏按钮状态")
+ @GetMapping("/check")
+ public Boolean checkFavorite(
+ @Parameter(description = "目标类型:heritage、inheritor、news") @RequestParam String targetType,
+ @Parameter(description = "目标ID") @RequestParam Long targetId
+ ) {
+ return hrtFavoriteService.checkFavorite(targetType, targetId);
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/heritage/HrtHeritageController.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/heritage/HrtHeritageController.java
new file mode 100644
index 0000000..fc6bb27
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/heritage/HrtHeritageController.java
@@ -0,0 +1,70 @@
+package org.leocoder.heritage.portal.controller.heritage;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.domain.model.vo.portal.HrtHeritageDetailVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtHeritageListVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtHeritageQueryVo;
+import org.leocoder.heritage.portal.service.heritage.HrtHeritageService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author Leocoder
+ * @description [前台非遗项目控制器]
+ */
+@Tag(name = "前台非遗项目管理", description = "非遗项目的查询、浏览、搜索等功能")
+@Validated
+@RequestMapping("/api/heritage")
+@RequiredArgsConstructor
+@RestController
+public class HrtHeritageController {
+
+ private final HrtHeritageService hrtHeritageService;
+
+ /**
+ * @description [分页查询非遗项目列表]
+ * @author Leocoder
+ */
+ @Operation(summary = "分页查询非遗项目列表", description = "支持关键词搜索(名称、描述)和多条件筛选(分类、标签、级别、地区等)")
+ @GetMapping("/list")
+ public IPage list(@Validated HrtHeritageQueryVo vo) {
+ return hrtHeritageService.listPage(vo);
+ }
+
+ /**
+ * @description [查看非遗项目详情]
+ * @author Leocoder
+ */
+ @Operation(summary = "查看非遗项目详情", description = "根据ID查看非遗项目的详细信息,浏览量自动+1")
+ @GetMapping("/detail/{id}")
+ public HrtHeritageDetailVo detail(@Parameter(description = "非遗项目ID") @PathVariable Long id) {
+ return hrtHeritageService.getDetail(id);
+ }
+
+ /**
+ * @description [热门非遗项目]
+ * @author Leocoder
+ */
+ @Operation(summary = "热门非遗项目", description = "根据浏览量和点赞数获取热门非遗项目列表")
+ @GetMapping("/hot")
+ public List hot(@Parameter(description = "查询数量,默认10条") @RequestParam(required = false, defaultValue = "10") Integer limit) {
+ return hrtHeritageService.getHotList(limit);
+ }
+
+ /**
+ * @description [精选非遗项目]
+ * @author Leocoder
+ */
+ @Operation(summary = "精选非遗项目", description = "获取平台精选的优质非遗项目")
+ @GetMapping("/featured")
+ public List featured(@Parameter(description = "查询数量,默认10条") @RequestParam(required = false, defaultValue = "10") Integer limit) {
+ return hrtHeritageService.getFeaturedList(limit);
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/inheritor/HrtInheritorController.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/inheritor/HrtInheritorController.java
new file mode 100644
index 0000000..c7f1abd
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/inheritor/HrtInheritorController.java
@@ -0,0 +1,70 @@
+package org.leocoder.heritage.portal.controller.inheritor;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.domain.model.vo.portal.HrtInheritorDetailVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtInheritorListVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtInheritorQueryVo;
+import org.leocoder.heritage.portal.service.inheritor.HrtInheritorService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author Leocoder
+ * @description [前台传承人控制器]
+ */
+@Tag(name = "前台传承人管理", description = "传承人的查询、浏览、搜索等功能")
+@Validated
+@RequestMapping("/api/inheritor")
+@RequiredArgsConstructor
+@RestController
+public class HrtInheritorController {
+
+ private final HrtInheritorService hrtInheritorService;
+
+ /**
+ * @description [分页查询传承人列表]
+ * @author Leocoder
+ */
+ @Operation(summary = "分页查询传承人列表", description = "支持关键词搜索(姓名、简介、故事)和多条件筛选(级别、地区、非遗项目等)")
+ @GetMapping("/list")
+ public IPage list(@Validated HrtInheritorQueryVo vo) {
+ return hrtInheritorService.listPage(vo);
+ }
+
+ /**
+ * @description [查看传承人详情]
+ * @author Leocoder
+ */
+ @Operation(summary = "查看传承人详情", description = "根据ID查看传承人的详细信息,浏览量自动+1")
+ @GetMapping("/detail/{id}")
+ public HrtInheritorDetailVo detail(@Parameter(description = "传承人ID") @PathVariable Long id) {
+ return hrtInheritorService.getDetail(id);
+ }
+
+ /**
+ * @description [根据非遗项目查询传承人]
+ * @author Leocoder
+ */
+ @Operation(summary = "根据非遗项目查询传承人", description = "根据非遗项目ID查询该项目的所有传承人")
+ @GetMapping("/listByHeritage/{heritageId}")
+ public List listByHeritage(@Parameter(description = "非遗项目ID") @PathVariable Long heritageId) {
+ return hrtInheritorService.listByHeritageId(heritageId);
+ }
+
+ /**
+ * @description [精选传承人]
+ * @author Leocoder
+ */
+ @Operation(summary = "精选传承人", description = "获取平台精选的优秀传承人")
+ @GetMapping("/featured")
+ public List featured(@Parameter(description = "查询数量,默认10条") @RequestParam(required = false, defaultValue = "10") Integer limit) {
+ return hrtInheritorService.getFeaturedList(limit);
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/like/HrtLikeController.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/like/HrtLikeController.java
new file mode 100644
index 0000000..2c34598
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/like/HrtLikeController.java
@@ -0,0 +1,58 @@
+package org.leocoder.heritage.portal.controller.like;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.domain.model.vo.portal.LikeOperateVo;
+import org.leocoder.heritage.portal.service.like.HrtLikeService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * @author Leocoder
+ * @description [前台点赞控制器]
+ */
+@Tag(name = "前台点赞管理", description = "用户点赞非遗项目、传承人、新闻、评论等功能")
+@Validated
+@RequestMapping("/api/like")
+@RequiredArgsConstructor
+@RestController
+public class HrtLikeController {
+
+ private final HrtLikeService hrtLikeService;
+
+ /**
+ * @description [点赞]
+ * @author Leocoder
+ */
+ @Operation(summary = "点赞", description = "点赞非遗项目、传承人、新闻或评论")
+ @PostMapping("/add")
+ public String addLike(@Validated @RequestBody LikeOperateVo vo) {
+ return hrtLikeService.addLike(vo);
+ }
+
+ /**
+ * @description [取消点赞]
+ * @author Leocoder
+ */
+ @Operation(summary = "取消点赞", description = "取消已点赞的内容")
+ @PostMapping("/cancel")
+ public String cancelLike(@Validated @RequestBody LikeOperateVo vo) {
+ return hrtLikeService.cancelLike(vo);
+ }
+
+ /**
+ * @description [检查是否已点赞]
+ * @author Leocoder
+ */
+ @Operation(summary = "检查是否已点赞", description = "用于前端判断点赞按钮状态")
+ @GetMapping("/check")
+ public Boolean checkLike(
+ @Parameter(description = "目标类型:heritage、inheritor、news、comment") @RequestParam String targetType,
+ @Parameter(description = "目标ID") @RequestParam Long targetId
+ ) {
+ return hrtLikeService.checkLike(targetType, targetId);
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/news/HrtNewsController.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/news/HrtNewsController.java
new file mode 100644
index 0000000..aed2edf
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/news/HrtNewsController.java
@@ -0,0 +1,72 @@
+package org.leocoder.heritage.portal.controller.news;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+import org.leocoder.heritage.portal.service.news.HrtNewsService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author Leocoder
+ * @description [前台新闻资讯控制器]
+ */
+@Tag(name = "前台新闻资讯", description = "新闻资讯列表、详情、热门、置顶等功能")
+@Validated
+@RequestMapping("/api/news")
+@RequiredArgsConstructor
+@RestController
+public class HrtNewsController {
+
+ private final HrtNewsService hrtNewsService;
+
+ /**
+ * @description [分页查询新闻列表]
+ * @author Leocoder
+ */
+ @Operation(summary = "新闻列表", description = "分页查询新闻列表,支持分类筛选和关键词搜索")
+ @GetMapping("/list")
+ public IPage listNews(@Validated NewsQueryVo vo) {
+ return hrtNewsService.listNews(vo);
+ }
+
+ /**
+ * @description [查看新闻详情]
+ * @author Leocoder
+ */
+ @Operation(summary = "新闻详情", description = "查看新闻详情,自动增加浏览量,包含点赞和收藏状态")
+ @GetMapping("/detail/{newsId}")
+ public NewsDetailVo getNewsDetail(
+ @Parameter(description = "新闻ID") @PathVariable Long newsId
+ ) {
+ return hrtNewsService.getNewsDetail(newsId);
+ }
+
+ /**
+ * @description [热门新闻]
+ * @author Leocoder
+ */
+ @Operation(summary = "热门新闻", description = "根据浏览量获取热门新闻列表")
+ @GetMapping("/hot")
+ public List hotNews(
+ @Parameter(description = "返回数量,默认10") @RequestParam(required = false, defaultValue = "10") Integer limit
+ ) {
+ return hrtNewsService.hotNews(limit);
+ }
+
+ /**
+ * @description [置顶新闻]
+ * @author Leocoder
+ */
+ @Operation(summary = "置顶新闻", description = "获取所有置顶新闻列表")
+ @GetMapping("/top")
+ public List topNews() {
+ return hrtNewsService.topNews();
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/user/HrtUserController.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/user/HrtUserController.java
new file mode 100644
index 0000000..ad34861
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/controller/user/HrtUserController.java
@@ -0,0 +1,96 @@
+package org.leocoder.heritage.portal.controller.user;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+import org.leocoder.heritage.portal.service.user.HrtUserService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * @author Leocoder
+ * @description [前台用户中心控制器]
+ */
+@Tag(name = "前台用户中心", description = "个人资料管理、密码修改、用户统计、浏览历史等功能")
+@Validated
+@RequestMapping("/api/user")
+@RequiredArgsConstructor
+@RestController
+public class HrtUserController {
+
+ private final HrtUserService hrtUserService;
+
+ /**
+ * @description [获取个人资料]
+ * @author Leocoder
+ */
+ @Operation(summary = "获取个人资料", description = "获取当前登录用户的个人资料信息")
+ @GetMapping("/profile")
+ public UserProfileVo getProfile() {
+ return hrtUserService.getProfile();
+ }
+
+ /**
+ * @description [修改个人资料]
+ * @author Leocoder
+ */
+ @Operation(summary = "修改个人资料", description = "修改昵称、邮箱、手机号、性别、生日等信息")
+ @PutMapping("/profile")
+ public String updateProfile(@Validated @RequestBody UserProfileUpdateVo vo) {
+ return hrtUserService.updateProfile(vo);
+ }
+
+ /**
+ * @description [更新头像]
+ * @author Leocoder
+ */
+ @Operation(summary = "更新头像", description = "更新用户头像URL")
+ @PutMapping("/avatar")
+ public String updateAvatar(@Validated @RequestBody UserAvatarUpdateVo vo) {
+ return hrtUserService.updateAvatar(vo);
+ }
+
+ /**
+ * @description [上传并更新头像]
+ * @author Leocoder
+ */
+ @Operation(summary = "上传头像", description = "上传头像文件并自动更新用户头像,支持jpg/png/gif等格式,限制2MB以内")
+ @PostMapping("/avatar/upload")
+ public String uploadAvatar(@RequestParam("file") MultipartFile file) {
+ return hrtUserService.uploadAndUpdateAvatar(file);
+ }
+
+ /**
+ * @description [修改密码]
+ * @author Leocoder
+ */
+ @Operation(summary = "修改密码", description = "修改登录密码,需验证旧密码")
+ @PutMapping("/password")
+ public String updatePassword(@Validated @RequestBody UserPasswordUpdateVo vo) {
+ return hrtUserService.updatePassword(vo);
+ }
+
+ /**
+ * @description [获取用户统计信息]
+ * @author Leocoder
+ */
+ @Operation(summary = "用户统计", description = "获取用户的浏览、评论、点赞、收藏、报名等统计数据")
+ @GetMapping("/stats")
+ public UserStatsVo getUserStats() {
+ return hrtUserService.getUserStats();
+ }
+
+ /**
+ * @description [获取浏览历史]
+ * @author Leocoder
+ */
+ @Operation(summary = "浏览历史", description = "分页查询用户的浏览历史记录,支持按类型筛选")
+ @GetMapping("/viewHistory")
+ public IPage getViewHistory(@Validated ViewHistoryQueryVo vo) {
+ return hrtUserService.getViewHistory(vo);
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/auth/HrtAuthService.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/auth/HrtAuthService.java
new file mode 100644
index 0000000..1f59c9d
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/auth/HrtAuthService.java
@@ -0,0 +1,38 @@
+package org.leocoder.heritage.portal.service.auth;
+
+import org.leocoder.heritage.domain.model.vo.portal.HrtLoginResultVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtLoginVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtRegisterVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtUserInfoVo;
+
+/**
+ * @author Leocoder
+ * @description [前台用户认证服务接口]
+ */
+public interface HrtAuthService {
+
+ /**
+ * @description [用户注册]
+ * @author Leocoder
+ */
+ String register(HrtRegisterVo vo);
+
+ /**
+ * @description [用户登录]
+ * @author Leocoder
+ */
+ HrtLoginResultVo login(HrtLoginVo vo);
+
+ /**
+ * @description [用户登出]
+ * @author Leocoder
+ */
+ String logout();
+
+ /**
+ * @description [获取当前登录用户信息]
+ * @author Leocoder
+ */
+ HrtUserInfoVo getUserInfo();
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/auth/HrtAuthServiceImpl.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/auth/HrtAuthServiceImpl.java
new file mode 100644
index 0000000..ccbf705
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/auth/HrtAuthServiceImpl.java
@@ -0,0 +1,166 @@
+package org.leocoder.heritage.portal.service.auth;
+
+import cn.hutool.core.bean.BeanUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.leocoder.heritage.common.exception.coder.YUtil;
+import org.leocoder.heritage.common.utils.ip.IpUtil;
+import org.leocoder.heritage.domain.model.vo.portal.HrtLoginResultVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtLoginVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtRegisterVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtUserInfoVo;
+import org.leocoder.heritage.domain.pojo.portal.HrtUser;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtUserMapper;
+import org.leocoder.heritage.satoken.config.CoderSaTokenPasswordUtil;
+import org.leocoder.heritage.satoken.util.HrtStpUtil;
+import org.springframework.stereotype.Service;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author Leocoder
+ * @description [前台用户认证服务实现类]
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class HrtAuthServiceImpl implements HrtAuthService {
+
+ private final HrtUserMapper hrtUserMapper;
+
+ /**
+ * @description [用户注册]
+ * @author Leocoder
+ */
+ @Override
+ public String register(HrtRegisterVo vo) {
+ // 1、校验两次密码是否一致
+ YUtil.isTrue(!vo.getPassword().equals(vo.getConfirmPassword()), "两次密码输入不一致");
+
+ // 2、校验用户名是否已存在
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtUser::getUsername, vo.getUsername());
+ HrtUser existUser = hrtUserMapper.selectOne(queryWrapper);
+ YUtil.isTrue(existUser != null, "用户名已存在");
+
+ // 3、校验邮箱是否已存在(如果提供了邮箱)
+ if (StringUtils.isNotBlank(vo.getEmail())) {
+ queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtUser::getEmail, vo.getEmail());
+ existUser = hrtUserMapper.selectOne(queryWrapper);
+ YUtil.isTrue(existUser != null, "邮箱已被注册");
+ }
+
+ // 4、校验手机号是否已存在(如果提供了手机号)
+ if (StringUtils.isNotBlank(vo.getPhone())) {
+ queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtUser::getPhone, vo.getPhone());
+ existUser = hrtUserMapper.selectOne(queryWrapper);
+ YUtil.isTrue(existUser != null, "手机号已被注册");
+ }
+
+ // 5、创建新用户
+ HrtUser newUser = new HrtUser();
+ newUser.setUsername(vo.getUsername());
+ newUser.setPassword(CoderSaTokenPasswordUtil.encryptPassword(vo.getPassword()));
+ newUser.setNickname(vo.getNickname());
+ newUser.setEmail(vo.getEmail());
+ newUser.setPhone(vo.getPhone());
+ newUser.setStatus(1);
+ newUser.setGender(0);
+
+ // 6、保存用户
+ int result = hrtUserMapper.insert(newUser);
+ YUtil.isTrue(result <= 0, "注册失败");
+
+ log.info("前台用户注册成功:{}", vo.getUsername());
+ return "注册成功";
+ }
+
+ /**
+ * @description [用户登录]
+ * @author Leocoder
+ */
+ @Override
+ public HrtLoginResultVo login(HrtLoginVo vo) {
+ // 1、查询用户(支持用户名、邮箱、手机号登录)
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.and(wrapper -> wrapper
+ .eq(HrtUser::getUsername, vo.getAccount())
+ .or()
+ .eq(HrtUser::getEmail, vo.getAccount())
+ .or()
+ .eq(HrtUser::getPhone, vo.getAccount())
+ );
+ HrtUser user = hrtUserMapper.selectOne(queryWrapper);
+ YUtil.isTrue(user == null, "账号或密码错误");
+
+ // 2、校验用户状态
+ YUtil.isTrue(user.getStatus() == 0, "账号已被禁用,请联系管理员");
+
+ // 3、校验密码
+ boolean passwordMatch = CoderSaTokenPasswordUtil.matchesPassword(vo.getPassword(), user.getPassword());
+ YUtil.isTrue(!passwordMatch, "账号或密码错误");
+
+ // 4、执行登录
+ HrtStpUtil.login(user.getId());
+
+ // 5、设置记住我
+ if (vo.getRememberMe() != null && vo.getRememberMe()) {
+ HrtStpUtil.getStpLogic().getTokenSession().updateTimeout(60 * 60 * 24 * 7);
+ }
+
+ // 6、更新登录信息
+ ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ HttpServletRequest request = attributes != null ? attributes.getRequest() : null;
+ String loginIp = request != null ? IpUtil.getIpAddr(request) : "unknown";
+
+ HrtUser updateUser = new HrtUser();
+ updateUser.setId(user.getId());
+ updateUser.setLoginIp(loginIp);
+ updateUser.setLoginTime(LocalDateTime.now());
+ hrtUserMapper.updateById(updateUser);
+
+ // 7、构建返回数据
+ HrtLoginResultVo result = new HrtLoginResultVo();
+ result.setToken(HrtStpUtil.getTokenValue());
+ result.setTokenName(HrtStpUtil.getTokenName());
+ result.setTokenTimeout(HrtStpUtil.getStpLogic().getTokenTimeout());
+
+ HrtUserInfoVo userInfo = BeanUtil.copyProperties(user, HrtUserInfoVo.class);
+ result.setUserInfo(userInfo);
+
+ log.info("前台用户登录成功:{}", user.getUsername());
+ return result;
+ }
+
+ /**
+ * @description [用户登出]
+ * @author Leocoder
+ */
+ @Override
+ public String logout() {
+ HrtStpUtil.logout();
+ log.info("前台用户登出成功");
+ return "登出成功";
+ }
+
+ /**
+ * @description [获取当前登录用户信息]
+ * @author Leocoder
+ */
+ @Override
+ public HrtUserInfoVo getUserInfo() {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+ HrtUser user = hrtUserMapper.selectById(userId);
+ YUtil.isTrue(user == null, "用户不存在");
+
+ return BeanUtil.copyProperties(user, HrtUserInfoVo.class);
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/comment/HrtCommentService.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/comment/HrtCommentService.java
new file mode 100644
index 0000000..c6fc9dc
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/comment/HrtCommentService.java
@@ -0,0 +1,36 @@
+package org.leocoder.heritage.portal.service.comment;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+
+/**
+ * @author Leocoder
+ * @description [评论服务接口]
+ */
+public interface HrtCommentService {
+
+ /**
+ * @description [发表评论]
+ * @author Leocoder
+ */
+ String addComment(CommentAddVo vo);
+
+ /**
+ * @description [删除自己的评论]
+ * @author Leocoder
+ */
+ String deleteComment(Long commentId);
+
+ /**
+ * @description [查看评论列表(按目标ID和类型)]
+ * @author Leocoder
+ */
+ IPage listComments(CommentQueryVo vo);
+
+ /**
+ * @description [我的评论列表]
+ * @author Leocoder
+ */
+ IPage myCommentList(MyCommentQueryVo vo);
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/comment/HrtCommentServiceImpl.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/comment/HrtCommentServiceImpl.java
new file mode 100644
index 0000000..7a67ad3
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/comment/HrtCommentServiceImpl.java
@@ -0,0 +1,343 @@
+package org.leocoder.heritage.portal.service.comment;
+
+import cn.hutool.core.bean.BeanUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.common.exception.BusinessException;
+import org.leocoder.heritage.domain.enums.TargetTypeEnum;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+import org.leocoder.heritage.domain.pojo.portal.*;
+import org.leocoder.heritage.mybatisplus.mapper.portal.*;
+import org.leocoder.heritage.satoken.util.HrtStpUtil;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * @author Leocoder
+ * @description [评论服务实现类]
+ */
+@Service
+@RequiredArgsConstructor
+public class HrtCommentServiceImpl implements HrtCommentService {
+
+ private final HrtCommentMapper hrtCommentMapper;
+ private final HrtUserMapper hrtUserMapper;
+ private final HrtHeritageMapper hrtHeritageMapper;
+ private final HrtInheritorMapper hrtInheritorMapper;
+ private final HrtNewsMapper hrtNewsMapper;
+ private final HrtLikeMapper hrtLikeMapper;
+
+ /**
+ * @description [发表评论]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String addComment(CommentAddVo vo) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ if (!TargetTypeEnum.isValid(vo.getTargetType())) {
+ throw new BusinessException(400, "不支持的目标类型");
+ }
+
+ validateTargetExists(vo.getTargetType(), vo.getTargetId());
+
+ if (vo.getParentId() != null && vo.getParentId() > 0) {
+ HrtComment parentComment = hrtCommentMapper.selectById(vo.getParentId());
+ if (parentComment == null) {
+ throw new BusinessException(404, "父评论不存在");
+ }
+ if (!parentComment.getTargetType().equals(vo.getTargetType())
+ || !parentComment.getTargetId().equals(vo.getTargetId())) {
+ throw new BusinessException(400, "父评论与目标不匹配");
+ }
+ }
+
+ if (vo.getRating() != null && !"heritage".equals(vo.getTargetType())) {
+ throw new BusinessException(400, "只有非遗项目支持评分");
+ }
+
+ HrtComment comment = new HrtComment();
+ comment.setUserId(userId);
+ comment.setTargetType(vo.getTargetType());
+ comment.setTargetId(vo.getTargetId());
+ comment.setContent(vo.getContent());
+ comment.setRating(vo.getRating());
+ comment.setParentId(vo.getParentId() == null ? 0L : vo.getParentId());
+ comment.setLikeCount(0);
+ comment.setStatus(1);
+
+ int result = hrtCommentMapper.insert(comment);
+ if (result <= 0) {
+ throw new BusinessException(500, "发表评论失败");
+ }
+
+ return "评论成功";
+ }
+
+ /**
+ * @description [删除自己的评论]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String deleteComment(Long commentId) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ HrtComment comment = hrtCommentMapper.selectById(commentId);
+ if (comment == null) {
+ throw new BusinessException(404, "评论不存在");
+ }
+
+ if (!comment.getUserId().equals(userId)) {
+ throw new BusinessException(403, "无权删除他人评论");
+ }
+
+ int result = hrtCommentMapper.deleteById(commentId);
+ if (result <= 0) {
+ throw new BusinessException(500, "删除评论失败");
+ }
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtComment::getParentId, commentId);
+ hrtCommentMapper.delete(wrapper);
+
+ return "删除成功";
+ }
+
+ /**
+ * @description [查看评论列表(按目标ID和类型)]
+ * @author Leocoder
+ */
+ @Override
+ public IPage listComments(CommentQueryVo vo) {
+ Long currentUserId = null;
+ try {
+ currentUserId = HrtStpUtil.getLoginIdAsLong();
+ } catch (Exception e) {
+ }
+
+ Page page = new Page<>(vo.getPageNum(), vo.getPageSize());
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtComment::getTargetType, vo.getTargetType())
+ .eq(HrtComment::getTargetId, vo.getTargetId())
+ .eq(HrtComment::getParentId, 0L)
+ .eq(HrtComment::getStatus, 1);
+
+ if ("hottest".equals(vo.getSortType())) {
+ wrapper.orderByDesc(HrtComment::getLikeCount);
+ } else {
+ wrapper.orderByDesc(HrtComment::getCreateTime);
+ }
+
+ IPage commentPage = hrtCommentMapper.selectPage(page, wrapper);
+
+ List userIds = commentPage.getRecords().stream()
+ .map(HrtComment::getUserId)
+ .distinct()
+ .collect(Collectors.toList());
+
+ Map userMap = userIds.isEmpty() ? Map.of() :
+ hrtUserMapper.selectBatchIds(userIds).stream()
+ .collect(Collectors.toMap(HrtUser::getId, user -> user));
+
+ List commentIds = commentPage.getRecords().stream()
+ .map(HrtComment::getId)
+ .collect(Collectors.toList());
+
+ Map> replyMap = Map.of();
+ if (!commentIds.isEmpty()) {
+ LambdaQueryWrapper replyWrapper = new LambdaQueryWrapper<>();
+ replyWrapper.in(HrtComment::getParentId, commentIds)
+ .eq(HrtComment::getStatus, 1)
+ .orderByAsc(HrtComment::getCreateTime);
+ List replies = hrtCommentMapper.selectList(replyWrapper);
+
+ List replyUserIds = replies.stream()
+ .map(HrtComment::getUserId)
+ .distinct()
+ .filter(uid -> !userMap.containsKey(uid))
+ .collect(Collectors.toList());
+
+ if (!replyUserIds.isEmpty()) {
+ hrtUserMapper.selectBatchIds(replyUserIds).forEach(user ->
+ userMap.put(user.getId(), user)
+ );
+ }
+
+ replyMap = replies.stream()
+ .collect(Collectors.groupingBy(HrtComment::getParentId));
+ }
+
+ Map likeMap = Map.of();
+ if (currentUserId != null && !commentIds.isEmpty()) {
+ List allCommentIds = new ArrayList<>(commentIds);
+ replyMap.values().forEach(replyList ->
+ replyList.forEach(reply -> allCommentIds.add(reply.getId()))
+ );
+
+ LambdaQueryWrapper likeWrapper = new LambdaQueryWrapper<>();
+ likeWrapper.eq(HrtLike::getUserId, currentUserId)
+ .eq(HrtLike::getTargetType, "comment")
+ .in(HrtLike::getTargetId, allCommentIds);
+ likeMap = hrtLikeMapper.selectList(likeWrapper).stream()
+ .collect(Collectors.toMap(HrtLike::getTargetId, like -> true));
+ }
+
+ final Map finalLikeMap = likeMap;
+ final Map> finalReplyMap = replyMap;
+
+ Page resultPage = new Page<>(vo.getPageNum(), vo.getPageSize());
+ resultPage.setTotal(commentPage.getTotal());
+ resultPage.setRecords(commentPage.getRecords().stream()
+ .map(comment -> buildCommentItemVo(comment, userMap, finalReplyMap, finalLikeMap))
+ .collect(Collectors.toList())
+ );
+
+ return resultPage;
+ }
+
+ /**
+ * @description [我的评论列表]
+ * @author Leocoder
+ */
+ @Override
+ public IPage myCommentList(MyCommentQueryVo vo) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ Page page = new Page<>(vo.getPageNum(), vo.getPageSize());
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtComment::getUserId, userId);
+
+ if (vo.getTargetType() != null && !vo.getTargetType().isEmpty()) {
+ wrapper.eq(HrtComment::getTargetType, vo.getTargetType());
+ }
+
+ wrapper.orderByDesc(HrtComment::getCreateTime);
+
+ IPage commentPage = hrtCommentMapper.selectPage(page, wrapper);
+
+ HrtUser currentUser = hrtUserMapper.selectById(userId);
+
+ Map likeMap = Map.of();
+ if (!commentPage.getRecords().isEmpty()) {
+ List commentIds = commentPage.getRecords().stream()
+ .map(HrtComment::getId)
+ .collect(Collectors.toList());
+
+ LambdaQueryWrapper likeWrapper = new LambdaQueryWrapper<>();
+ likeWrapper.eq(HrtLike::getUserId, userId)
+ .eq(HrtLike::getTargetType, "comment")
+ .in(HrtLike::getTargetId, commentIds);
+ likeMap = hrtLikeMapper.selectList(likeWrapper).stream()
+ .collect(Collectors.toMap(HrtLike::getTargetId, like -> true));
+ }
+
+ final Map finalLikeMap = likeMap;
+
+ Page resultPage = new Page<>(vo.getPageNum(), vo.getPageSize());
+ resultPage.setTotal(commentPage.getTotal());
+ resultPage.setRecords(commentPage.getRecords().stream()
+ .map(comment -> {
+ CommentItemVo itemVo = BeanUtil.copyProperties(comment, CommentItemVo.class);
+ itemVo.setUserName(currentUser.getNickname());
+ itemVo.setUserAvatar(currentUser.getAvatar());
+ itemVo.setIsLiked(finalLikeMap.getOrDefault(comment.getId(), false));
+ itemVo.setReplyCount(0);
+ return itemVo;
+ })
+ .collect(Collectors.toList())
+ );
+
+ return resultPage;
+ }
+
+ /**
+ * @description [验证目标对象是否存在]
+ * @author Leocoder
+ */
+ private void validateTargetExists(String targetType, Long targetId) {
+ TargetTypeEnum typeEnum = TargetTypeEnum.fromCode(targetType);
+ if (typeEnum == null) {
+ throw new BusinessException(400, "无效的目标类型");
+ }
+
+ switch (typeEnum) {
+ case HERITAGE:
+ HrtHeritage heritage = hrtHeritageMapper.selectById(targetId);
+ if (heritage == null || heritage.getPublishStatus() != 1) {
+ throw new BusinessException(404, "非遗项目不存在或未发布");
+ }
+ break;
+ case INHERITOR:
+ HrtInheritor inheritor = hrtInheritorMapper.selectById(targetId);
+ if (inheritor == null || inheritor.getPublishStatus() != 1) {
+ throw new BusinessException(404, "传承人不存在或未发布");
+ }
+ break;
+ case NEWS:
+ HrtNews news = hrtNewsMapper.selectById(targetId);
+ if (news == null || news.getPublishStatus() != 1) {
+ throw new BusinessException(404, "新闻不存在或未发布");
+ }
+ break;
+ case COMMENT:
+ throw new BusinessException(400, "不支持对评论进行评论");
+ default:
+ throw new BusinessException(400, "不支持的目标类型");
+ }
+ }
+
+ /**
+ * @description [构建评论项VO]
+ * @author Leocoder
+ */
+ private CommentItemVo buildCommentItemVo(HrtComment comment,
+ Map userMap,
+ Map> replyMap,
+ Map likeMap) {
+ CommentItemVo itemVo = BeanUtil.copyProperties(comment, CommentItemVo.class);
+
+ HrtUser user = userMap.get(comment.getUserId());
+ if (user != null) {
+ itemVo.setUserName(user.getNickname());
+ itemVo.setUserAvatar(user.getAvatar());
+ }
+
+ itemVo.setIsLiked(likeMap.getOrDefault(comment.getId(), false));
+
+ List replies = replyMap.getOrDefault(comment.getId(), List.of());
+ itemVo.setReplyCount(replies.size());
+
+ if (!replies.isEmpty()) {
+ List replyVos = replies.stream()
+ .map(reply -> {
+ CommentItemVo replyVo = BeanUtil.copyProperties(reply, CommentItemVo.class);
+ HrtUser replyUser = userMap.get(reply.getUserId());
+ if (replyUser != null) {
+ replyVo.setUserName(replyUser.getNickname());
+ replyVo.setUserAvatar(replyUser.getAvatar());
+ }
+ replyVo.setIsLiked(likeMap.getOrDefault(reply.getId(), false));
+ replyVo.setReplyCount(0);
+ return replyVo;
+ })
+ .collect(Collectors.toList());
+ itemVo.setReplies(replyVos);
+ } else {
+ itemVo.setReplies(new ArrayList<>());
+ }
+
+ return itemVo;
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/event/HrtEventService.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/event/HrtEventService.java
new file mode 100644
index 0000000..9fd39ef
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/event/HrtEventService.java
@@ -0,0 +1,42 @@
+package org.leocoder.heritage.portal.service.event;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+
+/**
+ * @author Leocoder
+ * @description [活动管理服务接口]
+ */
+public interface HrtEventService {
+
+ /**
+ * @description [活动列表]
+ * @author Leocoder
+ */
+ IPage listEvents(EventQueryVo vo);
+
+ /**
+ * @description [活动详情]
+ * @author Leocoder
+ */
+ EventDetailVo getEventDetail(Long eventId);
+
+ /**
+ * @description [报名参加活动]
+ * @author Leocoder
+ */
+ String registerEvent(EventRegistrationVo vo);
+
+ /**
+ * @description [取消报名]
+ * @author Leocoder
+ */
+ String cancelRegistration(Long eventId);
+
+ /**
+ * @description [我报名的活动]
+ * @author Leocoder
+ */
+ IPage myRegistrations(MyEventRegistrationVo vo);
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/event/HrtEventServiceImpl.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/event/HrtEventServiceImpl.java
new file mode 100644
index 0000000..6393e48
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/event/HrtEventServiceImpl.java
@@ -0,0 +1,320 @@
+package org.leocoder.heritage.portal.service.event;
+
+import cn.hutool.core.bean.BeanUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.common.exception.BusinessException;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+import org.leocoder.heritage.domain.pojo.portal.HrtEvent;
+import org.leocoder.heritage.domain.pojo.portal.HrtEventRegistration;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtEventMapper;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtEventRegistrationMapper;
+import org.leocoder.heritage.portal.service.user.HrtUserService;
+import org.leocoder.heritage.satoken.util.HrtStpUtil;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * @author Leocoder
+ * @description [活动管理服务实现类]
+ */
+@Service
+@RequiredArgsConstructor
+public class HrtEventServiceImpl implements HrtEventService {
+
+ private final HrtEventMapper hrtEventMapper;
+ private final HrtEventRegistrationMapper hrtEventRegistrationMapper;
+ private final HrtUserService hrtUserService;
+
+ /**
+ * @description [活动列表]
+ * @author Leocoder
+ */
+ @Override
+ public IPage listEvents(EventQueryVo vo) {
+ Long currentUserId = null;
+ try {
+ currentUserId = HrtStpUtil.getLoginIdAsLong();
+ } catch (Exception e) {
+ }
+
+ Page page = new Page<>(vo.getPageNum(), vo.getPageSize());
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtEvent::getPublishStatus, 1);
+
+ if (vo.getStatus() != null && !vo.getStatus().isEmpty()) {
+ wrapper.eq(HrtEvent::getStatus, vo.getStatus());
+ }
+
+ if (vo.getKeyword() != null && !vo.getKeyword().isEmpty()) {
+ wrapper.and(w -> w.like(HrtEvent::getTitle, vo.getKeyword())
+ .or()
+ .like(HrtEvent::getSummary, vo.getKeyword())
+ .or()
+ .like(HrtEvent::getContent, vo.getKeyword()));
+ }
+
+ wrapper.orderByDesc(HrtEvent::getStartTime);
+
+ IPage eventPage = hrtEventMapper.selectPage(page, wrapper);
+
+ Map registrationMap = Map.of();
+ if (currentUserId != null && !eventPage.getRecords().isEmpty()) {
+ List eventIds = eventPage.getRecords().stream()
+ .map(HrtEvent::getId)
+ .collect(Collectors.toList());
+
+ LambdaQueryWrapper regWrapper = new LambdaQueryWrapper<>();
+ regWrapper.eq(HrtEventRegistration::getUserId, currentUserId)
+ .in(HrtEventRegistration::getEventId, eventIds)
+ .in(HrtEventRegistration::getStatus, List.of("pending", "approved"));
+
+ registrationMap = hrtEventRegistrationMapper.selectList(regWrapper).stream()
+ .collect(Collectors.toMap(HrtEventRegistration::getEventId, reg -> true));
+ }
+
+ final Map finalRegistrationMap = registrationMap;
+
+ Page resultPage = new Page<>(vo.getPageNum(), vo.getPageSize());
+ resultPage.setTotal(eventPage.getTotal());
+ resultPage.setRecords(eventPage.getRecords().stream()
+ .map(event -> {
+ EventListVo listVo = BeanUtil.copyProperties(event, EventListVo.class);
+ listVo.setIsRegistered(finalRegistrationMap.getOrDefault(event.getId(), false));
+ return listVo;
+ })
+ .collect(Collectors.toList())
+ );
+
+ return resultPage;
+ }
+
+ /**
+ * @description [活动详情]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public EventDetailVo getEventDetail(Long eventId) {
+ HrtEvent event = hrtEventMapper.selectById(eventId);
+ if (event == null || event.getPublishStatus() != 1) {
+ throw new BusinessException(404, "活动不存在或未发布");
+ }
+
+ hrtEventMapper.update(null,
+ new LambdaUpdateWrapper()
+ .setSql("view_count = view_count + 1")
+ .eq(HrtEvent::getId, eventId)
+ );
+
+ EventDetailVo detailVo = BeanUtil.copyProperties(event, EventDetailVo.class);
+ detailVo.setViewCount(event.getViewCount() + 1);
+
+ hrtUserService.recordViewHistory("event", eventId);
+
+ Long currentUserId = null;
+ try {
+ currentUserId = HrtStpUtil.getLoginIdAsLong();
+ } catch (Exception e) {
+ }
+
+ if (currentUserId != null) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtEventRegistration::getUserId, currentUserId)
+ .eq(HrtEventRegistration::getEventId, eventId)
+ .in(HrtEventRegistration::getStatus, List.of("pending", "approved"));
+ Long count = hrtEventRegistrationMapper.selectCount(wrapper);
+ detailVo.setIsRegistered(count > 0);
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ boolean canRegister = event.getStatus().equals("upcoming")
+ && event.getRegistrationStart() != null
+ && event.getRegistrationEnd() != null
+ && now.isAfter(event.getRegistrationStart())
+ && now.isBefore(event.getRegistrationEnd())
+ && (event.getMaxParticipants() == null || event.getCurrentParticipants() < event.getMaxParticipants())
+ && !detailVo.getIsRegistered();
+ detailVo.setCanRegister(canRegister);
+
+ return detailVo;
+ }
+
+ /**
+ * @description [报名参加活动]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String registerEvent(EventRegistrationVo vo) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ HrtEvent event = hrtEventMapper.selectById(vo.getEventId());
+ if (event == null || event.getPublishStatus() != 1) {
+ throw new BusinessException(404, "活动不存在或未发布");
+ }
+
+ if (!event.getStatus().equals("upcoming")) {
+ throw new BusinessException(400, "该活动不在报名阶段");
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ if (event.getRegistrationStart() == null || event.getRegistrationEnd() == null) {
+ throw new BusinessException(400, "活动报名时间未设置");
+ }
+
+ if (now.isBefore(event.getRegistrationStart())) {
+ throw new BusinessException(400, "报名尚未开始");
+ }
+
+ if (now.isAfter(event.getRegistrationEnd())) {
+ throw new BusinessException(400, "报名已结束");
+ }
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtEventRegistration::getUserId, userId)
+ .eq(HrtEventRegistration::getEventId, vo.getEventId())
+ .in(HrtEventRegistration::getStatus, List.of("pending", "approved"));
+ Long count = hrtEventRegistrationMapper.selectCount(wrapper);
+ if (count > 0) {
+ throw new BusinessException(400, "您已报名该活动");
+ }
+
+ if (event.getMaxParticipants() != null && event.getCurrentParticipants() >= event.getMaxParticipants()) {
+ throw new BusinessException(400, "活动报名人数已满");
+ }
+
+ HrtEventRegistration registration = new HrtEventRegistration();
+ registration.setUserId(userId);
+ registration.setEventId(vo.getEventId());
+ registration.setStatus("approved");
+ registration.setPhone(vo.getPhone());
+ registration.setRemark(vo.getRemark());
+
+ int result = hrtEventRegistrationMapper.insert(registration);
+ if (result <= 0) {
+ throw new BusinessException(500, "报名失败");
+ }
+
+ hrtEventMapper.update(null,
+ new LambdaUpdateWrapper()
+ .setSql("current_participants = current_participants + 1")
+ .eq(HrtEvent::getId, vo.getEventId())
+ );
+
+ return "报名成功";
+ }
+
+ /**
+ * @description [取消报名]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String cancelRegistration(Long eventId) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtEventRegistration::getUserId, userId)
+ .eq(HrtEventRegistration::getEventId, eventId)
+ .in(HrtEventRegistration::getStatus, List.of("pending", "approved"));
+ HrtEventRegistration registration = hrtEventRegistrationMapper.selectOne(wrapper);
+
+ if (registration == null) {
+ throw new BusinessException(400, "您未报名该活动");
+ }
+
+ HrtEvent event = hrtEventMapper.selectById(eventId);
+ if (event == null) {
+ throw new BusinessException(404, "活动不存在");
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ if (event.getStartTime() != null && now.isAfter(event.getStartTime())) {
+ throw new BusinessException(400, "活动已开始,无法取消报名");
+ }
+
+ registration.setStatus("cancelled");
+ int result = hrtEventRegistrationMapper.updateById(registration);
+ if (result <= 0) {
+ throw new BusinessException(500, "取消报名失败");
+ }
+
+ hrtEventMapper.update(null,
+ new LambdaUpdateWrapper()
+ .setSql("current_participants = GREATEST(current_participants - 1, 0)")
+ .eq(HrtEvent::getId, eventId)
+ );
+
+ return "取消报名成功";
+ }
+
+ /**
+ * @description [我报名的活动]
+ * @author Leocoder
+ */
+ @Override
+ public IPage myRegistrations(MyEventRegistrationVo vo) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ Page page = new Page<>(vo.getPageNum(), vo.getPageSize());
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtEventRegistration::getUserId, userId);
+
+ if (vo.getStatus() != null && !vo.getStatus().isEmpty()) {
+ wrapper.eq(HrtEventRegistration::getStatus, vo.getStatus());
+ }
+
+ wrapper.orderByDesc(HrtEventRegistration::getCreateTime);
+
+ IPage registrationPage = hrtEventRegistrationMapper.selectPage(page, wrapper);
+
+ List eventIds = registrationPage.getRecords().stream()
+ .map(HrtEventRegistration::getEventId)
+ .collect(Collectors.toList());
+
+ Map eventMap = eventIds.isEmpty() ? Map.of() :
+ hrtEventMapper.selectBatchIds(eventIds).stream()
+ .collect(Collectors.toMap(HrtEvent::getId, event -> event));
+
+ Page resultPage = new Page<>(vo.getPageNum(), vo.getPageSize());
+ resultPage.setTotal(registrationPage.getTotal());
+ resultPage.setRecords(registrationPage.getRecords().stream()
+ .map(registration -> {
+ MyEventRegistrationItemVo itemVo = new MyEventRegistrationItemVo();
+ itemVo.setId(registration.getId());
+ itemVo.setEventId(registration.getEventId());
+ itemVo.setRegistrationStatus(registration.getStatus());
+ itemVo.setPhone(registration.getPhone());
+ itemVo.setRemark(registration.getRemark());
+ itemVo.setCreateTime(registration.getCreateTime());
+
+ HrtEvent event = eventMap.get(registration.getEventId());
+ if (event != null) {
+ itemVo.setEventTitle(event.getTitle());
+ itemVo.setEventCoverImage(event.getCoverImage());
+ itemVo.setEventLocation(event.getLocation());
+ itemVo.setEventStartTime(event.getStartTime());
+ itemVo.setEventEndTime(event.getEndTime());
+ itemVo.setEventStatus(event.getStatus());
+ }
+
+ return itemVo;
+ })
+ .collect(Collectors.toList())
+ );
+
+ return resultPage;
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/favorite/HrtFavoriteService.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/favorite/HrtFavoriteService.java
new file mode 100644
index 0000000..7ccf05d
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/favorite/HrtFavoriteService.java
@@ -0,0 +1,38 @@
+package org.leocoder.heritage.portal.service.favorite;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.leocoder.heritage.domain.model.vo.portal.FavoriteItemVo;
+import org.leocoder.heritage.domain.model.vo.portal.FavoriteOperateVo;
+import org.leocoder.heritage.domain.model.vo.portal.FavoriteQueryVo;
+
+/**
+ * @author Leocoder
+ * @description [收藏服务接口]
+ */
+public interface HrtFavoriteService {
+
+ /**
+ * @description [添加收藏]
+ * @author Leocoder
+ */
+ String addFavorite(FavoriteOperateVo vo);
+
+ /**
+ * @description [取消收藏]
+ * @author Leocoder
+ */
+ String cancelFavorite(FavoriteOperateVo vo);
+
+ /**
+ * @description [我的收藏列表]
+ * @author Leocoder
+ */
+ IPage myFavoriteList(FavoriteQueryVo vo);
+
+ /**
+ * @description [检查是否已收藏]
+ * @author Leocoder
+ */
+ Boolean checkFavorite(String targetType, Long targetId);
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/favorite/HrtFavoriteServiceImpl.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/favorite/HrtFavoriteServiceImpl.java
new file mode 100644
index 0000000..2c2a082
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/favorite/HrtFavoriteServiceImpl.java
@@ -0,0 +1,306 @@
+package org.leocoder.heritage.portal.service.favorite;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.common.exception.BusinessException;
+import org.leocoder.heritage.domain.enums.TargetTypeEnum;
+import org.leocoder.heritage.domain.model.vo.portal.FavoriteItemVo;
+import org.leocoder.heritage.domain.model.vo.portal.FavoriteOperateVo;
+import org.leocoder.heritage.domain.model.vo.portal.FavoriteQueryVo;
+import org.leocoder.heritage.domain.pojo.portal.HrtFavorite;
+import org.leocoder.heritage.domain.pojo.portal.HrtHeritage;
+import org.leocoder.heritage.domain.pojo.portal.HrtInheritor;
+import org.leocoder.heritage.domain.pojo.portal.HrtNews;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtFavoriteMapper;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtHeritageMapper;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtInheritorMapper;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtNewsMapper;
+import org.leocoder.heritage.satoken.util.HrtStpUtil;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @author Leocoder
+ * @description [收藏服务实现类]
+ */
+@Service
+@RequiredArgsConstructor
+public class HrtFavoriteServiceImpl implements HrtFavoriteService {
+
+ private final HrtFavoriteMapper hrtFavoriteMapper;
+ private final HrtHeritageMapper hrtHeritageMapper;
+ private final HrtInheritorMapper hrtInheritorMapper;
+ private final HrtNewsMapper hrtNewsMapper;
+
+ /**
+ * @description [添加收藏]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String addFavorite(FavoriteOperateVo vo) {
+ // 1、验证目标类型是否支持收藏
+ if (!TargetTypeEnum.isFavoriteSupported(vo.getTargetType())) {
+ throw new BusinessException(400, "不支持的收藏类型");
+ }
+
+ // 2、获取当前登录用户ID
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ // 3、检查是否已收藏(查询有效记录)
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtFavorite::getUserId, userId)
+ .eq(HrtFavorite::getTargetType, vo.getTargetType())
+ .eq(HrtFavorite::getTargetId, vo.getTargetId());
+ HrtFavorite existingFavorite = hrtFavoriteMapper.selectOne(queryWrapper);
+ if (existingFavorite != null) {
+ throw new BusinessException(400, "您已经收藏过了");
+ }
+
+ // 4、检查是否存在逻辑删除的记录(使用自定义方法绕过@TableLogic过滤)
+ HrtFavorite deletedFavorite = hrtFavoriteMapper.selectDeletedOne(userId, vo.getTargetType(), vo.getTargetId());
+
+ if (deletedFavorite != null) {
+ // 5、恢复逻辑删除的记录(使用原生SQL绕过@TableLogic限制)
+ int result = hrtFavoriteMapper.restoreDeleted(deletedFavorite.getId());
+ if (result <= 0) {
+ throw new BusinessException(500, "收藏失败");
+ }
+ } else {
+ // 6、验证目标对象是否存在且已发布
+ validateTargetExists(vo.getTargetType(), vo.getTargetId());
+
+ // 7、插入新的收藏记录
+ HrtFavorite favorite = new HrtFavorite();
+ favorite.setUserId(userId);
+ favorite.setTargetType(vo.getTargetType());
+ favorite.setTargetId(vo.getTargetId());
+ int result = hrtFavoriteMapper.insert(favorite);
+ if (result <= 0) {
+ throw new BusinessException(500, "收藏失败");
+ }
+ }
+
+ // 8、更新目标对象的收藏计数 +1
+ updateFavoriteCount(vo.getTargetType(), vo.getTargetId(), 1);
+
+ return "收藏成功";
+ }
+
+ /**
+ * @description [取消收藏]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String cancelFavorite(FavoriteOperateVo vo) {
+ // 1、获取当前登录用户ID
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ // 2、查找收藏记录
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtFavorite::getUserId, userId)
+ .eq(HrtFavorite::getTargetType, vo.getTargetType())
+ .eq(HrtFavorite::getTargetId, vo.getTargetId());
+ HrtFavorite favorite = hrtFavoriteMapper.selectOne(queryWrapper);
+ if (favorite == null) {
+ throw new BusinessException(400, "您还未收藏该内容");
+ }
+
+ // 3、逻辑删除收藏记录
+ int result = hrtFavoriteMapper.deleteById(favorite.getId());
+ if (result <= 0) {
+ throw new BusinessException(500, "取消收藏失败");
+ }
+
+ // 4、更新目标对象的收藏计数 -1
+ updateFavoriteCount(vo.getTargetType(), vo.getTargetId(), -1);
+
+ return "取消收藏成功";
+ }
+
+ /**
+ * @description [我的收藏列表]
+ * @author Leocoder
+ */
+ @Override
+ public IPage myFavoriteList(FavoriteQueryVo vo) {
+ // 1、获取当前登录用户ID
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ // 2、分页查询收藏记录
+ Page page = new Page<>(vo.getPageNum(), vo.getPageSize());
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtFavorite::getUserId, userId);
+
+ // 按类型筛选(可选)
+ if (StrUtil.isNotBlank(vo.getTargetType())) {
+ if (!TargetTypeEnum.isFavoriteSupported(vo.getTargetType())) {
+ throw new BusinessException(400, "不支持的收藏类型");
+ }
+ queryWrapper.eq(HrtFavorite::getTargetType, vo.getTargetType());
+ }
+
+ // 按创建时间倒序
+ queryWrapper.orderByDesc(HrtFavorite::getCreateTime);
+
+ IPage favoritePageResult = hrtFavoriteMapper.selectPage(page, queryWrapper);
+
+ // 3、转换为VO并填充目标对象信息
+ Page resultPage = new Page<>(vo.getPageNum(), vo.getPageSize());
+ resultPage.setTotal(favoritePageResult.getTotal());
+ resultPage.setPages(favoritePageResult.getPages());
+
+ List itemList = new ArrayList<>();
+ for (HrtFavorite favorite : favoritePageResult.getRecords()) {
+ FavoriteItemVo itemVo = new FavoriteItemVo();
+ itemVo.setId(favorite.getId());
+ itemVo.setTargetType(favorite.getTargetType());
+ itemVo.setTargetId(favorite.getTargetId());
+ itemVo.setCreateTime(favorite.getCreateTime());
+
+ // 填充目标对象信息
+ fillTargetInfo(itemVo, favorite.getTargetType(), favorite.getTargetId());
+ itemList.add(itemVo);
+ }
+
+ resultPage.setRecords(itemList);
+ return resultPage;
+ }
+
+ /**
+ * @description [检查是否已收藏]
+ * @author Leocoder
+ */
+ @Override
+ public Boolean checkFavorite(String targetType, Long targetId) {
+ // 1、获取当前登录用户ID
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ // 2、查询收藏记录是否存在
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtFavorite::getUserId, userId)
+ .eq(HrtFavorite::getTargetType, targetType)
+ .eq(HrtFavorite::getTargetId, targetId);
+ Long count = hrtFavoriteMapper.selectCount(queryWrapper);
+
+ return count > 0;
+ }
+
+ /**
+ * @description [验证目标对象是否存在且已发布]
+ * @author Leocoder
+ */
+ private void validateTargetExists(String targetType, Long targetId) {
+ TargetTypeEnum typeEnum = TargetTypeEnum.fromCode(targetType);
+ if (typeEnum == null) {
+ throw new BusinessException(400, "无效的目标类型");
+ }
+
+ switch (typeEnum) {
+ case HERITAGE:
+ HrtHeritage heritage = hrtHeritageMapper.selectById(targetId);
+ if (heritage == null || heritage.getPublishStatus() != 1) {
+ throw new BusinessException(404, "非遗项目不存在或未发布");
+ }
+ break;
+ case INHERITOR:
+ HrtInheritor inheritor = hrtInheritorMapper.selectById(targetId);
+ if (inheritor == null || inheritor.getPublishStatus() != 1) {
+ throw new BusinessException(404, "传承人不存在或未发布");
+ }
+ break;
+ case NEWS:
+ HrtNews news = hrtNewsMapper.selectById(targetId);
+ if (news == null || news.getPublishStatus() != 1) {
+ throw new BusinessException(404, "新闻不存在或未发布");
+ }
+ break;
+ default:
+ throw new BusinessException(400, "不支持的收藏类型");
+ }
+ }
+
+ /**
+ * @description [更新收藏计数(原子操作)]
+ * @author Leocoder
+ */
+ private void updateFavoriteCount(String targetType, Long targetId, int delta) {
+ TargetTypeEnum typeEnum = TargetTypeEnum.fromCode(targetType);
+ if (typeEnum == null) {
+ return;
+ }
+
+ String sqlSet = delta > 0
+ ? "favorite_count = favorite_count + " + delta
+ : "favorite_count = GREATEST(favorite_count + (" + delta + "), 0)";
+
+ switch (typeEnum) {
+ case HERITAGE:
+ hrtHeritageMapper.update(null,
+ new LambdaUpdateWrapper()
+ .setSql(sqlSet)
+ .eq(HrtHeritage::getId, targetId)
+ );
+ break;
+ case INHERITOR:
+ // 传承人表没有收藏计数字段,跳过
+ break;
+ case NEWS:
+ // 新闻表没有收藏计数字段,跳过
+ break;
+ }
+ }
+
+ /**
+ * @description [填充目标对象信息]
+ * @author Leocoder
+ */
+ private void fillTargetInfo(FavoriteItemVo itemVo, String targetType, Long targetId) {
+ TargetTypeEnum typeEnum = TargetTypeEnum.fromCode(targetType);
+ if (typeEnum == null) {
+ return;
+ }
+
+ switch (typeEnum) {
+ case HERITAGE:
+ HrtHeritage heritage = hrtHeritageMapper.selectById(targetId);
+ if (heritage != null) {
+ itemVo.setTargetTitle(heritage.getName());
+ itemVo.setTargetCover(heritage.getCoverImage());
+ itemVo.setTargetDescription(heritage.getDescription());
+ itemVo.setTargetViewCount(heritage.getViewCount());
+ itemVo.setTargetLikeCount(heritage.getLikeCount());
+ }
+ break;
+ case INHERITOR:
+ HrtInheritor inheritor = hrtInheritorMapper.selectById(targetId);
+ if (inheritor != null) {
+ itemVo.setTargetTitle(inheritor.getName());
+ itemVo.setTargetCover(inheritor.getAvatar());
+ itemVo.setTargetDescription(inheritor.getIntroduction());
+ itemVo.setTargetViewCount(inheritor.getViewCount());
+ itemVo.setTargetLikeCount(inheritor.getLikeCount());
+ }
+ break;
+ case NEWS:
+ HrtNews news = hrtNewsMapper.selectById(targetId);
+ if (news != null) {
+ itemVo.setTargetTitle(news.getTitle());
+ itemVo.setTargetCover(news.getCoverImage());
+ itemVo.setTargetDescription(news.getSummary());
+ itemVo.setTargetViewCount(news.getViewCount());
+ itemVo.setTargetLikeCount(news.getLikeCount());
+ }
+ break;
+ }
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/heritage/HrtHeritageService.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/heritage/HrtHeritageService.java
new file mode 100644
index 0000000..e190b8d
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/heritage/HrtHeritageService.java
@@ -0,0 +1,40 @@
+package org.leocoder.heritage.portal.service.heritage;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.leocoder.heritage.domain.model.vo.portal.HrtHeritageDetailVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtHeritageListVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtHeritageQueryVo;
+
+import java.util.List;
+
+/**
+ * @author Leocoder
+ * @description [非遗项目服务接口]
+ */
+public interface HrtHeritageService {
+
+ /**
+ * @description [分页查询非遗项目列表]
+ * @author Leocoder
+ */
+ IPage listPage(HrtHeritageQueryVo vo);
+
+ /**
+ * @description [查看非遗项目详情]
+ * @author Leocoder
+ */
+ HrtHeritageDetailVo getDetail(Long id);
+
+ /**
+ * @description [热门非遗项目]
+ * @author Leocoder
+ */
+ List getHotList(Integer limit);
+
+ /**
+ * @description [精选非遗项目]
+ * @author Leocoder
+ */
+ List getFeaturedList(Integer limit);
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/heritage/HrtHeritageServiceImpl.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/heritage/HrtHeritageServiceImpl.java
new file mode 100644
index 0000000..0cced04
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/heritage/HrtHeritageServiceImpl.java
@@ -0,0 +1,194 @@
+package org.leocoder.heritage.portal.service.heritage;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.common.exception.BusinessException;
+import org.leocoder.heritage.domain.model.vo.portal.HrtHeritageDetailVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtHeritageListVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtHeritageQueryVo;
+import org.leocoder.heritage.domain.pojo.portal.HrtHeritage;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtHeritageMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @author Leocoder
+ * @description [非遗项目服务实现类]
+ */
+@Service
+@RequiredArgsConstructor
+public class HrtHeritageServiceImpl implements HrtHeritageService {
+
+ private final HrtHeritageMapper hrtHeritageMapper;
+ private final org.leocoder.heritage.portal.service.user.HrtUserService hrtUserService;
+
+ /**
+ * @description [分页查询非遗项目列表]
+ * @author Leocoder
+ */
+ @Override
+ public IPage listPage(HrtHeritageQueryVo vo) {
+ Page page = new Page<>(vo.getPageNum(), vo.getPageSize());
+
+ LambdaQueryWrapper wrapper = buildQueryWrapper(vo);
+
+ // 只查询已发布的项目
+ wrapper.eq(HrtHeritage::getPublishStatus, 1);
+
+ // 排序
+ applySorting(wrapper, vo.getSortField(), vo.getSortOrder());
+
+ IPage heritagePageResult = hrtHeritageMapper.selectPage(page, wrapper);
+
+ // 转换为VO
+ return heritagePageResult.convert(heritage -> BeanUtil.copyProperties(heritage, HrtHeritageListVo.class));
+ }
+
+ /**
+ * @description [查看非遗项目详情]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public HrtHeritageDetailVo getDetail(Long id) {
+ HrtHeritage heritage = hrtHeritageMapper.selectById(id);
+
+ if (heritage == null || heritage.getPublishStatus() != 1) {
+ throw new BusinessException(404, "非遗项目不存在或未发布");
+ }
+
+ // 浏览量+1
+ HrtHeritage updateHeritage = new HrtHeritage();
+ updateHeritage.setId(id);
+ updateHeritage.setViewCount(heritage.getViewCount() + 1);
+ hrtHeritageMapper.updateById(updateHeritage);
+
+ // 记录浏览历史
+ hrtUserService.recordViewHistory("heritage", id);
+
+ // 转换为详情VO
+ HrtHeritageDetailVo detailVo = BeanUtil.copyProperties(heritage, HrtHeritageDetailVo.class);
+ detailVo.setViewCount(heritage.getViewCount() + 1);
+
+ return detailVo;
+ }
+
+ /**
+ * @description [热门非遗项目]
+ * @author Leocoder
+ */
+ @Override
+ public List getHotList(Integer limit) {
+ // 默认查询10条
+ if (limit == null || limit <= 0) {
+ limit = 10;
+ }
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtHeritage::getPublishStatus, 1)
+ .orderByDesc(HrtHeritage::getViewCount, HrtHeritage::getLikeCount)
+ .last("LIMIT " + limit);
+
+ List heritageList = hrtHeritageMapper.selectList(wrapper);
+
+ return heritageList.stream()
+ .map(heritage -> BeanUtil.copyProperties(heritage, HrtHeritageListVo.class))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * @description [精选非遗项目]
+ * @author Leocoder
+ */
+ @Override
+ public List getFeaturedList(Integer limit) {
+ // 默认查询10条
+ if (limit == null || limit <= 0) {
+ limit = 10;
+ }
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtHeritage::getPublishStatus, 1)
+ .eq(HrtHeritage::getIsFeatured, 1)
+ .orderByDesc(HrtHeritage::getSortOrder, HrtHeritage::getCreateTime)
+ .last("LIMIT " + limit);
+
+ List heritageList = hrtHeritageMapper.selectList(wrapper);
+
+ return heritageList.stream()
+ .map(heritage -> BeanUtil.copyProperties(heritage, HrtHeritageListVo.class))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * @description [构建查询条件]
+ * @author Leocoder
+ */
+ private LambdaQueryWrapper buildQueryWrapper(HrtHeritageQueryVo vo) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+
+ // 关键词搜索(搜索名称、英文名称、描述)
+ if (StrUtil.isNotBlank(vo.getKeyword())) {
+ wrapper.and(w -> w
+ .like(HrtHeritage::getName, vo.getKeyword())
+ .or().like(HrtHeritage::getNameEn, vo.getKeyword())
+ .or().like(HrtHeritage::getDescription, vo.getKeyword())
+ );
+ }
+
+ // 名称模糊查询(兼容旧版本)
+ wrapper.like(StrUtil.isNotBlank(vo.getName()), HrtHeritage::getName, vo.getName());
+
+ // 分类
+ wrapper.eq(StrUtil.isNotBlank(vo.getCategory()), HrtHeritage::getCategory, vo.getCategory());
+
+ // 级别
+ wrapper.eq(StrUtil.isNotBlank(vo.getLevel()), HrtHeritage::getLevel, vo.getLevel());
+
+ // 省份
+ wrapper.eq(StrUtil.isNotBlank(vo.getProvince()), HrtHeritage::getProvince, vo.getProvince());
+
+ // 城市
+ wrapper.eq(StrUtil.isNotBlank(vo.getCity()), HrtHeritage::getCity, vo.getCity());
+
+ // 状态
+ wrapper.eq(StrUtil.isNotBlank(vo.getStatus()), HrtHeritage::getStatus, vo.getStatus());
+
+ // 标签模糊匹配
+ wrapper.like(StrUtil.isNotBlank(vo.getTag()), HrtHeritage::getTags, vo.getTag());
+
+ return wrapper;
+ }
+
+ /**
+ * @description [应用排序规则]
+ * @author Leocoder
+ */
+ private void applySorting(LambdaQueryWrapper wrapper, String sortField, String sortOrder) {
+ boolean isAsc = "asc".equalsIgnoreCase(sortOrder);
+
+ switch (sortField) {
+ case "view_count":
+ wrapper.orderBy(true, isAsc, HrtHeritage::getViewCount);
+ break;
+ case "like_count":
+ wrapper.orderBy(true, isAsc, HrtHeritage::getLikeCount);
+ break;
+ case "favorite_count":
+ wrapper.orderBy(true, isAsc, HrtHeritage::getFavoriteCount);
+ break;
+ case "create_time":
+ default:
+ wrapper.orderBy(true, isAsc, HrtHeritage::getCreateTime);
+ break;
+ }
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/inheritor/HrtInheritorService.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/inheritor/HrtInheritorService.java
new file mode 100644
index 0000000..439ac08
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/inheritor/HrtInheritorService.java
@@ -0,0 +1,40 @@
+package org.leocoder.heritage.portal.service.inheritor;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.leocoder.heritage.domain.model.vo.portal.HrtInheritorDetailVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtInheritorListVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtInheritorQueryVo;
+
+import java.util.List;
+
+/**
+ * @author Leocoder
+ * @description [传承人服务接口]
+ */
+public interface HrtInheritorService {
+
+ /**
+ * @description [分页查询传承人列表]
+ * @author Leocoder
+ */
+ IPage listPage(HrtInheritorQueryVo vo);
+
+ /**
+ * @description [查看传承人详情]
+ * @author Leocoder
+ */
+ HrtInheritorDetailVo getDetail(Long id);
+
+ /**
+ * @description [根据非遗项目查询传承人]
+ * @author Leocoder
+ */
+ List listByHeritageId(Long heritageId);
+
+ /**
+ * @description [精选传承人]
+ * @author Leocoder
+ */
+ List getFeaturedList(Integer limit);
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/inheritor/HrtInheritorServiceImpl.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/inheritor/HrtInheritorServiceImpl.java
new file mode 100644
index 0000000..a5fce24
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/inheritor/HrtInheritorServiceImpl.java
@@ -0,0 +1,185 @@
+package org.leocoder.heritage.portal.service.inheritor;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.common.exception.BusinessException;
+import org.leocoder.heritage.domain.model.vo.portal.HrtInheritorDetailVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtInheritorListVo;
+import org.leocoder.heritage.domain.model.vo.portal.HrtInheritorQueryVo;
+import org.leocoder.heritage.domain.pojo.portal.HrtInheritor;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtInheritorMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @author Leocoder
+ * @description [传承人服务实现类]
+ */
+@Service
+@RequiredArgsConstructor
+public class HrtInheritorServiceImpl implements HrtInheritorService {
+
+ private final HrtInheritorMapper hrtInheritorMapper;
+ private final org.leocoder.heritage.portal.service.user.HrtUserService hrtUserService;
+
+ /**
+ * @description [分页查询传承人列表]
+ * @author Leocoder
+ */
+ @Override
+ public IPage listPage(HrtInheritorQueryVo vo) {
+ Page page = new Page<>(vo.getPageNum(), vo.getPageSize());
+
+ LambdaQueryWrapper wrapper = buildQueryWrapper(vo);
+
+ // 只查询已发布的传承人
+ wrapper.eq(HrtInheritor::getPublishStatus, 1);
+
+ // 排序
+ applySorting(wrapper, vo.getSortField(), vo.getSortOrder());
+
+ IPage inheritorPageResult = hrtInheritorMapper.selectPage(page, wrapper);
+
+ // 转换为VO
+ return inheritorPageResult.convert(inheritor -> BeanUtil.copyProperties(inheritor, HrtInheritorListVo.class));
+ }
+
+ /**
+ * @description [查看传承人详情]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public HrtInheritorDetailVo getDetail(Long id) {
+ HrtInheritor inheritor = hrtInheritorMapper.selectById(id);
+
+ if (inheritor == null || inheritor.getPublishStatus() != 1) {
+ throw new BusinessException(404, "传承人不存在或未发布");
+ }
+
+ // 浏览量+1
+ HrtInheritor updateInheritor = new HrtInheritor();
+ updateInheritor.setId(id);
+ updateInheritor.setViewCount(inheritor.getViewCount() + 1);
+ hrtInheritorMapper.updateById(updateInheritor);
+
+ // 记录浏览历史
+ hrtUserService.recordViewHistory("inheritor", id);
+
+ // 转换为详情VO
+ HrtInheritorDetailVo detailVo = BeanUtil.copyProperties(inheritor, HrtInheritorDetailVo.class);
+ detailVo.setViewCount(inheritor.getViewCount() + 1);
+
+ return detailVo;
+ }
+
+ /**
+ * @description [根据非遗项目查询传承人]
+ * @author Leocoder
+ */
+ @Override
+ public List listByHeritageId(Long heritageId) {
+ if (heritageId == null) {
+ throw new BusinessException(400, "非遗项目ID不能为空");
+ }
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtInheritor::getHeritageId, heritageId)
+ .eq(HrtInheritor::getPublishStatus, 1)
+ .orderByDesc(HrtInheritor::getSortOrder, HrtInheritor::getCreateTime);
+
+ List inheritorList = hrtInheritorMapper.selectList(wrapper);
+
+ return inheritorList.stream()
+ .map(inheritor -> BeanUtil.copyProperties(inheritor, HrtInheritorListVo.class))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * @description [精选传承人]
+ * @author Leocoder
+ */
+ @Override
+ public List getFeaturedList(Integer limit) {
+ // 默认查询10条
+ if (limit == null || limit <= 0) {
+ limit = 10;
+ }
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtInheritor::getPublishStatus, 1)
+ .eq(HrtInheritor::getIsFeatured, 1)
+ .orderByDesc(HrtInheritor::getSortOrder, HrtInheritor::getCreateTime)
+ .last("LIMIT " + limit);
+
+ List inheritorList = hrtInheritorMapper.selectList(wrapper);
+
+ return inheritorList.stream()
+ .map(inheritor -> BeanUtil.copyProperties(inheritor, HrtInheritorListVo.class))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * @description [构建查询条件]
+ * @author Leocoder
+ */
+ private LambdaQueryWrapper buildQueryWrapper(HrtInheritorQueryVo vo) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+
+ // 关键词搜索(搜索姓名、英文名、简介、传承故事)
+ if (StrUtil.isNotBlank(vo.getKeyword())) {
+ wrapper.and(w -> w
+ .like(HrtInheritor::getName, vo.getKeyword())
+ .or().like(HrtInheritor::getNameEn, vo.getKeyword())
+ .or().like(HrtInheritor::getIntroduction, vo.getKeyword())
+ .or().like(HrtInheritor::getStory, vo.getKeyword())
+ );
+ }
+
+ // 姓名模糊查询(兼容旧版本)
+ wrapper.like(StrUtil.isNotBlank(vo.getName()), HrtInheritor::getName, vo.getName());
+
+ // 传承人级别
+ wrapper.eq(StrUtil.isNotBlank(vo.getLevel()), HrtInheritor::getLevel, vo.getLevel());
+
+ // 省份
+ wrapper.eq(StrUtil.isNotBlank(vo.getProvince()), HrtInheritor::getProvince, vo.getProvince());
+
+ // 城市
+ wrapper.eq(StrUtil.isNotBlank(vo.getCity()), HrtInheritor::getCity, vo.getCity());
+
+ // 关联非遗项目ID
+ wrapper.eq(vo.getHeritageId() != null, HrtInheritor::getHeritageId, vo.getHeritageId());
+
+ return wrapper;
+ }
+
+ /**
+ * @description [应用排序规则]
+ * @author Leocoder
+ */
+ private void applySorting(LambdaQueryWrapper wrapper, String sortField, String sortOrder) {
+ boolean isAsc = "asc".equalsIgnoreCase(sortOrder);
+
+ switch (sortField) {
+ case "view_count":
+ wrapper.orderBy(true, isAsc, HrtInheritor::getViewCount);
+ break;
+ case "like_count":
+ wrapper.orderBy(true, isAsc, HrtInheritor::getLikeCount);
+ break;
+ case "create_time":
+ default:
+ wrapper.orderBy(true, isAsc, HrtInheritor::getCreateTime);
+ break;
+ }
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/like/HrtLikeService.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/like/HrtLikeService.java
new file mode 100644
index 0000000..be7bd80
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/like/HrtLikeService.java
@@ -0,0 +1,29 @@
+package org.leocoder.heritage.portal.service.like;
+
+import org.leocoder.heritage.domain.model.vo.portal.LikeOperateVo;
+
+/**
+ * @author Leocoder
+ * @description [点赞服务接口]
+ */
+public interface HrtLikeService {
+
+ /**
+ * @description [点赞]
+ * @author Leocoder
+ */
+ String addLike(LikeOperateVo vo);
+
+ /**
+ * @description [取消点赞]
+ * @author Leocoder
+ */
+ String cancelLike(LikeOperateVo vo);
+
+ /**
+ * @description [检查是否已点赞]
+ * @author Leocoder
+ */
+ Boolean checkLike(String targetType, Long targetId);
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/like/HrtLikeServiceImpl.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/like/HrtLikeServiceImpl.java
new file mode 100644
index 0000000..b4ccb63
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/like/HrtLikeServiceImpl.java
@@ -0,0 +1,221 @@
+package org.leocoder.heritage.portal.service.like;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.common.exception.BusinessException;
+import org.leocoder.heritage.domain.enums.TargetTypeEnum;
+import org.leocoder.heritage.domain.model.vo.portal.LikeOperateVo;
+import org.leocoder.heritage.domain.pojo.portal.*;
+import org.leocoder.heritage.mybatisplus.mapper.portal.*;
+import org.leocoder.heritage.satoken.util.HrtStpUtil;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * @author Leocoder
+ * @description [点赞服务实现类]
+ */
+@Service
+@RequiredArgsConstructor
+public class HrtLikeServiceImpl implements HrtLikeService {
+
+ private final HrtLikeMapper hrtLikeMapper;
+ private final HrtHeritageMapper hrtHeritageMapper;
+ private final HrtInheritorMapper hrtInheritorMapper;
+ private final HrtNewsMapper hrtNewsMapper;
+ private final HrtCommentMapper hrtCommentMapper;
+
+ /**
+ * @description [点赞]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String addLike(LikeOperateVo vo) {
+ // 1、验证目标类型是否支持点赞
+ if (!TargetTypeEnum.isLikeSupported(vo.getTargetType())) {
+ throw new BusinessException(400, "不支持的点赞类型");
+ }
+
+ // 2、获取当前登录用户ID
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ // 3、检查是否已点赞(查询有效记录)
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtLike::getUserId, userId)
+ .eq(HrtLike::getTargetType, vo.getTargetType())
+ .eq(HrtLike::getTargetId, vo.getTargetId());
+ HrtLike existingLike = hrtLikeMapper.selectOne(queryWrapper);
+ if (existingLike != null) {
+ throw new BusinessException(400, "您已经点赞过了");
+ }
+
+ // 4、检查是否存在逻辑删除的记录(使用自定义方法绕过@TableLogic过滤)
+ HrtLike deletedLike = hrtLikeMapper.selectDeletedOne(userId, vo.getTargetType(), vo.getTargetId());
+
+ if (deletedLike != null) {
+ // 5、恢复逻辑删除的记录(使用原生SQL绕过@TableLogic限制)
+ int result = hrtLikeMapper.restoreDeleted(deletedLike.getId());
+ if (result <= 0) {
+ throw new BusinessException(500, "点赞失败");
+ }
+ } else {
+ // 6、验证目标对象是否存在
+ validateTargetExists(vo.getTargetType(), vo.getTargetId());
+
+ // 7、插入新的点赞记录
+ HrtLike like = new HrtLike();
+ like.setUserId(userId);
+ like.setTargetType(vo.getTargetType());
+ like.setTargetId(vo.getTargetId());
+ int result = hrtLikeMapper.insert(like);
+ if (result <= 0) {
+ throw new BusinessException(500, "点赞失败");
+ }
+ }
+
+ // 8、更新目标对象的点赞计数 +1
+ updateLikeCount(vo.getTargetType(), vo.getTargetId(), 1);
+
+ return "点赞成功";
+ }
+
+ /**
+ * @description [取消点赞]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String cancelLike(LikeOperateVo vo) {
+ // 1、获取当前登录用户ID
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ // 2、查找点赞记录
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtLike::getUserId, userId)
+ .eq(HrtLike::getTargetType, vo.getTargetType())
+ .eq(HrtLike::getTargetId, vo.getTargetId());
+ HrtLike like = hrtLikeMapper.selectOne(queryWrapper);
+ if (like == null) {
+ throw new BusinessException(400, "您还未点赞该内容");
+ }
+
+ // 3、逻辑删除点赞记录
+ int result = hrtLikeMapper.deleteById(like.getId());
+ if (result <= 0) {
+ throw new BusinessException(500, "取消点赞失败");
+ }
+
+ // 4、更新目标对象的点赞计数 -1
+ updateLikeCount(vo.getTargetType(), vo.getTargetId(), -1);
+
+ return "取消点赞成功";
+ }
+
+ /**
+ * @description [检查是否已点赞]
+ * @author Leocoder
+ */
+ @Override
+ public Boolean checkLike(String targetType, Long targetId) {
+ // 1、获取当前登录用户ID
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ // 2、查询点赞记录是否存在
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
+ queryWrapper.eq(HrtLike::getUserId, userId)
+ .eq(HrtLike::getTargetType, targetType)
+ .eq(HrtLike::getTargetId, targetId);
+ Long count = hrtLikeMapper.selectCount(queryWrapper);
+
+ return count > 0;
+ }
+
+ /**
+ * @description [验证目标对象是否存在]
+ * @author Leocoder
+ */
+ private void validateTargetExists(String targetType, Long targetId) {
+ TargetTypeEnum typeEnum = TargetTypeEnum.fromCode(targetType);
+ if (typeEnum == null) {
+ throw new BusinessException(400, "无效的目标类型");
+ }
+
+ switch (typeEnum) {
+ case HERITAGE:
+ HrtHeritage heritage = hrtHeritageMapper.selectById(targetId);
+ if (heritage == null || heritage.getPublishStatus() != 1) {
+ throw new BusinessException(404, "非遗项目不存在或未发布");
+ }
+ break;
+ case INHERITOR:
+ HrtInheritor inheritor = hrtInheritorMapper.selectById(targetId);
+ if (inheritor == null || inheritor.getPublishStatus() != 1) {
+ throw new BusinessException(404, "传承人不存在或未发布");
+ }
+ break;
+ case NEWS:
+ HrtNews news = hrtNewsMapper.selectById(targetId);
+ if (news == null || news.getPublishStatus() != 1) {
+ throw new BusinessException(404, "新闻不存在或未发布");
+ }
+ break;
+ case COMMENT:
+ HrtComment comment = hrtCommentMapper.selectById(targetId);
+ if (comment == null || comment.getStatus() != 1) {
+ throw new BusinessException(404, "评论不存在或未通过审核");
+ }
+ break;
+ default:
+ throw new BusinessException(400, "不支持的点赞类型");
+ }
+ }
+
+ /**
+ * @description [更新点赞计数(原子操作)]
+ * @author Leocoder
+ */
+ private void updateLikeCount(String targetType, Long targetId, int delta) {
+ TargetTypeEnum typeEnum = TargetTypeEnum.fromCode(targetType);
+ if (typeEnum == null) {
+ return;
+ }
+
+ String sqlSet = delta > 0
+ ? "like_count = like_count + " + delta
+ : "like_count = GREATEST(like_count + (" + delta + "), 0)";
+
+ switch (typeEnum) {
+ case HERITAGE:
+ hrtHeritageMapper.update(null,
+ new LambdaUpdateWrapper()
+ .setSql(sqlSet)
+ .eq(HrtHeritage::getId, targetId)
+ );
+ break;
+ case INHERITOR:
+ hrtInheritorMapper.update(null,
+ new LambdaUpdateWrapper()
+ .setSql(sqlSet)
+ .eq(HrtInheritor::getId, targetId)
+ );
+ break;
+ case NEWS:
+ hrtNewsMapper.update(null,
+ new LambdaUpdateWrapper()
+ .setSql(sqlSet)
+ .eq(HrtNews::getId, targetId)
+ );
+ break;
+ case COMMENT:
+ hrtCommentMapper.update(null,
+ new LambdaUpdateWrapper()
+ .setSql(sqlSet)
+ .eq(HrtComment::getId, targetId)
+ );
+ break;
+ }
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/news/HrtNewsService.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/news/HrtNewsService.java
new file mode 100644
index 0000000..7fcec66
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/news/HrtNewsService.java
@@ -0,0 +1,38 @@
+package org.leocoder.heritage.portal.service.news;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+
+import java.util.List;
+
+/**
+ * @author Leocoder
+ * @description [新闻资讯服务接口]
+ */
+public interface HrtNewsService {
+
+ /**
+ * @description [分页查询新闻列表]
+ * @author Leocoder
+ */
+ IPage listNews(NewsQueryVo vo);
+
+ /**
+ * @description [查看新闻详情]
+ * @author Leocoder
+ */
+ NewsDetailVo getNewsDetail(Long newsId);
+
+ /**
+ * @description [热门新闻]
+ * @author Leocoder
+ */
+ List hotNews(Integer limit);
+
+ /**
+ * @description [置顶新闻]
+ * @author Leocoder
+ */
+ List topNews();
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/news/HrtNewsServiceImpl.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/news/HrtNewsServiceImpl.java
new file mode 100644
index 0000000..3f7fb8b
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/news/HrtNewsServiceImpl.java
@@ -0,0 +1,166 @@
+package org.leocoder.heritage.portal.service.news;
+
+import cn.hutool.core.bean.BeanUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.leocoder.heritage.common.exception.BusinessException;
+import org.leocoder.heritage.domain.model.vo.portal.NewsDetailVo;
+import org.leocoder.heritage.domain.model.vo.portal.NewsListVo;
+import org.leocoder.heritage.domain.model.vo.portal.NewsQueryVo;
+import org.leocoder.heritage.domain.pojo.portal.HrtFavorite;
+import org.leocoder.heritage.domain.pojo.portal.HrtLike;
+import org.leocoder.heritage.domain.pojo.portal.HrtNews;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtFavoriteMapper;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtLikeMapper;
+import org.leocoder.heritage.mybatisplus.mapper.portal.HrtNewsMapper;
+import org.leocoder.heritage.portal.service.user.HrtUserService;
+import org.leocoder.heritage.satoken.util.HrtStpUtil;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @author Leocoder
+ * @description [新闻资讯服务实现类]
+ */
+@Service
+@RequiredArgsConstructor
+public class HrtNewsServiceImpl implements HrtNewsService {
+
+ private final HrtNewsMapper hrtNewsMapper;
+ private final HrtLikeMapper hrtLikeMapper;
+ private final HrtFavoriteMapper hrtFavoriteMapper;
+ private final HrtUserService hrtUserService;
+
+ /**
+ * @description [分页查询新闻列表]
+ * @author Leocoder
+ */
+ @Override
+ public IPage listNews(NewsQueryVo vo) {
+ Page page = new Page<>(vo.getPageNum(), vo.getPageSize());
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtNews::getPublishStatus, 1);
+
+ if (vo.getCategory() != null && !vo.getCategory().isEmpty()) {
+ wrapper.eq(HrtNews::getCategory, vo.getCategory());
+ }
+
+ if (vo.getKeyword() != null && !vo.getKeyword().isEmpty()) {
+ wrapper.and(w -> w.like(HrtNews::getTitle, vo.getKeyword())
+ .or()
+ .like(HrtNews::getSummary, vo.getKeyword())
+ .or()
+ .like(HrtNews::getContent, vo.getKeyword()));
+ }
+
+ wrapper.orderByDesc(HrtNews::getIsTop)
+ .orderByDesc(HrtNews::getPublishTime);
+
+ IPage newsPage = hrtNewsMapper.selectPage(page, wrapper);
+
+ Page resultPage = new Page<>(vo.getPageNum(), vo.getPageSize());
+ resultPage.setTotal(newsPage.getTotal());
+ resultPage.setRecords(newsPage.getRecords().stream()
+ .map(news -> BeanUtil.copyProperties(news, NewsListVo.class))
+ .collect(Collectors.toList())
+ );
+
+ return resultPage;
+ }
+
+ /**
+ * @description [查看新闻详情]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public NewsDetailVo getNewsDetail(Long newsId) {
+ HrtNews news = hrtNewsMapper.selectById(newsId);
+ if (news == null || news.getPublishStatus() != 1) {
+ throw new BusinessException(404, "新闻不存在或未发布");
+ }
+
+ hrtNewsMapper.update(null,
+ new LambdaUpdateWrapper()
+ .setSql("view_count = view_count + 1")
+ .eq(HrtNews::getId, newsId)
+ );
+
+ NewsDetailVo detailVo = BeanUtil.copyProperties(news, NewsDetailVo.class);
+ detailVo.setViewCount(news.getViewCount() + 1);
+
+ hrtUserService.recordViewHistory("news", newsId);
+
+ Long currentUserId = null;
+ try {
+ currentUserId = HrtStpUtil.getLoginIdAsLong();
+ } catch (Exception e) {
+ }
+
+ if (currentUserId != null) {
+ LambdaQueryWrapper likeWrapper = new LambdaQueryWrapper<>();
+ likeWrapper.eq(HrtLike::getUserId, currentUserId)
+ .eq(HrtLike::getTargetType, "news")
+ .eq(HrtLike::getTargetId, newsId);
+ Long likeCount = hrtLikeMapper.selectCount(likeWrapper);
+ detailVo.setIsLiked(likeCount > 0);
+
+ LambdaQueryWrapper favoriteWrapper = new LambdaQueryWrapper<>();
+ favoriteWrapper.eq(HrtFavorite::getUserId, currentUserId)
+ .eq(HrtFavorite::getTargetType, "news")
+ .eq(HrtFavorite::getTargetId, newsId);
+ Long favoriteCount = hrtFavoriteMapper.selectCount(favoriteWrapper);
+ detailVo.setIsFavorited(favoriteCount > 0);
+ }
+
+ return detailVo;
+ }
+
+ /**
+ * @description [热门新闻]
+ * @author Leocoder
+ */
+ @Override
+ public List hotNews(Integer limit) {
+ if (limit == null || limit <= 0) {
+ limit = 10;
+ }
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtNews::getPublishStatus, 1)
+ .orderByDesc(HrtNews::getViewCount)
+ .last("LIMIT " + limit);
+
+ List newsList = hrtNewsMapper.selectList(wrapper);
+
+ return newsList.stream()
+ .map(news -> BeanUtil.copyProperties(news, NewsListVo.class))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * @description [置顶新闻]
+ * @author Leocoder
+ */
+ @Override
+ public List topNews() {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtNews::getPublishStatus, 1)
+ .eq(HrtNews::getIsTop, 1)
+ .orderByDesc(HrtNews::getPublishTime);
+
+ List newsList = hrtNewsMapper.selectList(wrapper);
+
+ return newsList.stream()
+ .map(news -> BeanUtil.copyProperties(news, NewsListVo.class))
+ .collect(Collectors.toList());
+ }
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/user/HrtUserService.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/user/HrtUserService.java
new file mode 100644
index 0000000..7650e48
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/user/HrtUserService.java
@@ -0,0 +1,61 @@
+package org.leocoder.heritage.portal.service.user;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * @author Leocoder
+ * @description [用户中心服务接口]
+ */
+public interface HrtUserService {
+
+ /**
+ * @description [获取个人资料]
+ * @author Leocoder
+ */
+ UserProfileVo getProfile();
+
+ /**
+ * @description [修改个人资料]
+ * @author Leocoder
+ */
+ String updateProfile(UserProfileUpdateVo vo);
+
+ /**
+ * @description [更新头像]
+ * @author Leocoder
+ */
+ String updateAvatar(UserAvatarUpdateVo vo);
+
+ /**
+ * @description [上传并更新头像]
+ * @author Leocoder
+ */
+ String uploadAndUpdateAvatar(MultipartFile file);
+
+ /**
+ * @description [修改密码]
+ * @author Leocoder
+ */
+ String updatePassword(UserPasswordUpdateVo vo);
+
+ /**
+ * @description [获取用户统计信息]
+ * @author Leocoder
+ */
+ UserStatsVo getUserStats();
+
+ /**
+ * @description [获取浏览历史]
+ * @author Leocoder
+ */
+ IPage getViewHistory(ViewHistoryQueryVo vo);
+
+ /**
+ * @description [记录浏览历史]
+ * @author Leocoder
+ */
+ void recordViewHistory(String targetType, Long targetId);
+
+}
diff --git a/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/user/HrtUserServiceImpl.java b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/user/HrtUserServiceImpl.java
new file mode 100644
index 0000000..f0f202b
--- /dev/null
+++ b/heritage-modules/heritage-portal/src/main/java/org/leocoder/heritage/portal/service/user/HrtUserServiceImpl.java
@@ -0,0 +1,478 @@
+package org.leocoder.heritage.portal.service.user;
+
+import cn.hutool.core.bean.BeanUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.leocoder.heritage.common.exception.BusinessException;
+import org.leocoder.heritage.domain.enums.TargetTypeEnum;
+import org.leocoder.heritage.domain.model.vo.portal.*;
+import org.leocoder.heritage.domain.pojo.portal.*;
+import org.leocoder.heritage.mybatisplus.mapper.portal.*;
+import org.leocoder.heritage.oss.service.StorageService;
+import org.leocoder.heritage.oss.service.StorageServiceFactory;
+import org.leocoder.heritage.oss.utils.OssUtil;
+import org.leocoder.heritage.satoken.config.CoderSaTokenPasswordUtil;
+import org.leocoder.heritage.satoken.util.HrtStpUtil;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * @author Leocoder
+ * @description [用户中心服务实现类]
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HrtUserServiceImpl implements HrtUserService {
+
+ private final HrtUserMapper hrtUserMapper;
+ private final HrtViewHistoryMapper hrtViewHistoryMapper;
+ private final HrtCommentMapper hrtCommentMapper;
+ private final HrtLikeMapper hrtLikeMapper;
+ private final HrtFavoriteMapper hrtFavoriteMapper;
+ private final HrtEventRegistrationMapper hrtEventRegistrationMapper;
+ private final HrtHeritageMapper hrtHeritageMapper;
+ private final HrtInheritorMapper hrtInheritorMapper;
+ private final HrtNewsMapper hrtNewsMapper;
+ private final HrtEventMapper hrtEventMapper;
+ private final StorageServiceFactory storageServiceFactory;
+
+ @Value("${coder.storage.type:local}")
+ private String storageType;
+
+ // 允许的头像文件扩展名
+ private static final List ALLOWED_AVATAR_EXTENSIONS = Arrays.asList(
+ "jpg", "jpeg", "png", "gif", "webp"
+ );
+
+ // 头像文件大小限制:2MB
+ private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024;
+
+ /**
+ * @description [获取个人资料]
+ * @author Leocoder
+ */
+ @Override
+ public UserProfileVo getProfile() {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ HrtUser user = hrtUserMapper.selectById(userId);
+ if (user == null) {
+ throw new BusinessException(404, "用户不存在");
+ }
+
+ return BeanUtil.copyProperties(user, UserProfileVo.class);
+ }
+
+ /**
+ * @description [修改个人资料]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String updateProfile(UserProfileUpdateVo vo) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ HrtUser user = hrtUserMapper.selectById(userId);
+ if (user == null) {
+ throw new BusinessException(404, "用户不存在");
+ }
+
+ if (vo.getEmail() != null && !vo.getEmail().isEmpty()) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtUser::getEmail, vo.getEmail())
+ .ne(HrtUser::getId, userId);
+ Long count = hrtUserMapper.selectCount(wrapper);
+ if (count > 0) {
+ throw new BusinessException(400, "该邮箱已被使用");
+ }
+ }
+
+ if (vo.getPhone() != null && !vo.getPhone().isEmpty()) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtUser::getPhone, vo.getPhone())
+ .ne(HrtUser::getId, userId);
+ Long count = hrtUserMapper.selectCount(wrapper);
+ if (count > 0) {
+ throw new BusinessException(400, "该手机号已被使用");
+ }
+ }
+
+ BeanUtil.copyProperties(vo, user, "id", "username", "password", "status", "createTime", "updateTime", "delFlag");
+
+ int result = hrtUserMapper.updateById(user);
+ if (result <= 0) {
+ throw new BusinessException(500, "修改个人资料失败");
+ }
+
+ return "修改成功";
+ }
+
+ /**
+ * @description [更新头像]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String updateAvatar(UserAvatarUpdateVo vo) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ HrtUser user = hrtUserMapper.selectById(userId);
+ if (user == null) {
+ throw new BusinessException(404, "用户不存在");
+ }
+
+ user.setAvatar(vo.getAvatar());
+
+ int result = hrtUserMapper.updateById(user);
+ if (result <= 0) {
+ throw new BusinessException(500, "更新头像失败");
+ }
+
+ return "更新成功";
+ }
+
+ /**
+ * @description [上传并更新头像]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String uploadAndUpdateAvatar(MultipartFile file) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ // 验证用户是否存在
+ HrtUser user = hrtUserMapper.selectById(userId);
+ if (user == null) {
+ throw new BusinessException(404, "用户不存在");
+ }
+
+ // 文件验证
+ validateAvatarFile(file);
+
+ String avatarUrl;
+ try {
+ // 获取存储服务
+ StorageService storageService = storageServiceFactory.getStorageService(storageType);
+
+ // 生成唯一文件名
+ String fileName = OssUtil.generateUniqueFileName(file.getOriginalFilename());
+
+ // 构建文件夹路径
+ String folderPath = "avatars";
+
+ // 上传文件
+ Map fileMap = storageService.uploadFile(file, fileName, folderPath);
+
+ // 获取文件URL
+ avatarUrl = (String) fileMap.get("fileUploadPath");
+
+ log.info("用户头像上传成功: userId={}, avatarUrl={}", userId, avatarUrl);
+
+ } catch (Exception e) {
+ log.error("用户头像上传失败: userId={}", userId, e);
+ throw new BusinessException(500, "头像上传失败: " + e.getMessage());
+ }
+
+ // 更新用户头像
+ user.setAvatar(avatarUrl);
+ int result = hrtUserMapper.updateById(user);
+ if (result <= 0) {
+ throw new BusinessException(500, "更新头像失败");
+ }
+
+ return avatarUrl;
+ }
+
+ /**
+ * @description [验证头像文件]
+ * @author Leocoder
+ */
+ private void validateAvatarFile(MultipartFile file) {
+ // 检查文件是否为空
+ if (file == null || file.isEmpty()) {
+ throw new BusinessException(400, "请选择要上传的头像文件");
+ }
+
+ // 检查文件大小
+ long fileSize = file.getSize();
+ if (fileSize > MAX_AVATAR_SIZE) {
+ throw new BusinessException(413, "头像文件大小不能超过2MB");
+ }
+
+ // 获取文件扩展名
+ String originalFilename = file.getOriginalFilename();
+ if (StringUtils.isBlank(originalFilename)) {
+ throw new BusinessException(400, "文件名不能为空");
+ }
+
+ String fileExtension = getFileExtension(originalFilename).toLowerCase();
+ if (StringUtils.isBlank(fileExtension)) {
+ throw new BusinessException(400, "文件必须有扩展名");
+ }
+
+ // 检查文件格式
+ if (!ALLOWED_AVATAR_EXTENSIONS.contains(fileExtension)) {
+ throw new BusinessException(400, "不支持的头像格式,仅支持:" + String.join(", ", ALLOWED_AVATAR_EXTENSIONS));
+ }
+
+ log.info("头像文件验证通过:文件名={}, 大小={}bytes, 类型={}", originalFilename, fileSize, fileExtension);
+ }
+
+ /**
+ * @description [获取文件扩展名]
+ * @author Leocoder
+ */
+ private String getFileExtension(String filename) {
+ if (StringUtils.isBlank(filename)) {
+ return "";
+ }
+ int lastDotIndex = filename.lastIndexOf('.');
+ if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) {
+ return "";
+ }
+ return filename.substring(lastDotIndex + 1);
+ }
+
+ /**
+ * @description [修改密码]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String updatePassword(UserPasswordUpdateVo vo) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ HrtUser user = hrtUserMapper.selectById(userId);
+ if (user == null) {
+ throw new BusinessException(404, "用户不存在");
+ }
+
+ if (!vo.getNewPassword().equals(vo.getConfirmPassword())) {
+ throw new BusinessException(400, "两次输入的新密码不一致");
+ }
+
+ if (!CoderSaTokenPasswordUtil.matchesPassword(vo.getOldPassword(), user.getPassword())) {
+ throw new BusinessException(400, "旧密码不正确");
+ }
+
+ if (vo.getOldPassword().equals(vo.getNewPassword())) {
+ throw new BusinessException(400, "新密码不能与旧密码相同");
+ }
+
+ String encryptedPassword = CoderSaTokenPasswordUtil.encryptPassword(vo.getNewPassword());
+ user.setPassword(encryptedPassword);
+
+ int result = hrtUserMapper.updateById(user);
+ if (result <= 0) {
+ throw new BusinessException(500, "修改密码失败");
+ }
+
+ return "修改成功";
+ }
+
+ /**
+ * @description [获取用户统计信息]
+ * @author Leocoder
+ */
+ @Override
+ public UserStatsVo getUserStats() {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ UserStatsVo stats = new UserStatsVo();
+
+ LambdaQueryWrapper viewWrapper = new LambdaQueryWrapper<>();
+ viewWrapper.eq(HrtViewHistory::getUserId, userId);
+ stats.setViewHistoryCount(hrtViewHistoryMapper.selectCount(viewWrapper));
+
+ LambdaQueryWrapper commentWrapper = new LambdaQueryWrapper<>();
+ commentWrapper.eq(HrtComment::getUserId, userId);
+ stats.setCommentCount(hrtCommentMapper.selectCount(commentWrapper));
+
+ LambdaQueryWrapper likeWrapper = new LambdaQueryWrapper<>();
+ likeWrapper.eq(HrtLike::getUserId, userId);
+ stats.setLikeCount(hrtLikeMapper.selectCount(likeWrapper));
+
+ LambdaQueryWrapper favoriteWrapper = new LambdaQueryWrapper<>();
+ favoriteWrapper.eq(HrtFavorite::getUserId, userId);
+ stats.setFavoriteCount(hrtFavoriteMapper.selectCount(favoriteWrapper));
+
+ LambdaQueryWrapper registrationWrapper = new LambdaQueryWrapper<>();
+ registrationWrapper.eq(HrtEventRegistration::getUserId, userId);
+ stats.setEventRegistrationCount(hrtEventRegistrationMapper.selectCount(registrationWrapper));
+
+ return stats;
+ }
+
+ /**
+ * @description [获取浏览历史]
+ * @author Leocoder
+ */
+ @Override
+ public IPage getViewHistory(ViewHistoryQueryVo vo) {
+ Long userId = HrtStpUtil.getLoginIdAsLong();
+
+ Page page = new Page<>(vo.getPageNum(), vo.getPageSize());
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtViewHistory::getUserId, userId);
+
+ if (vo.getTargetType() != null && !vo.getTargetType().isEmpty()) {
+ wrapper.eq(HrtViewHistory::getTargetType, vo.getTargetType());
+ }
+
+ wrapper.orderByDesc(HrtViewHistory::getUpdateTime);
+
+ IPage historyPage = hrtViewHistoryMapper.selectPage(page, wrapper);
+
+ List heritageIds = historyPage.getRecords().stream()
+ .filter(h -> "heritage".equals(h.getTargetType()))
+ .map(HrtViewHistory::getTargetId)
+ .distinct()
+ .collect(Collectors.toList());
+
+ List inheritorIds = historyPage.getRecords().stream()
+ .filter(h -> "inheritor".equals(h.getTargetType()))
+ .map(HrtViewHistory::getTargetId)
+ .distinct()
+ .collect(Collectors.toList());
+
+ List newsIds = historyPage.getRecords().stream()
+ .filter(h -> "news".equals(h.getTargetType()))
+ .map(HrtViewHistory::getTargetId)
+ .distinct()
+ .collect(Collectors.toList());
+
+ List eventIds = historyPage.getRecords().stream()
+ .filter(h -> "event".equals(h.getTargetType()))
+ .map(HrtViewHistory::getTargetId)
+ .distinct()
+ .collect(Collectors.toList());
+
+ Map heritageMap = heritageIds.isEmpty() ? Map.of() :
+ hrtHeritageMapper.selectBatchIds(heritageIds).stream()
+ .collect(Collectors.toMap(HrtHeritage::getId, h -> h));
+
+ Map inheritorMap = inheritorIds.isEmpty() ? Map.of() :
+ hrtInheritorMapper.selectBatchIds(inheritorIds).stream()
+ .collect(Collectors.toMap(HrtInheritor::getId, i -> i));
+
+ Map newsMap = newsIds.isEmpty() ? Map.of() :
+ hrtNewsMapper.selectBatchIds(newsIds).stream()
+ .collect(Collectors.toMap(HrtNews::getId, n -> n));
+
+ Map eventMap = eventIds.isEmpty() ? Map.of() :
+ hrtEventMapper.selectBatchIds(eventIds).stream()
+ .collect(Collectors.toMap(HrtEvent::getId, e -> e));
+
+ Page resultPage = new Page<>(vo.getPageNum(), vo.getPageSize());
+ resultPage.setTotal(historyPage.getTotal());
+ resultPage.setRecords(historyPage.getRecords().stream()
+ .map(history -> buildViewHistoryItemVo(history, heritageMap, inheritorMap, newsMap, eventMap))
+ .collect(Collectors.toList())
+ );
+
+ return resultPage;
+ }
+
+ /**
+ * @description [记录浏览历史]
+ * @author Leocoder
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void recordViewHistory(String targetType, Long targetId) {
+ Long userId;
+ try {
+ userId = HrtStpUtil.getLoginIdAsLong();
+ } catch (Exception e) {
+ return;
+ }
+
+ if (!TargetTypeEnum.isValid(targetType)) {
+ return;
+ }
+
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(HrtViewHistory::getUserId, userId)
+ .eq(HrtViewHistory::getTargetType, targetType)
+ .eq(HrtViewHistory::getTargetId, targetId);
+
+ HrtViewHistory existingHistory = hrtViewHistoryMapper.selectOne(wrapper);
+
+ if (existingHistory != null) {
+ hrtViewHistoryMapper.updateById(existingHistory);
+ } else {
+ HrtViewHistory history = new HrtViewHistory();
+ history.setUserId(userId);
+ history.setTargetType(targetType);
+ history.setTargetId(targetId);
+ hrtViewHistoryMapper.insert(history);
+ }
+ }
+
+ /**
+ * @description [构建浏览历史项VO]
+ * @author Leocoder
+ */
+ private ViewHistoryItemVo buildViewHistoryItemVo(HrtViewHistory history,
+ Map heritageMap,
+ Map inheritorMap,
+ Map newsMap,
+ Map eventMap) {
+ ViewHistoryItemVo itemVo = new ViewHistoryItemVo();
+ itemVo.setId(history.getId());
+ itemVo.setTargetType(history.getTargetType());
+ itemVo.setTargetId(history.getTargetId());
+ itemVo.setViewTime(history.getUpdateTime());
+
+ switch (history.getTargetType()) {
+ case "heritage":
+ HrtHeritage heritage = heritageMap.get(history.getTargetId());
+ if (heritage != null) {
+ itemVo.setTargetTitle(heritage.getName());
+ itemVo.setTargetCover(heritage.getCoverImage());
+ itemVo.setTargetDescription(heritage.getDescription());
+ }
+ break;
+ case "inheritor":
+ HrtInheritor inheritor = inheritorMap.get(history.getTargetId());
+ if (inheritor != null) {
+ itemVo.setTargetTitle(inheritor.getName());
+ itemVo.setTargetCover(inheritor.getAvatar());
+ itemVo.setTargetDescription(inheritor.getIntroduction());
+ }
+ break;
+ case "news":
+ HrtNews news = newsMap.get(history.getTargetId());
+ if (news != null) {
+ itemVo.setTargetTitle(news.getTitle());
+ itemVo.setTargetCover(news.getCoverImage());
+ itemVo.setTargetDescription(news.getSummary());
+ }
+ break;
+ case "event":
+ HrtEvent event = eventMap.get(history.getTargetId());
+ if (event != null) {
+ itemVo.setTargetTitle(event.getTitle());
+ itemVo.setTargetCover(event.getCoverImage());
+ itemVo.setTargetDescription(event.getSummary());
+ }
+ break;
+ }
+
+ return itemVo;
+ }
+
+}