From b91c790f8c421c148d1a89750040a929cc3ad9a0 Mon Sep 17 00:00:00 2001 From: NagoColer Date: Fri, 4 Aug 2023 21:03:50 +0800 Subject: [PATCH] Init Commit --- .gitignore | 2 + README.md | 7 + my-project-backend/.gitignore | 33 + my-project-backend/pom.xml | 153 ++++ .../example/MyProjectBackendApplication.java | 13 + .../example/config/RabbitConfiguration.java | 19 + .../example/config/SecurityConfiguration.java | 135 ++++ .../com/example/config/WebConfiguration.java | 19 + .../controller/AuthorizeController.java | 78 ++ .../exception/ErrorPageController.java | 66 ++ .../exception/ValidationController.java | 26 + .../java/com/example/entity/BaseData.java | 61 ++ .../java/com/example/entity/RestBean.java | 53 ++ .../java/com/example/entity/dto/Account.java | 26 + .../entity/vo/request/EmailRegisterVO.java | 22 + .../entity/vo/request/EmailResetVO.java | 19 + .../entity/vo/response/AuthorizeVO.java | 13 + .../java/com/example/filter/CorsFilter.java | 66 ++ .../example/filter/FlowLimitingFilter.java | 80 ++ .../filter/JwtAuthenticationFilter.java | 46 ++ .../com/example/filter/RequestLogFilter.java | 74 ++ .../example/listener/MailQueueListener.java | 64 ++ .../com/example/mapper/AccountMapper.java | 9 + .../com/example/service/AccountService.java | 14 + .../service/impl/AccountServiceImpl.java | 185 +++++ .../main/java/com/example/utils/Const.java | 26 + .../java/com/example/utils/FlowUtils.java | 90 +++ .../main/java/com/example/utils/JwtUtils.java | 180 +++++ .../example/utils/SnowflakeIdGenerator.java | 79 ++ .../src/main/resources/application-dev.yml | 40 + .../src/main/resources/application-prod.yml | 37 + .../src/main/resources/application.yml | 3 + .../src/main/resources/logback-spring.xml | 34 + .../MyProjectBackendApplicationTests.java | 13 + my-project-frontend/.gitignore | 28 + my-project-frontend/.vscode/extensions.json | 3 + my-project-frontend/README.md | 29 + my-project-frontend/index.html | 13 + my-project-frontend/my-project-frontend.iml | 9 + my-project-frontend/package-lock.json | 691 ++++++++++++++++++ my-project-frontend/package.json | 17 + my-project-frontend/public/favicon.ico | Bin 0 -> 4286 bytes my-project-frontend/src/App.vue | 47 ++ my-project-frontend/src/assets/base.css | 73 ++ my-project-frontend/src/assets/logo.svg | 1 + my-project-frontend/src/assets/main.css | 35 + .../src/components/HelloWorld.vue | 44 ++ .../src/components/TheWelcome.vue | 86 +++ .../src/components/WelcomeItem.vue | 87 +++ .../src/components/icons/IconCommunity.vue | 7 + .../components/icons/IconDocumentation.vue | 7 + .../src/components/icons/IconEcosystem.vue | 7 + .../src/components/icons/IconSupport.vue | 7 + .../src/components/icons/IconTooling.vue | 19 + my-project-frontend/src/main.js | 6 + my-project-frontend/vite.config.js | 16 + 56 files changed, 3017 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 my-project-backend/.gitignore create mode 100644 my-project-backend/pom.xml create mode 100644 my-project-backend/src/main/java/com/example/MyProjectBackendApplication.java create mode 100644 my-project-backend/src/main/java/com/example/config/RabbitConfiguration.java create mode 100644 my-project-backend/src/main/java/com/example/config/SecurityConfiguration.java create mode 100644 my-project-backend/src/main/java/com/example/config/WebConfiguration.java create mode 100644 my-project-backend/src/main/java/com/example/controller/AuthorizeController.java create mode 100644 my-project-backend/src/main/java/com/example/controller/exception/ErrorPageController.java create mode 100644 my-project-backend/src/main/java/com/example/controller/exception/ValidationController.java create mode 100644 my-project-backend/src/main/java/com/example/entity/BaseData.java create mode 100644 my-project-backend/src/main/java/com/example/entity/RestBean.java create mode 100644 my-project-backend/src/main/java/com/example/entity/dto/Account.java create mode 100644 my-project-backend/src/main/java/com/example/entity/vo/request/EmailRegisterVO.java create mode 100644 my-project-backend/src/main/java/com/example/entity/vo/request/EmailResetVO.java create mode 100644 my-project-backend/src/main/java/com/example/entity/vo/response/AuthorizeVO.java create mode 100644 my-project-backend/src/main/java/com/example/filter/CorsFilter.java create mode 100644 my-project-backend/src/main/java/com/example/filter/FlowLimitingFilter.java create mode 100644 my-project-backend/src/main/java/com/example/filter/JwtAuthenticationFilter.java create mode 100644 my-project-backend/src/main/java/com/example/filter/RequestLogFilter.java create mode 100644 my-project-backend/src/main/java/com/example/listener/MailQueueListener.java create mode 100644 my-project-backend/src/main/java/com/example/mapper/AccountMapper.java create mode 100644 my-project-backend/src/main/java/com/example/service/AccountService.java create mode 100644 my-project-backend/src/main/java/com/example/service/impl/AccountServiceImpl.java create mode 100644 my-project-backend/src/main/java/com/example/utils/Const.java create mode 100644 my-project-backend/src/main/java/com/example/utils/FlowUtils.java create mode 100644 my-project-backend/src/main/java/com/example/utils/JwtUtils.java create mode 100644 my-project-backend/src/main/java/com/example/utils/SnowflakeIdGenerator.java create mode 100644 my-project-backend/src/main/resources/application-dev.yml create mode 100644 my-project-backend/src/main/resources/application-prod.yml create mode 100644 my-project-backend/src/main/resources/application.yml create mode 100644 my-project-backend/src/main/resources/logback-spring.xml create mode 100644 my-project-backend/src/test/java/com/example/MyProjectBackendApplicationTests.java create mode 100644 my-project-frontend/.gitignore create mode 100644 my-project-frontend/.vscode/extensions.json create mode 100644 my-project-frontend/README.md create mode 100644 my-project-frontend/index.html create mode 100644 my-project-frontend/my-project-frontend.iml create mode 100644 my-project-frontend/package-lock.json create mode 100644 my-project-frontend/package.json create mode 100644 my-project-frontend/public/favicon.ico create mode 100644 my-project-frontend/src/App.vue create mode 100644 my-project-frontend/src/assets/base.css create mode 100644 my-project-frontend/src/assets/logo.svg create mode 100644 my-project-frontend/src/assets/main.css create mode 100644 my-project-frontend/src/components/HelloWorld.vue create mode 100644 my-project-frontend/src/components/TheWelcome.vue create mode 100644 my-project-frontend/src/components/WelcomeItem.vue create mode 100644 my-project-frontend/src/components/icons/IconCommunity.vue create mode 100644 my-project-frontend/src/components/icons/IconDocumentation.vue create mode 100644 my-project-frontend/src/components/icons/IconEcosystem.vue create mode 100644 my-project-frontend/src/components/icons/IconSupport.vue create mode 100644 my-project-frontend/src/components/icons/IconTooling.vue create mode 100644 my-project-frontend/src/main.js create mode 100644 my-project-frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa592df --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +log/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cddde1c --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# 前后端分离项目(JWT方案) +采用SpringBoot3 + Vue3编写的前后端分离模版项目,集成多种技术栈,使用JWT校验方案。 +*** +### 后端功能与技术点 +* 用户注册、用户登录、重置密码 + +### 前端功能与技术点 diff --git a/my-project-backend/.gitignore b/my-project-backend/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/my-project-backend/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/my-project-backend/pom.xml b/my-project-backend/pom.xml new file mode 100644 index 0000000..154c56d --- /dev/null +++ b/my-project-backend/pom.xml @@ -0,0 +1,153 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.1.2 + + + com.example + my-project-backend + 0.0.1-SNAPSHOT + my-project-backend + my-project-backend + + 17 + + + + + org.springframework.boot + spring-boot-starter-mail + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + com.baomidou + mybatis-plus-boot-starter + 3.5.3.1 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + org.springframework.boot + spring-boot-starter-amqp + + + + com.alibaba.fastjson2 + fastjson2 + 2.0.25 + + + + com.auth0 + java-jwt + 4.3.0 + + + + + + + dev + + true + + + dev + + + + + prod + + false + + + prod + + + + + + + + org.graalvm.buildtools + native-maven-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + src/main/resources + + application*.yml + + + + src/main/resources + true + + application.yml + application-${environment}.yml + + + + + + diff --git a/my-project-backend/src/main/java/com/example/MyProjectBackendApplication.java b/my-project-backend/src/main/java/com/example/MyProjectBackendApplication.java new file mode 100644 index 0000000..00c9e04 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/MyProjectBackendApplication.java @@ -0,0 +1,13 @@ +package com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MyProjectBackendApplication { + + public static void main(String[] args) { + SpringApplication.run(MyProjectBackendApplication.class, args); + } + +} diff --git a/my-project-backend/src/main/java/com/example/config/RabbitConfiguration.java b/my-project-backend/src/main/java/com/example/config/RabbitConfiguration.java new file mode 100644 index 0000000..2e21a8f --- /dev/null +++ b/my-project-backend/src/main/java/com/example/config/RabbitConfiguration.java @@ -0,0 +1,19 @@ +package com.example.config; + +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.QueueBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * RabbitMQ消息队列配置 + */ +@Configuration +public class RabbitConfiguration { + @Bean("mailQueue") + public Queue queue(){ + return QueueBuilder + .durable("mail") + .build(); + } +} diff --git a/my-project-backend/src/main/java/com/example/config/SecurityConfiguration.java b/my-project-backend/src/main/java/com/example/config/SecurityConfiguration.java new file mode 100644 index 0000000..ec9b004 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/config/SecurityConfiguration.java @@ -0,0 +1,135 @@ +package com.example.config; + +import com.example.entity.RestBean; +import com.example.entity.dto.Account; +import com.example.entity.vo.response.AuthorizeVO; +import com.example.filter.JwtAuthenticationFilter; +import com.example.filter.RequestLogFilter; +import com.example.service.AccountService; +import com.example.utils.Const; +import com.example.utils.JwtUtils; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.io.IOException; +import java.io.PrintWriter; + +/** + * SpringSecurity相关配置 + */ +@Configuration +public class SecurityConfiguration { + + @Resource + JwtAuthenticationFilter jwtAuthenticationFilter; + + @Resource + RequestLogFilter requestLogFilter; + + @Resource + JwtUtils utils; + + @Resource + AccountService service; + + /** + * 针对于 SpringSecurity 6 的新版配置方法 + * @param http 配置器 + * @return 自动构建的内置过滤器链 + * @throws Exception 可能的异常 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .authorizeHttpRequests(conf -> conf + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/error").permitAll() + .anyRequest().hasAnyRole(Const.ROLE_DEFAULT) + ) + .formLogin(conf -> conf + .loginProcessingUrl("/api/auth/login") + .failureHandler(this::handleProcess) + .successHandler(this::handleProcess) + .permitAll() + ) + .logout(conf -> conf + .logoutUrl("/api/auth/logout") + .logoutSuccessHandler(this::onLogoutSuccess) + ) + .exceptionHandling(conf -> conf + .accessDeniedHandler(this::handleProcess) + .authenticationEntryPoint(this::handleProcess) + ) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(conf -> conf + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(requestLogFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, RequestLogFilter.class) + .build(); + } + + /** + * 将多种类型的Handler整合到同一个方法中,包含: + * - 登录成功 + * - 登录失败 + * - 未登录拦截/无权限拦截 + * @param request 请求 + * @param response 响应 + * @param exceptionOrAuthentication 异常或是验证实体 + * @throws IOException 可能的异常 + */ + private void handleProcess(HttpServletRequest request, + HttpServletResponse response, + Object exceptionOrAuthentication) throws IOException { + response.setContentType("application/json;charset=utf-8"); + PrintWriter writer = response.getWriter(); + if(exceptionOrAuthentication instanceof AccessDeniedException exception) { + writer.write(RestBean + .forbidden(exception.getMessage()).asJsonString()); + } else if(exceptionOrAuthentication instanceof Exception exception) { + writer.write(RestBean + .unauthorized(exception.getMessage()).asJsonString()); + } else if(exceptionOrAuthentication instanceof Authentication authentication){ + User user = (User) authentication.getPrincipal(); + Account account = service.findAccountByNameOrEmail(user.getUsername()); + String jwt = utils.createJwt(user, account.getUsername(), account.getId()); + if(jwt == null) { + writer.write(RestBean.forbidden("登录验证频繁,请稍后再试").asJsonString()); + } else { + AuthorizeVO vo = account.asViewObject(AuthorizeVO.class, o -> o.setToken(jwt)); + writer.write(RestBean.success(vo).asJsonString()); + } + } + } + + /** + * 退出登录处理,将对应的Jwt令牌列入黑名单不再使用 + * @param request 请求 + * @param response 响应 + * @param authentication 验证实体 + * @throws IOException 可能的异常 + */ + private void onLogoutSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + response.setContentType("application/json;charset=utf-8"); + PrintWriter writer = response.getWriter(); + String authorization = request.getHeader("Authorization"); + if(utils.invalidateJwt(authorization)) { + writer.write(RestBean.success("退出登录成功").asJsonString()); + return; + } + writer.write(RestBean.failure(400, "退出登录失败").asJsonString()); + } +} diff --git a/my-project-backend/src/main/java/com/example/config/WebConfiguration.java b/my-project-backend/src/main/java/com/example/config/WebConfiguration.java new file mode 100644 index 0000000..991163b --- /dev/null +++ b/my-project-backend/src/main/java/com/example/config/WebConfiguration.java @@ -0,0 +1,19 @@ +package com.example.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 一般Web服务相关配置 + */ +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + @Bean + public PasswordEncoder passwordEncoder(){ + return new BCryptPasswordEncoder(); + } +} diff --git a/my-project-backend/src/main/java/com/example/controller/AuthorizeController.java b/my-project-backend/src/main/java/com/example/controller/AuthorizeController.java new file mode 100644 index 0000000..d43b670 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/controller/AuthorizeController.java @@ -0,0 +1,78 @@ +package com.example.controller; + +import com.example.entity.RestBean; +import com.example.entity.vo.request.EmailRegisterVO; +import com.example.entity.vo.request.EmailResetVO; +import com.example.service.AccountService; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.function.Supplier; + +/** + * 用于验证相关Controller包含用户的注册、重置密码等操作 + */ +@Validated +@RestController +@RequestMapping("/api/auth") +public class AuthorizeController { + + @Resource + AccountService accountService; + + /** + * 请求邮件验证码 + * @param email 邮件 + * @param request 请求 + * @return 是否请求成功 + */ + @PostMapping("/ask-code") + public RestBean askVerifyCode(@RequestParam @Email String email, + HttpServletRequest request){ + return this.messageHandle(() -> + accountService.registerEmailVerifyCode(String.valueOf(email), request.getRemoteAddr())); + } + + /** + * 进行用户注册操作,需要先请求邮件验证码 + * @param vo 注册信息 + * @return 是否注册成功 + */ + @PostMapping("/register") + public RestBean register(@Valid EmailRegisterVO vo){ + return this.messageHandle(() -> + accountService.registerEmailAccount(vo)); + } + + /** + * 执行密码重置操作 + * @param vo 密码重置信息 + * @return 是否操作成功 + */ + @PostMapping("/reset-password") + public RestBean reset(@Valid EmailResetVO vo){ + return this.messageHandle(() -> + accountService.resetEmailAccountPassword(vo)); + } + + /** + * 针对于返回值为String作为错误信息的方法进行统一处理 + * @param action 具体操作 + * @return 响应结果 + * @param 响应结果类型 + */ + private RestBean messageHandle(Supplier action){ + String message = action.get(); + if(message == null) + return RestBean.success(); + else + return RestBean.failure(400, message); + } +} diff --git a/my-project-backend/src/main/java/com/example/controller/exception/ErrorPageController.java b/my-project-backend/src/main/java/com/example/controller/exception/ErrorPageController.java new file mode 100644 index 0000000..b191a26 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/controller/exception/ErrorPageController.java @@ -0,0 +1,66 @@ +package com.example.controller.exception; + +import com.example.entity.RestBean; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.autoconfigure.web.servlet.error.AbstractErrorController; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.servlet.error.ErrorAttributes; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Optional; + +/** + * 专用用于处理错误页面的Controller + */ +@RestController +@RequestMapping({"${server.error.path:${error.path:/error}}"}) +public class ErrorPageController extends AbstractErrorController { + + public ErrorPageController(ErrorAttributes errorAttributes) { + super(errorAttributes); + } + + /** + * 所有错误在这里统一处理,自动解析状态码和原因 + * @param request 请求 + * @return 失败响应 + */ + @RequestMapping + public RestBean error(HttpServletRequest request) { + HttpStatus status = this.getStatus(request); + Map errorAttributes = this.getErrorAttributes(request, this.getAttributeOptions()); + String message = this.convertErrorMessage(status) + .orElse(errorAttributes.get("message").toString()); + return RestBean.failure(status.value(), message); + } + + /** + * 对于一些特殊的状态码,错误信息转换 + * @param status 状态码 + * @return 错误信息 + */ + private Optional convertErrorMessage(HttpStatus status){ + String value = switch (status.value()) { + case 400 -> "请求参数有误"; + case 404 -> "请求的接口不存在"; + case 405 -> "请求方法错误"; + case 500 -> "内部错误,请联系管理员"; + default -> null; + }; + return Optional.ofNullable(value); + } + + /** + * 错误属性获取选项,这里额外添加了错误消息和异常类型 + * @return 选项 + */ + private ErrorAttributeOptions getAttributeOptions(){ + return ErrorAttributeOptions + .defaults() + .including(ErrorAttributeOptions.Include.MESSAGE, + ErrorAttributeOptions.Include.EXCEPTION); + } +} diff --git a/my-project-backend/src/main/java/com/example/controller/exception/ValidationController.java b/my-project-backend/src/main/java/com/example/controller/exception/ValidationController.java new file mode 100644 index 0000000..403ca87 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/controller/exception/ValidationController.java @@ -0,0 +1,26 @@ +package com.example.controller.exception; + +import com.example.entity.RestBean; +import jakarta.validation.ValidationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 用于接口参数校验处理的控制器 + */ +@Slf4j +@RestControllerAdvice +public class ValidationController { + + /** + * 与SpringBoot保持一致,校验不通过打印警告信息,而不是直接抛出异常 + * @param exception 验证异常 + * @return 校验结果 + */ + @ExceptionHandler(ValidationException.class) + public RestBean validateError(ValidationException exception) { + log.warn("Resolved [{}: {}]", exception.getClass().getName(), exception.getMessage()); + return RestBean.failure(400, "请求参数有误"); + } +} diff --git a/my-project-backend/src/main/java/com/example/entity/BaseData.java b/my-project-backend/src/main/java/com/example/entity/BaseData.java new file mode 100644 index 0000000..7906fb8 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/entity/BaseData.java @@ -0,0 +1,61 @@ +package com.example.entity; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.function.Consumer; + +/** + * 用于DTO快速转换VO实现,只需将DTO类继承此类即可使用 + */ +public interface BaseData { + /** + * 创建指定的VO类并将当前DTO对象中的所有成员变量值直接复制到VO对象中 + * @param clazz 指定VO类型 + * @param consumer 返回VO对象之前可以使用Lambda进行额外处理 + * @return 指定VO对象 + * @param 指定VO类型 + */ + default V asViewObject(Class clazz, Consumer consumer) { + V v = this.asViewObject(clazz); + consumer.accept(v); + return v; + } + + /** + * 创建指定的VO类并将当前DTO对象中的所有成员变量值直接复制到VO对象中 + * @param clazz 指定VO类型 + * @return 指定VO对象 + * @param 指定VO类型 + */ + default V asViewObject(Class clazz) { + try { + Field[] fields = clazz.getDeclaredFields(); + Constructor constructor = clazz.getConstructor(); + V v = constructor.newInstance(); + Arrays.asList(fields).forEach(field -> convert(field, v)); + return v; + } catch (ReflectiveOperationException exception) { + Logger logger = LoggerFactory.getLogger(BaseData.class); + logger.error("在VO与DTO转换时出现了一些错误", exception); + throw new RuntimeException(exception.getMessage()); + } + } + + /** + * 内部使用,快速将当前类中目标对象字段同名字段的值复制到目标对象字段上 + * @param field 目标对象字段 + * @param target 目标对象 + */ + private void convert(Field field, Object target){ + try { + Field source = this.getClass().getDeclaredField(field.getName()); + field.setAccessible(true); + source.setAccessible(true); + field.set(target, source.get(this)); + } catch (IllegalAccessException | NoSuchFieldException ignored) {} + } +} diff --git a/my-project-backend/src/main/java/com/example/entity/RestBean.java b/my-project-backend/src/main/java/com/example/entity/RestBean.java new file mode 100644 index 0000000..d4f9fd3 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/entity/RestBean.java @@ -0,0 +1,53 @@ +package com.example.entity; + +import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.JSONWriter; +import org.slf4j.MDC; + +import java.util.Optional; + +/** + * 响应实体类封装,Rest风格 + * @param code 状态码 + * @param data 响应数据 + * @param message 其他消息 + * @param 响应数据类型 + */ +public record RestBean (long id, int code, T data, String message) { + public static RestBean success(T data){ + return new RestBean<>(requestId(), 200, data, "请求成功"); + } + + public static RestBean success(){ + return success(null); + } + + public static RestBean forbidden(String message){ + return failure(403, message); + } + + public static RestBean unauthorized(String message){ + return failure(401, message); + } + + public static RestBean failure(int code, String message){ + return new RestBean<>(requestId(), code, null, message); + } + + /** + * 快速将当前实体转换为JSON字符串格式 + * @return JSON字符串 + */ + public String asJsonString() { + return JSONObject.toJSONString(this, JSONWriter.Feature.WriteNulls); + } + + /** + * 获取当前请求ID方便快速定位错误 + * @return ID + */ + private static long requestId(){ + String requestId = Optional.ofNullable(MDC.get("reqId")).orElse("0"); + return Long.parseLong(requestId); + } +} diff --git a/my-project-backend/src/main/java/com/example/entity/dto/Account.java b/my-project-backend/src/main/java/com/example/entity/dto/Account.java new file mode 100644 index 0000000..3fbfa1f --- /dev/null +++ b/my-project-backend/src/main/java/com/example/entity/dto/Account.java @@ -0,0 +1,26 @@ +package com.example.entity.dto; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.example.entity.BaseData; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.Date; + +/** + * 数据库中的用户信息 + */ +@Data +@TableName("db_account") +@AllArgsConstructor +public class Account implements BaseData { + @TableId(type = IdType.AUTO) + Integer id; + String username; + String password; + String email; + String role; + Date registerTime; +} diff --git a/my-project-backend/src/main/java/com/example/entity/vo/request/EmailRegisterVO.java b/my-project-backend/src/main/java/com/example/entity/vo/request/EmailRegisterVO.java new file mode 100644 index 0000000..2bc6b30 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/entity/vo/request/EmailRegisterVO.java @@ -0,0 +1,22 @@ +package com.example.entity.vo.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + +/** + * 用户注册表单信息 + */ +@Data +public class EmailRegisterVO { + @Email + String email; + @Length(max = 6, min = 6) + String code; + @Length(min = 1, max = 10) + String username; + @Length(min = 6, max = 20) + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@#$%^&+=]).*$") + String password; +} diff --git a/my-project-backend/src/main/java/com/example/entity/vo/request/EmailResetVO.java b/my-project-backend/src/main/java/com/example/entity/vo/request/EmailResetVO.java new file mode 100644 index 0000000..e2f9f73 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/entity/vo/request/EmailResetVO.java @@ -0,0 +1,19 @@ +package com.example.entity.vo.request; + +import jakarta.validation.constraints.Email; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + +/** + * 密码重置表单实体 + */ +@Data +public class EmailResetVO { + @Email + String email; + @Length(max = 6, min = 6) + String code; + @Length(min = 6, max = 20) + //@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@#$%^&+=]).*$") + String password; +} diff --git a/my-project-backend/src/main/java/com/example/entity/vo/response/AuthorizeVO.java b/my-project-backend/src/main/java/com/example/entity/vo/response/AuthorizeVO.java new file mode 100644 index 0000000..e7673eb --- /dev/null +++ b/my-project-backend/src/main/java/com/example/entity/vo/response/AuthorizeVO.java @@ -0,0 +1,13 @@ +package com.example.entity.vo.response; + +import lombok.Data; + +/** + * 登录验证成功的用户信息响应 + */ +@Data +public class AuthorizeVO { + String username; + String role; + String token; +} diff --git a/my-project-backend/src/main/java/com/example/filter/CorsFilter.java b/my-project-backend/src/main/java/com/example/filter/CorsFilter.java new file mode 100644 index 0000000..dd92cd4 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/filter/CorsFilter.java @@ -0,0 +1,66 @@ +package com.example.filter; + +import com.example.utils.Const; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 跨域配置过滤器,仅处理跨域,添加跨域响应头 + */ +@Component +@Order(Const.ORDER_CORS) +public class CorsFilter extends HttpFilter { + + @Value("${spring.web.cors.origin}") + String origin; + + @Value("${spring.web.cors.credentials}") + boolean credentials; + + @Value("${spring.web.cors.methods}") + String methods; + + @Override + protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + chain.doFilter(request, response); + this.addCorsHeader(request, response); + } + + /** + * 添加所有跨域相关响应头 + * @param request 请求 + * @param response 响应 + */ + private void addCorsHeader(HttpServletRequest request, HttpServletResponse response) { + response.addHeader("Access-Control-Allow-Origin", this.resolveMethod()); + response.addHeader("Access-Control-Allow-Methods", this.resolveOrigin(request)); + if(credentials) { + response.addHeader("Access-Control-Allow-Credentials", "true"); + } + } + + /** + * 解析配置文件中的请求方法 + * @return 解析得到的请求头值 + */ + private String resolveMethod(){ + return methods; + } + + /** + * 解析配置文件中的请求原始站点 + * @param request 请求 + * @return 解析得到的请求头值 + */ + private String resolveOrigin(HttpServletRequest request){ + return origin; + } +} diff --git a/my-project-backend/src/main/java/com/example/filter/FlowLimitingFilter.java b/my-project-backend/src/main/java/com/example/filter/FlowLimitingFilter.java new file mode 100644 index 0000000..f7aaceb --- /dev/null +++ b/my-project-backend/src/main/java/com/example/filter/FlowLimitingFilter.java @@ -0,0 +1,80 @@ +package com.example.filter; + +import com.example.entity.RestBean; +import com.example.utils.Const; +import com.example.utils.FlowUtils; +import jakarta.annotation.Resource; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.annotation.Order; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.PrintWriter; + +/** + * 限流控制过滤器 + * 防止用户高频请求接口,借助Redis进行限流 + */ +@Slf4j +@Component +@Order(Const.ORDER_FLOW_LIMIT) +public class FlowLimitingFilter extends HttpFilter { + + @Resource + StringRedisTemplate template; + //指定时间内最大请求次数限制 + @Value("${spring.web.flow.limit}") + int limit; + //计数时间周期 + @Value("${spring.web.flow.period}") + int period; + //超出请求限制封禁时间 + @Value("${spring.web.flow.block}") + int block; + + @Resource + FlowUtils utils; + + @Override + protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + String address = request.getRemoteAddr(); + if (!tryCount(address)) + this.writeBlockMessage(response); + else + chain.doFilter(request, response); + } + + /** + * 尝试对指定IP地址请求计数,如果被限制则无法继续访问 + * @param address 请求IP地址 + * @return 是否操作成功 + */ + private boolean tryCount(String address) { + synchronized (address.intern()) { + if(Boolean.TRUE.equals(template.hasKey(Const.FLOW_LIMIT_BLOCK + address))) + return false; + String counterKey = Const.FLOW_LIMIT_COUNTER + address; + String blockKey = Const.FLOW_LIMIT_BLOCK + address; + return utils.limitPeriodCheck(counterKey, blockKey, block, limit, period); + } + } + + /** + * 为响应编写拦截内容,提示用户操作频繁 + * @param response 响应 + * @throws IOException 可能的异常 + */ + private void writeBlockMessage(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=utf-8"); + PrintWriter writer = response.getWriter(); + writer.write(RestBean.forbidden("操作频繁,请稍后再试").asJsonString()); + } +} diff --git a/my-project-backend/src/main/java/com/example/filter/JwtAuthenticationFilter.java b/my-project-backend/src/main/java/com/example/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..d110125 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/filter/JwtAuthenticationFilter.java @@ -0,0 +1,46 @@ +package com.example.filter; + +import com.auth0.jwt.interfaces.DecodedJWT; +import com.example.utils.Const; +import com.example.utils.JwtUtils; +import jakarta.annotation.Resource; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 用于对请求头中Jwt令牌进行校验的工具,为当前请求添加用户验证信息 + * 并将用户的ID存放在请求对象属性中,方便后续使用 + */ +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + @Resource + JwtUtils utils; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String authorization = request.getHeader("Authorization"); + DecodedJWT jwt = utils.resolveJwt(authorization); + if(jwt != null) { + UserDetails user = utils.toUser(jwt); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + request.setAttribute(Const.ATTR_USER_ID, utils.toId(jwt)); + } + filterChain.doFilter(request, response); + } +} diff --git a/my-project-backend/src/main/java/com/example/filter/RequestLogFilter.java b/my-project-backend/src/main/java/com/example/filter/RequestLogFilter.java new file mode 100644 index 0000000..db12c91 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/filter/RequestLogFilter.java @@ -0,0 +1,74 @@ +package com.example.filter; + +import com.alibaba.fastjson2.JSONObject; +import com.example.utils.Const; +import com.example.utils.SnowflakeIdGenerator; +import jakarta.annotation.Resource; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; + +/** + * 请求日志过滤器,用于记录所有用户请求信息 + */ +@Slf4j +@Component +public class RequestLogFilter extends OncePerRequestFilter { + + @Resource + SnowflakeIdGenerator generator; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + long startTime = System.currentTimeMillis(); + this.logRequestStart(request); + ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response); + filterChain.doFilter(request, wrapper); + this.logRequestEnd(wrapper, startTime); + wrapper.copyBodyToResponse(); + } + + /** + * 请求结束时的日志打印,包含处理耗时以及响应结果 + * @param wrapper 用于读取响应结果的包装类 + * @param startTime 起始时间 + */ + public void logRequestEnd(ContentCachingResponseWrapper wrapper, long startTime){ + long time = System.currentTimeMillis() - startTime; + int status = wrapper.getStatus(); + String content = status != 200 ? + status + " 错误" : new String(wrapper.getContentAsByteArray()); + log.info("请求处理耗时: {}ms | 响应结果: {}", time, content); + } + + /** + * 请求开始时的日志打印,包含请求全部信息,以及对应用户角色 + * @param request 请求 + */ + public void logRequestStart(HttpServletRequest request){ + long reqId = generator.nextId(); + MDC.put("reqId", String.valueOf(reqId)); + JSONObject object = new JSONObject(); + request.getParameterMap().forEach((k, v) -> object.put(k, v.length > 0 ? v[0] : null)); + Object id = request.getAttribute(Const.ATTR_USER_ID); + if(id != null) { + User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + log.info("请求URL: \"{}\" ({}) | 远程IP地址: {} │ 身份: {} (UID: {}) | 角色: {} | 请求参数列表: {}", + request.getServletPath(), request.getMethod(), request.getRemoteAddr(), + user.getUsername(), id, user.getAuthorities(), object); + } else { + log.info("请求URL: \"{}\" ({}) | 远程IP地址: {} │ 身份: 未验证 | 请求参数列表: {}", + request.getServletPath(), request.getMethod(), request.getRemoteAddr(), object); + } + } +} diff --git a/my-project-backend/src/main/java/com/example/listener/MailQueueListener.java b/my-project-backend/src/main/java/com/example/listener/MailQueueListener.java new file mode 100644 index 0000000..df16f0b --- /dev/null +++ b/my-project-backend/src/main/java/com/example/listener/MailQueueListener.java @@ -0,0 +1,64 @@ +package com.example.listener; + +import jakarta.annotation.Resource; +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 用于处理邮件发送的消息队列监听器 + */ +@Component +@RabbitListener(queues = "mail") +public class MailQueueListener { + + @Resource + JavaMailSender sender; + + @Value("${spring.mail.username}") + String username; + + /** + * 处理邮件发送 + * @param data 邮件信息 + */ + @RabbitHandler + public void sendMailMessage(Map data) { + String email = data.get("email").toString(); + Integer code = (Integer) data.get("code"); + SimpleMailMessage message = switch (data.get("type").toString()) { + case "register" -> + createMessage("欢迎注册我们的网站", + "您的邮件注册验证码为: "+code+",有效时间3分钟,为了保障您的账户安全,请勿向他人泄露验证码信息。", + email); + case "reset" -> + createMessage("您的密码重置邮件", + "你好,您正在执行重置密码操作,验证码: "+code+",有效时间3分钟,如非本人操作,请无视。", + email); + default -> null; + }; + if(message == null) return; + sender.send(message); + } + + /** + * 快速封装简单邮件消息实体 + * @param title 标题 + * @param content 内容 + * @param email 收件人 + * @return 邮件实体 + */ + private SimpleMailMessage createMessage(String title, String content, String email){ + SimpleMailMessage message = new SimpleMailMessage(); + message.setSubject(title); + message.setText(content); + message.setTo(email); + message.setFrom(username); + return message; + } +} diff --git a/my-project-backend/src/main/java/com/example/mapper/AccountMapper.java b/my-project-backend/src/main/java/com/example/mapper/AccountMapper.java new file mode 100644 index 0000000..9639d4d --- /dev/null +++ b/my-project-backend/src/main/java/com/example/mapper/AccountMapper.java @@ -0,0 +1,9 @@ +package com.example.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.example.entity.dto.Account; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AccountMapper extends BaseMapper { +} diff --git a/my-project-backend/src/main/java/com/example/service/AccountService.java b/my-project-backend/src/main/java/com/example/service/AccountService.java new file mode 100644 index 0000000..432048d --- /dev/null +++ b/my-project-backend/src/main/java/com/example/service/AccountService.java @@ -0,0 +1,14 @@ +package com.example.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.example.entity.dto.Account; +import com.example.entity.vo.request.EmailRegisterVO; +import com.example.entity.vo.request.EmailResetVO; +import org.springframework.security.core.userdetails.UserDetailsService; + +public interface AccountService extends IService, UserDetailsService { + Account findAccountByNameOrEmail(String text); + String registerEmailVerifyCode(String email, String address); + String registerEmailAccount(EmailRegisterVO info); + String resetEmailAccountPassword(EmailResetVO info); +} diff --git a/my-project-backend/src/main/java/com/example/service/impl/AccountServiceImpl.java b/my-project-backend/src/main/java/com/example/service/impl/AccountServiceImpl.java new file mode 100644 index 0000000..c72ab25 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/service/impl/AccountServiceImpl.java @@ -0,0 +1,185 @@ +package com.example.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.example.entity.dto.Account; +import com.example.entity.vo.request.EmailRegisterVO; +import com.example.entity.vo.request.EmailResetVO; +import com.example.mapper.AccountMapper; +import com.example.service.AccountService; +import com.example.utils.Const; +import com.example.utils.FlowUtils; +import jakarta.annotation.Resource; +import org.springframework.amqp.core.AmqpTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +/** + * 账户信息处理相关服务 + */ +@Service +public class AccountServiceImpl extends ServiceImpl implements AccountService { + + //验证邮件发送冷却时间限制,秒为单位 + @Value("${spring.web.verify.mail-limit}") + int verifyLimit; + + @Resource + AmqpTemplate rabbitTemplate; + + @Resource + StringRedisTemplate stringRedisTemplate; + + @Resource + PasswordEncoder passwordEncoder; + + @Resource + FlowUtils flow; + + /** + * 从数据库中通过用户名或邮箱查找用户详细信息 + * @param username 用户名 + * @return 用户详细信息 + * @throws UsernameNotFoundException 如果用户未找到则抛出此异常 + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Account account = this.findAccountByNameOrEmail(username); + if(account == null) + throw new UsernameNotFoundException("用户名或密码错误"); + return User + .withUsername(username) + .password(account.getPassword()) + .roles(account.getRole()) + .build(); + } + + /** + * 生成注册验证码存入Redis中,并将邮件发送请求提交到消息队列等待发送 + * @param email 邮件地址 + * @param address 请求IP地址 + * @return 操作结果,null表示正常,否则为错误原因 + */ + public String registerEmailVerifyCode(String email, String address){ + synchronized (address.intern()) { + if(!this.verifyLimit(address)) + return "请求频繁,请稍后再试"; + Random random = new Random(); + int code = random.nextInt(899999) + 100000; + Map data = Map.of("type","register","email", email, "code", code); + rabbitTemplate.convertAndSend(Const.MQ_MAIL, data); + stringRedisTemplate.opsForValue() + .set(Const.VERIFY_EMAIL_DATA + email, String.valueOf(code), 3, TimeUnit.MINUTES); + return null; + } + } + + /** + * 邮件验证码注册账号操作,需要检查验证码是否正确以及邮箱、用户名是否存在重名 + * @param info 注册基本信息 + * @return 操作结果,null表示正常,否则为错误原因 + */ + public String registerEmailAccount(EmailRegisterVO info){ + String email = info.getEmail(); + String code = this.getEmailVerifyCode(email); + if(code == null) return "请先获取验证码"; + if(!code.equals(info.getCode())) return "验证码错误,请重新输入"; + if(this.existsAccountByEmail(email)) return "该邮件地址已被注册"; + String username = info.getUsername(); + if(this.existsAccountByUsername(username)) return "该用户名已被他人使用,请重新更换"; + String password = passwordEncoder.encode(info.getPassword()); + Account account = new Account(null, info.getUsername(), + password, email, Const.ROLE_DEFAULT, new Date()); + if(!this.save(account)) { + return "内部错误,注册失败"; + } else { + this.deleteEmailVerifyCode(email); + return null; + } + } + + /** + * 邮件验证码重置密码操作,需要检查验证码是否正确 + * @param info 重置基本信息 + * @return 操作结果,null表示正常,否则为错误原因 + */ + @Override + public String resetEmailAccountPassword(EmailResetVO info) { + String email = info.getEmail(); + String code = this.getEmailVerifyCode(email); + if(code == null) return "请先获取验证码"; + if(!code.equals(info.getCode())) return "验证码错误,请重新输入"; + String password = passwordEncoder.encode(info.getPassword()); + boolean update = this.update().eq("email", email).set("password", password).update(); + return update ? null : "更新失败,请联系管理员"; + } + + /** + * 移除Redis中存储的邮件验证码 + * @param email 电邮 + */ + private void deleteEmailVerifyCode(String email){ + String key = Const.VERIFY_EMAIL_DATA + email; + stringRedisTemplate.delete(key); + } + + /** + * 获取Redis中存储的邮件验证码 + * @param email 电邮 + * @return 验证码 + */ + private String getEmailVerifyCode(String email){ + String key = Const.VERIFY_EMAIL_DATA + email; + return stringRedisTemplate.opsForValue().get(key); + } + + /** + * 针对IP地址进行邮件验证码获取限流 + * @param address 地址 + * @return 是否通过验证 + */ + private boolean verifyLimit(String address) { + String key = Const.VERIFY_EMAIL_LIMIT + address; + return flow.limitOnceCheck(key, verifyLimit); + } + + /** + * 通过用户名或邮件地址查找用户 + * @param text 用户名或邮件 + * @return 账户实体 + */ + public Account findAccountByNameOrEmail(String text){ + return this.query() + .eq("username", text).or() + .eq("email", text) + .one(); + } + + /** + * 查询指定邮箱的用户是否已经存在 + * @param email 邮箱 + * @return 是否存在 + */ + private boolean existsAccountByEmail(String email){ + return this.baseMapper.exists(Wrappers.query().eq("email", email)); + } + + /** + * 查询指定用户名的用户是否已经存在 + * @param username 用户名 + * @return 是否存在 + */ + private boolean existsAccountByUsername(String username){ + return this.baseMapper.exists(Wrappers.query().eq("username", username)); + } +} diff --git a/my-project-backend/src/main/java/com/example/utils/Const.java b/my-project-backend/src/main/java/com/example/utils/Const.java new file mode 100644 index 0000000..941bf74 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/utils/Const.java @@ -0,0 +1,26 @@ +package com.example.utils; + +/** + * 一些常量字符串整合 + */ +public final class Const { + //JWT令牌 + public final static String JWT_BLACK_LIST = "jwt:blacklist:"; + public final static String JWT_FREQUENCY = "jwt:frequency:"; + //请求频率限制 + public final static String FLOW_LIMIT_COUNTER = "flow:counter:"; + public final static String FLOW_LIMIT_BLOCK = "flow:block:"; + //邮件验证码 + public final static String VERIFY_EMAIL_LIMIT = "verify:email:limit:"; + public final static String VERIFY_EMAIL_DATA = "verify:email:data:"; + //过滤器优先级 + public final static int ORDER_FLOW_LIMIT = -101; + public final static int ORDER_CORS = -102; + //请求自定义属性 + public final static String ATTR_USER_ID = "userId"; + //消息队列 + public final static String MQ_MAIL = "mail"; + //用户角色 + public final static String ROLE_DEFAULT = "user"; + +} diff --git a/my-project-backend/src/main/java/com/example/utils/FlowUtils.java b/my-project-backend/src/main/java/com/example/utils/FlowUtils.java new file mode 100644 index 0000000..f5b0af6 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/utils/FlowUtils.java @@ -0,0 +1,90 @@ +package com.example.utils; + +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * 限流通用工具 + * 针对于不同的情况进行限流操作,支持限流升级 + */ +@Slf4j +@Component +public class FlowUtils { + + @Resource + StringRedisTemplate template; + + /** + * 针对于单次频率限制,请求成功后,在冷却时间内不得再次进行请求,如3秒内不能再次发起请求 + * @param key 键 + * @param blockTime 限制时间 + * @return 是否通过限流检查 + */ + public boolean limitOnceCheck(String key, int blockTime){ + return this.internalCheck(key, 1, blockTime, (overclock) -> false); + } + + /** + * 针对于单次频率限制,请求成功后,在冷却时间内不得再次进行请求 + * 如3秒内不能再次发起请求,如果不听劝阻继续发起请求,将限制更长时间 + * @param key 键 + * @param frequency 请求频率 + * @param baseTime 基础限制时间 + * @param upgradeTime 升级限制时间 + * @return 是否通过限流检查 + */ + public boolean limitOnceUpgradeCheck(String key, int frequency, int baseTime, int upgradeTime){ + return this.internalCheck(key, frequency, baseTime, (overclock) -> { + if (overclock) + template.opsForValue().set(key, "1", upgradeTime, TimeUnit.SECONDS); + return false; + }); + } + + /** + * 针对于在时间段内多次请求限制,如3秒内限制请求20次,超出频率则封禁一段时间 + * @param counterKey 计数键 + * @param blockKey 封禁键 + * @param blockTime 封禁时间 + * @param frequency 请求频率 + * @param period 计数周期 + * @return 是否通过限流检查 + */ + public boolean limitPeriodCheck(String counterKey, String blockKey, int blockTime, int frequency, int period){ + return this.internalCheck(counterKey, frequency, period, (overclock) -> { + if (overclock) + template.opsForValue().set(blockKey, "", blockTime, TimeUnit.SECONDS); + return !overclock; + }); + } + + /** + * 内部使用请求限制主要逻辑 + * @param key 计数键 + * @param frequency 请求频率 + * @param period 计数周期 + * @param action 限制行为与策略 + * @return 是否通过限流检查 + */ + private boolean internalCheck(String key, int frequency, int period, LimitAction action){ + if (Boolean.TRUE.equals(template.hasKey(key))) { + Long value = Optional.ofNullable(template.opsForValue().increment(key)).orElse(0L); + return action.run(value > frequency); + } else { + template.opsForValue().set(key, "1", period, TimeUnit.SECONDS); + return true; + } + } + + /** + * 内部使用,限制行为与策略 + */ + private interface LimitAction { + boolean run(boolean overclock); + } +} diff --git a/my-project-backend/src/main/java/com/example/utils/JwtUtils.java b/my-project-backend/src/main/java/com/example/utils/JwtUtils.java new file mode 100644 index 0000000..5d88428 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/utils/JwtUtils.java @@ -0,0 +1,180 @@ +package com.example.utils; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * 用于处理Jwt令牌的工具类 + */ +@Component +public class JwtUtils { + + //用于给Jwt令牌签名校验的秘钥 + @Value("${spring.security.jwt.key}") + private String key; + //令牌的过期时间,以小时为单位 + @Value("${spring.security.jwt.expire}") + private int expire; + //为用户生成Jwt令牌的冷却时间,防止刷接口频繁登录生成令牌,以秒为单位 + @Value("${spring.security.jwt.limit.base}") + private int limit_base; + //用户如果继续恶意刷令牌,更严厉的封禁时间 + @Value("${spring.security.jwt.limit.upgrade}") + private int limit_upgrade; + //判定用户在冷却时间内,继续恶意刷令牌的次数 + @Value("${spring.security.jwt.limit.frequency}") + private int limit_frequency; + + @Resource + StringRedisTemplate template; + + @Resource + FlowUtils utils; + + /** + * 让指定Jwt令牌失效 + * @param headerToken 请求头中携带的令牌 + * @return 是否操作成功 + */ + public boolean invalidateJwt(String headerToken){ + String token = this.convertToken(headerToken); + Algorithm algorithm = Algorithm.HMAC256(key); + JWTVerifier jwtVerifier = JWT.require(algorithm).build(); + try { + DecodedJWT verify = jwtVerifier.verify(token); + return deleteToken(verify.getId(), verify.getExpiresAt()); + } catch (JWTVerificationException e) { + return false; + } + } + + /** + * 根据UserDetails生成对应的Jwt令牌 + * @param user 用户信息 + * @return 令牌 + */ + public String createJwt(UserDetails user, String username, int userId) { + if(this.frequencyCheck(userId)) { + Algorithm algorithm = Algorithm.HMAC256(key); + Calendar calendar = Calendar.getInstance(); + Date now = calendar.getTime(); + calendar.add(Calendar.HOUR, expire); + return JWT.create() + .withJWTId(UUID.randomUUID().toString()) + .withClaim("id", userId) + .withClaim("name", username) + .withClaim("authorities", user.getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority).toList()) + .withExpiresAt(calendar.getTime()) + .withIssuedAt(now) + .sign(algorithm); + } else { + return null; + } + } + + /** + * 解析Jwt令牌 + * @param headerToken 请求头中携带的令牌 + * @return DecodedJWT + */ + public DecodedJWT resolveJwt(String headerToken){ + String token = this.convertToken(headerToken); + if(token == null) return null; + Algorithm algorithm = Algorithm.HMAC256(key); + JWTVerifier jwtVerifier = JWT.require(algorithm).build(); + try { + DecodedJWT verify = jwtVerifier.verify(token); + if(this.isInvalidToken(verify.getId())) return null; + Map claims = verify.getClaims(); + return new Date().after(claims.get("exp").asDate()) ? null : verify; + } catch (JWTVerificationException e) { + return null; + } + } + + /** + * 将jwt对象中的内容封装为UserDetails + * @param jwt 已解析的Jwt对象 + * @return UserDetails + */ + public UserDetails toUser(DecodedJWT jwt) { + Map claims = jwt.getClaims(); + return User + .withUsername(claims.get("name").asString()) + .password("******") + .authorities(claims.get("authorities").asArray(String.class)) + .build(); + } + + /** + * 将jwt对象中的用户ID提取出来 + * @param jwt 已解析的Jwt对象 + * @return 用户ID + */ + public Integer toId(DecodedJWT jwt) { + Map claims = jwt.getClaims(); + return claims.get("id").asInt(); + } + + /** + * 频率检测,防止用户高频申请Jwt令牌,并且采用阶段封禁机制 + * 如果已经提示无法登录的情况下用户还在刷,那么就封禁更长时间 + * @param userId 用户ID + * @return 是否通过频率检测 + */ + private boolean frequencyCheck(int userId){ + String key = Const.JWT_FREQUENCY + userId; + return utils.limitOnceUpgradeCheck(key, limit_frequency, limit_base, limit_upgrade); + } + + /** + * 校验并转换请求头中的Token令牌 + * @param headerToken 请求头中的Token + * @return 转换后的令牌 + */ + private String convertToken(String headerToken){ + if(headerToken == null || !headerToken.startsWith("Bearer ")) + return null; + return headerToken.substring(7); + } + + /** + * 将Token列入Redis黑名单中 + * @param uuid 令牌ID + * @param time 过期时间 + * @return 是否操作成功 + */ + private boolean deleteToken(String uuid, Date time){ + if(this.isInvalidToken(uuid)) + return false; + Date now = new Date(); + long expire = Math.max(time.getTime() - now.getTime(), 0); + template.opsForValue().set(Const.JWT_BLACK_LIST + uuid, "", expire, TimeUnit.MILLISECONDS); + return true; + } + + /** + * 验证Token是否被列入Redis黑名单 + * @param uuid 令牌ID + * @return 是否操作成功 + */ + private boolean isInvalidToken(String uuid){ + return Boolean.TRUE.equals(template.hasKey(Const.JWT_BLACK_LIST + uuid)); + } +} diff --git a/my-project-backend/src/main/java/com/example/utils/SnowflakeIdGenerator.java b/my-project-backend/src/main/java/com/example/utils/SnowflakeIdGenerator.java new file mode 100644 index 0000000..0e5ff3b --- /dev/null +++ b/my-project-backend/src/main/java/com/example/utils/SnowflakeIdGenerator.java @@ -0,0 +1,79 @@ +package com.example.utils; + +import org.springframework.stereotype.Component; + +/** + * 雪花算法ID生成器 + */ +@Component +public class SnowflakeIdGenerator { + private static final long START_TIMESTAMP = 1691087910202L; + + private static final long DATA_CENTER_ID_BITS = 5L; + private static final long WORKER_ID_BITS = 5L; + private static final long SEQUENCE_BITS = 12L; + + private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS); + private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); + private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); + + private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; + private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; + private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS; + + private final long dataCenterId; + private final long workerId; + private long lastTimestamp = -1L; + private long sequence = 0L; + + public SnowflakeIdGenerator(){ + this(1, 1); + } + + private SnowflakeIdGenerator(long dataCenterId, long workerId) { + if (dataCenterId > MAX_DATA_CENTER_ID || dataCenterId < 0) { + throw new IllegalArgumentException("Data center ID can't be greater than " + MAX_DATA_CENTER_ID + " or less than 0"); + } + if (workerId > MAX_WORKER_ID || workerId < 0) { + throw new IllegalArgumentException("Worker ID can't be greater than " + MAX_WORKER_ID + " or less than 0"); + } + this.dataCenterId = dataCenterId; + this.workerId = workerId; + } + + /** + * 生成一个新的雪花算法ID加锁 + * @return 雪花ID + */ + public synchronized long nextId() { + long timestamp = getCurrentTimestamp(); + if (timestamp < lastTimestamp) { + throw new IllegalStateException("Clock moved backwards. Refusing to generate ID."); + } + if (timestamp == lastTimestamp) { + sequence = (sequence + 1) & MAX_SEQUENCE; + if (sequence == 0) { + timestamp = getNextTimestamp(lastTimestamp); + } + } else { + sequence = 0L; + } + lastTimestamp = timestamp; + return ((timestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT) | + (dataCenterId << DATA_CENTER_ID_SHIFT) | + (workerId << WORKER_ID_SHIFT) | + sequence; + } + + private long getCurrentTimestamp() { + return System.currentTimeMillis(); + } + + private long getNextTimestamp(long lastTimestamp) { + long timestamp = getCurrentTimestamp(); + while (timestamp <= lastTimestamp) { + timestamp = getCurrentTimestamp(); + } + return timestamp; + } +} diff --git a/my-project-backend/src/main/resources/application-dev.yml b/my-project-backend/src/main/resources/application-dev.yml new file mode 100644 index 0000000..34dfa12 --- /dev/null +++ b/my-project-backend/src/main/resources/application-dev.yml @@ -0,0 +1,40 @@ +# 开发环境配置 +spring: + mail: + host: smtp.163.com + username: javastudy111@163.com + password: VKQFYZMUSUZGSGEG + rabbitmq: + addresses: localhost + username: admin + password: admin + virtual-host: / + datasource: + url: jdbc:mysql://localhost:3306/test + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + security: + jwt: + key: 'abcdefghijklmn' + expire: 72 + limit: + base: 10 + upgrade: 300 + frequency: 30 + filter: + order: -100 + web: + verify: + mail-limit: 60 + flow: + period: 3 + limit: 10 + block: 30 + cors: + origin: '*' + credentials: false + methods: '*' +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl diff --git a/my-project-backend/src/main/resources/application-prod.yml b/my-project-backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..632920a --- /dev/null +++ b/my-project-backend/src/main/resources/application-prod.yml @@ -0,0 +1,37 @@ +#生产环境配置 +spring: + mail: + host: smtp.163.com + username: javastudy111@163.com + password: VKQFYZMUSUZGSGEG + rabbitmq: + addresses: localhost + username: admin + password: admin + virtual-host: / + datasource: + url: jdbc:mysql://localhost:3306/test + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + security: + jwt: + key: 'abcdefghijklmn' + expire: 72 + limit: + base: 10 + upgrade: 300 + frequency: 30 + filter: + order: -100 + web: + verify: + mail-limit: 60 + flow: + period: 3 + limit: 10 + block: 30 + cors: + origin: '*' + credentials: false + methods: '*' diff --git a/my-project-backend/src/main/resources/application.yml b/my-project-backend/src/main/resources/application.yml new file mode 100644 index 0000000..2ddba41 --- /dev/null +++ b/my-project-backend/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: '@environment@' diff --git a/my-project-backend/src/main/resources/logback-spring.xml b/my-project-backend/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..a4f8354 --- /dev/null +++ b/my-project-backend/src/main/resources/logback-spring.xml @@ -0,0 +1,34 @@ + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + ${FILE_LOG_PATTERN} + ${FILE_LOG_CHARSET} + + + log/%d{yyyy-MM-dd}-spring-%i.log + true + 7 + 10MB + + + + + + + + diff --git a/my-project-backend/src/test/java/com/example/MyProjectBackendApplicationTests.java b/my-project-backend/src/test/java/com/example/MyProjectBackendApplicationTests.java new file mode 100644 index 0000000..0a367c8 --- /dev/null +++ b/my-project-backend/src/test/java/com/example/MyProjectBackendApplicationTests.java @@ -0,0 +1,13 @@ +package com.example; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class MyProjectBackendApplicationTests { + + @Test + void contextLoads() { + + } +} diff --git a/my-project-frontend/.gitignore b/my-project-frontend/.gitignore new file mode 100644 index 0000000..38adffa --- /dev/null +++ b/my-project-frontend/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/my-project-frontend/.vscode/extensions.json b/my-project-frontend/.vscode/extensions.json new file mode 100644 index 0000000..c0a6e5a --- /dev/null +++ b/my-project-frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] +} diff --git a/my-project-frontend/README.md b/my-project-frontend/README.md new file mode 100644 index 0000000..e4c9117 --- /dev/null +++ b/my-project-frontend/README.md @@ -0,0 +1,29 @@ +# my-project-frontend + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). + +## Customize configuration + +See [Vite Configuration Reference](https://vitejs.dev/config/). + +## Project Setup + +```sh +npm install +``` + +### Compile and Hot-Reload for Development + +```sh +npm run dev +``` + +### Compile and Minify for Production + +```sh +npm run build +``` diff --git a/my-project-frontend/index.html b/my-project-frontend/index.html new file mode 100644 index 0000000..99f583a --- /dev/null +++ b/my-project-frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite App + + +
+ + + diff --git a/my-project-frontend/my-project-frontend.iml b/my-project-frontend/my-project-frontend.iml new file mode 100644 index 0000000..8021953 --- /dev/null +++ b/my-project-frontend/my-project-frontend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/my-project-frontend/package-lock.json b/my-project-frontend/package-lock.json new file mode 100644 index 0000000..4f4eba6 --- /dev/null +++ b/my-project-frontend/package-lock.json @@ -0,0 +1,691 @@ +{ + "name": "my-project-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "my-project-frontend", + "version": "0.0.0", + "dependencies": { + "vue": "^3.3.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.2.3", + "vite": "^4.4.6" + } + }, + "node_modules/@babel/parser": { + "version": "7.22.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.22.7.tgz", + "integrity": "sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.18.17.tgz", + "integrity": "sha512-wHsmJG/dnL3OkpAcwbgoBTTMHVi4Uyou3F5mf58ZtmUyIKfcdA7TROav/6tCzET4A3QW2Q2FC+eFneMU+iyOxg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.18.17.tgz", + "integrity": "sha512-9np+YYdNDed5+Jgr1TdWBsozZ85U1Oa3xW0c7TWqH0y2aGghXtZsuT8nYRbzOMcl0bXZXjOGbksoTtVOlWrRZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.18.17.tgz", + "integrity": "sha512-O+FeWB/+xya0aLg23hHEM2E3hbfwZzjqumKMSIqcHbNvDa+dza2D0yLuymRBQQnC34CWrsJUXyH2MG5VnLd6uw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.17.tgz", + "integrity": "sha512-M9uJ9VSB1oli2BE/dJs3zVr9kcCBBsE883prage1NWz6pBS++1oNn/7soPNS3+1DGj0FrkSvnED4Bmlu1VAE9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.18.17.tgz", + "integrity": "sha512-XDre+J5YeIJDMfp3n0279DFNrGCXlxOuGsWIkRb1NThMZ0BsrWXoTg23Jer7fEXQ9Ye5QjrvXpxnhzl3bHtk0g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.17.tgz", + "integrity": "sha512-cjTzGa3QlNfERa0+ptykyxs5A6FEUQQF0MuilYXYBGdBxD3vxJcKnzDlhDCa1VAJCmAxed6mYhA2KaJIbtiNuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.17.tgz", + "integrity": "sha512-sOxEvR8d7V7Kw8QqzxWc7bFfnWnGdaFBut1dRUYtu+EIRXefBc/eIsiUiShnW0hM3FmQ5Zf27suDuHsKgZ5QrA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.18.17.tgz", + "integrity": "sha512-2d3Lw6wkwgSLC2fIvXKoMNGVaeY8qdN0IC3rfuVxJp89CRfA3e3VqWifGDfuakPmp90+ZirmTfye1n4ncjv2lg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.18.17.tgz", + "integrity": "sha512-c9w3tE7qA3CYWjT+M3BMbwMt+0JYOp3vCMKgVBrCl1nwjAlOMYzEo+gG7QaZ9AtqZFj5MbUc885wuBBmu6aADQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.18.17.tgz", + "integrity": "sha512-1DS9F966pn5pPnqXYz16dQqWIB0dmDfAQZd6jSSpiT9eX1NzKh07J6VKR3AoXXXEk6CqZMojiVDSZi1SlmKVdg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.18.17.tgz", + "integrity": "sha512-EvLsxCk6ZF0fpCB6w6eOI2Fc8KW5N6sHlIovNe8uOFObL2O+Mr0bflPHyHwLT6rwMg9r77WOAWb2FqCQrVnwFg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.17.tgz", + "integrity": "sha512-e0bIdHA5p6l+lwqTE36NAW5hHtw2tNRmHlGBygZC14QObsA3bD4C6sXLJjvnDIjSKhW1/0S3eDy+QmX/uZWEYQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.17.tgz", + "integrity": "sha512-BAAilJ0M5O2uMxHYGjFKn4nJKF6fNCdP1E0o5t5fvMYYzeIqy2JdAP88Az5LHt9qBoUa4tDaRpfWt21ep5/WqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.17.tgz", + "integrity": "sha512-Wh/HW2MPnC3b8BqRSIme/9Zhab36PPH+3zam5pqGRH4pE+4xTrVLx2+XdGp6fVS3L2x+DrsIcsbMleex8fbE6g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.18.17.tgz", + "integrity": "sha512-j/34jAl3ul3PNcK3pfI0NSlBANduT2UO5kZ7FCaK33XFv3chDhICLY8wJJWIhiQ+YNdQ9dxqQctRg2bvrMlYgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.18.17.tgz", + "integrity": "sha512-QM50vJ/y+8I60qEmFxMoxIx4de03pGo2HwxdBeFd4nMh364X6TIBZ6VQ5UQmPbQWUVWHWws5MmJXlHAXvJEmpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.17.tgz", + "integrity": "sha512-/jGlhWR7Sj9JPZHzXyyMZ1RFMkNPjC6QIAan0sDOtIo2TYk3tZn5UDrkE0XgsTQCxWTTOcMPf9p6Rh2hXtl5TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.17.tgz", + "integrity": "sha512-rSEeYaGgyGGf4qZM2NonMhMOP/5EHp4u9ehFiBrg7stH6BYEEjlkVREuDEcQ0LfIl53OXLxNbfuIj7mr5m29TA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.18.17.tgz", + "integrity": "sha512-Y7ZBbkLqlSgn4+zot4KUNYst0bFoO68tRgI6mY2FIM+b7ZbyNVtNbDP5y8qlu4/knZZ73fgJDlXID+ohY5zt5g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.18.17.tgz", + "integrity": "sha512-bwPmTJsEQcbZk26oYpc4c/8PvTY3J5/QK8jM19DVlEsAB41M39aWovWoHtNm78sd6ip6prilxeHosPADXtEJFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.18.17.tgz", + "integrity": "sha512-H/XaPtPKli2MhW+3CQueo6Ni3Avggi6hP/YvgkEe1aSaxw+AeO8MFjq8DlgfTd9Iz4Yih3QCZI6YLMoyccnPRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.18.17.tgz", + "integrity": "sha512-fGEb8f2BSA3CW7riJVurug65ACLuQAzKq0SSqkY2b2yHHH0MzDfbLyKIGzHwOI/gkHcxM/leuSW6D5w/LMNitA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-4.2.3.tgz", + "integrity": "sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==", + "dev": true, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.3.4.tgz", + "integrity": "sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==", + "dependencies": { + "@babel/parser": "^7.21.3", + "@vue/shared": "3.3.4", + "estree-walker": "^2.0.2", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz", + "integrity": "sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==", + "dependencies": { + "@vue/compiler-core": "3.3.4", + "@vue/shared": "3.3.4" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz", + "integrity": "sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==", + "dependencies": { + "@babel/parser": "^7.20.15", + "@vue/compiler-core": "3.3.4", + "@vue/compiler-dom": "3.3.4", + "@vue/compiler-ssr": "3.3.4", + "@vue/reactivity-transform": "3.3.4", + "@vue/shared": "3.3.4", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.0", + "postcss": "^8.1.10", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.3.4.tgz", + "integrity": "sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==", + "dependencies": { + "@vue/compiler-dom": "3.3.4", + "@vue/shared": "3.3.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.3.4.tgz", + "integrity": "sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==", + "dependencies": { + "@vue/shared": "3.3.4" + } + }, + "node_modules/@vue/reactivity-transform": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/reactivity-transform/-/reactivity-transform-3.3.4.tgz", + "integrity": "sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==", + "dependencies": { + "@babel/parser": "^7.20.15", + "@vue/compiler-core": "3.3.4", + "@vue/shared": "3.3.4", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.0" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.3.4.tgz", + "integrity": "sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==", + "dependencies": { + "@vue/reactivity": "3.3.4", + "@vue/shared": "3.3.4" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.3.4.tgz", + "integrity": "sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==", + "dependencies": { + "@vue/runtime-core": "3.3.4", + "@vue/shared": "3.3.4", + "csstype": "^3.1.1" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.3.4.tgz", + "integrity": "sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==", + "dependencies": { + "@vue/compiler-ssr": "3.3.4", + "@vue/shared": "3.3.4" + }, + "peerDependencies": { + "vue": "3.3.4" + } + }, + "node_modules/@vue/shared": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.3.4.tgz", + "integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==" + }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + }, + "node_modules/esbuild": { + "version": "0.18.17", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.18.17.tgz", + "integrity": "sha512-1GJtYnUxsJreHYA0Y+iQz2UEykonY66HNWOb0yXYZi9/kNrORUEHVg87eQsCtqh59PEJ5YVZJO98JHznMJSWjg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.17", + "@esbuild/android-arm64": "0.18.17", + "@esbuild/android-x64": "0.18.17", + "@esbuild/darwin-arm64": "0.18.17", + "@esbuild/darwin-x64": "0.18.17", + "@esbuild/freebsd-arm64": "0.18.17", + "@esbuild/freebsd-x64": "0.18.17", + "@esbuild/linux-arm": "0.18.17", + "@esbuild/linux-arm64": "0.18.17", + "@esbuild/linux-ia32": "0.18.17", + "@esbuild/linux-loong64": "0.18.17", + "@esbuild/linux-mips64el": "0.18.17", + "@esbuild/linux-ppc64": "0.18.17", + "@esbuild/linux-riscv64": "0.18.17", + "@esbuild/linux-s390x": "0.18.17", + "@esbuild/linux-x64": "0.18.17", + "@esbuild/netbsd-x64": "0.18.17", + "@esbuild/openbsd-x64": "0.18.17", + "@esbuild/sunos-x64": "0.18.17", + "@esbuild/win32-arm64": "0.18.17", + "@esbuild/win32-ia32": "0.18.17", + "@esbuild/win32-x64": "0.18.17" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.2", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.2.tgz", + "integrity": "sha512-lNZdu7pewtq/ZvWUp9Wpf/x7WzMTsR26TWV03BRZrXFsv+BI6dy8RAiKgm1uM/kyR0rCfUcqvOlXKG66KhIGug==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/postcss": { + "version": "8.4.27", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.27.tgz", + "integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "3.27.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-3.27.0.tgz", + "integrity": "sha512-aOltLCrYZ0FhJDm7fCqwTjIUEVjWjcydKBV/Zeid6Mn8BWgDCUBBWT5beM5ieForYNo/1ZHuGJdka26kvQ3Gzg==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "4.4.8", + "resolved": "https://registry.npmmirror.com/vite/-/vite-4.4.8.tgz", + "integrity": "sha512-LONawOUUjxQridNWGQlNizfKH89qPigK36XhMI7COMGztz8KNY0JHim7/xDd71CZwGT4HtSRgI7Hy+RlhG0Gvg==", + "dev": true, + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.26", + "rollup": "^3.25.2" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.3.4", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.3.4.tgz", + "integrity": "sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==", + "dependencies": { + "@vue/compiler-dom": "3.3.4", + "@vue/compiler-sfc": "3.3.4", + "@vue/runtime-dom": "3.3.4", + "@vue/server-renderer": "3.3.4", + "@vue/shared": "3.3.4" + } + } + } +} diff --git a/my-project-frontend/package.json b/my-project-frontend/package.json new file mode 100644 index 0000000..dab65b9 --- /dev/null +++ b/my-project-frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "my-project-frontend", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.3.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.2.3", + "vite": "^4.4.6" + } +} diff --git a/my-project-frontend/public/favicon.ico b/my-project-frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 GIT binary patch literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S literal 0 HcmV?d00001 diff --git a/my-project-frontend/src/App.vue b/my-project-frontend/src/App.vue new file mode 100644 index 0000000..633a5df --- /dev/null +++ b/my-project-frontend/src/App.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/my-project-frontend/src/assets/base.css b/my-project-frontend/src/assets/base.css new file mode 100644 index 0000000..d3de42e --- /dev/null +++ b/my-project-frontend/src/assets/base.css @@ -0,0 +1,73 @@ +/* color palette from */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + + --vt-c-indigo: #2c3e50; + + --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); + --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); + --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); + --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + + --vt-c-text-light-1: var(--vt-c-indigo); + --vt-c-text-light-2: rgba(60, 60, 60, 0.66); + --vt-c-text-dark-1: var(--vt-c-white); + --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); +} + +/* semantic color variables for this project */ +:root { + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); + + --color-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); + + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); + + --section-gap: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); + + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: color 0.5s, background-color 0.5s; + line-height: 1.6; + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, + Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/my-project-frontend/src/assets/logo.svg b/my-project-frontend/src/assets/logo.svg new file mode 100644 index 0000000..7565660 --- /dev/null +++ b/my-project-frontend/src/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/my-project-frontend/src/assets/main.css b/my-project-frontend/src/assets/main.css new file mode 100644 index 0000000..e8667cd --- /dev/null +++ b/my-project-frontend/src/assets/main.css @@ -0,0 +1,35 @@ +@import './base.css'; + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + + font-weight: normal; +} + +a, +.green { + text-decoration: none; + color: hsla(160, 100%, 37%, 1); + transition: 0.4s; +} + +@media (hover: hover) { + a:hover { + background-color: hsla(160, 100%, 37%, 0.2); + } +} + +@media (min-width: 1024px) { + body { + display: flex; + place-items: center; + } + + #app { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 0 2rem; + } +} diff --git a/my-project-frontend/src/components/HelloWorld.vue b/my-project-frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..5fb372c --- /dev/null +++ b/my-project-frontend/src/components/HelloWorld.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/my-project-frontend/src/components/TheWelcome.vue b/my-project-frontend/src/components/TheWelcome.vue new file mode 100644 index 0000000..5e64625 --- /dev/null +++ b/my-project-frontend/src/components/TheWelcome.vue @@ -0,0 +1,86 @@ + + + diff --git a/my-project-frontend/src/components/WelcomeItem.vue b/my-project-frontend/src/components/WelcomeItem.vue new file mode 100644 index 0000000..6d7086a --- /dev/null +++ b/my-project-frontend/src/components/WelcomeItem.vue @@ -0,0 +1,87 @@ + + + diff --git a/my-project-frontend/src/components/icons/IconCommunity.vue b/my-project-frontend/src/components/icons/IconCommunity.vue new file mode 100644 index 0000000..2dc8b05 --- /dev/null +++ b/my-project-frontend/src/components/icons/IconCommunity.vue @@ -0,0 +1,7 @@ + diff --git a/my-project-frontend/src/components/icons/IconDocumentation.vue b/my-project-frontend/src/components/icons/IconDocumentation.vue new file mode 100644 index 0000000..6d4791c --- /dev/null +++ b/my-project-frontend/src/components/icons/IconDocumentation.vue @@ -0,0 +1,7 @@ + diff --git a/my-project-frontend/src/components/icons/IconEcosystem.vue b/my-project-frontend/src/components/icons/IconEcosystem.vue new file mode 100644 index 0000000..c3a4f07 --- /dev/null +++ b/my-project-frontend/src/components/icons/IconEcosystem.vue @@ -0,0 +1,7 @@ + diff --git a/my-project-frontend/src/components/icons/IconSupport.vue b/my-project-frontend/src/components/icons/IconSupport.vue new file mode 100644 index 0000000..7452834 --- /dev/null +++ b/my-project-frontend/src/components/icons/IconSupport.vue @@ -0,0 +1,7 @@ + diff --git a/my-project-frontend/src/components/icons/IconTooling.vue b/my-project-frontend/src/components/icons/IconTooling.vue new file mode 100644 index 0000000..660598d --- /dev/null +++ b/my-project-frontend/src/components/icons/IconTooling.vue @@ -0,0 +1,19 @@ + + diff --git a/my-project-frontend/src/main.js b/my-project-frontend/src/main.js new file mode 100644 index 0000000..0ac3a5f --- /dev/null +++ b/my-project-frontend/src/main.js @@ -0,0 +1,6 @@ +import './assets/main.css' + +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/my-project-frontend/vite.config.js b/my-project-frontend/vite.config.js new file mode 100644 index 0000000..5c45e1d --- /dev/null +++ b/my-project-frontend/vite.config.js @@ -0,0 +1,16 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + } +})