login and register

This commit is contained in:
N1KO 2024-12-13 18:19:41 +08:00
parent 6467eac4ed
commit c2f59d9f07
24 changed files with 1325 additions and 107 deletions

11
pom.xml
View File

@ -42,6 +42,11 @@
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
@ -71,6 +76,12 @@
<version>3.0.1</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.8.2</version>
</dependency>
</dependencies>
<build>

View File

@ -0,0 +1,17 @@
package top.baogutang.music.annos;
import java.lang.annotation.*;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 17:52
*/
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
boolean required() default true;
}

View File

@ -0,0 +1,109 @@
package top.baogutang.music.aspect;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import top.baogutang.music.annos.Login;
import top.baogutang.music.domain.res.user.UserLoginRes;
import top.baogutang.music.exceptions.LoginException;
import top.baogutang.music.utils.TokenUtil;
import top.baogutang.music.utils.UserThreadLocal;
import javax.annotation.Resource;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Objects;
import static top.baogutang.music.constants.CacheKey.KEY_LOGIN_PREFIX;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 17:53
*/
@Slf4j
@Aspect
@Component
public class LoginAspect {
@Resource
private RedisTemplate<String, Object> redisTemplate;
public static final String AUTHORIZATION = "token";
@Pointcut("@annotation(top.baogutang.music.annos.Login)")
public void point() {
}
@Before(value = "point() && @annotation(login)")
public void verifyTokenForClass(Login login) {
if (login.required()) {
checkToken();
} else {
checkTokenWithOutRequired();
}
}
private void checkTokenWithOutRequired() {
ServletRequestAttributes requestAttributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (Objects.isNull(requestAttributes)) {
return;
}
HttpServletRequest request = requestAttributes.getRequest();
Cookie[] cookies = request.getCookies();
if (Objects.isNull(cookies) || cookies.length == 0) {
return;
}
Cookie tokenCookie = Arrays.stream(cookies)
.filter(cookie -> AUTHORIZATION.equalsIgnoreCase(cookie.getName()))
.findFirst()
.orElse(null);
if (Objects.isNull(tokenCookie) || StringUtils.isBlank(tokenCookie.getValue())) {
return;
}
String token = tokenCookie.getValue();
UserLoginRes userLoginRes = TokenUtil.verify(token);
UserThreadLocal.set(userLoginRes.getId());
}
private void checkToken() {
ServletRequestAttributes requestAttributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (Objects.isNull(requestAttributes)) {
return;
}
HttpServletRequest request = requestAttributes.getRequest();
Cookie[] cookies = request.getCookies();
if (Objects.isNull(cookies) || cookies.length == 0) {
throw new LoginException("请登录后操作");
}
Cookie tokenCookie = Arrays.stream(cookies)
.filter(cookie -> AUTHORIZATION.equalsIgnoreCase(cookie.getName()))
.findFirst()
.orElse(null);
if (Objects.isNull(tokenCookie) || StringUtils.isBlank(tokenCookie.getValue())) {
throw new LoginException("请登录后操作");
}
String token = tokenCookie.getValue();
log.info("request url:{},method:{}", request.getRequestURL(), request.getMethod());
UserLoginRes userLoginRes = TokenUtil.verify(token);
Long userId = userLoginRes.getId();
Object val = redisTemplate.opsForValue().get(KEY_LOGIN_PREFIX + userId);
if (Objects.isNull(val)) {
redisTemplate.opsForValue().set(KEY_LOGIN_PREFIX + userId, token);
}
UserThreadLocal.set(userId);
}
}

View File

@ -0,0 +1,70 @@
package top.baogutang.music.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.net.UnknownHostException;
import java.util.TimeZone;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 10:56
*/
@Configuration
@Slf4j
public class RedisConfig {
/**
* 编写自定义的 redisTemplate
* 这是一个比较固定的模板
*/
@SuppressWarnings("all")
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
// 为了开发方便直接使用<String, Object>
RedisTemplate<String, Object> template = new RedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
// Json 配置序列化
// 使用 jackson 解析任意的对象
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
// 使用 objectMapper 进行转义
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
objectMapper.registerModule(new JavaTimeModule());
objectMapper.setTimeZone(TimeZone.getDefault());
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
// String 的序列化
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// key 采用 String 的序列化方式
template.setKeySerializer(stringRedisSerializer);
// Hash key 采用 String 的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value 采用 jackson 的序列化方式
template.setValueSerializer(jackson2JsonRedisSerializer);
// Hash value 采用 String 的序列化方式
template.setHashValueSerializer(jackson2JsonRedisSerializer);
// 把所有的配置 set template
template.afterPropertiesSet();
return template;
}
}

View File

@ -0,0 +1,21 @@
package top.baogutang.music.constants;
/**
*
* @description:
*
* @author: nikooh
* @date: 2024/11/01 : 11:03
*/
public class CacheKey {
private CacheKey() {
// private constructor
}
public static final String LOCK_REGISTER_PREFIX = "baogutang-music:lock:register:username:";
public static final String KEY_LOGIN_PREFIX = "baogutang-music:user:token:id:";
}

