Ver Fonte

新增 API_server 模块:User Management API (Spring Boot 3.5, H2+JPA+springdoc)

yangyi há 1 semana atrás
pai
commit
2e851bec52

+ 1 - 0
.gitignore

@@ -1,4 +1,5 @@
 target/
+uploads/
 !.mvn/wrapper/maven-wrapper.jar
 !**/src/main/**/target/
 !**/src/test/**/target/

+ 33 - 9
AGENTS.md

@@ -2,20 +2,44 @@
 
 ## Project
 
-Single-module Maven project (Java 17) for exploring `java.net.http.HttpClient`.
-Entry point: `src/main/java/space/anyi/httpClient/Main.java`
+Single-module Maven project (Java 17) demonstrating `java.net.http.HttpClient`.
+Each feature is a small example class in
+`src/main/java/space/anyi/httpClient/` (QuickStart, GetExample, PostExample,
+HeaderExample, BodyExample, FileDownloadExample, SyncAsyncExample,
+ResponseHandlerExample, ClientConfigExample, RequestConfigExample,
+CoreApiExample). There is no `Main.java` / `exec:java` — examples are invoked via tests.
 
-## Build & Run
+## Build & Test
 
 ```bash
-mvn compile exec:java          # compile and run Main.main()
-mvn package -q                 # package (no tests yet)
+mvn test                       # run all tests in src/test (JUnit5 + Jackson)
+mvn -Dtest=GetExampleTest test # run a single test class
 ```
 
-No test framework is configured; `src/test/` is empty.
+All 12 test classes in `src/test/java/space/anyi/httpClient/` hit the live HTTP API and require the API server running on port 8080. Without it, network-dependent tests fail.
+
+## API Server (test dependency)
+
+`API_server/` is a separate, standalone Spring Boot 3.5 app (H2 + JPA + springdoc)
+providing the User Management API the HTTP Client examples call. It is **not**
+a Maven module of the root pom (root pom is single-module and independent).
+
+```bash
+mvn -f API_server/pom.xml spring-boot:run   # start API on http://localhost:8080
+```
+
+Endpoint notes: default port 8080; `uploads/` (gitignored) stores uploads.
+OpenAPI UI available via springdoc once running.
+
+## Documentation
+
+- `httpClient.md` — topic outline (source of truth for structure)
+- `doc.md` — detailed usage doc; `blog.md` — finished blog rendering of doc.md,
+  aligned with the example classes
 
 ## Conventions
 
-- Package: `space.anyi.httpClient`
-- Language: Java 17 (`maven.compiler.source/target` = 17)
-- Documentation notes live in `httpClient.md`
+- Package: `space.anyi.httpClient` (root), `space.anyi.*` (API server)
+- Java 17 (`maven.compiler.source/target` = 17)
+- Tests use `System.nanoTime()` to generate unique accounts, avoiding conflicts
+  with previously-created rows against the shared API server.

+ 58 - 0
API_server/pom.xml

@@ -0,0 +1,58 @@
+<?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 http://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.5.16</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>space.anyi</groupId>
+    <artifactId>API_server</artifactId>
+    <version>1.0-SNAPSHOT</version>
+    <description>测试使用的HTTP API</description>
+
+    <properties>
+        <maven.compiler.source>17</maven.compiler.source>
+        <maven.compiler.target>17</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-jpa</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springdoc</groupId>
+            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
+            <version>2.9.1</version>
+        </dependency>
+        <dependency>
+            <groupId>com.h2database</groupId>
+            <artifactId>h2</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 11 - 0
API_server/src/main/java/space/anyi/Application.java

@@ -0,0 +1,11 @@
+package space.anyi;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class Application {
+    public static void main(String[] args) {
+        SpringApplication.run(Application.class, args);
+    }
+}

+ 45 - 0
API_server/src/main/java/space/anyi/common/GlobalExceptionHandler.java

