Merge branch 'main' of github.com:nagisa77/OpenIsle

This commit is contained in:
tim
2025-07-01 10:02:29 +08:00
16 changed files with 266 additions and 9 deletions

View File

@@ -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 过期时间(毫秒)。

View File

@@ -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) {

View 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;
}
}

View File

@@ -35,6 +35,8 @@ public class User {
private String verificationCode;
private String avatar;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role = Role.USER;

View 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;
}
}

View 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);
}

View 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);
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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"));
}
}

View File

@@ -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");
}
}

View File

@@ -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();

View 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);
}
}

View File

@@ -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