Просмотр исходного кода

feat(zhaozhaonews): 新增文章/用户/行为服务模块 - article(文章CRUD/搜索/评论/收藏/FreeMarker模板) - user(用户登录/注册/关注) - behavior(点赞/阅读/不喜欢/收藏行为)

WanJL 3 недель назад
Родитель
Сommit
294a592060
56 измененных файлов с 2678 добавлено и 0 удалено
  1. 65 0
      zhaozhaonews/zhaozhaonews-service/pom.xml
  2. 47 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/pom.xml
  3. 21 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/ArticleApplication.java
  4. 33 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/config/WebMvcConfig.java
  5. 52 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/ArticleController.java
  6. 35 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/CollectionController.java
  7. 64 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/CommentController.java
  8. 51 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/CommentRepayController.java
  9. 86 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/SearchController.java
  10. 24 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/feign/ArticleClient.java
  11. 47 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/interceptor/ApArticleThreadLocalUtil.java
  12. 53 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/interceptor/ApArticleTokenInterceptor.java
  13. 9 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApArticleConfigMapper.java
  14. 17 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApArticleContentMapper.java
  15. 32 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApArticleMapper.java
  16. 9 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApAssociateWordsMapper.java
  17. 9 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApCommentMapper.java
  18. 9 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApCommentRepayMapper.java
  19. 9 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApUserSearchMapper.java
  20. 30 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/service/ApArticleBehaviorService.java
  21. 38 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/service/ApArticleService.java
  22. 128 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/service/impl/ApArticleBehaviorServiceImpl.java
  23. 129 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/service/impl/ApArticleServiceImpl.java
  24. 12 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/bootstrap.yml
  25. 58 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/logback-spring.xml
  26. 52 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/mapper/ApArticleMapper.xml
  27. 182 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/templates/article.ftl
  28. 14 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/test.json
  29. 128 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/test/java/com/zhaozhaonews/article/test/ArticleFreeMarkerTest.java
  30. 28 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/pom.xml
  31. 26 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/BehaviorApplication.java
  32. 34 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/config/WebMvcConfig.java
  33. 62 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/controller/BehaviorController.java
  34. 47 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/interceptor/ApUserThreadLocalUtil.java
  35. 53 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/interceptor/BehaviorTokenInterceptor.java
  36. 43 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/service/BehaviorService.java
  37. 131 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/service/impl/BehaviorServiceImpl.java
  38. 12 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/resources/bootstrap.yml
  39. 58 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/resources/logback-spring.xml
  40. 20 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/pom.xml
  41. 25 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/UserApplication.java
  42. 34 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/config/WebMvcConfig.java
  43. 27 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/controller/v1/ApUserController.java
  44. 46 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/controller/v1/ApUserLoginController.java
  45. 35 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/controller/v1/ApUserRelationController.java
  46. 47 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/interceptor/ApUserThreadLocalUtil.java
  47. 53 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/interceptor/ApUserTokenInterceptor.java
  48. 18 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/mapper/ApUserMapper.java
  49. 20 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/service/ApUserRelationService.java
  50. 18 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/service/ApUserService.java
  51. 84 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/service/impl/ApUserRelationServiceImpl.java
  52. 151 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/service/impl/ApUserServiceImpl.java
  53. 38 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/util/VerificationCodeStore.java
  54. 12 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/resources/bootstrap.yml
  55. 95 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/resources/logback.xml
  56. 18 0
      zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/resources/mapper/ApUserMapper.xml

+ 65 - 0
zhaozhaonews/zhaozhaonews-service/pom.xml

@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.zhaozhaonews</groupId>
+        <artifactId>zhaozhaonews</artifactId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>zhaozhaonews-service</artifactId>
+    <packaging>pom</packaging>
+    <modules>
+        <module>zhaozhaonews-user</module>
+        <module>zhaozhaonews-admin</module>
+        <module>zhaozhaonews-article</module>
+        <module>zhaozhaonews-wemedia</module>
+        <module>zhaozhaonews-behavior</module>
+    </modules>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+    <dependencies>
+        <!-- 引入依赖模块 -->
+        <dependency>
+            <groupId>com.zhaozhaonews</groupId>
+            <artifactId>zhaozhaonews-model</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.zhaozhaonews</groupId>
+            <artifactId>zhaozhaonews-common</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.zhaozhaonews</groupId>
+            <artifactId>zhaozhaonews-feign-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.zhaozhaonews</groupId>
+            <artifactId>zhaozhaonews-util</artifactId>
+        </dependency>
+
+        <!-- Spring boot starter -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba.cloud</groupId>
+            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba.cloud</groupId>
+            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
+        </dependency>
+    </dependencies>
+</project>

+ 47 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/pom.xml

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.zhaozhaonews</groupId>
+        <artifactId>zhaozhaonews-service</artifactId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>zhaozhaonews-article</artifactId>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-freemarker</artifactId>
+        </dependency>
+
+        <!--引入我们自己封装的文件上传的starter-->
+        <dependency>
+            <groupId>com.zhaozhaonews</groupId>
+            <artifactId>zhaozhaonews-file-starter</artifactId>
+            <version>1.0-SNAPSHOT</version>
+        </dependency>
+
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <excludes>
+                        <exclude>**/ArticleFreeMarkerTest.java</exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 21 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/ArticleApplication.java

@@ -0,0 +1,21 @@
+package com.zhaozhaonews;
+
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
+
+/**
+ * @author WanJl
+ * @version 1.0
+ * @title AdminApplication
+ * @description
+ * @create 2025/2/13
+ */
+@SpringBootApplication
+@EnableDiscoveryClient
+public class ArticleApplication {
+    public static void main(String[] args) {
+        SpringApplication.run(ArticleApplication.class, args);
+    }
+}

+ 33 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/config/WebMvcConfig.java

@@ -0,0 +1,33 @@
+package com.zhaozhaonews.article.config;
+
+import com.zhaozhaonews.article.interceptor.ApArticleTokenInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * 文章模块 Web MVC 配置
+ *
+ * 注册 ApArticleTokenInterceptor 拦截器,拦截所有请求路径,
+ * 在请求处理前从请求头获取 userId 并存入 ThreadLocal。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private ApArticleTokenInterceptor apArticleTokenInterceptor;
+
+    /**
+     * 注册拦截器,拦截所有路径
+     */
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(apArticleTokenInterceptor)
+                .addPathPatterns("/**");
+    }
+}

+ 52 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/ArticleController.java

@@ -0,0 +1,52 @@
+package com.zhaozhaonews.article.controller;
+
+
+import com.zhaozhaonews.article.service.ApArticleService;
+import com.zhaozhaonews.article.service.ApArticleBehaviorService;
+import com.zhaozhaonews.common.constants.ArticleConstants;
+import com.zhaozhaonews.model.article.dto.ArticleHomeDto;
+import com.zhaozhaonews.model.article.dtos.ArticleInfoDto;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * @author WanJl
+ * @version 1.0
+ * @title ArticleController
+ * @description
+ * @create 2026/4/19
+ */
+@RestController
+@RequestMapping("/api/v1/article")
+public class ArticleController {
+    @Autowired
+    private ApArticleService articleService;
+
+    @Autowired
+    private ApArticleBehaviorService apArticleBehaviorService;
+
+    @PostMapping("/load")
+    public ResponseResult load(@RequestBody ArticleHomeDto dto){
+        return articleService.load(dto, ArticleConstants.LOAD_TYPE_LOAD);
+    }
+    @PostMapping("/loadmore")
+    public ResponseResult loadMore(@RequestBody ArticleHomeDto dto){
+        return articleService.load(dto, ArticleConstants.LOAD_TYPE_MORE);
+    }
+    @PostMapping("/loadnew")
+    public ResponseResult loadNew(@RequestBody ArticleHomeDto dto){
+        return articleService.load(dto, ArticleConstants.LOAD_TYPE_NEW);
+    }
+
+    @PostMapping("/load_article_behavior")
+    public ResponseResult loadArticleBehavior(@RequestBody ArticleInfoDto dto){
+        return apArticleBehaviorService.loadArticleBehavior(dto);
+    }
+
+    @GetMapping("/{id}")
+    public ResponseResult getArticleDetail(@PathVariable("id") Long id){
+        return articleService.loadArticleDetail(id);
+    }
+
+}

+ 35 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/CollectionController.java

@@ -0,0 +1,35 @@
+package com.zhaozhaonews.article.controller;
+
+import com.zhaozhaonews.article.service.ApArticleBehaviorService;
+import com.zhaozhaonews.model.behavior.dtos.CollectionBehaviorDto;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 收藏行为控制器
+ *
+ * 提供 APP 端文章收藏相关的 HTTP 接口。
+ * 用户可以对文章进行收藏和取消收藏操作,
+ * 行为数据存储在 Redis 中,以 Set 结构按用户维度组织。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@RestController
+public class CollectionController {
+
+    @Autowired
+    private ApArticleBehaviorService apArticleBehaviorService;
+
+    @PostMapping("/api/v1/collection_behavior")
+    public ResponseResult collection(@RequestBody CollectionBehaviorDto dto) {
+        return apArticleBehaviorService.collection(dto);
+    }
+
+    @GetMapping("/api/v1/collection/list")
+    public ResponseResult collectionList(@RequestParam(value = "userId", required = false) Long userId) {
+        return apArticleBehaviorService.collectionList(userId);
+    }
+}

+ 64 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/CommentController.java

@@ -0,0 +1,64 @@
+package com.zhaozhaonews.article.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zhaozhaonews.article.mapper.ApCommentMapper;
+import com.zhaozhaonews.article.mapper.ApCommentRepayMapper;
+import com.zhaozhaonews.model.comment.entity.ApComment;
+import com.zhaozhaonews.model.comment.entity.ApCommentRepay;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/comment")
+public class CommentController {
+
+    @Autowired
+    private ApCommentMapper apCommentMapper;
+
+    @Autowired
+    private ApCommentRepayMapper apCommentRepayMapper;
+
+    @PostMapping("/load")
+    public ResponseResult load(@RequestBody Map<String, Object> params) {
+        Object articleId = params.get("articleId");
+        LambdaQueryWrapper<ApComment> wrapper = new LambdaQueryWrapper<ApComment>()
+                .eq(articleId != null, ApComment::getArticleId, articleId)
+                .orderByDesc(ApComment::getCreatedTime);
+        List<ApComment> list = apCommentMapper.selectList(wrapper);
+        return ResponseResult.okResult(list);
+    }
+
+    @PostMapping("/save")
+    public ResponseResult save(@RequestBody ApComment comment) {
+        comment.setCreatedTime(new Date());
+        comment.setLikes(0);
+        apCommentMapper.insert(comment);
+        return ResponseResult.okResult(comment.getId());
+    }
+
+    @PostMapping("/like")
+    public ResponseResult like(@RequestBody Map<String, Object> params) {
+        Object commentId = params.get("commentId");
+        if (commentId != null) {
+            ApComment comment = apCommentMapper.selectById((Long) commentId);
+            if (comment != null) {
+                comment.setLikes(comment.getLikes() == null ? 1 : comment.getLikes() + 1);
+                apCommentMapper.updateById(comment);
+            }
+        }
+        return ResponseResult.okResult(null);
+    }
+
+    @GetMapping("/my")
+    public ResponseResult myComments(@RequestParam(value = "userId", required = false) Long userId) {
+        LambdaQueryWrapper<ApComment> wrapper = new LambdaQueryWrapper<ApComment>()
+                .eq(userId != null, ApComment::getUserId, userId)
+                .orderByDesc(ApComment::getCreatedTime);
+        return ResponseResult.okResult(apCommentMapper.selectList(wrapper));
+    }
+}

