项目开发阶段化
我将项目开发划分成了下面四个阶段,从基础准备到核心业务开发再到技术难点攻关
阶段一:基础与权限(当前阶段)
- 统一返回类与异常处理:填充common模块。
- 数据模型映射:在
model 模块里,根据 SQL 写出 User、InterfaceInfo 等实体类。
- 用户模块 (User Service):
- 实现手机号注册/登录(配合 Redis 存验证码)。
- 引入 JWT (Token) 鉴权。
- 实现 AK/SK 的自动生成(注册时分配)。
阶段二:接口管理(核心业务)
- 接口发布与管理:实现管理员对 API 接口的增删改查(CRUD)。
- 接口详情页展示:让开发者能在前端看到有哪些 API 可以调。
阶段三:签名认证与 SDK(技术难点)
- API 签名算法实现:编写核心的校验逻辑。
- 开发 SDK:写一个可以让别人直接
import 的 jar 包,自动处理签名。
阶段四:网关与中间件(高并发攻坚)
- Gateway 搭建:实现统一鉴权和路由转发。
- Redis 限流:防止接口被刷。
- MQ + ES 日志系统:异步收集调用记录并实现报表搜索。
统一返回类与异常处理
在一个标准的项目里,后端不能直接把一堆原始数据或者英文报错丢给前端。我们需要一个统一的包装盒,不管结果是成功还是失败,都长这样: { "code": 0, "data": { ... }, "message": "ok" }
错误码枚举(ErrorCode.java)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
public enum ErrorCode {
SUCCESS(0, "ok"), PARAMS_ERROR(40000, "请求参数错误"), NOT_LOGIN_ERROR(40100, "未登录"), NO_AUTH_ERROR(40101, "无权限"), NOT_FOUND_ERROR(40400, "请求数据不存在"), FORBIDDEN_ERROR(40301, "禁止操作"), SYSTEM_ERROR(50000, "系统内部异常"), OPERATION_ERROR(50001, "操作失败");
private final int code; private final String message;
ErrorCode(int code, String message) { this.code = code; this.message = message; }
public int getCode() { return code; } public String getMessage() { return message; } }
|
在这里采用的是业务状态码而不是HTTP状态码,因为标准的 HTTP 状态码只有几十个,不够描述复杂的业务场景。
- 404 只告诉前端“资源找不到了”。
- 40400 可能代表“找不到该用户”,40401 可能代表“找不到该订单”。 通过扩充位数(通常是 5 位),可以对错误进行分类管理。
另外,无论后端发生什么错误,通常都会给前端返回200OK的HTTP状态,然后在返回的JSON体中告知具体的业务代码:
1 2 3 4 5
| { "code": 40400, "message": "请求资源不存在", "data": null }
|
这样做的好处是:前端的AJAX拦截器可以统一处理业务逻辑,而不会因为HTTP状态码不是200就直接崩溃或弹窗
通用对象返回(BaseResponse.java)
这就是那个“包装盒”,用泛型 <T> 确保它可以装下任何类型的返回数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
@Data public class BaseResponse<T> implements Serializable {
private int code; private T data; private String message;
public BaseResponse(int code, T data, String message) { this.code = code; this.data = data; this.message = message; }
public BaseResponse(int code, T data) { this(code, data, ""); }
public BaseResponse(ErrorCode errorCode) { this(errorCode.getCode(), null, errorCode.getMessage()); } }
|
泛型的好处
1.代码复用:一套 BaseResponse 逻辑走天下,不用重复造轮子。
2.类型安全:编译器会帮你检查类型。如果你声明了 BaseResponse<String>,却试图往里面放个 Integer,代码在编译阶段就会报错,而不是等到程序运行(Runtime)时才崩溃。
3.语义清晰:看到 BaseResponse<User>,任何人一眼就能看出这个响应体里装的是用户信息。
常见的占位符字母
虽然你可以用任何字母(甚至是 BaseResponse<ABC>),但按照 Java 的惯例,通常使用以下单大写字母:
| 字母 |
含义 |
常见用途 |
| T |
Type |
表示任意类型(最常用) |
| E |
Element |
表示集合中的元素(如 List<E>) |
| K |
Key |
表示键(如 Map<K, V> 中的键) |
| V |
Value |
表示值(如 Map<K, V> 中的值) |
| R |
Return |
表示方法的返回值类型 |
返回工具类(ResultUtils.java)
可以简化在Controller里的代码量,直接ResultUtils.success(data)就能返回标准格式,不需要每次都new
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
public class ResultUtils {
public static <T> BaseResponse<T> success(T data) { return new BaseResponse<>(0, data, "ok"); }
public static BaseResponse error(ErrorCode errorCode){ return new BaseResponse<>(errorCode); }
public static BaseResponse error(int code,String message){ return new BaseResponse<>(code,null,message); }
public static BaseResponse error(ErrorCode errorCode,String message){ return new BaseResponse<>(errorCode.getCode(),null,message); }
}
|
数据模型映射
主要使用了
Lombok (@Data):自动生成 Get/Set 方法。
MyBatis-Plus 注解:告诉框架这个类对应哪张表、哪个是主键、哪个是逻辑删除字段。
用户实体类 (User.java)
这个类主要存储开发者的基本信息以及最重要的 API 签名密钥(AK/SK)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
|
@Data @TableName(value = "user") public class User implements Serializable {
@TableId(type = IdType.AUTO) private Long id;
private String userAccount;
private String userPassword;
private String phone;
private String accessKey;
private String secretKey;
private String userRole;
private Date createTime;
private Date updateTime;
@TableLogic private Integer isDeleted;
@TableField(exist = false) private static final long serialVersionUID = 1L;
}
|
接口信息实体类 (InterfaceInfo.java)
API 开放平台里的“商品货架”,记录了每个接口的详细属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| @Data @TableName(value = "interface_info") public class InterfaceInfo implements Serializable {
@TableId(type = IdType.AUTO) private Long id;
private String name;
private String description;
private String url;
private String method;
private String requestParams;
private String requestHeader;
private String responseHeader;
private Integer status;
private Long userId;
private Long createTime;
private Long updateTime;
@TableLogic private Integer isDeleted;
@TableField(exist = false) private static final long serialVersionUID = 1L; }
|
用户调用接口关系表 (UserInterfaceInvoke.java)
高并发抢占资源的核心表,用来记录每个开发者对某个接口还能调用多少次(配额)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| @Data @TableName(value = "user_interface_invoke") public class UserInterfaceInvoke implements Serializable {
@TableId(type= IdType.AUTO) private Long id;
private Long userId;
private Long interfaceInfoId;
private Integer totalNum;
private Integer leftNum;
private Integer status;
private Date createTime;
private Date updateTime;
@TableLogic private Integer isDeleted;
@TableField(exist = false) private static final long serialVersionUID = 1L; }
|
用户模块
注册模块
密码加密工具类 (PasswordUtils.java)
这里采用最经典的 MD5 + 固态盐值(Salt) 方案。Spring 框架自带了非常好用的 DigestUtils,我们直接拿来用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
public class PasswordUtils {
private static final String SALT = "api_platform_AccyCx_2026";
public static String encryptPassword(String userPassword){
String saltedPassword = SALT + userPassword;
return DigestUtils.md5DigestAsHex(saltedPassword.getBytes()); }
}
|
为什么要“加盐(Salt)”?
单纯的 MD5 加密并不安全,因为黑客手里有‘彩虹表’(记录了常见密码 123456 对应的 MD5 值)。为了防破解,应该在明文密码上拼接了一段只有后端代码才知道的‘盐值(Salt)’。这样一来,即使用户的密码再简单,经过加盐混淆后,算出来的 MD5 值也是完全陌生且无法通过彩虹表反查的。
(注:在更高级的安全场景中,还会使用 BCrypt 这种每次生成密文都不一样的动态加盐算法,目前我们用 MD5+静态盐已经足够支撑这个项目的注册登录逻辑了。)
密钥生成工具类 (KeyUtils.java)
这里我们使用 Java 自带的 UUID(通用唯一识别码)来生成基础的随机串。为了让 SK 更加安全和复杂,我们甚至可以复用刚才写的 PasswordUtils 对它进行一次哈希混淆。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
public class KeyUtils {
public static String generateAccessKey(){ return UUID.randomUUID().toString().replace("-",""); }
public static String generateSecretKey(){
String rawKey = UUID.randomUUID().toString().replace("-","");
return PasswordUtils.encryptPassword(rawKey); }
}
|
建立数据通道:UserMapper.java
在 mapper 包下新建这个接口。它继承了 MyBatis-Plus 的 BaseMapper,连一行 SQL 都不用写,就已经拥有了对 user 表的增删改查能力。
1 2 3 4 5 6 7
|
@Mapper public interface UserMapper extends BaseMapper<User> {
}
|
定义业务规范:UserService.java
在 service 包下新建这个接口。同样继承 MyBatis-Plus 的 IService。我们在这里定义一个专门用于注册的方法。(之前提到有手机号注册,redis存验证码的方案,后续会添加)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
public interface UserService extends IService<User> {
long userRegister(String userAccount, String userPassword, String checkPassword); }
|
核心逻辑落地:UserServiceImpl.java
这里详细定义注册方法的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
|
@Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired private UserMapper userMapper;
@Override public long userRegister(String userAccount,String userPassword,String checkPassword){
if(StringUtils.isAnyBlank(userAccount,userPassword,checkPassword)){ throw new RuntimeException("参数不能为空"); }
if(userAccount.length()<4 || userPassword.length()<8){ throw new RuntimeException("账号过短或密码过短"); }
if(!userPassword.equals(checkPassword)){ throw new RuntimeException("两次输入的密码不一致"); }
QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("user_account",userAccount); long count = userMapper.selectCount(queryWrapper); if(count>0){ throw new RuntimeException("账号重复"); }
String encryptPassword = PasswordUtils.encryptPassword(userPassword);
String accessKey = KeyUtils.generateAccessKey(); String secretKey = KeyUtils.generateSecretKey();
User user = new User(); user.setUserAccount(userAccount); user.setUserPassword(encryptPassword); user.setAccessKey(accessKey); user.setSecretKey(secretKey);
boolean saveResult = this.save(user); if(!saveResult){ throw new RuntimeException("注册失败,数据库错误"); }
return user.getId(); } }
|
第 4 步检查账号重复,如果在极高的并发下,两个请求同时来到这里,发现账号都不存在,然后同时执行插入,不就产生重复账号了吗?
没错!代码层面的查重在多线程下会失效。所以我们在之前设计数据库表时,已经在 user_account 字段上加了 UNIQUE KEY(唯一索引)。即使代码层没拦住,数据库底层也会抛出 DuplicateKeyException,确保数据绝对一致。
创建注册请求DTO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
@Data @Schema(description = "用户注册请求体") public class UserRegisterRequest implements Serializable { private static final long serialVersionUID = 1L;
@Schema(description = "用户账号") private String userAccount;
@Schema(description = "用户密码") private String userPassword;
@Schema(description = "确认密码") private String checkPassword; }
|
编写 UserControlle的用户注册接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| ** * 用户接口 */ @RestController @RequestMapping("/user") @Tag(name = "用户接口", description = "用户的注册、登录与管理") public class UserController {
@Autowired private UserService userService;
@PostMapping("/register") @Operation(summary = "用户注册") public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest){
if(userRegisterRequest == null){
return ResultUtils.error(ErrorCode.PARAMS_ERROR,"请求参数为空"); }
String userAccount = userRegisterRequest.getUserAccount(); String userPassword = userRegisterRequest.getUserPassword(); String checkPassword = userRegisterRequest.getCheckPassword();
if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) { return ResultUtils.error(ErrorCode.PARAMS_ERROR, "账号、密码或确认密码不能为空"); }
long result = userService.userRegister(userAccount, userPassword, checkPassword);
return ResultUtils.success(result); }
}
|
到现在:一条完整的“注册”业务线已经彻底打通!前端发起 HTTP POST 请求 -> UserController 接收并校验 -> UserService 加密并分配 AK/SK -> UserMapper 插入数据库。成功通过接口测试后就可以进行下一步了!
登录模块
创建JWT工具类
在 common 模块的 utils 包下创建 JwtUtils.java。这个工具负责根据用户的 ID 和账号,生成一段加密的字符串(Token)。
在写这个工具类前,记得在pom文件里面添加JWT相关的依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public class JwtUtils { private static final long EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L;
private static final String SECRET_KEY = "api_platform_jwt_secret_key_accycx_must_be_very_long_for_security_reasons_123456";
private static final Key KEY = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
public static String generateToken(Long userId, String userAccount){ Map<String,Object> claims = new HashMap<>(); claims.put("userId", userId); claims.put("userAccount", userAccount);
return Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(KEY, SignatureAlgorithm.HS512) .compact(); } }
|
创建登录 DTO 和登录返回的 VO
1 2 3 4 5 6 7 8 9 10 11
| @Data @Schema(description = "用户登录请求体") public class UserLoginRequest implements Serializable {
@Schema(description = "用户账号") private String userAccount;
@Schema(description = "用户密码") private String userPassword; }
|
登录成功后,前端不仅需要 Token,还需要展示用户的账号和角色。我们绝对不能把包含 AK/SK 的原声 User 类直接扔给前端,所以要用一个脱敏的 VO 包装一下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Data @Schema(description = "登录用户返回体") public class LoginUserVO implements Serializable {
@Schema(description = "用户ID") private Long id;
@Schema(description = "用户账号") private String userAccount;
@Schema(description = "用户角色") private String userRole;
@Schema(description = "令牌") private String token; }
|
在Service中实现登录逻辑
在UserService.java中添加方法定义:
1
| LoginUserVO userLogin(String userAccount, String userPassword);
|
在UserServiceImpl.java中实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| @Override public LoginUserVO userLogin(String userAccount, String userPassword){
if(StringUtils.isAnyBlank(userAccount,userPassword)){ throw new RuntimeException("账号和密码不能为空"); }
String encryptPassword = PasswordUtils.encryptPassword(userPassword);
QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("user_account",userAccount); queryWrapper.eq("user_password",encryptPassword); User user = userMapper.selectOne(queryWrapper); if(user == null){ throw new RuntimeException("账号或密码错误"); }
String token = JwtUtils.generateToken(user.getId(),user.getUserAccount());
LoginUserVO loginUserVO = new LoginUserVO(); loginUserVO.setId(user.getId()); loginUserVO.setUserAccount(user.getUserAccount()); loginUserVO.setUserRole(user.getUserRole()); loginUserVO.setToken(token);
return loginUserVO;
}
|
在Controller写登录接口
在UserController.java中实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
|
@PostMapping("/login") @Operation(summary = "用户登录") public BaseResponse<LoginUserVO> userLogin(@RequestBody UserLoginRequest userLoginRequest){
if(userLoginRequest == null){ return ResultUtils.error(ErrorCode.PARAMS_ERROR,"请求参数为空"); }
String userAccount = userLoginRequest.getUserAccount(); String userPassword = userLoginRequest.getUserPassword();
if (StringUtils.isAnyBlank(userAccount, userPassword)) { return ResultUtils.error(ErrorCode.PARAMS_ERROR, "账号或密码不能为空"); }
LoginUserVO loginUserVO = userService.userLogin(userAccount, userPassword);
return ResultUtils.success(loginUserVO); }
|
到这里该项目的一阶段已完成,下篇文章会进入到阶段二:接口管理(核心业务)