@@ -0,0 +1,45 @@
+package space.anyi.common;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
+import org.springframework.web.server.ResponseStatusException;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    @ExceptionHandler(ResponseStatusException.class)
+    public ResponseEntity<Result<Void>> handleResponseStatus(ResponseStatusException ex) {
+        HttpStatus status = (HttpStatus) ex.getStatusCode();
+        String message = ex.getReason() != null ? ex.getReason() : status.getReasonPhrase();
+        return new ResponseEntity<>(Result.error(status.value(), message), status);
+    }
+
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public ResponseEntity<Result<Void>> handleValidation(MethodArgumentNotValidException ex) {
+        String message = ex.getBindingResult().getFieldErrors().stream()
+                .findFirst()
+                .map(error -> error.getDefaultMessage())
+                .orElse(ResultCode.BAD_REQUEST.getMessage());
+        return new ResponseEntity<>(Result.error(ResultCode.BAD_REQUEST, message), HttpStatus.BAD_REQUEST);
+    }
+
+    @ExceptionHandler(HttpMessageNotReadableException.class)
+    public ResponseEntity<Result<Void>> handleNotReadable(HttpMessageNotReadableException ex) {
+        return new ResponseEntity<>(Result.error(ResultCode.BAD_REQUEST, "Malformed request body"), HttpStatus.BAD_REQUEST);
+    }
+
+    @ExceptionHandler(MaxUploadSizeExceededException.class)
+    public ResponseEntity<Result<Void>> handleMaxUpload(MaxUploadSizeExceededException ex) {
+        return new ResponseEntity<>(Result.error(ResultCode.BAD_REQUEST, "File size exceeds the upload limit"), HttpStatus.BAD_REQUEST);
+    }
+
+    @ExceptionHandler(Exception.class)
+    public ResponseEntity<Result<Void>> handleGeneric(Exception ex) {
+        return new ResponseEntity<>(Result.error(ResultCode.INTERNAL_SERVER_ERROR), HttpStatus.INTERNAL_SERVER_ERROR);
+    }
+}

+ 69 - 0
API_server/src/main/java/space/anyi/common/Result.java

@@ -0,0 +1,69 @@
+package space.anyi.common;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "Unified API response wrapper")
+public class Result<T> {
+
+    @Schema(description = "Business status code", example = "200")
+    private Integer code;
+
+    @Schema(description = "Response message", example = "OK")
+    private String message;
+
+    @Schema(description = "Response payload")
+    private T data;
+
+    public Result() {
+    }
+
+    public Result(Integer code, String message, T data) {
+        this.code = code;
+        this.message = message;
+        this.data = data;
+    }
+
+    public static <T> Result<T> success() {
+        return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null);
+    }
+
+    public static <T> Result<T> success(T data) {
+        return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
+    }
+
+    public static <T> Result<T> error(ResultCode resultCode) {
+        return new Result<>(resultCode.getCode(), resultCode.getMessage(), null);
+    }
+
+    public static <T> Result<T> error(ResultCode resultCode, String message) {
+        return new Result<>(resultCode.getCode(), message, null);
+    }
+
+    public static <T> Result<T> error(int code, String message) {
+        return new Result<>(code, message, null);
+    }
+
+    public Integer getCode() {
+        return code;
+    }
+
+    public void setCode(Integer code) {
+        this.code = code;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    public T getData() {
+        return data;
+    }
+
+    public void setData(T data) {
+        this.data = data;
+    }
+}

+ 29 - 0
API_server/src/main/java/space/anyi/common/ResultCode.java