+ 51 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/CommentRepayController.java

@@ -0,0 +1,51 @@
+package com.zhaozhaonews.article.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zhaozhaonews.article.mapper.ApCommentRepayMapper;
+import com.zhaozhaonews.model.comment.entity.ApCommentRepay;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/comment_repay")
+public class CommentRepayController {
+
+    @Autowired
+    private ApCommentRepayMapper apCommentRepayMapper;
+
+    @PostMapping("/load")
+    public ResponseResult load(@RequestBody Map<String, Object> params) {
+        Object commentId = params.get("commentId");
+        LambdaQueryWrapper<ApCommentRepay> wrapper = new LambdaQueryWrapper<ApCommentRepay>()
+                .eq(commentId != null, ApCommentRepay::getCommentId, commentId)
+                .orderByDesc(ApCommentRepay::getCreatedTime);
+        List<ApCommentRepay> list = apCommentRepayMapper.selectList(wrapper);
+        return ResponseResult.okResult(list);
+    }
+
+    @PostMapping("/save")
+    public ResponseResult save(@RequestBody ApCommentRepay repay) {
+        repay.setCreatedTime(new Date());
+        repay.setLikes(0);
+        apCommentRepayMapper.insert(repay);
+        return ResponseResult.okResult(repay.getId());
+    }
+
+    @PostMapping("/like")
+    public ResponseResult like(@RequestBody Map<String, Object> params) {
+        Object repayId = params.get("commentRepayId");
+        if (repayId != null) {
+            ApCommentRepay repay = apCommentRepayMapper.selectById((Long) repayId);
+            if (repay != null) {
+                repay.setLikes(repay.getLikes() == null ? 1 : repay.getLikes() + 1);
+                apCommentRepayMapper.updateById(repay);
+            }
+        }
+        return ResponseResult.okResult(null);
+    }
+}

+ 86 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/controller/SearchController.java

@@ -0,0 +1,86 @@
+package com.zhaozhaonews.article.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zhaozhaonews.article.mapper.ApArticleMapper;
+import com.zhaozhaonews.article.mapper.ApAssociateWordsMapper;
+import com.zhaozhaonews.article.mapper.ApUserSearchMapper;
+import com.zhaozhaonews.model.article.entity.ApArticle;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.search.entity.ApAssociateWords;
+import com.zhaozhaonews.model.search.entity.ApUserSearch;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/v1")
+public class SearchController {
+
+    @Autowired
+    private ApUserSearchMapper apUserSearchMapper;
+
+    @Autowired
+    private ApAssociateWordsMapper apAssociateWordsMapper;
+
+    @Autowired
+    private ApArticleMapper apArticleMapper;
+
+    @PostMapping("/history/load")
+    public ResponseResult loadHistory() {
+        List<ApUserSearch> list = apUserSearchMapper.selectList(
+                new LambdaQueryWrapper<ApUserSearch>()
+                        .orderByDesc(ApUserSearch::getCreatedTime));
+        return ResponseResult.okResult(list);
+    }
+
+    @PostMapping("/history/del")
+    public ResponseResult delHistory(@RequestBody ApUserSearch search) {
+        if (search.getId() != null) {
+            apUserSearchMapper.deleteById(search.getId());
+        }
+        return ResponseResult.okResult(null);
+    }
+
+    @PostMapping("/history/clear")
+    public ResponseResult clearHistory() {
+        apUserSearchMapper.delete(null);
+        return ResponseResult.okResult(null);
+    }
+
+    @PostMapping("/associate/search")
+    public ResponseResult associateSearch(@RequestBody ApAssociateWords param) {
+        if (StringUtils.isBlank(param.getWord())) {
+            return ResponseResult.okResult(null);
+        }
+        List<ApAssociateWords> list = apAssociateWordsMapper.selectList(
+                new LambdaQueryWrapper<ApAssociateWords>()
+                        .like(ApAssociateWords::getWord, param.getWord()));
+        return ResponseResult.okResult(list);
+    }
+
+    @PostMapping("/hot_keywords/load")
+    public ResponseResult loadHotKeywords() {
+        List<ApAssociateWords> list = apAssociateWordsMapper.selectList(
+                new LambdaQueryWrapper<ApAssociateWords>()
+                        .orderByDesc(ApAssociateWords::getId));
+        return ResponseResult.okResult(list);
+    }
+
+    @PostMapping("/article/search/search")
+    public ResponseResult searchArticle(@RequestBody com.zhaozhaonews.model.article.dto.ArticleHomeDto dto) {
+        if (StringUtils.isBlank(dto.getTag())) {
+            return ResponseResult.okResult(null);
+        }
+        Page<ApArticle> page = new Page<>(1, 20);
+        LambdaQueryWrapper<ApArticle> wrapper = new LambdaQueryWrapper<ApArticle>()
+                .like(ApArticle::getTitle, dto.getTag())
+                .orderByDesc(ApArticle::getPublishTime);
+        apArticleMapper.selectPage(page, wrapper);
+        return ResponseResult.okResult(page.getRecords());
+    }
+}

+ 24 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/feign/ArticleClient.java

@@ -0,0 +1,24 @@
+package com.zhaozhaonews.article.feign;
+
+
+import com.zhaozhaonews.apis.article.IArticleClient;
+import com.zhaozhaonews.article.service.ApArticleService;
+import com.zhaozhaonews.model.article.dtos.ArticleDto;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class ArticleClient implements IArticleClient {
+
+    @Autowired
+    private ApArticleService apArticleService;
+
+    @Override
+    @PostMapping("/api/v1/article/save")
+    public ResponseResult saveArticle(@RequestBody ArticleDto dto) {
+        return apArticleService.saveArticle(dto);
+    }
+}

+ 47 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/interceptor/ApArticleThreadLocalUtil.java

@@ -0,0 +1,47 @@
+package com.zhaozhaonews.article.interceptor;
+
+/**
+ * 文章模块用户 ID 线程上下文工具类
+ *
+ * 基于 ThreadLocal 在请求链路中传递当前登录用户的 ID,
+ * 避免在 Service 层方法调用链中频繁传递 userId 参数。
+ *
+ * 在 ApArticleTokenInterceptor 的 preHandle 中从请求头获取 userId 并设置,
+ * 在 afterCompletion 中清理,防止内存泄漏。
+ *
+ * @see ApArticleTokenInterceptor
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+public class ApArticleThreadLocalUtil {
+
+    private static final ThreadLocal<Long> USER_ID_THREAD_LOCAL = new ThreadLocal<>();
+
+    /**
+     * 设置当前线程的用户 ID
+     *
+     * @param userId 用户 ID
+     */
+    public static void setUserId(Long userId) {
+        USER_ID_THREAD_LOCAL.set(userId);
+    }
+
+    /**
+     * 获取当前线程的用户 ID
+     *
+     * @return 用户 ID,未设置时返回 null
+     */
+    public static Long getUserId() {
+        return USER_ID_THREAD_LOCAL.get();
+    }
+
+    /**
+     * 清理当前线程的用户 ID
+     *
+     * 必须在请求处理完成后调用,防止 ThreadLocal 内存泄漏。
+     */
+    public static void clear() {
+        USER_ID_THREAD_LOCAL.remove();
+    }
+}

+ 53 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/interceptor/ApArticleTokenInterceptor.java

@@ -0,0 +1,53 @@
+package com.zhaozhaonews.article.interceptor;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 文章模块 Token 拦截器
+ *
+ * 从 HTTP 请求头中提取 userId,存入 ThreadLocal 供后续业务逻辑使用。
+ * 请求处理完成后在 afterCompletion 中清理 ThreadLocal,防止内存泄漏。
+ *
+ * 由于文章微服务部署在内部网络中,外层网关已经完成 JWT 鉴权,
+ * 因此本拦截器直接从请求头获取已解析的用户 ID,无需再次解析 Token。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@Slf4j
+@Component
+public class ApArticleTokenInterceptor implements HandlerInterceptor {
+
+    /**
+     * 请求处理前从请求头获取 userId 并存入 ThreadLocal
+     *
+     * @param request  HTTP 请求
+     * @param response HTTP 响应
+     * @param handler  处理器对象
+     * @return 始终返回 true,继续执行后续处理
+     */
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        String userId = request.getHeader("userId");
+        if (userId != null && !"".equals(userId)) {
+            ApArticleThreadLocalUtil.setUserId(Long.valueOf(userId));
+            log.info("ApArticleTokenInterceptor设置用户信息到threadlocal,userId:{}", userId);
+        }
+        return true;
+    }
+
+    /**
+     * 请求处理完成后清理 ThreadLocal
+     */
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        log.info("ApArticleTokenInterceptor清理threadlocal...");
+        ApArticleThreadLocalUtil.clear();
+    }
+}

+ 9 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApArticleConfigMapper.java

@@ -0,0 +1,9 @@
+package com.zhaozhaonews.article.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zhaozhaonews.model.article.entity.ApArticleConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ApArticleConfigMapper extends BaseMapper<ApArticleConfig> {
+}

+ 17 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApArticleContentMapper.java

@@ -0,0 +1,17 @@
+package com.zhaozhaonews.article.mapper;
+
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zhaozhaonews.model.article.entity.ApArticleContent;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * @author WanJl
+ * @version 1.0
+ * @title ApArticleContentMapper
+ * @description 新闻详情mapper
+ * @create 2026/4/26
+ */
+@Mapper
+public interface ApArticleContentMapper extends BaseMapper<ApArticleContent> {
+}

+ 32 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApArticleMapper.java

@@ -0,0 +1,32 @@
+package com.zhaozhaonews.article.mapper;
+
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zhaozhaonews.model.article.dto.ArticleHomeDto;
+import com.zhaozhaonews.model.article.entity.ApArticle;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * @author WanJl
+ * @version 1.0
+ * @title ApArticleMapper
+ * @description
+ * @create 2026/4/19
+ */
+@Mapper
+public interface ApArticleMapper extends BaseMapper<ApArticle> {
+    //初始加载
+
+    /**
+     * 根据文章id查询文章
+     * @param dto 文章条件
+     * @param type 查询类型  1-更多 2-最新
+     * @return
+     */
+    List<ApArticle> loadArticleList(@Param("dto") ArticleHomeDto dto,
+                                    @Param("type") Integer type);
+
+}

+ 9 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApAssociateWordsMapper.java

@@ -0,0 +1,9 @@
+package com.zhaozhaonews.article.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zhaozhaonews.model.search.entity.ApAssociateWords;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ApAssociateWordsMapper extends BaseMapper<ApAssociateWords> {
+}

+ 9 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApCommentMapper.java

@@ -0,0 +1,9 @@
+package com.zhaozhaonews.article.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zhaozhaonews.model.comment.entity.ApComment;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ApCommentMapper extends BaseMapper<ApComment> {
+}

+ 9 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApCommentRepayMapper.java

@@ -0,0 +1,9 @@
+package com.zhaozhaonews.article.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zhaozhaonews.model.comment.entity.ApCommentRepay;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ApCommentRepayMapper extends BaseMapper<ApCommentRepay> {
+}

