初始化
This commit is contained in:
68
backend/pom.xml
Normal file
68
backend/pom.xml
Normal file
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>training-backend</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<name>training-backend</name>
|
||||
<description>Training project backend service</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>3.5.12</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||
<version>3.5.12</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<directory>${project.basedir}/build</directory>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.training;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class TrainingApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TrainingApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.example.training.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.training.controller;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/health")
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping
|
||||
public HealthResponse health() {
|
||||
return new HealthResponse("UP", "training-backend", OffsetDateTime.now(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
public record HealthResponse(String status, String service, OffsetDateTime timestamp) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.example.training.controller;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.training.domain.User;
|
||||
import com.example.training.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
public UserController(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public UserPage list(
|
||||
@RequestParam(defaultValue = "") String keyword,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
int safePage = Math.max(page, 0);
|
||||
int safeSize = Math.min(Math.max(size, 1), 100);
|
||||
Page<User> resultPage = userService.pageUsers(keyword, safePage, safeSize);
|
||||
return new UserPage(resultPage.getRecords().stream().map(UserResponse::from).toList(),
|
||||
resultPage.getTotal(), (int) resultPage.getCurrent() - 1, (int) resultPage.getSize());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> create(@Valid @RequestBody UserRequest request) {
|
||||
User user = userService.createUser(request.fullName(), request.email(),
|
||||
request.role(), request.status());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public UserResponse update(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
|
||||
User user = userService.updateUser(id, request.fullName(), request.email(),
|
||||
request.role(), request.status());
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||
}
|
||||
return UserResponse.from(user);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
if (!userService.deleteUser(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||
}
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
ResponseEntity<ApiError> handleDuplicateEmail() {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(new ApiError("该邮箱已被使用,请更换后重试"));
|
||||
}
|
||||
|
||||
public record UserRequest(
|
||||
@NotBlank @Size(max = 80) String fullName,
|
||||
@NotBlank @Email @Size(max = 160) String email,
|
||||
@NotBlank @Size(max = 40) String role,
|
||||
@NotBlank @Size(max = 20) String status) {
|
||||
}
|
||||
|
||||
public record UserPage(List<UserResponse> items, long total, int page, int size) {
|
||||
}
|
||||
|
||||
public record UserResponse(Long id, String fullName, String email, String role,
|
||||
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
static UserResponse from(User user) {
|
||||
return new UserResponse(user.getId(), user.getFullName(), user.getEmail(),
|
||||
user.getRole(), user.getStatus(), user.getCreatedAt(), user.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
|
||||
public record ApiError(String message) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.example.training.database;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "app.database.verify-on-startup", havingValue = "true")
|
||||
public class DatabaseConnectionVerifier implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DatabaseConnectionVerifier.class);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ConfigurableApplicationContext applicationContext;
|
||||
private final boolean exitAfterVerify;
|
||||
|
||||
public DatabaseConnectionVerifier(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
ConfigurableApplicationContext applicationContext,
|
||||
@Value("${app.database.exit-after-verify:false}") boolean exitAfterVerify) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.applicationContext = applicationContext;
|
||||
this.exitAfterVerify = exitAfterVerify;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
Map<String, Object> result = jdbcTemplate.queryForMap(
|
||||
"SELECT DATABASE() AS database_name, CURRENT_USER() AS connected_user, VERSION() AS mysql_version");
|
||||
log.info("Database connectivity verified: database={}, user={}, version={}",
|
||||
result.get("database_name"), result.get("connected_user"), result.get("mysql_version"));
|
||||
if (exitAfterVerify) {
|
||||
SpringApplication.exit(applicationContext, () -> 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
backend/src/main/java/com/example/training/domain/User.java
Normal file
57
backend/src/main/java/com/example/training/domain/User.java
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.example.training.domain;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
@TableName("users")
|
||||
public class User {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("full_name")
|
||||
private String fullName;
|
||||
|
||||
private String email;
|
||||
private String role;
|
||||
private String status;
|
||||
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public User() {
|
||||
}
|
||||
|
||||
public User(String fullName, String email, String role, String status) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
this.fullName = fullName;
|
||||
this.email = email;
|
||||
this.role = role;
|
||||
this.status = status;
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getFullName() { return fullName; }
|
||||
public String getEmail() { return email; }
|
||||
public String getRole() { return role; }
|
||||
public String getStatus() { return status; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
|
||||
public void update(String fullName, String email, String role, String status) {
|
||||
this.fullName = fullName;
|
||||
this.email = email;
|
||||
this.role = role;
|
||||
this.status = status;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.training.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.example.training.domain.User;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface UserMapper extends BaseMapper<User> {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.training.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.training.domain.User;
|
||||
|
||||
public interface UserService {
|
||||
|
||||
Page<User> pageUsers(String keyword, int page, int size);
|
||||
|
||||
User createUser(String fullName, String email, String role, String status);
|
||||
|
||||
User updateUser(Long id, String fullName, String email, String role, String status);
|
||||
|
||||
boolean deleteUser(Long id);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.example.training.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.training.domain.User;
|
||||
import com.example.training.mapper.UserMapper;
|
||||
import com.example.training.service.UserService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
|
||||
public UserServiceImpl(UserMapper userMapper) {
|
||||
this.userMapper = userMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Page<User> pageUsers(String keyword, int page, int size) {
|
||||
Page<User> requestPage = new Page<>(page + 1L, size);
|
||||
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<>();
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
String trimmedKeyword = keyword.trim();
|
||||
query.like(User::getFullName, trimmedKeyword)
|
||||
.or()
|
||||
.like(User::getEmail, trimmedKeyword);
|
||||
}
|
||||
query.orderByDesc(User::getCreatedAt);
|
||||
return userMapper.selectPage(requestPage, query);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public User createUser(String fullName, String email, String role, String status) {
|
||||
User user = new User(normalize(fullName), normalizeEmail(email), role, status);
|
||||
userMapper.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public User updateUser(Long id, String fullName, String email, String role, String status) {
|
||||
User user = userMapper.selectById(id);
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
user.update(normalize(fullName), normalizeEmail(email), role, status);
|
||||
userMapper.updateById(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public boolean deleteUser(Long id) {
|
||||
if (userMapper.selectById(id) == null) {
|
||||
return false;
|
||||
}
|
||||
return userMapper.deleteById(id) > 0;
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeEmail(String value) {
|
||||
return normalize(value).toLowerCase();
|
||||
}
|
||||
}
|
||||
38
backend/src/main/resources/application.yml
Normal file
38
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
spring:
|
||||
config:
|
||||
import: optional:file:../.env.example[.properties],optional:file:../.env[.properties]
|
||||
application:
|
||||
name: ${SPRING_APPLICATION_NAME:training-backend}
|
||||
datasource:
|
||||
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&connectTimeout=${MYSQL_CONNECT_TIMEOUT_MS:5000}&socketTimeout=${MYSQL_SOCKET_TIMEOUT_MS:10000}&tcpKeepAlive=true
|
||||
username: ${MYSQL_USER}
|
||||
password: ${MYSQL_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
minimum-idle: ${MYSQL_POOL_MINIMUM_IDLE:1}
|
||||
maximum-pool-size: ${MYSQL_POOL_MAXIMUM_SIZE:5}
|
||||
connection-timeout: ${MYSQL_POOL_CONNECTION_TIMEOUT_MS:10000}
|
||||
validation-timeout: ${MYSQL_POOL_VALIDATION_TIMEOUT_MS:5000}
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
continue-on-error: false
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
global-config:
|
||||
banner: false
|
||||
|
||||
app:
|
||||
database:
|
||||
verify-on-startup: ${APP_DATABASE_VERIFY_ON_STARTUP:false}
|
||||
exit-after-verify: ${APP_DATABASE_EXIT_AFTER_VERIFY:false}
|
||||
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
shutdown: graceful
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.example.training: ${LOGGING_LEVEL_COM_EXAMPLE_TRAINING:INFO}
|
||||
com.baomidou.mybatisplus: INFO
|
||||
13
backend/src/main/resources/schema.sql
Normal file
13
backend/src/main/resources/schema.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
full_name VARCHAR(80) NOT NULL,
|
||||
email VARCHAR(160) NOT NULL,
|
||||
role VARCHAR(40) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_users_email (email),
|
||||
KEY idx_users_full_name (full_name),
|
||||
KEY idx_users_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.training;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import com.example.training.controller.HealthController;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(HealthController.class)
|
||||
@ActiveProfiles("test")
|
||||
class TrainingApplicationTests {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void healthEndpointReturnsUp() throws Exception {
|
||||
mockMvc.perform(get("/api/health"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("UP"))
|
||||
.andExpect(jsonPath("$.service").value("training-backend"));
|
||||
}
|
||||
}
|
||||
20
backend/src/test/resources/application-test.yml
Normal file
20
backend/src/test/resources/application-test.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: test
|
||||
datasource:
|
||||
url: jdbc:h2:mem:training-test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
username: sa
|
||||
password:
|
||||
driver-class-name: org.h2.Driver
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
|
||||
server:
|
||||
port: 0
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.example.training: INFO
|
||||
org.springframework: WARN
|
||||
Reference in New Issue
Block a user