diff --git a/README.md b/README.md index 37510ffa5..51af298f8 100644 --- a/README.md +++ b/README.md @@ -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 过期时间(毫秒)。 diff --git a/src/main/java/com/openisle/controller/AuthController.java b/src/main/java/com/openisle/controller/AuthController.java index 797aee5a1..6baf3f028 100644 --- a/src/main/java/com/openisle/controller/AuthController.java +++ b/src/main/java/com/openisle/controller/AuthController.java @@ -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) { diff --git a/src/main/java/com/openisle/controller/UserController.java b/src/main/java/com/openisle/controller/UserController.java new file mode 100644 index 000000000..ba19c5dbd --- /dev/null +++ b/src/main/java/com/openisle/controller/UserController.java @@ -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 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; + } +} diff --git a/src/main/java/com/openisle/model/User.java b/src/main/java/com/openisle/model/User.java index 42fbafe70..43a726126 100644 --- a/src/main/java/com/openisle/model/User.java +++ b/src/main/java/com/openisle/model/User.java @@ -35,6 +35,8 @@ public class User { private String verificationCode; + private String avatar; + @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role = Role.USER; diff --git a/src/main/java/com/openisle/service/CosImageUploader.java b/src/main/java/com/openisle/service/CosImageUploader.java new file mode 100644 index 000000000..c55bfab59 --- /dev/null +++ b/src/main/java/com/openisle/service/CosImageUploader.java @@ -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; + } +} diff --git a/src/main/java/com/openisle/service/EmailSender.java b/src/main/java/com/openisle/service/EmailSender.java new file mode 100644 index 000000000..c0705c7e2 --- /dev/null +++ b/src/main/java/com/openisle/service/EmailSender.java @@ -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); +} diff --git a/src/main/java/com/openisle/service/ImageUploader.java b/src/main/java/com/openisle/service/ImageUploader.java new file mode 100644 index 000000000..d6cc8f944 --- /dev/null +++ b/src/main/java/com/openisle/service/ImageUploader.java @@ -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); +} diff --git a/src/main/java/com/openisle/service/EmailService.java b/src/main/java/com/openisle/service/ResendEmailSender.java similarity index 95% rename from src/main/java/com/openisle/service/EmailService.java rename to src/main/java/com/openisle/service/ResendEmailSender.java index b9442800a..21db847e0 100644 --- a/src/main/java/com/openisle/service/EmailService.java +++ b/src/main/java/com/openisle/service/ResendEmailSender.java @@ -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 diff --git a/src/main/java/com/openisle/service/UserService.java b/src/main/java/com/openisle/service/UserService.java index c481e0adc..9367134a9 100644 --- a/src/main/java/com/openisle/service/UserService.java +++ b/src/main/java/com/openisle/service/UserService.java @@ -78,4 +78,15 @@ public class UserService { .filter(User::isVerified) .filter(user -> passwordEncoder.matches(password, user.getPassword())); } + + public Optional 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); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b342455a7..95e062f5e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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} diff --git a/src/test/java/com/openisle/controller/AuthControllerTest.java b/src/test/java/com/openisle/controller/AuthControllerTest.java index cdc73798d..8f0a5cd04 100644 --- a/src/test/java/com/openisle/controller/AuthControllerTest.java +++ b/src/test/java/com/openisle/controller/AuthControllerTest.java @@ -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 { diff --git a/src/test/java/com/openisle/controller/CategoryControllerTest.java b/src/test/java/com/openisle/controller/CategoryControllerTest.java new file mode 100644 index 000000000..e9a2850c6 --- /dev/null +++ b/src/test/java/com/openisle/controller/CategoryControllerTest.java @@ -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")); + } +} diff --git a/src/test/java/com/openisle/controller/UserControllerTest.java b/src/test/java/com/openisle/controller/UserControllerTest.java new file mode 100644 index 000000000..e75c85ded --- /dev/null +++ b/src/test/java/com/openisle/controller/UserControllerTest.java @@ -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"); + } +} diff --git a/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java b/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java index a1016f393..578a6f832 100644 --- a/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java +++ b/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java @@ -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(); diff --git a/src/test/java/com/openisle/service/CosImageUploaderTest.java b/src/test/java/com/openisle/service/CosImageUploaderTest.java new file mode 100644 index 000000000..7ac38d807 --- /dev/null +++ b/src/test/java/com/openisle/service/CosImageUploaderTest.java @@ -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); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index ae0b8fb00..fa5a6a264 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -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