+ 9 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/mapper/ApUserSearchMapper.java

@@ -0,0 +1,9 @@
+package com.zhaozhaonews.article.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zhaozhaonews.model.search.entity.ApUserSearch;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ApUserSearchMapper extends BaseMapper<ApUserSearch> {
+}

+ 30 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/service/ApArticleBehaviorService.java

@@ -0,0 +1,30 @@
+package com.zhaozhaonews.article.service;
+
+import com.zhaozhaonews.model.article.dtos.ArticleInfoDto;
+import com.zhaozhaonews.model.behavior.dtos.CollectionBehaviorDto;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+
+/**
+ * 文章行为服务接口
+ *
+ * 定义用户对文章的行为操作业务方法,包括收藏文章和查询文章行为状态。
+ * 行为数据存储在 Redis 中,以 Set 结构按用户或文章维度组织。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+public interface ApArticleBehaviorService {
+
+    /**
+     * 处理文章收藏/取消收藏行为
+     *
+     * @param dto 收藏行为 DTO,包含文章ID和操作类型(0-收藏,1-取消收藏)
+     * @return 操作结果
+     */
+    ResponseResult collection(CollectionBehaviorDto dto);
+
+    ResponseResult loadArticleBehavior(ArticleInfoDto dto);
+
+    ResponseResult collectionList(Long userId);
+}

+ 38 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/service/ApArticleService.java

@@ -0,0 +1,38 @@
+package com.zhaozhaonews.article.service;
+
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.zhaozhaonews.model.article.dto.ArticleHomeDto;
+import com.zhaozhaonews.model.article.dtos.ArticleDto;
+import com.zhaozhaonews.model.article.entity.ApArticle;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+
+/**
+ * @author WanJl
+ * @version 1.0
+ * @title ApArticleService
+ * @description
+ * @create 2026/4/19
+ */
+public interface ApArticleService extends IService<ApArticle> {
+    /**
+     * 根据参数加载文章列表
+     * @param dto
+     * @param type 1:加载更多新闻 2:下拉刷新最新新闻
+     * @return
+     */
+    ResponseResult load(ArticleHomeDto dto,Integer type);
+    /**
+     * 保存APP端相关新闻
+     * @param dto
+     * @return
+     */
+    ResponseResult saveArticle(ArticleDto dto);
+
+    /**
+     * 根据ID加载文章详情(含正文内容)
+     * @param id 文章ID
+     * @return
+     */
+    ResponseResult loadArticleDetail(Long id);
+}

+ 128 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/service/impl/ApArticleBehaviorServiceImpl.java

@@ -0,0 +1,128 @@
+package com.zhaozhaonews.article.service.impl;
+
+import com.zhaozhaonews.article.interceptor.ApArticleThreadLocalUtil;
+import com.zhaozhaonews.article.service.ApArticleBehaviorService;
+import com.zhaozhaonews.model.article.dtos.ArticleBehaviorVo;
+import com.zhaozhaonews.model.article.dtos.ArticleInfoDto;
+import com.zhaozhaonews.model.behavior.dtos.CollectionBehaviorDto;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.common.enums.AppHttpCodeEnum;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 文章行为服务实现类
+ *
+ * 基于 Redis 的 Set 数据结构存储用户对文章的行为数据。
+ * 点赞、不喜欢、收藏、关注分别使用不同的键前缀进行隔离。
+ * 从 ApArticleThreadLocalUtil 中获取当前登录用户 ID。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@Service
+@Slf4j
+public class ApArticleBehaviorServiceImpl implements ApArticleBehaviorService {
+
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
+    /** Redis 点赞行为键前缀,结构:behavior:likes:{文章ID},值为 Set<用户ID> */
+    private static final String LIKES_KEY = "behavior:likes:";
+
+    /** Redis 不喜欢行为键前缀,结构:behavior:unlike:{文章ID} */
+    private static final String UNLIKE_KEY = "behavior:unlike:";
+
+    /** Redis 收藏行为键前缀,结构:behavior:collection:{用户ID},值为 Set<文章ID> */
+    private static final String COLLECTION_KEY = "behavior:collection:";
+
+    /** Redis 关注行为键前缀,结构:behavior:follow:{用户ID},值为 Set<作者ID> */
+    private static final String FOLLOW_KEY = "behavior:follow:";
+
+    /**
+     * 处理文章收藏或取消收藏
+     *
+     * operation=0 时收藏,将文章 ID 加入用户的收藏 Set;
+     * operation=1 时取消收藏,从用户的收藏 Set 中移除。
+     *
+     * @param dto 收藏行为 DTO
+     * @return 操作结果
+     */
+    @Override
+    public ResponseResult collection(CollectionBehaviorDto dto) {
+        if (dto.getEntryId() == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID, "文章ID不能为空");
+        }
+
+        Long userId = ApArticleThreadLocalUtil.getUserId();
+        if (userId == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
+        }
+
+        String key = COLLECTION_KEY + userId;
+        if (dto.getOperation() == 0) {
+            redisTemplate.opsForSet().add(key, String.valueOf(dto.getEntryId()));
+            log.info("用户{}收藏了文章{}", userId, dto.getEntryId());
+        } else if (dto.getOperation() == 1) {
+            redisTemplate.opsForSet().remove(key, String.valueOf(dto.getEntryId()));
+            log.info("用户{}取消了文章{}的收藏", userId, dto.getEntryId());
+        }
+
+        return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
+    }
+
+    /**
+     * 加载用户对某篇文章的完整行为状态
+     *
+     * 查询当前用户对指定文章的点赞、不喜欢、收藏状态,
+     * 以及是否关注了文章作者。所有数据均从 Redis 中查询。
+     *
+     * @param dto 文章信息 DTO
+     * @return 包含行为状态标志的响应结果
+     */
+    @Override
+    public ResponseResult loadArticleBehavior(ArticleInfoDto dto) {
+        if (dto.getArticleId() == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID, "文章ID不能为空");
+        }
+
+        Long userId = ApArticleThreadLocalUtil.getUserId();
+        ArticleBehaviorVo vo = new ArticleBehaviorVo();
+
+        if (userId != null) {
+            String userIdStr = String.valueOf(userId);
+
+            vo.setIslike(redisTemplate.opsForSet().isMember(LIKES_KEY + dto.getArticleId(), userIdStr));
+            vo.setIsunlike(redisTemplate.opsForSet().isMember(UNLIKE_KEY + dto.getArticleId(), userIdStr));
+            vo.setIscollection(redisTemplate.opsForSet().isMember(COLLECTION_KEY + userId, String.valueOf(dto.getArticleId())));
+
+            if (dto.getAuthorId() != null) {
+                vo.setIsfollow(redisTemplate.opsForSet().isMember(FOLLOW_KEY + userId, String.valueOf(dto.getAuthorId())));
+            }
+        }
+
+        return ResponseResult.okResult(vo);
+    }
+
+    @Override
+    public ResponseResult collectionList(Long userId) {
+        if (userId == null) {
+            userId = ApArticleThreadLocalUtil.getUserId();
+        }
+        if (userId == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
+        }
+        Set<String> members = redisTemplate.opsForSet().members(COLLECTION_KEY + userId);
+        List<Long> articleIds = members.stream()
+                .map(Long::valueOf)
+                .collect(Collectors.toList());
+        return ResponseResult.okResult(articleIds);
+    }
+}

+ 129 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/java/com/zhaozhaonews/article/service/impl/ApArticleServiceImpl.java

@@ -0,0 +1,129 @@
+package com.zhaozhaonews.article.service.impl;
+
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.zhaozhaonews.article.mapper.ApArticleConfigMapper;
+import com.zhaozhaonews.article.mapper.ApArticleContentMapper;
+import com.zhaozhaonews.article.mapper.ApArticleMapper;
+import com.zhaozhaonews.article.service.ApArticleService;
+import com.zhaozhaonews.common.constants.ArticleConstants;
+import com.zhaozhaonews.model.article.dto.ArticleHomeDto;
+import com.zhaozhaonews.model.article.dtos.ArticleDto;
+import com.zhaozhaonews.model.article.entity.ApArticle;
+import com.zhaozhaonews.model.article.entity.ApArticleConfig;
+import com.zhaozhaonews.model.article.entity.ApArticleContent;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.common.enums.AppHttpCodeEnum;
+import org.apache.commons.lang.StringUtils;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * @author WanJl
+ * @version 1.0
+ * @title ApArticleServiceImpl
+ * @description
+ * @create 2026/4/19
+ */
+@Service
+public class ApArticleServiceImpl extends ServiceImpl<ApArticleMapper, ApArticle> implements ApArticleService {
+    private static final Integer MAX_PAGE_SIZE = 50;
+    @Autowired
+    private ApArticleMapper apArticleMapper;
+    /**
+     * 根据参数加载文章列表
+     *
+     * @param dto
+     * @param type 1:加载更多新闻 2:下拉刷新最新新闻
+     * @return
+     */
+    @Override
+    public ResponseResult load(ArticleHomeDto dto, Integer type) {
+        //1、分页条数参数校验和初始化
+        Integer size = dto.getSize();
+        size = size == null || size == 0 ? 10 : size; //默认10条
+        size = Math.min(size, MAX_PAGE_SIZE); //最大50条
+        //2、校验文章频道
+        if(StringUtils.isBlank(dto.getTag())){
+            dto.setTag(ArticleConstants.DEFAULT_TAG);
+        }
+        //3、查询数据
+        List<ApArticle> articleList = apArticleMapper.loadArticleList(dto, type);
+
+        return ResponseResult.okResult(articleList);
+    }
+
+    @Autowired
+    private ApArticleConfigMapper apArticleConfigMapper;
+
+    @Autowired
+    private ApArticleContentMapper apArticleContentMapper;
+    /**
+     * 保存APP端相关新闻
+     *
+     * @param dto
+     * @return
+     */
+    @Override
+    public ResponseResult saveArticle(ArticleDto dto) {
+
+        //1.检查参数
+        if(dto == null){
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
+        }
+
+        ApArticle apArticle = new ApArticle();
+        BeanUtils.copyProperties(dto,apArticle);
+
+        //2.判断是否存在id
+        if(dto.getId() == null){
+            //2.1 不存在id  保存  新闻  新闻配置  新闻内容
+
+            //保存新闻
+            save(apArticle);
+
+            //保存配置
+            ApArticleConfig apArticleConfig = new ApArticleConfig(apArticle.getId());
+            apArticleConfigMapper.insert(apArticleConfig);
+
+            //保存 新闻内容
+            ApArticleContent apArticleContent = new ApArticleContent();
+            apArticleContent.setArticleId(apArticle.getId());
+            apArticleContent.setContent(dto.getContent());
+            apArticleContentMapper.insert(apArticleContent);
+
+        }else {
+            //2.2 存在id   修改  新闻  新闻内容
+
+            //修改  新闻
+            updateById(apArticle);
+
+            //修改新闻内容
+            ApArticleContent apArticleContent = apArticleContentMapper.selectOne(Wrappers.<ApArticleContent>lambdaQuery().eq(ApArticleContent::getArticleId, dto.getId()));
+            apArticleContent.setContent(dto.getContent());
+            apArticleContentMapper.updateById(apArticleContent);
+        }
+
+
+        //3.结果返回  新闻的id
+        return ResponseResult.okResult(apArticle.getId());
+    }
+
+    @Override
+    public ResponseResult loadArticleDetail(Long id) {
+        ApArticle article = apArticleMapper.selectById(id);
+        if (article == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.DATA_NOT_EXIST);
+        }
+        ApArticleContent content = apArticleContentMapper.selectOne(
+                Wrappers.<ApArticleContent>lambdaQuery().eq(ApArticleContent::getArticleId, id));
+        java.util.Map<String, Object> result = new java.util.HashMap<>();
+        result.put("article", article);
+        result.put("content", content != null ? content.getContent() : null);
+        return ResponseResult.okResult(result);
+    }
+}

