feat: 支持手机号登录(演示环境不开放)

1.在个人中心-安全设置中绑手机号后,才支持手机号登录
2.SMS4J(短信聚合框架,轻松集成多家短信服务,解决接入多个短信 SDK 的繁琐流程)
This commit is contained in:
Charles7c 2023-10-27 21:32:25 +08:00
parent 2f2905efdc
commit 4d70bc84db
27 changed files with 780 additions and 126 deletions

View File

@ -218,29 +218,30 @@ pnpm dev
## 核心技术栈 ## 核心技术栈
| 名称 | 版本 | 简介 | | 名称 | 版本 | 简介 |
| :----------------------------------------------------------- | :----------- | :----------------------------------------------------------- | |:----------------------------------------------------------------------------------------------------------------------------------|:-------------| :----------------------------------------------------------- |
| <a href="https://cn.vuejs.org/" target="_blank">Vue</a> | 3.3.4 | 渐进式 JavaScript 框架,易学易用,性能出色,适用场景丰富的 Web 前端框架。 | | <a href="https://cn.vuejs.org/" target="_blank">Vue</a> | 3.3.4 | 渐进式 JavaScript 框架,易学易用,性能出色,适用场景丰富的 Web 前端框架。 |
| <a href="https://www.typescriptlang.org/zh/" target="_blank">TypeScript</a> | 4.9.5 | TypeScript 是微软开发的一个开源的编程语言,通过在 JavaScript 的基础上添加静态类型定义构建而成。 | | <a href="https://www.typescriptlang.org/zh/" target="_blank">TypeScript</a> | 4.9.5 | TypeScript 是微软开发的一个开源的编程语言,通过在 JavaScript 的基础上添加静态类型定义构建而成。 |
| <a href="https://arco.design/vue/docs/start" target="_blank">Arco Design Vue</a> | 2.52.0 | 字节跳动推出的前端 UI 框架,样式美观,组件丰富。 | | <a href="https://arco.design/vue/docs/start" target="_blank">Arco Design Vue</a> | 2.52.0 | 字节跳动推出的前端 UI 框架,样式美观,组件丰富。 |
| <a href="https://spring.io/projects/spring-boot" target="_blank">Spring Boot</a> | 2.7.16 | 简化新 Spring 应用的初始搭建以及开发过程。 | | <a href="https://spring.io/projects/spring-boot" target="_blank">Spring Boot</a> | 2.7.16 | 简化新 Spring 应用的初始搭建以及开发过程。 |
| <a href="https://undertow.io/" target="_blank">Undertow</a> | 2.2.26.Final | 采用 Java 开发的灵活的高性能 Web 服务器,提供包括阻塞和基于 NIO 的非堵塞机制。 | | <a href="https://undertow.io/" target="_blank">Undertow</a> | 2.2.26.Final | 采用 Java 开发的灵活的高性能 Web 服务器,提供包括阻塞和基于 NIO 的非堵塞机制。 |
| <a href="https://sa-token.dev33.cn/" target="_blank">Sa-Token + JWT</a> | 1.36.0 | 轻量级 Java 权限认证框架,让鉴权变得简单、优雅。 | | <a href="https://sa-token.dev33.cn/" target="_blank">Sa-Token + JWT</a> | 1.36.0 | 轻量级 Java 权限认证框架,让鉴权变得简单、优雅。 |
| <a href="https://mariadb.org/" target="_blank">MariaDB</a> | 10.10.2 | MySQL 的一个分支,主要由开源社区在维护,完全兼容 MySQL包括 API 和命令行,能轻松成为 MySQL 的代替品。 | | <a href="https://mariadb.org/" target="_blank">MariaDB</a> | 10.10.2 | MySQL 的一个分支,主要由开源社区在维护,完全兼容 MySQL包括 API 和命令行,能轻松成为 MySQL 的代替品。 |
| <a href="https://baomidou.com/" target="_blank">MyBatis Plus</a> | 3.5.3.2 | MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,简化开发、提高效率。 | | <a href="https://baomidou.com/" target="_blank">MyBatis Plus</a> | 3.5.3.2 | MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,简化开发、提高效率。 |
| <a href="https://www.kancloud.cn/tracy5546/dynamic-datasource/2264611" target="_blank">dynamic-datasource-spring-boot-starter</a> | 3.6.1 | 基于 Spring Boot 的快速集成多数据源的启动器。 | | <a href="https://www.kancloud.cn/tracy5546/dynamic-datasource/2264611" target="_blank">dynamic-datasource-spring-boot-starter</a> | 3.6.1 | 基于 Spring Boot 的快速集成多数据源的启动器。 |
| Hikari | 4.0.3 | JDBC 连接池,号称 “史上最快连接池”SpringBoot 在 2.0 之后,采用的默认数据库连接池就是 Hikari。 | | Hikari | 4.0.3 | JDBC 连接池,号称 “史上最快连接池”SpringBoot 在 2.0 之后,采用的默认数据库连接池就是 Hikari。 |
| <a href="https://dev.mysql.com/doc/connector-j/8.0/en/" target="_blank">mysql-connector-j</a> | 8.0.33 | MySQL Java 驱动。 | | <a href="https://dev.mysql.com/doc/connector-j/8.0/en/" target="_blank">mysql-connector-j</a> | 8.0.33 | MySQL Java 驱动。 |
| <a href="https://github.com/p6spy/p6spy" target="_blank">P6Spy</a> | 3.9.1 | SQL 性能分析组件。 | | <a href="https://github.com/p6spy/p6spy" target="_blank">P6Spy</a> | 3.9.1 | SQL 性能分析组件。 |
| <a href="https://github.com/liquibase/liquibase" target="_blank">Liquibase</a> | 4.9.1 | 用于管理数据库版本,跟踪、管理和应用数据库变化。 | | <a href="https://github.com/liquibase/liquibase" target="_blank">Liquibase</a> | 4.9.1 | 用于管理数据库版本,跟踪、管理和应用数据库变化。 |
| <a href="https://redis.io/" target="_blank">Redis</a> | 6.2.7 | 高性能的 key-value 数据库。 | | <a href="https://redis.io/" target="_blank">Redis</a> | 6.2.7 | 高性能的 key-value 数据库。 |
| <a href="https://github.com/redisson/redisson/wiki/Redisson%E9%A1%B9%E7%9B%AE%E4%BB%8B%E7%BB%8D" target="_blank">Redisson</a> | 3.20.1 | 不仅仅是一个 Redis Java 客户端,同其他 Redis Java 客户端有着很大的区别,相比之下其他客户端提供的功能还仅仅停留在作为数据库驱动层面上,比如仅针对 Redis 提供连接方式,发送命令和处理返回结果等。而 Redisson 充分的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。 | | <a href="https://github.com/redisson/redisson/wiki/Redisson%E9%A1%B9%E7%9B%AE%E4%BB%8B%E7%BB%8D" target="_blank">Redisson</a> | 3.20.1 | 不仅仅是一个 Redis Java 客户端,同其他 Redis Java 客户端有着很大的区别,相比之下其他客户端提供的功能还仅仅停留在作为数据库驱动层面上,比如仅针对 Redis 提供连接方式,发送命令和处理返回结果等。而 Redisson 充分的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。 |
| <a href="https://justauth.cn/" target="_blank">Just Auth</a> | 1.16.5 | 开箱即用的整合第三方登录的开源组件,脱离繁琐的第三方登录 SDK让登录变得 So easy | | <a href="https://sms4j.com/" target="_blank">SMS4J</a> | 3.0.3 | 短信聚合框架,轻松集成多家短信服务,解决接入多个短信 SDK 的繁琐流程。 |
| <a href="https://easyexcel.opensource.alibaba.com/" target="_blank">Easy Excel</a> | 3.3.2 | 一个基于 Java 的、快速、简洁、解决大文件内存溢出的 Excel 处理工具。 | | <a href="https://justauth.cn/" target="_blank">Just Auth</a> | 1.16.5 | 开箱即用的整合第三方登录的开源组件,脱离繁琐的第三方登录 SDK让登录变得 So easy |
| Easy Captcha | 1.6.2 | Java 图形验证码,支持 gif、中文、算术等类型可用于 Java Web、JavaSE 等项目。 | | <a href="https://easyexcel.opensource.alibaba.com/" target="_blank">Easy Excel</a> | 3.3.2 | 一个基于 Java 的、快速、简洁、解决大文件内存溢出的 Excel 处理工具。 |
| <a href="https://doc.xiaominfo.com/" target="_blank">Knife4j</a> | 4.3.0 | 前身是 swagger-bootstrap-ui集 Swagger2 和 OpenAPI3 为一体的增强解决方案。本项目使用的是 <a href="https://gitee.com/xiaoym/swagger-bootstrap-ui-demo/tree/master/knife4j-springdoc-openapi-demo" target="_blank">knife4j-openapi3-spring-boot-starter</a> 基于 OpenAPI3 规范,在 Spring Boot < 3.0.0-M1 的单体架构下可以直接引用此 starter该模块包含了 UI 部分底层基于 springdoc-openapi 项目 | | Easy Captcha | 1.6.2 | Java 图形验证码,支持 gif、中文、算术等类型可用于 Java Web、JavaSE 等项目。 |
| <a href="https://www.hutool.cn/" target="_blank">Hutool</a> | 5.8.22 | 小而全的 Java 工具类库,通过静态方法封装,降低相关 API 的学习成本,提高工作效率,使 Java 拥有函数式语言般的优雅,让 Java 语言也可以“甜甜的”。 | | <a href="https://doc.xiaominfo.com/" target="_blank">Knife4j</a> | 4.3.0 | 前身是 swagger-bootstrap-ui集 Swagger2 和 OpenAPI3 为一体的增强解决方案。本项目使用的是 <a href="https://gitee.com/xiaoym/swagger-bootstrap-ui-demo/tree/master/knife4j-springdoc-openapi-demo" target="_blank">knife4j-openapi3-spring-boot-starter</a> 基于 OpenAPI3 规范,在 Spring Boot < 3.0.0-M1 的单体架构下可以直接引用此 starter该模块包含了 UI 部分底层基于 springdoc-openapi 项目 |
| <a href="https://projectlombok.org/" target="_blank">Lombok</a> | 1.18.30 | 在 Java 开发过程中用注解的方式,简化了 JavaBean 的编写,避免了冗余和样板式代码,让编写的类更加简洁。 | | <a href="https://www.hutool.cn/" target="_blank">Hutool</a> | 5.8.22 | 小而全的 Java 工具类库,通过静态方法封装,降低相关 API 的学习成本,提高工作效率,使 Java 拥有函数式语言般的优雅,让 Java 语言也可以“甜甜的”。 |
| <a href="https://projectlombok.org/" target="_blank">Lombok</a> | 1.18.30 | 在 Java 开发过程中用注解的方式,简化了 JavaBean 的编写,避免了冗余和样板式代码,让编写的类更加简洁。 |
## 项目结构 ## 项目结构

