From 9ca032131775c4d09c3ce9430ea9b553fc5c0019 Mon Sep 17 00:00:00 2001 From: 15004070936 <1097449274@qq.com> Date: Tue, 1 Sep 2026 11:32:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9F=AD=E4=BF=A1=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E7=99=BB=E5=BD=95=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/login/AppLoginController.java | 59 ++- .../controller/login/CaptchaController.java | 400 +++++++++++++++++- .../ruoyi/common/constant/CacheConstants.java | 6 + .../common/core/domain/entity/AppUser.java | 24 +- .../core/domain/model/AppUserLoginBody.java | 1 + ruoyi-framework/pom.xml | 13 +- .../framework/config/SecurityConfig.java | 14 +- .../sms/SmsCodeAuthenticationProvider.java | 69 +++ .../sms/SmsCodeAuthenticationToken.java | 52 +++ .../com/ruoyi/framework/sms/SmsService.java | 59 +++ .../framework/sms/SmsUserDetailsService.java | 87 ++++ .../web/service/AppLoginService.java | 51 +++ .../com/ruoyi/basic/domain/AppAgreement.java | 48 +++ .../basic/mapper/AppAgreementMapper.java | 10 + .../basic/service/AppAgreementService.java | 14 + .../service/impl/AppAgreementServiceImpl.java | 28 ++ .../service/impl/ScClaTimeServiceImpl.java | 37 +- .../ruoyi/system/mapper/SysUserMapper.java | 4 + .../ruoyi/system/service/ISysUserService.java | 2 +- .../service/impl/SysUserServiceImpl.java | 7 + .../resources/mapper/basic/YjStoreMapper.xml | 2 +- .../resources/mapper/system/SysUserMapper.xml | 5 + 22 files changed, 943 insertions(+), 49 deletions(-) create mode 100644 ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsCodeAuthenticationProvider.java create mode 100644 ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsCodeAuthenticationToken.java create mode 100644 ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsService.java create mode 100644 ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsUserDetailsService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/basic/domain/AppAgreement.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/basic/mapper/AppAgreementMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/basic/service/AppAgreementService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/basic/service/impl/AppAgreementServiceImpl.java diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/login/AppLoginController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/login/AppLoginController.java index b746354..3006abf 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/login/AppLoginController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/login/AppLoginController.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.im.util.BeanUtils; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.ruoyi.RestResponse; @@ -16,6 +17,7 @@ import com.ruoyi.common.core.domain.model.AppLoginUser; import com.ruoyi.common.core.domain.model.AppUserLoginBody; import com.ruoyi.common.utils.JpushClientUtil; import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.framework.sms.SmsCodeAuthenticationToken; import com.ruoyi.framework.web.service.AppLoginService; import com.ruoyi.basic.domain.YjStore; import com.ruoyi.basic.service.impl.YjStoreServiceImpl; @@ -25,9 +27,11 @@ import com.ruoyi.web.controller.basic.dto.AppLoginUserForGetInfor; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -51,12 +55,32 @@ public class AppLoginController { @Autowired private SysPermissionService permissionService; + /** + * 短信验证码登录 + */ + @PostMapping("/sms/login") + public AjaxResult smsLogin(@RequestBody AppUserLoginBody loginBody) { + AjaxResult ajax = AjaxResult.success(); + String token=appLoginService.loginBySms(loginBody.getPhonenumber(),loginBody.getSmsCode(),loginBody.getRegisterId(),loginBody.getMiniAppCode()); + ajax.put(Constants.TOKEN, token); + // 保存设备id绑定别名,便于推送消息 + if (StrUtil.isNotEmpty(loginBody.getRegisterId())){ + appUserService.update(new UpdateWrapper().eq("phone_number",loginBody.getPhonenumber()) + .set("register_id",loginBody.getRegisterId()) + .set(StrUtil.isNotEmpty(loginBody.getLoginSysPlatform()),"login_sys_platform",loginBody.getLoginSysPlatform()) + ); + // 极光推送注册设备id绑定别名,便于推送消息 + JpushClientUtil.bindAlias(loginBody.getRegisterId(),loginBody.getPhonenumber()); + } + + return ajax; + } /** * 注册 * @param loginBody * @return */ - @PostMapping("/register") +// @PostMapping("/register") public RestResponse register(@RequestBody AppUserLoginBody loginBody){ appLoginService.validateCaptcha(loginBody.getCode(), loginBody.getUuid()); if (!loginBody.getPassword().equals(loginBody.getPasswordSecond())){ @@ -87,7 +111,6 @@ public class AppLoginController { AjaxResult ajax = AjaxResult.success(); String token=appLoginService.login(loginBody.getPhonenumber(),loginBody.getPassword(),loginBody.getCode(),loginBody.getUuid(),loginBody.getRegisterId(),loginBody.getMiniAppCode()); ajax.put(Constants.TOKEN, token); - // 保存设备id绑定别名,便于推送消息 if (StrUtil.isNotEmpty(loginBody.getRegisterId())){ appUserService.update(new UpdateWrapper().eq("phone_number",loginBody.getPhonenumber()) @@ -131,6 +154,37 @@ public class AppLoginController { return ajax; } + @ApiOperation(value = "注销用户") + @GetMapping("/cancel") + public RestResponse cancel() + { + AppLoginUser user = SecurityUtils.getAppLoginUser(); + if (ObjectUtil.isEmpty(user)){ + return new RestResponse().setSuccess(false).setMessage("登录已过期,请重新登录"); + } + AppUser appUser=new AppUser(); + appUser.setId(user.getAppUser().getId()); + appUser.setNickName("用户已注销"); + appUser.setStatus(0); + appUser.setPhoneNumber(null); + appUser.setUserName(null); + appUser.setPassword(null); + appUser.setAvatar(null); + appUser.setSex(null); + appUser.setCity(null); + appUser.setProvince(null); + appUser.setCountry(null); + appUser.setBirthday(null); + appUser.setRegisterId(null); + appUser.setUnionid(null); + appUser.setOpenid(null); + appUser.setLoginIp(null); + appUser.setSignature(null); + appUser.setLoginSysPlatform(null); + appUser.setCancelTime(new Date()); + appUserService.updateById(appUser); + return new RestResponse().setSuccess(true).setMessage("注销成功"); + } @Resource private YjStoreServiceImpl storeService; @@ -175,4 +229,5 @@ public class AppLoginController { + } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/login/CaptchaController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/login/CaptchaController.java index f92b63c..63bee57 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/login/CaptchaController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/login/CaptchaController.java @@ -1,8 +1,12 @@ package com.ruoyi.web.controller.login; +import cn.hutool.core.date.DateUtil; import com.google.code.kaptcha.Producer; +import com.ruoyi.RestResponse; +import com.ruoyi.basic.domain.AppAgreement; import com.ruoyi.basic.domain.YjStore; import com.ruoyi.basic.mapper.YjStoreMapper; +import com.ruoyi.basic.service.AppAgreementService; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.constant.CacheConstants; import com.ruoyi.common.constant.Constants; @@ -11,16 +15,27 @@ import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.utils.JpushClientUtil; import com.ruoyi.common.utils.sign.Base64; import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.framework.sms.SmsService; import com.ruoyi.system.service.ISysConfigService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.util.FastByteArrayOutputStream; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.imageio.ImageIO; +import javax.servlet.http.HttpServletResponse; +import javax.validation.constraints.Pattern; import java.awt.image.BufferedImage; import java.io.IOException; +import java.io.PrintWriter; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.TimeUnit; @@ -30,7 +45,7 @@ import java.util.concurrent.TimeUnit; * @author ruoyi */ @RestController -public class CaptchaController +public class CaptchaController { @Resource(name = "captchaProducer") private Producer captchaProducer; @@ -44,6 +59,59 @@ public class CaptchaController @Autowired private ISysConfigService configService; + @Autowired + private SmsService smsService; + + @Autowired + private RedisTemplate redisTemplate; // 注入 StringRedisTemplate + + private static final String SMS_CODE_PREFIX = "sms_code:"; + + + /** + * 发送短信验证码 + */ + @PostMapping("/sms/sendCode") + public AjaxResult sendSmsCode(@RequestBody Map map ) { + String phone=map.get("phone"); + // 1. 验证手机号格式(可交由前端或参数校验) + if (!phone.matches("^1[3-9]\\d{9}$")) { + return AjaxResult.error("手机号格式不正确"); + } + + // 2. 限流检查:60秒内同一手机号只能发送一次(原子操作) + String limitKey = CacheConstants.SMS_SEND_LIMIT_KEY + phone; + // 尝试设置key,如果已存在则返回false,说明60秒内已发送过 + Boolean absent = redisTemplate.opsForValue() + .setIfAbsent(limitKey, "1", Duration.ofSeconds(60)); + if (Boolean.FALSE.equals(absent)) { + // 获取剩余时间(可选,用于提示) + Long expire = redisTemplate.getExpire(limitKey, TimeUnit.SECONDS); + String msg = expire != null && expire > 0 + ? "操作过于频繁,请" + expire + "秒后再试" + : "操作过于频繁,请稍后再试"; + return AjaxResult.error(msg); + } + + + // 1. 生成6位随机验证码 + String code = String.format("%06d", new Random().nextInt(999999)); + // 2. 存入Redis,有效期1分钟 + String codeKey = CacheConstants.SMS_CODE_KEY + phone; + redisCache.setCacheObject(codeKey, code, 1, TimeUnit.MINUTES); +// redisTemplate.opsForValue().set(codeKey, code, Duration.ofMinutes(1)); + // 3. 发送短信 + try { + smsService.sendSmsCode(phone, code); + return AjaxResult.success("验证码发送成功"); + } catch (Exception e) { + // 发送失败:删除验证码(避免无效验证码被使用) + redisTemplate.delete(codeKey); + // 删除限流key,允许用户立即重试 + redisTemplate.delete(limitKey); + return AjaxResult.error(e.getMessage()); + } + } /** * 生成验证码 @@ -97,7 +165,333 @@ public class CaptchaController ajax.put("img", Base64.encode(os.toByteArray())); return ajax; } + @Autowired + private AppAgreementService appAgreementService; + + /** + * 获取协议 + * @param type 1-用户服务协议,2-隐私政策 + * @return 协议内容 + */ + @GetMapping(value ="/api/termsOfService", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity agreementOfService(HttpServletResponse response) throws IOException { + AppAgreement agreement = appAgreementService.getLatestByType(1); + String html = buildHtmlPage(agreement, agreement.getTitle()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.valueOf("text/html; charset=UTF-8")); + return new ResponseEntity<>(html, headers, HttpStatus.OK); + } + + /** + *隐私政策 + * @param response + * @return + * @throws IOException + */ + @GetMapping(value ="/api/privacyPolicy", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity privacyPolicy(HttpServletResponse response) throws IOException { + AppAgreement agreement = appAgreementService.getLatestByType(2); + String html = buildHtmlPage(agreement, agreement.getTitle()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.valueOf("text/html; charset=UTF-8")); + return new ResponseEntity<>(html, headers, HttpStatus.OK); + } + + /** + * 技术支持 + * @param response + * @return + * @throws IOException + */ + @GetMapping(value ="/api/technicalSupport", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity technicalSupport(HttpServletResponse response) throws IOException { + AppAgreement agreement = appAgreementService.getLatestByType(3); + String html = buildHtmlPage(agreement, agreement.getTitle()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.valueOf("text/html; charset=UTF-8")); + return new ResponseEntity<>(html, headers, HttpStatus.OK); + } + /** + * 技术支持 + * @param response + * @return + * @throws IOException + */ + @GetMapping(value ="/api/marketing", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity marketing(HttpServletResponse response) throws IOException { + AppAgreement agreement = appAgreementService.getLatestByType(5); + String html = buildHtmlPage(agreement, agreement.getTitle()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.valueOf("text/html; charset=UTF-8")); + return new ResponseEntity<>(html, headers, HttpStatus.OK); + } + + /** + * 公司信息 + * @param response + * @return + * @throws IOException + */ + @GetMapping(value ="/api/companyInformation", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity companyInformation(HttpServletResponse response) throws IOException { + AppAgreement agreement = appAgreementService.getLatestByType(4); + String html = buildHtmlPage(agreement, agreement.getTitle()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.valueOf("text/html; charset=UTF-8")); + return new ResponseEntity<>(html, headers, HttpStatus.OK); + } + + /** + * 构建完整 HTML 页面 + */ + private String buildHtmlPage(AppAgreement agreement, String title) { + String content = agreement != null ? agreement.getContent() : "

协议内容暂未发布,请联系客服。

"; + String updateDate = agreement != null ? DateUtil.format(agreement.getEffectiveDate(), "yyyy-MM-dd") : ""; + Integer type = agreement != null ? agreement.getType() : 0; + + // 判断是否为官网页面(type=5),使用更宽的容器 + boolean isCompanyPage = type == 4; + + return "\n" + + "\n" + + "\n" + + " \n" + + " \n" + + " " + escapeHtml(title) + "\n" + + " \n" + + "\n" + + "\n" + + "
\n" + + (isCompanyPage ? "" : "

" + escapeHtml(title) + "

\n") + + (agreement != null && !isCompanyPage ? "
更新日期:" + updateDate + "  生效日期:" + updateDate + "
" : "") + + "
\n" + + content + "\n" + + "
\n" + + (!isCompanyPage ? "
" + (agreement != null ? agreement.getVersion() : "") + "
" : "") + + "
\n" + + "\n" + + ""; + } + + /** + * 简单的 HTML 转义,防止 XSS + */ + private String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } public void testBindAlias() { diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java b/ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java index 2d5e123..bf5db15 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java @@ -18,6 +18,12 @@ public class CacheConstants * 验证码 redis key */ public static final String CAPTCHA_CODE_KEY = "captcha_codes:"; + public static final String SMS_CODE_KEY= "sms_codes:"; + + /** + * 短信发送频率限制缓存key前缀(60秒内限制) + */ + public static final String SMS_SEND_LIMIT_KEY = "sms_send_limit:"; /** * 参数管理 cache key diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/AppUser.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/AppUser.java index 097b6f0..21d05cd 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/AppUser.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/AppUser.java @@ -1,9 +1,6 @@ package com.ruoyi.common.core.domain.entity; -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableField; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.*; import com.fasterxml.jackson.annotation.JsonFormat; import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.utils.SecurityUtils; @@ -36,52 +33,66 @@ public class AppUser{ private String nickName; @ApiModelProperty("名称") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String userName; @ApiModelProperty("密码") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String password; @ApiModelProperty("手机号") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String phoneNumber; @ApiModelProperty("帐号启用状态:0->禁用;1->启用") private Integer status; @ApiModelProperty("头像") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String avatar; @ApiModelProperty("性别:0->未知;1->女;2->男") + @TableField(updateStrategy = FieldStrategy.IGNORED) private Integer sex; @ApiModelProperty("用户所在城市") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String city; @ApiModelProperty("用户所在省份") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String province; @ApiModelProperty("用户所在国家") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String country; @ApiModelProperty("生日") @Excel(name = "生日", width = 30, dateFormat = "yyyy-MM-dd") + @TableField(updateStrategy = FieldStrategy.IGNORED) private LocalDate birthday; @ApiModelProperty("设备id") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String registerId; @ApiModelProperty("访问/浏览门店id") private Long visitStore; @ApiModelProperty("小程序unionid") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String unionid; @ApiModelProperty("小程序openid") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String openid; /** 最后登录IP */ + @TableField(updateStrategy = FieldStrategy.IGNORED) private String loginIp; //个性签名(140字) + @TableField(updateStrategy = FieldStrategy.IGNORED) private String signature; /** 最后登录时间 */ @@ -98,9 +109,10 @@ public class AppUser{ //iOS:ios ; //鸿蒙:harmonyos ; //微信小程序:weixin; - @TableField("login_sys_platform") + @TableField(updateStrategy = FieldStrategy.IGNORED) private String loginSysPlatform; - + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date cancelTime; } diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/model/AppUserLoginBody.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/model/AppUserLoginBody.java index 0abe9a8..e7fbeb5 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/model/AppUserLoginBody.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/model/AppUserLoginBody.java @@ -41,6 +41,7 @@ public class AppUserLoginBody * 图片验证码 */ private String code; + /** * 设备id */ diff --git a/ruoyi-framework/pom.xml b/ruoyi-framework/pom.xml index 8053d42..e7a506b 100644 --- a/ruoyi-framework/pom.xml +++ b/ruoyi-framework/pom.xml @@ -47,7 +47,16 @@ - + + com.aliyun + aliyun-java-sdk-core + 4.6.3 + + + com.aliyun + aliyun-java-sdk-dysmsapi + 2.2.1 + com.github.oshi @@ -61,4 +70,4 @@ - \ No newline at end of file + diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java index 411ba43..28f8141 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.ruoyi.framework.config; +import com.ruoyi.framework.sms.SmsCodeAuthenticationProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; @@ -73,6 +74,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter @Autowired private PermitAllUrlProperties permitAllUrl; + @Autowired + private SmsCodeAuthenticationProvider smsCodeAuthenticationProvider; /** * 解决 无法直接注入 AuthenticationManager * @@ -141,9 +144,17 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter .antMatchers("/login", "/register", "/captchaImage", + "/sms/sendCode", "/getStoreList", "/api/login", - "/api/register" ).anonymous() + "/api/sms/login", + "/api/register" , + "/api/termsOfService" , + "/api/privacyPolicy", + "/api/technicalSupport", + "/api/companyInformation", + "/api/marketing" + ).anonymous() // 静态资源,可匿名访问 .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll() .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll() @@ -179,6 +190,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { + auth.authenticationProvider(smsCodeAuthenticationProvider); auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder()); } diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsCodeAuthenticationProvider.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsCodeAuthenticationProvider.java new file mode 100644 index 0000000..6a49650 --- /dev/null +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsCodeAuthenticationProvider.java @@ -0,0 +1,69 @@ +package com.ruoyi.framework.sms; + +import com.ruoyi.common.constant.CacheConstants; +import com.ruoyi.common.core.redis.RedisCache; +import com.ruoyi.common.exception.ServiceException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +public class SmsCodeAuthenticationProvider implements AuthenticationProvider { + + @Autowired + private RedisCache redisCache; + + @Autowired + private SmsUserDetailsService smsUserDetailsService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + // 1. 获取手机号和验证码 + String phoneNumber = (String) authentication.getPrincipal(); + String smsCode = (String) authentication.getCredentials(); + + // 2. 从Redis中校验验证码 + String cacheKey = CacheConstants.SMS_CODE_KEY + phoneNumber; + String cachedCode = redisCache.getCacheObject(cacheKey); + if (cachedCode == null) { + throw new BadCredentialsException("验证码已过期,请重新获取"); + } + if (!cachedCode.equals(smsCode)) { + throw new BadCredentialsException("验证码错误"); + } + + // 3. 验证通过,删除验证码(一次性) + redisCache.deleteObject(cacheKey); + + // 4. 加载用户信息 + UserDetails userDetails; + try { + userDetails = smsUserDetailsService.loadUserByUsername(phoneNumber); + } catch (UsernameNotFoundException e) { + throw new ServiceException("该手机号未注册,请先注册"); + } + + // 5. 构建已认证的Token + SmsCodeAuthenticationToken authenticatedToken = new SmsCodeAuthenticationToken( + userDetails, + userDetails.getAuthorities() + ); + // 设置详细信息(IP等) + authenticatedToken.setDetails(authentication.getDetails()); + + return authenticatedToken; + } + + @Override + public boolean supports(Class authentication) { + return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsCodeAuthenticationToken.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsCodeAuthenticationToken.java new file mode 100644 index 0000000..d35a1c3 --- /dev/null +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsCodeAuthenticationToken.java @@ -0,0 +1,52 @@ +package com.ruoyi.framework.sms; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken { + + private static final long serialVersionUID = 1L; + + private final Object principal; // 手机号 + private Object credentials; // 验证码(认证后清空) + + /** + * 未认证的构造函数 + */ + public SmsCodeAuthenticationToken(String phoneNumber, String smsCode) { + super(null); + this.principal = phoneNumber; + this.credentials = smsCode; + setAuthenticated(false); + } + + /** + * 已认证的构造函数 + */ + public SmsCodeAuthenticationToken(Object principal, Collection authorities) { + super(authorities); + this.principal = principal; + this.credentials = null; + super.setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return credentials; + } + + @Override + public Object getPrincipal() { + return principal; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { + if (isAuthenticated) { + throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); + } + super.setAuthenticated(false); + } +} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsService.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsService.java new file mode 100644 index 0000000..f7a3f33 --- /dev/null +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsService.java @@ -0,0 +1,59 @@ +package com.ruoyi.framework.sms; + +import com.aliyuncs.DefaultAcsClient; +import com.aliyuncs.IAcsClient; +import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest; +import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse; +import com.aliyuncs.exceptions.ClientException; +import com.aliyuncs.profile.DefaultProfile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SmsService { + private static final Logger log = LoggerFactory.getLogger(SmsService.class); + + + private String accessKeyId = "LTAI5tABg9V5VaXFDcZW4LLk"; + + + private String accessKeySecret = "xp5k5fwnqfCqZe2K5IBI1h5xPEN45V"; + + + private String signName = "沈阳润芃智能科技"; + + + private String templateCode = "SMS_337755625"; + + /** + * 发送短信验证码 + * @param phone 手机号 + * @param code 验证码 + * @throws RuntimeException 发送失败时抛出 + */ + public void sendSmsCode(String phone, String code) { + DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", accessKeyId, accessKeySecret); + IAcsClient client = new DefaultAcsClient(profile); + + SendSmsRequest request = new SendSmsRequest(); + request.setPhoneNumbers(phone); + request.setSignName(signName); + request.setTemplateCode(templateCode); + request.setTemplateParam("{\"code\":\"" + code + "\"}"); + + try { + SendSmsResponse response = client.getAcsResponse(request); + if (!"OK".equals(response.getCode())) { + log.error("短信发送失败,code={}, message={}", response.getCode(), response.getMessage()); + throw new RuntimeException("短信发送失败:" + response.getMessage()); + } + log.info("短信发送成功,手机号:{},验证码:{}", phone, code); + } catch (ClientException e) { +// } catch (Exception e) { + log.error("调用阿里云短信API异常", e); + throw new RuntimeException("短信服务异常,请稍后重试"); + } + } +} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsUserDetailsService.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsUserDetailsService.java new file mode 100644 index 0000000..7d1aaaa --- /dev/null +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/sms/SmsUserDetailsService.java @@ -0,0 +1,87 @@ +package com.ruoyi.framework.sms; + +import cn.hutool.core.util.ObjectUtil; +import com.ruoyi.basic.service.impl.YjAppUserServiceImpl; +import com.ruoyi.common.core.domain.entity.AppUser; +import com.ruoyi.common.core.domain.entity.SysRole; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.AppLoginUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.course.service.IScStudentService; +import com.ruoyi.system.service.ISysUserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +public class SmsUserDetailsService implements UserDetailsService { + + private static final Logger log = LoggerFactory.getLogger(SmsUserDetailsService.class); + + @Autowired + private YjAppUserServiceImpl userService; + + @Autowired + private ISysUserService sysUserService; + + @Autowired + private IScStudentService studentService; + + @Override + public UserDetails loadUserByUsername(String phonenumber) throws UsernameNotFoundException { + // 通过手机号查询用户(需在SysUserService中增加按手机号查询的方法) + AppUser user = userService.selectAppUserByPhone(phonenumber); + if (StringUtils.isNull(user)) + { + log.info("登录用户:{} 不存在.", phonenumber); + throw new ServiceException("登录用户:" + phonenumber + " 不存在"); + } + else if (user.getStatus() == 0) + { + log.info("登录用户:{} 已被停用.", phonenumber); + throw new ServiceException("对不起,您的账号:" + phonenumber + " 已停用"); + } + List roles=new ArrayList<>(); + roles.add(107l); + if (ObjectUtil.isNotEmpty(user.getManageAccountId())){ + //todo 角色ID;顾问:103 ;教练:104 ;店长:105 ;普通用户:107; + + SysUser sysUser= sysUserService.selectUserById(user.getManageAccountId()); + if (ObjectUtil.isEmpty(sysUser)){ + log.info("登录用户:{} 已被停用.", phonenumber); + throw new ServiceException("对不起,您的账号:" + phonenumber + " 所属租户异常,请联系管理员!"); + } + List roles1=sysUser.getRoles(); + for (SysRole sysRole : roles1) { + roles.add(sysRole.getRoleId()); + } + } + Long studentId=studentService.getStudentByAppUserId(user.getId()); + +// Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext(); +// String password = usernamePasswordAuthenticationToken.getCredentials().toString(); +// if (!SecurityUtils.matchesPassword(password,user.getPassword())){ +// throw new RuntimeException(Constants.LOGIN_INFO.WRONG); +// } + +// SysUser user1=new SysUser(); +// user1.setPassword(user.getPassword()); +// passwordService.validate(user1); + + return createLoginUser(user,roles.toArray(new Long[roles.size()]) ,studentId); + } + + public UserDetails createLoginUser(AppUser user, Long[] roles,Long studentId) + { + return new AppLoginUser(user.getId(),user.getUserName(), user.getManageAccountId(),roles,studentId, user); + } +} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/AppLoginService.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/AppLoginService.java index e644bf4..7da8528 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/AppLoginService.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/AppLoginService.java @@ -24,6 +24,7 @@ import com.ruoyi.common.utils.ip.IpUtils; import com.ruoyi.framework.manager.AsyncManager; import com.ruoyi.framework.manager.factory.AsyncFactory; import com.ruoyi.framework.security.context.AuthenticationContextHolder; +import com.ruoyi.framework.sms.SmsCodeAuthenticationToken; import com.ruoyi.mall.util.PayConstants; import com.ruoyi.system.service.ISysConfigService; import com.ruoyi.system.service.ISysUserService; @@ -66,6 +67,8 @@ public class AppLoginService @Qualifier("AppUseAuthenticationManager") private AuthenticationManager appUserAuthenticationManager; + @Autowired + private AuthenticationManager authenticationManager; /** * 登录验证 * @@ -130,6 +133,54 @@ public class AppLoginService return tokenService.createAppToken(loginUser); } + /** + * 短信验证码登录(App端) + * + * @param phoneNumber 手机号 + * @param smsCode 短信验证码 + * @param registerId 设备注册ID(可选) + * @param miniAppCode 小程序登录凭证(可选,用于获取openId) + * @return 登录Token + */ + public String loginBySms(String phoneNumber, String smsCode, String registerId, String miniAppCode) { + // ========== 1. 校验短信验证码 ========== + // 1. 构造未认证的Token + SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(phoneNumber, smsCode); + + // 2. 调用AuthenticationManager认证 + Authentication authentication = authenticationManager.authenticate(authRequest); + + // 3. 若认证成功,生成Token + AppLoginUser loginUser = (AppLoginUser) authentication.getPrincipal(); + + boolean update=false; + UpdateWrapper wrapper=new UpdateWrapper().eq("app_user_id",loginUser.getAppUserId()); + + //设备id + if (StrUtil.isNotEmpty(loginUser.getAppUser().getRegisterId())&&!loginUser.getAppUser().getRegisterId().equals(registerId)){ + wrapper.set("register_id",registerId); + update=true; + } + //小程序登录 + if (StrUtil.isNotEmpty(miniAppCode) && StrUtil.isEmpty(loginUser.getAppUser().getOpenid())){ + + HashMap map=getOpenId(miniAppCode); + wrapper.set(ObjectUtil.isNotEmpty(map.get("openId")),"openid",map.get("openId").toString()); + wrapper.set(ObjectUtil.isNotEmpty(map.get("unionId")),"unionid",map.get("unionId").toString()); + update=true; + } + if (update){ + appUserService.update(wrapper); + } + + //将登录信息存入到数据库 + wrapper.set("login_ip",IpUtils.getIpAddr(ServletUtils.getRequest())); + wrapper.set("login_date",DateUtils.getNowDate()); + appUserService.update(wrapper); + + // ========== 6. 生成并返回Token ========== + return tokenService.createAppToken(loginUser); + } /** * 校验验证码 * diff --git a/ruoyi-system/src/main/java/com/ruoyi/basic/domain/AppAgreement.java b/ruoyi-system/src/main/java/com/ruoyi/basic/domain/AppAgreement.java new file mode 100644 index 0000000..5498ae5 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/basic/domain/AppAgreement.java @@ -0,0 +1,48 @@ +package com.ruoyi.basic.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 应用协议实体类 + * @TableName 指定数据库表名 + */ +@Data +@TableName("app_agreements") +public class AppAgreement { + + @TableId(type = IdType.AUTO) + private Integer id; + + /** + * 类型:1-用户服务协议,2-隐私政策 + */ + private Integer type; + + /** + * 协议标题 + */ + private String title; + + /** + * 协议内容(富文本HTML) + */ + private String content; + + /** + * 版本号,如 v3.2 + */ + private String version; + + /** + * 生效日期 + */ + private LocalDateTime effectiveDate; + + /** + * 创建时间(自动填充) + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/basic/mapper/AppAgreementMapper.java b/ruoyi-system/src/main/java/com/ruoyi/basic/mapper/AppAgreementMapper.java new file mode 100644 index 0000000..d044f56 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/basic/mapper/AppAgreementMapper.java @@ -0,0 +1,10 @@ +package com.ruoyi.basic.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ruoyi.basic.domain.AppAgreement; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AppAgreementMapper extends BaseMapper { + // 无需额外方法,使用 MyBatis-Plus 提供的条件构造器即可 +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/basic/service/AppAgreementService.java b/ruoyi-system/src/main/java/com/ruoyi/basic/service/AppAgreementService.java new file mode 100644 index 0000000..7e76710 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/basic/service/AppAgreementService.java @@ -0,0 +1,14 @@ +package com.ruoyi.basic.service; + + +import com.ruoyi.basic.domain.AppAgreement; + +public interface AppAgreementService { + + /** + * 根据类型获取当前生效的最新协议 + * @param type 1-用户服务协议,2-隐私政策 + * @return 最新协议,若无则返回 null + */ + AppAgreement getLatestByType(Integer type); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/basic/service/impl/AppAgreementServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/basic/service/impl/AppAgreementServiceImpl.java new file mode 100644 index 0000000..d729ba8 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/basic/service/impl/AppAgreementServiceImpl.java @@ -0,0 +1,28 @@ +package com.ruoyi.basic.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.basic.domain.AppAgreement; +import com.ruoyi.basic.mapper.AppAgreementMapper; +import com.ruoyi.basic.service.AppAgreementService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +public class AppAgreementServiceImpl implements AppAgreementService { + + @Autowired + private AppAgreementMapper appAgreementMapper; + + @Override + public AppAgreement getLatestByType(Integer type) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppAgreement::getType, type) + .le(AppAgreement::getEffectiveDate, LocalDateTime.now()) // 仅查询已生效的 + .orderByDesc(AppAgreement::getEffectiveDate) // 按生效日期降序 + .orderByDesc(AppAgreement::getVersion) // 辅助按版本号降序(可选) + .last("LIMIT 1"); // 只取一条 + return appAgreementMapper.selectOne(wrapper); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/course/service/impl/ScClaTimeServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/course/service/impl/ScClaTimeServiceImpl.java index 9ff67f9..e1365ec 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/course/service/impl/ScClaTimeServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/course/service/impl/ScClaTimeServiceImpl.java @@ -740,6 +740,10 @@ public class ScClaTimeServiceImpl extends ServiceImpll.getCheckIn()==0).count(); if (notCheckIn>0){ + //todo 排除无需签到的学员 + //确认上课时 直接扣费 + //但要在预约的时候 判断余额 算上已预约的课程 不能超额 + //在后台修改余额的时候 算上已预约的课程 不能透支 提醒:当前会员卡余额或被预约占用,剩余额度不足; 无法修改 return new RestResponse().setSuccess(false).setMessage("请确认学员签到情况,如无法到课请联系学员取消预约!"); } //签到学员 @@ -921,39 +925,6 @@ public class ScClaTimeServiceImpl extends ServiceImpl diff --git a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml index 5b7340d..2c979fe 100644 --- a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -58,6 +58,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where u.user_name = #{userName} and u.delete_flag = '0' + +