@@ -0,0 +1,29 @@
+package space.anyi.common;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "Unified business status code")
+public enum ResultCode {
+
+    SUCCESS(200, "OK"),
+    BAD_REQUEST(400, "Bad Request"),
+    NOT_FOUND(404, "Not Found"),
+    CONFLICT(409, "Conflict"),
+    INTERNAL_SERVER_ERROR(500, "Internal Server Error");
+
+    private final int code;
+    private final String message;
+
+    ResultCode(int code, String message) {
+        this.code = code;
+        this.message = message;
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+}

+ 26 - 0
API_server/src/main/java/space/anyi/config/OpenApiConfig.java

@@ -0,0 +1,26 @@
+package space.anyi.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Contact;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+    @Bean
+    public OpenAPI openAPI() {
+        return new OpenAPI()
+                .info(new Info()
+                        .title("User Management API")
+                        .description("CRUD API for managing users and uploading/downloading files")
+                        .version("1.0.0")
+                        .contact(new Contact()
+                                .name("Anyi")
+                                .email("contact@anyi.space"))
+                        .license(new License()
+                                .name("MIT")));
+    }
+}

+ 102 - 0
API_server/src/main/java/space/anyi/config/RealInfoConnectionProvider.java

@@ -0,0 +1,102 @@
+package space.anyi.config;
+
+import com.zaxxer.hikari.HikariDataSource;
+import org.hibernate.HibernateException;
+import org.hibernate.dialect.Dialect;
+import org.hibernate.engine.jdbc.connections.internal.ConnectionProviderInitiator;
+import org.hibernate.engine.jdbc.connections.internal.DatabaseConnectionInfoImpl;
+import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
+import org.hibernate.engine.jdbc.connections.spi.DatabaseConnectionInfo;
+import org.hibernate.service.UnknownUnwrapTypeException;
+import org.hibernate.service.spi.Configurable;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.SQLException;
+import java.util.Map;
+
+public class RealInfoConnectionProvider implements ConnectionProvider, Configurable {
+
+    private DataSource dataSource;
+
+    @Override
+    public void configure(Map<String, Object> configurationValues) {
+        Object value = configurationValues.get("hibernate.connection.datasource");
+        if (value instanceof DataSource ds) {
+            this.dataSource = ds;
+        } else {
+            throw new HibernateException(
+                    "RealInfoConnectionProvider requires 'hibernate.connection.datasource' to be a DataSource"
+                            + " (provided by Spring Boot)");
+        }
+    }
+
+    @Override
+    public Connection getConnection() throws SQLException {
+        return dataSource.getConnection();
+    }
+
+    @Override
+    public void closeConnection(Connection connection) throws SQLException {
+        connection.close();
+    }
+
+    @Override
+    public boolean supportsAggressiveRelease() {
+        return true;
+    }
+
+    @Override
+    public boolean isUnwrappableAs(Class<?> unwrapType) {
+        return ConnectionProvider.class.equals(unwrapType);
+    }
+
+    @Override
+    public <T> T unwrap(Class<T> unwrapType) {
+        if (ConnectionProvider.class.equals(unwrapType)) {
+            return unwrapType.cast(this);
+        }
+        throw new UnknownUnwrapTypeException(unwrapType);
+    }
+
+    @Override
+    public DatabaseConnectionInfo getDatabaseConnectionInfo(Dialect dialect) {
+        try (Connection connection = getConnection()) {
+            DatabaseMetaData metadata = connection.getMetaData();
+            return new DatabaseConnectionInfoImpl(
+                    metadata.getURL(),
+                    driverInfo(metadata),
+                    dialect.getVersion(),
+                    Boolean.toString(connection.getAutoCommit()),
+                    ConnectionProviderInitiator.toIsolationNiceName(connection.getTransactionIsolation()),
+                    poolMinSize(),
+                    poolMaxSize());
+        } catch (SQLException e) {
+            throw new HibernateException("Failed to read database connection info", e);
+        }
+    }
+
+    private String driverInfo(DatabaseMetaData metadata) throws SQLException {
+        String name = metadata.getDriverName();
+        String version = metadata.getDriverVersion();
+        if (name == null) {
+            return null;
+        }
+        return version == null ? name : name + " " + version;
+    }
+
+    private Integer poolMinSize() {
+        if (dataSource instanceof HikariDataSource hikari) {
+            return hikari.getMinimumIdle();
+        }
+        return null;
+    }
+
+    private Integer poolMaxSize() {
+        if (dataSource instanceof HikariDataSource hikari) {
+            return hikari.getMaximumPoolSize();
+        }
+        return null;
+    }
+}