+ 12 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/bootstrap.yml

@@ -0,0 +1,12 @@
+server:
+  port: 51802   #文章服务端口号
+spring:
+  application:
+    name: zhaozhaonews-article  #文章服务名称
+  cloud:
+    nacos:
+      discovery:
+        server-addr: 192.168.200.129:8848
+      config:
+        server-addr: 192.168.200.129:8848
+        file-extension: yaml  #配置文件后缀名  zhaozhaonews-article.yaml

+ 58 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/logback-spring.xml

@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration debug="false" scan="true" scanPeriod="30 seconds">
+    <springProperty name="APP_NAME" source="spring.application.name" defaultValue="zhaozhaonews" />
+    <property name="LOG_HOME" value="${user.dir}/logs"/>
+
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}) - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_HOME}/${APP_NAME}.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <FileNamePattern>${LOG_HOME}/${APP_NAME}.log.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
+            <MaxFileSize>10MB</MaxFileSize>
+            <MaxHistory>30</MaxHistory>
+            <totalSizeCap>1GB</totalSizeCap>
+        </rollingPolicy>
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_HOME}/error.log</file>
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>ERROR</level>
+        </filter>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <FileNamePattern>${LOG_HOME}/error.%d{yyyy-MM-dd}.log</FileNamePattern>
+            <MaxHistory>30</MaxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <logger name="com.baomidou.mybatisplus" level="DEBUG"/>
+    <logger name="org.springframework" level="INFO"/>
+    <logger name="org.springframework.web" level="INFO"/>
+    <logger name="springfox" level="WARN"/>
+    <logger name="io.swagger" level="WARN"/>
+    <logger name="com.zaxxer.hikari" level="INFO"/>
+    <logger name="java.sql.Connection" level="DEBUG"/>
+    <logger name="java.sql.Statement" level="DEBUG"/>
+    <logger name="java.sql.PreparedStatement" level="DEBUG"/>
+    <logger name="java.sql.ResultSet" level="DEBUG"/>
+
+    <root level="INFO">
+        <appender-ref ref="STDOUT"/>
+        <appender-ref ref="FILE"/>
+        <appender-ref ref="ERROR_FILE"/>
+    </root>
+</configuration>

+ 52 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/mapper/ApArticleMapper.xml

@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.zhaozhaonews.article.mapper.ApArticleMapper">
+
+    <!-- 文章信息表结果映射 -->
+    <resultMap id="ApArticleResultMap" type="com.zhaozhaonews.model.article.entity.ApArticle">
+        <id column="id" property="id" jdbcType="BIGINT"/>
+        <result column="title" property="title" jdbcType="VARCHAR"/>
+        <result column="author_id" property="authorId" jdbcType="BIGINT"/>
+        <result column="author_name" property="authorName" jdbcType="VARCHAR"/>
+        <result column="channel_id" property="channelId" jdbcType="INTEGER"/>
+        <result column="channel_name" property="channelName" jdbcType="VARCHAR"/>
+        <result column="layout" property="layout" jdbcType="TINYINT"/>
+        <result column="flag" property="flag" jdbcType="TINYINT"/>
+        <result column="images" property="images" jdbcType="VARCHAR"/>
+        <result column="labels" property="labels" jdbcType="VARCHAR"/>
+        <result column="likes" property="likes" jdbcType="INTEGER"/>
+        <result column="collection" property="collection" jdbcType="INTEGER"/>
+        <result column="comment" property="comment" jdbcType="INTEGER"/>
+        <result column="views" property="views" jdbcType="INTEGER"/>
+        <result column="province_id" property="provinceId" jdbcType="INTEGER"/>
+        <result column="city_id" property="cityId" jdbcType="INTEGER"/>
+        <result column="county_id" property="countyId" jdbcType="INTEGER"/>
+        <result column="created_time" property="createdTime" jdbcType="TIMESTAMP"/>
+        <result column="publish_time" property="publishTime" jdbcType="TIMESTAMP"/>
+        <result column="sync_status" property="syncStatus" jdbcType="BOOLEAN"/>
+        <result column="origin" property="origin" jdbcType="BOOLEAN"/>
+        <result column="static_url" property="staticUrl" jdbcType="VARCHAR"/>
+    </resultMap>
+    <select id="loadArticleList" resultMap="ApArticleResultMap">
+        SELECT aa.*
+        FROM ap_article aa
+        LEFT JOIN ap_article_config aac ON aa.id=aac.article_id
+        <where>
+            AND aac.is_delete=0
+            AND aac.is_down=0
+            <if test="type!=null and type == 1">
+                AND aa.publish_time <![CDATA[<=]]> #{dto.minBehotTime}
+            </if>
+            <if test="type!=null and type == 2">
+                AND aa.publish_time <![CDATA[>=]]> #{dto.maxBehotTime}
+            </if>
+            <if test="dto.tag != '__all__'">
+                AND aa.channel_id = #{dto.tag}
+            </if>
+        </where>
+        ORDER BY aa.publish_time DESC,aa.id DESC
+        LIMIT #{dto.size}
+    </select>
+
+
+</mapper>

+ 182 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/templates/article.ftl

@@ -0,0 +1,182 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport"
+          content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover">
+    <title>朝朝新闻</title>
+    <!-- 引入样式文件 -->
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vant@2.12.20/lib/index.css">
+    <!-- 页面样式 -->
+    <link rel="stylesheet" href="../../../plugins/css/index.css">
+</head>
+
+<body>
+<div id="app">
+    <div class="article">
+        <van-row>
+            <van-col span="24" class="article-title" v-html="title"></van-col>
+        </van-row>
+
+        <van-row type="flex" align="center" class="article-header">
+            <van-col span="3">
+                <van-image round class="article-avatar" src="https://p3.pstatp.com/thumb/1480/7186611868"></van-image>
+            </van-col>
+            <van-col span="16">
+                <div v-html="authorName"></div>
+                <div>{{ publishTime | timestampToDateTime }}</div>
+            </van-col>
+            <van-col span="5">
+                <van-button round :icon="relation.isfollow ? '' : 'plus'" type="info" class="article-focus"
+                            :text="relation.isfollow ? '取消关注' : '关注'" :loading="followLoading" @click="handleClickArticleFollow">
+                </van-button>
+            </van-col>
+        </van-row>
+
+        <van-row class="article-content">
+            <#if content??>
+                <#list content as item>
+                    <#if item.type='text'>
+                        <van-col span="24" class="article-text">${item.value}</van-col>
+                    <#else>
+                        <van-col span="24" class="article-image">
+                            <van-image width="100%" src="${item.value}"></van-image>
+                        </van-col>
+                    </#if>
+                </#list>
+            </#if>
+        </van-row>
+
+        <van-row type="flex" justify="center" class="article-action">
+            <van-col>
+                <van-button round :icon="relation.islike ? 'good-job' : 'good-job-o'" class="article-like"
+                            :loading="likeLoading" :text="relation.islike ? '取消赞' : '点赞'" @click="handleClickArticleLike"></van-button>
+                <van-button round :icon="relation.isunlike ? 'delete' : 'delete-o'" class="article-unlike"
+                            :loading="unlikeLoading" @click="handleClickArticleUnlike">不喜欢</van-button>
+            </van-col>
+        </van-row>
+
+        <!-- 文章评论列表 -->
+        <van-list v-model="commentsLoading" :finished="commentsFinished" finished-text="没有更多了"
+                  @load="onLoadArticleComments">
+            <van-row id="#comment-view" type="flex" class="article-comment" v-for="(item, index) in comments" :key="index">
+                <van-col span="3">
+                    <van-image round src="https://p3.pstatp.com/thumb/1480/7186611868" class="article-avatar"></van-image>
+                </van-col>
+                <van-col span="21">
+                    <van-row type="flex" align="center" justify="space-between">
+                        <van-col class="comment-author" v-html="item.authorName"></van-col>
+                        <van-col>
+                            <van-button round :icon="item.operation === 0 ? 'good-job' : 'good-job-o'" size="normal"
+                                        @click="handleClickCommentLike(item)">{{ item.likes || '' }}
+                            </van-button>
+                        </van-col>
+                    </van-row>
+
+                    <van-row>
+                        <van-col class="comment-content" v-html="item.content"></van-col>
+                    </van-row>
+                    <van-row type="flex" align="center">
+                        <van-col span="10" class="comment-time">
+                            {{ item.createdTime | timestampToDateTime }}
+                        </van-col>
+                        <van-col span="3">
+                            <van-button round size="normal" v-html="item.reply" @click="showCommentRepliesPopup(item.id)">回复 {{
+                                item.reply || '' }}
+                            </van-button>
+                        </van-col>
+                    </van-row>
+                </van-col>
+            </van-row>
+        </van-list>
+    </div>
+    <!-- 文章底部栏 -->
+    <van-row type="flex" justify="space-around" align="center" class="article-bottom-bar">
+        <van-col span="13">
+            <van-field v-model="commentValue" placeholder="写评论">
+                <template #button>
+                    <van-button icon="back-top" @click="handleSaveComment"></van-button>
+                </template>
+            </van-field>
+        </van-col>
+        <van-col span="3">
+            <van-button icon="comment-o" @click="handleScrollIntoCommentView"></van-button>
+        </van-col>
+        <van-col span="3">
+            <van-button :icon="relation.iscollection ? 'star' : 'star-o'" :loading="collectionLoading"
+                        @click="handleClickArticleCollection"></van-button>
+        </van-col>
+        <van-col span="3">
+            <van-button icon="share-o"></van-button>
+        </van-col>
+    </van-row>
+
+    <!-- 评论Popup 弹出层 -->
+    <van-popup v-model="showPopup" closeable position="bottom"
+               :style="{ width: '750px', height: '60%', left: '50%', 'margin-left': '-375px' }">
+        <!-- 评论回复列表 -->
+        <van-list v-model="commentRepliesLoading" :finished="commentRepliesFinished" finished-text="没有更多了"
+                  @load="onLoadCommentReplies">
+            <van-row id="#comment-reply-view" type="flex" class="article-comment-reply"
+                     v-for="(item, index) in commentReplies" :key="index">
+                <van-col span="3">
+                    <van-image round src="https://p3.pstatp.com/thumb/1480/7186611868" class="article-avatar"></van-image>
+                </van-col>
+                <van-col span="21">
+                    <van-row type="flex" align="center" justify="space-between">
+                        <van-col class="comment-author" v-html="item.authorName"></van-col>
+                        <van-col>
+                            <van-button round :icon="item.operation === 0 ? 'good-job' : 'good-job-o'" size="normal"
+                                        @click="handleClickCommentReplyLike(item)">{{ item.likes || '' }}
+                            </van-button>
+                        </van-col>
+                    </van-row>
+
+                    <van-row>
+                        <van-col class="comment-content" v-html="item.content"></van-col>
+                    </van-row>
+                    <van-row type="flex" align="center">
+                        <!-- TODO: js计算时间差 -->
+                        <van-col span="10" class="comment-time">
+                            {{ item.createdTime | timestampToDateTime }}
+                        </van-col>
+                    </van-row>
+                </van-col>
+            </van-row>
+        </van-list>
+        <!-- 评论回复底部栏 -->
+        <van-row type="flex" justify="space-around" align="center" class="comment-reply-bottom-bar">
+            <van-col span="13">
+                <van-field v-model="commentReplyValue" placeholder="写评论">
+                    <template #button>
+                        <van-button icon="back-top" @click="handleSaveCommentReply"></van-button>
+                    </template>
+                </van-field>
+            </van-col>
+            <van-col span="3">
+                <van-button icon="comment-o"></van-button>
+            </van-col>
+            <van-col span="3">
+                <van-button icon="star-o"></van-button>
+            </van-col>
+            <van-col span="3">
+                <van-button icon="share-o"></van-button>
+            </van-col>
+        </van-row>
+    </van-popup>
+</div>
+
+<!-- 引入 Vue 和 Vant 的 JS 文件 -->
+<script src=" https://cdn.jsdelivr.net/npm/vue/dist/vue.min.js">
+</script>
+<script src="https://cdn.jsdelivr.net/npm/vant@2.12.20/lib/vant.min.js"></script>
+<!-- 引入 Axios 的 JS 文件 -->
+<#--<script src="https://unpkg.com/axios/dist/axios.min.js"></script>-->
+<script src="../../../plugins/js/axios.min.js"></script>
+<!-- 页面逻辑 -->
+<script src="../../../plugins/js/index.js"></script>
+</body>
+
+</html>