View File

@ -115,6 +115,12 @@ limitations under the License.
</dependency> </dependency>
<!-- ################ 工具库相关 ################ --> <!-- ################ 工具库相关 ################ -->
<!-- SMS4J短信聚合框架轻松集成多家短信服务解决接入多个短信 SDK 的繁琐流程) -->
<dependency>
<groupId>org.dromara.sms4j</groupId>
<artifactId>sms4j-spring-boot-starter</artifactId>
</dependency>
<!-- Just Auth开箱即用的整合第三方登录的开源组件脱离繁琐的第三方登录 SDK让登录变得 So easy! --> <!-- Just Auth开箱即用的整合第三方登录的开源组件脱离繁琐的第三方登录 SDK让登录变得 So easy! -->
<dependency> <dependency>
<groupId>com.xkcoding.justauth</groupId> <groupId>com.xkcoding.justauth</groupId>

View File

@ -52,6 +52,11 @@ public class CaptchaProperties {
*/ */
private CaptchaMail mail; private CaptchaMail mail;
/**
* 短信验证码配置
*/
private CaptchaSms sms;
/** /**
* 图片验证码配置 * 图片验证码配置
*/ */
@ -133,6 +138,32 @@ public class CaptchaProperties {
private String templatePath; private String templatePath;
} }
/**
* 短信验证码配置
*/
@Data
public static class CaptchaSms {
/**
* 内容长度
*/
private int length;
/**
* 过期时间
*/
private long expirationInMinutes;
/**
* 限制时间
*/
private long limitInSeconds;
/**
* 模板 ID
*/
private String templateId;
}
/** /**
* 图片验证码类型枚举 * 图片验证码类型枚举
*/ */

View File

@ -0,0 +1,59 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.auth.model.request;
import java.io.Serializable;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import lombok.Data;
import io.swagger.v3.oas.annotations.media.Schema;
import org.hibernate.validator.constraints.Length;
import top.charles7c.cnadmin.common.constant.RegexConsts;
/**
* 手机号登录信息
*
* @author Charles7c
* @since 2023/10/26 22:37
*/
@Data
@Schema(description = "手机号登录信息")
public class PhoneLoginRequest implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 手机号
*/
@Schema(description = "手机号", example = "13811111111")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = RegexConsts.MOBILE, message = "手机号格式错误")
private String phone;
/**
* 验证码
*/
@Schema(description = "验证码", example = "8888")
@NotBlank(message = "验证码不能为空")
@Length(max = 4, message = "验证码非法")
private String captcha;
}

View File

@ -41,6 +41,15 @@ public interface LoginService {
*/ */
String accountLogin(String username, String password); String accountLogin(String username, String password);
/**
* 手机号登录
*
* @param phone
* 手机号
* @return 令牌
*/
String phoneLogin(String phone);
/** /**
* 邮箱登录 * 邮箱登录
* *

View File

@ -85,6 +85,14 @@ public class LoginServiceImpl implements LoginService {
return this.login(user); return this.login(user);
} }
@Override
public String phoneLogin(String phone) {
UserDO user = userService.getByPhone(phone);
CheckUtils.throwIfNull(user, "此手机号未绑定本系统账号");
this.checkUserStatus(user);
return this.login(user);
}
@Override @Override
public String emailLogin(String email) { public String emailLogin(String email) {
UserDO user = userService.getByEmail(email); UserDO user = userService.getByEmail(email);

View File

@ -40,6 +40,16 @@ public interface UserMapper extends DataPermissionMapper<UserDO> {
@Select("SELECT * FROM `sys_user` WHERE `username` = #{username}") @Select("SELECT * FROM `sys_user` WHERE `username` = #{username}")
UserDO selectByUsername(@Param("username") String username); UserDO selectByUsername(@Param("username") String username);
/**
* 根据手机号查询
*
* @param phone
* 手机号
* @return 用户信息
*/
@Select("SELECT * FROM `sys_user` WHERE `phone` = #{phone}")
UserDO selectByPhone(@Param("phone") String phone);
/** /**
* 根据邮箱查询 * 根据邮箱查询
* *

View File

@ -0,0 +1,67 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.system.model.request;
import java.io.Serializable;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import lombok.Data;
import io.swagger.v3.oas.annotations.media.Schema;
import org.hibernate.validator.constraints.Length;
import top.charles7c.cnadmin.common.constant.RegexConsts;
/**
* 用户手机号修改信息
*
* @author Charles7c
* @since 2023/10/27 20:11
*/
@Data
@Schema(description = "用户手机号修改信息")
public class UserPhoneUpdateRequest implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 新手机号
*/
@Schema(description = "新手机号", example = "13811111111")
@NotBlank(message = "新手机号不能为空")
@Pattern(regexp = RegexConsts.MOBILE, message = "手机号格式错误")
private String newPhone;
/**
* 验证码
*/
@Schema(description = "验证码", example = "8888")
@NotBlank(message = "验证码不能为空")
@Length(max = 4, message = "验证码非法")
private String captcha;
/**
* 当前密码加密后
*/
@Schema(description = "当前密码(加密后)",
example = "SYRLSszQGcMv4kP2Yolou9zf28B9GDakR9u91khxmR7V++i5A384kwnNZxqgvT6bjT4zqpIDuMFLWSt92hQJJA==")
@NotBlank(message = "当前密码不能为空")
private String currentPassword;
}