+ 85 - 0
API_server/src/main/java/space/anyi/controller/FileController.java

@@ -0,0 +1,85 @@
+package space.anyi.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.server.ResponseStatusException;
+import space.anyi.common.Result;
+import space.anyi.service.FileService;
+import space.anyi.vo.FileVO;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+@RestController
+@RequestMapping("/api/files")
+@Tag(name = "File Management", description = "Endpoints for file operations")
+public class FileController {
+
+    private final FileService fileService;
+
+    public FileController(FileService fileService) {
+        this.fileService = fileService;
+    }
+
+    @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    @Operation(summary = "Upload a file", description = "Uploads a file and stores it on the server. Returns file metadata.")
+    @ApiResponses({
+            @ApiResponse(responseCode = "200", description = "File uploaded"),
+            @ApiResponse(responseCode = "400", description = "File is empty or exceeds the size limit")
+    })
+    public Result<FileVO> upload(
+            @RequestBody(description = "The file to upload", required = true,
+                    content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE,
+                            schema = @Schema(type = "object",
+                                    example = "{ \"file\": \"file content\" }")))
+            @RequestPart("file") MultipartFile file) {
+        return Result.success(fileService.upload(file));
+    }
+
+    @GetMapping("/download/{storedFileName}")
+    @Operation(summary = "Download a file", description = "Downloads a previously uploaded file by its stored file name. Returns the file content as a binary stream.")
+    @ApiResponses({
+            @ApiResponse(responseCode = "200", description = "File content (binary stream)",
+                    content = @Content(mediaType = "application/octet-stream")),
+            @ApiResponse(responseCode = "400", description = "Invalid file name"),
+            @ApiResponse(responseCode = "404", description = "File not found")
+    })
+    public ResponseEntity<Resource> download(
+            @Parameter(description = "Stored file name returned by the upload endpoint", example = "1f2a3c4d-8e90-4a5b-9c7d-0e1f2a3c4d5e.pdf")
+            @PathVariable("storedFileName") String storedFileName) {
+        Resource resource = fileService.downloadResource(storedFileName);
+        MediaType contentType = MediaType.parseMediaType(fileService.contentType(storedFileName));
+        String fileName = fileService.originalFileName(storedFileName);
+        ContentDisposition disposition = ContentDisposition.attachment()
+                .filename(fileName, StandardCharsets.UTF_8)
+                .build();
+        ResponseEntity.BodyBuilder builder = ResponseEntity.ok()
+                .contentType(contentType)
+                .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString());
+        try {
+            builder.contentLength(resource.contentLength());
+        } catch (IOException e) {
+            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to read the file");
+        }
+        return builder.body(resource);
+    }
+}

+ 123 - 0
API_server/src/main/java/space/anyi/controller/UserController.java

