mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-21 14:30:59 +08:00
Merge branch 'main' of github.com:nagisa77/OpenIsle
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
# OpenIsle
|
||||
|
||||
OpenIsle 是一个基于 Spring Boot 的社区后端平台示例,提供注册、登录和基于 JWT 的认证功能,支持使用 MySQL 作为数据库,并通过 [Resend](https://resend.com) API 发送注册邮件。
|
||||
OpenIsle 是一个基于 Spring Boot 的社区后端平台示例,提供注册、登录和基于 JWT 的认证功能,支持使用 MySQL 作为数据库,并通过可插拔的邮件发送组件发送注册邮件。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **注册/登录**:用户可以注册并登录,密码使用 BCrypt 加密保存。
|
||||
- **JWT 认证**:登录成功后返回 JWT,后续请求需在 `Authorization` 头中携带 `Bearer` token。
|
||||
- **邮件通知**:示例通过 Resend API 发送欢迎邮件,可根据需要修改。
|
||||
- **邮件通知**:邮件发送通过 `EmailSender` 抽象实现,默认提供 `ResendEmailSender` 实现,可根据需要扩展。
|
||||
- **灵活配置**:数据库地址、账户密码、Resend API Key 等均可通过环境变量或 `application.properties` 配置。
|
||||
- **角色权限**:内置 `ADMIN` 和 `USER` 两种角色,`/api/admin/**` 接口仅管理员可访问。
|
||||
- **文章/评论**:支持发表文章并在文章下发布评论,评论可多级回复。
|
||||
- **图片上传**:图片上传通过 `ImageUploader` 抽象实现,示例中提供基于腾讯云 COS 的 `CosImageUploader`。
|
||||
- **用户头像**:`User` 模型新增 `avatar` 字段,可通过 `UserController` 上传并更新。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -26,6 +28,7 @@ OpenIsle 是一个基于 Spring Boot 的社区后端平台示例,提供注册
|
||||
- `MYSQL_USER`:数据库用户名。
|
||||
- `MYSQL_PASSWORD`:数据库密码。
|
||||
- `RESEND_API_KEY`:Resend 邮件服务 API Key。
|
||||
- `COS_BASE_URL`:腾讯云 COS 访问域名,用于生成图片链接。
|
||||
- `JWT_SECRET`:JWT 签名密钥。
|
||||
- `JWT_EXPIRATION`:JWT 过期时间(毫秒)。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.EmailService;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.service.JwtService;
|
||||
import com.openisle.service.UserService;
|
||||
import lombok.Data;
|
||||
@@ -42,7 +42,7 @@ curl -X POST http://localhost:8080/api/auth/login \
|
||||
public class AuthController {
|
||||
private final UserService userService;
|
||||
private final JwtService jwtService;
|
||||
private final EmailService emailService;
|
||||
private final EmailSender emailService;
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
|
||||
|
||||
53
src/main/java/com/openisle/controller/UserController.java
Normal file
53
src/main/java/com/openisle/controller/UserController.java
Normal file
@@ -0,0 +1,53 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.ImageUploader;
|
||||
import com.openisle.service.UserService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<UserDto> me(Authentication auth) {
|
||||
User user = userService.findByUsername(auth.getName()).orElseThrow();
|
||||
return ResponseEntity.ok(toDto(user));
|
||||
}
|
||||
|
||||
@PostMapping("/me/avatar")
|
||||
public ResponseEntity<?> uploadAvatar(@RequestParam("file") MultipartFile file,
|
||||
Authentication auth) throws IOException {
|
||||
String url = imageUploader.upload(file.getBytes(), file.getOriginalFilename());
|
||||
userService.updateAvatar(auth.getName(), url);
|
||||
return ResponseEntity.ok(Map.of("url", url));
|
||||
}
|
||||
|
||||
private UserDto toDto(User user) {
|
||||
UserDto dto = new UserDto();
|
||||
dto.setId(user.getId());
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setEmail(user.getEmail());
|
||||
dto.setAvatar(user.getAvatar());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class UserDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String email;
|
||||
private String avatar;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ public class User {
|
||||
|
||||
private String verificationCode;
|
||||
|
||||
private String avatar;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private Role role = Role.USER;
|
||||
|
||||
24
src/main/java/com/openisle/service/CosImageUploader.java
Normal file
24
src/main/java/com/openisle/service/CosImageUploader.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* ImageUploader implementation using Tencent Cloud COS.
|
||||
* For simplicity this demo just returns a URL composed of the base URL and file name.
|
||||
*/
|
||||
@Service
|
||||
public class CosImageUploader extends ImageUploader {
|
||||
|
||||
private final String baseUrl;
|
||||
|
||||
public CosImageUploader(@Value("${cos.base-url:https://example.com}") String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(byte[] data, String filename) {
|
||||
// In a real implementation you would call COS SDK here
|
||||
return baseUrl + "/" + filename;
|
||||
}
|
||||
}
|
||||
14
src/main/java/com/openisle/service/EmailSender.java
Normal file
14
src/main/java/com/openisle/service/EmailSender.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.openisle.service;
|
||||
|
||||
/**
|
||||
* Abstract email sender used to deliver emails.
|
||||
*/
|
||||
public abstract class EmailSender {
|
||||
/**
|
||||
* Send an email to a recipient.
|
||||
* @param to recipient email address
|
||||
* @param subject email subject
|
||||
* @param text email body
|
||||
*/
|
||||
public abstract void sendEmail(String to, String subject, String text);
|
||||
}
|
||||
14
src/main/java/com/openisle/service/ImageUploader.java
Normal file
14
src/main/java/com/openisle/service/ImageUploader.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.openisle.service;
|
||||
|
||||
/**
|
||||
* Abstract service for uploading images.
|
||||
*/
|
||||
public abstract class ImageUploader {
|
||||
/**
|
||||
* Upload an image and return its accessible URL.
|
||||
* @param data image binary data
|
||||
* @param filename name of the file
|
||||
* @return accessible URL of the uploaded file
|
||||
*/
|
||||
public abstract String upload(byte[] data, String filename);
|
||||
}
|
||||
@@ -12,13 +12,14 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class EmailService {
|
||||
public class ResendEmailSender extends EmailSender {
|
||||
|
||||
@Value("${resend.api.key}")
|
||||
private String apiKey;
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
@Override
|
||||
public void sendEmail(String to, String subject, String text) {
|
||||
String url = "https://api.resend.com/emails"; // hypothetical endpoint
|
||||
|
||||
@@ -78,4 +78,15 @@ public class UserService {
|
||||
.filter(User::isVerified)
|
||||
.filter(user -> passwordEncoder.matches(password, user.getPassword()));
|
||||
}
|
||||
|
||||
public Optional<User> findByUsername(String username) {
|
||||
return userRepository.findByUsername(username);
|
||||
}
|
||||
|
||||
public User updateAvatar(String username, String avatarUrl) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
user.setAvatar(avatarUrl);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ spring.datasource.password=${MYSQL_PASSWORD:password}
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
resend.api.key=${RESEND_API_KEY:}
|
||||
cos.base-url=${COS_BASE_URL:https://example.com}
|
||||
|
||||
app.jwt.secret=${JWT_SECRET:ChangeThisSecretKeyForJwt}
|
||||
app.jwt.expiration=${JWT_EXPIRATION:86400000}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.EmailService;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.service.JwtService;
|
||||
import com.openisle.service.UserService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -33,7 +33,7 @@ class AuthControllerTest {
|
||||
@MockBean
|
||||
private JwtService jwtService;
|
||||
@MockBean
|
||||
private EmailService emailService;
|
||||
private EmailSender emailService;
|
||||
|
||||
@Test
|
||||
void registerSendsEmail() throws Exception {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.service.CategoryService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(CategoryController.class)
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
class CategoryControllerTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private CategoryService categoryService;
|
||||
|
||||
@Test
|
||||
void createAndGetCategory() throws Exception {
|
||||
Category c = new Category();
|
||||
c.setId(1L);
|
||||
c.setName("tech");
|
||||
Mockito.when(categoryService.createCategory(eq("tech"))).thenReturn(c);
|
||||
Mockito.when(categoryService.getCategory(1L)).thenReturn(c);
|
||||
|
||||
mockMvc.perform(post("/api/categories")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"tech\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("tech"));
|
||||
|
||||
mockMvc.perform(get("/api/categories/1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listCategories() throws Exception {
|
||||
Category c = new Category();
|
||||
c.setId(2L);
|
||||
c.setName("life");
|
||||
Mockito.when(categoryService.listCategories()).thenReturn(List.of(c));
|
||||
|
||||
mockMvc.perform(get("/api/categories"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("life"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.ImageUploader;
|
||||
import com.openisle.service.UserService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(UserController.class)
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
class UserControllerTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private UserService userService;
|
||||
@MockBean
|
||||
private ImageUploader imageUploader;
|
||||
|
||||
@Test
|
||||
void getCurrentUser() throws Exception {
|
||||
User u = new User();
|
||||
u.setId(1L);
|
||||
u.setUsername("alice");
|
||||
u.setEmail("a@b.com");
|
||||
u.setAvatar("http://x/avatar.png");
|
||||
Mockito.when(userService.findByUsername("alice")).thenReturn(Optional.of(u));
|
||||
|
||||
mockMvc.perform(get("/api/users/me").principal(new UsernamePasswordAuthenticationToken("alice","p")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.avatar").value("http://x/avatar.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadAvatar() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "a.png", MediaType.IMAGE_PNG_VALUE, "img".getBytes());
|
||||
Mockito.when(imageUploader.upload(any(), eq("a.png"))).thenReturn("http://img/a.png");
|
||||
|
||||
mockMvc.perform(multipart("/api/users/me/avatar").file(file).principal(new UsernamePasswordAuthenticationToken("alice","p")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.url").value("http://img/a.png"));
|
||||
|
||||
Mockito.verify(userService).updateAvatar("alice", "http://img/a.png");
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package com.openisle.integration;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.EmailService;
|
||||
import com.openisle.service.EmailSender;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -27,7 +27,7 @@ class ComplexFlowIntegrationTest {
|
||||
private UserRepository users;
|
||||
|
||||
@MockBean
|
||||
private EmailService emailService;
|
||||
private EmailSender emailService;
|
||||
|
||||
private String registerAndLogin(String username, String email) {
|
||||
HttpHeaders h = new HttpHeaders();
|
||||
|
||||
14
src/test/java/com/openisle/service/CosImageUploaderTest.java
Normal file
14
src/test/java/com/openisle/service/CosImageUploaderTest.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CosImageUploaderTest {
|
||||
@Test
|
||||
void uploadReturnsUrl() {
|
||||
CosImageUploader uploader = new CosImageUploader("http://cos.example.com");
|
||||
String url = uploader.upload("data".getBytes(), "img.png");
|
||||
assertEquals("http://cos.example.com/img.png", url);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ spring.datasource.password=
|
||||
spring.jpa.hibernate.ddl-auto=create-drop
|
||||
|
||||
resend.api.key=dummy
|
||||
cos.base-url=http://test.example.com
|
||||
|
||||
app.jwt.secret=TestSecret
|
||||
app.jwt.expiration=3600000
|
||||
|
||||
Reference in New Issue
Block a user