View File

@ -79,6 +79,18 @@ public interface UserService extends BaseService<UserVO, UserDetailVO, UserQuery
*/ */
void updatePassword(String oldPassword, String newPassword, Long id); void updatePassword(String oldPassword, String newPassword, Long id);
/**
* 修改手机号
*
* @param newPhone
* 新手机号
* @param currentPassword
* 当前密码
* @param id
* ID
*/
void updatePhone(String newPhone, String currentPassword, Long id);
/** /**
* 修改邮箱 * 修改邮箱
* *
@ -118,6 +130,15 @@ public interface UserService extends BaseService<UserVO, UserDetailVO, UserQuery
*/ */
UserDO getByUsername(String username); UserDO getByUsername(String username);
/**
* 根据手机号查询
*
* @param phone
* 手机号
* @return 用户信息
*/
UserDO getByPhone(String phone);
/** /**
* 根据邮箱查询 * 根据邮箱查询
* *

View File

@ -210,6 +210,17 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, UserDO, UserVO,
.set(UserDO::getPwdResetTime, now).eq(UserDO::getId, id).update(); .set(UserDO::getPwdResetTime, now).eq(UserDO::getId, id).update();
} }
@Override
public void updatePhone(String newPhone, String currentPassword, Long id) {
UserDO user = super.getById(id);
CheckUtils.throwIfNotEqual(SecureUtils.md5Salt(currentPassword, id.toString()), user.getPassword(), "当前密码错误");
Long count = baseMapper.lambdaQuery().eq(UserDO::getPhone, newPhone).count();
CheckUtils.throwIf(count > 0, "手机号已绑定其他账号,请更换其他手机号");
CheckUtils.throwIfEqual(newPhone, user.getPhone(), "新手机号不能与当前手机号相同");
// 更新手机号
baseMapper.lambdaUpdate().set(UserDO::getPhone, newPhone).eq(UserDO::getId, id).update();
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void updateEmail(String newEmail, String currentPassword, Long id) { public void updateEmail(String newEmail, String currentPassword, Long id) {
@ -244,6 +255,11 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, UserDO, UserVO,
return baseMapper.selectByUsername(username); return baseMapper.selectByUsername(username);
} }
@Override
public UserDO getByPhone(String phone) {
return baseMapper.selectByPhone(phone);
}
@Override @Override
public UserDO getByEmail(String email) { public UserDO getByEmail(String email) {
return baseMapper.selectByEmail(email); return baseMapper.selectByEmail(email);

View File

@ -4,8 +4,7 @@ import { UserState } from '@/store/modules/user/types';
const BASE_URL = '/auth'; const BASE_URL = '/auth';
export interface LoginReq { export interface AccountLoginReq {
phone?: string;
username?: string; username?: string;
password?: string; password?: string;
captcha: string; captcha: string;
@ -16,7 +15,7 @@ export interface LoginRes {
token: string; token: string;
} }
export function accountLogin(req: LoginReq) { export function accountLogin(req: AccountLoginReq) {
return axios.post<LoginRes>(`${BASE_URL}/account`, req); return axios.post<LoginRes>(`${BASE_URL}/account`, req);
} }
@ -29,6 +28,15 @@ export function emailLogin(req: EmailLoginReq) {
return axios.post<LoginRes>(`${BASE_URL}/email`, req); return axios.post<LoginRes>(`${BASE_URL}/email`, req);
} }
export interface PhoneLoginReq {
phone: string;
captcha: string;
}
export function phoneLogin(req: PhoneLoginReq) {
return axios.post<LoginRes>(`${BASE_URL}/phone`, req);
}
export function logout() { export function logout() {
return axios.post(`${BASE_URL}/logout`); return axios.post(`${BASE_URL}/logout`);
} }

View File

@ -1,22 +1,19 @@
import axios from 'axios'; import axios from 'axios';
import qs from 'query-string';
const BASE_URL = '/common/captcha';
export interface ImageCaptchaRes { export interface ImageCaptchaRes {
uuid: string; uuid: string;
img: string; img: string;
} }
export function getImageCaptcha() { export function getImageCaptcha() {
return axios.get<ImageCaptchaRes>('/common/captcha/img'); return axios.get<ImageCaptchaRes>(`${BASE_URL}/img`);
} }
export interface MailCaptchaReq { export function getMailCaptcha(email: string) {
email: string; return axios.get(`${BASE_URL}/mail?email=${email}`);
} }
export function getMailCaptcha(params: MailCaptchaReq) {
return axios.get('/common/captcha/mail', { export function getSmsCaptcha(phone: string) {
params, return axios.get(`${BASE_URL}/sms?phone=${phone}`);
paramsSerializer: (obj) => {
return qs.stringify(obj);
},
});
} }

View File

@ -16,31 +16,41 @@ export function uploadAvatar(data: FormData) {
return axios.post<AvatarRes>(`${BASE_URL}/avatar`, data); return axios.post<AvatarRes>(`${BASE_URL}/avatar`, data);
} }
export interface UpdateBasicInfoReq { export interface UserBasicInfoUpdateReq {
nickname: string; nickname: string;
gender: number; gender: number;
} }
export function updateBasicInfo(req: UpdateBasicInfoReq) { export function updateBasicInfo(req: UserBasicInfoUpdateReq) {
return axios.patch(`${BASE_URL}/basic/info`, req); return axios.patch(`${BASE_URL}/basic/info`, req);
} }
export interface UpdatePasswordReq { export interface UserPasswordUpdateReq {
oldPassword: string; oldPassword: string;
newPassword: string; newPassword: string;
} }
export function updatePassword(req: UpdatePasswordReq) { export function updatePassword(req: UserPasswordUpdateReq) {
return axios.patch(`${BASE_URL}/password`, req); return axios.patch(`${BASE_URL}/password`, req);
} }
export interface UpdateEmailReq { export interface UserPhoneUpdateReq {
newPhone: string;
captcha: string;
currentPassword: string;
}
export function updatePhone(req: UserPhoneUpdateReq) {
return axios.patch(`${BASE_URL}/phone`, req);
}
export interface UserEmailUpdateReq {
newEmail: string; newEmail: string;
captcha: string; captcha: string;
currentPassword: string; currentPassword: string;
} }
export function updateEmail(req: UpdateEmailReq) { export function updateEmail(req: UserEmailUpdateReq) {
return axios.patch(`${BASE_URL}/email`, req); return axios.patch(`${BASE_URL}/email`, req);
} }

View File

@ -1,9 +1,11 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { import {
LoginReq, AccountLoginReq,
EmailLoginReq, EmailLoginReq,
PhoneLoginReq,
accountLogin as userAccountLogin, accountLogin as userAccountLogin,
emailLogin as userEmailLogin, emailLogin as userEmailLogin,
phoneLogin as userPhoneLogin,
socialLogin as userSocialLogin, socialLogin as userSocialLogin,
logout as userLogout, logout as userLogout,
getUserInfo, getUserInfo,
@ -45,7 +47,7 @@ const useUserStore = defineStore('user', {
}, },
// 账号登录 // 账号登录
async accountLogin(req: LoginReq) { async accountLogin(req: AccountLoginReq) {
try { try {
const res = await userAccountLogin(req); const res = await userAccountLogin(req);
setToken(res.data.token); setToken(res.data.token);
@ -66,6 +68,17 @@ const useUserStore = defineStore('user', {
} }
}, },
// 手机号登录
async phoneLogin(req: PhoneLoginReq) {
try {
const res = await userPhoneLogin(req);
setToken(res.data.token);
} catch (err) {
clearToken();
throw err;
}
},
// 三方账号身份登录 // 三方账号身份登录
async socialLogin(source: string, req: any) { async socialLogin(source: string, req: any) {
try { try {

View File

@ -57,7 +57,7 @@
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { LoginReq } from '@/api/auth'; import { AccountLoginReq } from '@/api/auth';
import { ValidatedError } from '@arco-design/web-vue'; import { ValidatedError } from '@arco-design/web-vue';
import { encryptByRsa } from '@/utils/encrypt'; import { encryptByRsa } from '@/utils/encrypt';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@ -81,7 +81,7 @@
password: loginConfig.value.password, password: loginConfig.value.password,
captcha: '', captcha: '',
uuid: '', uuid: '',
} as LoginReq, } as AccountLoginReq,
rules: { rules: {
username: [ username: [
{ required: true, message: t('login.account.error.required.username') }, { required: true, message: t('login.account.error.required.username') },

View File

@ -93,9 +93,7 @@
if (!valid) { if (!valid) {
captchaLoading.value = true; captchaLoading.value = true;
captchaBtnNameKey.value = 'login.captcha.ing'; captchaBtnNameKey.value = 'login.captcha.ing';
getMailCaptcha({ getMailCaptcha(form.value.email)
email: form.value.email,
})
.then((res) => { .then((res) => {
captchaLoading.value = false; captchaLoading.value = false;
captchaDisable.value = true; captchaDisable.value = true;
@ -108,10 +106,7 @@
captchaTime.value captchaTime.value
}s)`; }s)`;
if (captchaTime.value <= 0) { if (captchaTime.value <= 0) {
window.clearInterval(captchaTimer.value); resetCaptcha();
captchaTime.value = 60;
captchaBtnNameKey.value = t('login.captcha.get');
captchaDisable.value = false;
} }
}, 1000); }, 1000);
proxy.$message.success(res.msg); proxy.$message.success(res.msg);

View File

@ -6,6 +6,7 @@
layout="vertical" layout="vertical"
size="large" size="large"
class="login-form" class="login-form"
@submit="handleLogin"
> >
<a-form-item field="phone" hide-label> <a-form-item field="phone" hide-label>
<a-select :options="['+86']" style="flex: 1 1" default-value="+86" /> <a-select :options="['+86']" style="flex: 1 1" default-value="+86" />
@ -20,7 +21,7 @@
<a-input <a-input
v-model="form.captcha" v-model="form.captcha"
:placeholder="$t('login.phone.placeholder.captcha')" :placeholder="$t('login.phone.placeholder.captcha')"
:max-length="6" :max-length="4"
allow-clear allow-clear
style="flex: 1 1" style="flex: 1 1"
/> />
@ -33,8 +34,13 @@
{{ captchaBtnName }} {{ captchaBtnName }}
</a-button> </a-button>
</a-form-item> </a-form-item>
<a-button class="btn" :loading="loading" type="primary" html-type="submit" <a-button
>{{ $t('login.button') }}即将开放 class="btn"
:loading="loading"
type="primary"
html-type="submit"
:disabled="captchaDisable"
>{{ $t('login.button') }}演示不开放
</a-button> </a-button>
</a-form> </a-form>
</template> </template>
@ -42,21 +48,28 @@
<script lang="ts" setup> <script lang="ts" setup>
import { getCurrentInstance, ref, toRefs, reactive, computed } from 'vue'; import { getCurrentInstance, ref, toRefs, reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { ValidatedError } from '@arco-design/web-vue';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { LoginReq } from '@/api/auth'; import { PhoneLoginReq } from '@/api/auth';
import { getSmsCaptcha } from '@/api/common/captcha';
const { proxy } = getCurrentInstance() as any; const { proxy } = getCurrentInstance() as any;
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const loading = ref(false); const loading = ref(false);
const captchaLoading = ref(false); const captchaLoading = ref(false);
const captchaDisable = ref(false); const captchaDisable = ref(true);
const captchaTime = ref(60); const captchaTime = ref(60);
const captchaTimer = ref(); const captchaTimer = ref();
const captchaBtnNameKey = ref('login.captcha.get'); const captchaBtnNameKey = ref('login.captcha.get');
const captchaBtnName = computed(() => t(captchaBtnNameKey.value)); const captchaBtnName = computed(() => t(captchaBtnNameKey.value));
const data = reactive({ const data = reactive({
form: {} as LoginReq, form: {
phone: '',
captcha: '',
} as PhoneLoginReq,
rules: { rules: {
phone: [ phone: [
{ required: true, message: t('login.phone.error.required.phone') }, { required: true, message: t('login.phone.error.required.phone') },
@ -91,26 +104,71 @@
if (!valid) { if (!valid) {
captchaLoading.value = true; captchaLoading.value = true;
captchaBtnNameKey.value = 'login.captcha.ing'; captchaBtnNameKey.value = 'login.captcha.ing';
captchaLoading.value = false; getSmsCaptcha(form.value.phone)
captchaDisable.value = true; .then((res) => {
captchaBtnNameKey.value = `${t( captchaLoading.value = false;
'login.captcha.get' captchaDisable.value = true;
)}(${(captchaTime.value -= 1)}s)`; captchaBtnNameKey.value = `${t(
captchaTimer.value = window.setInterval(() => { 'login.captcha.get'
captchaTime.value -= 1; )}(${(captchaTime.value -= 1)}s)`;
captchaBtnNameKey.value = `${t('login.captcha.get')}(${ captchaTimer.value = window.setInterval(() => {
captchaTime.value captchaTime.value -= 1;
}s)`; captchaBtnNameKey.value = `${t('login.captcha.get')}(${
if (captchaTime.value <= 0) { captchaTime.value
window.clearInterval(captchaTimer.value); }s)`;
captchaTime.value = 60; if (captchaTime.value <= 0) {
captchaBtnNameKey.value = t('login.captcha.get'); resetCaptcha();
captchaDisable.value = false; }
} }, 1000);
}, 1000); proxy.$message.success(res.msg);
})
.catch(() => {
resetCaptcha();
captchaLoading.value = false;
});
} }
}); });
}; };
/**
* 登录
*
* @param errors 表单验证错误
* @param values 表单数据
*/
const handleLogin = ({
errors,
values,
}: {
errors: Record<string, ValidatedError> | undefined;
values: Record<string, any>;
}) => {
if (loading.value) return;
if (!errors) {
loading.value = true;
userStore
.phoneLogin({
phone: values.phone,
captcha: values.captcha,
})
.then(() => {
const { redirect, ...othersQuery } = router.currentRoute.value.query;
router.push({
name: (redirect as string) || 'Workplace',
query: {
...othersQuery,
},
});
proxy.$notification.success(t('login.success'));
})
.catch(() => {
form.value.captcha = '';
})
.finally(() => {
loading.value = false;
});
}
};
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@ -62,9 +62,7 @@
<a-input <a-input
v-model="form.captcha" v-model="form.captcha"
:placeholder=" :placeholder="
$t( $t('userCenter.securitySettings.form.placeholder.captcha')
'userCenter.securitySettings.updateEmail.form.placeholder.captcha'
)
" "
:max-length="6" :max-length="6"
allow-clear allow-clear
@ -107,13 +105,12 @@
import { getCurrentInstance, ref, reactive, computed } from 'vue'; import { getCurrentInstance, ref, reactive, computed } from 'vue';
import { FieldRule } from '@arco-design/web-vue'; import { FieldRule } from '@arco-design/web-vue';
import { getMailCaptcha } from '@/api/common/captcha'; import { getMailCaptcha } from '@/api/common/captcha';
import { updateEmail } from '@/api/system/user-center'; import { UserEmailUpdateReq, updateEmail } from '@/api/system/user-center';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { encryptByRsa } from '@/utils/encrypt'; import { encryptByRsa } from '@/utils/encrypt';
const { proxy } = getCurrentInstance() as any; const { proxy } = getCurrentInstance() as any;
const { t } = useI18n(); const { t } = useI18n();
const userStore = useUserStore(); const userStore = useUserStore();
const captchaTime = ref(60); const captchaTime = ref(60);
@ -121,13 +118,11 @@
const captchaLoading = ref(false); const captchaLoading = ref(false);
const captchaDisable = ref(false); const captchaDisable = ref(false);
const visible = ref(false); const visible = ref(false);
const captchaBtnNameKey = ref( const captchaBtnNameKey = ref('userCenter.securitySettings.captcha.get');
'userCenter.securitySettings.updateEmail.form.sendCaptcha'
);
const captchaBtnName = computed(() => t(captchaBtnNameKey.value)); const captchaBtnName = computed(() => t(captchaBtnNameKey.value));
// //
const form = reactive({ const form = reactive<UserEmailUpdateReq>({
newEmail: '', newEmail: '',
captcha: '', captcha: '',
currentPassword: '', currentPassword: '',
@ -152,9 +147,7 @@
captcha: [ captcha: [
{ {
required: true, required: true,
message: t( message: t('userCenter.securitySettings.form.error.required.captcha'),
'userCenter.securitySettings.updateEmail.form.error.required.captcha'
),
}, },
], ],
currentPassword: [ currentPassword: [
@ -174,8 +167,7 @@
const resetCaptcha = () => { const resetCaptcha = () => {
window.clearInterval(captchaTimer.value); window.clearInterval(captchaTimer.value);
captchaTime.value = 60; captchaTime.value = 60;
captchaBtnNameKey.value = captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.get';
'userCenter.securitySettings.updateEmail.form.sendCaptcha';
captchaDisable.value = false; captchaDisable.value = false;
}; };
@ -187,29 +179,21 @@
proxy.$refs.formRef.validateField('newEmail', (valid: any) => { proxy.$refs.formRef.validateField('newEmail', (valid: any) => {
if (!valid) { if (!valid) {
captchaLoading.value = true; captchaLoading.value = true;
captchaBtnNameKey.value = captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.ing';
'userCenter.securitySettings.updateEmail.form.loading.sendCaptcha'; getMailCaptcha(form.newEmail)
getMailCaptcha({
email: form.newEmail,
})
.then((res) => { .then((res) => {
captchaLoading.value = false; captchaLoading.value = false;
captchaDisable.value = true; captchaDisable.value = true;
captchaBtnNameKey.value = `${t( captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.updateEmail.form.reSendCaptcha' 'userCenter.securitySettings.captcha.get'
)}(${(captchaTime.value -= 1)}s)`; )}(${(captchaTime.value -= 1)}s)`;
captchaTimer.value = window.setInterval(() => { captchaTimer.value = window.setInterval(() => {
captchaTime.value -= 1; captchaTime.value -= 1;
captchaBtnNameKey.value = `${t( captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.updateEmail.form.reSendCaptcha' 'userCenter.securitySettings.captcha.get'
)}(${captchaTime.value}s)`; )}(${captchaTime.value}s)`;
if (captchaTime.value <= 0) { if (captchaTime.value <= 0) {
window.clearInterval(captchaTimer.value); resetCaptcha();
captchaTime.value = 60;
captchaBtnNameKey.value = t(
'userCenter.securitySettings.updateEmail.form.reSendCaptcha'
);
captchaDisable.value = false;
} }
}, 1000); }, 1000);
proxy.$message.success(res.msg); proxy.$message.success(res.msg);

View File

@ -18,18 +18,232 @@
</a-typography-paragraph> </a-typography-paragraph>
</div> </div>
<div class="operation"> <div class="operation">
<a-link disabled :title="$t('userCenter.securitySettings.button.update')"> <a-link
:title="$t('userCenter.securitySettings.button.update')"
@click="toUpdate"
>
{{ $t('userCenter.securitySettings.button.update') }} {{ $t('userCenter.securitySettings.button.update') }}
</a-link> </a-link>
</div> </div>
</template> </template>
</a-list-item-meta> </a-list-item-meta>
<a-modal
:title="$t('userCenter.securitySettings.updatePhone.modal.title')"
:visible="visible"
:mask-closable="false"
:esc-to-close="false"
@ok="handleUpdate"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="form" :rules="rules" size="large">
<a-form-item
:label="
$t('userCenter.securitySettings.updatePhone.form.label.newPhone')
"
field="newPhone"
>
<a-input
v-model="form.newPhone"
:placeholder="
$t(
'userCenter.securitySettings.updatePhone.form.placeholder.newPhone'
)
"
allow-clear
/>
</a-form-item>
<a-form-item
:label="
$t('userCenter.securitySettings.updatePhone.form.label.captcha')
"
field="captcha"
>
<a-input
v-model="form.captcha"
:placeholder="
$t('userCenter.securitySettings.form.placeholder.captcha')
"
:max-length="4"
allow-clear
style="width: 80%"
/>
<a-button
:loading="captchaLoading"
type="primary"
:disabled="captchaDisable"
class="captcha-btn"
@click="handleSendCaptcha"
>
{{ captchaBtnName }}
</a-button>
</a-form-item>
<a-form-item
:label="
$t(
'userCenter.securitySettings.updatePhone.form.label.currentPassword'
)
"
field="currentPassword"
>
<a-input-password
v-model="form.currentPassword"
:placeholder="
$t(
'userCenter.securitySettings.updatePhone.form.placeholder.currentPassword'
)
"
:max-length="32"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { getCurrentInstance, ref, reactive, computed } from 'vue';
import { FieldRule } from '@arco-design/web-vue';
import { getSmsCaptcha } from '@/api/common/captcha';
import { UserPhoneUpdateReq, updatePhone } from '@/api/system/user-center';
import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { encryptByRsa } from '@/utils/encrypt';
const { proxy } = getCurrentInstance() as any;
const { t } = useI18n();
const userStore = useUserStore(); const userStore = useUserStore();
const captchaTime = ref(60);
const captchaTimer = ref();
const captchaLoading = ref(false);
const captchaDisable = ref(true);
const visible = ref(false);
const captchaBtnNameKey = ref('userCenter.securitySettings.captcha.get');
const captchaBtnName = computed(() => t(captchaBtnNameKey.value));
//
const form = reactive<UserPhoneUpdateReq>({
newPhone: '',
captcha: '',
currentPassword: '',
});
//
const rules = computed((): Record<string, FieldRule[]> => {
return {
newPhone: [
{
required: true,
message: t(
'userCenter.securitySettings.updatePhone.form.error.required.newPhone'
),
},
{
match: /^1[3-9]\d{9}$/,
message: t(
'userCenter.securitySettings.updatePhone.form.error.match.newPhone'
),
},
],
captcha: [
{
required: true,
message: t('userCenter.securitySettings.form.error.required.captcha'),
},
],
currentPassword: [
{
required: true,
message: t(
'userCenter.securitySettings.updatePhone.form.error.required.currentPassword'
),
},
],
};
});
/**
* 重置验证码
*/
const resetCaptcha = () => {
window.clearInterval(captchaTimer.value);
captchaTime.value = 60;
captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.get';
captchaDisable.value = false;
};
/**
* 发送验证码
*/
const handleSendCaptcha = () => {
if (captchaLoading.value) return;
proxy.$refs.formRef.validateField('newPhone', (valid: any) => {
if (!valid) {
captchaLoading.value = true;
captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.ing';
getSmsCaptcha(form.newPhone)
.then((res) => {
captchaLoading.value = false;
captchaDisable.value = true;
captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.captcha.get'
)}(${(captchaTime.value -= 1)}s)`;
captchaTimer.value = window.setInterval(() => {
captchaTime.value -= 1;
captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.captcha.get'
)}(${captchaTime.value}s)`;
if (captchaTime.value <= 0) {
resetCaptcha();
}
}, 1000);
proxy.$message.success(res.msg);
})
.catch(() => {
resetCaptcha();
captchaLoading.value = false;
});
}
});
};
/**
* 取消
*/
const handleCancel = () => {
visible.value = false;
proxy.$refs.formRef.resetFields();
resetCaptcha();
};
/**
* 修改
*/
const handleUpdate = () => {
proxy.$refs.formRef.validate((valid: any) => {
if (!valid) {
updatePhone({
newPhone: form.newPhone,
captcha: form.captcha,
currentPassword: encryptByRsa(form.currentPassword) || '',
}).then((res) => {
handleCancel();
userStore.getInfo();
proxy.$message.success(res.msg);
});
}
});
};
/**
* 打开修改对话框
*/
const toUpdate = () => {
visible.value = true;
};
</script> </script>
<style scoped lang="less"></style> <style scoped lang="less">
.captcha-btn {
margin-left: 5px;
}
</style>

View File

@ -74,6 +74,24 @@ export default {
'It is used to receive messages, verify identity, and support mobile phone verification code login after binding', 'It is used to receive messages, verify identity, and support mobile phone verification code login after binding',
'userCenter.securitySettings.phone.content': 'Unbound', 'userCenter.securitySettings.phone.content': 'Unbound',
'userCenter.securitySettings.updatePhone.modal.title': 'Update phone',
'userCenter.securitySettings.updatePhone.form.label.newPhone': 'New phone',
'userCenter.securitySettings.updatePhone.form.label.captcha': 'Captcha',
'userCenter.securitySettings.updatePhone.form.label.currentPassword':
'Current password',
'userCenter.securitySettings.updatePhone.form.placeholder.newPhone':
'Please enter new phone',
'userCenter.securitySettings.updatePhone.form.placeholder.currentPassword':
'Please enter current password',
'userCenter.securitySettings.updatePhone.form.error.required.newPhone':
'Please enter new phone',
'userCenter.securitySettings.updatePhone.form.error.match.newPhone':
'Please enter the correct phone',
'userCenter.securitySettings.updatePhone.form.error.required.currentPassword':
'Please enter current password',
// update-email // update-email
'userCenter.securitySettings.email.label': 'Email', 'userCenter.securitySettings.email.label': 'Email',
'userCenter.securitySettings.email.tip': 'userCenter.securitySettings.email.tip':
@ -85,16 +103,9 @@ export default {
'userCenter.securitySettings.updateEmail.form.label.captcha': 'Captcha', 'userCenter.securitySettings.updateEmail.form.label.captcha': 'Captcha',
'userCenter.securitySettings.updateEmail.form.label.currentPassword': 'userCenter.securitySettings.updateEmail.form.label.currentPassword':
'Current password', 'Current password',
'userCenter.securitySettings.updateEmail.form.sendCaptcha': 'Send captcha',
'userCenter.securitySettings.updateEmail.form.reSendCaptcha':
'Resend captcha',
'userCenter.securitySettings.updateEmail.form.loading.sendCaptcha':
'Sending...',
'userCenter.securitySettings.updateEmail.form.placeholder.newEmail': 'userCenter.securitySettings.updateEmail.form.placeholder.newEmail':
'Please enter new email', 'Please enter new email',
'userCenter.securitySettings.updateEmail.form.placeholder.captcha':
'Please enter email captcha',
'userCenter.securitySettings.updateEmail.form.placeholder.currentPassword': 'userCenter.securitySettings.updateEmail.form.placeholder.currentPassword':
'Please enter current password', 'Please enter current password',
@ -102,8 +113,6 @@ export default {
'Please enter new email', 'Please enter new email',
'userCenter.securitySettings.updateEmail.form.error.match.newEmail': 'userCenter.securitySettings.updateEmail.form.error.match.newEmail':
'Please enter the correct email', 'Please enter the correct email',
'userCenter.securitySettings.updateEmail.form.error.required.captcha':
'Please enter email captcha',
'userCenter.securitySettings.updateEmail.form.error.required.currentPassword': 'userCenter.securitySettings.updateEmail.form.error.required.currentPassword':
'Please enter current password', 'Please enter current password',
@ -115,4 +124,10 @@ export default {
'userCenter.securitySettings.content.hasBeenSet': 'Has been set', 'userCenter.securitySettings.content.hasBeenSet': 'Has been set',
'userCenter.securitySettings.button.update': 'Update', 'userCenter.securitySettings.button.update': 'Update',
'userCenter.securitySettings.captcha.get': 'Get captcha',
'userCenter.securitySettings.captcha.ing': 'Sending...',
'userCenter.securitySettings.form.placeholder.captcha':
'Please enter captcha',
'userCenter.securitySettings.form.error.required.captcha':
'Please enter captcha',
}; };

View File

@ -70,6 +70,25 @@ export default {
'用于接收消息、验证身份,绑定后可支持手机验证码登录', '用于接收消息、验证身份,绑定后可支持手机验证码登录',
'userCenter.securitySettings.phone.content': '未绑定', 'userCenter.securitySettings.phone.content': '未绑定',
'userCenter.securitySettings.updatePhone.modal.title': '修改手机号',
'userCenter.securitySettings.updatePhone.form.label.newPhone': '新手机号',
'userCenter.securitySettings.updatePhone.form.label.captcha': '验证码',
'userCenter.securitySettings.updatePhone.form.label.currentPassword':
'当前密码',
'userCenter.securitySettings.updatePhone.form.placeholder.newPhone':
'请输入新手机号',
'userCenter.securitySettings.updatePhone.form.placeholder.currentPassword':
'请输入当前密码',
'userCenter.securitySettings.updatePhone.form.error.required.newPhone':
'请输入新手机号',
'userCenter.securitySettings.updatePhone.form.error.match.newPhone':
'请输入正确的手机号',
'userCenter.securitySettings.updatePhone.form.error.required.currentPassword':
'请输入当前密码',
// update-email // update-email
'userCenter.securitySettings.email.label': '安全邮箱', 'userCenter.securitySettings.email.label': '安全邮箱',
'userCenter.securitySettings.email.tip': 'userCenter.securitySettings.email.tip':
@ -81,15 +100,9 @@ export default {
'userCenter.securitySettings.updateEmail.form.label.captcha': '验证码', 'userCenter.securitySettings.updateEmail.form.label.captcha': '验证码',
'userCenter.securitySettings.updateEmail.form.label.currentPassword': 'userCenter.securitySettings.updateEmail.form.label.currentPassword':
'当前密码', '当前密码',
'userCenter.securitySettings.updateEmail.form.sendCaptcha': '发送验证码',
'userCenter.securitySettings.updateEmail.form.reSendCaptcha': '重新发送',
'userCenter.securitySettings.updateEmail.form.loading.sendCaptcha':
'发送中...',
'userCenter.securitySettings.updateEmail.form.placeholder.newEmail': 'userCenter.securitySettings.updateEmail.form.placeholder.newEmail':
'请输入新邮箱', '请输入新邮箱',
'userCenter.securitySettings.updateEmail.form.placeholder.captcha':
'请输入邮箱验证码',
'userCenter.securitySettings.updateEmail.form.placeholder.currentPassword': 'userCenter.securitySettings.updateEmail.form.placeholder.currentPassword':
'请输入当前密码', '请输入当前密码',
@ -97,8 +110,6 @@ export default {
'请输入新邮箱', '请输入新邮箱',
'userCenter.securitySettings.updateEmail.form.error.match.newEmail': 'userCenter.securitySettings.updateEmail.form.error.match.newEmail':
'请输入正确的邮箱', '请输入正确的邮箱',
'userCenter.securitySettings.updateEmail.form.error.required.captcha':
'请输入邮箱验证码',
'userCenter.securitySettings.updateEmail.form.error.required.currentPassword': 'userCenter.securitySettings.updateEmail.form.error.required.currentPassword':
'请输入当前密码', '请输入当前密码',
@ -109,4 +120,8 @@ export default {
'userCenter.securitySettings.content.hasBeenSet': '已设置', 'userCenter.securitySettings.content.hasBeenSet': '已设置',
'userCenter.securitySettings.button.update': '修改', 'userCenter.securitySettings.button.update': '修改',
'userCenter.securitySettings.captcha.get': '获取验证码',
'userCenter.securitySettings.captcha.ing': '发送中...',
'userCenter.securitySettings.form.placeholder.captcha': '请输入验证码',
'userCenter.securitySettings.form.error.required.captcha': '请输入验证码',
}; };

View File

@ -34,6 +34,7 @@ import cn.hutool.core.bean.BeanUtil;
import top.charles7c.cnadmin.auth.model.request.AccountLoginRequest; import top.charles7c.cnadmin.auth.model.request.AccountLoginRequest;
import top.charles7c.cnadmin.auth.model.request.EmailLoginRequest; import top.charles7c.cnadmin.auth.model.request.EmailLoginRequest;
import top.charles7c.cnadmin.auth.model.request.PhoneLoginRequest;
import top.charles7c.cnadmin.auth.model.vo.LoginVO; import top.charles7c.cnadmin.auth.model.vo.LoginVO;
import top.charles7c.cnadmin.auth.model.vo.RouteVO; import top.charles7c.cnadmin.auth.model.vo.RouteVO;
import top.charles7c.cnadmin.auth.model.vo.UserInfoVO; import top.charles7c.cnadmin.auth.model.vo.UserInfoVO;
@ -96,6 +97,20 @@ public class AuthController {
return LoginVO.builder().token(token).build(); return LoginVO.builder().token(token).build();
} }
@SaIgnore
@Operation(summary = "手机号登录", description = "根据手机号和验证码进行登录认证")
@PostMapping("/phone")
public LoginVO phoneLogin(@Validated @RequestBody PhoneLoginRequest loginRequest) {
String phone = loginRequest.getPhone();
String captchaKey = RedisUtils.formatKey(CacheConsts.CAPTCHA_KEY_PREFIX, phone);
String captcha = RedisUtils.getCacheObject(captchaKey);
ValidationUtils.throwIfBlank(captcha, "验证码已失效");
ValidationUtils.throwIfNotEqualIgnoreCase(loginRequest.getCaptcha(), captcha, "验证码错误");
RedisUtils.deleteCacheObject(captchaKey);
String token = loginService.phoneLogin(phone);
return LoginVO.builder().token(token).build();
}
@SaIgnore @SaIgnore
@Operation(summary = "用户退出", description = "注销用户的当前登录") @Operation(summary = "用户退出", description = "注销用户的当前登录")
@Parameter(name = "Authorization", description = "令牌", required = true, example = "Bearer xxxx-xxxx-xxxx-xxxx", @Parameter(name = "Authorization", description = "令牌", required = true, example = "Bearer xxxx-xxxx-xxxx-xxxx",

View File

@ -17,6 +17,8 @@
package top.charles7c.cnadmin.webapi.controller.common; package top.charles7c.cnadmin.webapi.controller.common;
import java.time.Duration; import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.mail.MessagingException; import javax.mail.MessagingException;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
@ -27,6 +29,10 @@ import lombok.RequiredArgsConstructor;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.comm.constant.SupplierConstant;
import org.dromara.sms4j.core.factory.SmsFactory;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -36,6 +42,7 @@ import com.wf.captcha.base.Captcha;
import cn.dev33.satoken.annotation.SaIgnore; import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.core.lang.Dict; import cn.hutool.core.lang.Dict;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
@ -90,22 +97,47 @@ public class CaptchaController {
String captchaKeyPrefix = CacheConsts.CAPTCHA_KEY_PREFIX; String captchaKeyPrefix = CacheConsts.CAPTCHA_KEY_PREFIX;
String limitCaptchaKey = RedisUtils.formatKey(limitKeyPrefix, captchaKeyPrefix, email); String limitCaptchaKey = RedisUtils.formatKey(limitKeyPrefix, captchaKeyPrefix, email);
long limitTimeInMillisecond = RedisUtils.getTimeToLive(limitCaptchaKey); long limitTimeInMillisecond = RedisUtils.getTimeToLive(limitCaptchaKey);
CheckUtils.throwIf(limitTimeInMillisecond > 0, "发送邮箱验证码过于频繁,请您 {}s 后再试", limitTimeInMillisecond / 1000); CheckUtils.throwIf(limitTimeInMillisecond > 0, "发送验证码过于频繁,请您 {}s 后再试", limitTimeInMillisecond / 1000);
// 生成验证码 // 生成验证码
CaptchaProperties.CaptchaMail captchaMail = captchaProperties.getMail(); CaptchaProperties.CaptchaMail captchaMail = captchaProperties.getMail();
String captcha = RandomUtil.randomNumbers(captchaMail.getLength()); String captcha = RandomUtil.randomNumbers(captchaMail.getLength());
// 发送验证码 // 发送验证码
Long expirationInMinutes = captchaMail.getExpirationInMinutes(); Long expirationInMinutes = captchaMail.getExpirationInMinutes();
String content = TemplateUtils.render(captchaMail.getTemplatePath(), String content = TemplateUtils.render(captchaMail.getTemplatePath(),
Dict.create().set("captcha", captcha).set("expiration", expirationInMinutes)); Dict.create().set("captcha", captcha).set("expiration", expirationInMinutes));
MailUtils.sendHtml(email, String.format("【%s】邮箱验证码", projectProperties.getName()), content); MailUtils.sendHtml(email, String.format("【%s】邮箱验证码", projectProperties.getName()), content);
// 保存验证码 // 保存验证码
String captchaKey = RedisUtils.formatKey(captchaKeyPrefix, email); String captchaKey = RedisUtils.formatKey(captchaKeyPrefix, email);
RedisUtils.setCacheObject(captchaKey, captcha, Duration.ofMinutes(expirationInMinutes)); RedisUtils.setCacheObject(captchaKey, captcha, Duration.ofMinutes(expirationInMinutes));
RedisUtils.setCacheObject(limitCaptchaKey, captcha, Duration.ofSeconds(captchaMail.getLimitInSeconds())); RedisUtils.setCacheObject(limitCaptchaKey, captcha, Duration.ofSeconds(captchaMail.getLimitInSeconds()));
return R.ok(String.format("发送成功,验证码有效期 %s 分钟", expirationInMinutes)); return R.ok(String.format("发送成功,验证码有效期 %s 分钟", expirationInMinutes));
} }
@Operation(summary = "获取短信验证码", description = "发送验证码到指定手机号")
@GetMapping("/sms")
public R getSmsCaptcha(
@NotBlank(message = "手机号不能为空") @Pattern(regexp = RegexConsts.MOBILE, message = "手机号格式错误") String phone) {
String limitKeyPrefix = CacheConsts.LIMIT_KEY_PREFIX;
String captchaKeyPrefix = CacheConsts.CAPTCHA_KEY_PREFIX;
String limitCaptchaKey = RedisUtils.formatKey(limitKeyPrefix, captchaKeyPrefix, phone);
long limitTimeInMillisecond = RedisUtils.getTimeToLive(limitCaptchaKey);
CheckUtils.throwIf(limitTimeInMillisecond > 0, "发送验证码过于频繁,请您 {}s 后再试", limitTimeInMillisecond / 1000);
// 生成验证码
CaptchaProperties.CaptchaSms captchaSms = captchaProperties.getSms();
String captcha = RandomUtil.randomNumbers(captchaSms.getLength());
// 发送验证码
Long expirationInMinutes = captchaSms.getExpirationInMinutes();
SmsBlend smsBlend = SmsFactory.getBySupplier(SupplierConstant.CLOOPEN);
Map<String, String> messageMap = MapUtil.newHashMap(2, true);
messageMap.put("captcha", captcha);
messageMap.put("expirationInMinutes", String.valueOf(expirationInMinutes));
SmsResponse smsResponse =
smsBlend.sendMessage(phone, captchaSms.getTemplateId(), (LinkedHashMap<String, String>)messageMap);
CheckUtils.throwIf(!smsResponse.isSuccess(), "验证码发送失败");
// 保存验证码
String captchaKey = RedisUtils.formatKey(captchaKeyPrefix, phone);
RedisUtils.setCacheObject(captchaKey, captcha, Duration.ofMinutes(expirationInMinutes));
RedisUtils.setCacheObject(limitCaptchaKey, captcha, Duration.ofSeconds(captchaSms.getLimitInSeconds()));
return R.ok(String.format("发送成功,验证码有效期 %s 分钟", expirationInMinutes));
}
} }

View File

@ -49,6 +49,7 @@ import top.charles7c.cnadmin.system.model.entity.UserSocialDO;
import top.charles7c.cnadmin.system.model.request.UserBasicInfoUpdateRequest; import top.charles7c.cnadmin.system.model.request.UserBasicInfoUpdateRequest;
import top.charles7c.cnadmin.system.model.request.UserEmailUpdateRequest; import top.charles7c.cnadmin.system.model.request.UserEmailUpdateRequest;
import top.charles7c.cnadmin.system.model.request.UserPasswordUpdateRequest; import top.charles7c.cnadmin.system.model.request.UserPasswordUpdateRequest;
import top.charles7c.cnadmin.system.model.request.UserPhoneUpdateRequest;
import top.charles7c.cnadmin.system.model.vo.AvatarVO; import top.charles7c.cnadmin.system.model.vo.AvatarVO;
import top.charles7c.cnadmin.system.model.vo.UserSocialBindVO; import top.charles7c.cnadmin.system.model.vo.UserSocialBindVO;
import top.charles7c.cnadmin.system.service.UserService; import top.charles7c.cnadmin.system.service.UserService;
@ -106,6 +107,21 @@ public class UserCenterController {
return R.ok("修改成功"); return R.ok("修改成功");
} }
@Operation(summary = "修改手机号", description = "修改手机号")
@PatchMapping("/phone")
public R updatePhone(@Validated @RequestBody UserPhoneUpdateRequest updateRequest) {
String rawCurrentPassword =
ExceptionUtils.exToNull(() -> SecureUtils.decryptByRsaPrivateKey(updateRequest.getCurrentPassword()));
ValidationUtils.throwIfBlank(rawCurrentPassword, "当前密码解密失败");
String captchaKey = RedisUtils.formatKey(CacheConsts.CAPTCHA_KEY_PREFIX, updateRequest.getNewPhone());
String captcha = RedisUtils.getCacheObject(captchaKey);
ValidationUtils.throwIfBlank(captcha, "验证码已失效");
ValidationUtils.throwIfNotEqualIgnoreCase(updateRequest.getCaptcha(), captcha, "验证码错误");
RedisUtils.deleteCacheObject(captchaKey);
userService.updatePhone(updateRequest.getNewPhone(), rawCurrentPassword, LoginHelper.getUserId());
return R.ok("修改成功");
}
@Operation(summary = "修改邮箱", description = "修改用户邮箱") @Operation(summary = "修改邮箱", description = "修改用户邮箱")
@PatchMapping("/email") @PatchMapping("/email")
public R updateEmail(@Validated @RequestBody UserEmailUpdateRequest updateRequest) { public R updateEmail(@Validated @RequestBody UserEmailUpdateRequest updateRequest) {

View File

@ -93,6 +93,19 @@ justauth:
cache: cache:
type: custom type: custom
--- ### 短信配置
sms:
# 从 YAML 读取配置
config-type: yaml
blends:
cloopen:
# 短信厂商
supplier: cloopen
base-url: https://app.cloopen.com:8883/2013-12-26
access-key-id: 你的Access Key
access-key-secret: 你的Access Key Secret
sdk-app-id: 你的应用ID
--- ### 邮件配置 --- ### 邮件配置
spring.mail: spring.mail:
# 根据需要更换 # 根据需要更换
@ -133,6 +146,16 @@ captcha:
limitInSeconds: 60 limitInSeconds: 60
# 模板路径 # 模板路径
templatePath: mail/captcha.ftl templatePath: mail/captcha.ftl
## 短信验证码配置
sms:
# 内容长度
length: 4
# 过期时间
expirationInMinutes: 5
# 限制时间
limitInSeconds: 60
# 模板 ID
templateId: 1
--- ### 安全配置-排除路径配置 --- ### 安全配置-排除路径配置
security.excludes: security.excludes:

View File

@ -95,6 +95,19 @@ justauth:
cache: cache:
type: custom type: custom
--- ### 短信配置
sms:
# 从 YAML 读取配置
config-type: yaml
blends:
cloopen:
# 短信厂商
supplier: cloopen
base-url: https://app.cloopen.com:8883/2013-12-26
access-key-id: 你的Access Key
access-key-secret: 你的Access Key Secret
sdk-app-id: 你的应用ID
--- ### 邮件配置 --- ### 邮件配置
spring.mail: spring.mail:
# 根据需要更换 # 根据需要更换
@ -135,6 +148,16 @@ captcha:
limitInSeconds: 60 limitInSeconds: 60
# 模板路径 # 模板路径
templatePath: mail/captcha.ftl templatePath: mail/captcha.ftl
## 短信验证码配置
sms:
# 内容长度
length: 4
# 过期时间
expirationInMinutes: 5
# 限制时间
limitInSeconds: 60
# 模板 ID
templateId: 1
--- ### 安全配置-排除路径配置 --- ### 安全配置-排除路径配置
security.excludes: security.excludes:

View File

@ -52,6 +52,7 @@ limitations under the License.
<p6spy.version>3.9.1</p6spy.version> <p6spy.version>3.9.1</p6spy.version>
<!-- ### 工具库相关 ### --> <!-- ### 工具库相关 ### -->
<sms4j.version>3.0.3</sms4j.version>
<justauth.version>1.16.5</justauth.version> <justauth.version>1.16.5</justauth.version>
<easyexcel.version>3.3.2</easyexcel.version> <easyexcel.version>3.3.2</easyexcel.version>
<ip2region.version>2.7.15</ip2region.version> <ip2region.version>2.7.15</ip2region.version>
@ -116,6 +117,13 @@ limitations under the License.
</dependency> </dependency>
<!-- ################ 工具库相关 ################ --> <!-- ################ 工具库相关 ################ -->
<!-- SMS4J短信聚合框架轻松集成多家短信服务解决接入多个短信 SDK 的繁琐流程) -->
<dependency>
<groupId>org.dromara.sms4j</groupId>
<artifactId>sms4j-spring-boot-starter</artifactId>
<version>${sms4j.version}</version>
</dependency>
<!-- Just Auth开箱即用的整合第三方登录的开源组件脱离繁琐的第三方登录 SDK让登录变得 So easy! --> <!-- Just Auth开箱即用的整合第三方登录的开源组件脱离繁琐的第三方登录 SDK让登录变得 So easy! -->
<dependency> <dependency>
<groupId>com.xkcoding.justauth</groupId> <groupId>com.xkcoding.justauth</groupId>