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:
ni ziyi 2026-01-13 10:41:08 +08:00
parent 98d88731a1
commit f145c2d741
35 changed files with 2959 additions and 43 deletions

View File

@ -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

View File

@ -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>

View File

@ -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/**" // 代练详情公开
);
}
}

View File

@ -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;
}
}

View File

@ -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("退出成功");
}
}

View File

@ -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 +
'}';
}
}

View File

@ -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 + '\'' +
'}';
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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;
}
}
}

View File

@ -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失败请稍后重试");
}
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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 +
'}';
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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
{ {
/** /**

View File

@ -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>

View File

@ -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应该不同");
}
}

View File

@ -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>
* // 方法级别
* &#64;TenantIgnore
* public List<Tenant> selectAllTenants() { ... }
*
* // 类级别
* &#64;TenantIgnore
* public class SystemConfigMapper { ... }
*
* // 显式指定不忽略用于覆盖类级别注解
* &#64;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;
}

View File

@ -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())

View File

@ -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>

View File

@ -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();
} }
} }

View File

@ -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"
);
} }
/** /**

View File

@ -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;
}
}

View File

@ -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) {
// 可以从配置文件读取参数
}
}

View File

@ -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>

View File

@ -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>