diff --git a/gameServiceSpring-admin/src/main/resources/application.yml b/gameServiceSpring-admin/src/main/resources/application.yml index a17cf89..ebf1ed1 100644 --- a/gameServiceSpring-admin/src/main/resources/application.yml +++ b/gameServiceSpring-admin/src/main/resources/application.yml @@ -134,3 +134,13 @@ xss: excludes: /system/notice # 匹配链接 urlPatterns: /system/*,/monitor/*,/tool/* + +# 微信小程序配置 +wx: + miniapp: + # 小程序AppID(请替换为实际值) + appId: wx1234567890abcdef + # 小程序AppSecret(请替换为实际值) + appSecret: your_app_secret_here + # API Token有效期(秒),默认7天 + tokenExpireTime: 604800 diff --git a/gameServiceSpring-business/pom.xml b/gameServiceSpring-business/pom.xml index f4f4f23..6765c80 100644 --- a/gameServiceSpring-business/pom.xml +++ b/gameServiceSpring-business/pom.xml @@ -23,6 +23,34 @@ aqroid-common + + + org.springframework.boot + spring-boot-starter-web + + + + + org.bouncycastle + bcprov-jdk15on + 1.70 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + net.jqwik + jqwik + 1.8.2 + test + + diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/config/ApiWebConfig.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/config/ApiWebConfig.java new file mode 100644 index 0000000..72d6e50 --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/config/ApiWebConfig.java @@ -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/**" // 代练详情(公开) + ); + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/config/WxConfig.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/config/WxConfig.java new file mode 100644 index 0000000..60bed7b --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/config/WxConfig.java @@ -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; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/controller/ApiAuthController.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/controller/ApiAuthController.java new file mode 100644 index 0000000..0629996 --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/controller/ApiAuthController.java @@ -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("退出成功"); + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/ApiLoginUser.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/ApiLoginUser.java new file mode 100644 index 0000000..44244e1 --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/ApiLoginUser.java @@ -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 + + '}'; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/WxSession.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/WxSession.java new file mode 100644 index 0000000..87f8b79 --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/WxSession.java @@ -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 + '\'' + + '}'; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/LoginResult.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/LoginResult.java new file mode 100644 index 0000000..8ac9e2c --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/LoginResult.java @@ -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; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/PhoneLoginRequest.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/PhoneLoginRequest.java new file mode 100644 index 0000000..c47322c --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/PhoneLoginRequest.java @@ -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; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/SwitchRoleRequest.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/SwitchRoleRequest.java new file mode 100644 index 0000000..69879ec --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/SwitchRoleRequest.java @@ -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; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/UpdateUserRequest.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/UpdateUserRequest.java new file mode 100644 index 0000000..e502765 --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/UpdateUserRequest.java @@ -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; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/WxLoginRequest.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/WxLoginRequest.java new file mode 100644 index 0000000..4639f5e --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/domain/dto/WxLoginRequest.java @@ -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; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/interceptor/ApiAuthInterceptor.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/interceptor/ApiAuthInterceptor.java new file mode 100644 index 0000000..23b179b --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/interceptor/ApiAuthInterceptor.java @@ -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 result = new HashMap<>(); + result.put("code", 401); + result.put("msg", message); + + response.getWriter().write(JSON.toJSONString(result)); + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/ApiTokenService.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/ApiTokenService.java new file mode 100644 index 0000000..be14f65 --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/ApiTokenService.java @@ -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); +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/WxAuthService.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/WxAuthService.java new file mode 100644 index 0000000..8b5de07 --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/WxAuthService.java @@ -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(); +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/impl/ApiTokenServiceImpl.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/impl/ApiTokenServiceImpl.java new file mode 100644 index 0000000..dcf325e --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/impl/ApiTokenServiceImpl.java @@ -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 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; + } + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/impl/WxAuthServiceImpl.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/impl/WxAuthServiceImpl.java new file mode 100644 index 0000000..e2a1999 --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/service/impl/WxAuthServiceImpl.java @@ -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 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 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失败,请稍后重试"); + } + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/util/ApiLoginUserHolder.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/util/ApiLoginUserHolder.java new file mode 100644 index 0000000..c49391b --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/auth/util/ApiLoginUserHolder.java @@ -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 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(); + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/common/tenant/TenantContext.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/common/tenant/TenantContext.java deleted file mode 100644 index 0373955..0000000 --- a/gameServiceSpring-business/src/main/java/cn/aqroid/business/common/tenant/TenantContext.java +++ /dev/null @@ -1,40 +0,0 @@ -package cn.aqroid.business.common.tenant; - -/** - * 租户上下文 - * 使用ThreadLocal存储当前请求的租户ID - * - * @author aqroid - */ -public class TenantContext { - - private static final ThreadLocal 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; - } -} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/domain/Customer.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/domain/Customer.java new file mode 100644 index 0000000..569dc2a --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/domain/Customer.java @@ -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 + + '}'; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/mapper/CustomerMapper.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/mapper/CustomerMapper.java new file mode 100644 index 0000000..1d666ff --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/mapper/CustomerMapper.java @@ -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 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); +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/service/ICustomerService.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/service/ICustomerService.java new file mode 100644 index 0000000..927229e --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/service/ICustomerService.java @@ -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 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); +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/service/impl/CustomerServiceImpl.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/service/impl/CustomerServiceImpl.java new file mode 100644 index 0000000..d387db8 --- /dev/null +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/customer/service/impl/CustomerServiceImpl.java @@ -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 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; + } +} diff --git a/gameServiceSpring-business/src/main/java/cn/aqroid/business/tenant/mapper/TenantMapper.java b/gameServiceSpring-business/src/main/java/cn/aqroid/business/tenant/mapper/TenantMapper.java index ba74f4b..84ab864 100644 --- a/gameServiceSpring-business/src/main/java/cn/aqroid/business/tenant/mapper/TenantMapper.java +++ b/gameServiceSpring-business/src/main/java/cn/aqroid/business/tenant/mapper/TenantMapper.java @@ -1,14 +1,18 @@ package cn.aqroid.business.tenant.mapper; import cn.aqroid.business.tenant.domain.Tenant; +import cn.aqroid.common.annotation.TenantIgnore; import java.util.List; /** * 租户Mapper接口 + * + * 注意:租户表本身不需要租户过滤,使用@TenantIgnore注解 * * @author aqroid * @date 2026-01-12 */ +@TenantIgnore public interface TenantMapper { /** diff --git a/gameServiceSpring-business/src/main/resources/mapper/customer/CustomerMapper.xml b/gameServiceSpring-business/src/main/resources/mapper/customer/CustomerMapper.xml new file mode 100644 index 0000000..1da3c40 --- /dev/null +++ b/gameServiceSpring-business/src/main/resources/mapper/customer/CustomerMapper.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + select id, openid, unionid, nickname, avatar, phone, status, last_login_time, create_time, update_time + from customer + + + + + + + + + + + + insert into customer + + openid, + unionid, + nickname, + avatar, + phone, + status, + last_login_time, + create_time, + update_time, + + + #{openid}, + #{unionid}, + #{nickname}, + #{avatar}, + #{phone}, + #{status}, + #{lastLoginTime}, + #{createTime}, + #{updateTime}, + + + + + update customer + + openid = #{openid}, + unionid = #{unionid}, + nickname = #{nickname}, + avatar = #{avatar}, + phone = #{phone}, + status = #{status}, + last_login_time = #{lastLoginTime}, + update_time = #{updateTime}, + + where id = #{id} + + + + update customer set last_login_time = now(), update_time = now() where id = #{id} + + + + delete from customer where id = #{id} + + + diff --git a/gameServiceSpring-business/src/test/java/cn/aqroid/business/tenant/TenantSoftDeletePropertyTest.java b/gameServiceSpring-business/src/test/java/cn/aqroid/business/tenant/TenantSoftDeletePropertyTest.java new file mode 100644 index 0000000..a6feed1 --- /dev/null +++ b/gameServiceSpring-business/src/test/java/cn/aqroid/business/tenant/TenantSoftDeletePropertyTest.java @@ -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应该不同"); + } +} diff --git a/gameServiceSpring-common/src/main/java/cn/aqroid/common/annotation/TenantIgnore.java b/gameServiceSpring-common/src/main/java/cn/aqroid/common/annotation/TenantIgnore.java new file mode 100644 index 0000000..6f86320 --- /dev/null +++ b/gameServiceSpring-common/src/main/java/cn/aqroid/common/annotation/TenantIgnore.java @@ -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. 登录认证等不需要租户隔离的操作 + * + * 使用示例: + *
+ * // 方法级别
+ * @TenantIgnore
+ * public List selectAllTenants() { ... }
+ * 
+ * // 类级别
+ * @TenantIgnore
+ * public class SystemConfigMapper { ... }
+ * 
+ * // 显式指定不忽略(用于覆盖类级别注解)
+ * @TenantIgnore(value = false)
+ * public List selectOrders() { ... }
+ * 
+ * + * @author aqroid + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface TenantIgnore { + + /** + * 是否忽略租户过滤 + * + * @return true表示忽略租户过滤,false表示正常过滤 + */ + boolean value() default true; +} diff --git a/gameServiceSpring-common/src/main/java/cn/aqroid/common/core/domain/entity/SysUser.java b/gameServiceSpring-common/src/main/java/cn/aqroid/common/core/domain/entity/SysUser.java index 642939f..a8554b4 100644 --- a/gameServiceSpring-common/src/main/java/cn/aqroid/common/core/domain/entity/SysUser.java +++ b/gameServiceSpring-common/src/main/java/cn/aqroid/common/core/domain/entity/SysUser.java @@ -27,6 +27,10 @@ public class SysUser extends BaseEntity @Excel(name = "用户序号", type = Type.EXPORT, cellType = ColumnType.NUMERIC, prompt = "用户编号") private Long userId; + /** 租户ID */ + @Excel(name = "租户编号", type = Type.IMPORT) + private Long tenantId; + /** 部门ID */ @Excel(name = "部门编号", type = Type.IMPORT) private Long deptId; @@ -114,6 +118,16 @@ public class SysUser extends BaseEntity this.userId = userId; } + public Long getTenantId() + { + return tenantId; + } + + public void setTenantId(Long tenantId) + { + this.tenantId = tenantId; + } + public boolean isAdmin() { return SecurityUtils.isAdmin(this.userId); @@ -312,6 +326,7 @@ public class SysUser extends BaseEntity public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) .append("userId", getUserId()) + .append("tenantId", getTenantId()) .append("deptId", getDeptId()) .append("userName", getUserName()) .append("nickName", getNickName()) diff --git a/gameServiceSpring-framework/pom.xml b/gameServiceSpring-framework/pom.xml index 1048527..df85999 100644 --- a/gameServiceSpring-framework/pom.xml +++ b/gameServiceSpring-framework/pom.xml @@ -59,6 +59,13 @@ aqroid-system + + + com.github.jsqlparser + jsqlparser + 4.6 + + \ No newline at end of file diff --git a/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/config/MyBatisConfig.java b/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/config/MyBatisConfig.java index 4d4b9f4..2e4b2e0 100644 --- a/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/config/MyBatisConfig.java +++ b/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/config/MyBatisConfig.java @@ -23,6 +23,8 @@ import org.springframework.core.type.classreading.MetadataReader; import org.springframework.core.type.classreading.MetadataReaderFactory; import org.springframework.util.ClassUtils; import cn.aqroid.common.utils.StringUtils; +import cn.aqroid.framework.interceptor.TenantSqlInterceptor; +import org.apache.ibatis.plugin.Interceptor; /** * Mybatis支持*匹配扫描包 @@ -35,6 +37,9 @@ public class MyBatisConfig @Autowired private Environment env; + @Autowired + private TenantSqlInterceptor tenantSqlInterceptor; + static final String DEFAULT_RESOURCE_PATTERN = "**/*.class"; public static String setTypeAliasesPackage(String typeAliasesPackage) @@ -127,6 +132,8 @@ public class MyBatisConfig sessionFactory.setTypeAliasesPackage(typeAliasesPackage); sessionFactory.setMapperLocations(resolveMapperLocations(StringUtils.split(mapperLocations, ","))); sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation)); + // 注册多租户SQL拦截器 + sessionFactory.setPlugins(new Interceptor[]{tenantSqlInterceptor}); return sessionFactory.getObject(); } } \ No newline at end of file diff --git a/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/config/ResourcesConfig.java b/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/config/ResourcesConfig.java index 6190e1e..ab6f31a 100644 --- a/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/config/ResourcesConfig.java +++ b/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/config/ResourcesConfig.java @@ -14,6 +14,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import cn.aqroid.common.config.RuoYiConfig; import cn.aqroid.common.constant.Constants; import cn.aqroid.framework.interceptor.RepeatSubmitInterceptor; +import cn.aqroid.framework.interceptor.TenantInterceptor; /** * 通用配置 @@ -26,6 +27,9 @@ public class ResourcesConfig implements WebMvcConfigurer @Autowired private RepeatSubmitInterceptor repeatSubmitInterceptor; + @Autowired + private TenantInterceptor tenantInterceptor; + @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { @@ -46,6 +50,22 @@ public class ResourcesConfig implements WebMvcConfigurer public void addInterceptors(InterceptorRegistry registry) { 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" + ); } /** diff --git a/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/interceptor/TenantInterceptor.java b/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/interceptor/TenantInterceptor.java new file mode 100644 index 0000000..e97d738 --- /dev/null +++ b/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/interceptor/TenantInterceptor.java @@ -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; + } +} diff --git a/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/interceptor/TenantSqlInterceptor.java b/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/interceptor/TenantSqlInterceptor.java new file mode 100644 index 0000000..97e43f0 --- /dev/null +++ b/gameServiceSpring-framework/src/main/java/cn/aqroid/framework/interceptor/TenantSqlInterceptor.java @@ -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 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 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 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) { + // 可以从配置文件读取参数 + } +} diff --git a/gameServiceSpring-system/src/main/resources/mapper/system/SysUserMapper.xml b/gameServiceSpring-system/src/main/resources/mapper/system/SysUserMapper.xml index 1a2ba86..c6417f7 100644 --- a/gameServiceSpring-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/gameServiceSpring-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -6,6 +6,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -48,7 +49,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - 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, r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status from sys_user u @@ -146,6 +147,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" insert into sys_user( user_id, + tenant_id, dept_id, user_name, nick_name, @@ -161,6 +163,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" create_time )values( #{userId}, + #{tenantId}, #{deptId}, #{userName}, #{nickName}, @@ -180,6 +183,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update sys_user + tenant_id = #{tenantId}, dept_id = #{deptId}, nick_name = #{nickName}, email = #{email}, diff --git a/pom.xml b/pom.xml index be03f3c..f7a9bb7 100644 --- a/pom.xml +++ b/pom.xml @@ -211,14 +211,14 @@ ${aqroid.version} - > + cn.aqroid aqroid-common ${aqroid.version} - > + cn.aqroid aqroid-business