登录实现 和 添加配说明文档

This commit is contained in:
zk
2026-03-11 17:44:29 +08:00
parent 7248248b89
commit 33f00d9f68
8 changed files with 345 additions and 10 deletions
@@ -0,0 +1,38 @@
package org.jiayunet.controller;
import lombok.AllArgsConstructor;
import org.jiayunet.pojo.dto.SmsLoginDto;
import org.jiayunet.pojo.vo.LoginVo;
import org.jiayunet.server.LoginServer;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.constraints.Pattern;
/**
* 登录控制类
*
* @author zk
*/
@RestController
@RequestMapping("/public")
@AllArgsConstructor
@Validated
public class LoginController {
private LoginServer loginServer;
@PostMapping("/sms/sendCode")
public boolean sendCode(@RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String mobileNumber) {
return loginServer.sendCode(mobileNumber);
}
@PostMapping("/login/smsLogin")
public LoginVo smsLogin(@Validated @RequestBody SmsLoginDto dto,
HttpServletRequest request,
HttpServletResponse response) {
return loginServer.smsLogin(dto.getMobileNumber(), dto.getCode(), request, response);
}
}
@@ -0,0 +1,20 @@
package org.jiayunet.pojo.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 短信验证码登录入参
*
* @author zk
*/
@Data
public class SmsLoginDto {
@NotBlank(message = "手机号不能为空")
private String mobileNumber;
@NotBlank(message = "验证码不能为空")
private String code;
}
@@ -0,0 +1,20 @@
package org.jiayunet.pojo.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 登录返回
*
* @author zk
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginVo {
private Long userId;
private String nick;
}
@@ -0,0 +1,164 @@
package org.jiayunet.server;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import lombok.extern.slf4j.Slf4j;
import org.jiayunet.constant.PreRedisKeyName;
import org.jiayunet.constant.SmsTemplateEnum;
import org.jiayunet.constant.sms.UniversalSmsVariable;
import org.jiayunet.exception.BusinessException;
import org.jiayunet.exception.BusinessExpCodeEnum;
import org.jiayunet.mapper.UserMapper;
import org.jiayunet.pojo.login.RedisLoginTokenInfo;
import org.jiayunet.pojo.po.User;
import org.jiayunet.pojo.vo.LoginVo;
import org.jiayunet.tool.HttpIpTool;
import org.jiayunet.tool.server.RedisServerTool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 登录服务
*
* @author zk
*/
@Service
@Slf4j
public class LoginServer {
@Value("${app.secret.token:youweiqingnian123}")
private String secret;
@Value("${app.login.token.exceed_time:43200}")
private int tokenExceedTime;
@Value("${app.login.device_online_quantity:5}")
private int deviceOnlineQuantity;
@Autowired
private SmsServer smsServer;
@Autowired
private UserMapper userMapper;
@Autowired
private RedisServerTool redisServerTool;
/**
* 发送短信验证码
*/
public boolean sendCode(String mobileNumber) {
Assert.hasText(mobileNumber, "手机号不能为空");
UniversalSmsVariable variable = new UniversalSmsVariable();
String code = String.valueOf((int) ((Math.random() * 9 + 1) * 100000));
variable.setCode(code);
return smsServer.send(mobileNumber, SmsTemplateEnum.UNIVERSAL, variable);
}
/**
* 短信验证码登录
*/
@Transactional(rollbackFor = Exception.class)
public LoginVo smsLogin(String mobileNumber, String code, HttpServletRequest request, HttpServletResponse response) {
Assert.hasText(mobileNumber, "手机号不能为空");
Assert.hasText(code, "验证码不能为空");
// 校验验证码
boolean verified = smsServer.verify(mobileNumber, SmsTemplateEnum.UNIVERSAL, code);
if (!verified) {
throw new BusinessException(BusinessExpCodeEnum.UNKNOWN_ERROR, "验证码错误或已过期");
}
// 查询用户
User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getMobileNumber, mobileNumber));
// 用户不存在则自动注册
if (user == null) {
user = new User();
user.setMobileNumber(mobileNumber);
user.setNick("用户" + mobileNumber.substring(mobileNumber.length() - 4));
user.setStatus(0);
user.setIsDelete(0L);
user.setCreateTime(Instant.now());
userMapper.insert(user);
}
// 检查用户状态
if (user.getStatus() != null && user.getStatus() == 1) {
throw new BusinessException(BusinessExpCodeEnum.UNKNOWN_ERROR, "账号已被禁用");
}
// 生成JWT
String uuId = UUID.randomUUID().toString().replace("-", "");
Algorithm algorithm = Algorithm.HMAC256(secret);
String token = JWT.create()
.withClaim("userId", user.getId())
.withClaim("uuId", uuId)
.sign(algorithm);
// 构建Redis登录信息
String redisKey = PreRedisKeyName.LOGIN_TOKEN + user.getId();
RedisLoginTokenInfo info = redisServerTool.get(redisKey, RedisLoginTokenInfo.class);
if (info == null) {
info = new RedisLoginTokenInfo();
info.setUserId(user.getId());
info.setAuthority(new ArrayList<>());
info.setRole(new ArrayList<>());
}
// 过滤过期设备
List<RedisLoginTokenInfo.LoginDevice> devices = info.getLoginDevices();
if (devices == null) {
devices = new ArrayList<>();
}
long expireMillis = System.currentTimeMillis() - tokenExceedTime * 1000L;
devices = devices.stream()
.filter(d -> d.getLastLoginTime().isAfter(Instant.ofEpochMilli(expireMillis)))
.collect(Collectors.toList());
// 超过设备上限,移除最早的
while (devices.size() >= deviceOnlineQuantity) {
devices.stream().min(Comparator.comparing(RedisLoginTokenInfo.LoginDevice::getLastLoginTime))
.ifPresent(devices::remove);
}
// 添加当前设备
RedisLoginTokenInfo.LoginDevice device = new RedisLoginTokenInfo.LoginDevice();
device.setUuId(uuId);
device.setLastLoginTime(Instant.now());
device.setLoginIp(HttpIpTool.gteRealIP(request));
devices.add(device);
info.setLoginDevices(devices);
redisServerTool.set(redisKey, info, tokenExceedTime, TimeUnit.SECONDS);
// 清除验证码
smsServer.clear(mobileNumber, SmsTemplateEnum.UNIVERSAL);
// 设置Cookie
Cookie cookie = new Cookie("Token", token);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(tokenExceedTime);
response.addCookie(cookie);
return new LoginVo(user.getId(), user.getNick());
}
}
@@ -17,8 +17,8 @@ spring:
# 电子邮箱
email:
status: close
account: ${EMAIL_ACCOUNT:sim18502043706@163.com}
authorization: ${EMAIL_AUTHORIZATION:CHBCVPYGFZCSUNCP}
account: ${EMAIL_ACCOUNT:xxx@163.com}
authorization: ${EMAIL_AUTHORIZATION:123456}
# 微信支付
wx_pay:
@@ -44,20 +44,20 @@ app:
token:
exceed_time: 129600
#设备在线数量
device_online_quantity: 10
device_online_quantity: 2
# 短信
sms:
service_provider: aliyun
aliyun:
access_key_id: LTAI5tGRFuJmt6nrxRqZYC7z
access_key_secret: xLC3kNSmCvEtsc9j4O8g3rOs70QjZQ
access_key_id: LTAI5tJBVUUJhB7yp14UDzVf
access_key_secret: Opf0iO5FKNrdwI63DPhXazW7utAGTj
oss:
service_provider: aliyun
aliyun:
access_key_id: LTAI5tGRFuJmt6nrxRqZYC7z
access_key_secret: xLC3kNSmCvEtsc9j4O8g3rOs70QjZQ
access_key_id: LTAI5tEdLKKQUKhTyUpfH5Mk
access_key_secret: RjUdTrq0V5qA4b3BUElNhXqs3ZLp5k
# 防刷配置 20秒 20次
prevent_replay:
@@ -15,7 +15,7 @@ public enum SmsTemplateEnum {
/**
* 通用验证码
*/
UNIVERSAL("加鱼宠物", "SMS_480245067", 5, UniversalSmsVariable.class);
UNIVERSAL("油梨科技", "SMS_501981064", 5, UniversalSmsVariable.class);
/**
* 模板签名
@@ -23,50 +23,62 @@ public class User {
* 手机号
*/
private String mobileNumber;
/**
* 邮箱
*/
private String email;
/**
* 登陆密码
*/
private String loginPassword;
/**
* 昵称
*/
private String nick;
/**
* 真实名字
*/
private String realName;
/**
* 头像
*/
private String picture;
/**
* 生日
*/
private Instant birthday;
/**
* 性别 1男 2女
*/
private Integer sex;
/**
* 微信头像
*/
private String wechatAvatar;
/**
* 学员的微信昵称
* 微信昵称
*/
private String wechatNick;
/**
* 微信openid
*/
private String wechatOpenid;
/**
* 微信UnionId
*/
private String wechatUnionId;
/**
* 状态 0 正常 1 禁用
*/
+81
View File
@@ -0,0 +1,81 @@
# OfferPieBackEnd 项目结构说明
## 1️⃣ 项目整体层次
```
offerpie/back-end
├─ pom.xml # 父 Maven 项目,统一管理依赖、插件、属性
├─ clientapi/ # **C 端**(面向用户)API 服务,包含业务控制器(如 LoginController)、DTO/VO(如 LoginVo、SmsLoginDto)以及服务器实现(LoginServer 等)
│ ├─ pom.xml
│ └─ src/main/java
│ └─ org.jiayunet
│ └─ ClientApplication.java # Spring Boot 主入口
├─ common/ # **共享层**:被 C 端和 B 端共同使用的代码库
│ ├─ pom.xml
│ └─ src/main/java
│ └─ org.jiayunet
│ ├─ config/ # OSS、Redis、Security、WxPay、Sms 等统一配置
│ ├─ tool/ # Http、IP、Redis、认证、验证码等工具类
│ ├─ interceptor/ # 全局拦截器(日志、TraceId、黑名单、SQL 统计等)
│ ├─ aop/ # AOP 日志切面
│ ├─ exception/ # 业务异常统一处理
│ ├─ email/ # 邮件发送抽象(EmailAbility
│ ├─ wxPay/ # 微信支付相关能力(Js、Native、Transfer 等)
│ ├─ pojo/ # 公共 POJO(统一响应、登录/防重放 token 等)
│ └─ web/ # Spring MVC 全局响应体 advice
└─ manager/ # **B 端 + C 端共享** 的业务实现(尚未搭建完整的 B 端 UI)
├─ pom.xml
└─ src/main/java
└─ org.jiayunet
├─ controller/ # 对外 REST 接口(HealthCheck、Oss 等)
├─ mapper/ # MyBatis MapperUserMapper、OssFileMapper
├─ pojo/
│ ├─ po/ # 持久化实体(User、OssFile 等)
│ └─ vo/ # ViewObjectOssUrlVo 等)
└─ … (业务 Service、Utils 等)
```
> **设计理念** `manager` 模块把 **C 端** 与 **B 端** 共用的代码(如实体、Mapper、统一响应、拦截器、配置)集中放在 `common`,从而避免在两个子项目之间出现重复实现。`client‑api` 只负责面向用户的 API,`manager` 提供后台管理相关的功能;两者均通过 `common` 中的工具、配置、统一异常处理等实现一致的技术栈。
## 2️⃣ 各层模块职责
| 层级 | 主要职责 | 关键类/包 |
|------|----------|-----------|
| **clientapi** | - 面向终端用户的 REST API <br> - 启动 Spring Boot 应用 | `ClientApplication``controller/*``application.yml` |
| **common** | - **统一配置**OSS、Redis、Security、WxPay、Sms 等 <br> - **跨层工具**:HTTP、IP、认证、验证码、Redis Server 等 <br> - **全局拦截/切面**:日志、TraceId、黑名单、SQL 打印 <br> - **统一异常/响应**`GlobalExceptionAdvice``UnifiedResponse` <br> - **业务抽象**:邮件发送、微信支付(Native/JS/Transfer <br> - **公共 POJO**:登录令牌、防重放信息等 | `config/`, `tool/`, `interceptor/`, `aop/`, `exception/`, `email/`, `wxPay/`, `pojo/` |
| **manager** | - **业务实体**`User``OssFile` 等) <br> - **MyBatis Mapper**`UserMapper``OssFileMapper` <br> - **业务 API**:文件上传/下载、健康检查等 <br> - **业务逻辑**:服务层、工具类等 <br> - **既供 B 端 UI(待实现)使用,也供 C 端 业务直接调用** | `controller/`, `mapper/`, `pojo/po/`, `pojo/vo/` |
## 3️⃣ 关键业务实体(示例)
| 实体 | 所属路径 | 作用概述 |
|------|----------|----------|
| `User` | `manager/src/main/java/.../pojo/po/User.java` | 记录用户基础信息(手机号、邮箱、密码、昵称、微信绑定等),配合 `UserMapper` 完成持久化。 |
| `OssFile` | `manager/src/main/java/.../pojo/po/OssFile.java` | 描述 OSS(对象存储)中文件的元数据(路径、大小、标签等),通过 `OssFileMapper` 进行增删改查。 |
| `OssUrlVo` | `manager/src/main/java/.../pojo/vo/OssUrlVo.java` | 对外返回的 OSS 访问 URL 结构体,供前端直接使用。 |
> 这些实体位于 **manager**,因为它们属于业务模型,`manager` 负责提供完整的业务实现;同时它们可以被 **C 端**(如 `clientapi`)直接调用,体现了 B 端 + C 端共享的设计初衷。
## 4️⃣ 共享技术栈(位于 `common`)
| 类别 | 关键实现 | 位置 |
|------|----------|------|
| **配置** | `OssConfig`, `RedissonConf`, `SecurityConfig`, `WxPayConfig`, `SmsConfig` | `common/config` |
| **安全** | JWT 过滤器、登录令牌 (`RedisLoginTokenInfo`)、防重放 (`RedisPreventReplayInfo`) | `common/interceptor``common/pojo/interceptor` |
| **邮件** | `EmailAbility`(封装邮件发送) | `common/email` |
| **微信支付** | `WxJsPayAbility`, `WxNativePayAbility`, `WxTransferPayAbility`, `WxPayNotifyController` | `common/wxPay` |
| **全局异常** | `GlobalExceptionAdvice`, `BusinessException`, `BusinessExpCodeEnum` | `common/exception` |
| **日志 & AOP** | `ControllerLogAspect`, `LoggingOriginalRequestFilter`, `SqlLoggerInterceptor` | `common/aop`, `common/interceptor` |
| **工具类** | `HttpTool`, `HttpIpTool`, `AuthenticTool`, `ObjectTool`, `VerifyImageCodeUtils` | `common/tool` |
| **统一返回体** | `UnifiedResponse`, `UnifiedResponseBodyAdvice` | `common/pojo`, `common/web` |
| **批量/更新** | `UpdateBatchMethod`(批量更新策略) | `common/config` |
这些设施在 **C 端****B 端** 中统一使用,确保技术栈、日志、异常、配置保持一致。
## 5️⃣ 构建与运行
- **父 POM**`back-end/pom.xml`)统一管理子模块的依赖与插件。
- 子模块 (`clientapi`, `common`, `manager`) 均可单独 `mvn clean install`,生成各自的 jar 包。
- **启动入口**:运行 `clientapi` 中的 `ClientApplication`Spring Boot 会自动扫描并加载 `common`(配置、拦截、工具)以及 `manager` 中声明的 Mapper 与 Service。
## 6️⃣ 小结
- 项目采用 **三层结构**
1. **clientapi** → 只负责 C 端 REST 接口。
2. **manager** → 包含业务实体、Mapper 与业务 API,既供 B 端(后台)使用,也供 C 端直接调用;因此是 **B 端 +C 端共享层**
3. **common** → 所有层共同依赖的底层设施(配置、工具、拦截、异常、支付、邮件等),实现“一次实现、全局共享”。
- 这种布局把 **业务实现****技术支撑** 明确分离,后续在 C 端或 B 端新增功能时,只需在 `manager` 中编写业务代码,`common` 负责统一的技术支撑,避免代码重复、提高维护效率。