diff --git a/src/main/java/com/vibevault/config/SecurityConfig.java b/src/main/java/com/vibevault/config/SecurityConfig.java index f179cf6..765009f 100644 --- a/src/main/java/com/vibevault/config/SecurityConfig.java +++ b/src/main/java/com/vibevault/config/SecurityConfig.java @@ -39,13 +39,31 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - // TODO: 配置安全规则 - // 提示: - // - 使用 http.authorizeHttpRequests() 配置路径权限 - // - 使用 http.csrf(csrf -> csrf.disable()) 禁用 CSRF - // - 使用 http.sessionManagement() 配置无状态会话 - // - 使用 http.exceptionHandling() 配置 401 响应 - // - 使用 http.addFilterBefore() 添加 JWT 过滤器 + http + // 配置路径权限 + .authorizeHttpRequests(authorizeRequests -> authorizeRequests + // 公开接口无需认证 + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/playlists").permitAll() + .requestMatchers(HttpMethod.GET, "/api/playlists/{id}").permitAll() + // 其他接口需要认证 + .anyRequest().authenticated() + ) + // 禁用 CSRF + .csrf(csrf -> csrf.disable()) + // 配置无状态会话 + .sessionManagement(sessionManagement -> sessionManagement + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + // 配置 401 响应 + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Unauthorized: Missing or invalid token"); + }) + ) + // 添加 JWT 过滤器 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/vibevault/controller/AuthController.java b/src/main/java/com/vibevault/controller/AuthController.java index 88066a1..1c6b9cd 100644 --- a/src/main/java/com/vibevault/controller/AuthController.java +++ b/src/main/java/com/vibevault/controller/AuthController.java @@ -36,9 +36,43 @@ public class AuthController { this.jwtService = jwtService; } - // TODO: 实现 POST /api/auth/register (状态码 201) + // POST /api/auth/register - 用户注册 + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + public RegisterResponse register(@RequestBody RegisterRequest request) { + // 检查用户名是否已存在 + if (userRepository.existsByUsername(request.username())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Username already exists"); + } - // TODO: 实现 POST /api/auth/login + // 创建新用户,加密密码 + User user = new User(); + user.setUsername(request.username()); + user.setPassword(passwordEncoder.encode(request.password())); + + // 保存用户到数据库 + userRepository.save(user); + + return new RegisterResponse("User registered successfully", user.getUsername()); + } + + // POST /api/auth/login - 用户登录 + @PostMapping("/login") + public LoginResponse login(@RequestBody LoginRequest request) { + // 检查用户名是否存在 + User user = userRepository.findByUsername(request.username()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password")); + + // 验证密码 + if (!passwordEncoder.matches(request.password(), user.getPassword())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password"); + } + + // 生成JWT token + String token = jwtService.generateToken(user.getUsername()); + + return new LoginResponse(token, user.getUsername()); + } } /** diff --git a/src/main/java/com/vibevault/controller/PlaylistController.java b/src/main/java/com/vibevault/controller/PlaylistController.java index 20a5e9c..af63303 100644 --- a/src/main/java/com/vibevault/controller/PlaylistController.java +++ b/src/main/java/com/vibevault/controller/PlaylistController.java @@ -39,19 +39,61 @@ public class PlaylistController { this.playlistService = playlistService; } - // TODO: 实现 GET /api/playlists + // GET /api/playlists - 获取所有歌单(公开) + @GetMapping + public List getAllPlaylists() { + return playlistService.getAllPlaylists(); + } - // TODO: 实现 GET /api/playlists/{id} + // GET /api/playlists/{id} - 获取指定歌单(公开) + @GetMapping("/{id}") + public PlaylistDTO getPlaylistById(@PathVariable Long id) { + return playlistService.getPlaylistById(id); + } - // TODO: 实现 POST /api/playlists (状态码 201) + // POST /api/playlists - 创建歌单(需认证) + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public PlaylistDTO createPlaylist(@RequestBody PlaylistCreateDTO playlistCreateDTO, Authentication authentication) { + String username = authentication.getName(); + return playlistService.createPlaylist(playlistCreateDTO.getName(), username); + } - // TODO: 实现 POST /api/playlists/{id}/songs (状态码 201) + // POST /api/playlists/{id}/songs - 添加歌曲(需认证) + @PostMapping("/{id}/songs") + @ResponseStatus(HttpStatus.CREATED) + public PlaylistDTO addSongToPlaylist(@PathVariable Long id, @RequestBody SongCreateDTO songCreateDTO, Authentication authentication) { + String username = authentication.getName(); + return playlistService.addSongToPlaylist(id, songCreateDTO, username); + } - // TODO: 实现 DELETE /api/playlists/{playlistId}/songs/{songId} (状态码 204) + // DELETE /api/playlists/{playlistId}/songs/{songId} - 移除歌曲(需认证) + @DeleteMapping("/{playlistId}/songs/{songId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void removeSongFromPlaylist(@PathVariable Long playlistId, @PathVariable Long songId, Authentication authentication) { + String username = authentication.getName(); + playlistService.removeSongFromPlaylist(playlistId, songId, username); + } - // TODO: 实现 DELETE /api/playlists/{id} (状态码 204) + // DELETE /api/playlists/{id} - 删除歌单(需认证) + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deletePlaylist(@PathVariable Long id, Authentication authentication) { + String username = authentication.getName(); + playlistService.deletePlaylist(id, username); + } - // TODO [Advanced]: 实现 GET /api/playlists/search?keyword=xxx + // [Advanced] GET /api/playlists/search?keyword=xxx - 搜索歌单 + @GetMapping("/search") + public List searchPlaylists(@RequestParam String keyword) { + return playlistService.searchPlaylists(keyword); + } - // TODO [Advanced]: 实现 POST /api/playlists/{id}/copy?newName=xxx (状态码 201) + // [Advanced] POST /api/playlists/{id}/copy?newName=xxx - 复制歌单 + @PostMapping("/{id}/copy") + @ResponseStatus(HttpStatus.CREATED) + public PlaylistDTO copyPlaylist(@PathVariable Long id, @RequestParam String newName, Authentication authentication) { + String username = authentication.getName(); + return playlistService.copyPlaylist(id, newName, username); + } } diff --git a/src/main/java/com/vibevault/exception/UnauthorizedAccessException.java b/src/main/java/com/vibevault/exception/UnauthorizedAccessException.java new file mode 100644 index 0000000..be2905f --- /dev/null +++ b/src/main/java/com/vibevault/exception/UnauthorizedAccessException.java @@ -0,0 +1,12 @@ +package com.vibevault.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.FORBIDDEN) +public class UnauthorizedAccessException extends RuntimeException { + + public UnauthorizedAccessException(String message) { + super(message); + } +} diff --git a/src/main/java/com/vibevault/model/Playlist.java b/src/main/java/com/vibevault/model/Playlist.java index 129868f..ed77191 100644 --- a/src/main/java/com/vibevault/model/Playlist.java +++ b/src/main/java/com/vibevault/model/Playlist.java @@ -16,14 +16,22 @@ import java.util.List; * - 一个歌单包含多首歌曲(一对多关系) * - 删除歌单时应级联删除其中的歌曲 */ +@Entity +@Table(name = "playlists") public class Playlist { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false) private String name; + @ManyToOne + @JoinColumn(name = "owner_id", nullable = false) private User owner; + @OneToMany(mappedBy = "playlist", cascade = CascadeType.ALL, orphanRemoval = true) private List songs = new ArrayList<>(); protected Playlist() { @@ -59,7 +67,8 @@ public class Playlist { * 提示:需要维护双向关系 */ public void addSong(Song song) { - // TODO: 实现添加歌曲逻辑 + songs.add(song); + song.setPlaylist(this); } /** @@ -67,6 +76,7 @@ public class Playlist { * 提示:需要维护双向关系 */ public void removeSong(Song song) { - // TODO: 实现移除歌曲逻辑 + songs.remove(song); + song.setPlaylist(null); } } diff --git a/src/main/java/com/vibevault/model/Song.java b/src/main/java/com/vibevault/model/Song.java index 953c654..956528a 100644 --- a/src/main/java/com/vibevault/model/Song.java +++ b/src/main/java/com/vibevault/model/Song.java @@ -10,16 +10,25 @@ import jakarta.persistence.*; * - id 作为自增主键 * - 每首歌曲属于一个歌单(多对一关系) */ +@Entity +@Table(name = "songs") public class Song { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false) private String title; + @Column(nullable = false) private String artist; + @Column(nullable = false) private int durationInSeconds; + @ManyToOne + @JoinColumn(name = "playlist_id") private Playlist playlist; public Song() { diff --git a/src/main/java/com/vibevault/model/User.java b/src/main/java/com/vibevault/model/User.java index b4caa63..3b6072b 100644 --- a/src/main/java/com/vibevault/model/User.java +++ b/src/main/java/com/vibevault/model/User.java @@ -12,15 +12,22 @@ import jakarta.persistence.*; * - password 不能为空 * - [Challenge] 支持用户角色(如 ROLE_USER, ROLE_ADMIN) */ +@Entity +@Table(name = "users") public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(unique = true, nullable = false) private String username; + @Column(nullable = false) private String password; // [Challenge] 用户角色,默认为 ROLE_USER + @Column(nullable = false) private String role = "ROLE_USER"; protected User() { @@ -49,6 +56,14 @@ public class User { return password; } + public void setUsername(String username) { + this.username = username; + } + + public void setPassword(String password) { + this.password = password; + } + public String getRole() { return role; } diff --git a/src/main/java/com/vibevault/repository/PlaylistRepository.java b/src/main/java/com/vibevault/repository/PlaylistRepository.java index c57a35a..8268d7e 100644 --- a/src/main/java/com/vibevault/repository/PlaylistRepository.java +++ b/src/main/java/com/vibevault/repository/PlaylistRepository.java @@ -18,5 +18,9 @@ import java.util.List; */ @Repository public interface PlaylistRepository extends JpaRepository { - // TODO [Advanced]: 添加高级查询方法 + // 按所有者查询歌单 + List findByOwner(User owner); + + // [Advanced]: 按名称模糊搜索歌单 + List findByNameContainingIgnoreCase(String keyword); } diff --git a/src/main/java/com/vibevault/repository/UserRepository.java b/src/main/java/com/vibevault/repository/UserRepository.java index 0b76601..7a47bc5 100644 --- a/src/main/java/com/vibevault/repository/UserRepository.java +++ b/src/main/java/com/vibevault/repository/UserRepository.java @@ -15,5 +15,9 @@ import java.util.Optional; */ @Repository public interface UserRepository extends JpaRepository { - // TODO: 添加必要的查询方法 + // 根据用户名查找用户 + Optional findByUsername(String username); + + // 检查用户名是否已存在 + boolean existsByUsername(String username); } diff --git a/src/main/java/com/vibevault/security/JwtAuthenticationFilter.java b/src/main/java/com/vibevault/security/JwtAuthenticationFilter.java index be2a0bb..1adb9a8 100644 --- a/src/main/java/com/vibevault/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/vibevault/security/JwtAuthenticationFilter.java @@ -45,17 +45,41 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @NonNull FilterChain filterChain ) throws ServletException, IOException { - // TODO: 实现 JWT 认证逻辑 // 1. 从请求头获取 Authorization + final String authHeader = request.getHeader("Authorization"); + final String jwt; + final String username; + // 2. 检查是否以 "Bearer " 开头 + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // 3. 提取 token 并验证 + jwt = authHeader.substring(7); + username = jwtService.extractUsername(jwt); + // 4. 如果有效,创建 Authentication 并设置到 SecurityContextHolder - // - // 提示: - // - 使用 request.getHeader("Authorization") 获取头 - // - 使用 jwtService.extractUsername() 和 jwtService.isTokenValid() - // - 使用 UsernamePasswordAuthenticationToken 创建认证对象 - // - 使用 SecurityContextHolder.getContext().setAuthentication() 设置 + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + User user = userRepository.findByUsername(username) + .orElse(null); + + if (user != null && jwtService.isTokenValid(jwt, user)) { + // 设置用户角色(当前只有默认角色) + List authorities = Collections.singletonList( + new SimpleGrantedAuthority("ROLE_USER") + ); + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + user, null, authorities + ); + authToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } filterChain.doFilter(request, response); } diff --git a/src/main/java/com/vibevault/security/JwtService.java b/src/main/java/com/vibevault/security/JwtService.java index f94e85a..a87fef7 100644 --- a/src/main/java/com/vibevault/security/JwtService.java +++ b/src/main/java/com/vibevault/security/JwtService.java @@ -30,26 +30,54 @@ public class JwtService { * 为用户生成 JWT token */ public String generateToken(String username) { - // TODO: 实现 token 生成 - // 提示:使用 Jwts.builder() - throw new UnsupportedOperationException("待实现"); + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + expiration); + + return Jwts.builder() + .subject(username) + .issuedAt(now) + .expiration(expirationDate) + .signWith(getSigningKey()) + .compact(); } /** * 从 token 中提取用户名 */ public String extractUsername(String token) { - // TODO: 实现用户名提取 - // 提示:使用 Jwts.parser() - throw new UnsupportedOperationException("待实现"); + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); } /** * 验证 token 是否有效 */ public boolean isTokenValid(String token, String username) { - // TODO: 实现 token 验证 - throw new UnsupportedOperationException("待实现"); + try { + String extractedUsername = extractUsername(token); + Date expirationDate = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getExpiration(); + Date now = new Date(); + + return username.equals(extractedUsername) && expirationDate.after(now); + } catch (Exception e) { + return false; + } + } + + /** + * 验证 token 是否对特定用户有效 + */ + public boolean isTokenValid(String token, com.vibevault.model.User user) { + return isTokenValid(token, user.getUsername()); } /** diff --git a/src/main/java/com/vibevault/service/impl/PlaylistServiceImpl.java b/src/main/java/com/vibevault/service/impl/PlaylistServiceImpl.java new file mode 100644 index 0000000..6ef5b47 --- /dev/null +++ b/src/main/java/com/vibevault/service/impl/PlaylistServiceImpl.java @@ -0,0 +1,153 @@ +package com.vibevault.service.impl; + +import com.vibevault.dto.PlaylistDTO; +import com.vibevault.dto.SongCreateDTO; +import com.vibevault.exception.ResourceNotFoundException; +import com.vibevault.exception.UnauthorizedAccessException; +import com.vibevault.model.Playlist; +import com.vibevault.model.Song; +import com.vibevault.model.User; +import com.vibevault.repository.PlaylistRepository; +import com.vibevault.repository.UserRepository; +import com.vibevault.service.PlaylistService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class PlaylistServiceImpl implements PlaylistService { + + private final PlaylistRepository playlistRepository; + private final UserRepository userRepository; + + @Autowired + public PlaylistServiceImpl(PlaylistRepository playlistRepository, UserRepository userRepository) { + this.playlistRepository = playlistRepository; + this.userRepository = userRepository; + } + + @Override + public List getAllPlaylists() { + return playlistRepository.findAll().stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + @Override + public PlaylistDTO getPlaylistById(Long id) { + Playlist playlist = playlistRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Playlist not found with id: " + id)); + return convertToDTO(playlist); + } + + @Override + @Transactional + public PlaylistDTO createPlaylist(String name, String ownerUsername) { + User owner = userRepository.findByUsername(ownerUsername) + .orElseThrow(() -> new ResourceNotFoundException("User not found with username: " + ownerUsername)); + + Playlist playlist = new Playlist(); + playlist.setName(name); + playlist.setOwner(owner); + + Playlist savedPlaylist = playlistRepository.save(playlist); + return convertToDTO(savedPlaylist); + } + + @Override + @Transactional + public PlaylistDTO addSongToPlaylist(Long playlistId, SongCreateDTO songDTO, String username) { + Playlist playlist = getPlaylistWithOwnershipCheck(playlistId, username); + + Song song = new Song(); + song.setTitle(songDTO.getTitle()); + song.setArtist(songDTO.getArtist()); + song.setDurationInSeconds(songDTO.getDurationInSeconds()); + + playlist.addSong(song); + Playlist savedPlaylist = playlistRepository.save(playlist); + return convertToDTO(savedPlaylist); + } + + @Override + @Transactional + public void removeSongFromPlaylist(Long playlistId, Long songId, String username) { + Playlist playlist = getPlaylistWithOwnershipCheck(playlistId, username); + + Song song = playlist.getSongs().stream() + .filter(s -> s.getId().equals(songId)) + .findFirst() + .orElseThrow(() -> new ResourceNotFoundException("Song not found in playlist with id: " + songId)); + + playlist.removeSong(song); + playlistRepository.save(playlist); + } + + @Override + @Transactional + public void deletePlaylist(Long playlistId, String username) { + Playlist playlist = getPlaylistWithOwnershipCheck(playlistId, username); + playlistRepository.delete(playlist); + } + + // ========== Advanced 方法(选做)========== + + @Override + public List searchPlaylists(String keyword) { + return playlistRepository.findByNameContainingIgnoreCase(keyword).stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + @Override + @Transactional + public PlaylistDTO copyPlaylist(Long playlistId, String newName, String username) { + Playlist originalPlaylist = playlistRepository.findById(playlistId) + .orElseThrow(() -> new ResourceNotFoundException("Playlist not found with id: " + playlistId)); + + User newOwner = userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("User not found with username: " + username)); + + Playlist copiedPlaylist = new Playlist(); + copiedPlaylist.setName(newName); + copiedPlaylist.setOwner(newOwner); + + // 复制歌曲 + for (Song originalSong : originalPlaylist.getSongs()) { + Song copiedSong = new Song(); + copiedSong.setTitle(originalSong.getTitle()); + copiedSong.setArtist(originalSong.getArtist()); + copiedSong.setDurationInSeconds(originalSong.getDurationInSeconds()); + copiedPlaylist.addSong(copiedSong); + } + + Playlist savedPlaylist = playlistRepository.save(copiedPlaylist); + return convertToDTO(savedPlaylist); + } + + // 辅助方法:检查歌单所有权 + private Playlist getPlaylistWithOwnershipCheck(Long playlistId, String username) { + Playlist playlist = playlistRepository.findById(playlistId) + .orElseThrow(() -> new ResourceNotFoundException("Playlist not found with id: " + playlistId)); + + // 检查是否为歌单所有者 + if (!playlist.getOwner().getUsername().equals(username)) { + throw new UnauthorizedAccessException("You don't have permission to modify this playlist"); + } + + return playlist; + } + + // 辅助方法:将 Playlist 转换为 PlaylistDTO + private PlaylistDTO convertToDTO(Playlist playlist) { + PlaylistDTO dto = new PlaylistDTO(); + dto.setId(playlist.getId()); + dto.setName(playlist.getName()); + dto.setOwnerUsername(playlist.getOwner().getUsername()); + dto.setSongCount(playlist.getSongs().size()); + return dto; + } +}