+ 14 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/main/resources/test.json

@@ -0,0 +1,14 @@
+[
+  {
+    "type": "image",
+    "value": "http://192.168.32.128/group1/M00/00/00/wKjIgl892vuAXr_MAASCMYD0yzc919.jpg"
+  },
+  {
+    "type": "text",
+    "value": "武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国"
+  },
+  {
+    "type": "text",
+    "value": "武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国武汉高校开学典礼万人歌唱祖国v"
+  }
+]

+ 128 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-article/src/test/java/com/zhaozhaonews/article/test/ArticleFreeMarkerTest.java

@@ -0,0 +1,128 @@
+package com.zhaozhaonews.article.test;
+
+import com.alibaba.fastjson.JSONArray;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.zhaozhaonews.ArticleApplication;
+import com.zhaozhaonews.article.mapper.ApArticleContentMapper;
+import com.zhaozhaonews.article.mapper.ApArticleMapper;
+import com.zhaozhaonews.file.service.FileStorageService;
+import com.zhaozhaonews.model.article.entity.ApArticle;
+import com.zhaozhaonews.model.article.entity.ApArticleContent;
+import freemarker.template.Template;
+import org.apache.commons.lang.StringUtils;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @author WanJl
+ * @version 1.0
+ * @title ArticleFreeMarkerTest
+ * @description 批量生成文章静态页面并上传MinIO、更新数据库
+ * @create 2026/4/26
+ */
+@SpringBootTest(classes = ArticleApplication.class)
+@RunWith(SpringRunner.class)
+public class ArticleFreeMarkerTest {
+
+    @Autowired
+    private ApArticleContentMapper apArticleContentMapper;
+    @Autowired
+    private ApArticleMapper apArticleMapper;
+    @Autowired
+    private FileStorageService fileStorageService;
+    @Autowired
+    private freemarker.template.Configuration configuration;
+
+    /**
+     * 批量生成文章静态页面
+     */
+    @Test
+    public void createStaticArticleURLBatchTest() {
+        // 批量文章ID列表(你提供的所有ID)
+        List<Long> articleIdList = Arrays.asList(
+                1302862387124125698L,
+                1302864436297482242L,
+                1302864730402078722L,
+                1302865008438296577L,
+                1302865306489733122L,
+                1302865474094120961L,
+                1302977178887004162L,
+                1302977458215067649L,
+                1302977558807060482L,
+                1302977754114826241L,
+                1302977754114826242L,
+                1302977754114826243L,
+                1303156149041758210L,
+                1383827787629252610L,
+                1383827888816836609L,
+                1383827911810011137L,
+                1383827952326987778L,
+                1383827976310018049L,
+                1383827995813531650L,
+                1383828014629179393L,
+                1892052874209398785L,
+                1892055572166086657L,
+                1892144149344423938L,
+                1892145504293359617L,
+                1892410771636359170L,
+                1893119912168263682L,
+                2042977564435288065L
+        );
+
+        // 遍历批量处理
+        for (Long articleId : articleIdList) {
+            try {
+                System.out.println("===== 开始处理文章ID:" + articleId + " =====");
+                generateArticleStaticHtml(articleId);
+                System.out.println("===== 文章ID:" + articleId + " 处理成功 =====");
+            } catch (Exception e) {
+                System.err.println("===== 文章ID:" + articleId + " 处理失败,原因:" + e.getMessage() + " =====");
+                // 继续处理下一个,不中断批量任务
+            }
+        }
+        System.out.println("===== 所有文章批量处理完成 =====");
+    }
+
+    /**
+     * 单个文章生成静态页面、上传、更新数据库(抽取成公共方法)
+     */
+    private void generateArticleStaticHtml(Long articleId) throws Exception {
+        // 1、获取新闻内容
+        QueryWrapper<ApArticleContent> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("article_id", articleId);
+        ApArticleContent apArticleContent = apArticleContentMapper.selectOne(queryWrapper);
+
+        // 判断新闻内容是否为空
+        if (apArticleContent == null || StringUtils.isBlank(apArticleContent.getContent())) {
+            throw new RuntimeException("新闻内容为空");
+        }
+
+        // 2、生成静态页面
+        StringWriter writer = new StringWriter();
+        Template template = configuration.getTemplate("article.ftl");
+        Map<String, Object> map = new HashMap<>();
+        map.put("content", JSONArray.parseArray(apArticleContent.getContent()));
+        template.process(map, writer);
+
+        // 3、上传到minio
+        InputStream inputStream = new ByteArrayInputStream(writer.toString().getBytes());
+        String url = fileStorageService.uploadHtmlFile("", apArticleContent.getArticleId() + ".html", inputStream);
+
+        // 4、更新数据库静态URL
+        ApArticle apArticle = new ApArticle();
+        apArticle.setId(apArticleContent.getArticleId());
+        apArticle.setStaticUrl(url);
+        apArticleMapper.updateById(apArticle);
+    }
+}

+ 28 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/pom.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.zhaozhaonews</groupId>
+        <artifactId>zhaozhaonews-service</artifactId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>zhaozhaonews-behavior</artifactId>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 26 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/BehaviorApplication.java

@@ -0,0 +1,26 @@
+package com.zhaozhaonews.behavior;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
+
+/**
+ * 行为微服务启动类
+ *
+ * 提供用户行为相关的功能,包括文章点赞、取消点赞、不喜欢、取消不喜欢、
+ * 阅读记录等。行为数据直接存储在 Redis 中,无需数据库支持,
+ * 因此排除了 DataSourceAutoConfiguration 自动配置。
+ * 注册到 Nacos 服务中心,通过 Gateway 网关对外暴露接口。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+@EnableDiscoveryClient
+public class BehaviorApplication {
+    public static void main(String[] args) {
+        SpringApplication.run(BehaviorApplication.class, args);
+    }
+}

+ 34 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/config/WebMvcConfig.java

@@ -0,0 +1,34 @@
+package com.zhaozhaonews.behavior.config;
+
+import com.zhaozhaonews.behavior.interceptor.BehaviorTokenInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * 行为模块 Web MVC 配置
+ *
+ * 注册 BehaviorTokenInterceptor 拦截器,拦截所有请求路径,
+ * 在请求处理前从请求头获取 userId 并存入 ThreadLocal,
+ * 用于后续业务逻辑中获取当前用户信息。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private BehaviorTokenInterceptor behaviorTokenInterceptor;
+
+    /**
+     * 注册拦截器,拦截所有路径
+     */
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(behaviorTokenInterceptor)
+                .addPathPatterns("/**");
+    }
+}

+ 62 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/controller/BehaviorController.java

@@ -0,0 +1,62 @@
+package com.zhaozhaonews.behavior.controller;
+
+import com.zhaozhaonews.behavior.service.BehaviorService;
+import com.zhaozhaonews.model.behavior.dtos.LikesBehaviorDto;
+import com.zhaozhaonews.model.behavior.dtos.ReadBehaviorDto;
+import com.zhaozhaonews.model.behavior.dtos.UnLikesBehaviorDto;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 行为控制器
+ *
+ * 提供 APP 端用户行为相关的 REST 接口。
+ * 包括文章点赞、取消点赞、设置不喜欢、阅读记录等操作。
+ * 所有行为数据基于 Redis 存储,以 Set 或 Hash 结构组织。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@RestController
+public class BehaviorController {
+
+    @Autowired
+    private BehaviorService behaviorService;
+
+    /**
+     * 点赞/取消点赞
+     *
+     * @param dto 点赞行为 DTO,包含文章ID和操作类型(0-点赞,1-取消点赞)
+     * @return 操作结果
+     */
+    @PostMapping("/api/v1/likes_behavior")
+    public ResponseResult like(@RequestBody LikesBehaviorDto dto) {
+        return behaviorService.like(dto);
+    }
+
+    /**
+     * 记录阅读行为
+     *
+     * @param dto 阅读行为 DTO,包含文章ID
+     * @return 操作结果
+     */
+    @PostMapping("/api/v1/read_behavior")
+    public ResponseResult read(@RequestBody ReadBehaviorDto dto) {
+        return behaviorService.read(dto);
+    }
+
+    /**
+     * 不喜欢/取消不喜欢
+     *
+     * @param dto 不喜欢行为 DTO,包含文章ID和操作类型(0-不喜欢,1-取消不喜欢)
+     * @return 操作结果
+     */
+    @PostMapping("/api/v1/un_likes_behavior")
+    public ResponseResult unlike(@RequestBody UnLikesBehaviorDto dto) {
+        return behaviorService.unlike(dto);
+    }
+}

+ 47 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/interceptor/ApUserThreadLocalUtil.java

@@ -0,0 +1,47 @@
+package com.zhaozhaonews.behavior.interceptor;
+
+/**
+ * 行为模块用户 ID 线程上下文工具类
+ *
+ * 基于 ThreadLocal 在请求链路中传递当前登录用户的 ID,
+ * 避免在 Service 层方法调用链中频繁传递 userId 参数。
+ *
+ * 在 BehaviorTokenInterceptor 的 preHandle 中从请求头获取 userId 并设置,
+ * 在 afterCompletion 中清理,防止内存泄漏。
+ *
+ * @see BehaviorTokenInterceptor
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+public class ApUserThreadLocalUtil {
+
+    private static final ThreadLocal<Long> USER_ID_THREAD_LOCAL = new ThreadLocal<>();
+
+    /**
+     * 设置当前线程的用户 ID
+     *
+     * @param userId 用户 ID
+     */
+    public static void setUserId(Long userId) {
+        USER_ID_THREAD_LOCAL.set(userId);
+    }
+
+    /**
+     * 获取当前线程的用户 ID
+     *
+     * @return 用户 ID,未设置时返回 null
+     */
+    public static Long getUserId() {
+        return USER_ID_THREAD_LOCAL.get();
+    }
+
+    /**
+     * 清理当前线程的用户 ID
+     *
+     * 必须在请求处理完成后调用,防止 ThreadLocal 内存泄漏。
+     */
+    public static void clear() {
+        USER_ID_THREAD_LOCAL.remove();
+    }
+}