View File

@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import top.baogutang.music.annos.Login;
import top.baogutang.music.domain.Results;
import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.domain.res.search.MusicPlaylistRes;
@ -28,6 +29,7 @@ public class MusicDownloadController {
private IMusicService musicService;
@GetMapping
@Login
public Results<MusicDownloadRes> download(@RequestParam(name = "channel") Integer channel,
@RequestParam(name = "id") String id) {
MusicDownloadRes res = musicService.getMusicService(channel).download(id);

View File

@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import top.baogutang.music.annos.Login;
import top.baogutang.music.domain.Results;
import top.baogutang.music.domain.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.search.*;
@ -28,12 +29,14 @@ public class MusicSearchController {
private IMusicService musicService;
@GetMapping
@Login
public Results<MusicSearchRes> search(MusicSearchReq req) {
MusicSearchRes res = musicService.getMusicService(req.getChannel()).search(req);
return Results.ok(res);
}
@GetMapping("/playlist")
@Login
public Results<MusicPlaylistRes> playlist(@RequestParam(name = "channel") Integer channel,
@RequestParam(name = "id") String id) {
MusicPlaylistRes res = musicService.getMusicService(channel).playList(id);
@ -41,6 +44,7 @@ public class MusicSearchController {
}
@GetMapping("/album")
@Login
public Results<MusicAlbumRes> album(@RequestParam(name = "channel") Integer channel,
@RequestParam(name = "id") String id) {
MusicAlbumRes res = musicService.getMusicService(channel).album(id);
@ -48,6 +52,7 @@ public class MusicSearchController {
}
@GetMapping("/artist")
@Login
public Results<MusicArtistRes> artist(@RequestParam(name = "channel") Integer channel,
@RequestParam(name = "id") String id) {
MusicArtistRes res = musicService.getMusicService(channel).artist(id);
@ -55,6 +60,7 @@ public class MusicSearchController {
}
@GetMapping("/detail")
@Login
public Results<MusicDetailRes> detail(@RequestParam(name = "channel") Integer channel,
@RequestParam(name = "id") String id) {
MusicDetailRes res = musicService.getMusicService(channel).detail(id);

View File

@ -14,8 +14,14 @@ import org.springframework.web.bind.annotation.RequestMapping;
public class MusicStaticController {
@RequestMapping
public String viewJsonParseHtml() {
public String music() {
// 这里返回的字符串是HTML文件名不包括扩展名
return "music";
}
@RequestMapping("/login")
public String login() {
return "login";
}
}

View File

@ -0,0 +1,37 @@
package top.baogutang.music.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import top.baogutang.music.domain.Results;
import top.baogutang.music.domain.req.user.UserRegisterAndLoginReq;
import top.baogutang.music.domain.res.user.UserLoginRes;
import top.baogutang.music.service.IUserService;
import javax.annotation.Resource;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 16:34
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/user")
public class UserController {
@Resource
private IUserService userService;
@PostMapping("/register")
public Results<Void> register(@RequestBody UserRegisterAndLoginReq req) {
userService.register(req);
return Results.ok();
}
@PostMapping("/login")
public Results<UserLoginRes> login(@RequestBody UserRegisterAndLoginReq req) {
return Results.ok(userService.login(req));
}
}

View File

@ -0,0 +1,33 @@
package top.baogutang.music.dao.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import top.baogutang.music.enums.UserLevel;
import top.baogutang.music.enums.UserRole;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 16:41
*/
@Getter
@Setter
@TableName("t_user")
public class UserEntity extends BaseEntity {
private static final long serialVersionUID = -7939103741882908678L;
private String username;
private String password;
private UserRole role;
private UserLevel level;
private String avatar;
}

View File

@ -0,0 +1,16 @@
package top.baogutang.music.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import top.baogutang.music.dao.entity.UserEntity;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 16:44
*/
@Mapper
public interface UserMapper extends BaseMapper<UserEntity> {
}

View File

@ -0,0 +1,22 @@
package top.baogutang.music.domain.req.user;
import lombok.Data;
import java.io.Serializable;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 16:35
*/
@Data
public class UserRegisterAndLoginReq implements Serializable {
private static final long serialVersionUID = 5259676190187565515L;
private String username;
private String password;
}

View File

@ -0,0 +1,36 @@
package top.baogutang.music.domain.res.user;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import top.baogutang.music.enums.UserLevel;
import top.baogutang.music.enums.UserRole;
import java.io.Serializable;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 17:17
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserLoginRes implements Serializable {
private static final long serialVersionUID = -4434691278877613222L;
private Long id;
private String username;
private UserLevel level;
private UserRole role;
private String token;
}

View File

@ -0,0 +1,19 @@
package top.baogutang.music.enums;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 16:43
*/
public enum UserLevel {
NORMAL,
VIP,
SUPER_VIP,
;
}

View File

@ -0,0 +1,18 @@
package top.baogutang.music.enums;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 16:43
*/
public enum UserRole {
NORMAL,
ADMIN,
;
}

View File

@ -29,6 +29,20 @@ public class GlobalExceptionHandler {
//
}
@ExceptionHandler({BusinessException.class})
public Results<Object> businessException(BusinessException e) {
log.error("请求发生错误code:{},message:{}", e.getCode(), e.getMessage());
return Results.failed(e.getCode(), e.getMessage());
}
@ExceptionHandler({LoginException.class})
public Results<Object> loginException(LoginException e) {
log.error("请求发生错误code:{},message:{}", e.getCode(), e.getMessage());
return Results.failed(e.getCode(), e.getMessage());
}
@ExceptionHandler({Throwable.class})
public Results<Object> handleException(Throwable e) {
log.error("请求发生错误,错误信息:{}", e.getMessage(), e);
@ -54,12 +68,6 @@ public class GlobalExceptionHandler {
return Results.failed(300, e.getMessage());
}
@ExceptionHandler({BusinessException.class})
public Results<Object> businessException(BusinessException e) {
log.error("请求发生错误code:{},message:{}", e.getCode(), e.getMessage());
return Results.failed(e.getCode(), e.getMessage());
}
@ExceptionHandler({MethodArgumentNotValidException.class})
public Results<Object> parameterExceptionHandler(MethodArgumentNotValidException e) {
BindingResult exceptions = e.getBindingResult();

View File

@ -0,0 +1,35 @@
package top.baogutang.music.exceptions;
import top.baogutang.music.domain.Results;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 17:33
*/
public class LoginException extends RuntimeException {
private int code = Results.FAIL_CODE;
public LoginException(int code, String message) {
super(message);
this.code = code;
}
public LoginException(String message) {
super(message);
this.code = -200;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
}

View File

@ -0,0 +1,20 @@
package top.baogutang.music.service;
import com.baomidou.mybatisplus.extension.service.IService;
import top.baogutang.music.dao.entity.UserEntity;
import top.baogutang.music.domain.req.user.UserRegisterAndLoginReq;
import top.baogutang.music.domain.res.user.UserLoginRes;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 16:41
*/
public interface IUserService extends IService<UserEntity> {
void register(UserRegisterAndLoginReq req);
UserLoginRes login(UserRegisterAndLoginReq req);
}

View File

@ -0,0 +1,132 @@
package top.baogutang.music.service.impl;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import top.baogutang.music.dao.entity.UserEntity;
import top.baogutang.music.dao.mapper.UserMapper;
import top.baogutang.music.domain.req.user.UserRegisterAndLoginReq;
import top.baogutang.music.domain.res.user.UserLoginRes;
import top.baogutang.music.enums.UserLevel;
import top.baogutang.music.enums.UserRole;
import top.baogutang.music.exceptions.BusinessException;
import top.baogutang.music.exceptions.LoginException;
import top.baogutang.music.service.IUserService;
import top.baogutang.music.utils.CacheUtil;
import top.baogutang.music.utils.TokenUtil;
import javax.annotation.Resource;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import static top.baogutang.music.constants.CacheKey.KEY_LOGIN_PREFIX;
import static top.baogutang.music.constants.CacheKey.LOCK_REGISTER_PREFIX;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 16:44
*/
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements IUserService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
public void register(UserRegisterAndLoginReq req) {
String lockKey = LOCK_REGISTER_PREFIX + req.getUsername();
CacheUtil.lockAndExecute(lockKey,
10L,
TimeUnit.SECONDS,
redisTemplate,
() -> {
// 校验
UserEntity existsUser = this.validate(req);
if (Objects.nonNull(existsUser)) {
throw new BusinessException("当前用户名已被注册");
}
// 密码加密
UserEntity user = new UserEntity();
user.setUsername(req.getUsername());
user.setPassword(encryptPassword(req.getPassword()));
user.setRole(UserRole.NORMAL);
user.setLevel(UserLevel.NORMAL);
user.setCreateTime(LocalDateTime.now());
save(user);
});
}
@Override
public UserLoginRes login(UserRegisterAndLoginReq req) {
UserEntity existsUser = this.validate(req);
if (Objects.isNull(existsUser)) {
throw new BusinessException("用户不存在");
}
boolean checkRes = this.verifyPassword(req.getPassword(), existsUser.getPassword());
if (!Boolean.TRUE.equals(checkRes)) {
throw new LoginException("用户名或密码错误");
}
UserLoginRes loginRes = new UserLoginRes();
loginRes.setId(existsUser.getId());
loginRes.setUsername(existsUser.getUsername());
loginRes.setLevel(existsUser.getLevel());
loginRes.setRole(existsUser.getRole());
// 生成token
String token = TokenUtil.token(existsUser.getId(), loginRes.getUsername());
redisTemplate.opsForValue().set(KEY_LOGIN_PREFIX + existsUser.getId(), token, 86400000, TimeUnit.MILLISECONDS);
loginRes.setToken(token);
return loginRes;
}
private UserEntity validate(UserRegisterAndLoginReq req) {
if (StringUtils.isBlank(req.getUsername())) {
throw new BusinessException("用户名不能为空");
}
if (StringUtils.isBlank(req.getPassword())) {
throw new BusinessException("密码不能为空");
}
if (req.getPassword().length() < 6 || req.getPassword().length() > 16) {
throw new BusinessException("密码长度不合规");
}
if (req.getUsername().length() < 4 || req.getUsername().length() > 12) {
throw new BusinessException("用户名长度不合规");
}
return new LambdaQueryChainWrapper<>(baseMapper)
.eq(UserEntity::getUsername, req.getUsername())
.eq(UserEntity::getDeleted, Boolean.FALSE)
.last(" limit 1")
.one();
}
// 密码加密方法
public String encryptPassword(String password) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(password.getBytes());
byte[] digest = md.digest();
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b & 0xff));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new BusinessException("请稍后再试");
}
}
// 登录验证方法
public boolean verifyPassword(String inputPassword, String encryptedPassword) {
String inputEncryptedPassword = encryptPassword(inputPassword);
return encryptedPassword.equals(inputEncryptedPassword);
}
}

View File

@ -0,0 +1,131 @@
package top.baogutang.music.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
/**
*
* @description:
*
* @author: nikooh
* @date: 2024/11/01 : 11:14
*/
@Slf4j
public class CacheUtil {
private CacheUtil() {
// private constructor
}
/**
* lock and then supply
*
* @param lockKey lock key
* @param timeout expire time
* @param timeUnit expire time unit
* @param redisTemplate redisTemplate
* @param supplier supplier
* @return T
* @param <T> T
*/
public static <T> T lockAndSupply(String lockKey,
Long timeout,
TimeUnit timeUnit,
RedisTemplate<String, Object> redisTemplate,
Supplier<T> supplier) {
// identity unique holder
String lockValue = UUID.randomUUID().toString();
Boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, timeout, timeUnit);
if (Boolean.TRUE.equals(lockAcquired)) {
try {
return supplier.get();
} catch (Exception e) {
log.error(">>>>>>>>>>deal supplier error:{}<<<<<<<<<<", e.getMessage(), e);
} finally {
String currentLockValue = (String) redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentLockValue)) {
redisTemplate.delete(lockKey);
}
}
} else {
log.error(">>>>>>>>>>Failed to acquire lock for key:{}<<<<<<<<<<", lockKey);
}
return null;
}
/**
* lock and then execute
* @param lockKey lock key
* @param timeout lock expire
* @param timeUnit lock time unit
* @param redisTemplate redisTemplate
* @param runnable runnable
* @return lock res
*/
public static boolean lockAndExecute(String lockKey,
Long timeout,
TimeUnit timeUnit,
RedisTemplate<String, Object> redisTemplate,
Runnable runnable) {
// identity unique holder
String lockValue = UUID.randomUUID().toString();
Boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, timeout, timeUnit);
if (Boolean.TRUE.equals(lockAcquired)) {
try {
runnable.run();
return true;
} catch (Exception e) {
log.error(">>>>>>>>>>deal runnable error:{}<<<<<<<<<<", e.getMessage(), e);
return false;
} finally {
String currentLockValue = (String) redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentLockValue)) {
redisTemplate.delete(lockKey);
}
}
} else {
log.error(">>>>>>>>>>Failed to acquire lock for key:{}<<<<<<<<<<", lockKey);
return false;
}
}
/**
* get from cache or supplier
* @param cacheKey cache key
* @param timeout cache expire
* @param timeUnit cache time unit
* @param redisTemplate redisTemplate
* @param supplier supplier
* @return supplier res
* @param <T> T
*/
@SuppressWarnings("unchecked")
public static <T> T cacheOrSupply(String cacheKey,
Long timeout,
TimeUnit timeUnit,
RedisTemplate<String, Object> redisTemplate,
Supplier<T> supplier) {
Object cacheObject = redisTemplate.opsForValue().get(cacheKey);
if (Objects.nonNull(cacheObject)) {
return (T) cacheObject;
}
T t = supplier.get();
if (Objects.isNull(t)) {
return null;
}
TimeUnit unit = Objects.isNull(timeUnit) ? TimeUnit.SECONDS : timeUnit;
if (Objects.isNull(timeout)) {
redisTemplate.opsForValue().set(cacheKey, t);
} else {
redisTemplate.opsForValue().set(cacheKey, t, timeout, unit);
}
return t;
}
}

