初始化

This commit is contained in:
马宝龙
2026-07-27 23:20:12 +08:00
commit 5e07c24dd4
35 changed files with 2966 additions and 0 deletions

68
backend/pom.xml Normal file
View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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

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

View File

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

View 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