+ 53 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/interceptor/BehaviorTokenInterceptor.java

@@ -0,0 +1,53 @@
+package com.zhaozhaonews.behavior.interceptor;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 行为模块 Token 拦截器
+ *
+ * 从 HTTP 请求头中提取 userId,存入 ThreadLocal 供后续业务逻辑使用。
+ * 请求处理完成后在 afterCompletion 中清理 ThreadLocal,防止内存泄漏。
+ *
+ * 由于行为微服务部署在内部网络中,外层网关已经完成 JWT 鉴权,
+ * 因此本拦截器直接从请求头获取已解析的用户 ID,无需再次解析 Token。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@Slf4j
+@Component
+public class BehaviorTokenInterceptor implements HandlerInterceptor {
+
+    /**
+     * 请求处理前从请求头获取 userId 并存入 ThreadLocal
+     *
+     * @param request  HTTP 请求
+     * @param response HTTP 响应
+     * @param handler  处理器对象
+     * @return 始终返回 true,继续执行后续处理
+     */
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        String userId = request.getHeader("userId");
+        if (userId != null && !"".equals(userId)) {
+            ApUserThreadLocalUtil.setUserId(Long.valueOf(userId));
+            log.info("behaviorTokenInterceptor设置用户信息到threadlocal,userId:{}", userId);
+        }
+        return true;
+    }
+
+    /**
+     * 请求处理完成后清理 ThreadLocal
+     */
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        log.info("behaviorTokenInterceptor清理threadlocal...");
+        ApUserThreadLocalUtil.clear();
+    }
+}

+ 43 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/service/BehaviorService.java

@@ -0,0 +1,43 @@
+package com.zhaozhaonews.behavior.service;
+
+import com.zhaozhaonews.model.behavior.dtos.LikesBehaviorDto;
+import com.zhaozhaonews.model.behavior.dtos.ReadBehaviorDto;
+import com.zhaozhaonews.model.behavior.dtos.UnLikesBehaviorDto;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+
+/**
+ * 行为服务接口
+ *
+ * 定义用户对文章的行为操作业务方法。
+ * 包括点赞、阅读记录、不喜欢等操作,所有行为数据持久化到 Redis。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+public interface BehaviorService {
+
+    /**
+     * 处理文章点赞或取消点赞
+     *
+     * @param dto 点赞行为 DTO,包含文章ID和操作类型
+     * @return 操作结果
+     */
+    ResponseResult like(LikesBehaviorDto dto);
+
+    /**
+     * 记录文章阅读行为
+     *
+     * @param dto 阅读行为 DTO,包含文章ID
+     * @return 操作结果
+     */
+    ResponseResult read(ReadBehaviorDto dto);
+
+    /**
+     * 处理文章不喜欢或取消不喜欢
+     *
+     * @param dto 不喜欢行为 DTO,包含文章ID和操作类型
+     * @return 操作结果
+     */
+    ResponseResult unlike(UnLikesBehaviorDto dto);
+}

+ 131 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/java/com/zhaozhaonews/behavior/service/impl/BehaviorServiceImpl.java

@@ -0,0 +1,131 @@
+package com.zhaozhaonews.behavior.service.impl;
+
+import com.zhaozhaonews.behavior.service.BehaviorService;
+import com.zhaozhaonews.model.behavior.dtos.LikesBehaviorDto;
+import com.zhaozhaonews.model.behavior.dtos.ReadBehaviorDto;
+import com.zhaozhaonews.model.behavior.dtos.UnLikesBehaviorDto;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.common.enums.AppHttpCodeEnum;
+import com.zhaozhaonews.behavior.interceptor.ApUserThreadLocalUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+/**
+ * 行为服务实现类
+ *
+ * 基于 Redis 实现用户行为数据的持久化:
+ * - 点赞/不喜欢:使用 Set 结构,以文章为维度存储操作过的用户 ID 集合
+ * - 阅读记录:使用 Hash 结构,以文章为维度存储用户 ID 到阅读次数的映射
+ *
+ * 当前用户 ID 从 ApUserThreadLocalUtil 中获取(由 BehaviorTokenInterceptor 注入)。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@Service
+@Slf4j
+public class BehaviorServiceImpl implements BehaviorService {
+
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
+    /** Redis 点赞键前缀,结构:behavior:likes:{文章ID},值为 Set<用户ID> */
+    private static final String LIKES_KEY = "behavior:likes:";
+
+    /** Redis 不喜欢键前缀,结构:behavior:unlike:{文章ID} */
+    private static final String UNLIKE_KEY = "behavior:unlike:";
+
+    /** Redis 阅读键前缀,结构:behavior:read:{文章ID},值为 Hash<用户ID, 阅读次数> */
+    private static final String READ_KEY = "behavior:read:";
+
+    /**
+     * 点赞或取消点赞
+     *
+     * operation=0 时点赞,将用户 ID 加入文章的点赞 Set;
+     * operation=1 时取消点赞,从 Set 中移除。
+     */
+    @Override
+    public ResponseResult like(LikesBehaviorDto dto) {
+        if (dto.getArticleId() == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID, "文章ID不能为空");
+        }
+
+        String key = LIKES_KEY + dto.getArticleId();
+        Long userId = getCurrentUserId();
+
+        if (dto.getOperation() == 0) {
+            redisTemplate.opsForSet().add(key, String.valueOf(userId));
+            log.info("用户{}点赞了文章{}", userId, dto.getArticleId());
+        } else if (dto.getOperation() == 1) {
+            redisTemplate.opsForSet().remove(key, String.valueOf(userId));
+            log.info("用户{}取消了文章{}的点赞", userId, dto.getArticleId());
+        }
+
+        return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
+    }
+
+    /**
+     * 记录阅读行为
+     *
+     * 使用 Hash 结构按文章维度存储每个用户的阅读次数,
+     * 每次阅读时对应次数加 1。
+     */
+    @Override
+    public ResponseResult read(ReadBehaviorDto dto) {
+        if (dto.getArticleId() == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID, "文章ID不能为空");
+        }
+
+        String key = READ_KEY + dto.getArticleId();
+        Long userId = getCurrentUserId();
+        String field = String.valueOf(userId);
+
+        String countStr = (String) redisTemplate.opsForHash().get(key, field);
+        int count = 1;
+        if (countStr != null) {
+            count = Integer.parseInt(countStr) + 1;
+        }
+        redisTemplate.opsForHash().put(key, field, String.valueOf(count));
+        log.info("用户{}阅读了文章{},阅读次数{}", userId, dto.getArticleId(), count);
+
+        return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
+    }
+
+    /**
+     * 不喜欢或取消不喜欢
+     *
+     * type=0 时设置不喜欢,将用户 ID 加入文章的不喜欢 Set;
+     * type=1 时取消不喜欢,从 Set 中移除。
+     */
+    @Override
+    public ResponseResult unlike(UnLikesBehaviorDto dto) {
+        if (dto.getArticleId() == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID, "文章ID不能为空");
+        }
+
+        String key = UNLIKE_KEY + dto.getArticleId();
+        Long userId = getCurrentUserId();
+
+        if (dto.getType() == 0) {
+            redisTemplate.opsForSet().add(key, String.valueOf(userId));
+            log.info("用户{}不喜欢了文章{}", userId, dto.getArticleId());
+        } else if (dto.getType() == 1) {
+            redisTemplate.opsForSet().remove(key, String.valueOf(userId));
+            log.info("用户{}取消了文章{}的不喜欢", userId, dto.getArticleId());
+        }
+
+        return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
+    }
+
+    /**
+     * 从线程上下文中获取当前用户 ID
+     *
+     * @return 用户 ID
+     */
+    private Long getCurrentUserId() {
+        return ApUserThreadLocalUtil.getUserId();
+    }
+}

+ 12 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/resources/bootstrap.yml

@@ -0,0 +1,12 @@
+server:
+  port: 51805
+spring:
+  application:
+    name: zhaozhaonews-behavior
+  cloud:
+    nacos:
+      discovery:
+        server-addr: 192.168.200.129:8848
+      config:
+        server-addr: 192.168.200.129:8848
+        file-extension: yaml

+ 58 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-behavior/src/main/resources/logback-spring.xml

@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration debug="false" scan="true" scanPeriod="30 seconds">
+    <springProperty name="APP_NAME" source="spring.application.name" defaultValue="zhaozhaonews" />
+    <property name="LOG_HOME" value="${user.dir}/logs"/>
+
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}) - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_HOME}/${APP_NAME}.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <FileNamePattern>${LOG_HOME}/${APP_NAME}.log.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
+            <MaxFileSize>10MB</MaxFileSize>
+            <MaxHistory>30</MaxHistory>
+            <totalSizeCap>1GB</totalSizeCap>
+        </rollingPolicy>
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_HOME}/error.log</file>
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>ERROR</level>
+        </filter>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <FileNamePattern>${LOG_HOME}/error.%d{yyyy-MM-dd}.log</FileNamePattern>
+            <MaxHistory>30</MaxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <logger name="com.baomidou.mybatisplus" level="DEBUG"/>
+    <logger name="org.springframework" level="INFO"/>
+    <logger name="org.springframework.web" level="INFO"/>
+    <logger name="springfox" level="WARN"/>
+    <logger name="io.swagger" level="WARN"/>
+    <logger name="com.zaxxer.hikari" level="INFO"/>
+    <logger name="java.sql.Connection" level="DEBUG"/>
+    <logger name="java.sql.Statement" level="DEBUG"/>
+    <logger name="java.sql.PreparedStatement" level="DEBUG"/>
+    <logger name="java.sql.ResultSet" level="DEBUG"/>
+
+    <root level="INFO">
+        <appender-ref ref="STDOUT"/>
+        <appender-ref ref="FILE"/>
+        <appender-ref ref="ERROR_FILE"/>
+    </root>
+</configuration>

+ 20 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/pom.xml

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.zhaozhaonews</groupId>
+        <artifactId>zhaozhaonews-service</artifactId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>zhaozhaonews-user</artifactId>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+</project>

+ 25 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/UserApplication.java

@@ -0,0 +1,25 @@
+package com.zhaozhaonews.user;
+
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
+import org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration;
+import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
+
+/**
+ * @author WanJl
+ * @version 1.0
+ * @title UserApplication
+ * @description
+ * @create 2025/2/13
+ */
+@SpringBootApplication
+@EnableDiscoveryClient
+@MapperScan("com.zhaozhaonews.user.mapper")
+public class UserApplication {
+    public static void main(String[] args) {
+        SpringApplication.run(UserApplication.class, args);
+    }
+}

+ 34 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/config/WebMvcConfig.java

@@ -0,0 +1,34 @@
+package com.zhaozhaonews.user.config;
+
+import com.zhaozhaonews.user.interceptor.ApUserTokenInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * APP 用户模块 Web MVC 配置
+ *
+ * 注册 ApUserTokenInterceptor 拦截器,拦截所有请求路径,
+ * 在请求处理前从请求头获取 userId 并存入 ThreadLocal,
+ * 用于后续业务逻辑中获取当前用户信息。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private ApUserTokenInterceptor apUserTokenInterceptor;
+
+    /**
+     * 注册拦截器,拦截所有路径
+     */
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(apUserTokenInterceptor)
+                .addPathPatterns("/**");
+    }
+}

