feat(多租户模块与用户认证与登录系统模块 ):
1、多租户功能采用tenantId,后端拦截器自动拼接tenantId实现商家的数据隔离。 2、微信和手机号码登录功能: - 微信登录流程:wx.login获取code → 后端code2Session → 返回token - 手机号授权:open-type="getPhoneNumber" → 后端解密 → 绑定手机号 - JWT Token:包含userId、openid、userType、tenantId等claims - 角色切换:更新token中的userType,支持customer/merchant/player三种角色 --- ####
This commit is contained in:
parent
98d88731a1
commit
f145c2d741
@ -134,3 +134,13 @@ xss:
|
|||||||
excludes: /system/notice
|
excludes: /system/notice
|
||||||
# 匹配链接
|
# 匹配链接
|
||||||
urlPatterns: /system/*,/monitor/*,/tool/*
|
urlPatterns: /system/*,/monitor/*,/tool/*
|
||||||
|
|
||||||
|
# 微信小程序配置
|
||||||
|
wx:
|
||||||
|
miniapp:
|
||||||
|
# 小程序AppID(请替换为实际值)
|
||||||
|
appId: wx1234567890abcdef
|
||||||
|
# 小程序AppSecret(请替换为实际值)
|
||||||
|
appSecret: your_app_secret_here
|
||||||
|
# API Token有效期(秒),默认7天
|
||||||
|
tokenExpireTime: 604800
|
||||||
|
|||||||
@ -23,6 +23,34 @@
|
|||||||
<artifactId>aqroid-common</artifactId>
|
<artifactId>aqroid-common</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Web MVC 支持 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Bouncy Castle 手机号加密库 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.bouncycastle</groupId>
|
||||||
|
<artifactId>bcprov-jdk15on</artifactId>
|
||||||
|
<version>1.70</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 测试依赖 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- jqwik 属性测试框架 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.jqwik</groupId>
|
||||||
|
<artifactId>jqwik</artifactId>
|
||||||
|
<version>1.8.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
package cn.aqroid.business.auth.config;
|
||||||
|
|
||||||
|
import cn.aqroid.business.auth.interceptor.ApiAuthInterceptor;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Web配置
|
||||||
|
* 配置小程序端API的拦截器
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ApiWebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApiAuthInterceptor apiAuthInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
// API认证拦截器 - 拦截/api/**路径
|
||||||
|
registry.addInterceptor(apiAuthInterceptor)
|
||||||
|
.addPathPatterns("/api/**")
|
||||||
|
.excludePathPatterns(
|
||||||
|
"/api/auth/wx-login", // 微信登录
|
||||||
|
"/api/auth/phone-login", // 手机号登录(需要先wx-login)
|
||||||
|
"/api/public/**", // 公开接口
|
||||||
|
"/api/service/list", // 服务列表(公开)
|
||||||
|
"/api/service/detail/**", // 服务详情(公开)
|
||||||
|
"/api/player/list", // 代练列表(公开)
|
||||||
|
"/api/player/detail/**" // 代练详情(公开)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package cn.aqroid.business.auth.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序配置
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "wx.miniapp")
|
||||||
|
public class WxConfig {
|
||||||
|
|
||||||
|
/** 小程序AppID */
|
||||||
|
private String appId;
|
||||||
|
|
||||||
|
/** 小程序AppSecret */
|
||||||
|
private String appSecret;
|
||||||
|
|
||||||
|
/** Token有效期(秒),默认7天 */
|
||||||
|
private Long tokenExpireTime = 604800L;
|
||||||
|
|
||||||
|
public String getAppId() {
|
||||||
|
return appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAppId(String appId) {
|
||||||
|
this.appId = appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAppSecret() {
|
||||||
|
return appSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAppSecret(String appSecret) {
|
||||||
|
this.appSecret = appSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTokenExpireTime() {
|
||||||
|
return tokenExpireTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTokenExpireTime(Long tokenExpireTime) {
|
||||||
|
this.tokenExpireTime = tokenExpireTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,288 @@
|
|||||||
|
package cn.aqroid.business.auth.controller;
|
||||||
|
|
||||||
|
import cn.aqroid.business.auth.domain.ApiLoginUser;
|
||||||
|
import cn.aqroid.business.auth.domain.WxSession;
|
||||||
|
import cn.aqroid.business.auth.domain.dto.*;
|
||||||
|
import cn.aqroid.business.auth.service.ApiTokenService;
|
||||||
|
import cn.aqroid.business.auth.service.WxAuthService;
|
||||||
|
import cn.aqroid.business.auth.util.ApiLoginUserHolder;
|
||||||
|
import cn.aqroid.business.customer.domain.Customer;
|
||||||
|
import cn.aqroid.business.customer.service.ICustomerService;
|
||||||
|
import cn.aqroid.common.core.domain.AjaxResult;
|
||||||
|
import cn.aqroid.common.exception.ServiceException;
|
||||||
|
import cn.aqroid.common.utils.StringUtils;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序端认证控制器
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
public class ApiAuthController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApiAuthController.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WxAuthService wxAuthService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ICustomerService customerService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApiTokenService apiTokenService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信登录
|
||||||
|
*/
|
||||||
|
@PostMapping("/wx-login")
|
||||||
|
public AjaxResult wxLogin(@RequestBody WxLoginRequest request) {
|
||||||
|
if (StringUtils.isEmpty(request.getCode())) {
|
||||||
|
return AjaxResult.error("登录code不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 通过code获取openid
|
||||||
|
WxSession wxSession = wxAuthService.code2Session(request.getCode());
|
||||||
|
|
||||||
|
if (!wxSession.isSuccess() || StringUtils.isEmpty(wxSession.getOpenid())) {
|
||||||
|
return AjaxResult.error("微信登录失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询或创建顾客
|
||||||
|
Customer existingCustomer = customerService.selectByOpenid(wxSession.getOpenid());
|
||||||
|
boolean isNewUser = (existingCustomer == null);
|
||||||
|
|
||||||
|
Customer customer = customerService.getOrCreateByOpenid(
|
||||||
|
wxSession.getOpenid(),
|
||||||
|
request.getNickname(),
|
||||||
|
request.getAvatar()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. 构建登录用户信息
|
||||||
|
ApiLoginUser loginUser = new ApiLoginUser();
|
||||||
|
loginUser.setUserId(customer.getId());
|
||||||
|
loginUser.setOpenid(customer.getOpenid());
|
||||||
|
loginUser.setUnionid(wxSession.getUnionid());
|
||||||
|
loginUser.setPhone(customer.getPhone());
|
||||||
|
loginUser.setNickname(customer.getNickname());
|
||||||
|
loginUser.setAvatar(customer.getAvatar());
|
||||||
|
loginUser.setUserType("customer");
|
||||||
|
loginUser.setCustomerId(customer.getId());
|
||||||
|
loginUser.setSessionKey(wxSession.getSessionKey());
|
||||||
|
|
||||||
|
// 4. 生成Token
|
||||||
|
String token = apiTokenService.createToken(loginUser);
|
||||||
|
|
||||||
|
// 5. 构建返回结果
|
||||||
|
LoginResult result = new LoginResult();
|
||||||
|
result.setToken(token);
|
||||||
|
result.setUserId(customer.getId());
|
||||||
|
result.setOpenid(customer.getOpenid());
|
||||||
|
result.setPhone(customer.getPhone());
|
||||||
|
result.setUserType("customer");
|
||||||
|
result.setNeedBindPhone(StringUtils.isEmpty(customer.getPhone()));
|
||||||
|
result.setIsNewUser(isNewUser);
|
||||||
|
result.setNickname(customer.getNickname());
|
||||||
|
result.setAvatar(customer.getAvatar());
|
||||||
|
|
||||||
|
log.info("用户登录成功: userId={}, openid={}, isNewUser={}",
|
||||||
|
customer.getId(), customer.getOpenid(), isNewUser);
|
||||||
|
|
||||||
|
return AjaxResult.success(result);
|
||||||
|
} catch (ServiceException e) {
|
||||||
|
log.error("微信登录失败: {}", e.getMessage());
|
||||||
|
return AjaxResult.error(e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("微信登录异常", e);
|
||||||
|
return AjaxResult.error("登录失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号授权登录
|
||||||
|
*/
|
||||||
|
@PostMapping("/phone-login")
|
||||||
|
public AjaxResult phoneLogin(@RequestBody PhoneLoginRequest request, HttpServletRequest httpRequest) {
|
||||||
|
if (StringUtils.isEmpty(request.getEncryptedData()) || StringUtils.isEmpty(request.getIv())) {
|
||||||
|
return AjaxResult.error("手机号授权数据不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前登录用户
|
||||||
|
ApiLoginUser loginUser = ApiLoginUserHolder.getLoginUser();
|
||||||
|
if (loginUser == null) {
|
||||||
|
return AjaxResult.error("请先登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密手机号
|
||||||
|
String phoneNumber = wxAuthService.decryptPhoneNumber(
|
||||||
|
loginUser.getSessionKey(),
|
||||||
|
request.getEncryptedData(),
|
||||||
|
request.getIv()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新顾客手机号
|
||||||
|
Customer customer = customerService.selectById(loginUser.getCustomerId());
|
||||||
|
if (customer != null) {
|
||||||
|
customer.setPhone(phoneNumber);
|
||||||
|
customerService.updateCustomer(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新登录用户信息
|
||||||
|
loginUser.setPhone(phoneNumber);
|
||||||
|
apiTokenService.refreshToken(loginUser);
|
||||||
|
|
||||||
|
// 构建返回结果
|
||||||
|
LoginResult result = new LoginResult();
|
||||||
|
result.setToken(loginUser.getToken());
|
||||||
|
result.setUserId(loginUser.getUserId());
|
||||||
|
result.setOpenid(loginUser.getOpenid());
|
||||||
|
result.setPhone(phoneNumber);
|
||||||
|
result.setUserType(loginUser.getUserType());
|
||||||
|
result.setNeedBindPhone(false);
|
||||||
|
result.setIsNewUser(false);
|
||||||
|
result.setNickname(loginUser.getNickname());
|
||||||
|
result.setAvatar(loginUser.getAvatar());
|
||||||
|
|
||||||
|
log.info("用户绑定手机号成功: userId={}, phone={}", loginUser.getUserId(), phoneNumber);
|
||||||
|
|
||||||
|
return AjaxResult.success(result);
|
||||||
|
} catch (ServiceException e) {
|
||||||
|
log.error("手机号授权失败: {}", e.getMessage());
|
||||||
|
return AjaxResult.error(e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("手机号授权异常", e);
|
||||||
|
return AjaxResult.error("授权失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
*/
|
||||||
|
@GetMapping("/user-info")
|
||||||
|
public AjaxResult getUserInfo() {
|
||||||
|
ApiLoginUser loginUser = ApiLoginUserHolder.getLoginUser();
|
||||||
|
if (loginUser == null) {
|
||||||
|
return AjaxResult.error(401, "未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
return AjaxResult.success(loginUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息
|
||||||
|
*/
|
||||||
|
@PutMapping("/user-info")
|
||||||
|
public AjaxResult updateUserInfo(@RequestBody UpdateUserRequest request) {
|
||||||
|
ApiLoginUser loginUser = ApiLoginUserHolder.getLoginUser();
|
||||||
|
if (loginUser == null) {
|
||||||
|
return AjaxResult.error(401, "未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证昵称长度
|
||||||
|
if (StringUtils.isNotEmpty(request.getNickname())) {
|
||||||
|
if (request.getNickname().length() < 2 || request.getNickname().length() > 20) {
|
||||||
|
return AjaxResult.error("昵称长度必须在2-20个字符之间");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新顾客信息
|
||||||
|
Customer customer = customerService.selectById(loginUser.getCustomerId());
|
||||||
|
if (customer != null) {
|
||||||
|
if (StringUtils.isNotEmpty(request.getNickname())) {
|
||||||
|
customer.setNickname(request.getNickname());
|
||||||
|
loginUser.setNickname(request.getNickname());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(request.getAvatar())) {
|
||||||
|
customer.setAvatar(request.getAvatar());
|
||||||
|
loginUser.setAvatar(request.getAvatar());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(request.getPhone())) {
|
||||||
|
customer.setPhone(request.getPhone());
|
||||||
|
loginUser.setPhone(request.getPhone());
|
||||||
|
}
|
||||||
|
customerService.updateCustomer(customer);
|
||||||
|
apiTokenService.refreshToken(loginUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AjaxResult.success("更新成功");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("更新用户信息异常", e);
|
||||||
|
return AjaxResult.error("更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换角色
|
||||||
|
*/
|
||||||
|
@PostMapping("/switch-role")
|
||||||
|
public AjaxResult switchRole(@RequestBody SwitchRoleRequest request) {
|
||||||
|
ApiLoginUser loginUser = ApiLoginUserHolder.getLoginUser();
|
||||||
|
if (loginUser == null) {
|
||||||
|
return AjaxResult.error(401, "未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StringUtils.isEmpty(request.getUserType())) {
|
||||||
|
return AjaxResult.error("角色类型不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证角色类型
|
||||||
|
String userType = request.getUserType();
|
||||||
|
if (!"customer".equals(userType) && !"merchant".equals(userType) && !"player".equals(userType)) {
|
||||||
|
return AjaxResult.error("无效的角色类型");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 验证用户是否拥有该角色
|
||||||
|
// 这里需要根据实际业务逻辑判断用户是否有权限切换到目标角色
|
||||||
|
|
||||||
|
// 更新登录用户角色
|
||||||
|
loginUser.setUserType(userType);
|
||||||
|
if (request.getTenantId() != null) {
|
||||||
|
loginUser.setTenantId(request.getTenantId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新Token
|
||||||
|
apiTokenService.refreshToken(loginUser);
|
||||||
|
|
||||||
|
// 生成新Token
|
||||||
|
String newToken = apiTokenService.createToken(loginUser);
|
||||||
|
|
||||||
|
LoginResult result = new LoginResult();
|
||||||
|
result.setToken(newToken);
|
||||||
|
result.setUserId(loginUser.getUserId());
|
||||||
|
result.setUserType(userType);
|
||||||
|
result.setTenantId(loginUser.getTenantId());
|
||||||
|
|
||||||
|
log.info("用户切换角色成功: userId={}, newRole={}", loginUser.getUserId(), userType);
|
||||||
|
|
||||||
|
return AjaxResult.success(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("切换角色异常", e);
|
||||||
|
return AjaxResult.error("切换失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public AjaxResult logout(HttpServletRequest request) {
|
||||||
|
String token = request.getHeader("Authorization");
|
||||||
|
if (StringUtils.isNotEmpty(token)) {
|
||||||
|
token = apiTokenService.getTokenFromHeader(token);
|
||||||
|
apiTokenService.invalidateToken(token);
|
||||||
|
}
|
||||||
|
ApiLoginUserHolder.clear();
|
||||||
|
return AjaxResult.success("退出成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
package cn.aqroid.business.auth.domain;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API登录用户模型
|
||||||
|
* 用于小程序端用户认证
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public class ApiLoginUser implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 用户ID(根据userType对应不同表的ID) */
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** 微信openid */
|
||||||
|
private String openid;
|
||||||
|
|
||||||
|
/** 微信unionid */
|
||||||
|
private String unionid;
|
||||||
|
|
||||||
|
/** 手机号 */
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
/** 昵称 */
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/** 头像 */
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
/** 用户类型: customer-顾客, merchant-商家, player-代练 */
|
||||||
|
private String userType;
|
||||||
|
|
||||||
|
/** 租户ID(商家/代练有值) */
|
||||||
|
private Long tenantId;
|
||||||
|
|
||||||
|
/** 顾客ID */
|
||||||
|
private Long customerId;
|
||||||
|
|
||||||
|
/** 商家ID */
|
||||||
|
private Long merchantId;
|
||||||
|
|
||||||
|
/** 代练ID */
|
||||||
|
private Long playerId;
|
||||||
|
|
||||||
|
/** JWT Token */
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
/** 登录时间(毫秒) */
|
||||||
|
private Long loginTime;
|
||||||
|
|
||||||
|
/** 过期时间(毫秒) */
|
||||||
|
private Long expireTime;
|
||||||
|
|
||||||
|
/** 微信session_key(用于解密手机号等) */
|
||||||
|
private String sessionKey;
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(Long userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenid() {
|
||||||
|
return openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOpenid(String openid) {
|
||||||
|
this.openid = openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUnionid() {
|
||||||
|
return unionid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUnionid(String unionid) {
|
||||||
|
this.unionid = unionid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPhone() {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhone(String phone) {
|
||||||
|
this.phone = phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNickname() {
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNickname(String nickname) {
|
||||||
|
this.nickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatar() {
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatar(String avatar) {
|
||||||
|
this.avatar = avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserType() {
|
||||||
|
return userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserType(String userType) {
|
||||||
|
this.userType = userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenantId(Long tenantId) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCustomerId() {
|
||||||
|
return customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomerId(Long customerId) {
|
||||||
|
this.customerId = customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMerchantId() {
|
||||||
|
return merchantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMerchantId(Long merchantId) {
|
||||||
|
this.merchantId = merchantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getPlayerId() {
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPlayerId(Long playerId) {
|
||||||
|
this.playerId = playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getToken() {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToken(String token) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getLoginTime() {
|
||||||
|
return loginTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoginTime(Long loginTime) {
|
||||||
|
this.loginTime = loginTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getExpireTime() {
|
||||||
|
return expireTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpireTime(Long expireTime) {
|
||||||
|
this.expireTime = expireTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionKey() {
|
||||||
|
return sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionKey(String sessionKey) {
|
||||||
|
this.sessionKey = sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为顾客
|
||||||
|
*/
|
||||||
|
public boolean isCustomer() {
|
||||||
|
return "customer".equals(userType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为商家
|
||||||
|
*/
|
||||||
|
public boolean isMerchant() {
|
||||||
|
return "merchant".equals(userType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为代练
|
||||||
|
*/
|
||||||
|
public boolean isPlayer() {
|
||||||
|
return "player".equals(userType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "ApiLoginUser{" +
|
||||||
|
"userId=" + userId +
|
||||||
|
", openid='" + openid + '\'' +
|
||||||
|
", phone='" + phone + '\'' +
|
||||||
|
", nickname='" + nickname + '\'' +
|
||||||
|
", userType='" + userType + '\'' +
|
||||||
|
", tenantId=" + tenantId +
|
||||||
|
", customerId=" + customerId +
|
||||||
|
", merchantId=" + merchantId +
|
||||||
|
", playerId=" + playerId +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package cn.aqroid.business.auth.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信登录会话信息
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public class WxSession {
|
||||||
|
|
||||||
|
/** 用户唯一标识 */
|
||||||
|
private String openid;
|
||||||
|
|
||||||
|
/** 会话密钥 */
|
||||||
|
private String sessionKey;
|
||||||
|
|
||||||
|
/** 用户在开放平台的唯一标识符(需要绑定开放平台) */
|
||||||
|
private String unionid;
|
||||||
|
|
||||||
|
/** 错误码 */
|
||||||
|
private Integer errcode;
|
||||||
|
|
||||||
|
/** 错误信息 */
|
||||||
|
private String errmsg;
|
||||||
|
|
||||||
|
public String getOpenid() {
|
||||||
|
return openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOpenid(String openid) {
|
||||||
|
this.openid = openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionKey() {
|
||||||
|
return sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionKey(String sessionKey) {
|
||||||
|
this.sessionKey = sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUnionid() {
|
||||||
|
return unionid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUnionid(String unionid) {
|
||||||
|
this.unionid = unionid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getErrcode() {
|
||||||
|
return errcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrcode(Integer errcode) {
|
||||||
|
this.errcode = errcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrmsg() {
|
||||||
|
return errmsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrmsg(String errmsg) {
|
||||||
|
this.errmsg = errmsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否成功
|
||||||
|
*/
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return errcode == null || errcode == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "WxSession{" +
|
||||||
|
"openid='" + openid + '\'' +
|
||||||
|
", sessionKey='" + sessionKey + '\'' +
|
||||||
|
", unionid='" + unionid + '\'' +
|
||||||
|
", errcode=" + errcode +
|
||||||
|
", errmsg='" + errmsg + '\'' +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
package cn.aqroid.business.auth.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录结果
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public class LoginResult {
|
||||||
|
|
||||||
|
/** JWT Token */
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
/** 用户ID */
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** 微信openid */
|
||||||
|
private String openid;
|
||||||
|
|
||||||
|
/** 手机号 */
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
/** 用户类型: customer/merchant/player */
|
||||||
|
private String userType;
|
||||||
|
|
||||||
|
/** 是否需要绑定手机号 */
|
||||||
|
private Boolean needBindPhone;
|
||||||
|
|
||||||
|
/** 是否新用户 */
|
||||||
|
private Boolean isNewUser;
|
||||||
|
|
||||||
|
/** 租户ID */
|
||||||
|
private Long tenantId;
|
||||||
|
|
||||||
|
/** 昵称 */
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/** 头像 */
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
public String getToken() {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToken(String token) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(Long userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenid() {
|
||||||
|
return openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOpenid(String openid) {
|
||||||
|
this.openid = openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPhone() {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhone(String phone) {
|
||||||
|
this.phone = phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserType() {
|
||||||
|
return userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserType(String userType) {
|
||||||
|
this.userType = userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getNeedBindPhone() {
|
||||||
|
return needBindPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNeedBindPhone(Boolean needBindPhone) {
|
||||||
|
this.needBindPhone = needBindPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsNewUser() {
|
||||||
|
return isNewUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsNewUser(Boolean isNewUser) {
|
||||||
|
this.isNewUser = isNewUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenantId(Long tenantId) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNickname() {
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNickname(String nickname) {
|
||||||
|
this.nickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatar() {
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatar(String avatar) {
|
||||||
|
this.avatar = avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package cn.aqroid.business.auth.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号登录请求
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public class PhoneLoginRequest {
|
||||||
|
|
||||||
|
/** 微信openid */
|
||||||
|
private String openid;
|
||||||
|
|
||||||
|
/** 加密数据 */
|
||||||
|
private String encryptedData;
|
||||||
|
|
||||||
|
/** 初始向量 */
|
||||||
|
private String iv;
|
||||||
|
|
||||||
|
/** 昵称(可选) */
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/** 头像(可选) */
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
public String getOpenid() {
|
||||||
|
return openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOpenid(String openid) {
|
||||||
|
this.openid = openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEncryptedData() {
|
||||||
|
return encryptedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEncryptedData(String encryptedData) {
|
||||||
|
this.encryptedData = encryptedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIv() {
|
||||||
|
return iv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIv(String iv) {
|
||||||
|
this.iv = iv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNickname() {
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNickname(String nickname) {
|
||||||
|
this.nickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatar() {
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatar(String avatar) {
|
||||||
|
this.avatar = avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package cn.aqroid.business.auth.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换角色请求
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public class SwitchRoleRequest {
|
||||||
|
|
||||||
|
/** 目标角色类型: customer/merchant/player */
|
||||||
|
private String userType;
|
||||||
|
|
||||||
|
/** 租户ID(切换到商家或代练时需要) */
|
||||||
|
private Long tenantId;
|
||||||
|
|
||||||
|
public String getUserType() {
|
||||||
|
return userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserType(String userType) {
|
||||||
|
this.userType = userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenantId(Long tenantId) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package cn.aqroid.business.auth.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息请求
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public class UpdateUserRequest {
|
||||||
|
|
||||||
|
/** 昵称 */
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/** 头像 */
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
/** 手机号 */
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
public String getNickname() {
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNickname(String nickname) {
|
||||||
|
this.nickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatar() {
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatar(String avatar) {
|
||||||
|
this.avatar = avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPhone() {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhone(String phone) {
|
||||||
|
this.phone = phone;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package cn.aqroid.business.auth.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信登录请求
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public class WxLoginRequest {
|
||||||
|
|
||||||
|
/** 微信登录code */
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/** 昵称(可选) */
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/** 头像(可选) */
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNickname() {
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNickname(String nickname) {
|
||||||
|
this.nickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatar() {
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatar(String avatar) {
|
||||||
|
this.avatar = avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package cn.aqroid.business.auth.interceptor;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
|
||||||
|
import cn.aqroid.business.auth.domain.ApiLoginUser;
|
||||||
|
import cn.aqroid.business.auth.service.ApiTokenService;
|
||||||
|
import cn.aqroid.business.auth.util.ApiLoginUserHolder;
|
||||||
|
import cn.aqroid.common.core.tenant.TenantContext;
|
||||||
|
import cn.aqroid.common.utils.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API认证拦截器
|
||||||
|
* 用于小程序端接口的Token验证
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ApiAuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApiAuthInterceptor.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApiTokenService apiTokenService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
|
// 获取Token
|
||||||
|
String token = request.getHeader("Authorization");
|
||||||
|
|
||||||
|
if (StringUtils.isEmpty(token)) {
|
||||||
|
log.debug("请求未携带Token: {}", request.getRequestURI());
|
||||||
|
writeUnauthorized(response, "未登录,请先登录");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去除Bearer前缀
|
||||||
|
token = apiTokenService.getTokenFromHeader(token);
|
||||||
|
|
||||||
|
// 验证Token
|
||||||
|
ApiLoginUser loginUser = apiTokenService.validateToken(token);
|
||||||
|
|
||||||
|
if (loginUser == null) {
|
||||||
|
log.debug("Token验证失败: {}", request.getRequestURI());
|
||||||
|
writeUnauthorized(response, "登录已过期,请重新登录");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置当前登录用户
|
||||||
|
ApiLoginUserHolder.setLoginUser(loginUser);
|
||||||
|
|
||||||
|
// 如果用户有租户ID,设置到租户上下文
|
||||||
|
if (loginUser.getTenantId() != null) {
|
||||||
|
TenantContext.setTenantId(loginUser.getTenantId());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("API认证通过: userId={}, userType={}, uri={}",
|
||||||
|
loginUser.getUserId(), loginUser.getUserType(), request.getRequestURI());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||||
|
// 清理上下文
|
||||||
|
ApiLoginUserHolder.clear();
|
||||||
|
TenantContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入未授权响应
|
||||||
|
*/
|
||||||
|
private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 401);
|
||||||
|
result.put("msg", message);
|
||||||
|
|
||||||
|
response.getWriter().write(JSON.toJSONString(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package cn.aqroid.business.auth.service;
|
||||||
|
|
||||||
|
import cn.aqroid.business.auth.domain.ApiLoginUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Token服务接口
|
||||||
|
* 用于小程序端JWT Token的生成和验证
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public interface ApiTokenService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建Token
|
||||||
|
*
|
||||||
|
* @param loginUser 登录用户信息
|
||||||
|
* @return JWT Token
|
||||||
|
*/
|
||||||
|
String createToken(ApiLoginUser loginUser);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证Token并获取用户信息
|
||||||
|
*
|
||||||
|
* @param token JWT Token
|
||||||
|
* @return 登录用户信息,验证失败返回null
|
||||||
|
*/
|
||||||
|
ApiLoginUser validateToken(String token);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新Token
|
||||||
|
*
|
||||||
|
* @param loginUser 登录用户信息
|
||||||
|
*/
|
||||||
|
void refreshToken(ApiLoginUser loginUser);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使Token失效
|
||||||
|
*
|
||||||
|
* @param token JWT Token
|
||||||
|
*/
|
||||||
|
void invalidateToken(String token);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求头中获取Token
|
||||||
|
*
|
||||||
|
* @param header Authorization请求头
|
||||||
|
* @return Token字符串
|
||||||
|
*/
|
||||||
|
String getTokenFromHeader(String header);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Token的唯一标识
|
||||||
|
*
|
||||||
|
* @param token JWT Token
|
||||||
|
* @return Token UUID
|
||||||
|
*/
|
||||||
|
String getTokenKey(String token);
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package cn.aqroid.business.auth.service;
|
||||||
|
|
||||||
|
import cn.aqroid.business.auth.domain.WxSession;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信认证服务接口
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public interface WxAuthService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过code获取微信会话信息(openid和session_key)
|
||||||
|
*
|
||||||
|
* @param code 微信登录code
|
||||||
|
* @return 微信会话信息
|
||||||
|
*/
|
||||||
|
WxSession code2Session(String code);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密微信手机号
|
||||||
|
*
|
||||||
|
* @param sessionKey 会话密钥
|
||||||
|
* @param encryptedData 加密数据
|
||||||
|
* @param iv 初始向量
|
||||||
|
* @return 解密后的手机号
|
||||||
|
*/
|
||||||
|
String decryptPhoneNumber(String sessionKey, String encryptedData, String iv);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取微信接口调用凭证(access_token)
|
||||||
|
*
|
||||||
|
* @return access_token
|
||||||
|
*/
|
||||||
|
String getAccessToken();
|
||||||
|
}
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
package cn.aqroid.business.auth.service.impl;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
|
||||||
|
import cn.aqroid.business.auth.config.WxConfig;
|
||||||
|
import cn.aqroid.business.auth.domain.ApiLoginUser;
|
||||||
|
import cn.aqroid.business.auth.service.ApiTokenService;
|
||||||
|
import cn.aqroid.common.utils.StringUtils;
|
||||||
|
import cn.aqroid.common.utils.uuid.IdUtils;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.SignatureAlgorithm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Token服务实现
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApiTokenServiceImpl implements ApiTokenService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApiTokenServiceImpl.class);
|
||||||
|
|
||||||
|
/** Token前缀 */
|
||||||
|
private static final String TOKEN_PREFIX = "Bearer ";
|
||||||
|
|
||||||
|
/** Redis缓存key前缀 */
|
||||||
|
private static final String API_LOGIN_USER_KEY = "api_login_user:";
|
||||||
|
|
||||||
|
/** JWT Claims中的用户ID */
|
||||||
|
private static final String CLAIM_USER_ID = "user_id";
|
||||||
|
|
||||||
|
/** JWT Claims中的openid */
|
||||||
|
private static final String CLAIM_OPENID = "openid";
|
||||||
|
|
||||||
|
/** JWT Claims中的用户类型 */
|
||||||
|
private static final String CLAIM_USER_TYPE = "user_type";
|
||||||
|
|
||||||
|
/** JWT Claims中的租户ID */
|
||||||
|
private static final String CLAIM_TENANT_ID = "tenant_id";
|
||||||
|
|
||||||
|
/** JWT Claims中的Token UUID */
|
||||||
|
private static final String CLAIM_TOKEN_UUID = "token_uuid";
|
||||||
|
|
||||||
|
@Value("${token.secret}")
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WxConfig wxConfig;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建Token
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String createToken(ApiLoginUser loginUser) {
|
||||||
|
String tokenUuid = IdUtils.fastUUID();
|
||||||
|
loginUser.setToken(tokenUuid);
|
||||||
|
|
||||||
|
long currentTime = System.currentTimeMillis();
|
||||||
|
loginUser.setLoginTime(currentTime);
|
||||||
|
loginUser.setExpireTime(currentTime + wxConfig.getTokenExpireTime() * 1000);
|
||||||
|
|
||||||
|
// 将用户信息存入Redis
|
||||||
|
String userKey = API_LOGIN_USER_KEY + tokenUuid;
|
||||||
|
redisTemplate.opsForValue().set(userKey, JSON.toJSONString(loginUser),
|
||||||
|
wxConfig.getTokenExpireTime(), TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
// 生成JWT Token
|
||||||
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
claims.put(CLAIM_TOKEN_UUID, tokenUuid);
|
||||||
|
claims.put(CLAIM_USER_ID, loginUser.getUserId());
|
||||||
|
claims.put(CLAIM_OPENID, loginUser.getOpenid());
|
||||||
|
claims.put(CLAIM_USER_TYPE, loginUser.getUserType());
|
||||||
|
claims.put(CLAIM_TENANT_ID, loginUser.getTenantId());
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.setClaims(claims)
|
||||||
|
.signWith(SignatureAlgorithm.HS512, secret)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证Token并获取用户信息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ApiLoginUser validateToken(String token) {
|
||||||
|
if (StringUtils.isEmpty(token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
if (claims == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String tokenUuid = (String) claims.get(CLAIM_TOKEN_UUID);
|
||||||
|
if (StringUtils.isEmpty(tokenUuid)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从Redis获取用户信息
|
||||||
|
String userKey = API_LOGIN_USER_KEY + tokenUuid;
|
||||||
|
String userJson = redisTemplate.opsForValue().get(userKey);
|
||||||
|
|
||||||
|
if (StringUtils.isEmpty(userJson)) {
|
||||||
|
log.debug("Token已过期或不存在: {}", tokenUuid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiLoginUser loginUser = JSON.parseObject(userJson, ApiLoginUser.class);
|
||||||
|
|
||||||
|
// 检查是否需要刷新Token
|
||||||
|
long currentTime = System.currentTimeMillis();
|
||||||
|
long expireTime = loginUser.getExpireTime();
|
||||||
|
long refreshThreshold = wxConfig.getTokenExpireTime() * 1000 / 3; // 剩余1/3时间时刷新
|
||||||
|
|
||||||
|
if (expireTime - currentTime < refreshThreshold) {
|
||||||
|
refreshToken(loginUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loginUser;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("验证Token异常", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新Token
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void refreshToken(ApiLoginUser loginUser) {
|
||||||
|
long currentTime = System.currentTimeMillis();
|
||||||
|
loginUser.setExpireTime(currentTime + wxConfig.getTokenExpireTime() * 1000);
|
||||||
|
|
||||||
|
String userKey = API_LOGIN_USER_KEY + loginUser.getToken();
|
||||||
|
redisTemplate.opsForValue().set(userKey, JSON.toJSONString(loginUser),
|
||||||
|
wxConfig.getTokenExpireTime(), TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使Token失效
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void invalidateToken(String token) {
|
||||||
|
if (StringUtils.isEmpty(token)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
if (claims != null) {
|
||||||
|
String tokenUuid = (String) claims.get(CLAIM_TOKEN_UUID);
|
||||||
|
if (StringUtils.isNotEmpty(tokenUuid)) {
|
||||||
|
String userKey = API_LOGIN_USER_KEY + tokenUuid;
|
||||||
|
redisTemplate.delete(userKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("使Token失效异常", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求头中获取Token
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getTokenFromHeader(String header) {
|
||||||
|
if (StringUtils.isNotEmpty(header) && header.startsWith(TOKEN_PREFIX)) {
|
||||||
|
return header.substring(TOKEN_PREFIX.length());
|
||||||
|
}
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Token的唯一标识
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getTokenKey(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
if (claims != null) {
|
||||||
|
return (String) claims.get(CLAIM_TOKEN_UUID);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取Token Key异常", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析JWT Token
|
||||||
|
*/
|
||||||
|
private Claims parseToken(String token) {
|
||||||
|
try {
|
||||||
|
return Jwts.parser()
|
||||||
|
.setSigningKey(secret)
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("解析Token失败: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,201 @@
|
|||||||
|
package cn.aqroid.business.auth.service.impl;
|
||||||
|
|
||||||
|
import cn.aqroid.business.auth.config.WxConfig;
|
||||||
|
import cn.aqroid.business.auth.domain.WxSession;
|
||||||
|
import cn.aqroid.business.auth.service.WxAuthService;
|
||||||
|
import cn.aqroid.common.exception.ServiceException;
|
||||||
|
import cn.aqroid.common.utils.StringUtils;
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.AlgorithmParameters;
|
||||||
|
import java.security.Security;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信认证服务实现
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WxAuthServiceImpl implements WxAuthService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WxAuthServiceImpl.class);
|
||||||
|
|
||||||
|
/** 微信登录接口URL */
|
||||||
|
private static final String WX_CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code";
|
||||||
|
|
||||||
|
/** 微信获取access_token接口URL */
|
||||||
|
private static final String WX_ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
|
||||||
|
|
||||||
|
/** access_token缓存key */
|
||||||
|
private static final String ACCESS_TOKEN_CACHE_KEY = "wx:access_token";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WxConfig wxConfig;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
|
static {
|
||||||
|
// 添加BouncyCastle加密提供者
|
||||||
|
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||||
|
Security.addProvider(new BouncyCastleProvider());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过code获取微信会话信息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public WxSession code2Session(String code) {
|
||||||
|
if (StringUtils.isEmpty(code)) {
|
||||||
|
throw new ServiceException("微信登录code不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
String url = String.format(WX_CODE2SESSION_URL,
|
||||||
|
wxConfig.getAppId(),
|
||||||
|
wxConfig.getAppSecret(),
|
||||||
|
code);
|
||||||
|
|
||||||
|
log.info("调用微信code2session接口, code: {}", code);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
String body = response.getBody();
|
||||||
|
|
||||||
|
log.info("微信code2session响应: {}", body);
|
||||||
|
|
||||||
|
if (StringUtils.isEmpty(body)) {
|
||||||
|
throw new ServiceException("微信登录失败,响应为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject json = JSON.parseObject(body);
|
||||||
|
WxSession session = new WxSession();
|
||||||
|
session.setOpenid(json.getString("openid"));
|
||||||
|
session.setSessionKey(json.getString("session_key"));
|
||||||
|
session.setUnionid(json.getString("unionid"));
|
||||||
|
session.setErrcode(json.getInteger("errcode"));
|
||||||
|
session.setErrmsg(json.getString("errmsg"));
|
||||||
|
|
||||||
|
if (!session.isSuccess()) {
|
||||||
|
log.error("微信登录失败: errcode={}, errmsg={}", session.getErrcode(), session.getErrmsg());
|
||||||
|
throw new ServiceException("微信登录失败: " + session.getErrmsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
} catch (ServiceException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("调用微信code2session接口异常", e);
|
||||||
|
throw new ServiceException("微信登录失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密微信手机号
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String decryptPhoneNumber(String sessionKey, String encryptedData, String iv) {
|
||||||
|
if (StringUtils.isEmpty(sessionKey) || StringUtils.isEmpty(encryptedData) || StringUtils.isEmpty(iv)) {
|
||||||
|
throw new ServiceException("解密参数不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(sessionKey);
|
||||||
|
byte[] ivBytes = Base64.getDecoder().decode(iv);
|
||||||
|
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
|
||||||
|
|
||||||
|
// 使用AES/CBC/PKCS7Padding解密
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding", "BC");
|
||||||
|
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
|
||||||
|
AlgorithmParameters params = AlgorithmParameters.getInstance("AES");
|
||||||
|
params.init(new IvParameterSpec(ivBytes));
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, keySpec, params);
|
||||||
|
|
||||||
|
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
||||||
|
String decryptedData = new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
log.info("解密后的数据: {}", decryptedData);
|
||||||
|
|
||||||
|
// 解析JSON获取手机号
|
||||||
|
JSONObject json = JSON.parseObject(decryptedData);
|
||||||
|
String phoneNumber = json.getString("phoneNumber");
|
||||||
|
|
||||||
|
if (StringUtils.isEmpty(phoneNumber)) {
|
||||||
|
throw new ServiceException("获取手机号失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return phoneNumber;
|
||||||
|
} catch (ServiceException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("解密微信手机号异常", e);
|
||||||
|
throw new ServiceException("解密手机号失败,请重新授权");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取微信接口调用凭证
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getAccessToken() {
|
||||||
|
// 先从缓存获取
|
||||||
|
String accessToken = redisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY);
|
||||||
|
if (StringUtils.isNotEmpty(accessToken)) {
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用微信接口获取
|
||||||
|
String url = String.format(WX_ACCESS_TOKEN_URL,
|
||||||
|
wxConfig.getAppId(),
|
||||||
|
wxConfig.getAppSecret());
|
||||||
|
|
||||||
|
try {
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
String body = response.getBody();
|
||||||
|
|
||||||
|
if (StringUtils.isEmpty(body)) {
|
||||||
|
throw new ServiceException("获取access_token失败,响应为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject json = JSON.parseObject(body);
|
||||||
|
accessToken = json.getString("access_token");
|
||||||
|
Integer expiresIn = json.getInteger("expires_in");
|
||||||
|
|
||||||
|
if (StringUtils.isEmpty(accessToken)) {
|
||||||
|
String errmsg = json.getString("errmsg");
|
||||||
|
throw new ServiceException("获取access_token失败: " + errmsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存access_token,提前5分钟过期
|
||||||
|
redisTemplate.opsForValue().set(ACCESS_TOKEN_CACHE_KEY, accessToken,
|
||||||
|
expiresIn - 300, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
return accessToken;
|
||||||
|
} catch (ServiceException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取微信access_token异常", e);
|
||||||
|
throw new ServiceException("获取access_token失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
package cn.aqroid.business.auth.util;
|
||||||
|
|
||||||
|
import cn.aqroid.business.auth.domain.ApiLoginUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API登录用户上下文持有者
|
||||||
|
* 使用ThreadLocal存储当前请求的登录用户信息
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public class ApiLoginUserHolder {
|
||||||
|
|
||||||
|
private static final ThreadLocal<ApiLoginUser> LOGIN_USER_HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前登录用户
|
||||||
|
*/
|
||||||
|
public static void setLoginUser(ApiLoginUser loginUser) {
|
||||||
|
LOGIN_USER_HOLDER.set(loginUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户
|
||||||
|
*/
|
||||||
|
public static ApiLoginUser getLoginUser() {
|
||||||
|
return LOGIN_USER_HOLDER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除当前登录用户
|
||||||
|
*/
|
||||||
|
public static void clear() {
|
||||||
|
LOGIN_USER_HOLDER.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户ID
|
||||||
|
*/
|
||||||
|
public static Long getUserId() {
|
||||||
|
ApiLoginUser loginUser = getLoginUser();
|
||||||
|
return loginUser != null ? loginUser.getUserId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户openid
|
||||||
|
*/
|
||||||
|
public static String getOpenid() {
|
||||||
|
ApiLoginUser loginUser = getLoginUser();
|
||||||
|
return loginUser != null ? loginUser.getOpenid() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户类型
|
||||||
|
*/
|
||||||
|
public static String getUserType() {
|
||||||
|
ApiLoginUser loginUser = getLoginUser();
|
||||||
|
return loginUser != null ? loginUser.getUserType() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前租户ID
|
||||||
|
*/
|
||||||
|
public static Long getTenantId() {
|
||||||
|
ApiLoginUser loginUser = getLoginUser();
|
||||||
|
return loginUser != null ? loginUser.getTenantId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否已登录
|
||||||
|
*/
|
||||||
|
public static boolean isLoggedIn() {
|
||||||
|
return getLoginUser() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为顾客
|
||||||
|
*/
|
||||||
|
public static boolean isCustomer() {
|
||||||
|
ApiLoginUser loginUser = getLoginUser();
|
||||||
|
return loginUser != null && loginUser.isCustomer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为商家
|
||||||
|
*/
|
||||||
|
public static boolean isMerchant() {
|
||||||
|
ApiLoginUser loginUser = getLoginUser();
|
||||||
|
return loginUser != null && loginUser.isMerchant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为代练
|
||||||
|
*/
|
||||||
|
public static boolean isPlayer() {
|
||||||
|
ApiLoginUser loginUser = getLoginUser();
|
||||||
|
return loginUser != null && loginUser.isPlayer();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,40 +0,0 @@
|
|||||||
package cn.aqroid.business.common.tenant;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 租户上下文
|
|
||||||
* 使用ThreadLocal存储当前请求的租户ID
|
|
||||||
*
|
|
||||||
* @author aqroid
|
|
||||||
*/
|
|
||||||
public class TenantContext {
|
|
||||||
|
|
||||||
private static final ThreadLocal<Long> TENANT_ID_HOLDER = new ThreadLocal<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置当前租户ID
|
|
||||||
*/
|
|
||||||
public static void setTenantId(Long tenantId) {
|
|
||||||
TENANT_ID_HOLDER.set(tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前租户ID
|
|
||||||
*/
|
|
||||||
public static Long getTenantId() {
|
|
||||||
return TENANT_ID_HOLDER.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除租户ID
|
|
||||||
*/
|
|
||||||
public static void clear() {
|
|
||||||
TENANT_ID_HOLDER.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断是否有租户ID
|
|
||||||
*/
|
|
||||||
public static boolean hasTenantId() {
|
|
||||||
return TENANT_ID_HOLDER.get() != null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
package cn.aqroid.business.customer.domain;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
|
||||||
|
import cn.aqroid.common.annotation.Excel;
|
||||||
|
import cn.aqroid.common.core.domain.BaseEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顾客对象 customer
|
||||||
|
*
|
||||||
|
* 注意:顾客表是跨租户的,不需要tenant_id字段
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public class Customer extends BaseEntity {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 顾客ID */
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 微信openid */
|
||||||
|
private String openid;
|
||||||
|
|
||||||
|
/** 微信unionid */
|
||||||
|
private String unionid;
|
||||||
|
|
||||||
|
/** 昵称 */
|
||||||
|
@Excel(name = "昵称")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/** 头像URL */
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
/** 手机号 */
|
||||||
|
@Excel(name = "手机号")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
/** 状态(0正常 1禁用) */
|
||||||
|
@Excel(name = "状态", readConverterExp = "0=正常,1=禁用")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** 最后登录时间 */
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private Date lastLoginTime;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenid() {
|
||||||
|
return openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOpenid(String openid) {
|
||||||
|
this.openid = openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUnionid() {
|
||||||
|
return unionid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUnionid(String unionid) {
|
||||||
|
this.unionid = unionid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNickname() {
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNickname(String nickname) {
|
||||||
|
this.nickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatar() {
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatar(String avatar) {
|
||||||
|
this.avatar = avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPhone() {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhone(String phone) {
|
||||||
|
this.phone = phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getLastLoginTime() {
|
||||||
|
return lastLoginTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastLoginTime(Date lastLoginTime) {
|
||||||
|
this.lastLoginTime = lastLoginTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Customer{" +
|
||||||
|
"id=" + id +
|
||||||
|
", openid='" + openid + '\'' +
|
||||||
|
", unionid='" + unionid + '\'' +
|
||||||
|
", nickname='" + nickname + '\'' +
|
||||||
|
", avatar='" + avatar + '\'' +
|
||||||
|
", phone='" + phone + '\'' +
|
||||||
|
", status='" + status + '\'' +
|
||||||
|
", lastLoginTime=" + lastLoginTime +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package cn.aqroid.business.customer.mapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import cn.aqroid.business.customer.domain.Customer;
|
||||||
|
import cn.aqroid.common.annotation.TenantIgnore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顾客Mapper接口
|
||||||
|
*
|
||||||
|
* 注意:顾客表是跨租户的,使用@TenantIgnore注解
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
@TenantIgnore
|
||||||
|
public interface CustomerMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据openid查询顾客
|
||||||
|
*
|
||||||
|
* @param openid 微信openid
|
||||||
|
* @return 顾客信息
|
||||||
|
*/
|
||||||
|
Customer selectByOpenid(String openid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询顾客
|
||||||
|
*
|
||||||
|
* @param id 顾客ID
|
||||||
|
* @return 顾客信息
|
||||||
|
*/
|
||||||
|
Customer selectById(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询顾客
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return 顾客信息
|
||||||
|
*/
|
||||||
|
Customer selectByPhone(String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询顾客列表
|
||||||
|
*
|
||||||
|
* @param customer 顾客查询条件
|
||||||
|
* @return 顾客列表
|
||||||
|
*/
|
||||||
|
List<Customer> selectCustomerList(Customer customer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增顾客
|
||||||
|
*
|
||||||
|
* @param customer 顾客信息
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int insertCustomer(Customer customer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新顾客信息
|
||||||
|
*
|
||||||
|
* @param customer 顾客信息
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int updateCustomer(Customer customer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新最后登录时间
|
||||||
|
*
|
||||||
|
* @param id 顾客ID
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int updateLastLoginTime(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除顾客
|
||||||
|
*
|
||||||
|
* @param id 顾客ID
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int deleteById(Long id);
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
package cn.aqroid.business.customer.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import cn.aqroid.business.customer.domain.Customer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顾客Service接口
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
public interface ICustomerService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据openid查询顾客
|
||||||
|
*
|
||||||
|
* @param openid 微信openid
|
||||||
|
* @return 顾客信息
|
||||||
|
*/
|
||||||
|
Customer selectByOpenid(String openid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询顾客
|
||||||
|
*
|
||||||
|
* @param id 顾客ID
|
||||||
|
* @return 顾客信息
|
||||||
|
*/
|
||||||
|
Customer selectById(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询顾客
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @return 顾客信息
|
||||||
|
*/
|
||||||
|
Customer selectByPhone(String phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询顾客列表
|
||||||
|
*
|
||||||
|
* @param customer 顾客查询条件
|
||||||
|
* @return 顾客列表
|
||||||
|
*/
|
||||||
|
List<Customer> selectCustomerList(Customer customer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增顾客
|
||||||
|
*
|
||||||
|
* @param customer 顾客信息
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int insertCustomer(Customer customer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新顾客信息
|
||||||
|
*
|
||||||
|
* @param customer 顾客信息
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int updateCustomer(Customer customer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新最后登录时间
|
||||||
|
*
|
||||||
|
* @param id 顾客ID
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int updateLastLoginTime(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除顾客
|
||||||
|
*
|
||||||
|
* @param id 顾客ID
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int deleteById(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据openid获取或创建顾客
|
||||||
|
* 如果openid不存在则创建新顾客
|
||||||
|
*
|
||||||
|
* @param openid 微信openid
|
||||||
|
* @param nickname 昵称(可选)
|
||||||
|
* @param avatar 头像(可选)
|
||||||
|
* @return 顾客信息
|
||||||
|
*/
|
||||||
|
Customer getOrCreateByOpenid(String openid, String nickname, String avatar);
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
package cn.aqroid.business.customer.service.impl;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import cn.aqroid.business.customer.domain.Customer;
|
||||||
|
import cn.aqroid.business.customer.mapper.CustomerMapper;
|
||||||
|
import cn.aqroid.business.customer.service.ICustomerService;
|
||||||
|
import cn.aqroid.common.utils.DateUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顾客Service业务层处理
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
* @date 2026-01-12
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CustomerServiceImpl implements ICustomerService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CustomerMapper customerMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据openid查询顾客
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Customer selectByOpenid(String openid) {
|
||||||
|
return customerMapper.selectByOpenid(openid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询顾客
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Customer selectById(Long id) {
|
||||||
|
return customerMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查询顾客
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Customer selectByPhone(String phone) {
|
||||||
|
return customerMapper.selectByPhone(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询顾客列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<Customer> selectCustomerList(Customer customer) {
|
||||||
|
return customerMapper.selectCustomerList(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增顾客
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int insertCustomer(Customer customer) {
|
||||||
|
customer.setCreateTime(DateUtils.getNowDate());
|
||||||
|
customer.setUpdateTime(DateUtils.getNowDate());
|
||||||
|
if (customer.getStatus() == null) {
|
||||||
|
customer.setStatus("0"); // 默认正常状态
|
||||||
|
}
|
||||||
|
return customerMapper.insertCustomer(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新顾客信息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int updateCustomer(Customer customer) {
|
||||||
|
customer.setUpdateTime(DateUtils.getNowDate());
|
||||||
|
return customerMapper.updateCustomer(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新最后登录时间
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int updateLastLoginTime(Long id) {
|
||||||
|
return customerMapper.updateLastLoginTime(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除顾客
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int deleteById(Long id) {
|
||||||
|
return customerMapper.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据openid获取或创建顾客
|
||||||
|
* 如果openid不存在则创建新顾客
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Customer getOrCreateByOpenid(String openid, String nickname, String avatar) {
|
||||||
|
// 先查询是否存在
|
||||||
|
Customer customer = customerMapper.selectByOpenid(openid);
|
||||||
|
|
||||||
|
if (customer != null) {
|
||||||
|
// 已存在,更新登录时间
|
||||||
|
customerMapper.updateLastLoginTime(customer.getId());
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在,创建新顾客
|
||||||
|
customer = new Customer();
|
||||||
|
customer.setOpenid(openid);
|
||||||
|
customer.setNickname(nickname != null ? nickname : "微信用户");
|
||||||
|
customer.setAvatar(avatar != null ? avatar : "");
|
||||||
|
customer.setStatus("0");
|
||||||
|
customer.setLastLoginTime(new Date());
|
||||||
|
customer.setCreateTime(DateUtils.getNowDate());
|
||||||
|
customer.setUpdateTime(DateUtils.getNowDate());
|
||||||
|
|
||||||
|
customerMapper.insertCustomer(customer);
|
||||||
|
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +1,18 @@
|
|||||||
package cn.aqroid.business.tenant.mapper;
|
package cn.aqroid.business.tenant.mapper;
|
||||||
|
|
||||||
import cn.aqroid.business.tenant.domain.Tenant;
|
import cn.aqroid.business.tenant.domain.Tenant;
|
||||||
|
import cn.aqroid.common.annotation.TenantIgnore;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 租户Mapper接口
|
* 租户Mapper接口
|
||||||
*
|
*
|
||||||
|
* 注意:租户表本身不需要租户过滤,使用@TenantIgnore注解
|
||||||
|
*
|
||||||
* @author aqroid
|
* @author aqroid
|
||||||
* @date 2026-01-12
|
* @date 2026-01-12
|
||||||
*/
|
*/
|
||||||
|
@TenantIgnore
|
||||||
public interface TenantMapper
|
public interface TenantMapper
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,100 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper
|
||||||
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="cn.aqroid.business.customer.mapper.CustomerMapper">
|
||||||
|
|
||||||
|
<resultMap type="cn.aqroid.business.customer.domain.Customer" id="CustomerResult">
|
||||||
|
<id property="id" column="id" />
|
||||||
|
<result property="openid" column="openid" />
|
||||||
|
<result property="unionid" column="unionid" />
|
||||||
|
<result property="nickname" column="nickname" />
|
||||||
|
<result property="avatar" column="avatar" />
|
||||||
|
<result property="phone" column="phone" />
|
||||||
|
<result property="status" column="status" />
|
||||||
|
<result property="lastLoginTime" column="last_login_time" />
|
||||||
|
<result property="createTime" column="create_time" />
|
||||||
|
<result property="updateTime" column="update_time" />
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<sql id="selectCustomerVo">
|
||||||
|
select id, openid, unionid, nickname, avatar, phone, status, last_login_time, create_time, update_time
|
||||||
|
from customer
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="selectByOpenid" parameterType="String" resultMap="CustomerResult">
|
||||||
|
<include refid="selectCustomerVo"/>
|
||||||
|
where openid = #{openid} and status = '0'
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectById" parameterType="Long" resultMap="CustomerResult">
|
||||||
|
<include refid="selectCustomerVo"/>
|
||||||
|
where id = #{id}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectByPhone" parameterType="String" resultMap="CustomerResult">
|
||||||
|
<include refid="selectCustomerVo"/>
|
||||||
|
where phone = #{phone} and status = '0'
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectCustomerList" parameterType="cn.aqroid.business.customer.domain.Customer" resultMap="CustomerResult">
|
||||||
|
<include refid="selectCustomerVo"/>
|
||||||
|
<where>
|
||||||
|
<if test="openid != null and openid != ''">and openid = #{openid}</if>
|
||||||
|
<if test="nickname != null and nickname != ''">and nickname like concat('%', #{nickname}, '%')</if>
|
||||||
|
<if test="phone != null and phone != ''">and phone = #{phone}</if>
|
||||||
|
<if test="status != null and status != ''">and status = #{status}</if>
|
||||||
|
</where>
|
||||||
|
order by create_time desc
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insertCustomer" parameterType="cn.aqroid.business.customer.domain.Customer" useGeneratedKeys="true" keyProperty="id">
|
||||||
|
insert into customer
|
||||||
|
<trim prefix="(" suffix=")" suffixOverrides=",">
|
||||||
|
<if test="openid != null and openid != ''">openid,</if>
|
||||||
|
<if test="unionid != null">unionid,</if>
|
||||||
|
<if test="nickname != null">nickname,</if>
|
||||||
|
<if test="avatar != null">avatar,</if>
|
||||||
|
<if test="phone != null">phone,</if>
|
||||||
|
<if test="status != null">status,</if>
|
||||||
|
<if test="lastLoginTime != null">last_login_time,</if>
|
||||||
|
<if test="createTime != null">create_time,</if>
|
||||||
|
<if test="updateTime != null">update_time,</if>
|
||||||
|
</trim>
|
||||||
|
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
||||||
|
<if test="openid != null and openid != ''">#{openid},</if>
|
||||||
|
<if test="unionid != null">#{unionid},</if>
|
||||||
|
<if test="nickname != null">#{nickname},</if>
|
||||||
|
<if test="avatar != null">#{avatar},</if>
|
||||||
|
<if test="phone != null">#{phone},</if>
|
||||||
|
<if test="status != null">#{status},</if>
|
||||||
|
<if test="lastLoginTime != null">#{lastLoginTime},</if>
|
||||||
|
<if test="createTime != null">#{createTime},</if>
|
||||||
|
<if test="updateTime != null">#{updateTime},</if>
|
||||||
|
</trim>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="updateCustomer" parameterType="cn.aqroid.business.customer.domain.Customer">
|
||||||
|
update customer
|
||||||
|
<trim prefix="SET" suffixOverrides=",">
|
||||||
|
<if test="openid != null and openid != ''">openid = #{openid},</if>
|
||||||
|
<if test="unionid != null">unionid = #{unionid},</if>
|
||||||
|
<if test="nickname != null">nickname = #{nickname},</if>
|
||||||
|
<if test="avatar != null">avatar = #{avatar},</if>
|
||||||
|
<if test="phone != null">phone = #{phone},</if>
|
||||||
|
<if test="status != null">status = #{status},</if>
|
||||||
|
<if test="lastLoginTime != null">last_login_time = #{lastLoginTime},</if>
|
||||||
|
<if test="updateTime != null">update_time = #{updateTime},</if>
|
||||||
|
</trim>
|
||||||
|
where id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="updateLastLoginTime" parameterType="Long">
|
||||||
|
update customer set last_login_time = now(), update_time = now() where id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="deleteById" parameterType="Long">
|
||||||
|
delete from customer where id = #{id}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
package cn.aqroid.business.tenant;
|
||||||
|
|
||||||
|
import net.jqwik.api.*;
|
||||||
|
import net.jqwik.api.constraints.*;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户软删除属性测试
|
||||||
|
*
|
||||||
|
* **Feature: multi-tenant-system, Property 12: Tenant Soft Delete**
|
||||||
|
* **Validates: Requirements 5.4**
|
||||||
|
*
|
||||||
|
* 测试租户软删除行为:删除操作应该设置del_flag='2'而不是物理删除记录
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
*/
|
||||||
|
public class TenantSoftDeletePropertyTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟租户实体用于测试
|
||||||
|
*/
|
||||||
|
static class TestTenant {
|
||||||
|
private Long tenantId;
|
||||||
|
private String tenantName;
|
||||||
|
private String delFlag;
|
||||||
|
|
||||||
|
public TestTenant(Long tenantId, String tenantName) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
this.tenantName = tenantName;
|
||||||
|
this.delFlag = "0"; // 默认存在状态
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTenantId() { return tenantId; }
|
||||||
|
public String getTenantName() { return tenantName; }
|
||||||
|
public String getDelFlag() { return delFlag; }
|
||||||
|
public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟软删除操作 - 与TenantMapper.xml中的deleteTenantByTenantId行为一致
|
||||||
|
* SQL: update sys_tenant set del_flag = '2' where tenant_id = #{tenantId}
|
||||||
|
*/
|
||||||
|
public void softDelete() {
|
||||||
|
this.delFlag = "2";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查租户是否被软删除
|
||||||
|
*/
|
||||||
|
public boolean isSoftDeleted() {
|
||||||
|
return "2".equals(this.delFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查租户是否存在(未被删除)
|
||||||
|
*/
|
||||||
|
public boolean exists() {
|
||||||
|
return "0".equals(this.delFlag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property 12: Tenant Soft Delete
|
||||||
|
*
|
||||||
|
* *For any* tenant that is deleted, the del_flag should be set to '2'
|
||||||
|
* and the record should still exist (not physically deleted).
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 5.4**
|
||||||
|
*/
|
||||||
|
@Property(tries = 100)
|
||||||
|
void softDeleteShouldSetDelFlagTo2AndPreserveRecord(
|
||||||
|
@ForAll @LongRange(min = 1, max = Long.MAX_VALUE) Long tenantId,
|
||||||
|
@ForAll @StringLength(min = 1, max = 100) @AlphaChars String tenantName
|
||||||
|
) {
|
||||||
|
// Arrange: 创建一个存在的租户
|
||||||
|
TestTenant tenant = new TestTenant(tenantId, tenantName);
|
||||||
|
|
||||||
|
// 验证初始状态
|
||||||
|
assertTrue(tenant.exists(), "新创建的租户应该存在");
|
||||||
|
assertFalse(tenant.isSoftDeleted(), "新创建的租户不应该被标记为删除");
|
||||||
|
assertEquals("0", tenant.getDelFlag(), "新创建的租户del_flag应该为'0'");
|
||||||
|
|
||||||
|
// Act: 执行软删除
|
||||||
|
tenant.softDelete();
|
||||||
|
|
||||||
|
// Assert: 验证软删除后的状态
|
||||||
|
// 1. del_flag应该被设置为'2'
|
||||||
|
assertEquals("2", tenant.getDelFlag(),
|
||||||
|
"软删除后del_flag应该为'2'");
|
||||||
|
|
||||||
|
// 2. 记录仍然存在(通过检查tenantId和tenantName未被清空)
|
||||||
|
assertNotNull(tenant.getTenantId(),
|
||||||
|
"软删除后tenantId应该仍然存在");
|
||||||
|
assertNotNull(tenant.getTenantName(),
|
||||||
|
"软删除后tenantName应该仍然存在");
|
||||||
|
assertEquals(tenantId, tenant.getTenantId(),
|
||||||
|
"软删除后tenantId应该保持不变");
|
||||||
|
assertEquals(tenantName, tenant.getTenantName(),
|
||||||
|
"软删除后tenantName应该保持不变");
|
||||||
|
|
||||||
|
// 3. isSoftDeleted应该返回true
|
||||||
|
assertTrue(tenant.isSoftDeleted(),
|
||||||
|
"软删除后isSoftDeleted()应该返回true");
|
||||||
|
|
||||||
|
// 4. exists应该返回false
|
||||||
|
assertFalse(tenant.exists(),
|
||||||
|
"软删除后exists()应该返回false");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证软删除是幂等的 - 多次删除同一租户应该产生相同结果
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 5.4**
|
||||||
|
*/
|
||||||
|
@Property(tries = 100)
|
||||||
|
void softDeleteShouldBeIdempotent(
|
||||||
|
@ForAll @LongRange(min = 1, max = Long.MAX_VALUE) Long tenantId,
|
||||||
|
@ForAll @StringLength(min = 1, max = 100) @AlphaChars String tenantName,
|
||||||
|
@ForAll @IntRange(min = 1, max = 10) int deleteCount
|
||||||
|
) {
|
||||||
|
// Arrange
|
||||||
|
TestTenant tenant = new TestTenant(tenantId, tenantName);
|
||||||
|
|
||||||
|
// Act: 执行多次软删除
|
||||||
|
for (int i = 0; i < deleteCount; i++) {
|
||||||
|
tenant.softDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert: 无论删除多少次,结果应该相同
|
||||||
|
assertEquals("2", tenant.getDelFlag(),
|
||||||
|
"多次软删除后del_flag应该仍然为'2'");
|
||||||
|
assertTrue(tenant.isSoftDeleted(),
|
||||||
|
"多次软删除后isSoftDeleted()应该返回true");
|
||||||
|
assertNotNull(tenant.getTenantId(),
|
||||||
|
"多次软删除后记录应该仍然存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证del_flag状态转换的正确性
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 5.4**
|
||||||
|
*/
|
||||||
|
@Property(tries = 100)
|
||||||
|
void delFlagTransitionShouldBeCorrect(
|
||||||
|
@ForAll @LongRange(min = 1, max = Long.MAX_VALUE) Long tenantId,
|
||||||
|
@ForAll @StringLength(min = 1, max = 100) @AlphaChars String tenantName
|
||||||
|
) {
|
||||||
|
// Arrange
|
||||||
|
TestTenant tenant = new TestTenant(tenantId, tenantName);
|
||||||
|
String initialDelFlag = tenant.getDelFlag();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
tenant.softDelete();
|
||||||
|
String afterDeleteDelFlag = tenant.getDelFlag();
|
||||||
|
|
||||||
|
// Assert: 验证状态转换
|
||||||
|
assertEquals("0", initialDelFlag,
|
||||||
|
"初始del_flag应该为'0'");
|
||||||
|
assertEquals("2", afterDeleteDelFlag,
|
||||||
|
"删除后del_flag应该为'2'");
|
||||||
|
assertNotEquals(initialDelFlag, afterDeleteDelFlag,
|
||||||
|
"删除前后del_flag应该不同");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package cn.aqroid.common.annotation;
|
||||||
|
|
||||||
|
import java.lang.annotation.Documented;
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户忽略注解 - 标注此注解的方法或类不进行租户过滤
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* 1. 平台管理员查看所有租户数据
|
||||||
|
* 2. 跨租户统计分析
|
||||||
|
* 3. 系统初始化数据
|
||||||
|
* 4. 登录认证等不需要租户隔离的操作
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* <pre>
|
||||||
|
* // 方法级别
|
||||||
|
* @TenantIgnore
|
||||||
|
* public List<Tenant> selectAllTenants() { ... }
|
||||||
|
*
|
||||||
|
* // 类级别
|
||||||
|
* @TenantIgnore
|
||||||
|
* public class SystemConfigMapper { ... }
|
||||||
|
*
|
||||||
|
* // 显式指定不忽略(用于覆盖类级别注解)
|
||||||
|
* @TenantIgnore(value = false)
|
||||||
|
* public List<Order> selectOrders() { ... }
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
*/
|
||||||
|
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
public @interface TenantIgnore {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否忽略租户过滤
|
||||||
|
*
|
||||||
|
* @return true表示忽略租户过滤,false表示正常过滤
|
||||||
|
*/
|
||||||
|
boolean value() default true;
|
||||||
|
}
|
||||||
@ -27,6 +27,10 @@ public class SysUser extends BaseEntity
|
|||||||
@Excel(name = "用户序号", type = Type.EXPORT, cellType = ColumnType.NUMERIC, prompt = "用户编号")
|
@Excel(name = "用户序号", type = Type.EXPORT, cellType = ColumnType.NUMERIC, prompt = "用户编号")
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
|
||||||
|
/** 租户ID */
|
||||||
|
@Excel(name = "租户编号", type = Type.IMPORT)
|
||||||
|
private Long tenantId;
|
||||||
|
|
||||||
/** 部门ID */
|
/** 部门ID */
|
||||||
@Excel(name = "部门编号", type = Type.IMPORT)
|
@Excel(name = "部门编号", type = Type.IMPORT)
|
||||||
private Long deptId;
|
private Long deptId;
|
||||||
@ -114,6 +118,16 @@ public class SysUser extends BaseEntity
|
|||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getTenantId()
|
||||||
|
{
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenantId(Long tenantId)
|
||||||
|
{
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isAdmin()
|
public boolean isAdmin()
|
||||||
{
|
{
|
||||||
return SecurityUtils.isAdmin(this.userId);
|
return SecurityUtils.isAdmin(this.userId);
|
||||||
@ -312,6 +326,7 @@ public class SysUser extends BaseEntity
|
|||||||
public String toString() {
|
public String toString() {
|
||||||
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
|
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
|
||||||
.append("userId", getUserId())
|
.append("userId", getUserId())
|
||||||
|
.append("tenantId", getTenantId())
|
||||||
.append("deptId", getDeptId())
|
.append("deptId", getDeptId())
|
||||||
.append("userName", getUserName())
|
.append("userName", getUserName())
|
||||||
.append("nickName", getNickName())
|
.append("nickName", getNickName())
|
||||||
|
|||||||
@ -59,6 +59,13 @@
|
|||||||
<artifactId>aqroid-system</artifactId>
|
<artifactId>aqroid-system</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JSqlParser SQL解析器 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.jsqlparser</groupId>
|
||||||
|
<artifactId>jsqlparser</artifactId>
|
||||||
|
<version>4.6</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
@ -23,6 +23,8 @@ import org.springframework.core.type.classreading.MetadataReader;
|
|||||||
import org.springframework.core.type.classreading.MetadataReaderFactory;
|
import org.springframework.core.type.classreading.MetadataReaderFactory;
|
||||||
import org.springframework.util.ClassUtils;
|
import org.springframework.util.ClassUtils;
|
||||||
import cn.aqroid.common.utils.StringUtils;
|
import cn.aqroid.common.utils.StringUtils;
|
||||||
|
import cn.aqroid.framework.interceptor.TenantSqlInterceptor;
|
||||||
|
import org.apache.ibatis.plugin.Interceptor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mybatis支持*匹配扫描包
|
* Mybatis支持*匹配扫描包
|
||||||
@ -35,6 +37,9 @@ public class MyBatisConfig
|
|||||||
@Autowired
|
@Autowired
|
||||||
private Environment env;
|
private Environment env;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TenantSqlInterceptor tenantSqlInterceptor;
|
||||||
|
|
||||||
static final String DEFAULT_RESOURCE_PATTERN = "**/*.class";
|
static final String DEFAULT_RESOURCE_PATTERN = "**/*.class";
|
||||||
|
|
||||||
public static String setTypeAliasesPackage(String typeAliasesPackage)
|
public static String setTypeAliasesPackage(String typeAliasesPackage)
|
||||||
@ -127,6 +132,8 @@ public class MyBatisConfig
|
|||||||
sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
|
sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
|
||||||
sessionFactory.setMapperLocations(resolveMapperLocations(StringUtils.split(mapperLocations, ",")));
|
sessionFactory.setMapperLocations(resolveMapperLocations(StringUtils.split(mapperLocations, ",")));
|
||||||
sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));
|
sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));
|
||||||
|
// 注册多租户SQL拦截器
|
||||||
|
sessionFactory.setPlugins(new Interceptor[]{tenantSqlInterceptor});
|
||||||
return sessionFactory.getObject();
|
return sessionFactory.getObject();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -14,6 +14,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
import cn.aqroid.common.config.RuoYiConfig;
|
import cn.aqroid.common.config.RuoYiConfig;
|
||||||
import cn.aqroid.common.constant.Constants;
|
import cn.aqroid.common.constant.Constants;
|
||||||
import cn.aqroid.framework.interceptor.RepeatSubmitInterceptor;
|
import cn.aqroid.framework.interceptor.RepeatSubmitInterceptor;
|
||||||
|
import cn.aqroid.framework.interceptor.TenantInterceptor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用配置
|
* 通用配置
|
||||||
@ -26,6 +27,9 @@ public class ResourcesConfig implements WebMvcConfigurer
|
|||||||
@Autowired
|
@Autowired
|
||||||
private RepeatSubmitInterceptor repeatSubmitInterceptor;
|
private RepeatSubmitInterceptor repeatSubmitInterceptor;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TenantInterceptor tenantInterceptor;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addResourceHandlers(ResourceHandlerRegistry registry)
|
public void addResourceHandlers(ResourceHandlerRegistry registry)
|
||||||
{
|
{
|
||||||
@ -46,6 +50,22 @@ public class ResourcesConfig implements WebMvcConfigurer
|
|||||||
public void addInterceptors(InterceptorRegistry registry)
|
public void addInterceptors(InterceptorRegistry registry)
|
||||||
{
|
{
|
||||||
registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
|
registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
|
||||||
|
|
||||||
|
// 租户拦截器
|
||||||
|
registry.addInterceptor(tenantInterceptor)
|
||||||
|
.addPathPatterns("/**")
|
||||||
|
.excludePathPatterns(
|
||||||
|
"/login", // 登录接口
|
||||||
|
"/register", // 注册接口
|
||||||
|
"/captchaImage", // 验证码
|
||||||
|
"/error", // 错误页面
|
||||||
|
"/swagger-resources/**", // Swagger
|
||||||
|
"/v2/api-docs",
|
||||||
|
"/v3/api-docs",
|
||||||
|
"/webjars/**",
|
||||||
|
"/doc.html",
|
||||||
|
"/favicon.ico"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,104 @@
|
|||||||
|
package cn.aqroid.framework.interceptor;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import cn.aqroid.common.core.tenant.TenantContext;
|
||||||
|
import cn.aqroid.common.utils.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户拦截器 - 解析请求中的租户ID并设置到上下文
|
||||||
|
*
|
||||||
|
* 支持两种方式获取租户ID:
|
||||||
|
* 1. 从请求Header中获取 "Tenant-Id"(小程序端)
|
||||||
|
* 2. 从登录用户信息中获取(管理后台)
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class TenantInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TenantInterceptor.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header中租户ID的key
|
||||||
|
*/
|
||||||
|
private static final String TENANT_ID_HEADER = "Tenant-Id";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
Long tenantId = null;
|
||||||
|
|
||||||
|
// 方式1: 从Header中获取(小程序端)
|
||||||
|
String tenantIdHeader = request.getHeader(TENANT_ID_HEADER);
|
||||||
|
if (StringUtils.isNotEmpty(tenantIdHeader)) {
|
||||||
|
try {
|
||||||
|
tenantId = Long.parseLong(tenantIdHeader);
|
||||||
|
log.debug("从Header中解析租户ID: {}", tenantId);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("Header中的租户ID格式错误: {}", tenantIdHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方式2: 从登录用户信息中获取(管理后台)
|
||||||
|
if (tenantId == null) {
|
||||||
|
try {
|
||||||
|
// 尝试从SecurityContext获取登录用户的租户ID
|
||||||
|
tenantId = getTenantIdFromSecurityContext();
|
||||||
|
if (tenantId != null) {
|
||||||
|
log.debug("从登录用户中解析租户ID: {}", tenantId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 未登录或用户未绑定租户,跳过
|
||||||
|
log.debug("无法从登录用户获取租户ID: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置到上下文
|
||||||
|
if (tenantId != null) {
|
||||||
|
TenantContext.setTenantId(tenantId);
|
||||||
|
log.debug("设置租户上下文: tenantId={}, uri={}", tenantId, request.getRequestURI());
|
||||||
|
} else {
|
||||||
|
log.debug("未识别到租户ID: uri={}", request.getRequestURI());
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Object handler, Exception ex) {
|
||||||
|
// 请求结束后清理上下文(防止内存泄漏)
|
||||||
|
TenantContext.clearAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从SecurityContext获取当前登录用户的租户ID
|
||||||
|
*
|
||||||
|
* @return 租户ID,如果未登录或未绑定租户则返回null
|
||||||
|
*/
|
||||||
|
private Long getTenantIdFromSecurityContext() {
|
||||||
|
try {
|
||||||
|
// 使用反射调用SecurityUtils,避免循环依赖
|
||||||
|
Class<?> securityUtilsClass = Class.forName("cn.aqroid.common.utils.SecurityUtils");
|
||||||
|
Object loginUser = securityUtilsClass.getMethod("getLoginUser").invoke(null);
|
||||||
|
if (loginUser != null) {
|
||||||
|
Object user = loginUser.getClass().getMethod("getUser").invoke(loginUser);
|
||||||
|
if (user != null) {
|
||||||
|
Object tenantId = user.getClass().getMethod("getTenantId").invoke(user);
|
||||||
|
if (tenantId instanceof Long) {
|
||||||
|
return (Long) tenantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 忽略异常,返回null
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,334 @@
|
|||||||
|
package cn.aqroid.framework.interceptor;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import org.apache.ibatis.executor.statement.StatementHandler;
|
||||||
|
import org.apache.ibatis.mapping.BoundSql;
|
||||||
|
import org.apache.ibatis.mapping.MappedStatement;
|
||||||
|
import org.apache.ibatis.mapping.SqlCommandType;
|
||||||
|
import org.apache.ibatis.plugin.Interceptor;
|
||||||
|
import org.apache.ibatis.plugin.Intercepts;
|
||||||
|
import org.apache.ibatis.plugin.Invocation;
|
||||||
|
import org.apache.ibatis.plugin.Plugin;
|
||||||
|
import org.apache.ibatis.plugin.Signature;
|
||||||
|
import org.apache.ibatis.reflection.MetaObject;
|
||||||
|
import org.apache.ibatis.reflection.SystemMetaObject;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import cn.aqroid.common.annotation.TenantIgnore;
|
||||||
|
import cn.aqroid.common.core.tenant.TenantContext;
|
||||||
|
import net.sf.jsqlparser.expression.Expression;
|
||||||
|
import net.sf.jsqlparser.expression.LongValue;
|
||||||
|
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
|
||||||
|
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
|
||||||
|
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
|
||||||
|
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||||
|
import net.sf.jsqlparser.schema.Column;
|
||||||
|
import net.sf.jsqlparser.statement.Statement;
|
||||||
|
import net.sf.jsqlparser.statement.delete.Delete;
|
||||||
|
import net.sf.jsqlparser.statement.insert.Insert;
|
||||||
|
import net.sf.jsqlparser.statement.select.FromItem;
|
||||||
|
import net.sf.jsqlparser.statement.select.PlainSelect;
|
||||||
|
import net.sf.jsqlparser.statement.select.Select;
|
||||||
|
import net.sf.jsqlparser.statement.update.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多租户SQL拦截器
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. SELECT: 自动添加 WHERE tenant_id = ?
|
||||||
|
* 2. INSERT: 自动添加 tenant_id 字段和值
|
||||||
|
* 3. UPDATE: 自动添加 WHERE tenant_id = ?
|
||||||
|
* 4. DELETE: 自动添加 WHERE tenant_id = ?
|
||||||
|
*
|
||||||
|
* @author aqroid
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Intercepts({
|
||||||
|
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
|
||||||
|
})
|
||||||
|
public class TenantSqlInterceptor implements Interceptor {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TenantSqlInterceptor.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户字段名
|
||||||
|
*/
|
||||||
|
private static final String TENANT_ID_COLUMN = "tenant_id";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 忽略租户过滤的表(系统表、字典表等)
|
||||||
|
*/
|
||||||
|
private static final List<String> IGNORE_TABLES = Arrays.asList(
|
||||||
|
"sys_tenant", // 租户表本身
|
||||||
|
"sys_tenant_package", // 租户套餐表
|
||||||
|
"sys_config", // 系统配置
|
||||||
|
"sys_dict_type", // 字典类型
|
||||||
|
"sys_dict_data", // 字典数据
|
||||||
|
"sys_menu", // 菜单表
|
||||||
|
"sys_notice", // 通知公告
|
||||||
|
"sys_logininfor", // 登录日志
|
||||||
|
"sys_oper_log", // 操作日志
|
||||||
|
"sys_job", // 定时任务
|
||||||
|
"sys_job_log", // 任务日志
|
||||||
|
"gen_table", // 代码生成表
|
||||||
|
"gen_table_column", // 代码生成字段
|
||||||
|
"customer" // 顾客表(跨租户)
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object intercept(Invocation invocation) throws Throwable {
|
||||||
|
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
|
||||||
|
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
|
||||||
|
|
||||||
|
// 获取MappedStatement
|
||||||
|
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
|
||||||
|
|
||||||
|
// 检查是否忽略租户
|
||||||
|
if (shouldIgnoreTenant(mappedStatement)) {
|
||||||
|
return invocation.proceed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前租户ID
|
||||||
|
Long tenantId = TenantContext.getTenantId();
|
||||||
|
if (tenantId == null) {
|
||||||
|
log.debug("未设置租户ID,跳过租户过滤: {}", mappedStatement.getId());
|
||||||
|
return invocation.proceed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取SQL类型
|
||||||
|
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
|
||||||
|
|
||||||
|
// 获取BoundSql
|
||||||
|
BoundSql boundSql = statementHandler.getBoundSql();
|
||||||
|
String originalSql = boundSql.getSql();
|
||||||
|
|
||||||
|
log.debug("原始SQL: {}", originalSql);
|
||||||
|
|
||||||
|
// 解析并改写SQL
|
||||||
|
String newSql = rewriteSql(originalSql, sqlCommandType, tenantId);
|
||||||
|
|
||||||
|
// 通过反射修改SQL
|
||||||
|
metaObject.setValue("delegate.boundSql.sql", newSql);
|
||||||
|
|
||||||
|
log.debug("改写后SQL: {}", newSql);
|
||||||
|
|
||||||
|
return invocation.proceed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否忽略租户
|
||||||
|
*/
|
||||||
|
private boolean shouldIgnoreTenant(MappedStatement mappedStatement) {
|
||||||
|
// 1. 检查上下文是否设置了忽略标志
|
||||||
|
if (TenantContext.isIgnore()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查方法上是否有@TenantIgnore注解
|
||||||
|
try {
|
||||||
|
String mappedStatementId = mappedStatement.getId();
|
||||||
|
String className = mappedStatementId.substring(0, mappedStatementId.lastIndexOf('.'));
|
||||||
|
String methodName = mappedStatementId.substring(mappedStatementId.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
|
Class<?> clazz = Class.forName(className);
|
||||||
|
|
||||||
|
// 检查类级别注解
|
||||||
|
TenantIgnore classAnnotation = clazz.getAnnotation(TenantIgnore.class);
|
||||||
|
if (classAnnotation != null && classAnnotation.value()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查方法级别注解
|
||||||
|
for (Method method : clazz.getDeclaredMethods()) {
|
||||||
|
if (method.getName().equals(methodName)) {
|
||||||
|
TenantIgnore methodAnnotation = method.getAnnotation(TenantIgnore.class);
|
||||||
|
if (methodAnnotation != null && methodAnnotation.value()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 忽略异常
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改写SQL
|
||||||
|
*/
|
||||||
|
private String rewriteSql(String originalSql, SqlCommandType sqlCommandType, Long tenantId) {
|
||||||
|
try {
|
||||||
|
Statement statement = CCJSqlParserUtil.parse(originalSql);
|
||||||
|
|
||||||
|
switch (sqlCommandType) {
|
||||||
|
case SELECT:
|
||||||
|
processSelect((Select) statement, tenantId);
|
||||||
|
break;
|
||||||
|
case INSERT:
|
||||||
|
processInsert((Insert) statement, tenantId);
|
||||||
|
break;
|
||||||
|
case UPDATE:
|
||||||
|
processUpdate((Update) statement, tenantId);
|
||||||
|
break;
|
||||||
|
case DELETE:
|
||||||
|
processDelete((Delete) statement, tenantId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return statement.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("SQL解析失败,返回原始SQL: {}", e.getMessage());
|
||||||
|
return originalSql;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理SELECT语句
|
||||||
|
*/
|
||||||
|
private void processSelect(Select select, Long tenantId) {
|
||||||
|
if (select.getSelectBody() instanceof PlainSelect) {
|
||||||
|
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
|
||||||
|
|
||||||
|
// 检查是否是忽略的表
|
||||||
|
if (isIgnoreTable(plainSelect.getFromItem())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加WHERE tenant_id = ?
|
||||||
|
Expression tenantCondition = buildTenantCondition(tenantId);
|
||||||
|
Expression where = plainSelect.getWhere();
|
||||||
|
|
||||||
|
if (where != null) {
|
||||||
|
plainSelect.setWhere(new AndExpression(tenantCondition, where));
|
||||||
|
} else {
|
||||||
|
plainSelect.setWhere(tenantCondition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理INSERT语句
|
||||||
|
*/
|
||||||
|
private void processInsert(Insert insert, Long tenantId) {
|
||||||
|
// 检查是否是忽略的表
|
||||||
|
if (isIgnoreTable(insert.getTable().getName())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加tenant_id字段
|
||||||
|
List<Column> columns = insert.getColumns();
|
||||||
|
if (columns != null && !containsTenantColumn(columns)) {
|
||||||
|
columns.add(new Column(TENANT_ID_COLUMN));
|
||||||
|
|
||||||
|
// 添加值
|
||||||
|
if (insert.getItemsList() instanceof ExpressionList) {
|
||||||
|
((ExpressionList) insert.getItemsList()).getExpressions().add(new LongValue(tenantId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理UPDATE语句
|
||||||
|
*/
|
||||||
|
private void processUpdate(Update update, Long tenantId) {
|
||||||
|
// 检查是否是忽略的表
|
||||||
|
if (isIgnoreTable(update.getTable().getName())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加WHERE tenant_id = ?
|
||||||
|
Expression tenantCondition = buildTenantCondition(tenantId);
|
||||||
|
Expression where = update.getWhere();
|
||||||
|
|
||||||
|
if (where != null) {
|
||||||
|
update.setWhere(new AndExpression(tenantCondition, where));
|
||||||
|
} else {
|
||||||
|
update.setWhere(tenantCondition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理DELETE语句
|
||||||
|
*/
|
||||||
|
private void processDelete(Delete delete, Long tenantId) {
|
||||||
|
// 检查是否是忽略的表
|
||||||
|
if (isIgnoreTable(delete.getTable().getName())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加WHERE tenant_id = ?
|
||||||
|
Expression tenantCondition = buildTenantCondition(tenantId);
|
||||||
|
Expression where = delete.getWhere();
|
||||||
|
|
||||||
|
if (where != null) {
|
||||||
|
delete.setWhere(new AndExpression(tenantCondition, where));
|
||||||
|
} else {
|
||||||
|
delete.setWhere(tenantCondition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建租户条件: tenant_id = ?
|
||||||
|
*/
|
||||||
|
private Expression buildTenantCondition(Long tenantId) {
|
||||||
|
EqualsTo equalsTo = new EqualsTo();
|
||||||
|
equalsTo.setLeftExpression(new Column(TENANT_ID_COLUMN));
|
||||||
|
equalsTo.setRightExpression(new LongValue(tenantId));
|
||||||
|
return equalsTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否包含tenant_id字段
|
||||||
|
*/
|
||||||
|
private boolean containsTenantColumn(List<Column> columns) {
|
||||||
|
return columns.stream()
|
||||||
|
.anyMatch(column -> TENANT_ID_COLUMN.equalsIgnoreCase(column.getColumnName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否是忽略的表
|
||||||
|
*/
|
||||||
|
private boolean isIgnoreTable(Object table) {
|
||||||
|
if (table == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String tableName;
|
||||||
|
if (table instanceof FromItem) {
|
||||||
|
tableName = table.toString();
|
||||||
|
} else {
|
||||||
|
tableName = table.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除反引号和别名
|
||||||
|
tableName = tableName.replace("`", "").trim();
|
||||||
|
// 处理别名情况,如 "sys_tenant t"
|
||||||
|
if (tableName.contains(" ")) {
|
||||||
|
tableName = tableName.split(" ")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
final String finalTableName = tableName;
|
||||||
|
return IGNORE_TABLES.stream()
|
||||||
|
.anyMatch(ignoreTable -> ignoreTable.equalsIgnoreCase(finalTableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object plugin(Object target) {
|
||||||
|
return Plugin.wrap(target, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setProperties(Properties properties) {
|
||||||
|
// 可以从配置文件读取参数
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
|
|
||||||
<resultMap type="SysUser" id="SysUserResult">
|
<resultMap type="SysUser" id="SysUserResult">
|
||||||
<id property="userId" column="user_id" />
|
<id property="userId" column="user_id" />
|
||||||
|
<result property="tenantId" column="tenant_id" />
|
||||||
<result property="deptId" column="dept_id" />
|
<result property="deptId" column="dept_id" />
|
||||||
<result property="userName" column="user_name" />
|
<result property="userName" column="user_name" />
|
||||||
<result property="nickName" column="nick_name" />
|
<result property="nickName" column="nick_name" />
|
||||||
@ -48,7 +49,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<sql id="selectUserVo">
|
<sql id="selectUserVo">
|
||||||
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.remark,
|
select u.user_id, u.tenant_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.remark,
|
||||||
d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status,
|
d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status,
|
||||||
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status
|
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status
|
||||||
from sys_user u
|
from sys_user u
|
||||||
@ -146,6 +147,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
<insert id="insertUser" parameterType="SysUser" useGeneratedKeys="true" keyProperty="userId">
|
<insert id="insertUser" parameterType="SysUser" useGeneratedKeys="true" keyProperty="userId">
|
||||||
insert into sys_user(
|
insert into sys_user(
|
||||||
<if test="userId != null and userId != 0">user_id,</if>
|
<if test="userId != null and userId != 0">user_id,</if>
|
||||||
|
<if test="tenantId != null">tenant_id,</if>
|
||||||
<if test="deptId != null and deptId != 0">dept_id,</if>
|
<if test="deptId != null and deptId != 0">dept_id,</if>
|
||||||
<if test="userName != null and userName != ''">user_name,</if>
|
<if test="userName != null and userName != ''">user_name,</if>
|
||||||
<if test="nickName != null and nickName != ''">nick_name,</if>
|
<if test="nickName != null and nickName != ''">nick_name,</if>
|
||||||
@ -161,6 +163,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
create_time
|
create_time
|
||||||
)values(
|
)values(
|
||||||
<if test="userId != null and userId != ''">#{userId},</if>
|
<if test="userId != null and userId != ''">#{userId},</if>
|
||||||
|
<if test="tenantId != null">#{tenantId},</if>
|
||||||
<if test="deptId != null and deptId != ''">#{deptId},</if>
|
<if test="deptId != null and deptId != ''">#{deptId},</if>
|
||||||
<if test="userName != null and userName != ''">#{userName},</if>
|
<if test="userName != null and userName != ''">#{userName},</if>
|
||||||
<if test="nickName != null and nickName != ''">#{nickName},</if>
|
<if test="nickName != null and nickName != ''">#{nickName},</if>
|
||||||
@ -180,6 +183,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
<update id="updateUser" parameterType="SysUser">
|
<update id="updateUser" parameterType="SysUser">
|
||||||
update sys_user
|
update sys_user
|
||||||
<set>
|
<set>
|
||||||
|
<if test="tenantId != null">tenant_id = #{tenantId},</if>
|
||||||
<if test="deptId != 0">dept_id = #{deptId},</if>
|
<if test="deptId != 0">dept_id = #{deptId},</if>
|
||||||
<if test="nickName != null and nickName != ''">nick_name = #{nickName},</if>
|
<if test="nickName != null and nickName != ''">nick_name = #{nickName},</if>
|
||||||
<if test="email != null ">email = #{email},</if>
|
<if test="email != null ">email = #{email},</if>
|
||||||
|
|||||||
4
pom.xml
4
pom.xml
@ -211,14 +211,14 @@
|
|||||||
<version>${aqroid.version}</version>
|
<version>${aqroid.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- 通用工具-->>
|
<!-- 通用工具-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.aqroid</groupId>
|
<groupId>cn.aqroid</groupId>
|
||||||
<artifactId>aqroid-common</artifactId>
|
<artifactId>aqroid-common</artifactId>
|
||||||
<version>${aqroid.version}</version>
|
<version>${aqroid.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- 业务模块-->>
|
<!-- 业务模块-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.aqroid</groupId>
|
<groupId>cn.aqroid</groupId>
|
||||||
<artifactId>aqroid-business</artifactId>
|
<artifactId>aqroid-business</artifactId>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user