View File

@ -0,0 +1,85 @@
package top.baogutang.music.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import top.baogutang.music.domain.res.user.UserLoginRes;
import top.baogutang.music.exceptions.BusinessException;
import top.baogutang.music.exceptions.LoginException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* @desc 使用token验证用户是否登录
* @author zm
**/
@Slf4j
public class TokenUtil {
//设置过期时间(一天)
private static final long EXPIRE_DATE = 86400000;
//token秘钥
private static final String TOKEN_SECRET = "ZCfasfBhuaUaUHOufGguuGuwTu2a02N0BQGWE";
public static String token(Long id, String username) {
String token = "";
try {
//过期时间
Date date = new Date(System.currentTimeMillis() + EXPIRE_DATE);
//秘钥及加密算法
Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
//设置头部信息
Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "HS256");
//携带usernamepassword信息生成签名
token = JWT.create()
.withHeader(header)
.withClaim("username", username)
.withClaim("id", id)
.withExpiresAt(date)
.sign(algorithm);
} catch (Exception e) {
log.error(">>>>>>>>>>gene token error:{}<<<<<<<<<<", e.getMessage(), e);
throw new LoginException("请稍后重试");
}
return token;
}
public static UserLoginRes verify(String token) {
/**
* @desc 验证token通过返回true
* @params [token]需要校验的串
**/
try {
Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
JWTVerifier verifier = JWT.require(algorithm).build();
DecodedJWT jwt = verifier.verify(token);
if (jwt.getExpiresAt().before(new Date())) {
throw new LoginException("登录已失效");
}
String username = jwt.getClaim("username").asString();
Long id = jwt.getClaim("id").asLong();
return UserLoginRes.builder()
.id(id)
.username(username)
.build();
} catch (Exception e) {
throw new LoginException("登录认证失败");
}
}
public static void main(String[] args) {
String username = "zhangsan";
String token = token(1L, username);
System.out.println(token);
UserLoginRes userLoginRes = verify(token);
System.out.println(JacksonUtil.toJson(userLoginRes));
}
}