@@ -0,0 +1,123 @@
+package space.anyi.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import jakarta.validation.Valid;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import space.anyi.common.Result;
+import space.anyi.dto.UserRequest;
+import space.anyi.entity.User;
+import space.anyi.service.UserService;
+import space.anyi.vo.UserVO;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/users")
+@Tag(name = "User Management", description = "Endpoints for managing users")
+public class UserController {
+
+    private final UserService userService;
+
+    public UserController(UserService userService) {
+        this.userService = userService;
+    }
+
+    @GetMapping
+    @Operation(summary = "List all users", description = "Returns the full list of users stored in the system.")
+    @ApiResponses({
+@ApiResponse(responseCode = "200", description = "A list of users"),
+                    @ApiResponse(responseCode = "500", description = "Internal server error")
+    })
+    public Result<List<UserVO>> findAll() {
+        List<UserVO> users = userService.findAll().stream().map(UserVO::from).toList();
+        return Result.success(users);
+    }
+
+    @GetMapping("/{id}")
+    @Operation(summary = "Get a user by id", description = "Returns a single user matching the given id.")
+    @ApiResponses({
+            @ApiResponse(responseCode = "200", description = "The requested user"),
+            @ApiResponse(responseCode = "404", description = "User not found")
+    })
+    public Result<UserVO> findById(
+            @Parameter(description = "Id of the user to retrieve", example = "1")
+            @PathVariable Long id) {
+        return Result.success(UserVO.from(userService.findById(id)));
+    }
+
+    @PostMapping
+    @Operation(summary = "Create a user", description = "Creates a new user. The account must be unique.")
+    @ApiResponses({
+            @ApiResponse(responseCode = "200", description = "User created"),
+            @ApiResponse(responseCode = "400", description = "Invalid request body"),
+            @ApiResponse(responseCode = "409", description = "Account already exists")
+    })
+    public Result<UserVO> create(
+            @RequestBody(description = "User data to create", required = true,
+                    content = @Content(schema = @Schema(implementation = UserRequest.class),
+                            examples = @ExampleObject(name = "user", value = """
+                                    {
+                                      "name": "Alice",
+                                      "account": "alice01",
+                                      "sex": "female"
+                                    }
+                                    """)))
+            @org.springframework.web.bind.annotation.RequestBody @Valid UserRequest request) {
+        return Result.success(UserVO.from(userService.create(toEntity(request))));
+    }
+
+    @PutMapping("/{id}")
+    @Operation(summary = "Update a user", description = "Updates an existing user by id. The account must be unique.")
+    @ApiResponses({
+            @ApiResponse(responseCode = "200", description = "User updated"),
+            @ApiResponse(responseCode = "400", description = "Invalid request body"),
+            @ApiResponse(responseCode = "404", description = "User not found"),
+            @ApiResponse(responseCode = "409", description = "Account already exists")
+    })
+    public Result<UserVO> update(
+            @Parameter(description = "Id of the user to update", example = "1")
+            @PathVariable Long id,
+            @RequestBody(description = "Updated user data", required = true,
+                    content = @Content(schema = @Schema(implementation = UserRequest.class),
+                            examples = @ExampleObject(name = "user", value = """
+                                    {
+                                      "name": "Alice",
+                                      "account": "alice_new",
+                                      "sex": "female"
+                                    }
+                                    """)))
+            @org.springframework.web.bind.annotation.RequestBody @Valid UserRequest request) {
+        return Result.success(UserVO.from(userService.update(id, toEntity(request))));
+    }
+
+    @DeleteMapping("/{id}")
+    @Operation(summary = "Delete a user", description = "Deletes the user matching the given id.")
+    @ApiResponses({
+            @ApiResponse(responseCode = "200", description = "User deleted"),
+            @ApiResponse(responseCode = "404", description = "User not found")
+    })
+    public Result<Void> delete(
+            @Parameter(description = "Id of the user to delete", example = "1")
+            @PathVariable Long id) {
+        userService.delete(id);
+        return Result.success();
+    }
+
+    private User toEntity(UserRequest request) {
+        return new User(request.getName(), request.getAccount(), request.getSex());
+    }
+}

+ 53 - 0
API_server/src/main/java/space/anyi/dto/UserRequest.java

