feat: add department creation endpoint and request validation

This commit is contained in:
siujamo
2026-03-23 15:13:23 +08:00
parent 7b9849c311
commit aebb693ee7
9 changed files with 146 additions and 41 deletions
@@ -2,11 +2,11 @@ package com.onixbyte.helix.controller;
import com.onixbyte.helix.domain.entity.Department; import com.onixbyte.helix.domain.entity.Department;
import com.onixbyte.helix.domain.common.TreeNode; import com.onixbyte.helix.domain.common.TreeNode;
import com.onixbyte.helix.domain.web.request.AddDepartmentRequest;
import com.onixbyte.helix.service.DepartmentService; import com.onixbyte.helix.service.DepartmentService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@@ -36,4 +36,9 @@ public class DepartmentController {
public List<Department> getDepartments() { public List<Department> getDepartments() {
return departmentService.getDepartments(); return departmentService.getDepartments();
} }
@PostMapping
public Department addDepartment(@Validated @RequestBody AddDepartmentRequest request) {
return departmentService.addDepartment(request);
}
} }
@@ -3,8 +3,10 @@ package com.onixbyte.helix.controller;
import com.onixbyte.helix.domain.web.response.BizExceptionResponse; import com.onixbyte.helix.domain.web.response.BizExceptionResponse;
import com.onixbyte.helix.exception.BizException; import com.onixbyte.helix.exception.BizException;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -25,22 +27,6 @@ import java.util.stream.Collectors;
@RestControllerAdvice @RestControllerAdvice
public class ExceptionController { public class ExceptionController {
/**
* Handles business logic exceptions thrown throughout the application.
* <p>
* This method intercepts {@link BizException} instances and converts them into appropriate HTTP
* responses. The HTTP status code is determined by the exception's status property, whilst the
* error message is extracted from the exception and included in the response body.
* <p>
* The response includes a timestamp indicating when the error occurred and the specific error
* message describing the business logic violation.
*
* @param ex the business exception that was thrown
* @return a {@link ResponseEntity} containing the error response with appropriate HTTP status
* and {@link BizExceptionResponse} body
* @see BizException
* @see BizExceptionResponse
*/
@ExceptionHandler(BizException.class) @ExceptionHandler(BizException.class)
public ResponseEntity<BizExceptionResponse> handleBizException(BizException ex) { public ResponseEntity<BizExceptionResponse> handleBizException(BizException ex) {
return ResponseEntity.status(ex.getStatus()) return ResponseEntity.status(ex.getStatus())
@@ -50,25 +36,6 @@ public class ExceptionController {
); );
} }
/**
* Handles bean validation constraint violation exceptions.
* <p>
* This method processes {@link ConstraintViolationException} instances that occur when bean
* validation constraints are violated during request processing. It extracts all constraint
* violations, formats them into a readable error message, and returns a standardised
* error response.
* <p>
* The error message includes the property path and violation message for each constraint that
* was violated, separated by commas for multiple violations. The response is automatically
* assigned a {@code 400 Bad Request} status.
*
* @param ex the constraint violation exception containing validation errors
* @return a {@link BizExceptionResponse} containing the formatted validation
* error messages and timestamp
* @see ConstraintViolationException
* @see BizExceptionResponse
* @see jakarta.validation.constraints
*/
@ExceptionHandler(ConstraintViolationException.class) @ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
public BizExceptionResponse handleConstraintViolation(ConstraintViolationException ex) { public BizExceptionResponse handleConstraintViolation(ConstraintViolationException ex) {
@@ -81,4 +48,19 @@ public class ExceptionController {
errorMessage errorMessage
); );
} }
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public BizExceptionResponse handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
var errorMessage = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining("; "));
return new BizExceptionResponse(
LocalDateTime.now(),
errorMessage
);
}
} }
@@ -22,7 +22,7 @@ import java.util.Objects;
*/ */
@Entity @Entity
@Table( @Table(
name = "user", name = "\"user\"",
uniqueConstraints = { uniqueConstraints = {
@UniqueConstraint(name = "uidx_users_username", columnNames = {"username"}), @UniqueConstraint(name = "uidx_users_username", columnNames = {"username"}),
@UniqueConstraint(name = "uidx_users_email", columnNames = {"email"}), @UniqueConstraint(name = "uidx_users_email", columnNames = {"email"}),
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2024-2026 OnixByte
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.onixbyte.helix.domain.web.request;
import com.onixbyte.helix.enumeration.Status;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record AddDepartmentRequest(
@NotNull(message = "Name of the department should not be null")
@NotBlank(message = "Name of the department should not be null")
String name,
Long parentId,
Integer sort,
Status status
) {
}
@@ -8,6 +8,8 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Optional;
@Component @Component
public class DepartmentManager { public class DepartmentManager {
@@ -27,4 +29,12 @@ public class DepartmentManager {
public Department selectById(Long id) { public Department selectById(Long id) {
return departmentRepository.findById(id).orElse(null); return departmentRepository.findById(id).orElse(null);
} }
public Integer getNextSort(Long parentId) {
return Optional.ofNullable(departmentRepository.findMaxSort(parentId)).orElse(0) + 1;
}
public Department save(Department department) {
return departmentRepository.save(department);
}
} }
@@ -1,3 +1,25 @@
/*
* Copyright (c) 2024-2026 OnixByte
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.onixbyte.helix.manager; package com.onixbyte.helix.manager;
import com.onixbyte.helix.common.regex.Patterns; import com.onixbyte.helix.common.regex.Patterns;
@@ -2,8 +2,17 @@ package com.onixbyte.helix.repository;
import com.onixbyte.helix.domain.entity.Department; import com.onixbyte.helix.domain.entity.Department;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@Repository @Repository
public interface DepartmentRepository extends JpaRepository<Department, Long> { public interface DepartmentRepository extends JpaRepository<Department, Long> {
@Query("""
select max(d.sort)
from Department d
where (:parentId is null and d.parentId is null)
or d.parentId = :parentId
""")
Integer findMaxSort(Long parentId);
} }
@@ -1,14 +1,19 @@
package com.onixbyte.helix.service; package com.onixbyte.helix.service;
import com.onixbyte.helix.domain.entity.Department;
import com.onixbyte.helix.domain.common.TreeNode; import com.onixbyte.helix.domain.common.TreeNode;
import com.onixbyte.helix.domain.entity.Department;
import com.onixbyte.helix.domain.web.request.AddDepartmentRequest;
import com.onixbyte.helix.enumeration.Status;
import com.onixbyte.helix.manager.DepartmentManager; import com.onixbyte.helix.manager.DepartmentManager;
import com.onixbyte.helix.utils.TreeUtil; import com.onixbyte.helix.utils.TreeUtil;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional;
@Service @Service
public class DepartmentService { public class DepartmentService {
@@ -28,4 +33,22 @@ public class DepartmentService {
public List<Department> getDepartments() { public List<Department> getDepartments() {
return departmentManager.selectAll(Pageable.unpaged()).getContent(); return departmentManager.selectAll(Pageable.unpaged()).getContent();
} }
@Transactional(rollbackFor = Throwable.class)
public Department addDepartment(AddDepartmentRequest request) {
var createdAt = LocalDateTime.now();
var parentId = request.parentId();
var sort = Optional.ofNullable(request.sort())
.orElseGet(() -> departmentManager.getNextSort(parentId));
return departmentManager.save(Department.builder()
.name(request.name())
.parentId(parentId)
.sort(sort)
.status(Optional.ofNullable(request.status()).orElse(Status.ACTIVE))
.createdAt(createdAt)
.updatedAt(createdAt)
.build());
}
} }
@@ -410,3 +410,20 @@ INSERT INTO menu (name, parent_id, code, sort, path, is_external_link, is_visibl
authority_code, icon, created_at, updated_at) authority_code, icon, created_at, updated_at)
VALUES ('Helix 官网', null, 'helix-official-site', 100, 'https://helix.onixbyte.com', true, true, VALUES ('Helix 官网', null, 'helix-official-site', 100, 'https://helix.onixbyte.com', true, true,
'ACTIVE'::STATUS, null, null, NOW(), NOW()); 'ACTIVE'::STATUS, null, null, NOW(), NOW());
DO
$$
DECLARE
v_user_id BIGINT;
v_now TIMESTAMP;
BEGIN
SELECT id INTO v_user_id FROM "user" WHERE username = 'helix';
v_now := CURRENT_TIMESTAMP;
RAISE NOTICE 'User ID: %, Timestamp: %', v_user_id, v_now;
-- Default password is '123456'
INSERT INTO "user_credential"
VALUES (v_user_id, 'LOCAL'::credential_provider, '$2a$10$1LoatLVvHL3LFK0pnNXcM.eiPK6.UdA9cl9IDwanWHBAAILn1xe0K', v_now, v_now);
END;
$$;