View File

@ -0,0 +1,22 @@
package top.baogutang.music.utils;
/**
* @author developer
*/
public class UserThreadLocal {
private static final ThreadLocal<Long> userThread = new ThreadLocal<>();
public static void set(Long userId) {
userThread.set(userId);
}
public static Long get() {
return userThread.get();
}
public static void remove() {
userThread.remove();
}
}

View File

@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<link rel="icon" type="image/png" href="NIKO.png">
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>BAOGUTANG-MUSIC-LOGIN</title>
<style>
body {
font-family: 'Arial', sans-serif;
background-color: #f4f4f9;
margin: 0;
padding: 20px;
}
.container {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 95%;
max-width: 400px;
margin: 50px auto;
}
h1 {
color: #62D2A1;
margin-bottom: 20px;
font-size: 1.8rem;
text-align: center;
}
.form-group {
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
label {
font-size: 1rem;
color: #555;
margin-bottom: 8px;
}
input[type="text"], input[type="password"] {
padding: 10px;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
margin-top: 10px;
}
.login-btn, .register-btn {
background-color: #62D2A1;
color: white;
border: none;
padding: 10px 20px;
font-size: 1rem;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
width: 100%;
margin-top: 10px;
}
.login-btn:hover, .register-btn:hover {
background-color: #45a049;
}
.error-msg {
color: #ff0000;
font-size: 0.95rem;
margin-bottom: 10px;
}
.tips {
font-size: 0.9rem;
color: #666;
margin-top: 10px;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<h1>登录BAOGUTANG-MUSIC</h1>
<div class="error-msg" id="error-msg" style="display:none;"></div>
<div class="form-group">
<label>用户名</label>
<input type="text" id="username" placeholder="请输入用户名 (字母数字组合)"/>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="password" placeholder="请输入密码 (字母数字组合)"/>
</div>
<button class="login-btn" id="login-btn">登录</button>
<button class="register-btn" id="register-btn">注册</button>
<div class="tips">没有账号?请点击注册按钮进行注册。</div>
</div>
<script>
// 登录接口URL及注册接口URL稍后提供这里先占位
const loginUrl = '/api/v1/user/login'; // 后端登录接口地址(示例)
const registerUrl = '/api/v1/user/register'; // 后端注册接口地址(示例)
const musicPageUrl = '/music.html'; // 登录后跳转的音乐搜索下载页面地址
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const loginBtn = document.getElementById('login-btn');
const registerBtn = document.getElementById('register-btn');
const errorMsg = document.getElementById('error-msg');
loginBtn.addEventListener('click', () => {
if (!validateInputs()) return;
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
doLogin(username, password);
});
registerBtn.addEventListener('click', () => {
if (!validateInputs()) return;
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
doRegister(username, password);
});
function validateInputs() {
errorMsg.style.display = 'none';
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
const reg = /^[A-Za-z0-9]+$/; // 用户名和密码必须是字母和数字组合
if (!username || !password) {
showError("用户名和密码不能为空");
return false;
}
if (username.length < 4) {
showError("用户名长度不能小于4位");
return false;
}
if (password.length < 6) {
showError("密码长度不能小于6位");
return false;
}
if (username.length > 12) {
showError("用户名过长");
return false;
}
if (password.length > 16) {
showError("密码过长");
return false;
}
if (!reg.test(username) || !reg.test(password)) {
showError("用户名和密码必须是字母和数字的组合");
return false;
}
return true;
}
function doLogin(username, password) {
fetch(loginUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({username: username, password: password})
})
.then(res => res.json())
.then(data => {
console.log('登录响应:', data);
if (data.code === 200) {
// 登陆成功data.data中有token和用户名
const token = data.data.token;
const name = data.data.username;
setCookie('token', token, 1); // 有效期1天可自行调整
setCookie('username', name, 1);
// 跳转到音乐搜索下载页面
window.location.href = musicPageUrl;
} else {
// 登录失败
if (data.code === -200) {
showError(data.msg || "登录失败,请重试");
} else {
showError(data.msg || "登录失败,请重试");
}
}
})
.catch(err => {
console.error('登录接口调用错误', err);
showError("登录接口调用失败,请稍后重试");
});
}
function doRegister(username, password) {
fetch('/api/v1/user/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({username: username, password: password})
})
.then(res => res.json())
.then(data => {
if (data.code === 200) {
// 注册成功,刷新当前页面等待用户登录
alert("注册成功,请使用新账号登录");
window.location.reload();
} else {
showError(data.msg || "注册失败,请重试");
}
})
.catch(err => {
console.error('注册接口调用错误', err);
showError("注册接口调用失败,请稍后重试");
});
}
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.style.display = 'block';
}
function setCookie(name, value, days) {
const d = new Date();
d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
let expires = "expires=" + d.toUTCString();
document.cookie = name + "=" + value + "; " + expires + "; path=/";
}
</script>
</body>
</html>

