阶段七:项目实战(持续进行)
纸上得来终觉浅,绝知此事要躬行。本阶段通过五个由易到难的实战项目,将前面所学知识融会贯通,构建完整的Java后端开发能力。
7.1 推荐实战项目(由易到难)
项目总览
| 序号 | 项目名称 | 技术栈 | 难度 | 预计周期 | 核心能力 |
|---|---|---|---|---|---|
| 1 | 个人博客系统 | Spring Boot + MyBatis + MySQL + Redis | ⭐⭐ | 2周 | 基础CRUD、缓存 |
| 2 | 电商系统 | Spring Cloud微服务架构 | ⭐⭐⭐⭐ | 4周 | 微服务、分布式 |
| 3 | 即时通讯系统 | WebSocket + Netty | ⭐⭐⭐ | 2周 | 长连接、高性能 |
| 4 | 短链接服务 | Redis + 分布式ID | ⭐⭐⭐ | 1周 | 高并发、高性能 |
| 5 | 权限管理系统 | RBAC + Spring Security + JWT | ⭐⭐⭐⭐ | 3周 | 安全、权限设计 |
7.1.1 个人博客系统
项目概述
一个功能完整的个人博客系统,支持文章发布、分类管理、标签管理、评论互动、用户管理等核心功能。
技术选型
| 层次 | 技术 | 说明 |
|---|---|---|
| 框架 | Spring Boot 3.x | 核心框架 |
| 持久层 | MyBatis-Plus | ORM框架 |
| 数据库 | MySQL 8.0 | 关系型数据库 |
| 缓存 | Redis | 热点数据缓存 |
| 认证 | JWT | 用户认证 |
| 文档 | Swagger/Knife4j | API文档 |
| 构建 | Maven | 项目构建 |
项目目录结构
blog-system/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/com/example/blog/
│ │ │ ├── BlogApplication.java # 启动类
│ │ │ ├── config/ # 配置类
│ │ │ │ ├── RedisConfig.java
│ │ │ │ ├── SwaggerConfig.java
│ │ │ │ ├── WebMvcConfig.java
│ │ │ │ └── MyBatisPlusConfig.java
│ │ │ ├── common/ # 公共模块
│ │ │ │ ├── Result.java # 统一响应
│ │ │ │ ├── PageResult.java # 分页响应
│ │ │ │ ├── BusinessException.java # 业务异常
│ │ │ │ ├── GlobalExceptionHandler.java # 全局异常处理
│ │ │ │ └── Constants.java # 常量定义
│ │ │ ├── entity/ # 实体类
│ │ │ │ ├── User.java
│ │ │ │ ├── Article.java
│ │ │ │ ├── Category.java
│ │ │ │ ├── Tag.java
│ │ │ │ ├── Comment.java
│ │ │ │ └── ArticleTag.java
│ │ │ ├── dto/ # 数据传输对象
│ │ │ │ ├── LoginDTO.java
│ │ │ │ ├── RegisterDTO.java
│ │ │ │ ├── ArticleDTO.java
│ │ │ │ ├── ArticleQueryDTO.java
│ │ │ │ └── CommentDTO.java
│ │ │ ├── vo/ # 视图对象
│ │ │ │ ├── UserVO.java
│ │ │ │ ├── ArticleVO.java
│ │ │ │ ├── ArticleDetailVO.java
│ │ │ │ └── CommentVO.java
│ │ │ ├── mapper/ # Mapper接口
│ │ │ │ ├── UserMapper.java
│ │ │ │ ├── ArticleMapper.java
│ │ │ │ ├── CategoryMapper.java
│ │ │ │ ├── TagMapper.java
│ │ │ │ └── CommentMapper.java
│ │ │ ├── service/ # 服务接口
│ │ │ │ ├── UserService.java
│ │ │ │ ├── ArticleService.java
│ │ │ │ ├── CategoryService.java
│ │ │ │ ├── TagService.java
│ │ │ │ └── CommentService.java
│ │ │ ├── service/impl/ # 服务实现
│ │ │ │ ├── UserServiceImpl.java
│ │ │ │ ├── ArticleServiceImpl.java
│ │ │ │ ├── CategoryServiceImpl.java
│ │ │ │ ├── TagServiceImpl.java
│ │ │ │ └── CommentServiceImpl.java
│ │ │ ├── controller/ # 控制器
│ │ │ │ ├── UserController.java
│ │ │ │ ├── ArticleController.java
│ │ │ │ ├── CategoryController.java
│ │ │ │ ├── TagController.java
│ │ │ │ └── CommentController.java
│ │ │ ├── interceptor/ # 拦截器
│ │ │ │ └── JwtInterceptor.java
│ │ │ └── util/ # 工具类
│ │ │ ├── JwtUtil.java
│ │ │ └── RedisUtil.java
│ │ └── resources/
│ │ ├── application.yml # 主配置
│ │ ├── application-dev.yml # 开发环境
│ │ ├── application-prod.yml # 生产环境
│ │ └── mapper/ # MyBatis XML
│ │ ├── ArticleMapper.xml
│ │ └── CommentMapper.xml
│ └── test/
│ └── java/com/example/blog/
│ ├── service/
│ │ └── ArticleServiceTest.java
│ └── controller/
│ └── ArticleControllerTest.java
└── sql/
└── blog_init.sql # 初始化SQL数据库设计
-- 用户表
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`password` VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密)',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`role` TINYINT NOT NULL DEFAULT 0 COMMENT '角色:0-普通用户,1-管理员',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 分类表
CREATE TABLE `category` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '分类ID',
`name` VARCHAR(50) NOT NULL COMMENT '分类名称',
`description` VARCHAR(200) DEFAULT NULL COMMENT '分类描述',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序序号',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分类表';
-- 标签表
CREATE TABLE `tag` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '标签ID',
`name` VARCHAR(50) NOT NULL COMMENT '标签名称',
`color` VARCHAR(20) DEFAULT '#409EFF' COMMENT '标签颜色',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签表';
-- 文章表
CREATE TABLE `article` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '文章ID',
`title` VARCHAR(200) NOT NULL COMMENT '文章标题',
`summary` VARCHAR(500) DEFAULT NULL COMMENT '文章摘要',
`content` LONGTEXT NOT NULL COMMENT '文章内容(Markdown)',
`cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图片URL',
`author_id` BIGINT NOT NULL COMMENT '作者ID',
`category_id` BIGINT DEFAULT NULL COMMENT '分类ID',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-草稿,1-已发布',
`view_count` INT NOT NULL DEFAULT 0 COMMENT '浏览次数',
`like_count` INT NOT NULL DEFAULT 0 COMMENT '点赞数',
`comment_count` INT NOT NULL DEFAULT 0 COMMENT '评论数',
`is_top` TINYINT NOT NULL DEFAULT 0 COMMENT '是否置顶:0-否,1-是',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_author_id` (`author_id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
-- 文章标签关联表
CREATE TABLE `article_tag` (
`article_id` BIGINT NOT NULL COMMENT '文章ID',
`tag_id` BIGINT NOT NULL COMMENT '标签ID',
PRIMARY KEY (`article_id`, `tag_id`),
KEY `idx_tag_id` (`tag_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章标签关联表';
-- 评论表
CREATE TABLE `comment` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '评论ID',
`content` VARCHAR(1000) NOT NULL COMMENT '评论内容',
`article_id` BIGINT NOT NULL COMMENT '文章ID',
`user_id` BIGINT NOT NULL COMMENT '评论用户ID',
`parent_id` BIGINT DEFAULT NULL COMMENT '父评论ID(用于回复)',
`reply_to_user_id` BIGINT DEFAULT NULL COMMENT '回复目标用户ID',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-待审核,1-已通过',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_article_id` (`article_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';核心代码示例
pom.xml 依赖配置:
<?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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>blog-system</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<jjwt.version>0.12.3</jjwt.version>
<knife4j.version>4.3.0</knife4j.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Knife4j(Swagger增强) -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>application.yml 配置:
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/blog_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4
username: root
password: ${DB_PASSWORD:123456}
data:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD:}
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.blog.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
jwt:
secret: ${JWT_SECRET:mySecretKeyForBlogSystemMustBeLongEnough2024}
expiration: 86400000 # 24小时
logging:
level:
com.example.blog: debug统一响应类 Result.java:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private int code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data);
}
public static <T> Result<T> success() {
return new Result<>(200, "success", null);
}
public static <T> Result<T> error(int code, String message) {
return new Result<>(code, message, null);
}
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null);
}
}全局异常处理 GlobalExceptionHandler.java:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 业务异常
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常:{}", e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
/**
* 参数校验异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
log.warn("参数校验失败:{}", message);
return Result.error(400, message);
}
/**
* 系统异常
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常:", e);
return Result.error("系统繁忙,请稍后重试");
}
}文章实体 Article.java:
@Data
@TableName("article")
public class Article {
@TableId(type = IdType.AUTO)
private Long id;
private String title;
private String summary;
private String content;
private String coverImage;
private Long authorId;
private Long categoryId;
private Integer status;
@TableField(fill = FieldFill.INSERT)
private Integer viewCount;
@TableField(fill = FieldFill.INSERT)
private Integer likeCount;
@TableField(fill = FieldFill.INSERT)
private Integer commentCount;
private Integer isTop;
@TableLogic
private Integer isDeleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}文章服务层 ArticleServiceImpl.java:
@Service
@Slf4j
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article>
implements ArticleService {
@Autowired
private ArticleTagMapper articleTagMapper;
@Autowired
private CategoryMapper categoryMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String ARTICLE_CACHE_KEY = "blog:article:";
private static final String ARTICLE_LIST_CACHE_KEY = "blog:article:list:";
/**
* 发布文章
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long publishArticle(ArticleDTO dto) {
// 1. 构建文章实体
Article article = new Article();
BeanUtils.copyProperties(dto, article);
article.setStatus(1); // 已发布
// 2. 保存文章
this.save(article);
// 3. 保存文章标签关联
if (dto.getTagIds() != null && !dto.getTagIds().isEmpty()) {
for (Long tagId : dto.getTagIds()) {
ArticleTag articleTag = new ArticleTag(article.getId(), tagId);
articleTagMapper.insert(articleTag);
}
}
// 4. 清除缓存
redisTemplate.delete(ARTICLE_LIST_CACHE_KEY + "*");
log.info("文章发布成功,ID:{}", article.getId());
return article.getId();
}
/**
* 分页查询文章列表(带缓存)
*/
@Override
public PageResult<ArticleVO> getArticleList(ArticleQueryDTO query) {
String cacheKey = ARTICLE_LIST_CACHE_KEY + query.getPage() + ":" + query.getSize();
// 尝试从缓存获取
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
try {
return new ObjectMapper().readValue(cached,
new TypeReference<PageResult<ArticleVO>>() {});
} catch (JsonProcessingException e) {
log.warn("缓存反序列化失败,重新查询");
}
}
// 构建查询条件
Page<Article> page = new Page<>(query.getPage(), query.getSize());
LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Article::getStatus, 1)
.eq(query.getCategoryId() != null, Article::getCategoryId, query.getCategoryId())
.like(StringUtils.hasText(query.getKeyword()), Article::getTitle, query.getKeyword())
.orderByDesc(Article::getIsTop)
.orderByDesc(Article::getCreatedAt);
Page<Article> result = this.page(page, wrapper);
// 转换为VO
List<ArticleVO> voList = result.getRecords().stream()
.map(this::convertToVO)
.collect(Collectors.toList());
PageResult<ArticleVO> pageResult = new PageResult<>(
voList, result.getTotal(), query.getPage(), query.getSize());
// 写入缓存(30分钟过期)
try {
redisTemplate.opsForValue().set(cacheKey,
new ObjectMapper().writeValueAsString(pageResult),
30, TimeUnit.MINUTES);
} catch (JsonProcessingException e) {
log.warn("缓存序列化失败");
}
return pageResult;
}
/**
* 获取文章详情
*/
@Override
public ArticleDetailVO getArticleDetail(Long id) {
// 尝试从缓存获取
String cacheKey = ARTICLE_CACHE_KEY + id;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
try {
return new ObjectMapper().readValue(cached, ArticleDetailVO.class);
} catch (JsonProcessingException e) {
log.warn("缓存反序列化失败");
}
}
// 查询文章
Article article = this.getById(id);
if (article == null || article.getStatus() != 1) {
throw new BusinessException(404, "文章不存在");
}
// 增加浏览次数
this.update(new UpdateWrapper<Article>()
.eq("id", id)
.setSql("view_count = view_count + 1"));
// 转换为详情VO
ArticleDetailVO vo = new ArticleDetailVO();
BeanUtils.copyProperties(article, vo);
vo.setViewCount(article.getViewCount() + 1);
// 查询作者信息
User author = userMapper.selectById(article.getAuthorId());
vo.setAuthorName(author.getNickname());
vo.setAuthorAvatar(author.getAvatar());
// 查询分类信息
if (article.getCategoryId() != null) {
Category category = categoryMapper.selectById(article.getCategoryId());
vo.setCategoryName(category.getName());
}
// 查询标签列表
List<Tag> tags = articleTagMapper.selectTagsByArticleId(id);
vo.setTags(tags);
// 写入缓存
try {
redisTemplate.opsForValue().set(cacheKey,
new ObjectMapper().writeValueAsString(vo),
1, TimeUnit.HOURS);
} catch (JsonProcessingException e) {
log.warn("缓存序列化失败");
}
return vo;
}
/**
* 转换为VO
*/
private ArticleVO convertToVO(Article article) {
ArticleVO vo = new ArticleVO();
BeanUtils.copyProperties(article, vo);
// 查询作者
User author = userMapper.selectById(article.getAuthorId());
vo.setAuthorName(author.getNickname());
// 查询分类
if (article.getCategoryId() != null) {
Category category = categoryMapper.selectById(article.getCategoryId());
vo.setCategoryName(category.getName());
}
// 查询标签
List<Tag> tags = articleTagMapper.selectTagsByArticleId(article.getId());
vo.setTags(tags);
return vo;
}
}文章控制器 ArticleController.java:
@RestController
@RequestMapping("/api/articles")
@Tag(name = "文章管理")
@Slf4j
public class ArticleController {
@Autowired
private ArticleService articleService;
@GetMapping
@Operation(summary = "分页查询文章列表")
public Result<PageResult<ArticleVO>> list(ArticleQueryDTO query) {
PageResult<ArticleVO> result = articleService.getArticleList(query);
return Result.success(result);
}
@GetMapping("/{id}")
@Operation(summary = "获取文章详情")
public Result<ArticleDetailVO> detail(@PathVariable Long id) {
ArticleDetailVO vo = articleService.getArticleDetail(id);
return Result.success(vo);
}
@PostMapping
@Operation(summary = "发布文章")
public Result<Long> publish(@RequestBody @Valid ArticleDTO dto) {
Long id = articleService.publishArticle(dto);
return Result.success(id);
}
@PutMapping("/{id}")
@Operation(summary = "更新文章")
public Result<Void> update(@PathVariable Long id, @RequestBody @Valid ArticleDTO dto) {
articleService.updateArticle(id, dto);
return Result.success();
}
@DeleteMapping("/{id}")
@Operation(summary = "删除文章")
public Result<Void> delete(@PathVariable Long id) {
articleService.deleteArticle(id);
return Result.success();
}
@PostMapping("/{id}/like")
@Operation(summary = "点赞文章")
public Result<Void> like(@PathVariable Long id) {
articleService.likeArticle(id);
return Result.success();
}
}JWT工具类 JwtUtil.java:
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
/**
* 生成Token
*/
public String generateToken(Long userId, String username) {
return Jwts.builder()
.subject(username)
.claim("userId", userId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
/**
* 解析Token
*/
public Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 验证Token
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false;
}
}
/**
* 获取用户ID
*/
public Long getUserId(String token) {
Claims claims = parseToken(token);
return claims.get("userId", Long.class);
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(
Base64.getEncoder().encodeToString(secret.getBytes()));
return Keys.hmacShaKeyFor(keyBytes);
}
}JWT拦截器 JwtInterceptor.java:
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
// 放行OPTIONS预检请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
token = token.substring(7);
}
if (!StringUtils.hasText(token) || !jwtUtil.validateToken(token)) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
try {
response.getWriter().write("{\"code\":401,\"message\":\"未登录或Token已过期\"}");
} catch (IOException e) {
throw new RuntimeException(e);
}
return false;
}
// 将用户信息存入request属性
Long userId = jwtUtil.getUserId(token);
request.setAttribute("userId", userId);
return true;
}
}接口设计
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
| POST | /api/users/register | 用户注册 | 否 |
| POST | /api/users/login | 用户登录 | 否 |
| GET | /api/users/info | 获取用户信息 | 是 |
| GET | /api/articles | 文章列表(分页) | 否 |
| GET | /api/articles/ | 文章详情 | 否 |
| POST | /api/articles | 发布文章 | 是 |
| PUT | /api/articles/ | 更新文章 | 是 |
| DELETE | /api/articles/ | 删除文章 | 是 |
| POST | /api/articles/{id}/like | 点赞文章 | 是 |
| GET | /api/categories | 分类列表 | 否 |
| POST | /api/categories | 创建分类 | 是 |
| GET | /api/tags | 标签列表 | 否 |
| POST | /api/tags | 创建标签 | 是 |
| GET | /api/articles/{id}/comments | 评论列表 | 否 |
| POST | /api/articles/{id}/comments | 发表评论 | 是 |
开发步骤
环境搭建(第1-2天)
- 创建Spring Boot项目
- 配置MySQL、Redis
- 集成MyBatis-Plus
- 配置Swagger文档
用户模块(第3-4天)
- 用户注册/登录
- JWT认证
- 用户信息管理
文章模块(第5-8天)
- 文章CRUD
- 分页查询
- Redis缓存
- 文章详情(含作者、分类、标签)
分类与标签(第9-10天)
- 分类管理
- 标签管理
- 文章-标签多对多关联
评论模块(第11-12天)
- 发表评论
- 评论列表(树形结构)
- 评论数统计
优化与测试(第13-14天)
- 接口测试
- 性能优化
- 部署上线
7.1.2 电商系统(微服务架构)
项目概述
基于Spring Cloud的微服务电商系统,包含用户服务、商品服务、订单服务、支付服务四大核心模块。
技术选型
| 层次 | 技术 | 说明 |
|---|---|---|
| 微服务框架 | Spring Cloud | 微服务治理 |
| 注册中心 | Nacos | 服务注册与发现 |
| 配置中心 | Nacos Config | 动态配置管理 |
| 网关 | Spring Cloud Gateway | API网关 |
| 服务调用 | OpenFeign | 声明式HTTP调用 |
| 负载均衡 | Spring Cloud LoadBalancer | 客户端负载均衡 |
| 熔断降级 | Sentinel | 流量控制与熔断 |
| 数据库 | MySQL 8.0 | 关系型数据库 |
| 缓存 | Redis | 分布式缓存 |
| 消息队列 | RabbitMQ | 异步消息 |
| 分布式事务 | Seata | 分布式事务 |
| 分布式ID | Snowflake | 雪花算法 |
项目目录结构
ecommerce-system/
├── pom.xml # 父POM
├── ecommerce-gateway/ # API网关
│ ├── pom.xml
│ └── src/main/java/com/example/gateway/
│ ├── GatewayApplication.java
│ ├── config/
│ │ └── GatewayConfig.java
│ └── filter/
│ └── AuthGlobalFilter.java
├── ecommerce-common/ # 公共模块
│ ├── pom.xml
│ └── src/main/java/com/example/common/
│ ├── result/Result.java
│ ├── exception/BusinessException.java
│ ├── util/SnowflakeIdGenerator.java
│ └── constant/OrderConstants.java
├── ecommerce-user/ # 用户服务
│ ├── pom.xml
│ └── src/main/java/com/example/user/
│ ├── UserApplication.java
│ ├── entity/User.java
│ ├── entity/UserAddress.java
│ ├── mapper/UserMapper.java
│ ├── mapper/UserAddressMapper.java
│ ├── service/UserService.java
│ ├── service/impl/UserServiceImpl.java
│ ├── controller/UserController.java
│ └── dto/LoginDTO.java
├── ecommerce-product/ # 商品服务
│ ├── pom.xml
│ └── src/main/java/com/example/product/
│ ├── ProductApplication.java
│ ├── entity/Product.java
│ ├── entity/ProductCategory.java
│ ├── entity/ProductSku.java
│ ├── mapper/ProductMapper.java
│ ├── service/ProductService.java
│ ├── service/impl/ProductServiceImpl.java
│ ├── controller/ProductController.java
│ └── dto/StockDeductionDTO.java
├── ecommerce-order/ # 订单服务
│ ├── pom.xml
│ └── src/main/java/com/example/order/
│ ├── OrderApplication.java
│ ├── entity/Order.java
│ ├── entity/OrderItem.java
│ ├── mapper/OrderMapper.java
│ ├── mapper/OrderItemMapper.java
│ ├── service/OrderService.java
│ ├── service/impl/OrderServiceImpl.java
│ ├── controller/OrderController.java
│ ├── feign/ProductFeignClient.java
│ ├── feign/UserFeignClient.java
│ ├── listener/OrderMessageListener.java
│ └── dto/CreateOrderDTO.java
├── ecommerce-payment/ # 支付服务
│ ├── pom.xml
│ └── src/main/java/com/example/payment/
│ ├── PaymentApplication.java
│ ├── entity/PaymentRecord.java
│ ├── mapper/PaymentRecordMapper.java
│ ├── service/PaymentService.java
│ ├── service/impl/PaymentServiceImpl.java
│ ├── controller/PaymentController.java
│ └── strategy/
│ ├── PaymentStrategy.java
│ ├── AlipayStrategy.java
│ └── WechatPayStrategy.java
└── sql/
├── ecommerce_user.sql
├── ecommerce_product.sql
├── ecommerce_order.sql
└── ecommerce_payment.sql数据库设计
用户服务数据库:
-- 用户表
CREATE TABLE `user` (
`id` BIGINT NOT NULL COMMENT '用户ID(雪花算法)',
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`password` VARCHAR(100) NOT NULL COMMENT '密码',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像',
`gender` TINYINT DEFAULT 0 COMMENT '性别:0-未知,1-男,2-女',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 收货地址表
CREATE TABLE `user_address` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`receiver_name` VARCHAR(50) NOT NULL COMMENT '收货人',
`receiver_phone` VARCHAR(20) NOT NULL COMMENT '收货电话',
`province` VARCHAR(50) NOT NULL COMMENT '省',
`city` VARCHAR(50) NOT NULL COMMENT '市',
`district` VARCHAR(50) NOT NULL COMMENT '区',
`detail_address` VARCHAR(200) NOT NULL COMMENT '详细地址',
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收货地址表';商品服务数据库:
-- 商品分类表
CREATE TABLE `product_category` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(50) NOT NULL COMMENT '分类名称',
`parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父分类ID',
`level` TINYINT NOT NULL COMMENT '层级:1-一级,2-二级,3-三级',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
`icon` VARCHAR(255) DEFAULT NULL COMMENT '图标',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表';
-- 商品表(SPU)
CREATE TABLE `product` (
`id` BIGINT NOT NULL COMMENT '商品ID',
`name` VARCHAR(200) NOT NULL COMMENT '商品名称',
`category_id` BIGINT NOT NULL COMMENT '分类ID',
`brand` VARCHAR(100) DEFAULT NULL COMMENT '品牌',
`description` TEXT COMMENT '商品描述',
`main_image` VARCHAR(255) NOT NULL COMMENT '主图',
`images` VARCHAR(1000) DEFAULT NULL COMMENT '图片列表(JSON)',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-下架,1-上架',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
-- 商品SKU表
CREATE TABLE `product_sku` (
`id` BIGINT NOT NULL COMMENT 'SKU ID',
`product_id` BIGINT NOT NULL COMMENT '商品ID',
`sku_name` VARCHAR(200) NOT NULL COMMENT 'SKU名称',
`sku_attrs` VARCHAR(500) DEFAULT NULL COMMENT 'SKU属性(JSON)',
`price` DECIMAL(10,2) NOT NULL COMMENT '价格',
`original_price` DECIMAL(10,2) DEFAULT NULL COMMENT '原价',
`stock` INT NOT NULL DEFAULT 0 COMMENT '库存',
`locked_stock` INT NOT NULL DEFAULT 0 COMMENT '锁定库存',
`image` VARCHAR(255) DEFAULT NULL COMMENT 'SKU图片',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU表';订单服务数据库:
-- 订单表
CREATE TABLE `order` (
`id` BIGINT NOT NULL COMMENT '订单ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单编号',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额',
`pay_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '实付金额',
`freight_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '运费',
`discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '优惠金额',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待支付,1-已支付,2-已发货,3-已完成,4-已取消,5-已退款',
`receiver_name` VARCHAR(50) NOT NULL COMMENT '收货人',
`receiver_phone` VARCHAR(20) NOT NULL COMMENT '收货电话',
`receiver_address` VARCHAR(300) NOT NULL COMMENT '收货地址',
`payment_time` DATETIME DEFAULT NULL COMMENT '支付时间',
`delivery_time` DATETIME DEFAULT NULL COMMENT '发货时间',
`receive_time` DATETIME DEFAULT NULL COMMENT '收货时间',
`expire_time` DATETIME NOT NULL COMMENT '过期时间(未支付自动取消)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
-- 订单明细表
CREATE TABLE `order_item` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`order_id` BIGINT NOT NULL COMMENT '订单ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单编号',
`product_id` BIGINT NOT NULL COMMENT '商品ID',
`sku_id` BIGINT NOT NULL COMMENT 'SKU ID',
`product_name` VARCHAR(200) NOT NULL COMMENT '商品名称',
`sku_name` VARCHAR(200) NOT NULL COMMENT 'SKU名称',
`product_image` VARCHAR(255) DEFAULT NULL COMMENT '商品图片',
`price` DECIMAL(10,2) NOT NULL COMMENT '单价',
`quantity` INT NOT NULL COMMENT '数量',
`total_amount` DECIMAL(10,2) NOT NULL COMMENT '小计金额',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';支付服务数据库:
-- 支付记录表
CREATE TABLE `payment_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`payment_no` VARCHAR(32) NOT NULL COMMENT '支付流水号',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单编号',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`amount` DECIMAL(10,2) NOT NULL COMMENT '支付金额',
`pay_channel` VARCHAR(20) NOT NULL COMMENT '支付渠道:ALIPAY/WECHAT',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待支付,1-支付成功,2-支付失败',
`transaction_id` VARCHAR(64) DEFAULT NULL COMMENT '第三方交易号',
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
`callback_content` TEXT DEFAULT NULL COMMENT '回调内容',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_payment_no` (`payment_no`),
KEY `idx_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付记录表';核心代码示例
父POM pom.xml:
<?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>
<groupId>com.example</groupId>
<artifactId>ecommerce-system</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>ecommerce-common</module>
<module>ecommerce-gateway</module>
<module>ecommerce-user</module>
<module>ecommerce-product</module>
<module>ecommerce-order</module>
<module>ecommerce-payment</module>
</modules>
<properties>
<java.version>17</java.version>
<spring-boot.version>3.2.0</spring-boot.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
<spring-cloud-alibaba.version>2023.0.0.0</spring-cloud-alibaba.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<seata.version>2.0.0</seata.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>雪花算法ID生成器 SnowflakeIdGenerator.java:
public class SnowflakeIdGenerator {
private final long epoch = 1704067200000L; // 2024-01-01 00:00:00
private final long workerIdBits = 5L;
private final long datacenterIdBits = 5L;
private final long sequenceBits = 12L;
private final long maxWorkerId = ~(-1L << workerIdBits);
private final long maxDatacenterId = ~(-1L << datacenterIdBits);
private final long sequenceMask = ~(-1L << sequenceBits);
private final long workerIdShift = sequenceBits;
private final long datacenterIdShift = sequenceBits + workerIdBits;
private final long timestampShift = sequenceBits + workerIdBits + datacenterIdBits;
private long workerId;
private long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException("workerId不合法");
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException("datacenterId不合法");
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成ID");
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - epoch) << timestampShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}订单服务核心逻辑 OrderServiceImpl.java:
@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order>
implements OrderService {
@Autowired
private OrderItemMapper orderItemMapper;
@Autowired
private ProductFeignClient productFeignClient;
@Autowired
private UserFeignClient userFeignClient;
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private StringRedisTemplate redisTemplate;
private final SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(1, 1);
/**
* 创建订单(核心流程)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public String createOrder(CreateOrderDTO dto, Long userId) {
log.info("开始创建订单,用户ID:{}", userId);
// 1. 获取收货地址
UserAddressDTO address = userFeignClient.getAddress(dto.getAddressId());
if (address == null) {
throw new BusinessException("收货地址不存在");
}
// 2. 获取商品信息并计算总价
BigDecimal totalAmount = BigDecimal.ZERO;
List<OrderItem> orderItems = new ArrayList<>();
for (OrderItemDTO itemDTO : dto.getItems()) {
// 查询SKU信息
SkuDTO sku = productFeignClient.getSku(itemDTO.getSkuId());
if (sku == null) {
throw new BusinessException("商品SKU不存在:" + itemDTO.getSkuId());
}
// 检查库存
if (sku.getStock() - sku.getLockedStock() < itemDTO.getQuantity()) {
throw new BusinessException("商品库存不足:" + sku.getSkuName());
}
// 构建订单明细
OrderItem item = new OrderItem();
item.setProductId(sku.getProductId());
item.setSkuId(sku.getId());
item.setProductName(sku.getProductName());
item.setSkuName(sku.getSkuName());
item.setProductImage(sku.getImage());
item.setPrice(sku.getPrice());
item.setQuantity(itemDTO.getQuantity());
item.setTotalAmount(sku.getPrice().multiply(
new BigDecimal(itemDTO.getQuantity())));
orderItems.add(item);
totalAmount = totalAmount.add(item.getTotalAmount());
}
// 3. 生成订单编号
String orderNo = generateOrderNo(userId);
// 4. 创建订单
Order order = new Order();
order.setId(idGenerator.nextId());
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setTotalAmount(totalAmount);
order.setPayAmount(totalAmount); // 暂不计算优惠
order.setFreightAmount(BigDecimal.ZERO);
order.setDiscountAmount(BigDecimal.ZERO);
order.setStatus(OrderConstants.STATUS_PENDING);
order.setReceiverName(address.getReceiverName());
order.setReceiverPhone(address.getReceiverPhone());
order.setReceiverAddress(
address.getProvince() + address.getCity() +
address.getDistrict() + address.getDetailAddress());
order.setExpireTime(LocalDateTime.now().plusMinutes(30)); // 30分钟过期
this.save(order);
// 5. 保存订单明细
for (OrderItem item : orderItems) {
item.setOrderId(order.getId());
item.setOrderNo(orderNo);
orderItemMapper.insert(item);
}
// 6. 锁定库存(调用商品服务)
List<StockDeductionDTO> stockList = dto.getItems().stream()
.map(item -> new StockDeductionDTO(item.getSkuId(), item.getQuantity()))
.collect(Collectors.toList());
productFeignClient.lockStock(stockList);
// 7. 发送延迟消息(30分钟后自动取消未支付订单)
rabbitTemplate.convertAndSend(
"order.exchange", "order.delay",
orderNo,
message -> {
message.getMessageProperties().setDelay(30 * 60 * 1000);
return message;
}
);
// 8. 清除购物车(异步)
List<Long> skuIds = dto.getItems().stream()
.map(OrderItemDTO::getSkuId)
.collect(Collectors.toList());
rabbitTemplate.convertAndSend("cart.exchange", "cart.clear",
new CartClearMessage(userId, skuIds));
log.info("订单创建成功,订单号:{}", orderNo);
return orderNo;
}
/**
* 支付订单
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void payOrder(String orderNo, String payChannel) {
Order order = this.getOne(
new LambdaQueryWrapper<Order>().eq(Order::getOrderNo, orderNo));
if (order == null) {
throw new BusinessException("订单不存在");
}
if (order.getStatus() != OrderConstants.STATUS_PENDING) {
throw new BusinessException("订单状态异常");
}
// 调用支付服务(通过Feign)
// paymentFeignClient.pay(orderNo, payChannel, order.getPayAmount());
// 更新订单状态
order.setStatus(OrderConstants.STATUS_PAID);
order.setPaymentTime(LocalDateTime.now());
this.updateById(order);
// 扣减库存(确认)
productFeignClient.confirmStock(orderNo);
// 发送订单已支付消息
rabbitTemplate.convertAndSend("order.exchange", "order.paid", orderNo);
}
/**
* 取消订单
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelOrder(String orderNo) {
Order order = this.getOne(
new LambdaQueryWrapper<Order>().eq(Order::getOrderNo, orderNo));
if (order == null) {
throw new BusinessException("订单不存在");
}
if (order.getStatus() != OrderConstants.STATUS_PENDING) {
throw new BusinessException("只能取消待支付订单");
}
// 更新订单状态
order.setStatus(OrderConstants.STATUS_CANCELLED);
this.updateById(order);
// 释放库存
productFeignClient.unlockStock(orderNo);
log.info("订单已取消,订单号:{}", orderNo);
}
/**
* 生成订单编号:时间戳 + 用户ID后4位 + 随机数
*/
private String generateOrderNo(Long userId) {
String timestamp = String.valueOf(System.currentTimeMillis());
String userSuffix = String.format("%04d", userId % 10000);
String random = String.format("%04d", new Random().nextInt(10000));
return timestamp + userSuffix + random;
}
}Feign客户端 ProductFeignClient.java:
@FeignClient(name = "ecommerce-product", fallbackFactory = ProductFeignFallback.class)
public interface ProductFeignClient {
@GetMapping("/api/skus/{id}")
SkuDTO getSku(@PathVariable("id") Long id);
@PostMapping("/api/stocks/lock")
Result<Void> lockStock(@RequestBody List<StockDeductionDTO> stockList);
@PostMapping("/api/stocks/confirm/{orderNo}")
Result<Void> confirmStock(@PathVariable("orderNo") String orderNo);
@PostMapping("/api/stocks/unlock/{orderNo}")
Result<Void> unlockStock(@PathVariable("orderNo") String orderNo);
}Sentinel熔断降级配置:
@Component
public class ProductFeignFallback implements FallbackFactory<ProductFeignClient> {
@Override
public ProductFeignClient create(Throwable cause) {
return new ProductFeignClient() {
@Override
public SkuDTO getSku(Long id) {
log.error("商品服务调用失败,降级处理:{}", cause.getMessage());
return null;
}
@Override
public Result<Void> lockStock(List<StockDeductionDTO> stockList) {
log.error("锁定库存失败,降级处理");
return Result.error("系统繁忙,请稍后重试");
}
@Override
public Result<Void> confirmStock(String orderNo) {
log.error("确认库存失败,降级处理");
return Result.error("系统繁忙,请稍后重试");
}
@Override
public Result<Void> unlockStock(String orderNo) {
log.error("释放库存失败,降级处理");
return Result.error("系统繁忙,请稍后重试");
}
};
}
}网关全局过滤器 AuthGlobalFilter.java:
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private static final List<String> WHITE_LIST = List.of(
"/api/user/login",
"/api/user/register",
"/api/product/list",
"/api/product/detail"
);
@Autowired
private JwtUtil jwtUtil;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
// 白名单直接放行
if (WHITE_LIST.stream().anyMatch(path::startsWith)) {
return chain.filter(exchange);
}
// 获取Token
String token = request.getHeaders().getFirst("Authorization");
if (!StringUtils.hasText(token) || !token.startsWith("Bearer ")) {
return unauthorized(exchange);
}
token = token.substring(7);
// 验证Token
if (!jwtUtil.validateToken(token)) {
return unauthorized(exchange);
}
// 将用户信息传递到下游服务
Long userId = jwtUtil.getUserId(token);
ServerHttpRequest mutatedRequest = request.mutate()
.header("X-User-Id", userId.toString())
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
@Override
public int getOrder() {
return -1;
}
private Mono<Void> unauthorized(ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = "{\"code\":401,\"message\":\"未登录或Token已过期\"}";
DataBuffer buffer = response.bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
}支付策略模式:
// 支付策略接口
public interface PaymentStrategy {
String getChannel();
PaymentResult pay(String orderNo, BigDecimal amount);
boolean handleCallback(String callbackData);
}
// 支付宝支付策略
@Component
public class AlipayStrategy implements PaymentStrategy {
@Override
public String getChannel() {
return "ALIPAY";
}
@Override
public PaymentResult pay(String orderNo, BigDecimal amount) {
// 调用支付宝SDK
// AlipayClient alipayClient = ...
// AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
// ...
return new PaymentResult(true, "ALIPAY_" + orderNo);
}
@Override
public boolean handleCallback(String callbackData) {
// 验证支付宝回调签名
// 更新支付状态
return true;
}
}
// 微信支付策略
@Component
public class WechatPayStrategy implements PaymentStrategy {
@Override
public String getChannel() {
return "WECHAT";
}
@Override
public PaymentResult pay(String orderNo, BigDecimal amount) {
// 调用微信支付SDK
return new PaymentResult(true, "WECHAT_" + orderNo);
}
@Override
public boolean handleCallback(String callbackData) {
// 验证微信回调签名
return true;
}
}
// 支付上下文(策略工厂)
@Component
public class PaymentContext {
@Autowired
private List<PaymentStrategy> strategies;
private Map<String, PaymentStrategy> strategyMap;
@PostConstruct
public void init() {
strategyMap = strategies.stream()
.collect(Collectors.toMap(PaymentStrategy::getChannel, s -> s));
}
public PaymentStrategy getStrategy(String channel) {
PaymentStrategy strategy = strategyMap.get(channel);
if (strategy == null) {
throw new BusinessException("不支持的支付渠道:" + channel);
}
return strategy;
}
}网关配置
server:
port: 8080
spring:
application:
name: ecommerce-gateway
cloud:
nacos:
discovery:
server-addr: localhost:8848
gateway:
routes:
- id: user-service
uri: lb://ecommerce-user
predicates:
- Path=/api/user/**
- id: product-service
uri: lb://ecommerce-product
predicates:
- Path=/api/product/**
- id: order-service
uri: lb://ecommerce-order
predicates:
- Path=/api/order/**
- id: payment-service
uri: lb://ecommerce-payment
predicates:
- Path=/api/payment/**
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Origin接口设计
| 服务 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 用户 | POST | /api/user/register | 注册 |
| 用户 | POST | /api/user/login | 登录 |
| 用户 | GET | /api/user/info | 用户信息 |
| 用户 | GET | /api/user/addresses | 地址列表 |
| 商品 | GET | /api/product/list | 商品列表 |
| 商品 | GET | /api/product/detail/ | 商品详情 |
| 商品 | GET | /api/product/categories | 分类列表 |
| 商品 | POST | /api/stocks/lock | 锁定库存 |
| 订单 | POST | /api/order/create | 创建订单 |
| 订单 | GET | /api/order/list | 订单列表 |
| 订单 | GET | /api/order/detail/ | 订单详情 |
| 订单 | POST | /api/order/pay/ | 支付订单 |
| 订单 | POST | /api/order/cancel/ | 取消订单 |
| 支付 | POST | /api/payment/pay | 发起支付 |
| 支付 | POST | /api/payment/callback/ | 支付回调 |
开发步骤
基础设施搭建(第1-3天)
- 搭建Nacos注册中心与配置中心
- 创建父工程与公共模块
- 搭建Gateway网关
- 配置各微服务基础框架
用户服务(第4-6天)
- 用户注册/登录
- JWT认证
- 收货地址管理
商品服务(第7-10天)
- 商品分类管理
- 商品SPU/SKU管理
- 库存管理(预扣/确认/释放)
- 商品搜索
订单服务(第11-16天)
- 创建订单流程
- Feign远程调用
- RabbitMQ延迟队列(订单超时取消)
- 分布式事务处理
支付服务(第17-20天)
- 支付策略模式
- 支付回调处理
- 退款流程
联调与优化(第21-28天)
- 服务间联调
- Sentinel限流熔断
- 性能测试
- Docker容器化部署
7.1.3 即时通讯系统
项目概述
基于WebSocket和Netty的高性能即时通讯系统,支持单聊、群聊、在线状态管理、消息历史记录、离线消息推送等功能。
技术选型
| 层次 | 技术 | 说明 |
|---|---|---|
| 网络框架 | Netty | 高性能NIO框架 |
| 协议 | WebSocket | 全双工通信 |
| 序列化 | Protobuf / JSON | 消息序列化 |
| 数据库 | MySQL | 消息持久化 |
| 缓存 | Redis | 在线状态、未读计数 |
| 消息队列 | RabbitMQ | 消息异步处理 |
项目目录结构
im-system/
├── pom.xml
├── src/main/java/com/example/im/
│ ├── ImApplication.java
│ ├── config/
│ │ ├── NettyConfig.java
│ │ └── WebSocketConfig.java
│ ├── netty/
│ │ ├── NettyServer.java # Netty服务端
│ │ ├── NettyServerInitializer.java
│ │ ├── WebSocketChannelHandler.java
│ │ ├── HeartbeatHandler.java
│ │ └── MessageHandler.java
│ ├── protocol/
│ │ ├── MessageProtocol.java # 消息协议
│ │ ├── MessageType.java
│ │ └── MessageCodec.java # 编解码器
│ ├── entity/
│ │ ├── User.java
│ │ ├── Message.java
│ │ ├── Group.java
│ │ └── GroupMember.java
│ ├── mapper/
│ │ ├── UserMapper.java
│ │ ├── MessageMapper.java
│ │ ├── GroupMapper.java
│ │ └── GroupMemberMapper.java
│ ├── service/
│ │ ├── ChatService.java
│ │ ├── GroupService.java
│ │ ├── MessageService.java
│ │ └── OnlineService.java
│ ├── service/impl/
│ │ ├── ChatServiceImpl.java
│ │ ├── GroupServiceImpl.java
│ │ ├── MessageServiceImpl.java
│ │ └── OnlineServiceImpl.java
│ ├── controller/
│ │ ├── ChatController.java
│ │ ├── GroupController.java
│ │ └── MessageController.java
│ ├── session/
│ │ ├── UserSession.java # 用户会话管理
│ │ └── SessionManager.java
│ └── handler/
│ ├── SingleChatHandler.java # 单聊处理
│ ├── GroupChatHandler.java # 群聊处理
│ └── SystemHandler.java # 系统消息处理
└── src/main/resources/
└── application.yml数据库设计
-- 用户表
CREATE TABLE `im_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`password` VARCHAR(100) NOT NULL COMMENT '密码',
`nickname` VARCHAR(50) NOT NULL COMMENT '昵称',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像',
`online_status` TINYINT NOT NULL DEFAULT 0 COMMENT '在线状态:0-离线,1-在线',
`last_login_time` DATETIME DEFAULT NULL COMMENT '最后登录时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 消息表
CREATE TABLE `im_message` (
`id` BIGINT NOT NULL COMMENT '消息ID',
`from_user_id` BIGINT NOT NULL COMMENT '发送者ID',
`to_id` BIGINT NOT NULL COMMENT '接收者(用户ID或群ID)',
`msg_type` TINYINT NOT NULL COMMENT '消息类型:1-单聊,2-群聊,3-系统',
`content_type` VARCHAR(20) NOT NULL COMMENT '内容类型:TEXT/IMAGE/FILE/VOICE',
`content` TEXT NOT NULL COMMENT '消息内容',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-发送中,1-已送达,2-已读',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_from_user` (`from_user_id`),
KEY `idx_to_id` (`to_id`, `msg_type`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息表';
-- 群组表
CREATE TABLE `im_group` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(50) NOT NULL COMMENT '群名称',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '群头像',
`owner_id` BIGINT NOT NULL COMMENT '群主ID',
`description` VARCHAR(200) DEFAULT NULL COMMENT '群描述',
`max_members` INT NOT NULL DEFAULT 200 COMMENT '最大成员数',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_owner_id` (`owner_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群组表';
-- 群成员表
CREATE TABLE `im_group_member` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`group_id` BIGINT NOT NULL COMMENT '群ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '群内昵称',
`role` TINYINT NOT NULL DEFAULT 0 COMMENT '角色:0-成员,1-管理员,2-群主',
`muted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否禁言',
`joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_user` (`group_id`, `user_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群成员表';
-- 用户关系表(好友)
CREATE TABLE `im_friendship` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`friend_id` BIGINT NOT NULL COMMENT '好友ID',
`remark` VARCHAR(50) DEFAULT NULL COMMENT '备注名',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待确认,1-已同意',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`),
KEY `idx_friend_id` (`friend_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友关系表';核心代码示例
Netty服务端 NettyServer.java:
@Component
@Slf4j
public class NettyServer {
@Value("${netty.port:8888}")
private int port;
@Autowired
private WebSocketChannelHandler channelHandler;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
@PostConstruct
public void start() {
new Thread(this::init).start();
}
private void init() {
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.option(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// HTTP编解码器(WebSocket基于HTTP升级)
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new ChunkedWriteHandler());
// HTTP聚合器(WebSocket握手需要)
pipeline.addLast(new HttpObjectAggregator(65536));
// WebSocket协议处理器
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
// 心跳检测
pipeline.addLast(new IdleStateHandler(60, 30, 0));
pipeline.addLast(new HeartbeatHandler());
// 消息处理
pipeline.addLast(channelHandler);
}
});
ChannelFuture future = bootstrap.bind(port).sync();
log.info("Netty WebSocket服务器启动,端口:{}", port);
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.error("Netty服务器异常", e);
Thread.currentThread().interrupt();
} finally {
shutdown();
}
}
@PreDestroy
public void shutdown() {
if (bossGroup != null) bossGroup.shutdownGracefully();
if (workerGroup != null) workerGroup.shutdownGracefully();
log.info("Netty服务器已关闭");
}
}消息协议定义 MessageProtocol.java:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MessageProtocol {
/** 消息类型 */
private Integer type;
/** 消息ID */
private String msgId;
/** 发送者ID */
private Long fromUserId;
/** 接收者ID */
private Long toId;
/** 内容类型 */
private String contentType;
/** 消息内容 */
private String content;
/** 时间戳 */
private Long timestamp;
/** 扩展字段(JSON) */
private String extra;
}
public class MessageType {
public static final int HEARTBEAT = 0; // 心跳
public static final int SINGLE_CHAT = 1; // 单聊
public static final int GROUP_CHAT = 2; // 群聊
public static final int SYSTEM = 3; // 系统消息
public static final int ONLINE_STATUS = 4; // 在线状态变更
public static final int MESSAGE_ACK = 5; // 消息确认
public static final int MESSAGE_READ = 6; // 消息已读
public static final int TYPING = 7; // 正在输入
}WebSocket消息处理器 WebSocketChannelHandler.java:
@Component
@ChannelHandler.Sharable
@Slf4j
public class WebSocketChannelHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Autowired
private SessionManager sessionManager;
@Autowired
private SingleChatHandler singleChatHandler;
@Autowired
private GroupChatHandler groupChatHandler;
@Autowired
private ObjectMapper objectMapper;
@Override
public void channelActive(ChannelHandlerContext ctx) {
log.info("客户端连接:{}", ctx.channel().remoteAddress());
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg)
throws Exception {
String text = msg.text();
log.debug("收到消息:{}", text);
MessageProtocol protocol = objectMapper.readValue(text, MessageProtocol.class);
switch (protocol.getType()) {
case MessageType.HEARTBEAT:
handleHeartbeat(ctx);
break;
case MessageType.SINGLE_CHAT:
singleChatHandler.handle(ctx, protocol);
break;
case MessageType.GROUP_CHAT:
groupChatHandler.handle(ctx, protocol);
break;
case MessageType.MESSAGE_ACK:
handleMessageAck(ctx, protocol);
break;
case MessageType.MESSAGE_READ:
handleMessageRead(ctx, protocol);
break;
default:
log.warn("未知消息类型:{}", protocol.getType());
}
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
// 用户断开连接
Long userId = sessionManager.getUserId(ctx.channel());
if (userId != null) {
sessionManager.removeSession(userId);
log.info("用户断开连接,用户ID:{}", userId);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.error("Channel异常:{}", cause.getMessage());
ctx.close();
}
private void handleHeartbeat(ChannelHandlerContext ctx) {
MessageProtocol pong = MessageProtocol.builder()
.type(MessageType.HEARTBEAT)
.timestamp(System.currentTimeMillis())
.build();
sendMessage(ctx, pong);
}
private void handleMessageAck(ChannelHandlerContext ctx, MessageProtocol protocol) {
// 更新消息状态为已送达
}
private void handleMessageRead(ChannelHandlerContext ctx, MessageProtocol protocol) {
// 更新消息状态为已读
}
public void sendMessage(ChannelHandlerContext ctx, MessageProtocol protocol) {
try {
String json = objectMapper.writeValueAsString(protocol);
ctx.writeAndFlush(new TextWebSocketFrame(json));
} catch (JsonProcessingException e) {
log.error("消息序列化失败", e);
}
}
}会话管理 SessionManager.java:
@Component
@Slf4j
public class SessionManager {
/** 用户ID -> Channel映射 */
private final ConcurrentHashMap<Long, Channel> userChannelMap = new ConcurrentHashMap<>();
/** Channel -> 用户ID映射 */
private final ConcurrentHashMap<Channel, Long> channelUserMap = new ConcurrentHashMap<>();
/**
* 注册会话
*/
public void addSession(Long userId, Channel channel) {
// 如果用户已在其他设备登录,关闭旧连接
Channel oldChannel = userChannelMap.get(userId);
if (oldChannel != null && oldChannel.isActive()) {
oldChannel.close();
}
userChannelMap.put(userId, channel);
channelUserMap.put(channel, userId);
log.info("用户上线,用户ID:{}", userId);
}
/**
* 移除会话
*/
public void removeSession(Long userId) {
Channel channel = userChannelMap.remove(userId);
if (channel != null) {
channelUserMap.remove(channel);
}
}
/**
* 获取用户Channel
*/
public Channel getChannel(Long userId) {
return userChannelMap.get(userId);
}
/**
* 获取Channel对应的用户ID
*/
public Long getUserId(Channel channel) {
return channelUserMap.get(channel);
}
/**
* 判断用户是否在线
*/
public boolean isOnline(Long userId) {
Channel channel = userChannelMap.get(userId);
return channel != null && channel.isActive();
}
/**
* 获取在线用户数
*/
public int getOnlineCount() {
return userChannelMap.size();
}
/**
* 获取所有在线用户ID
*/
public Set<Long> getOnlineUsers() {
return userChannelMap.keySet();
}
}单聊处理器 SingleChatHandler.java:
@Component
@Slf4j
public class SingleChatHandler {
@Autowired
private SessionManager sessionManager;
@Autowired
private MessageService messageService;
@Autowired
private OnlineService onlineService;
@Autowired
private ObjectMapper objectMapper;
public void handle(ChannelHandlerContext ctx, MessageProtocol protocol) {
Long fromUserId = protocol.getFromUserId();
Long toUserId = protocol.getToId();
// 1. 保存消息到数据库
Message message = new Message();
message.setId(Long.parseLong(protocol.getMsgId()));
message.setFromUserId(fromUserId);
message.setToId(toUserId);
message.setMsgType(MessageType.SINGLE_CHAT);
message.setContentType(protocol.getContentType());
message.setContent(protocol.getContent());
message.setStatus(0); // 发送中
messageService.save(message);
// 2. 构建推送消息
MessageProtocol pushMsg = MessageProtocol.builder()
.type(MessageType.SINGLE_CHAT)
.msgId(protocol.getMsgId())
.fromUserId(fromUserId)
.toId(toUserId)
.contentType(protocol.getContentType())
.content(protocol.getContent())
.timestamp(System.currentTimeMillis())
.build();
// 3. 判断接收者是否在线
if (sessionManager.isOnline(toUserId)) {
// 在线:直接推送
Channel toChannel = sessionManager.getChannel(toUserId);
sendToChannel(toChannel, pushMsg);
// 更新消息状态为已送达
message.setStatus(1);
messageService.updateById(message);
} else {
// 离线:存入离线消息队列
onlineService.saveOfflineMessage(toUserId, pushMsg);
}
// 4. 给发送者回复ACK
MessageProtocol ackMsg = MessageProtocol.builder()
.type(MessageType.MESSAGE_ACK)
.msgId(protocol.getMsgId())
.timestamp(System.currentTimeMillis())
.build();
sendToChannel(ctx.channel(), ackMsg);
}
private void sendToChannel(Channel channel, MessageProtocol protocol) {
try {
String json = objectMapper.writeValueAsString(protocol);
channel.writeAndFlush(new TextWebSocketFrame(json));
} catch (JsonProcessingException e) {
log.error("消息序列化失败", e);
}
}
}群聊处理器 GroupChatHandler.java:
@Component
@Slf4j
public class GroupChatHandler {
@Autowired
private SessionManager sessionManager;
@Autowired
private GroupService groupService;
@Autowired
private MessageService messageService;
@Autowired
private ObjectMapper objectMapper;
public void handle(ChannelHandlerContext ctx, MessageProtocol protocol) {
Long fromUserId = protocol.getFromUserId();
Long groupId = protocol.getToId();
// 1. 验证用户是否是群成员
if (!groupService.isMember(groupId, fromUserId)) {
log.warn("用户不是群成员,用户ID:{},群ID:{}", fromUserId, groupId);
return;
}
// 2. 保存消息
Message message = new Message();
message.setId(Long.parseLong(protocol.getMsgId()));
message.setFromUserId(fromUserId);
message.setToId(groupId);
message.setMsgType(MessageType.GROUP_CHAT);
message.setContentType(protocol.getContentType());
message.setContent(protocol.getContent());
messageService.save(message);
// 3. 获取群成员列表
List<Long> memberIds = groupService.getMemberIds(groupId);
// 4. 构建推送消息
MessageProtocol pushMsg = MessageProtocol.builder()
.type(MessageType.GROUP_CHAT)
.msgId(protocol.getMsgId())
.fromUserId(fromUserId)
.toId(groupId)
.contentType(protocol.getContentType())
.content(protocol.getContent())
.timestamp(System.currentTimeMillis())
.build();
// 5. 遍历群成员推送消息
for (Long memberId : memberIds) {
if (memberId.equals(fromUserId)) {
continue; // 不给自己发
}
if (sessionManager.isOnline(memberId)) {
Channel channel = sessionManager.getChannel(memberId);
sendToChannel(channel, pushMsg);
} else {
// 离线消息
// 存入离线消息队列
}
}
}
private void sendToChannel(Channel channel, MessageProtocol protocol) {
try {
String json = objectMapper.writeValueAsString(protocol);
channel.writeAndFlush(new TextWebSocketFrame(json));
} catch (JsonProcessingException e) {
log.error("消息序列化失败", e);
}
}
}心跳处理器 HeartbeatHandler.java:
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.READER_IDLE) {
// 读超时:客户端未发送心跳
log.warn("客户端心跳超时,关闭连接:{}", ctx.channel().remoteAddress());
ctx.close();
} else if (event.state() == IdleState.WRITER_IDLE) {
// 写超时:主动发送心跳
MessageProtocol heartbeat = MessageProtocol.builder()
.type(MessageType.HEARTBEAT)
.timestamp(System.currentTimeMillis())
.build();
try {
String json = new ObjectMapper().writeValueAsString(heartbeat);
ctx.writeAndFlush(new TextWebSocketFrame(json));
} catch (JsonProcessingException e) {
log.error("心跳消息序列化失败");
}
}
}
}
}接口设计
| 方法 | 路径 | 说明 |
|---|---|---|
| WebSocket | ws://host:port/ws | WebSocket连接 |
| POST | /api/auth/login | 登录获取Token |
| GET | /api/friends | 好友列表 |
| POST | /api/friends/request | 添加好友 |
| POST | /api/groups | 创建群 |
| GET | /api/groups/{id}/members | 群成员列表 |
| POST | /api/groups/{id}/join | 加入群 |
| GET | /api/messages/history | 历史消息 |
| GET | /api/messages/unread/count | 未读消息数 |
开发步骤
基础搭建(第1-3天)
- Netty服务器搭建
- WebSocket协议处理
- 心跳机制
单聊功能(第4-6天)
- 消息收发
- 消息持久化
- 在线/离线消息处理
群聊功能(第7-9天)
- 群组管理
- 群消息广播
- 群成员管理
高级功能(第10-14天)
- 消息已读回执
- 离线消息推送
- 消息搜索
- 文件/图片传输
7.1.4 短链接服务
项目概述
高性能短链接生成与解析服务,类似bit.ly。支持短链接生成、跳转、统计分析、自定义短码等功能。
技术选型
| 层次 | 技术 | 说明 |
|---|---|---|
| 框架 | Spring Boot | 核心框架 |
| 数据库 | MySQL | 持久化存储 |
| 缓存 | Redis | 热点数据缓存 |
| 分布式ID | Snowflake / 号段模式 | ID生成 |
| 消息队列 | RabbitMQ | 异步统计 |
项目目录结构
short-url-service/
├── pom.xml
├── src/main/java/com/example/shorturl/
│ ├── ShortUrlApplication.java
│ ├── config/
│ │ └── RedisConfig.java
│ ├── entity/
│ │ ├── ShortUrl.java
│ │ └── UrlAccessLog.java
│ ├── mapper/
│ │ ├── ShortUrlMapper.java
│ │ └── UrlAccessLogMapper.java
│ ├── service/
│ │ ├── ShortUrlService.java
│ │ └── StatisticsService.java
│ ├── service/impl/
│ │ ├── ShortUrlServiceImpl.java
│ │ └── StatisticsServiceImpl.java
│ ├── controller/
│ │ ├── ShortUrlController.java
│ │ └── RedirectController.java
│ ├── generator/
│ │ ├── ShortCodeGenerator.java # 短码生成策略
│ │ ├── Base62Generator.java
│ │ └── PreGeneratedPoolGenerator.java # 预生成池
│ └── filter/
│ └── RateLimitFilter.java # 限流过滤器
└── src/main/resources/
└── application.yml数据库设计
-- 短链接映射表
CREATE TABLE `short_url` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`short_code` VARCHAR(10) NOT NULL COMMENT '短码',
`original_url` VARCHAR(2000) NOT NULL COMMENT '原始URL',
`user_id` BIGINT DEFAULT NULL COMMENT '创建用户ID',
`expire_time` DATETIME DEFAULT NULL COMMENT '过期时间(NULL表示永不过期)',
`access_count` BIGINT NOT NULL DEFAULT 0 COMMENT '访问次数',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_short_code` (`short_code`),
KEY `idx_user_id` (`user_id`),
KEY `idx_original_url` (`original_url`(100))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链接映射表';
-- 访问日志表
CREATE TABLE `url_access_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`short_code` VARCHAR(10) NOT NULL COMMENT '短码',
`access_ip` VARCHAR(50) DEFAULT NULL COMMENT '访问IP',
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT '浏览器UA',
`referer` VARCHAR(500) DEFAULT NULL COMMENT '来源',
`device_type` VARCHAR(20) DEFAULT NULL COMMENT '设备类型',
`os_type` VARCHAR(20) DEFAULT NULL COMMENT '操作系统',
`browser_type` VARCHAR(20) DEFAULT NULL COMMENT '浏览器类型',
`country` VARCHAR(50) DEFAULT NULL COMMENT '国家',
`province` VARCHAR(50) DEFAULT NULL COMMENT '省份',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_short_code` (`short_code`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='访问日志表';核心代码示例
Base62短码生成器 Base62Generator.java:
@Component
public class Base62Generator implements ShortCodeGenerator {
private static final String BASE62_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private static final int BASE = 62;
private static final int CODE_LENGTH = 6; // 6位短码
@Autowired
private IdGenerator idGenerator;
@Override
public String generate() {
long id = idGenerator.nextId();
return encode(id);
}
/**
* 将数字编码为Base62字符串
*/
private String encode(long num) {
StringBuilder sb = new StringBuilder();
while (num > 0) {
sb.insert(0, BASE62_CHARS.charAt((int) (num % BASE)));
num /= BASE;
}
// 补齐到固定长度
while (sb.length() < CODE_LENGTH) {
sb.insert(0, '0');
}
return sb.toString();
}
/**
* 将Base62字符串解码为数字
*/
public long decode(String code) {
long num = 0;
for (int i = 0; i < code.length(); i++) {
num = num * BASE + BASE62_CHARS.indexOf(code.charAt(i));
}
return num;
}
}预生成池方案 PreGeneratedPoolGenerator.java:
@Component
public class PreGeneratedPoolGenerator implements ShortCodeGenerator {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String POOL_KEY = "shorturl:code:pool";
private static final int POOL_THRESHOLD = 1000; // 低于此值触发补充
@PostConstruct
public void init() {
// 初始化时预生成一批短码
if (redisTemplate.opsForList().size(POOL_KEY) == 0) {
fillPool(10000);
}
// 定时检查并补充
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(this::checkAndFill, 1, 1, TimeUnit.MINUTES);
}
@Override
public String generate() {
String code = redisTemplate.opsForList().rightPop(POOL_KEY);
if (code == null) {
// 池为空,同步生成
fillPool(1000);
code = redisTemplate.opsForList().rightPop(POOL_KEY);
}
return code;
}
private void checkAndFill() {
Long size = redisTemplate.opsForList().size(POOL_KEY);
if (size != null && size < POOL_THRESHOLD) {
fillPool(5000);
}
}
private synchronized void fillPool(int count) {
List<String> codes = new ArrayList<>();
for (int i = 0; i < count; i++) {
codes.add(randomCode());
}
redisTemplate.opsForList().leftPushAll(POOL_KEY, codes);
}
private String randomCode() {
String chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
StringBuilder sb = new StringBuilder(6);
Random random = new Random();
for (int i = 0; i < 6; i++) {
sb.append(chars.charAt(random.nextInt(chars.length())));
}
return sb.toString();
}
}短链接服务核心 ShortUrlServiceImpl.java:
@Service
@Slf4j
public class ShortUrlServiceImpl implements ShortUrlService {
@Autowired
private ShortUrlMapper shortUrlMapper;
@Autowired
private ShortCodeGenerator shortCodeGenerator;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
private static final String CACHE_PREFIX = "shorturl:";
private static final long CACHE_EXPIRE_HOURS = 24;
/**
* 创建短链接
*/
@Override
public ShortUrl createShortUrl(String originalUrl, Long userId, LocalDateTime expireTime) {
// 1. 检查是否已存在相同URL的短链接
String existCode = redisTemplate.opsForValue().get("shorturl:url:" + originalUrl);
if (existCode != null) {
return shortUrlMapper.selectByShortCode(existCode);
}
ShortUrl exist = shortUrlMapper.selectByOriginalUrl(originalUrl);
if (exist != null) {
return exist;
}
// 2. 生成短码(保证唯一)
String shortCode;
do {
shortCode = shortCodeGenerator.generate();
} while (shortUrlMapper.selectByShortCode(shortCode) != null);
// 3. 保存短链接
ShortUrl shortUrl = new ShortUrl();
shortUrl.setShortCode(shortCode);
shortUrl.setOriginalUrl(originalUrl);
shortUrl.setUserId(userId);
shortUrl.setExpireTime(expireTime);
shortUrlMapper.insert(shortUrl);
// 4. 写入缓存
redisTemplate.opsForValue().set(
CACHE_PREFIX + shortCode, originalUrl,
CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
redisTemplate.opsForValue().set(
"shorturl:url:" + originalUrl, shortCode,
CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
log.info("短链接创建成功:{} -> {}", shortCode, originalUrl);
return shortUrl;
}
/**
* 解析短链接(重定向核心逻辑)
*/
@Override
public String resolveShortUrl(String shortCode, String clientIp, String userAgent) {
// 1. 先查缓存
String originalUrl = redisTemplate.opsForValue().get(CACHE_PREFIX + shortCode);
if (originalUrl == null) {
// 2. 缓存未命中,查数据库
ShortUrl shortUrl = shortUrlMapper.selectByShortCode(shortCode);
if (shortUrl == null) {
return null;
}
// 检查是否过期
if (shortUrl.getExpireTime() != null &&
shortUrl.getExpireTime().isBefore(LocalDateTime.now())) {
return null;
}
// 检查是否禁用
if (shortUrl.getStatus() != 1) {
return null;
}
originalUrl = shortUrl.getOriginalUrl();
// 3. 写入缓存
redisTemplate.opsForValue().set(
CACHE_PREFIX + shortCode, originalUrl,
CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
}
// 4. 异步记录访问日志
UrlAccessLog accessLog = new UrlAccessLog();
accessLog.setShortCode(shortCode);
accessLog.setAccessIp(clientIp);
accessLog.setUserAgent(userAgent);
accessLog.setCreatedAt(LocalDateTime.now());
rabbitTemplate.convertAndSend("url.access", "access.log", accessLog);
// 5. 异步更新访问次数
redisTemplate.opsForValue().increment("shorturl:count:" + shortCode);
return originalUrl;
}
/**
* 获取短链接统计信息
*/
@Override
public UrlStatistics getStatistics(String shortCode) {
UrlStatistics stats = new UrlStatistics();
// 总访问次数
String countStr = redisTemplate.opsForValue().get("shorturl:count:" + shortCode);
long redisCount = countStr != null ? Long.parseLong(countStr) : 0;
ShortUrl shortUrl = shortUrlMapper.selectByShortCode(shortCode);
stats.setTotalAccess(shortUrl.getAccessCount() + redisCount);
// 今日访问次数
long todayCount = shortUrlMapper.countTodayAccess(shortCode);
stats.setTodayAccess(todayCount);
// 最近7天访问趋势
List<DailyAccess> dailyAccesses = shortUrlMapper.countDailyAccess(shortCode, 7);
stats.setDailyAccesses(dailyAccesses);
return stats;
}
}重定向控制器 RedirectController.java:
@RestController
@Slf4j
public class RedirectController {
@Autowired
private ShortUrlService shortUrlService;
/**
* 短链接重定向(核心接口,需要高性能)
*/
@GetMapping("/{shortCode}")
public void redirect(@PathVariable String shortCode,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
String clientIp = getClientIp(request);
String userAgent = request.getHeader("User-Agent");
String originalUrl = shortUrlService.resolveShortUrl(shortCode, clientIp, userAgent);
if (originalUrl == null) {
response.sendError(404, "短链接不存在或已过期");
return;
}
// 302临时重定向(每次都会访问短链接服务)
// 如果需要减少短链接服务压力,可使用301永久重定向
response.setStatus(302);
response.setHeader("Location", originalUrl);
response.setHeader("Cache-Control", "no-cache");
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多个代理时取第一个
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}限流过滤器 RateLimitFilter.java:
@Component
public class RateLimitFilter implements Filter {
@Autowired
private StringRedisTemplate redisTemplate;
private static final int MAX_REQUESTS = 100; // 每分钟最大请求数
private static final int WINDOW_SECONDS = 60;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String clientIp = httpRequest.getRemoteAddr();
String key = "ratelimit:" + clientIp;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, WINDOW_SECONDS, TimeUnit.SECONDS);
}
if (count > MAX_REQUESTS) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(429);
httpResponse.setContentType("application/json;charset=UTF-8");
httpResponse.getWriter().write(
"{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\"}");
return;
}
chain.doFilter(request, response);
}
}接口设计
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
| POST | /api/shorturl | 创建短链接 | 可选 |
| GET | /api/shorturl/ | 获取短链接信息 | 否 |
| DELETE | /api/shorturl/ | 删除短链接 | 是 |
| PUT | /api/shorturl/ | 更新短链接 | 是 |
| GET | /api/shorturl/{code}/stats | 获取统计信息 | 是 |
| GET | / | 短链接重定向 | 否 |
开发步骤
基础功能(第1-2天)
- 短码生成算法实现
- 短链接CRUD
- 重定向逻辑
性能优化(第3-4天)
- Redis缓存层
- 预生成短码池
- 异步访问日志
高级功能(第5-7天)
- 访问统计分析
- 限流保护
- 自定义短码
- 批量生成
7.1.5 权限管理系统
项目概述
基于RBAC(基于角色的访问控制)模型的权限管理系统,支持用户管理、角色管理、菜单管理、部门管理、日志审计等功能。
技术选型
| 层次 | 技术 | 说明 |
|---|---|---|
| 安全框架 | Spring Security | 安全认证与授权 |
| 认证方式 | JWT | 无状态认证 |
| 权限模型 | RBAC | 基于角色的访问控制 |
| 数据权限 | MyBatis拦截器 | SQL自动拼接 |
RBAC权限模型
用户(User) ──N:M──> 角色(Role) ──N:M──> 权限/菜单(Permission)
│ │
└──────────── 部门(Dept) ────────────┘- 一个用户可以拥有多个角色
- 一个角色可以分配给多个用户
- 一个角色可以拥有多个权限
- 一个权限可以属于多个角色
项目目录结构
permission-system/
├── pom.xml
├── src/main/java/com/example/permission/
│ ├── PermissionApplication.java
│ ├── security/
│ │ ├── SecurityConfig.java # Security配置
│ │ ├── JwtAuthenticationFilter.java # JWT认证过滤器
│ │ ├── JwtAuthenticationEntryPoint.java # 未认证处理
│ │ ├── AccessDeniedHandlerImpl.java # 无权限处理
│ │ ├── UserDetailsServiceImpl.java # 用户详情服务
│ │ ├── LoginUser.java # 登录用户信息
│ │ └── annotation/
│ │ ├── RequiresPermission.java # 权限注解
│ │ └── DataScope.java # 数据权限注解
│ ├── entity/
│ │ ├── SysUser.java
│ │ ├── SysRole.java
│ │ ├── SysMenu.java
│ │ ├── SysDept.java
│ │ ├── SysUserRole.java
│ │ ├── SysRoleMenu.java
│ │ └── SysOperLog.java
│ ├── mapper/
│ │ ├── SysUserMapper.java
│ │ ├── SysRoleMapper.java
│ │ ├── SysMenuMapper.java
│ │ ├── SysDeptMapper.java
│ │ └── SysOperLogMapper.java
│ ├── service/
│ │ ├── SysUserService.java
│ │ ├── SysRoleService.java
│ │ ├── SysMenuService.java
│ │ ├── SysDeptService.java
│ │ └── SysOperLogService.java
│ ├── service/impl/
│ │ ├── SysUserServiceImpl.java
│ │ ├── SysRoleServiceImpl.java
│ │ ├── SysMenuServiceImpl.java
│ │ ├── SysDeptServiceImpl.java
│ │ └── SysOperLogServiceImpl.java
│ ├── controller/
│ │ ├── AuthController.java
│ │ ├── SysUserController.java
│ │ ├── SysRoleController.java
│ │ ├── SysMenuController.java
│ │ └── SysDeptController.java
│ ├── interceptor/
│ │ └── DataScopeInterceptor.java # 数据权限拦截器
│ └── aspect/
│ └── OperLogAspect.java # 操作日志切面
└── src/main/resources/
└── application.yml数据库设计
-- 用户表
CREATE TABLE `sys_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`password` VARCHAR(100) NOT NULL COMMENT '密码(BCrypt)',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像',
`dept_id` BIGINT DEFAULT NULL COMMENT '部门ID',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_dept_id` (`dept_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 角色表
CREATE TABLE `sys_role` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`role_name` VARCHAR(50) NOT NULL COMMENT '角色名称',
`role_key` VARCHAR(50) NOT NULL COMMENT '角色标识',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
`data_scope` TINYINT NOT NULL DEFAULT 1 COMMENT '数据范围:1-全部,2-本部门,3-本部门及以下,4-本人',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`remark` VARCHAR(200) DEFAULT NULL COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_key` (`role_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
-- 菜单权限表
CREATE TABLE `sys_menu` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称',
`parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父菜单ID',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
`path` VARCHAR(200) DEFAULT NULL COMMENT '路由路径',
`component` VARCHAR(200) DEFAULT NULL COMMENT '组件路径',
`menu_type` TINYINT NOT NULL DEFAULT 0 COMMENT '类型:0-目录,1-菜单,2-按钮',
`permission` VARCHAR(100) DEFAULT NULL COMMENT '权限标识(如:system:user:list)',
`icon` VARCHAR(100) DEFAULT NULL COMMENT '图标',
`visible` TINYINT NOT NULL DEFAULT 1 COMMENT '是否可见:0-隐藏,1-显示',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表';
-- 部门表
CREATE TABLE `sys_dept` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`dept_name` VARCHAR(50) NOT NULL COMMENT '部门名称',
`parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父部门ID',
`ancestors` VARCHAR(500) DEFAULT NULL COMMENT '祖级列表(如:0,1,2)',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
`leader` VARCHAR(50) DEFAULT NULL COMMENT '负责人',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表';
-- 用户角色关联表
CREATE TABLE `sys_user_role` (
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`role_id` BIGINT NOT NULL COMMENT '角色ID',
PRIMARY KEY (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
-- 角色菜单关联表
CREATE TABLE `sys_role_menu` (
`role_id` BIGINT NOT NULL COMMENT '角色ID',
`menu_id` BIGINT NOT NULL COMMENT '菜单ID',
PRIMARY KEY (`role_id`, `menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表';
-- 操作日志表
CREATE TABLE `sys_oper_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT DEFAULT NULL COMMENT '操作人ID',
`username` VARCHAR(50) DEFAULT NULL COMMENT '操作人',
`operation` VARCHAR(200) DEFAULT NULL COMMENT '操作描述',
`method` VARCHAR(200) DEFAULT NULL COMMENT '请求方法',
`params` TEXT DEFAULT NULL COMMENT '请求参数',
`ip` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
`duration` BIGINT DEFAULT NULL COMMENT '耗时(毫秒)',
`status` TINYINT DEFAULT NULL COMMENT '状态:0-失败,1-成功',
`error_msg` TEXT DEFAULT NULL COMMENT '错误信息',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';核心代码示例
Spring Security配置 SecurityConfig.java:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthFilter;
@Autowired
private JwtAuthenticationEntryPoint entryPoint;
@Autowired
private AccessDeniedHandlerImpl accessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/doc.html", "/webjars/**").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(entryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}JWT认证过滤器 JwtAuthenticationFilter.java:
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtUtil.validateToken(token)) {
filterChain.doFilter(request, response);
return;
}
String username = jwtUtil.getUsername(token);
// 如果尚未设置认证信息
if (SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
filterChain.doFilter(request, response);
}
}用户详情服务 UserDetailsServiceImpl.java:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private SysUserMapper userMapper;
@Autowired
private SysRoleMapper roleMapper;
@Autowired
private SysMenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. 查询用户
SysUser user = userMapper.selectByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在:" + username);
}
if (user.getStatus() != 1) {
throw new DisabledException("用户已被禁用");
}
// 2. 查询用户角色
List<SysRole> roles = roleMapper.selectRolesByUserId(user.getId());
Set<String> roleKeys = roles.stream()
.map(SysRole::getRoleKey)
.collect(Collectors.toSet());
// 3. 查询用户权限标识
Set<String> permissions;
if (roleKeys.contains("admin")) {
// 超级管理员拥有所有权限
permissions = Set.of("*:*:*");
} else {
List<SysMenu> menus = menuMapper.selectMenusByUserId(user.getId());
permissions = menus.stream()
.map(SysMenu::getPermission)
.filter(StringUtils::hasText)
.collect(Collectors.toSet());
}
// 4. 查询用户数据权限范围
Integer dataScope = roles.stream()
.map(SysRole::getDataScope)
.max(Integer::compareTo)
.orElse(1);
// 5. 构建登录用户对象
LoginUser loginUser = new LoginUser();
loginUser.setUserId(user.getId());
loginUser.setUsername(user.getUsername());
loginUser.setDeptId(user.getDeptId());
loginUser.setRoles(roleKeys);
loginUser.setPermissions(permissions);
loginUser.setDataScope(dataScope);
return loginUser;
}
}登录用户 LoginUser.java:
@Data
public class LoginUser implements UserDetails {
private Long userId;
private String username;
private String password;
private Long deptId;
private Set<String> roles;
private Set<String> permissions;
private Integer dataScope;
@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
// 角色权限
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
// 菜单权限
for (String permission : permissions) {
authorities.add(new SimpleGrantedAuthority(permission));
}
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}权限注解 @RequiresPermission:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {
String[] value(); // 权限标识
Logical logical() default Logical.AND; // 逻辑关系
}
public enum Logical {
AND, OR
}权限校验AOP:
@Aspect
@Component
public class PermissionAspect {
@Before("@annotation(requiresPermission)")
public void checkPermission(RequiresPermission requiresPermission) {
LoginUser loginUser = getLoginUser();
if (loginUser == null) {
throw new BusinessException(401, "未登录");
}
Set<String> permissions = loginUser.getPermissions();
// 超级管理员
if (permissions.contains("*:*:*")) {
return;
}
String[] requiredPerms = requiresPermission.value();
Logical logical = requiresPermission.logical();
if (logical == Logical.AND) {
for (String perm : requiredPerms) {
if (!permissions.contains(perm)) {
throw new BusinessException(403, "没有权限:" + perm);
}
}
} else {
boolean hasAny = false;
for (String perm : requiredPerms) {
if (permissions.contains(perm)) {
hasAny = true;
break;
}
}
if (!hasAny) {
throw new BusinessException(403, "没有所需权限");
}
}
}
private LoginUser getLoginUser() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof LoginUser) {
return (LoginUser) authentication.getPrincipal();
}
return null;
}
}数据权限拦截器 DataScopeInterceptor.java:
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class,
ResultHandler.class})
})
@Component
public class DataScopeInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
// 获取方法上的@DataScope注解
DataScope dataScope = getDataScopeAnnotation(ms);
if (dataScope == null) {
return invocation.proceed();
}
LoginUser loginUser = getLoginUser();
if (loginUser == null) {
return invocation.proceed();
}
// 超级管理员不做限制
if (loginUser.getRoles().contains("admin")) {
return invocation.proceed();
}
// 根据数据范围拼接SQL条件
String sqlCondition = buildSqlCondition(dataScope, loginUser);
// 修改SQL(通过BoundSql包装)
// ...
return invocation.proceed();
}
private String buildSqlCondition(DataScope dataScope, LoginUser loginUser) {
String deptAlias = dataScope.deptAlias();
String userAlias = dataScope.userAlias();
switch (loginUser.getDataScope()) {
case 1: // 全部数据
return "";
case 2: // 本部门数据
return String.format(" AND %s.dept_id = %d",
deptAlias, loginUser.getDeptId());
case 3: // 本部门及以下数据
return String.format(
" AND %s.dept_id IN (SELECT id FROM sys_dept WHERE FIND_IN_SET(%s.dept_id, ancestors))",
deptAlias, deptAlias);
case 4: // 仅本人数据
return String.format(" AND %s.id = %d",
userAlias, loginUser.getUserId());
default:
return "";
}
}
}操作日志切面 OperLogAspect.java:
@Aspect
@Component
@Slf4j
public class OperLogAspect {
@Autowired
private SysOperLogService operLogService;
@Around("@annotation(operLog)")
public Object around(ProceedingJoinPoint point, OperLog operLog) throws Throwable {
long startTime = System.currentTimeMillis();
SysOperLog logEntity = new SysOperLog();
logEntity.setOperation(operLog.value());
logEntity.setMethod(point.getTarget().getClass().getName() + "." +
point.getSignature().getName());
// 获取请求参数
Object[] args = point.getArgs();
try {
logEntity.setParams(new ObjectMapper().writeValueAsString(args));
} catch (Exception e) {
logEntity.setParams("参数序列化失败");
}
// 获取当前用户
LoginUser loginUser = getLoginUser();
if (loginUser != null) {
logEntity.setUserId(loginUser.getUserId());
logEntity.setUsername(loginUser.getUsername());
}
try {
Object result = point.proceed();
logEntity.setStatus(1); // 成功
logEntity.setDuration(System.currentTimeMillis() - startTime);
return result;
} catch (Exception e) {
logEntity.setStatus(0); // 失败
logEntity.setErrorMsg(e.getMessage());
logEntity.setDuration(System.currentTimeMillis() - startTime);
throw e;
} finally {
// 异步保存日志
operLogService.saveAsync(logEntity);
}
}
}认证控制器 AuthController.java:
@RestController
@RequestMapping("/api/auth")
@Tag(name = "认证管理")
@Slf4j
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private StringRedisTemplate redisTemplate;
@PostMapping("/login")
@Operation(summary = "用户登录")
public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) {
// 1. 认证
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword()));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 2. 生成Token
String token = jwtUtil.generateToken(loginUser.getUsername());
// 3. 将用户信息存入Redis(用于Token校验和权限缓存)
String redisKey = "login:token:" + token;
redisTemplate.opsForValue().set(redisKey,
JSON.toJSONString(loginUser), 24, TimeUnit.HOURS);
// 4. 构建返回
LoginVO loginVO = new LoginVO();
loginVO.setToken(token);
loginVO.setUserId(loginUser.getUserId());
loginVO.setUsername(loginUser.getUsername());
loginVO.setRoles(loginUser.getRoles());
loginVO.setPermissions(loginUser.getPermissions());
log.info("用户登录成功:{}", dto.getUsername());
return Result.success(loginVO);
}
@PostMapping("/logout")
@Operation(summary = "退出登录")
public Result<Void> logout(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
redisTemplate.delete("login:token:" + token);
}
return Result.success();
}
@GetMapping("/info")
@Operation(summary = "获取当前用户信息")
public Result<UserInfoVO> getUserInfo() {
LoginUser loginUser = getLoginUser();
UserInfoVO vo = new UserInfoVO();
vo.setUserId(loginUser.getUserId());
vo.setUsername(loginUser.getUsername());
vo.setRoles(loginUser.getRoles());
vo.setPermissions(loginUser.getPermissions());
return Result.success(vo);
}
private LoginUser getLoginUser() {
return (LoginUser) SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();
}
}用户管理控制器 SysUserController.java:
@RestController
@RequestMapping("/api/system/users")
@Tag(name = "用户管理")
public class SysUserController {
@Autowired
private SysUserService userService;
@GetMapping
@RequiresPermission("system:user:list")
@Operation(summary = "用户列表")
public Result<PageResult<SysUserVO>> list(UserQueryDTO query) {
return Result.success(userService.getUserList(query));
}
@GetMapping("/{id}")
@RequiresPermission("system:user:query")
@Operation(summary = "用户详情")
public Result<SysUserVO> detail(@PathVariable Long id) {
return Result.success(userService.getUserDetail(id));
}
@PostMapping
@RequiresPermission("system:user:add")
@Operation(summary = "新增用户")
public Result<Void> add(@RequestBody @Valid UserDTO dto) {
userService.addUser(dto);
return Result.success();
}
@PutMapping("/{id}")
@RequiresPermission("system:user:edit")
@Operation(summary = "修改用户")
public Result<Void> update(@PathVariable Long id, @RequestBody @Valid UserDTO dto) {
userService.updateUser(id, dto);
return Result.success();
}
@DeleteMapping("/{id}")
@RequiresPermission("system:user:remove")
@Operation(summary = "删除用户")
public Result<Void> delete(@PathVariable Long id) {
userService.deleteUser(id);
return Result.success();
}
@PutMapping("/{id}/status")
@RequiresPermission("system:user:edit")
@Operation(summary = "修改用户状态")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
userService.updateStatus(id, status);
return Result.success();
}
@PutMapping("/{id}/password")
@RequiresPermission("system:user:resetPwd")
@Operation(summary = "重置密码")
public Result<Void> resetPassword(@PathVariable Long id, @RequestParam String password) {
userService.resetPassword(id, password);
return Result.success();
}
@PostMapping("/{id}/roles")
@RequiresPermission("system:user:edit")
@Operation(summary = "分配角色")
public Result<Void> assignRoles(@PathVariable Long id, @RequestBody List<Long> roleIds) {
userService.assignRoles(id, roleIds);
return Result.success();
}
}接口设计
| 模块 | 方法 | 路径 | 权限标识 |
|---|---|---|---|
| 认证 | POST | /api/auth/login | 无需认证 |
| 认证 | POST | /api/auth/logout | 需要认证 |
| 认证 | GET | /api/auth/info | 需要认证 |
| 用户 | GET | /api/system/users | system:user:list |
| 用户 | POST | /api/system/users | system:user:add |
| 用户 | PUT | /api/system/users/ | system:user:edit |
| 用户 | DELETE | /api/system/users/ | system:user:remove |
| 角色 | GET | /api/system/roles | system:role:list |
| 角色 | POST | /api/system/roles | system:role:add |
| 角色 | PUT | /api/system/roles/ | system:role:edit |
| 角色 | DELETE | /api/system/roles/ | system:role:remove |
| 菜单 | GET | /api/system/menus | system:menu:list |
| 菜单 | POST | /api/system/menus | system:menu:add |
| 部门 | GET | /api/system/depts | system:dept:list |
| 部门 | POST | /api/system/depts | system:dept:add |
| 日志 | GET | /api/system/logs | system:log:list |
开发步骤
安全框架集成(第1-3天)
- Spring Security配置
- JWT认证流程
- 登录/登出
RBAC权限模型(第4-7天)
- 用户管理CRUD
- 角色管理CRUD
- 菜单权限管理
- 权限分配
接口权限控制(第8-10天)
- @PreAuthorize注解
- 自定义权限注解
- 权限校验AOP
数据权限(第11-13天)
- MyBatis拦截器
- 数据范围SQL拼接
- 部门层级数据
辅助功能(第14-18天)
- 部门管理
- 操作日志
- 登录日志
- 在线用户管理
测试与优化(第19-21天)
- 单元测试
- 接口测试
- 安全审计
7.2 开发规范
7.2.1 代码规范:阿里巴巴Java开发手册
命名规范
// ✅ 正确示例
// 类名:UpperCamelCase
public class UserService {}
public class OrderController {}
public class ProductServiceImpl {}
// 方法名/变量名:lowerCamelCase
public void getUserById() {}
private String userName;
private Long orderId;
// 常量:全大写下划线分隔
public static final int MAX_RETRY_COUNT = 3;
public static final String DEFAULT_CHARSET = "UTF-8";
// 包名:全小写
package com.example.user.service;
// 抽象类以Abstract或Base开头
public abstract class AbstractService {}
// 异常类以Exception结尾
public class BusinessException extends RuntimeException {}
// 测试类以Test结尾
public class UserServiceTest {}
// 枚举类以Enum结尾
public enum OrderStatusEnum {
PENDING, PAID, SHIPPED, COMPLETED, CANCELLED
}
// ❌ 错误示例
public class userservice {} // 不符合UpperCamelCase
public void GetUserInfo() {} // 方法名不应大写开头
private String user_name; // 变量名不应用下划线
public static final int max_count = 3; // 常量应全大写POJO类规范
// ✅ 正确的POJO类
@Data
public class UserDTO {
/** 用户ID */
@NotNull(message = "用户ID不能为空")
private Long userId;
/** 用户名 */
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度3-20位")
private String username;
/** 邮箱 */
@Email(message = "邮箱格式不正确")
private String email;
/** 年龄 */
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能大于150")
private Integer age;
// 规则:
// 1. POJO类属性必须使用包装数据类型(Integer而非int)
// 2. 布尔类型属性不加is前缀(避免序列化问题)
// 3. 必须重写toString方法(Lombok @Data自动处理)
// 4. 构造方法里有业务逻辑,禁止使用@DefaultConstructor
}集合处理规范
// ✅ 正确
// 判断集合是否为空
if (CollectionUtils.isEmpty(list)) { ... }
if (CollectionUtils.isNotEmpty(list)) { ... }
// 判断Map是否为空
if (MapUtils.isEmpty(map)) { ... }
// 使用entrySet遍历Map
for (Map.Entry<String, Object> entry : map.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
}
// 集合转数组
String[] array = list.toArray(new String[0]);
// Arrays.asList返回的是固定大小列表,不能add/remove
List<String> fixedList = Arrays.asList("A", "B", "C");
// fixedList.add("D"); // 抛出UnsupportedOperationException
// 使用new ArrayList<>(Arrays.asList(...))创建可变列表
List<String> mutableList = new ArrayList<>(Arrays.asList("A", "B", "C"));
// ❌ 错误
if (list == null || list.size() == 0) { ... } // 应使用CollectionUtils并发处理规范
// ✅ 线程池创建(禁止使用Executors快捷方法)
ThreadPoolExecutor executor = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(), // 核心线程数
Runtime.getRuntime().availableProcessors() * 2, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲超时
new LinkedBlockingQueue<>(1000), // 工作队列
new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
// ✅ SimpleDateFormat线程不安全
// 方式1:使用DateTimeFormatter(推荐)
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formatted = LocalDateTime.now().format(formatter);
// 方式2:使用ThreadLocal
private static final ThreadLocal<SimpleDateFormat> SDF =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// ✅ 对多个资源加锁时,注意锁顺序,避免死锁
// 始终按固定顺序获取锁注释规范
/**
* 用户服务类
*
* @author zhangsan
* @date 2024-01-01
*/
@Service
public class UserService {
/**
* 根据用户名查询用户
*
* @param username 用户名,不能为空
* @return 用户信息,不存在返回null
* @throws BusinessException 当参数非法时抛出
*/
public User findByUsername(String username) {
// 先查缓存
User user = redisTemplate.get("user:" + username);
if (user != null) {
return user;
}
// 缓存未命中,查数据库
user = userMapper.selectByUsername(username);
// 写入缓存
if (user != null) {
redisTemplate.set("user:" + username, user, 30, TimeUnit.MINUTES);
}
return user;
}
}
// 规则:
// 1. 所有类必须有创建者和创建日期
// 2. 所有公共方法必须有Javadoc注释
// 3. 方法内部单行注释用 //
// 4. 边写代码边注释,修改代码同时修改注释
// 5. 特殊注释标记:
// TODO(标记人,标记时间,预计处理时间)
// FIXME(标记问题,预计处理时间)7.2.2 Git工作流(Git Flow)
分支模型
main (master) ─────────────────────────────── 生产分支
│ ↑
│ merge │
├── develop ─────────────────────────────── 开发分支
│ │ ↑
│ │ feature/login │ merge
│ ├───●───●───●────────┤
│ │ │
│ │ feature/order │ merge
│ ├───●───●────────────┤
│ │ │
│ ├── release/1.0 ─────┤
│ │ │
│ └── hotfix/bug ──────┘分支说明:
main/master:生产环境分支,始终保持稳定develop:开发主分支,包含最新开发功能feature/*:功能分支,从 develop 创建,合并回 developrelease/*:发布分支,从 develop 创建,测试通过后合并到 main 和 develophotfix/*:紧急修复分支,从 main 创建,修复后合并到 main 和 develop
Git 提交规范
# 提交信息格式
<type>(<scope>): <subject>
# type 类型:
feat: 新功能
fix: 修复 bug
docs: 文档更新
style: 代码格式调整(不影响功能)
refactor: 代码重构
test: 测试相关
chore: 构建/工具更新
# 示例:
feat(user): 添加用户登录功能
fix(order): 修复订单金额计算错误
docs(readme): 更新项目说明文档
style(code): 统一代码格式
refactor(service): 重构用户服务层
test(user): 添加用户模块单元测试
chore(deps): 升级依赖版本分支命名规范
# 功能分支
feature/user-login
feature/order-payment
feature/product-search
# 修复分支
fix/login-error
fix/order-calculation
# 发布分支
release/v1.0.0
release/v1.1.0
# 热修复分支
hotfix/login-crash
hotfix/payment-timeout7.2.3 接口设计规范(RESTful)
RESTful 设计原则
// ✅ 正确的 RESTful 设计
// 资源命名:使用名词复数
GET /api/users // 获取用户列表
GET /api/users/{id} // 获取单个用户
POST /api/users // 创建用户
PUT /api/users/{id} // 更新用户(全量)
PATCH /api/users/{id} // 更新用户(部分)
DELETE /api/users/{id} // 删除用户
// ❌ 错误的 RESTful 设计
GET /api/getUsers // 不要使用动词
POST /api/createUser // 不要使用动词
GET /api/user/{id} // 应该用复数 users
// 嵌套资源
GET /api/users/{userId}/orders // 获取用户的订单列表
GET /api/users/{userId}/orders/{orderId} // 获取用户的单个订单
// 查询参数
GET /api/users?page=1&size=10 // 分页
GET /api/users?status=1&role=admin // 过滤
GET /api/users?sort=createdAt,desc // 排序
GET /api/users?keyword=zhang // 搜索统一响应格式
// 成功响应
{
"code": 200,
"message": "success",
"data": {
"userId": 1,
"username": "zhangsan"
},
"timestamp": 1704067200000
}
// 分页响应
{
"code": 200,
"message": "success",
"data": {
"list": [...],
"total": 100,
"page": 1,
"size": 10,
"pages": 10
}
}
// 错误响应
{
"code": 400,
"message": "参数错误:用户名不能为空",
"data": null,
"timestamp": 1704067200000
}
// 未认证
{
"code": 401,
"message": "未登录或 Token 已过期",
"data": null
}
// 无权限
{
"code": 403,
"message": "没有操作权限",
"data": null
}HTTP 状态码使用
// 2xx 成功
200 OK // 请求成功(GET、PUT、PATCH)
201 Created // 创建成功(POST)
204 No Content // 删除成功(DELETE)
// 4xx 客户端错误
400 Bad Request // 请求参数错误
401 Unauthorized // 未认证
403 Forbidden // 无权限
404 Not Found // 资源不存在
405 Method Not Allowed // 请求方法不允许
429 Too Many Requests // 请求过于频繁
// 5xx 服务器错误
500 Internal Server Error // 服务器内部错误
502 Bad Gateway // 网关错误
503 Service Unavailable // 服务不可用
504 Gateway Timeout // 网关超时7.2.4 日志规范
日志级别使用
@Slf4j
public class OrderService {
public Order createOrder(OrderDTO dto) {
// DEBUG:调试信息,开发环境使用
log.debug("创建订单,参数:{}", dto);
try {
// INFO:重要业务流程
log.info("开始创建订单,用户ID:{}", dto.getUserId());
Order order = orderMapper.insert(dto);
log.info("订单创建成功,订单ID:{}", order.getId());
return order;
} catch (Exception e) {
// ERROR:错误异常,需要立即处理
log.error("创建订单失败,参数:{}", dto, e);
throw new BusinessException("创建订单失败");
}
}
public void processPayment(Order order) {
// WARN:警告信息,可能有问题但不影响主流程
if (order.getAmount() > 10000) {
log.warn("大额订单,订单ID:{},金额:{}", order.getId(), order.getAmount());
}
// TRACE:最详细的追踪信息,一般不启用
log.trace("处理支付,订单详情:{}", order);
}
}
// 日志级别使用原则:
// ERROR:系统错误,需要立即处理(如:数据库连接失败、第三方服务调用失败)
// WARN:潜在问题,需要关注(如:参数异常但可降级处理、性能警告)
// INFO:重要业务流程(如:用户登录、订单创建、支付成功)
// DEBUG:调试信息(如:方法入参、中间结果)
// TRACE:详细追踪(如:循环内部变量)日志格式规范
// ✅ 正确的日志格式
log.info("用户登录成功,用户ID:{},登录IP:{}", userId, ip);
log.error("调用支付接口失败,订单ID:{},错误信息:{}", orderId, e.getMessage(), e);
// ❌ 错误的日志格式
log.info("用户登录成功"); // 缺少关键信息
log.error("错误:" + e.getMessage()); // 使用字符串拼接
log.info("用户ID:" + userId); // 使用占位符而非拼接
// 日志内容规范:
// 1. 使用占位符 {} 而非字符串拼接
// 2. 包含关键业务信息(ID、订单号等)
// 3. 异常日志必须包含堆栈信息
// 4. 避免记录敏感信息(密码、身份证号等)
// 5. 避免在循环中打印大量日志Logback 配置示例
<!-- logback-spring.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATH" value="./logs"/>
<property name="APP_NAME" value="blog-system"/>
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 文件输出(按天滚动) -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${APP_NAME}.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 错误日志单独文件 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}-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_PATH}/${APP_NAME}-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 开发环境 -->
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
<logger name="com.example.blog" level="DEBUG"/>
</springProfile>
<!-- 生产环境 -->
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</springProfile>
</configuration>7.2.5 单元测试规范
测试类命名规范
// 测试类命名:被测试类名 + Test
public class UserServiceTest { }
public class OrderServiceTest { }
// 测试方法命名:test + 方法名 + 场景
@Test
public void testFindById_success() { }
@Test
public void testFindById_notFound() { }
@Test
public void testCreateOrder_success() { }
@Test
public void testCreateOrder_insufficientStock() { }JUnit 5 + Mockito 示例
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderMapper orderMapper;
@Mock
private ProductMapper productMapper;
@Mock
private StringRedisTemplate redisTemplate;
@InjectMocks
private OrderService orderService;
@Test
@DisplayName("创建订单成功")
void testCreateOrder_success() {
// Given(准备数据)
OrderDTO dto = new OrderDTO();
dto.setUserId(1L);
dto.setProductId(100L);
dto.setQuantity(2);
Product product = new Product();
product.setId(100L);
product.setName("测试商品");
product.setPrice(new BigDecimal("99.99"));
product.setStock(10);
// Mock 行为
when(productMapper.selectById(100L)).thenReturn(product);
when(orderMapper.insert(any(Order.class))).thenReturn(1);
// When(执行测试)
Order result = orderService.createOrder(dto);
// Then(验证结果)
assertNotNull(result);
assertEquals(1L, result.getUserId());
assertEquals(new BigDecimal("199.98"), result.getTotalAmount());
// 验证方法调用
verify(productMapper, times(1)).selectById(100L);
verify(orderMapper, times(1)).insert(any(Order.class));
}
@Test
@DisplayName("创建订单失败 - 库存不足")
void testCreateOrder_insufficientStock() {
// Given
OrderDTO dto = new OrderDTO();
dto.setUserId(1L);
dto.setProductId(100L);
dto.setQuantity(10);
Product product = new Product();
product.setId(100L);
product.setStock(5); // 库存不足
when(productMapper.selectById(100L)).thenReturn(product);
// When & Then
BusinessException exception = assertThrows(BusinessException.class, () -> {
orderService.createOrder(dto);
});
assertEquals("库存不足", exception.getMessage());
}
@Test
@DisplayName("查询订单列表 - 空结果")
void testFindOrderList_empty() {
// Given
when(orderMapper.selectList(any())).thenReturn(Collections.emptyList());
// When
List<Order> result = orderService.findOrderList(1L);
// Then
assertTrue(result.isEmpty());
}
}测试覆盖率要求
// 测试覆盖原则:
// 1. 核心业务逻辑必须测试(Service 层)
// 2. 边界条件必须测试(空值、极值、异常)
// 3. 分支覆盖(if-else、switch 的所有分支)
// 4. 异常场景必须测试
// 测试分层:
// 单元测试:测试单个类或方法(使用 Mock)
// 集成测试:测试多个组件协作(使用真实数据库)
// 接口测试:测试 HTTP 接口(使用 MockMvc)
@SpringBootTest
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void testLogin_success() throws Exception {
LoginDTO dto = new LoginDTO();
dto.setUsername("admin");
dto.setPassword("123456");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.token").isNotEmpty());
}
}7.3 练习任务
综合练习项目
练习1:个人博客系统(入门级)
目标: 独立完成一个完整的博客系统
功能要求:
- 用户注册/登录(JWT 认证)
- 文章 CRUD(支持 Markdown)
- 分类管理
- 标签管理(多对多)
- 评论功能
- 文章搜索
- Redis 缓存热点文章
技术要点:
- Spring Boot + MyBatis-Plus
- MySQL 数据库设计
- Redis 缓存策略
- JWT 认证
- Swagger API 文档
预计时间: 2周
练习2:电商订单系统(进阶级)
目标: 实现电商核心订单流程
功能要求:
- 商品管理(SPU/SKU)
- 购物车功能
- 订单创建(库存扣减)
- 订单支付(模拟)
- 订单超时取消(延迟队列)
- 订单查询(分页、搜索)
技术要点:
- 分布式 ID 生成
- Redis 库存预扣减
- RabbitMQ 延迟队列
- 分布式事务(Seata 或最终一致性)
- 接口幂等性设计
预计时间: 3周
练习3:权限管理系统(高级)
目标: 实现企业级权限管理系统
功能要求:
- 用户管理
- 角色管理
- 菜单权限管理
- 部门管理
- 数据权限(部门级别)
- 操作日志审计
- 在线用户管理
技术要点:
- Spring Security 深度集成
- RBAC 权限模型
- 动态权限加载
- AOP 操作日志
- MyBatis 拦截器(数据权限)
预计时间: 3周
7.4 学习检查清单
项目实战能力
- [ ] 能够独立完成从需求分析到部署上线的完整流程
- [ ] 掌握至少一个完整的项目架构设计
- [ ] 能够设计合理的数据库表结构
- [ ] 能够编写规范的 RESTful API
- [ ] 能够使用 Redis 进行缓存优化
- [ ] 能够使用消息队列处理异步任务
- [ ] 能够使用 Spring Security 实现认证授权
代码质量能力
- [ ] 遵循阿里巴巴 Java 开发规范
- [ ] 能够编写清晰的注释和文档
- [ ] 能够编写单元测试(覆盖率 > 60%)
- [ ] 能够使用 Git 进行版本控制和团队协作
- [ ] 能够进行代码审查和重构
问题解决能力
- [ ] 能够独立排查和解决常见 Bug
- [ ] 能够使用日志定位问题
- [ ] 能够进行基本的性能优化
- [ ] 能够阅读源码理解框架原理
工程化能力
- [ ] 能够使用 Maven/Gradle 管理项目依赖
- [ ] 能够使用 Docker 容器化部署
- [ ] 能够配置 CI/CD 自动化流程
- [ ] 能够编写项目文档(README、API 文档)
7.5 学习建议
项目选择建议
- 初学者:从个人博客系统开始,熟悉基础开发流程
- 有一定经验:挑战电商系统,学习分布式架构
- 进阶开发者:实现权限管理系统,深入理解安全框架
学习方法建议
- 先模仿后创新:参考开源项目,理解设计思路后再加入自己的改进
- 小步快跑:将大项目拆分为小模块,逐个实现
- 持续重构:完成功能后回顾代码,不断优化
- 写文档:记录设计思路、遇到的问题及解决方案
- 代码审查:找同学或社区帮忙 Review 代码
常见问题解答
Q1:项目做到什么程度才算完成?
A:满足以下条件即可:
- 核心功能完整可用
- 代码规范,有适当注释
- 有基本的单元测试
- 有完整的 API 文档
- 能够部署运行
Q2:遇到不会的技术怎么办?
A:
- 先查阅官方文档
- 搜索相关教程和博客
- 参考开源项目的实现
- 在社区提问(StackOverflow、掘金等)
- 适当降低要求,先用简单方案实现
Q3:如何避免项目烂尾?
A:
- 控制项目规模,不要贪大求全
- 制定明确的里程碑和截止时间
- 优先完成核心功能
- 记录进度,保持成就感
- 找伙伴一起学习和监督
完成本阶段后,你应该能够独立完成中等复杂度的 Java 后端项目,具备初级 Java 工程师的实战能力。继续深入学习和实践,你将逐步成长为中高级开发者。
评论
正在加载评论…