From 33f00d9f68eec17632e4f297ec041b3c24fb1d03 Mon Sep 17 00:00:00 2001 From: zk Date: Wed, 11 Mar 2026 17:44:29 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=AE=9E=E7=8E=B0=20?= =?UTF-8?q?=E5=92=8C=20=E6=B7=BB=E5=8A=A0=E9=85=8D=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jiayunet/controller/LoginController.java | 38 ++++ .../org/jiayunet/pojo/dto/SmsLoginDto.java | 20 +++ .../java/org/jiayunet/pojo/vo/LoginVo.java | 20 +++ .../java/org/jiayunet/server/LoginServer.java | 164 ++++++++++++++++++ .../src/main/resources/application-local.yml | 14 +- .../jiayunet/constant/SmsTemplateEnum.java | 2 +- .../main/java/org/jiayunet/pojo/po/User.java | 16 +- 项目结构说明.md | 81 +++++++++ 8 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 client-api/src/main/java/org/jiayunet/controller/LoginController.java create mode 100644 client-api/src/main/java/org/jiayunet/pojo/dto/SmsLoginDto.java create mode 100644 client-api/src/main/java/org/jiayunet/pojo/vo/LoginVo.java create mode 100644 client-api/src/main/java/org/jiayunet/server/LoginServer.java create mode 100644 项目结构说明.md diff --git a/client-api/src/main/java/org/jiayunet/controller/LoginController.java b/client-api/src/main/java/org/jiayunet/controller/LoginController.java new file mode 100644 index 0000000..78f8031 --- /dev/null +++ b/client-api/src/main/java/org/jiayunet/controller/LoginController.java @@ -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); + } +} diff --git a/client-api/src/main/java/org/jiayunet/pojo/dto/SmsLoginDto.java b/client-api/src/main/java/org/jiayunet/pojo/dto/SmsLoginDto.java new file mode 100644 index 0000000..6449165 --- /dev/null +++ b/client-api/src/main/java/org/jiayunet/pojo/dto/SmsLoginDto.java @@ -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; +} diff --git a/client-api/src/main/java/org/jiayunet/pojo/vo/LoginVo.java b/client-api/src/main/java/org/jiayunet/pojo/vo/LoginVo.java new file mode 100644 index 0000000..32a545f --- /dev/null +++ b/client-api/src/main/java/org/jiayunet/pojo/vo/LoginVo.java @@ -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; +} diff --git a/client-api/src/main/java/org/jiayunet/server/LoginServer.java b/client-api/src/main/java/org/jiayunet/server/LoginServer.java new file mode 100644 index 0000000..40fd755 --- /dev/null +++ b/client-api/src/main/java/org/jiayunet/server/LoginServer.java @@ -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().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 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()); + } +} diff --git a/client-api/src/main/resources/application-local.yml b/client-api/src/main/resources/application-local.yml index bf4875f..a34e3a6 100644 --- a/client-api/src/main/resources/application-local.yml +++ b/client-api/src/main/resources/application-local.yml @@ -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: diff --git a/manager/src/main/java/org/jiayunet/constant/SmsTemplateEnum.java b/manager/src/main/java/org/jiayunet/constant/SmsTemplateEnum.java index c935bf4..8d3ce2c 100644 --- a/manager/src/main/java/org/jiayunet/constant/SmsTemplateEnum.java +++ b/manager/src/main/java/org/jiayunet/constant/SmsTemplateEnum.java @@ -15,7 +15,7 @@ public enum SmsTemplateEnum { /** * 通用验证码 */ - UNIVERSAL("加鱼宠物", "SMS_480245067", 5, UniversalSmsVariable.class); + UNIVERSAL("油梨科技", "SMS_501981064", 5, UniversalSmsVariable.class); /** * 模板签名 diff --git a/manager/src/main/java/org/jiayunet/pojo/po/User.java b/manager/src/main/java/org/jiayunet/pojo/po/User.java index a4c5cb3..f8a1221 100644 --- a/manager/src/main/java/org/jiayunet/pojo/po/User.java +++ b/manager/src/main/java/org/jiayunet/pojo/po/User.java @@ -23,55 +23,67 @@ 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 禁用 */ private Integer status; - + /** * 添加时间 */ diff --git a/项目结构说明.md b/项目结构说明.md new file mode 100644 index 0000000..b235cab --- /dev/null +++ b/项目结构说明.md @@ -0,0 +1,81 @@ +# OfferPie Back‑End 项目结构说明 + +## 1️⃣ 项目整体层次 +``` +offerpie/back-end +│ +├─ pom.xml # 父 Maven 项目,统一管理依赖、插件、属性 +│ +├─ client‑api/ # **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 Mapper(UserMapper、OssFileMapper) + ├─ pojo/ + │ ├─ po/ # 持久化实体(User、OssFile 等) + │ └─ vo/ # ViewObject(OssUrlVo 等) + └─ … (业务 Service、Utils 等) +``` +> **设计理念** – `manager` 模块把 **C 端** 与 **B 端** 共用的代码(如实体、Mapper、统一响应、拦截器、配置)集中放在 `common`,从而避免在两个子项目之间出现重复实现。`client‑api` 只负责面向用户的 API,`manager` 提供后台管理相关的功能;两者均通过 `common` 中的工具、配置、统一异常处理等实现一致的技术栈。 + +## 2️⃣ 各层模块职责 +| 层级 | 主要职责 | 关键类/包 | +|------|----------|-----------| +| **client‑api** | - 面向终端用户的 REST API
- 启动 Spring Boot 应用 | `ClientApplication`、`controller/*`、`application.yml` | +| **common** | - **统一配置**:OSS、Redis、Security、WxPay、Sms 等
- **跨层工具**:HTTP、IP、认证、验证码、Redis Server 等
- **全局拦截/切面**:日志、TraceId、黑名单、SQL 打印
- **统一异常/响应**:`GlobalExceptionAdvice`、`UnifiedResponse`
- **业务抽象**:邮件发送、微信支付(Native/JS/Transfer)
- **公共 POJO**:登录令牌、防重放信息等 | `config/`, `tool/`, `interceptor/`, `aop/`, `exception/`, `email/`, `wxPay/`, `pojo/` | +| **manager** | - **业务实体**(`User`、`OssFile` 等)
- **MyBatis Mapper**(`UserMapper`、`OssFileMapper`)
- **业务 API**:文件上传/下载、健康检查等
- **业务逻辑**:服务层、工具类等
- **既供 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 端**(如 `client‑api`)直接调用,体现了 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`)统一管理子模块的依赖与插件。 +- 子模块 (`client‑api`, `common`, `manager`) 均可单独 `mvn clean install`,生成各自的 jar 包。 +- **启动入口**:运行 `client‑api` 中的 `ClientApplication`,Spring Boot 会自动扫描并加载 `common`(配置、拦截、工具)以及 `manager` 中声明的 Mapper 与 Service。 + +## 6️⃣ 小结 +- 项目采用 **三层结构**: + 1. **client‑api** → 只负责 C 端 REST 接口。 + 2. **manager** → 包含业务实体、Mapper 与业务 API,既供 B 端(后台)使用,也供 C 端直接调用;因此是 **B 端 + C 端共享层**。 + 3. **common** → 所有层共同依赖的底层设施(配置、工具、拦截、异常、支付、邮件等),实现“一次实现、全局共享”。 +- 这种布局把 **业务实现** 与 **技术支撑** 明确分离,后续在 C 端或 B 端新增功能时,只需在 `manager` 中编写业务代码,`common` 负责统一的技术支撑,避免代码重复、提高维护效率。 \ No newline at end of file