+ 27 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/controller/v1/ApUserController.java

@@ -0,0 +1,27 @@
+package com.zhaozhaonews.user.controller.v1;
+
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.user.entity.ApUser;
+import com.zhaozhaonews.user.mapper.ApUserMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/user")
+public class ApUserController {
+
+    @Autowired
+    private ApUserMapper apUserMapper;
+
+    @GetMapping("/{id}")
+    public ResponseResult getUserById(@PathVariable("id") Long id) {
+        ApUser user = apUserMapper.selectById(id);
+        if (user == null) {
+            return ResponseResult.errorResult(500, "数据不存在");
+        }
+        return ResponseResult.okResult(user);
+    }
+}

+ 46 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/controller/v1/ApUserLoginController.java

@@ -0,0 +1,46 @@
+package com.zhaozhaonews.user.controller.v1;
+
+
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.user.dto.LoginDto;
+import com.zhaozhaonews.model.user.dto.RegisterDto;
+import com.zhaozhaonews.model.user.entity.ApUser;
+import com.zhaozhaonews.user.service.ApUserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/login")
+public class ApUserLoginController {
+    @Autowired
+    private ApUserService apUserService;
+    @PostMapping("/login_auth")
+    public ResponseResult login(@RequestBody LoginDto loginDto) {
+        return apUserService.login(loginDto);
+    }
+
+    @PostMapping("/register")
+    public ResponseResult register(@RequestBody RegisterDto dto) {
+        return apUserService.register(dto);
+    }
+
+    @PostMapping("/send_code")
+    public ResponseResult sendCode(@RequestParam("phone") String phone) {
+        return apUserService.sendCode(phone);
+    }
+
+    @PostMapping("/code_login")
+    public ResponseResult codeLogin(@RequestBody RegisterDto dto) {
+        return apUserService.codeLogin(dto);
+    }
+
+    @PostMapping("/reset_password")
+    public ResponseResult resetPassword(@RequestBody RegisterDto dto) {
+        return apUserService.resetPassword(dto);
+    }
+
+    @PutMapping("/user/info")
+    public ResponseResult updateUserInfo(@RequestBody ApUser user) {
+        return apUserService.updateUserInfo(user);
+    }
+}

+ 35 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/controller/v1/ApUserRelationController.java

@@ -0,0 +1,35 @@
+package com.zhaozhaonews.user.controller.v1;
+
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.user.dto.UserRelationDto;
+import com.zhaozhaonews.user.service.ApUserRelationService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * APP 用户关系控制器
+ *
+ * 提供 APP 端用户关注/取消关注作者的 REST 接口。
+ * 关注关系数据存储在 Redis 中,以 Set 结构按用户维度组织。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@RestController
+@RequestMapping("/api/v1/user")
+public class ApUserRelationController {
+
+    @Autowired
+    private ApUserRelationService apUserRelationService;
+
+    @PostMapping("/user_follow")
+    public ResponseResult follow(@RequestBody UserRelationDto dto) {
+        return apUserRelationService.follow(dto);
+    }
+
+    @GetMapping("/follow/list")
+    public ResponseResult followList(@RequestParam(value = "userId", required = false) Long userId) {
+        return apUserRelationService.followList(userId);
+    }
+}

+ 47 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/interceptor/ApUserThreadLocalUtil.java

@@ -0,0 +1,47 @@
+package com.zhaozhaonews.user.interceptor;
+
+/**
+ * APP 用户模块用户 ID 线程上下文工具类
+ *
+ * 基于 ThreadLocal 在请求链路中传递当前登录用户的 ID,
+ * 避免在 Service 层方法调用链中频繁传递 userId 参数。
+ *
+ * 在 ApUserTokenInterceptor 的 preHandle 中从请求头获取 userId 并设置,
+ * 在 afterCompletion 中清理,防止内存泄漏。
+ *
+ * @see ApUserTokenInterceptor
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+public class ApUserThreadLocalUtil {
+
+    private static final ThreadLocal<Long> USER_ID_THREAD_LOCAL = new ThreadLocal<>();
+
+    /**
+     * 设置当前线程的用户 ID
+     *
+     * @param userId 用户 ID
+     */
+    public static void setUserId(Long userId) {
+        USER_ID_THREAD_LOCAL.set(userId);
+    }
+
+    /**
+     * 获取当前线程的用户 ID
+     *
+     * @return 用户 ID,未设置时返回 null
+     */
+    public static Long getUserId() {
+        return USER_ID_THREAD_LOCAL.get();
+    }
+
+    /**
+     * 清理当前线程的用户 ID
+     *
+     * 必须在请求处理完成后调用,防止 ThreadLocal 内存泄漏。
+     */
+    public static void clear() {
+        USER_ID_THREAD_LOCAL.remove();
+    }
+}

+ 53 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/interceptor/ApUserTokenInterceptor.java

@@ -0,0 +1,53 @@
+package com.zhaozhaonews.user.interceptor;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * APP 用户模块 Token 拦截器
+ *
+ * 从 HTTP 请求头中提取 userId,存入 ThreadLocal 供后续业务逻辑使用。
+ * 请求处理完成后在 afterCompletion 中清理 ThreadLocal,防止内存泄漏。
+ *
+ * 由于 APP 用户微服务部署在内部网络中,外层网关已经完成 JWT 鉴权,
+ * 因此本拦截器直接从请求头获取已解析的用户 ID,无需再次解析 Token。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@Slf4j
+@Component
+public class ApUserTokenInterceptor implements HandlerInterceptor {
+
+    /**
+     * 请求处理前从请求头获取 userId 并存入 ThreadLocal
+     *
+     * @param request  HTTP 请求
+     * @param response HTTP 响应
+     * @param handler  处理器对象
+     * @return 始终返回 true,继续执行后续处理
+     */
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        String userId = request.getHeader("userId");
+        if (userId != null && !"".equals(userId)) {
+            ApUserThreadLocalUtil.setUserId(Long.valueOf(userId));
+            log.info("ApUserTokenInterceptor设置用户信息到threadlocal,userId:{}", userId);
+        }
+        return true;
+    }
+
+    /**
+     * 请求处理完成后清理 ThreadLocal
+     */
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        log.info("ApUserTokenInterceptor清理threadlocal...");
+        ApUserThreadLocalUtil.clear();
+    }
+}

+ 18 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/mapper/ApUserMapper.java

@@ -0,0 +1,18 @@
+package com.zhaozhaonews.user.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zhaozhaonews.model.user.entity.ApUser;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface ApUserMapper extends BaseMapper<ApUser> {
+
+    /**
+     * 根据手机号查询用户信息(用于登录)
+     * @param phone 手机号
+     * @return 用户实体
+     */
+    ApUser selectUserByPhone(@Param("phone") String phone);
+
+}

+ 20 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/service/ApUserRelationService.java

@@ -0,0 +1,20 @@
+package com.zhaozhaonews.user.service;
+
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.user.dto.UserRelationDto;
+
+/**
+ * APP 用户关系服务接口
+ *
+ * 定义用户关注/取消关注作者的业务方法。
+ * 关注关系数据存储在 Redis 中,以 Set 结构按用户维度组织。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+public interface ApUserRelationService {
+
+    ResponseResult follow(UserRelationDto dto);
+    ResponseResult followList(Long userId);
+}

+ 18 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/service/ApUserService.java

@@ -0,0 +1,18 @@
+package com.zhaozhaonews.user.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.user.dto.LoginDto;
+import com.zhaozhaonews.model.user.entity.ApUser;
+
+import com.zhaozhaonews.model.user.dto.RegisterDto;
+import com.zhaozhaonews.model.user.entity.ApUser;
+
+public interface ApUserService extends IService<ApUser> {
+    ResponseResult login(LoginDto loginDto);
+    ResponseResult register(RegisterDto dto);
+    ResponseResult sendCode(String phone);
+    ResponseResult codeLogin(RegisterDto dto);
+    ResponseResult resetPassword(RegisterDto dto);
+    ResponseResult updateUserInfo(ApUser user);
+}

+ 84 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/service/impl/ApUserRelationServiceImpl.java

@@ -0,0 +1,84 @@
+package com.zhaozhaonews.user.service.impl;
+
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.common.enums.AppHttpCodeEnum;
+import com.zhaozhaonews.model.user.dto.UserRelationDto;
+import com.zhaozhaonews.user.interceptor.ApUserThreadLocalUtil;
+import com.zhaozhaonews.user.service.ApUserRelationService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 用户关系服务实现类
+ *
+ * 基于 Redis 的 Set 数据结构存储用户的关注关系。
+ * 以用户维度存储其关注的作者 ID 集合,键格式为 behavior:follow:{用户ID}。
+ * 当前用户 ID 从 ApUserThreadLocalUtil 中获取(由 ApUserTokenInterceptor 注入)。
+ *
+ * @author WanJl
+ * @version 1.0
+ * @since 2026/04/19
+ */
+@Service
+@Slf4j
+public class ApUserRelationServiceImpl implements ApUserRelationService {
+
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
+    /** Redis 关注关系键前缀,结构:behavior:follow:{用户ID},值为 Set<作者ID> */
+    private static final String FOLLOW_KEY = "behavior:follow:";
+
+    /**
+     * 关注或取消关注作者
+     *
+     * operation=0 时关注,将作者 ID 加入用户的关注 Set;
+     * operation=1 时取消关注,从 Set 中移除。
+     *
+     * @param dto 用户关系 DTO
+     * @return 操作结果
+     */
+    @Override
+    public ResponseResult follow(UserRelationDto dto) {
+        if (dto.getAuthorId() == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID, "作者ID不能为空");
+        }
+
+        Long userId = ApUserThreadLocalUtil.getUserId();
+        if (userId == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
+        }
+
+        String key = FOLLOW_KEY + userId;
+        if (dto.getOperation() == 0) {
+            redisTemplate.opsForSet().add(key, String.valueOf(dto.getAuthorId()));
+            log.info("用户{}关注了作者{}", userId, dto.getAuthorId());
+        } else if (dto.getOperation() == 1) {
+            redisTemplate.opsForSet().remove(key, String.valueOf(dto.getAuthorId()));
+            log.info("用户{}取消了关注作者{}", userId, dto.getAuthorId());
+        }
+
+        return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
+    }
+
+    @Override
+    public ResponseResult followList(Long userId) {
+        if (userId == null) {
+            userId = ApUserThreadLocalUtil.getUserId();
+        }
+        if (userId == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
+        }
+        Set<String> members = redisTemplate.opsForSet().members(FOLLOW_KEY + userId);
+        List<Integer> authorIds = members.stream()
+                .map(Integer::valueOf)
+                .collect(Collectors.toList());
+        return ResponseResult.okResult(authorIds);
+    }
+}

+ 151 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/service/impl/ApUserServiceImpl.java