View File

@ -20,12 +20,23 @@
width: 95%;
max-width: 1200px;
margin: 0 auto;
position: relative;
}
h1 {
color: #62D2A1;
margin-bottom: 20px;
font-size: 1.8rem;
}
.header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 20px;
}
.user-info {
font-size: 1rem;
color: #333;
}
.form-group {
margin-bottom: 20px;
display: flex;
@ -313,6 +324,9 @@
</head>
<body>
<div class="container">
<div class="header">
<div class="user-info" id="user-info">欢迎, 未登录</div>
</div>
<h1>BAOGUTANG-MUSIC</h1>
<div class="form-group">
@ -364,30 +378,39 @@
fetchPlatforms();
fetchSearchTypes();
window.addEventListener('scroll', handleScroll);
displayUsername();
});
function fetchPlatforms() {
fetch('/api/v1/music/common?type=channel')
fetch('/api/v1/music/common?type=channel', {
credentials: 'include' // 确保请求携带cookie
})
.then(response => response.json())
.then(data => {
const platformList = document.getElementById('platform-list');
data.data.forEach(platform => {
const platformItem = document.createElement('div');
platformItem.classList.add('item');
const logoSrc = logos[platform.code] || '';
platformItem.innerHTML = `<img src="${logoSrc}" class="platform-logo" alt="${platform.desc}" /> ${platform.desc}`;
platformItem.setAttribute('data-code', platform.code);
if (data.code === 200) {
const platformList = document.getElementById('platform-list');
data.data.forEach(platform => {
const platformItem = document.createElement('div');
platformItem.classList.add('item');
const logoSrc = logos[platform.code] || '';
platformItem.innerHTML = `<img src="${logoSrc}" class="platform-logo" alt="${platform.desc}" /> ${platform.desc}`;
platformItem.setAttribute('data-code', platform.code);
platformItem.addEventListener('click', () => {
document.querySelectorAll('#platform-list .item').forEach(item => {
item.classList.remove('selected');
platformItem.addEventListener('click', () => {
document.querySelectorAll('#platform-list .item').forEach(item => {
item.classList.remove('selected');
});
platformItem.classList.add('selected');
selectedPlatformCode = platform.code;
});
platformItem.classList.add('selected');
selectedPlatformCode = platform.code;
});
platformList.appendChild(platformItem);
});
platformList.appendChild(platformItem);
});
} else if (data.code === -200) {
showMessage(data.msg);
} else {
console.error('未知的响应码:', data.code);
}
})
.catch(error => {
console.error('请求平台接口失败:', error);
@ -395,26 +418,34 @@
}
function fetchSearchTypes() {
fetch('/api/v1/music/common?type=searchType')
fetch('/api/v1/music/common?type=searchType', {
credentials: 'include' // 确保请求携带cookie
})
.then(response => response.json())
.then(data => {
const searchTypeList = document.getElementById('searchType-list');
data.data.forEach(type => {
const typeItem = document.createElement('div');
typeItem.classList.add('item');
typeItem.textContent = type.desc;
typeItem.setAttribute('data-code', type.code);
if (data.code === 200) {
const searchTypeList = document.getElementById('searchType-list');
data.data.forEach(type => {
const typeItem = document.createElement('div');
typeItem.classList.add('item');
typeItem.textContent = type.desc;
typeItem.setAttribute('data-code', type.code);
typeItem.addEventListener('click', () => {
document.querySelectorAll('#searchType-list .item').forEach(item => {
item.classList.remove('selected');
typeItem.addEventListener('click', () => {
document.querySelectorAll('#searchType-list .item').forEach(item => {
item.classList.remove('selected');
});
typeItem.classList.add('selected');
selectedSearchTypeCode = type.code;
});
typeItem.classList.add('selected');
selectedSearchTypeCode = type.code;
});
searchTypeList.appendChild(typeItem);
});
searchTypeList.appendChild(typeItem);
});
} else if (data.code === -200) {
showMessage(data.msg);
} else {
console.error('未知的响应码:', data.code);
}
})
.catch(error => {
console.error('请求搜索类型接口失败:', error);
@ -448,7 +479,9 @@
document.getElementById('loading').style.display = 'block';
const keywords = document.getElementById('keywords').value;
fetch(`/api/v1/music/search?channel=${selectedPlatformCode}&keywords=${keywords}&type=${selectedSearchTypeCode}&offset=${offset}&limit=${limit}`)
fetch(`/api/v1/music/search?channel=${selectedPlatformCode}&keywords=${encodeURIComponent(keywords)}&type=${selectedSearchTypeCode}&offset=${offset}&limit=${limit}`, {
credentials: 'include' // 确保请求携带cookie
})
.then(response => response.json())
.then(data => {
loading = false;
@ -456,18 +489,24 @@
const resultDiv = document.getElementById('result');
const result = data.data?.result || {};
if (selectedSearchTypeCode == "1") {
// 单曲
displaySongs(result, resultDiv);
} else if (selectedSearchTypeCode == "10") {
// 专辑
displayAlbums(result, resultDiv);
} else if (selectedSearchTypeCode == "100") {
// 歌手
displayArtists(result, resultDiv);
} else if (selectedSearchTypeCode == "1000") {
// 歌单
displayPlaylists(result, resultDiv);
if (data.code === 200) {
if (selectedSearchTypeCode == "1") {
// 单曲
displaySongs(result, resultDiv);
} else if (selectedSearchTypeCode == "10") {
// 专辑
displayAlbums(result, resultDiv);
} else if (selectedSearchTypeCode == "100") {
// 歌手
displayArtists(result, resultDiv);
} else if (selectedSearchTypeCode == "1000") {
// 歌单
displayPlaylists(result, resultDiv);
}
} else if (data.code === -200) {
showMessage(data.msg);
} else {
console.error('未知的响应码:', data.code);
}
})
.catch(error => {
@ -487,13 +526,13 @@
header = document.createElement('div');
header.className = 'list-header';
header.innerHTML = `
<div class="header-info">
<div class="song-checkbox select-all-wrapper"><input type="checkbox"></div>
<div class="song-name">名字</div>
<div class="song-album">所属专辑</div>
<div class="song-artists">作者</div>
</div>
`;
<div class="header-info">
<div class="song-checkbox select-all-wrapper"><input type="checkbox"></div>
<div class="song-name">名字</div>
<div class="song-album">所属专辑</div>
<div class="song-artists">作者</div>
</div>
`;
const selectAllCheckbox = header.querySelector('.select-all-wrapper input[type="checkbox"]');
selectAllCheckbox.addEventListener('change', toggleSelectAll);
@ -520,11 +559,11 @@
const row = document.createElement('div');
row.className = 'song-row';
row.innerHTML = `
<div class="song-checkbox"><input type="checkbox"></div>
<div class="song-name">${song.name}</div>
<div class="song-album">${albumName}</div>
<div class="song-artists">${artists}</div>
`;
<div class="song-checkbox"><input type="checkbox"></div>
<div class="song-name">${song.name}</div>
<div class="song-album">${albumName}</div>
<div class="song-artists">${artists}</div>
`;
const checkbox = row.querySelector('.song-checkbox input[type="checkbox"]');
checkbox.addEventListener('change', () => toggleSongSelection(song.id, checkbox.checked));
list.appendChild(row);
@ -547,10 +586,10 @@
albumItem.className = 'album-item';
albumItem.innerHTML = `
<img src="${album.blurPicUrl}" class="album-cover" alt="${album.name}" />
<div class="album-name" data-id="${album.id}">${album.name}</div>
<div class="album-type">${album.type ? album.type : ''}</div>
`;
<img src="${album.blurPicUrl}" class="album-cover" alt="${album.name}" />
<div class="album-name" data-id="${album.id}">${album.name}</div>
<div class="album-type">${album.type ? album.type : ''}</div>
`;
const albumNameEl = albumItem.querySelector('.album-name');
const albumCoverEl = albumItem.querySelector('.album-cover');
@ -578,9 +617,9 @@
artistItem.className = 'artist-item';
artistItem.innerHTML = `
<img src="${artist.picUrl}" class="artist-pic" alt="${artist.name}" />
<div class="artist-name">${artist.name}</div>
`;
<img src="${artist.picUrl}" class="artist-pic" alt="${artist.name}" />
<div class="artist-name">${artist.name}</div>
`;
artistItem.addEventListener('click', () => {
fetchArtistSongs(artist.id);
@ -608,9 +647,9 @@
const playlistItem = document.createElement('div');
playlistItem.className = 'playlist-item';
playlistItem.innerHTML = `
<img src="${pl.coverImgUrl}" class="playlist-cover" alt="${pl.name}" />
<div class="playlist-name" data-id="${pl.id}">${pl.name}</div>
`;
<img src="${pl.coverImgUrl}" class="playlist-cover" alt="${pl.name}" />
<div class="playlist-name" data-id="${pl.id}">${pl.name}</div>
`;
playlistItem.addEventListener('click', () => {
fetchPlaylistSongs(pl.id);
@ -625,56 +664,80 @@
}
function fetchAlbumSongs(albumId) {
fetch(`/api/v1/music/search/album?channel=${selectedPlatformCode}&id=${albumId}`)
fetch(`/api/v1/music/search/album?channel=${selectedPlatformCode}&id=${albumId}`, {
credentials: 'include' // 确保请求携带cookie
})
.then(res => res.json())
.then(data => {
const songsData = data.data?.songs || [];
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '';
allSongs = [];
selectedSongs = [];
const result = {
songs: songsData,
hasMore: false
};
displaySongs(result, resultDiv);
if (data.code === 200) {
const songsData = data.data?.songs || [];
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '';
allSongs = [];
selectedSongs = [];
const result = {
songs: songsData,
hasMore: false
};
displaySongs(result, resultDiv);
} else if (data.code === -200) {
showMessage(data.msg);
} else {
console.error('未知的响应码:', data.code);
}
})
.catch(err => console.error('获取专辑歌曲出错:', err));
}
function fetchArtistSongs(artistId) {
fetch(`/api/v1/music/search/artist?channel=${selectedPlatformCode}&id=${artistId}`)
fetch(`/api/v1/music/search/artist?channel=${selectedPlatformCode}&id=${artistId}`, {
credentials: 'include' // 确保请求携带cookie
})
.then(res => res.json())
.then(data => {
const hotSongs = data.data?.hotSongs || [];
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '';
allSongs = [];
selectedSongs = [];
const result = {
songs: hotSongs,
hasMore: false
};
displaySongs(result, resultDiv);
if (data.code === 200) {
const hotSongs = data.data?.hotSongs || [];
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '';
allSongs = [];
selectedSongs = [];
const result = {
songs: hotSongs,
hasMore: false
};
displaySongs(result, resultDiv);
} else if (data.code === -200) {
showMessage(data.msg);
} else {
console.error('未知的响应码:', data.code);
}
})
.catch(err => console.error('获取歌手歌曲出错:', err));
}
function fetchPlaylistSongs(playlistId) {
// 获取歌单歌曲数据,返回 data.songs
fetch(`/api/v1/music/search/playlist?channel=${selectedPlatformCode}&id=${playlistId}`)
fetch(`/api/v1/music/search/playlist?channel=${selectedPlatformCode}&id=${playlistId}`, {
credentials: 'include' // 确保请求携带cookie
})
.then(res => res.json())
.then(data => {
const songsData = data.data?.songs || [];
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '';
allSongs = [];
selectedSongs = [];
const result = {
songs: songsData,
hasMore: false
};
displaySongs(result, resultDiv);
if (data.code === 200) {
const songsData = data.data?.songs || [];
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '';
allSongs = [];
selectedSongs = [];
const result = {
songs: songsData,
hasMore: false
};
displaySongs(result, resultDiv);
} else if (data.code === -200) {
showMessage(data.msg);
} else {
console.error('未知的响应码:', data.code);
}
})
.catch(err => console.error('获取歌单歌曲出错:', err));
}
@ -751,9 +814,16 @@
const promises = selectedSongs.map(id => {
const url = `/api/v1/music/download?channel=${channel}&id=${id}`;
return fetch(url)
return fetch(url, {
credentials: 'include' // 确保请求携带cookie
})
.then(res => res.json())
.then(data => {
if (data.code === 200) {
// 下载成功,可以处理成功逻辑
} else if (data.code === -200) {
showMessage(data.msg);
}
completedCount++;
const percent = Math.floor((completedCount / total) * 100);
progressBar.value = percent;
@ -774,6 +844,58 @@
alert("下载完成!文件已存放到服务器指定目录中。");
});
}
// 显示用户名
function displayUsername() {
const username = getCookie('username');
const userInfoDiv = document.getElementById('user-info');
if (username) {
userInfoDiv.textContent = `欢迎, ${username}`;
} else {
userInfoDiv.textContent = `欢迎, 未登录`;
}
}
// 获取Cookie的值
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return decodeURIComponent(parts.pop().split(';').shift());
}
// 设置Cookie的值
function setCookie(name, value, days) {
const d = new Date();
d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
let expires = "expires=" + d.toUTCString();
document.cookie = name + "=" + encodeURIComponent(value) + "; " + expires + "; path=/";
}
// 显示消息并处理重定向
function showMessage(msg) {
alert(msg);
setTimeout(() => {
window.location.href = '/login.html'; // 确保登录页面的路径正确
}, 1000);
}
// 全局的fetch包装函数用于统一处理响应
// 可选如果您希望在所有fetch请求中自动处理code=-200可以定义一个包装函数
// 例如:
/*
function customFetch(url, options = {}) {
options.credentials = 'include'; // 确保携带cookie
return fetch(url, options)
.then(response => response.json())
.then(data => {
if (data.code === -200) {
showMessage(data.msg);
throw new Error(data.msg);
}
return data;
});
}
*/
</script>
</body>
</html>