@@ -0,0 +1,53 @@
+package space.anyi.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "Request body for creating or updating a user")
+public class UserRequest {
+
+    @NotBlank(message = "Name must not be blank")
+    @Schema(description = "User name", example = "Alice", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String name;
+
+    @NotBlank(message = "Account must not be blank")
+    @Schema(description = "Unique account identifier", example = "alice01", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String account;
+
+    @NotBlank(message = "Sex must not be blank")
+    @Schema(description = "Gender", example = "female", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String sex;
+
+    public UserRequest() {
+    }
+
+    public UserRequest(String name, String account, String sex) {
+        this.name = name;
+        this.account = account;
+        this.sex = sex;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public String getAccount() {
+        return account;
+    }
+
+    public void setAccount(String account) {
+        this.account = account;
+    }
+
+    public String getSex() {
+        return sex;
+    }
+
+    public void setSex(String sex) {
+        this.sex = sex;
+    }
+}

+ 73 - 0
API_server/src/main/java/space/anyi/entity/User.java

@@ -0,0 +1,73 @@
+package space.anyi.entity;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+@Entity
+@Table(name = "users")
+@Schema(description = "A user account")
+public class User {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Schema(description = "Unique user id", example = "1")
+    private Long id;
+
+    @Column(nullable = false)
+    @Schema(description = "User name", example = "Alice", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String name;
+
+    @Column(nullable = false, unique = true)
+    @Schema(description = "Unique account identifier", example = "alice01", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String account;
+
+    @Column(nullable = false)
+    @Schema(description = "Gender", example = "female", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String sex;
+
+    public User() {
+    }
+
+    public User(String name, String account, String sex) {
+        this.name = name;
+        this.account = account;
+        this.sex = sex;
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public String getAccount() {
+        return account;
+    }
+
+    public void setAccount(String account) {
+        this.account = account;
+    }
+
+    public String getSex() {
+        return sex;
+    }
+
+    public void setSex(String sex) {
+        this.sex = sex;
+    }
+}

+ 11 - 0
API_server/src/main/java/space/anyi/repository/UserRepository.java

@@ -0,0 +1,11 @@
+package space.anyi.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import space.anyi.entity.User;
+
+import java.util.Optional;
+
+public interface UserRepository extends JpaRepository<User, Long> {
+
+    Optional<User> findByAccount(String account);
+}

+ 106 - 0
API_server/src/main/java/space/anyi/service/FileService.java

@@ -0,0 +1,106 @@
+package space.anyi.service;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.server.ResponseStatusException;
+import space.anyi.vo.FileVO;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.UUID;
+
+@Service
+public class FileService {
+
+    private final Path uploadDir;
+
+    public FileService(@Value("${app.upload.dir:uploads}") String uploadDir) {
+        this.uploadDir = Paths.get(uploadDir).toAbsolutePath().normalize();
+    }
+
+    public FileVO upload(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Uploaded file must not be empty");
+        }
+
+        String originalFileName = sanitizeFileName(file.getOriginalFilename());
+        String extension = extractExtension(originalFileName);
+        String storedFileName = UUID.randomUUID() + (extension.isEmpty() ? "" : "." + extension);
+        Path target = uploadDir.resolve(storedFileName).normalize();
+
+        if (!target.startsWith(uploadDir)) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid file name");
+        }
+
+        try {
+            Files.createDirectories(uploadDir);
+            file.transferTo(target);
+        } catch (IOException e) {
+            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to store the uploaded file");
+        }
+
+        return new FileVO(originalFileName, storedFileName, file.getSize(), file.getContentType(), uploadDir.relativize(target).toString());
+    }
+
+    public Resource downloadResource(String storedFileName) {
+        Path target = resolveStoredFile(storedFileName);
+        try {
+            return new FileSystemResource(target);
+        } catch (Exception e) {
+            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to read the file");
+        }
+    }
+
+    public String contentType(String storedFileName) {
+        Path target = resolveStoredFile(storedFileName);
+        try {
+            String contentType = Files.probeContentType(target);
+            return contentType != null ? contentType : "application/octet-stream";
+        } catch (IOException e) {
+            return "application/octet-stream";
+        }
+    }
+
+    public String originalFileName(String storedFileName) {
+        String sanitized = sanitizeFileName(storedFileName);
+        int dot = sanitized.lastIndexOf('.');
+        if (dot <= 0) {
+            return sanitized;
+        }
+        return sanitized.substring(0, dot);
+    }
+
+    public Path resolveStoredFile(String storedFileName) {
+        if (storedFileName == null || storedFileName.isBlank() || storedFileName.contains("/") || storedFileName.contains("\\")) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid file name");
+        }
+        Path target = uploadDir.resolve(storedFileName).normalize();
+        if (!target.startsWith(uploadDir) || !Files.isRegularFile(target)) {
+            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "File not found");
+        }
+        return target;
+    }
+
+    private String sanitizeFileName(String fileName) {
+        if (fileName == null || fileName.isBlank()) {
+            return "file";
+        }
+        String name = fileName.replace('\\', '/');
+        int slash = name.lastIndexOf('/');
+        return slash >= 0 ? name.substring(slash + 1) : name;
+    }
+
+    private String extractExtension(String fileName) {
+        int dot = fileName.lastIndexOf('.');
+        if (dot <= 0 || dot == fileName.length() - 1) {
+            return "";
+        }
+        return fileName.substring(dot + 1);
+    }
+}

+ 55 - 0
API_server/src/main/java/space/anyi/service/UserService.java

@@ -0,0 +1,55 @@
+package space.anyi.service;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.server.ResponseStatusException;
+import space.anyi.entity.User;
+import space.anyi.repository.UserRepository;
+
+import java.util.List;
+
+@Service
+public class UserService {
+
+    private final UserRepository userRepository;
+
+    public UserService(UserRepository userRepository) {
+        this.userRepository = userRepository;
+    }
+
+    public List<User> findAll() {
+        return userRepository.findAll();
+    }
+
+    public User findById(Long id) {
+        return userRepository.findById(id)
+                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + id));
+    }
+
+    public User create(User user) {
+        verifyAccountUnique(user.getAccount(), null);
+        user.setId(null);
+        return userRepository.save(user);
+    }
+
+    public User update(Long id, User user) {
+        User existing = findById(id);
+        verifyAccountUnique(user.getAccount(), id);
+        existing.setName(user.getName());
+        existing.setAccount(user.getAccount());
+        existing.setSex(user.getSex());
+        return userRepository.save(existing);
+    }
+
+    public void delete(Long id) {
+        userRepository.delete(findById(id));
+    }
+
+    private void verifyAccountUnique(String account, Long excludeId) {
+        userRepository.findByAccount(account).ifPresent(u -> {
+            if (excludeId == null || !u.getId().equals(excludeId)) {
+                throw new ResponseStatusException(HttpStatus.CONFLICT, "Account already exists: " + account);
+            }
+        });
+    }
+}

+ 73 - 0
API_server/src/main/java/space/anyi/vo/FileVO.java

@@ -0,0 +1,73 @@
+package space.anyi.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "File upload response payload")
+public class FileVO {
+
+    @Schema(description = "Original file name", example = "report.pdf")
+    private String originalFileName;
+
+    @Schema(description = "Stored file name on disk", example = "1f2a3c4d-8e90-4a5b-9c7d-0e1f2a3c4d5e.pdf")
+    private String storedFileName;
+
+    @Schema(description = "File size in bytes", example = "2048")
+    private Long size;
+
+    @Schema(description = "MIME content type", example = "application/pdf")
+    private String contentType;
+
+    @Schema(description = "Relative storage path", example = "uploads/1f2a3c4d-8e90-4a5b-9c7d-0e1f2a3c4d5e.pdf")
+    private String storagePath;
+
+    public FileVO() {
+    }
+
+    public FileVO(String originalFileName, String storedFileName, Long size, String contentType, String storagePath) {
+        this.originalFileName = originalFileName;
+        this.storedFileName = storedFileName;
+        this.size = size;
+        this.contentType = contentType;
+        this.storagePath = storagePath;
+    }
+
+    public String getOriginalFileName() {
+        return originalFileName;
+    }
+
+    public void setOriginalFileName(String originalFileName) {
+        this.originalFileName = originalFileName;
+    }
+
+    public String getStoredFileName() {
+        return storedFileName;
+    }
+
+    public void setStoredFileName(String storedFileName) {
+        this.storedFileName = storedFileName;
+    }
+
+    public Long getSize() {
+        return size;
+    }
+
+    public void setSize(Long size) {
+        this.size = size;
+    }
+
+    public String getContentType() {
+        return contentType;
+    }
+
+    public void setContentType(String contentType) {
+        this.contentType = contentType;
+    }
+
+    public String getStoragePath() {
+        return storagePath;
+    }
+
+    public void setStoragePath(String storagePath) {
+        this.storagePath = storagePath;
+    }
+}

+ 66 - 0
API_server/src/main/java/space/anyi/vo/UserVO.java

@@ -0,0 +1,66 @@
+package space.anyi.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import space.anyi.entity.User;
+
+@Schema(description = "User response payload")
+public class UserVO {
+
+    @Schema(description = "Unique user id", example = "1")
+    private Long id;
+
+    @Schema(description = "User name", example = "Alice")
+    private String name;
+
+    @Schema(description = "Unique account identifier", example = "alice01")
+    private String account;
+
+    @Schema(description = "Gender", example = "female")
+    private String sex;
+
+    public UserVO() {
+    }
+
+    public UserVO(Long id, String name, String account, String sex) {
+        this.id = id;
+        this.name = name;
+        this.account = account;
+        this.sex = sex;
+    }
+
+    public static UserVO from(User user) {
+        return new UserVO(user.getId(), user.getName(), user.getAccount(), user.getSex());
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public String getAccount() {
+        return account;
+    }
+
+    public void setAccount(String account) {
+        this.account = account;
+    }
+
+    public String getSex() {
+        return sex;
+    }
+
+    public void setSex(String sex) {
+        this.sex = sex;
+    }
+}

+ 42 - 0
API_server/src/main/resources/application.properties

@@ -0,0 +1,42 @@
+spring.application.name=server
+server.port=8080
+
+# ---------- Datasource (H2 in-memory) ----------
+spring.datasource.url=jdbc:h2:mem:userdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
+spring.datasource.driver-class-name=org.h2.Driver
+spring.datasource.username=sa
+spring.datasource.password=
+spring.datasource.hikari.pool-name=HikariPool-1
+spring.datasource.hikari.minimum-idle=1
+spring.datasource.hikari.maximum-pool-size=10
+spring.datasource.hikari.connection-timeout=30000
+spring.datasource.hikari.idle-timeout=600000
+spring.datasource.hikari.max-lifetime=1800000
+
+# ---------- JPA / Hibernate ----------
+spring.jpa.hibernate.ddl-auto=create-drop
+spring.jpa.properties.hibernate.connection.provider_class=space.anyi.config.RealInfoConnectionProvider
+spring.jpa.show-sql=true
+spring.jpa.open-in-view=false
+spring.jpa.properties.hibernate.format_sql=true
+
+# ---------- H2 Console ----------
+spring.h2.console.enabled=true
+spring.h2.console.path=/h2-console
+spring.h2.console.settings.web-allow-others=false
+
+# ---------- File Upload ----------
+spring.servlet.multipart.enabled=true
+spring.servlet.multipart.max-file-size=10MB
+spring.servlet.multipart.max-request-size=10MB
+
+# ---------- App ----------
+app.upload.dir=uploads
+
+# ---------- OpenAPI / Swagger ----------
+springdoc.swagger-ui.path=/swagger-ui.html
+
+# ---------- Logging ----------
+logging.level.root=INFO
+logging.level.space.anyi=DEBUG
+logging.level.org.hibernate.SQL=DEBUG