@@ -0,0 +1,151 @@
+package com.zhaozhaonews.user.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.zhaozhaonews.model.common.dto.ResponseResult;
+import com.zhaozhaonews.model.common.enums.AppHttpCodeEnum;
+import com.zhaozhaonews.model.user.dto.LoginDto;
+import com.zhaozhaonews.model.user.dto.RegisterDto;
+import com.zhaozhaonews.model.user.entity.ApUser;
+import com.zhaozhaonews.user.mapper.ApUserMapper;
+import com.zhaozhaonews.user.service.ApUserService;
+import com.zhaozhaonews.user.util.VerificationCodeStore;
+import com.zhaozhaonews.util.common.AppJwtUtil;
+import com.zhaozhaonews.util.common.MD5Utils;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+@Slf4j
+public class ApUserServiceImpl extends ServiceImpl<ApUserMapper, ApUser> implements ApUserService {
+    @Autowired
+    private ApUserMapper apUserMapper;
+
+    @Autowired
+    private VerificationCodeStore codeStore;
+
+    @Override
+    public ResponseResult login(LoginDto loginDto) {
+        if (StringUtils.isBlank(loginDto.getPhone()) || StringUtils.isBlank(loginDto.getPassword())) {
+            Map<String ,Object > map =new HashMap<>();
+            map.put("token", AppJwtUtil.getToken(0l));
+            return ResponseResult.okResult(map);
+        }
+        ApUser apUser = apUserMapper.selectUserByPhone(loginDto.getPhone());
+        if (apUser == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.AP_USER_DATA_NOT_EXIST,"用户不存在");
+        }
+        String salt = apUser.getSalt();
+        String password = MD5Utils.encodeWithSalt(loginDto.getPassword(), salt);
+        if (!apUser.getPassword().equals(password)) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_PASSWORD_ERROR);
+        }
+        Map<String ,Object > map =new HashMap<>();
+        map.put("token", AppJwtUtil.getToken(apUser.getId()));
+        apUser.setSalt("");
+        apUser.setPassword("");
+        map.put("user",apUser);
+        return ResponseResult.okResult(map);
+    }
+
+    @Override
+    public ResponseResult register(RegisterDto dto) {
+        if (StringUtils.isBlank(dto.getPhone()) || StringUtils.isBlank(dto.getCode()) || StringUtils.isBlank(dto.getPassword())) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
+        }
+        if (!codeStore.verify(dto.getPhone(), dto.getCode())) {
+            return ResponseResult.errorResult(500, "验证码错误或已过期");
+        }
+        ApUser existing = apUserMapper.selectUserByPhone(dto.getPhone());
+        if (existing != null) {
+            return ResponseResult.errorResult(500, "该手机号已注册");
+        }
+        String salt = MD5Utils.generateSalt();
+        ApUser user = new ApUser();
+        user.setPhone(dto.getPhone());
+        user.setSalt(salt);
+        user.setPassword(MD5Utils.encodeWithSalt(dto.getPassword(), salt));
+        user.setName(dto.getPhone());
+        user.setStatus(true);
+        user.setCreatedTime(LocalDateTime.now());
+        apUserMapper.insert(user);
+        codeStore.remove(dto.getPhone());
+        Map<String, Object> map = new HashMap<>();
+        map.put("token", AppJwtUtil.getToken(user.getId()));
+        return ResponseResult.okResult(map);
+    }
+
+    @Override
+    public ResponseResult sendCode(String phone) {
+        if (StringUtils.isBlank(phone)) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
+        }
+        String code = String.valueOf((int) ((Math.random() * 900000) + 100000));
+        codeStore.save(phone, code);
+        log.info("验证码已发送: phone={}, code={}", phone, code);
+        return ResponseResult.okResult(code);
+    }
+
+    @Override
+    public ResponseResult codeLogin(RegisterDto dto) {
+        if (StringUtils.isBlank(dto.getPhone()) || StringUtils.isBlank(dto.getCode())) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
+        }
+        if (!codeStore.verify(dto.getPhone(), dto.getCode())) {
+            return ResponseResult.errorResult(500, "验证码错误或已过期");
+        }
+        ApUser apUser = apUserMapper.selectUserByPhone(dto.getPhone());
+        if (apUser == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.AP_USER_DATA_NOT_EXIST, "用户不存在");
+        }
+        codeStore.remove(dto.getPhone());
+        Map<String, Object> map = new HashMap<>();
+        map.put("token", AppJwtUtil.getToken(apUser.getId()));
+        apUser.setSalt("");
+        apUser.setPassword("");
+        map.put("user", apUser);
+        return ResponseResult.okResult(map);
+    }
+
+    @Override
+    public ResponseResult resetPassword(RegisterDto dto) {
+        if (StringUtils.isBlank(dto.getPhone()) || StringUtils.isBlank(dto.getCode()) || StringUtils.isBlank(dto.getPassword())) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
+        }
+        if (!codeStore.verify(dto.getPhone(), dto.getCode())) {
+            return ResponseResult.errorResult(500, "验证码错误或已过期");
+        }
+        ApUser apUser = apUserMapper.selectUserByPhone(dto.getPhone());
+        if (apUser == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.AP_USER_DATA_NOT_EXIST, "用户不存在");
+        }
+        String salt = MD5Utils.generateSalt();
+        apUser.setSalt(salt);
+        apUser.setPassword(MD5Utils.encodeWithSalt(dto.getPassword(), salt));
+        apUserMapper.updateById(apUser);
+        codeStore.remove(dto.getPhone());
+        return ResponseResult.okResult(null);
+    }
+
+    @Override
+    public ResponseResult updateUserInfo(ApUser user) {
+        if (user.getId() == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
+        }
+        ApUser existing = apUserMapper.selectById(user.getId());
+        if (existing == null) {
+            return ResponseResult.errorResult(AppHttpCodeEnum.AP_USER_DATA_NOT_EXIST);
+        }
+        if (StringUtils.isNotBlank(user.getName())) existing.setName(user.getName());
+        if (StringUtils.isNotBlank(user.getImage())) existing.setImage(user.getImage());
+        if (user.getSex() != null) existing.setSex(user.getSex());
+        apUserMapper.updateById(existing);
+        return ResponseResult.okResult(null);
+    }
+}

+ 38 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/java/com/zhaozhaonews/user/util/VerificationCodeStore.java

@@ -0,0 +1,38 @@
+package com.zhaozhaonews.user.util;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component
+public class VerificationCodeStore {
+
+    private final Map<String, CacheEntry> store = new ConcurrentHashMap<>();
+
+    public void save(String phone, String code) {
+        store.put(phone, new CacheEntry(code, System.currentTimeMillis() + 300_000));
+    }
+
+    public boolean verify(String phone, String code) {
+        CacheEntry entry = store.get(phone);
+        if (entry == null || System.currentTimeMillis() > entry.expiry) {
+            store.remove(phone);
+            return false;
+        }
+        return entry.code.equals(code);
+    }
+
+    public void remove(String phone) {
+        store.remove(phone);
+    }
+
+    private static class CacheEntry {
+        final String code;
+        final long expiry;
+        CacheEntry(String code, long expiry) {
+            this.code = code;
+            this.expiry = expiry;
+        }
+    }
+}

+ 12 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/resources/bootstrap.yml

@@ -0,0 +1,12 @@
+server:
+  port: 51801
+spring:
+  application:
+    name: zhaozhaonews-user
+  cloud:
+    nacos:
+      discovery:
+        server-addr: 192.168.200.129:8848
+      config:
+        server-addr: 192.168.200.129:8848
+        file-extension: yaml

+ 95 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/resources/logback.xml

@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration debug="false" scan="true" scanPeriod="30 seconds">
+    <!-- ======================== 日志全局配置 ======================== -->
+    <!-- 日志文件保存根目录:项目根目录下的 logs 文件夹(跨平台兼容) -->
+    <property name="LOG_HOME" value="${user.dir}/logs"/>
+
+    <!-- ======================== 控制台输出(开发用) ======================== -->
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <!-- 日志输出格式编码器 -->
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <!-- 优化格式:增加颜色显示,更易读 -->
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}) - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- ======================== 文件输出(生产用) ======================== -->
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <!-- 当前日志文件路径 -->
+        <file>${LOG_HOME}/zhaozhaonews-user.log</file>
+        <!-- 滚动策略:同时支持 按天归档 + 按大小切割 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <!-- 日志文件命名规则:日期+序号 -->
+            <FileNamePattern>${LOG_HOME}/zhaozhaonews-user.log.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
+            <!-- 单个日志文件最大大小:超过自动切割 -->
+            <MaxFileSize>10MB</MaxFileSize>
+            <!-- 日志文件最多保留天数 -->
+            <MaxHistory>30</MaxHistory>
+            <!-- 所有日志文件总大小限制 -->
+            <totalSizeCap>1GB</totalSizeCap>
+        </rollingPolicy>
+        <!-- 文件日志输出格式 -->
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- ======================== 错误日志单独输出 ======================== -->
+    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_HOME}/error.log</file>
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>ERROR</level>
+        </filter>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <FileNamePattern>${LOG_HOME}/error.%d{yyyy-MM-dd}.log</FileNamePattern>
+            <MaxHistory>30</MaxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- ======================== 日志级别配置 ======================== -->
+
+    <!-- 1. 项目代码日志:DEBUG级别,便于调试 -->
+    <logger name="com.interview" level="DEBUG" additivity="false">
+        <appender-ref ref="STDOUT"/>
+        <appender-ref ref="FILE"/>
+    </logger>
+
+    <!-- 2. MyBatis-Plus SQL日志(关键修正!) -->
+    <logger name="com.baomidou.mybatisplus" level="DEBUG"/>
+
+    <!-- 3. Spring框架日志:INFO级别,减少噪音 -->
+    <logger name="org.springframework" level="INFO"/>
+
+    <!-- 4. Spring Web日志:DEBUG级别,便于查看请求响应 -->
+    <logger name="org.springframework.web" level="INFO"/>
+
+    <!-- 5. Knife4j/Swagger日志:减少噪音 -->
+    <logger name="springfox" level="WARN"/>
+    <logger name="io.swagger" level="WARN"/>
+
+    <!-- 6. 数据库连接池日志 -->
+    <logger name="com.zaxxer.hikari" level="INFO"/>
+
+    <!-- 7. JDBC SQL日志(查看原始SQL) -->
+    <logger name="java.sql.Connection" level="DEBUG"/>
+    <logger name="java.sql.Statement" level="DEBUG"/>
+    <logger name="java.sql.PreparedStatement" level="DEBUG"/>
+    <logger name="java.sql.ResultSet" level="DEBUG"/>
+
+    <!-- ======================== 根日志配置 ======================== -->
+    <!-- 全局默认日志级别:INFO -->
+    <root level="INFO">
+        <!-- 输出到控制台 -->
+        <appender-ref ref="STDOUT"/>
+        <!-- 输出到文件 -->
+        <appender-ref ref="FILE"/>
+        <!-- 错误日志单独输出 -->
+        <appender-ref ref="ERROR_FILE"/>
+    </root>
+</configuration>

+ 18 - 0
zhaozhaonews/zhaozhaonews-service/zhaozhaonews-user/src/main/resources/mapper/ApUserMapper.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.zhaozhaonews.user.mapper.ApUserMapper">
+    <!-- 通用查询结果列 -->
+    <sql id="Base_Column_List">
+        id, salt, name, password, phone, image, sex,
+        is_certification, is_identity_authentication,
+        status, flag, created_time
+    </sql>
+
+    <!-- ===================== 登录方法:根据手机号查询用户 ===================== -->
+    <select id="selectUserByPhone" resultType="com.zhaozhaonews.model.user.entity.ApUser">
+        SELECT <include refid="Base_Column_List"/>
+        FROM ap_user
+        WHERE phone = #{phone}
+    </select>
+</mapper>