1. 后端
1.1. dto 新增字段
LoginRequest
java
package com.yu.cloudpicturebackend.model.dto;
import com.yu.cloudpicturebackend.common.ValidationGroups;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.*;
import java.io.Serializable;
@Data
public class LoginRequest implements Serializable {
private static final long serialVersionUID = -6263796057820657368L;
/**
* 邮箱
*/
@NotEmpty(message = "邮箱不能为空", groups = ValidationGroups.NotNullCheck.class)
@ApiModelProperty(value = "邮箱", required = true)
private String email;
/**
* 密码
*/
@NotBlank(message = "密码不能为空", groups = ValidationGroups.NotNullCheck.class)
@Size(min = 8, max = 15, message = "密码长度应为8-15位", groups = ValidationGroups.LengthCheck.class)
@ApiModelProperty(value = "密码", required = true)
private String password;
/**
* 验证码 id
*/
@NotBlank(message = "验证码Id不能为空")
@ApiModelProperty(value = "验证码Id", required = true)
private String captchaId;
/**
* 验证码
*/
@NotBlank(message = "验证码不能为空")
@ApiModelProperty(value = "验证码", required = true)
private String captchaCode;
}
1.2. 新增 vo
CaptchaVO
java
package com.yu.cloudpicturebackend.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class CaptchaVO {
/**
* 验证码 id
*/
private String captchaId;
/**
* base64图片地址
*/
private String imageBase64;
/**
* 过期时长
*/
private Integer expireTime;
/**
* 算术验证码的问题
*/
private String question;
/**
* 验证码类型:line, circle, math, gif
*/
private String captchaType;
}
1.3. 新增错误码
ErrorCode
java
package com.yu.cloudpicturebackend.exception;
import lombok.Getter;
@Getter
public enum ErrorCode {
SUCCESS(0, "ok"),
PARAMS_ERROR(40000, "请求参数错误"),
PASSWORD_NOT_MATCH(40001, "账号或密码错误"),
CAPTCHA_ERROR(40002, "验证码错误"),
NOT_LOGIN_ERROR(40100, "未登录"),
NO_AUTH_ERROR(40101, "无权限"),
NOT_FOUND_ERROR(40400, "请求数据不存在"),
FORBIDDEN_ERROR(40300, "禁止访问"),
SYSTEM_ERROR(50000, "系统内部异常"),
OPERATION_ERROR(50001, "操作失败");
/**
* 错误码
*/
private final int code;
/**
* 信息
*/
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
}
1.4. 新增常量类
AuthConstant
java
package com.yu.cloudpicturebackend.constant;
public final class AuthConstant {
private AuthConstant() {
// 防止实例化
}
public static final String SEND_KEY_PRE = "email:send:";
public static final String STATUS_KEY_PRE = "email:verified:";
public static final String CAPTCHA_PREFIX = "captcha:";
public static final int CAPTCHA_EXPIRE_SECONDS = 300; // 5分钟
public static final int CAPTCHA_WIDTH = 120;
public static final int CAPTCHA_HEIGHT = 40;
}
1.5. 接口层
java
/**
* 获取图片验证码
*
* @return
*/
@GetMapping("/captcha")
@ApiOperation("获取图片验证码")
@ApiImplicitParams({
@ApiImplicitParam(
name = "type",
value = "验证码类型: line(线段干扰), circle(圆圈干扰), shear(扭曲干扰), gif(gif类型), math(算术类型)",
required = true,
dataType = "string",
paramType = "query",
defaultValue = "line",
allowableValues = "line,circle,shear,gif,math"
)
})
public BaseResponse<CaptchaVO> generateCaptcha(@RequestParam(defaultValue = "line") String type) {
if (!Arrays.asList("line", "circle", "shear", "gif", "math").contains(type)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "不支持的验证码类型");
}
try {
CaptchaVO captchaVO = authService.generateCaptcha(type);
return ResultUtils.success(captchaVO);
} catch (Exception e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "验证码生成失败");
}
}
1.6. 服务层
java
package com.yu.cloudpicturebackend.service;
import cn.hutool.captcha.*;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.yu.cloudpicturebackend.model.vo.CaptchaVO;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import static com.yu.cloudpicturebackend.constant.AuthConstant.*;
@Service
@Slf4j
public class AuthService {
//获取配置文件的信息
@Value("${spring.mail.host}")
private String host;
@Value("${spring.mail.username}")
private String username;
@Value("${spring.mail.password}")
private String password;
// springboot 整合 redis
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 生成验证码(支持算术验证码)
*/
public CaptchaVO generateCaptcha(String type) {
try {
if ("math".equals(type)) {
return generateMathCaptcha();
} else {
ICaptcha captcha = createCaptchaInstance(type);
captcha.createCode();
return buildCaptchaVO(captcha, type);
}
} catch (Exception e) {
log.error("生成验证码失败, type: {}", type, e);
throw new RuntimeException("验证码生成失败");
}
}
/**
* 生成算术验证码
*/
private CaptchaVO generateMathCaptcha() {
try {
// 生成算术题
MathProblem problem = generateMathProblem();
// 创建基础验证码
LineCaptcha captcha = new LineCaptcha(120, 40, 4, 50);
// 设置自定义生成器
captcha.setGenerator(new CodeGenerator() {
@Override
public String generate() {
return problem.getQuestion() + "=?";
}
@Override
public boolean verify(String code, String userInput) {
return problem.getAnswer().equals(userInput.trim());
}
});
// 生成验证码
captcha.createCode();
String captchaId = IdUtil.fastUUID();
storeCaptcha(captchaId, problem.getAnswer());
String base64Image = getBase64Image(captcha);
return CaptchaVO.builder()
.captchaId(captchaId)
.imageBase64("data:image/png;base64," + base64Image)
.question(problem.getQuestion() + " = ?")
.expireTime(CAPTCHA_EXPIRE_SECONDS)
.captchaType("math")
.build();
} catch (Exception e) {
log.error("生成算术验证码失败", e);
throw new RuntimeException("算术验证码生成失败");
}
}
/**
* 生成数学问题
*/
private MathProblem generateMathProblem() {
Random random = new Random();
int num1 = random.nextInt(10) + 1;
int num2 = random.nextInt(10) + 1;
String[] operators = {"+", "-", "*"};
String operator = operators[random.nextInt(operators.length)];
String question = num1 + " " + operator + " " + num2;
String answer = calculateResult(num1, num2, operator);
return new MathProblem(question, answer);
}
private String calculateResult(int num1, int num2, String operator) {
switch (operator) {
case "+": return String.valueOf(num1 + num2);
case "-": return String.valueOf(num1 - num2);
case "*": return String.valueOf(num1 * num2);
default: return String.valueOf(num1 + num2);
}
}
/**
* 创建ICaptcha实例
*/
private ICaptcha createCaptchaInstance(String type) {
switch (type) {
case "circle":
return new CircleCaptcha(120, 40, 4, 20);
case "shear":
return new ShearCaptcha(120, 40, 4, 4);
case "gif":
return new GifCaptcha(120, 40, 4);
case "line":
default:
return new LineCaptcha(120, 40, 4, 50);
}
}
/**
* 构建验证码VO对象
*/
private CaptchaVO buildCaptchaVO(ICaptcha captcha, String type) {
String code = captcha.getCode();
String base64Image = getBase64Image(captcha);
String captchaId = IdUtil.fastUUID();
storeCaptcha(captchaId, code);
return CaptchaVO.builder()
.captchaId(captchaId)
.imageBase64(getImageDataUrl(type, base64Image))
.expireTime(CAPTCHA_EXPIRE_SECONDS)
.captchaType(type)
.build();
}
/**
* 获取Base64图片数据
*/
private String getBase64Image(ICaptcha captcha) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
captcha.write(outputStream);
return Base64.getEncoder().encodeToString(outputStream.toByteArray());
} catch (Exception e) {
log.error("获取验证码Base64数据失败", e);
throw new RuntimeException("验证码生成失败");
}
}
/**
* 根据类型生成Data URL
*/
private String getImageDataUrl(String type, String base64Image) {
switch (type) {
case "gif":
return "data:image/gif;base64," + base64Image;
default:
return "data:image/png;base64," + base64Image;
}
}
/**
* 存储验证码到Redis
*/
private void storeCaptcha(String captchaId, String code) {
String key = CAPTCHA_PREFIX + captchaId;
stringRedisTemplate.opsForValue().set(
key,
code,
CAPTCHA_EXPIRE_SECONDS,
TimeUnit.SECONDS
);
}
/**
* 验证验证码
*/
public boolean validateCaptcha(String captchaId, String userInput) {
if (StrUtil.isBlank(captchaId) || StrUtil.isBlank(userInput)) {
return false;
}
String key = CAPTCHA_PREFIX + captchaId;
String storedCode = stringRedisTemplate.opsForValue().get(key);
if (storedCode == null) {
return false;
}
boolean isValid = storedCode.equalsIgnoreCase(userInput.trim());
if (isValid) {
stringRedisTemplate.delete(key);
}
return isValid;
}
@Data
@AllArgsConstructor
private static class MathProblem {
private String question;
private String answer;
}
}
2. 前端
javascript
<template>
<div id="login-page">
<div class="login-card">
<div class="title">密码登录</div>
<div class="form-box">
<a-form
:model="formState"
name="normal_login"
class="login-form"
@finish="onFinish"
@finishFailed="onFinishFailed"
>
<a-form-item name="email" :rules="[{ required: true, message: '请输入你的账号!' }]">
<a-input v-model:value="formState.email" placeholder="请输入账号/邮箱">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item name="password" :rules="[{ required: true, message: '请输入你的密码!' }]">
<a-input-password v-model:value="formState.password" placeholder="请输入密码">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item name="captchaCode" :rules="[{ required: true, message: '请输入验证码!' }]">
<a-row :gutter="8">
<a-col :span="16">
<a-input v-model:value="formState.captchaCode" placeholder="请输入验证码">
<template #prefix>
<SafetyCertificateOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-col>
<a-col :span="8">
<img
:src="captcha.imageBase64"
style="width: 100%"
alt="captcha"
@click="getCaptcha"
/>
</a-col>
</a-row>
</a-form-item>
<a-form-item>
<a-button
:disabled="disabled"
block
type="primary"
html-type="submit"
class="login-form-button"
>
登录
</a-button>
或者
<a href="/user/register">立即注册</a>
</a-form-item>
</a-form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, computed, onMounted, ref } from 'vue'
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
import { loginUsingPost1 } from '@/api/yonghujiekou.ts'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useLoginUserStore } from '@/stores/loginUserStore.ts'
import { generateCaptchaUsingGet1 } from '@/api/shouquanjiekou.ts'
interface FormState {
email: string
password: string
captchaId: string
captchaCode: string
remember: boolean
}
const formState = reactive<FormState>({
email: '',
password: '',
captchaId: '',
captchaCode: '',
remember: true,
})
const router = useRouter()
const loginUserStore = useLoginUserStore()
const onFinish = async (values: any) => {
const res = await loginUsingPost1(formState)
if (res.data.code == 0 && res.data.data) {
message.success('登录成功')
// token 存入本地
// localStorage.setItem('token', res.data.data)
await loginUserStore.fetchLoginUser()
await router.replace('/')
}
console.log('Success:', values)
}
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo)
}
const disabled = computed(() => {
return !(formState.email && formState.password && formState.captchaCode)
})
let captcha = ref<API.CaptchaVO>({
captchaId: '',
imageBase64: '',
expireTime: 0,
question: '',
captchaType: '',
})
/**
* 获取验证码
*/
const getCaptcha = async () => {
const res = await generateCaptchaUsingGet1({
type: 'math',
})
if (res.data.code == 0 && res.data.data) {
captcha.value = res.data.data
formState.captchaId = captcha.value.captchaId
}
}
onMounted(async () => {
await getCaptcha()
})
</script>
<style scoped>
#login-page {
width: 100%;
height: 100%;
justify-content: center;
background-color: #f8f5f2;
overflow: hidden;
.login-card {
width: 25%;
min-width: 380px;
border-radius: 15px;
padding: 20px;
box-sizing: border-box;
background-color: #fff;
margin: 6% auto 0;
.title {
text-align: center;
font-size: 24px;
}
.form-box {
margin-top: 30px;
padding: 20px 20px 0;
.login-form {
min-width: 300px;
}
.login-form-forgot {
float: right;
}
.login-form-button {
margin-bottom: 10px;
}
}
}
}
</style>