commit cbef58aee54f590d8871a4c0838e51a7f73e80d1 Author: wh Date: Thu Apr 23 11:59:31 2026 +0800 main主干首次提交,包含用户认证模块 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d40740 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# ========================================== +# 1. Maven/Java 构建产物 (一键忽略整个目录) +# ========================================== +target/ +*.class +*.jar +*.war +*.ear +docs/ +specs/ +src/test/ +CLAUDE.md +# ========================================== +# 2. IDE 配置文件 +# ========================================== +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +.agents/ +.history/ +logs/ + +# ========================================== +# 3. 项目特定工具目录 (根据你的文件列表) +# ========================================== +# 忽略 Specifiy 工具生成的所有配置和脚本 +.specify/ + +# 忽略 Claude 本地设置和技能文件 +.claude/ + +# ========================================== +# 4. 操作系统文件 +# ========================================== +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3cf8ba9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,129 @@ + + + 4.0.0 + com.labelsys + label-backend + 1.0.0-SNAPSHOT + jar + LabelSys backend + + + 21 + 3.1.5 + 3.5.3.1 + 2.3.0 + UTF-8 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + software.amazon.awssdk + bom + 2.26.31 + pom + import + + + + org.testcontainers + testcontainers-bom + 1.20.1 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version} + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.security + spring-security-crypto + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi.version} + + + org.postgresql + postgresql + 42.2.24 + + + org.projectlombok + lombok + true + + + com.h2database + h2 + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + 1.18.30 + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/LabelsysBackendApplication.java b/src/main/java/com/labelsys/backend/LabelsysBackendApplication.java new file mode 100644 index 0000000..bbf8c6c --- /dev/null +++ b/src/main/java/com/labelsys/backend/LabelsysBackendApplication.java @@ -0,0 +1,14 @@ +package com.labelsys.backend; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan("com.labelsys.backend.mapper") +public class LabelsysBackendApplication { + + public static void main(String[] args) { + SpringApplication.run(LabelsysBackendApplication.class, args); + } +} diff --git a/src/main/java/com/labelsys/backend/annotation/RequirePosition.java b/src/main/java/com/labelsys/backend/annotation/RequirePosition.java new file mode 100644 index 0000000..142a602 --- /dev/null +++ b/src/main/java/com/labelsys/backend/annotation/RequirePosition.java @@ -0,0 +1,14 @@ +package com.labelsys.backend.annotation; + +import com.labelsys.backend.enums.UserPosition; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequirePosition { + + UserPosition value(); +} diff --git a/src/main/java/com/labelsys/backend/common/Result.java b/src/main/java/com/labelsys/backend/common/Result.java new file mode 100644 index 0000000..b54991f --- /dev/null +++ b/src/main/java/com/labelsys/backend/common/Result.java @@ -0,0 +1,30 @@ +package com.labelsys.backend.common; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "统一返回结果") +public class Result { + + @Schema(description = "业务状态码") + private Integer code; + + @Schema(description = "返回消息") + private String message; + + @Schema(description = "返回数据") + private T data; + + public static Result success() { + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); + } + + public static Result success(T data) { + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); + } +} diff --git a/src/main/java/com/labelsys/backend/common/ResultCode.java b/src/main/java/com/labelsys/backend/common/ResultCode.java new file mode 100644 index 0000000..978cfc7 --- /dev/null +++ b/src/main/java/com/labelsys/backend/common/ResultCode.java @@ -0,0 +1,19 @@ +package com.labelsys.backend.common; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ResultCode { + SUCCESS(200, "操作成功"), + BAD_REQUEST(400, "请求参数错误"), + UNAUTHORIZED(401, "未登录或登录已过期"), + FORBIDDEN(403, "无权限访问"), + NOT_FOUND(404, "资源不存在"), + CONFLICT(409, "资源冲突"), + ERROR(500, "系统异常"); + + private final Integer code; + private final String message; +} diff --git a/src/main/java/com/labelsys/backend/common/exception/BusinessException.java b/src/main/java/com/labelsys/backend/common/exception/BusinessException.java new file mode 100644 index 0000000..a846315 --- /dev/null +++ b/src/main/java/com/labelsys/backend/common/exception/BusinessException.java @@ -0,0 +1,15 @@ +package com.labelsys.backend.common.exception; + +import com.labelsys.backend.common.ResultCode; +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + + private final ResultCode resultCode; + + public BusinessException(ResultCode resultCode, String message) { + super(message); + this.resultCode = resultCode; + } +} diff --git a/src/main/java/com/labelsys/backend/common/exception/ForbiddenException.java b/src/main/java/com/labelsys/backend/common/exception/ForbiddenException.java new file mode 100644 index 0000000..f6e8efc --- /dev/null +++ b/src/main/java/com/labelsys/backend/common/exception/ForbiddenException.java @@ -0,0 +1,10 @@ +package com.labelsys.backend.common.exception; + +import com.labelsys.backend.common.ResultCode; + +public class ForbiddenException extends BusinessException { + + public ForbiddenException(String message) { + super(ResultCode.FORBIDDEN, message); + } +} diff --git a/src/main/java/com/labelsys/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/labelsys/backend/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..7300d91 --- /dev/null +++ b/src/main/java/com/labelsys/backend/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,56 @@ +package com.labelsys.backend.common.exception; + +import com.labelsys.backend.common.Result; +import com.labelsys.backend.common.ResultCode; +import jakarta.servlet.http.HttpServletResponse; +import java.util.stream.Collectors; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public Result handleBusiness(BusinessException exception, HttpServletResponse response) { + response.setStatus(toHttpStatus(exception.getResultCode()).value()); + return new Result<>(exception.getResultCode().getCode(), exception.getMessage(), null); + } + + @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) + public Result handleValidation(Exception exception, HttpServletResponse response) { + response.setStatus(HttpStatus.BAD_REQUEST.value()); + String message; + if (exception instanceof MethodArgumentNotValidException methodArgumentNotValidException) { + message = methodArgumentNotValidException.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + error.getDefaultMessage()) + .collect(Collectors.joining(";")); + } else if (exception instanceof BindException bindException) { + message = bindException.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + error.getDefaultMessage()) + .collect(Collectors.joining(";")); + } else { + message = ResultCode.BAD_REQUEST.getMessage(); + } + return new Result<>(ResultCode.BAD_REQUEST.getCode(), message, null); + } + + @ExceptionHandler(Exception.class) + public Result handleUnexpected(Exception exception, HttpServletResponse response) { + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + return new Result<>(ResultCode.ERROR.getCode(), exception.getMessage(), null); + } + + private HttpStatus toHttpStatus(ResultCode resultCode) { + return switch (resultCode) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; + case FORBIDDEN -> HttpStatus.FORBIDDEN; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case CONFLICT -> HttpStatus.CONFLICT; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/src/main/java/com/labelsys/backend/common/exception/UnauthorizedException.java b/src/main/java/com/labelsys/backend/common/exception/UnauthorizedException.java new file mode 100644 index 0000000..3872962 --- /dev/null +++ b/src/main/java/com/labelsys/backend/common/exception/UnauthorizedException.java @@ -0,0 +1,10 @@ +package com.labelsys.backend.common.exception; + +import com.labelsys.backend.common.ResultCode; + +public class UnauthorizedException extends BusinessException { + + public UnauthorizedException(String message) { + super(ResultCode.UNAUTHORIZED, message); + } +} diff --git a/src/main/java/com/labelsys/backend/config/OpenApiConfig.java b/src/main/java/com/labelsys/backend/config/OpenApiConfig.java new file mode 100644 index 0000000..783157d --- /dev/null +++ b/src/main/java/com/labelsys/backend/config/OpenApiConfig.java @@ -0,0 +1,18 @@ +package com.labelsys.backend.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI labelsysOpenApi() { + return new OpenAPI().info(new Info() + .title("LabelSys 后台认证鉴权接口") + .version("0.0.1") + .description("公司、员工、认证、岗位权限和数据权限接口")); + } +} diff --git a/src/main/java/com/labelsys/backend/config/SecurityBeanConfig.java b/src/main/java/com/labelsys/backend/config/SecurityBeanConfig.java new file mode 100644 index 0000000..a3ce514 --- /dev/null +++ b/src/main/java/com/labelsys/backend/config/SecurityBeanConfig.java @@ -0,0 +1,15 @@ +package com.labelsys.backend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityBeanConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/labelsys/backend/config/WebMvcConfig.java b/src/main/java/com/labelsys/backend/config/WebMvcConfig.java new file mode 100644 index 0000000..d973380 --- /dev/null +++ b/src/main/java/com/labelsys/backend/config/WebMvcConfig.java @@ -0,0 +1,19 @@ +package com.labelsys.backend.config; + +import com.labelsys.backend.interceptor.AuthInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthInterceptor authInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor).addPathPatterns("/api/**"); + } +} diff --git a/src/main/java/com/labelsys/backend/context/LoginUser.java b/src/main/java/com/labelsys/backend/context/LoginUser.java new file mode 100644 index 0000000..92f883e --- /dev/null +++ b/src/main/java/com/labelsys/backend/context/LoginUser.java @@ -0,0 +1,43 @@ +package com.labelsys.backend.context; + +import com.labelsys.backend.entity.SysCompany; +import com.labelsys.backend.entity.SysUser; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "当前登录用户上下文") +public record LoginUser( + @Schema(description = "用户ID") Long userId, + @Schema(description = "公司ID") Long companyId, + @Schema(description = "公司编码") String companyCode, + @Schema(description = "公司名称") String companyName, + @Schema(description = "手机号") String phone, + @Schema(description = "用户名,可为空", example = "alpha-reviewer") String username, + @Schema(description = "真实姓名") String realName, + @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole role, + @Schema(description = "岗位,枚举值:UPLOADER上传者、ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") UserPosition position, + @Schema(description = "是否必须修改密码") boolean mustChangePassword, + @Schema(description = "会话版本") Integer sessionVersion +) { + + public static LoginUser from(SysUser user, SysCompany company) { + return new LoginUser( + user.getId(), + company.getId(), + company.getCompanyCode(), + company.getCompanyName(), + user.getPhone(), + user.getUsername(), + user.getRealName(), + user.getRole(), + user.getPosition(), + Boolean.TRUE.equals(user.getMustChangePassword()), + user.getSessionVersion() + ); + } + + public boolean isPlatformAdmin() { + return "PLATFORM".equals(companyCode) && position == UserPosition.ADMIN; + } +} diff --git a/src/main/java/com/labelsys/backend/context/UserContext.java b/src/main/java/com/labelsys/backend/context/UserContext.java new file mode 100644 index 0000000..201cf25 --- /dev/null +++ b/src/main/java/com/labelsys/backend/context/UserContext.java @@ -0,0 +1,28 @@ +package com.labelsys.backend.context; + +import com.labelsys.backend.common.exception.UnauthorizedException; +import java.util.Optional; + +public final class UserContext { + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + private UserContext() { + } + + public static void set(LoginUser loginUser) { + HOLDER.set(loginUser); + } + + public static Optional get() { + return Optional.ofNullable(HOLDER.get()); + } + + public static LoginUser requireUser() { + return get().orElseThrow(() -> new UnauthorizedException("未登录或登录已过期")); + } + + public static void clear() { + HOLDER.remove(); + } +} diff --git a/src/main/java/com/labelsys/backend/controller/AuthController.java b/src/main/java/com/labelsys/backend/controller/AuthController.java new file mode 100644 index 0000000..34bcc6e --- /dev/null +++ b/src/main/java/com/labelsys/backend/controller/AuthController.java @@ -0,0 +1,70 @@ +package com.labelsys.backend.controller; + +import com.labelsys.backend.common.Result; +import com.labelsys.backend.context.LoginUser; +import com.labelsys.backend.context.UserContext; +import com.labelsys.backend.dto.request.ChangePasswordRequest; +import com.labelsys.backend.dto.request.LoginRequest; +import com.labelsys.backend.dto.response.CompanyOptionResponse; +import com.labelsys.backend.dto.response.CurrentUserResponse; +import com.labelsys.backend.dto.response.LoginResponse; +import com.labelsys.backend.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "认证管理") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "根据手机号查询可登录公司") + @GetMapping("/companies") + public Result> listCompanies(@RequestParam String phone) { + return Result.success(authService.listAvailableCompanies(phone)); + } + + @Operation(summary = "登录") + @PostMapping("/login") + public Result login(@Valid @RequestBody LoginRequest request) { + return Result.success(authService.login(request)); + } + + @Operation(summary = "修改密码") + @PostMapping("/change-password") + public Result changePassword(@Valid @RequestBody ChangePasswordRequest request) { + authService.changePassword(UserContext.requireUser(), request); + return Result.success(); + } + + @Operation(summary = "退出登录") + @PostMapping("/logout") + public Result logout(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) { + authService.logout(extractToken(authorization)); + return Result.success(); + } + + @Operation(summary = "获取当前登录态") + @GetMapping("/me") + public Result currentUser() { + LoginUser loginUser = UserContext.requireUser(); + return Result.success(CurrentUserResponse.from(loginUser)); + } + + private String extractToken(String authorization) { + return authorization.substring(7).trim(); + } +} diff --git a/src/main/java/com/labelsys/backend/controller/CompanyUserController.java b/src/main/java/com/labelsys/backend/controller/CompanyUserController.java new file mode 100644 index 0000000..078c727 --- /dev/null +++ b/src/main/java/com/labelsys/backend/controller/CompanyUserController.java @@ -0,0 +1,59 @@ +package com.labelsys.backend.controller; + +import com.labelsys.backend.annotation.RequirePosition; +import com.labelsys.backend.common.Result; +import com.labelsys.backend.context.UserContext; +import com.labelsys.backend.dto.request.CreateUserRequest; +import com.labelsys.backend.dto.request.UpdateUserAssignmentRequest; +import com.labelsys.backend.dto.request.UpdateUserStatusRequest; +import com.labelsys.backend.dto.response.UserResponse; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.RestController; + +@Tag(name = "公司员工管理") +@RestController +@RequestMapping("/api/company/users") +@RequirePosition(UserPosition.ADMIN) +@RequiredArgsConstructor +public class CompanyUserController { + + private final UserService userService; + + @Operation(summary = "获取当前公司员工列表") + @GetMapping + public Result> listUsers() { + return Result.success(userService.listCompanyUsers(UserContext.requireUser()).stream().map(UserResponse::from).toList()); + } + + @Operation(summary = "创建当前公司员工") + @PostMapping + public Result createUser(@Valid @RequestBody CreateUserRequest request) { + return Result.success(UserResponse.from(userService.createCompanyUser(UserContext.requireUser(), request))); + } + + @Operation(summary = "修改员工角色和岗位") + @PutMapping("/{userId}/assignment") + public Result updateAssignment(@PathVariable Long userId, @Valid @RequestBody UpdateUserAssignmentRequest request) { + userService.updateAssignment(UserContext.requireUser(), userId, request); + return Result.success(); + } + + @Operation(summary = "修改员工状态") + @PutMapping("/{userId}/status") + public Result updateStatus(@PathVariable Long userId, @Valid @RequestBody UpdateUserStatusRequest request) { + userService.updateStatus(UserContext.requireUser(), userId, request); + return Result.success(); + } +} diff --git a/src/main/java/com/labelsys/backend/controller/MenuController.java b/src/main/java/com/labelsys/backend/controller/MenuController.java new file mode 100644 index 0000000..493a19e --- /dev/null +++ b/src/main/java/com/labelsys/backend/controller/MenuController.java @@ -0,0 +1,28 @@ +// package com.labelsys.backend.controller; + +// import com.labelsys.backend.common.Result; +// import com.labelsys.backend.context.UserContext; +// import com.labelsys.backend.dto.response.MenuResponse; +// import com.labelsys.backend.service.MenuService; +// import io.swagger.v3.oas.annotations.Operation; +// import io.swagger.v3.oas.annotations.tags.Tag; +// import java.util.List; +// import lombok.RequiredArgsConstructor; +// import org.springframework.web.bind.annotation.GetMapping; +// import org.springframework.web.bind.annotation.RequestMapping; +// import org.springframework.web.bind.annotation.RestController; + +// @Tag(name = "菜单管理") +// @RestController +// @RequestMapping("/api/menus") +// @RequiredArgsConstructor +// public class MenuController { + +// private final MenuService menuService; + +// @Operation(summary = "获取当前用户菜单") +// @GetMapping("/current") +// public Result> currentMenus() { +// return Result.success(menuService.listCurrentMenus(UserContext.requireUser())); +// } +// } diff --git a/src/main/java/com/labelsys/backend/controller/PlatformCompanyAdminController.java b/src/main/java/com/labelsys/backend/controller/PlatformCompanyAdminController.java new file mode 100644 index 0000000..51c98d8 --- /dev/null +++ b/src/main/java/com/labelsys/backend/controller/PlatformCompanyAdminController.java @@ -0,0 +1,56 @@ +package com.labelsys.backend.controller; + +import com.labelsys.backend.annotation.RequirePosition; +import com.labelsys.backend.common.Result; +import com.labelsys.backend.context.UserContext; +import com.labelsys.backend.dto.request.CreateCompanyAdminRequest; +import com.labelsys.backend.dto.request.UpdateUserStatusRequest; +import com.labelsys.backend.dto.response.UserResponse; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +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; + +@Tag(name = "平台公司管理员管理") +@RestController +@RequestMapping("/api/platform/company-admins") +@RequirePosition(UserPosition.ADMIN) +@RequiredArgsConstructor +public class PlatformCompanyAdminController { + + private final UserService userService; + + @Operation(summary = "查询指定公司管理员列表") + @GetMapping + public Result> listCompanyAdmins(@RequestParam Long companyId) { + return Result.success(userService.listCompanyAdmins(UserContext.requireUser(), companyId).stream().map(UserResponse::from).toList()); + } + + @Operation(summary = "创建公司管理员") + @PostMapping + public Result createCompanyAdmin(@Valid @RequestBody CreateCompanyAdminRequest request) { + return Result.success(UserResponse.from(userService.createCompanyAdmin(UserContext.requireUser(), request))); + } + + @Operation(summary = "修改公司管理员状态") + @PutMapping("/{companyId}/{userId}/status") + public Result updateCompanyAdminStatus( + @PathVariable Long companyId, + @PathVariable Long userId, + @Valid @RequestBody UpdateUserStatusRequest request + ) { + userService.updateCompanyAdminStatus(UserContext.requireUser(), companyId, userId, request); + return Result.success(); + } +} diff --git a/src/main/java/com/labelsys/backend/controller/PlatformCompanyController.java b/src/main/java/com/labelsys/backend/controller/PlatformCompanyController.java new file mode 100644 index 0000000..2a6e7ad --- /dev/null +++ b/src/main/java/com/labelsys/backend/controller/PlatformCompanyController.java @@ -0,0 +1,51 @@ +package com.labelsys.backend.controller; + +import com.labelsys.backend.annotation.RequirePosition; +import com.labelsys.backend.common.Result; +import com.labelsys.backend.context.UserContext; +import com.labelsys.backend.dto.request.CreateCompanyRequest; +import com.labelsys.backend.dto.request.UpdateCompanyStatusRequest; +import com.labelsys.backend.dto.response.CompanyResponse; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.service.CompanyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.RestController; + +@Tag(name = "平台公司管理") +@RestController +@RequestMapping("/api/platform/companies") +@RequirePosition(UserPosition.ADMIN) +@RequiredArgsConstructor +public class PlatformCompanyController { + + private final CompanyService companyService; + + @Operation(summary = "获取公司列表") + @GetMapping + public Result> listCompanies() { + return Result.success(companyService.listCompanies(UserContext.requireUser()).stream().map(CompanyResponse::from).toList()); + } + + @Operation(summary = "创建公司") + @PostMapping + public Result createCompany(@Valid @RequestBody CreateCompanyRequest request) { + return Result.success(CompanyResponse.from(companyService.createCompany(UserContext.requireUser(), request))); + } + + @Operation(summary = "修改公司状态") + @PutMapping("/{companyId}/status") + public Result updateCompanyStatus(@PathVariable Long companyId, @Valid @RequestBody UpdateCompanyStatusRequest request) { + companyService.updateStatus(UserContext.requireUser(), companyId, request); + return Result.success(); + } +} diff --git a/src/main/java/com/labelsys/backend/dto/request/ChangePasswordRequest.java b/src/main/java/com/labelsys/backend/dto/request/ChangePasswordRequest.java new file mode 100644 index 0000000..14e9f53 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/request/ChangePasswordRequest.java @@ -0,0 +1,12 @@ +package com.labelsys.backend.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "修改密码请求") +public record ChangePasswordRequest( + @Schema(description = "旧密码") @NotBlank(message = "不能为空") String oldPassword, + @Schema(description = "新密码") @NotBlank(message = "不能为空") String newPassword, + @Schema(description = "确认新密码") @NotBlank(message = "不能为空") String confirmPassword +) { +} diff --git a/src/main/java/com/labelsys/backend/dto/request/CreateCompanyAdminRequest.java b/src/main/java/com/labelsys/backend/dto/request/CreateCompanyAdminRequest.java new file mode 100644 index 0000000..cc6f906 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/request/CreateCompanyAdminRequest.java @@ -0,0 +1,14 @@ +package com.labelsys.backend.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "创建公司管理员请求") +public record CreateCompanyAdminRequest( + @Schema(description = "公司ID") @NotNull(message = "不能为空") Long companyId, + @Schema(description = "手机号") @NotBlank(message = "不能为空") String phone, + @Schema(description = "用户名,前端展示用,可为空", example = "platform-ops") String username, + @Schema(description = "真实姓名") @NotBlank(message = "不能为空") String realName +) { +} diff --git a/src/main/java/com/labelsys/backend/dto/request/CreateCompanyRequest.java b/src/main/java/com/labelsys/backend/dto/request/CreateCompanyRequest.java new file mode 100644 index 0000000..5dbd65f --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/request/CreateCompanyRequest.java @@ -0,0 +1,11 @@ +package com.labelsys.backend.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "创建公司请求") +public record CreateCompanyRequest( + @Schema(description = "公司编码") @NotBlank(message = "不能为空") String companyCode, + @Schema(description = "公司名称") @NotBlank(message = "不能为空") String companyName +) { +} diff --git a/src/main/java/com/labelsys/backend/dto/request/CreateUserRequest.java b/src/main/java/com/labelsys/backend/dto/request/CreateUserRequest.java new file mode 100644 index 0000000..aaca155 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/request/CreateUserRequest.java @@ -0,0 +1,17 @@ +package com.labelsys.backend.dto.request; + +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "创建员工请求") +public record CreateUserRequest( + @Schema(description = "手机号") @NotBlank(message = "不能为空") String phone, + @Schema(description = "用户名,前端展示用,可为空", example = "alpha-admin") String username, + @Schema(description = "真实姓名") @NotBlank(message = "不能为空") String realName, + @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") @NotNull(message = "不能为空") UserRole role, + @Schema(description = "岗位,枚举值:UPLOADER上传者、ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") @NotNull(message = "不能为空") UserPosition position +) { +} diff --git a/src/main/java/com/labelsys/backend/dto/request/LoginRequest.java b/src/main/java/com/labelsys/backend/dto/request/LoginRequest.java new file mode 100644 index 0000000..a47f8f9 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/request/LoginRequest.java @@ -0,0 +1,12 @@ +package com.labelsys.backend.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "登录请求") +public record LoginRequest( + @Schema(description = "手机号") @NotBlank(message = "不能为空") String phone, + @Schema(description = "公司编码") @NotBlank(message = "不能为空") String companyCode, + @Schema(description = "登录密码") @NotBlank(message = "不能为空") String password +) { +} diff --git a/src/main/java/com/labelsys/backend/dto/request/UpdateCompanyStatusRequest.java b/src/main/java/com/labelsys/backend/dto/request/UpdateCompanyStatusRequest.java new file mode 100644 index 0000000..6b42560 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/request/UpdateCompanyStatusRequest.java @@ -0,0 +1,11 @@ +package com.labelsys.backend.dto.request; + +import com.labelsys.backend.enums.CompanyStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "修改公司状态请求") +public record UpdateCompanyStatusRequest( + @Schema(description = "公司状态,枚举值:ENABLED启用、DISABLED禁用") @NotNull(message = "不能为空") CompanyStatus status +) { +} diff --git a/src/main/java/com/labelsys/backend/dto/request/UpdateUserAssignmentRequest.java b/src/main/java/com/labelsys/backend/dto/request/UpdateUserAssignmentRequest.java new file mode 100644 index 0000000..291777d --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/request/UpdateUserAssignmentRequest.java @@ -0,0 +1,13 @@ +package com.labelsys.backend.dto.request; + +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "修改员工角色岗位请求") +public record UpdateUserAssignmentRequest( + @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") @NotNull(message = "不能为空") UserRole role, + @Schema(description = "岗位,枚举值:UPLOADER上传者、ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") @NotNull(message = "不能为空") UserPosition position +) { +} diff --git a/src/main/java/com/labelsys/backend/dto/request/UpdateUserStatusRequest.java b/src/main/java/com/labelsys/backend/dto/request/UpdateUserStatusRequest.java new file mode 100644 index 0000000..9548dd4 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/request/UpdateUserStatusRequest.java @@ -0,0 +1,11 @@ +package com.labelsys.backend.dto.request; + +import com.labelsys.backend.enums.UserStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "修改员工状态请求") +public record UpdateUserStatusRequest( + @Schema(description = "用户状态,枚举值:ENABLED启用、DISABLED禁用") @NotNull(message = "不能为空") UserStatus status +) { +} diff --git a/src/main/java/com/labelsys/backend/dto/response/CompanyOptionResponse.java b/src/main/java/com/labelsys/backend/dto/response/CompanyOptionResponse.java new file mode 100644 index 0000000..4123641 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/response/CompanyOptionResponse.java @@ -0,0 +1,15 @@ +package com.labelsys.backend.dto.response; + +import com.labelsys.backend.entity.SysCompany; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "可登录公司选项") +public record CompanyOptionResponse( + @Schema(description = "公司ID") Long companyId, + @Schema(description = "公司编码") String companyCode, + @Schema(description = "公司名称") String companyName +) { + public static CompanyOptionResponse from(SysCompany company) { + return new CompanyOptionResponse(company.getId(), company.getCompanyCode(), company.getCompanyName()); + } +} diff --git a/src/main/java/com/labelsys/backend/dto/response/CompanyResponse.java b/src/main/java/com/labelsys/backend/dto/response/CompanyResponse.java new file mode 100644 index 0000000..2ed7439 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/response/CompanyResponse.java @@ -0,0 +1,17 @@ +package com.labelsys.backend.dto.response; + +import com.labelsys.backend.entity.SysCompany; +import com.labelsys.backend.enums.CompanyStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "公司响应") +public record CompanyResponse( + @Schema(description = "公司ID") Long companyId, + @Schema(description = "公司编码") String companyCode, + @Schema(description = "公司名称") String companyName, + @Schema(description = "公司状态,枚举值:ENABLED启用、DISABLED禁用") CompanyStatus status +) { + public static CompanyResponse from(SysCompany company) { + return new CompanyResponse(company.getId(), company.getCompanyCode(), company.getCompanyName(), company.getStatus()); + } +} diff --git a/src/main/java/com/labelsys/backend/dto/response/CurrentUserResponse.java b/src/main/java/com/labelsys/backend/dto/response/CurrentUserResponse.java new file mode 100644 index 0000000..5d3e31e --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/response/CurrentUserResponse.java @@ -0,0 +1,35 @@ +package com.labelsys.backend.dto.response; + +import com.labelsys.backend.context.LoginUser; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "当前登录用户响应") +public record CurrentUserResponse( + @Schema(description = "用户ID") Long userId, + @Schema(description = "公司ID") Long companyId, + @Schema(description = "公司编码") String companyCode, + @Schema(description = "公司名称") String companyName, + @Schema(description = "手机号") String phone, + @Schema(description = "用户名,可为空", example = "alpha-admin") String username, + @Schema(description = "真实姓名") String realName, + @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole role, + @Schema(description = "岗位,枚举值:UPLOADER上传者、ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") UserPosition position, + @Schema(description = "是否必须修改密码") boolean mustChangePassword +) { + public static CurrentUserResponse from(LoginUser loginUser) { + return new CurrentUserResponse( + loginUser.userId(), + loginUser.companyId(), + loginUser.companyCode(), + loginUser.companyName(), + loginUser.phone(), + loginUser.username(), + loginUser.realName(), + loginUser.role(), + loginUser.position(), + loginUser.mustChangePassword() + ); + } +} diff --git a/src/main/java/com/labelsys/backend/dto/response/DataRecordResponse.java b/src/main/java/com/labelsys/backend/dto/response/DataRecordResponse.java new file mode 100644 index 0000000..fbdcdb4 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/response/DataRecordResponse.java @@ -0,0 +1,24 @@ +package com.labelsys.backend.dto.response; + +import com.labelsys.backend.entity.BizDataRecord; +import com.labelsys.backend.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "数据记录响应") +public record DataRecordResponse( + @Schema(description = "记录ID") Long id, + @Schema(description = "公司ID") Long companyId, + @Schema(description = "创建人ID") Long creatorId, + @Schema(description = "创建人角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole creatorRole, + @Schema(description = "记录名称") String recordName +) { + public static DataRecordResponse from(BizDataRecord record) { + return new DataRecordResponse( + record.getId(), + record.getCompanyId(), + record.getCreatorId(), + record.getCreatorRole(), + record.getRecordName() + ); + } +} diff --git a/src/main/java/com/labelsys/backend/dto/response/LoginResponse.java b/src/main/java/com/labelsys/backend/dto/response/LoginResponse.java new file mode 100644 index 0000000..8cc06e1 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/response/LoginResponse.java @@ -0,0 +1,32 @@ +package com.labelsys.backend.dto.response; + +import com.labelsys.backend.context.LoginUser; +import com.labelsys.backend.entity.SysCompany; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "登录响应") +public record LoginResponse( + @Schema(description = "访问令牌") String token, + @Schema(description = "当前公司信息") CompanyOptionResponse company, + @Schema(description = "手机号") String phone, + @Schema(description = "用户名,可为空", example = "alpha-admin") String username, + @Schema(description = "真实姓名") String realName, + @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole role, + @Schema(description = "岗位,枚举值:UPLOADER上传者、ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") UserPosition position, + @Schema(description = "是否必须修改密码") boolean mustChangePassword +) { + public static LoginResponse from(String token, LoginUser loginUser, SysCompany company) { + return new LoginResponse( + token, + CompanyOptionResponse.from(company), + loginUser.phone(), + loginUser.username(), + loginUser.realName(), + loginUser.role(), + loginUser.position(), + loginUser.mustChangePassword() + ); + } +} diff --git a/src/main/java/com/labelsys/backend/dto/response/MenuResponse.java b/src/main/java/com/labelsys/backend/dto/response/MenuResponse.java new file mode 100644 index 0000000..66c67a9 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/response/MenuResponse.java @@ -0,0 +1,16 @@ +package com.labelsys.backend.dto.response; + +import com.labelsys.backend.entity.SysMenu; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "菜单响应") +public record MenuResponse( + @Schema(description = "菜单编码") String menuCode, + @Schema(description = "菜单名称") String menuName, + @Schema(description = "菜单路径") String path, + @Schema(description = "排序") Integer sortOrder +) { + public static MenuResponse from(SysMenu menu) { + return new MenuResponse(menu.getMenuCode(), menu.getMenuName(), menu.getPath(), menu.getSortOrder()); + } +} diff --git a/src/main/java/com/labelsys/backend/dto/response/UserResponse.java b/src/main/java/com/labelsys/backend/dto/response/UserResponse.java new file mode 100644 index 0000000..ca9bb46 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/response/UserResponse.java @@ -0,0 +1,34 @@ +package com.labelsys.backend.dto.response; + +import com.labelsys.backend.entity.SysUser; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserRole; +import com.labelsys.backend.enums.UserStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "用户响应") +public record UserResponse( + @Schema(description = "用户ID") Long userId, + @Schema(description = "公司ID") Long companyId, + @Schema(description = "手机号") String phone, + @Schema(description = "用户名,可为空", example = "alpha-admin") String username, + @Schema(description = "真实姓名") String realName, + @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole role, + @Schema(description = "岗位,枚举值:UPLOADER上传者、ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") UserPosition position, + @Schema(description = "用户状态,枚举值:ENABLED启用、DISABLED禁用") UserStatus status, + @Schema(description = "是否必须修改密码") boolean mustChangePassword +) { + public static UserResponse from(SysUser user) { + return new UserResponse( + user.getId(), + user.getCompanyId(), + user.getPhone(), + user.getUsername(), + user.getRealName(), + user.getRole(), + user.getPosition(), + user.getStatus(), + Boolean.TRUE.equals(user.getMustChangePassword()) + ); + } +} diff --git a/src/main/java/com/labelsys/backend/entity/BizDataRecord.java b/src/main/java/com/labelsys/backend/entity/BizDataRecord.java new file mode 100644 index 0000000..7d2a7ee --- /dev/null +++ b/src/main/java/com/labelsys/backend/entity/BizDataRecord.java @@ -0,0 +1,27 @@ +package com.labelsys.backend.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.labelsys.backend.enums.UserRole; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TableName("biz_data_record") +public class BizDataRecord { + @TableId(type = IdType.INPUT) + private Long id; + private Long companyId; + private Long creatorId; + private UserRole creatorRole; + private String recordName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/labelsys/backend/entity/SysCompany.java b/src/main/java/com/labelsys/backend/entity/SysCompany.java new file mode 100644 index 0000000..e46c0a2 --- /dev/null +++ b/src/main/java/com/labelsys/backend/entity/SysCompany.java @@ -0,0 +1,26 @@ +package com.labelsys.backend.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.labelsys.backend.enums.CompanyStatus; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TableName("sys_company") +public class SysCompany { + @TableId(type = IdType.INPUT) + private Long id; + private String companyCode; + private String companyName; + private CompanyStatus status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/labelsys/backend/entity/SysMenu.java b/src/main/java/com/labelsys/backend/entity/SysMenu.java new file mode 100644 index 0000000..e1a124b --- /dev/null +++ b/src/main/java/com/labelsys/backend/entity/SysMenu.java @@ -0,0 +1,28 @@ +package com.labelsys.backend.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TableName("sys_menu") +public class SysMenu { + @TableId(type = IdType.INPUT) + private Long id; + private Long companyId; + private String permissionCode; + private String menuCode; + private String menuName; + private String path; + private Integer sortOrder; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/labelsys/backend/entity/SysUser.java b/src/main/java/com/labelsys/backend/entity/SysUser.java new file mode 100644 index 0000000..c9f289b --- /dev/null +++ b/src/main/java/com/labelsys/backend/entity/SysUser.java @@ -0,0 +1,35 @@ +package com.labelsys.backend.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserRole; +import com.labelsys.backend.enums.UserStatus; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TableName("sys_user") +public class SysUser { + @TableId(type = IdType.INPUT) + private Long id; + private Long companyId; + private String phone; + private String username; + private UserRole role; + private UserPosition position; + private String realName; + private String passwordHash; + private Boolean mustChangePassword; + private UserStatus status; + private Integer sessionVersion; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/labelsys/backend/enums/CompanyStatus.java b/src/main/java/com/labelsys/backend/enums/CompanyStatus.java new file mode 100644 index 0000000..aba07e6 --- /dev/null +++ b/src/main/java/com/labelsys/backend/enums/CompanyStatus.java @@ -0,0 +1,9 @@ +package com.labelsys.backend.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "公司状态,枚举值:ENABLED启用、DISABLED禁用") +public enum CompanyStatus { + ENABLED, + DISABLED +} diff --git a/src/main/java/com/labelsys/backend/enums/UserPosition.java b/src/main/java/com/labelsys/backend/enums/UserPosition.java new file mode 100644 index 0000000..87f1f8b --- /dev/null +++ b/src/main/java/com/labelsys/backend/enums/UserPosition.java @@ -0,0 +1,22 @@ +package com.labelsys.backend.enums; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "岗位枚举,枚举值:UPLOADER上传者、ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") +public enum UserPosition { + UPLOADER(1), + ANNOTATOR(2), + DATA_TRAINER(3), + REVIEWER(4), + ADMIN(5); + + private final int level; + + public boolean canAccess(UserPosition required) { + return this.level >= required.level; + } +} diff --git a/src/main/java/com/labelsys/backend/enums/UserRole.java b/src/main/java/com/labelsys/backend/enums/UserRole.java new file mode 100644 index 0000000..0b4815c --- /dev/null +++ b/src/main/java/com/labelsys/backend/enums/UserRole.java @@ -0,0 +1,16 @@ +package com.labelsys.backend.enums; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "角色枚举,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") +public enum UserRole { + EMPLOYEE(1), + MANAGER(2), + ENGINEER(3); + + private final int level; +} diff --git a/src/main/java/com/labelsys/backend/enums/UserStatus.java b/src/main/java/com/labelsys/backend/enums/UserStatus.java new file mode 100644 index 0000000..7fc6f1b --- /dev/null +++ b/src/main/java/com/labelsys/backend/enums/UserStatus.java @@ -0,0 +1,9 @@ +package com.labelsys.backend.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "用户状态,枚举值:ENABLED启用、DISABLED禁用") +public enum UserStatus { + ENABLED, + DISABLED +} diff --git a/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java b/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java new file mode 100644 index 0000000..20ee660 --- /dev/null +++ b/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java @@ -0,0 +1,117 @@ +package com.labelsys.backend.interceptor; + +import com.labelsys.backend.annotation.RequirePosition; +import com.labelsys.backend.common.exception.ForbiddenException; +import com.labelsys.backend.common.exception.UnauthorizedException; +import com.labelsys.backend.context.LoginUser; +import com.labelsys.backend.context.UserContext; +import com.labelsys.backend.entity.SysCompany; +import com.labelsys.backend.entity.SysUser; +import com.labelsys.backend.enums.CompanyStatus; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserStatus; +import com.labelsys.backend.mapper.SysCompanyMapper; +import com.labelsys.backend.mapper.SysUserMapper; +import com.labelsys.backend.service.session.TokenSessionRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import java.util.Set; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AuthInterceptor implements HandlerInterceptor { + + private static final Set OPEN_PATHS = Set.of( + "/label/api/auth/companies", + "/label/api/auth/login", + "/label/swagger-ui.html", + "/label/v3/api-docs", + "/label/v3/api-docs/swagger-config" + ); + + private static final Set ALLOWED_WHEN_MUST_CHANGE_PASSWORD = Set.of( + "/label/api/auth/change-password", + "/label/api/auth/logout", + "/label/api/auth/me" + ); + + private final TokenSessionRepository tokenSessionRepository; + private final SysUserMapper sysUserMapper; + private final SysCompanyMapper sysCompanyMapper; + private final Duration sessionTtl; + + public AuthInterceptor( + TokenSessionRepository tokenSessionRepository, + SysUserMapper sysUserMapper, + SysCompanyMapper sysCompanyMapper, + @Value("${labelsys.session.ttl:PT2H}") Duration sessionTtl + ) { + this.tokenSessionRepository = tokenSessionRepository; + this.sysUserMapper = sysUserMapper; + this.sysCompanyMapper = sysCompanyMapper; + this.sessionTtl = sessionTtl; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + String path = request.getRequestURI(); + if (path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || OPEN_PATHS.contains(path)) { + return true; + } + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + String token = extractToken(request.getHeader("Authorization")); + LoginUser loginUser = tokenSessionRepository.find(token) + .orElseThrow(() -> new UnauthorizedException("未登录或登录已过期")); + + SysUser user = sysUserMapper.findByIdAndCompanyId(loginUser.userId(), loginUser.companyId()); + SysCompany company = sysCompanyMapper.findById(loginUser.companyId()); + if (user == null || company == null || user.getStatus() != UserStatus.ENABLED || company.getStatus() != CompanyStatus.ENABLED) { + throw new UnauthorizedException("未登录或登录已过期"); + } + if (!user.getSessionVersion().equals(loginUser.sessionVersion())) { + throw new UnauthorizedException("登录状态已失效,请重新登录"); + } + if (Boolean.TRUE.equals(user.getMustChangePassword()) && !ALLOWED_WHEN_MUST_CHANGE_PASSWORD.contains(path)) { + throw new ForbiddenException("首次登录后请先修改密码"); + } + + LoginUser refreshedUser = LoginUser.from(user, company); + UserContext.set(refreshedUser); + tokenSessionRepository.refresh(token, sessionTtl); + + RequirePosition requirePosition = resolveRequirePosition(handlerMethod); + if (requirePosition != null && !refreshedUser.position().canAccess(requirePosition.value())) { + throw new ForbiddenException("当前岗位无权限访问"); + } + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + UserContext.clear(); + } + + private String extractToken(String authorization) { + if (authorization == null || !authorization.startsWith("Bearer ")) { + throw new UnauthorizedException("未登录或登录已过期"); + } + return authorization.substring(7).trim(); + } + + private RequirePosition resolveRequirePosition(HandlerMethod handlerMethod) { + RequirePosition methodAnnotation = handlerMethod.getMethodAnnotation(RequirePosition.class); + if (methodAnnotation != null) { + return methodAnnotation; + } + return handlerMethod.getBeanType().getAnnotation(RequirePosition.class); + } +} diff --git a/src/main/java/com/labelsys/backend/mapper/BizDataRecordMapper.java b/src/main/java/com/labelsys/backend/mapper/BizDataRecordMapper.java new file mode 100644 index 0000000..7f68e16 --- /dev/null +++ b/src/main/java/com/labelsys/backend/mapper/BizDataRecordMapper.java @@ -0,0 +1,15 @@ +package com.labelsys.backend.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.labelsys.backend.entity.BizDataRecord; +import java.util.List; +import org.apache.ibatis.annotations.Param; + +public interface BizDataRecordMapper extends BaseMapper { + + List listVisibleByEmployee(@Param("companyId") Long companyId, @Param("creatorId") Long creatorId); + + List listVisibleByManager(@Param("companyId") Long companyId); + + List listVisibleByEngineer(@Param("companyId") Long companyId); +} diff --git a/src/main/java/com/labelsys/backend/mapper/SysCompanyMapper.java b/src/main/java/com/labelsys/backend/mapper/SysCompanyMapper.java new file mode 100644 index 0000000..aafd165 --- /dev/null +++ b/src/main/java/com/labelsys/backend/mapper/SysCompanyMapper.java @@ -0,0 +1,24 @@ +package com.labelsys.backend.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.labelsys.backend.entity.SysCompany; +import com.labelsys.backend.enums.CompanyStatus; +import java.util.List; +import org.apache.ibatis.annotations.Param; + +public interface SysCompanyMapper extends BaseMapper { + + int insert(SysCompany company); + + SysCompany findById(@Param("id") Long id); + + SysCompany findByCompanyCode(@Param("companyCode") String companyCode); + + List findEnabledCompaniesByPhone(@Param("phone") String phone); + + List listAll(); + + int updateStatus(@Param("id") Long id, @Param("status") CompanyStatus status); + + void deleteAll(); +} diff --git a/src/main/java/com/labelsys/backend/mapper/SysMenuMapper.java b/src/main/java/com/labelsys/backend/mapper/SysMenuMapper.java new file mode 100644 index 0000000..5a42212 --- /dev/null +++ b/src/main/java/com/labelsys/backend/mapper/SysMenuMapper.java @@ -0,0 +1,11 @@ +package com.labelsys.backend.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.labelsys.backend.entity.SysMenu; +import java.util.List; +import org.apache.ibatis.annotations.Param; + +public interface SysMenuMapper extends BaseMapper { + + List listCurrentMenus(@Param("companyId") Long companyId, @Param("positionCodes") List positionCodes); +} diff --git a/src/main/java/com/labelsys/backend/mapper/SysUserMapper.java b/src/main/java/com/labelsys/backend/mapper/SysUserMapper.java new file mode 100644 index 0000000..80688bd --- /dev/null +++ b/src/main/java/com/labelsys/backend/mapper/SysUserMapper.java @@ -0,0 +1,44 @@ +package com.labelsys.backend.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.labelsys.backend.entity.SysUser; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserRole; +import com.labelsys.backend.enums.UserStatus; +import java.util.List; +import org.apache.ibatis.annotations.Param; + +public interface SysUserMapper extends BaseMapper { + + int insert(SysUser user); + + SysUser findById(@Param("id") Long id); + + SysUser findByIdAndCompanyId(@Param("id") Long id, @Param("companyId") Long companyId); + + SysUser findByCompanyIdAndPhone(@Param("companyId") Long companyId, @Param("phone") String phone); + + List listByCompanyId(@Param("companyId") Long companyId); + + List listCompanyAdmins(@Param("companyId") Long companyId); + + int updatePassword( + @Param("id") Long id, + @Param("companyId") Long companyId, + @Param("passwordHash") String passwordHash, + @Param("mustChangePassword") boolean mustChangePassword + ); + + int updateAssignment( + @Param("id") Long id, + @Param("companyId") Long companyId, + @Param("role") UserRole role, + @Param("position") UserPosition position + ); + + int updateStatus(@Param("id") Long id, @Param("companyId") Long companyId, @Param("status") UserStatus status); + + int bumpSessionVersion(@Param("id") Long id, @Param("companyId") Long companyId); + + void deleteAll(); +} diff --git a/src/main/java/com/labelsys/backend/service/AuthService.java b/src/main/java/com/labelsys/backend/service/AuthService.java new file mode 100644 index 0000000..a407f35 --- /dev/null +++ b/src/main/java/com/labelsys/backend/service/AuthService.java @@ -0,0 +1,98 @@ +package com.labelsys.backend.service; + +import com.labelsys.backend.common.ResultCode; +import com.labelsys.backend.common.exception.BusinessException; +import com.labelsys.backend.common.exception.UnauthorizedException; +import com.labelsys.backend.context.LoginUser; +import com.labelsys.backend.dto.request.ChangePasswordRequest; +import com.labelsys.backend.dto.request.LoginRequest; +import com.labelsys.backend.dto.response.CompanyOptionResponse; +import com.labelsys.backend.dto.response.LoginResponse; +import com.labelsys.backend.entity.SysCompany; +import com.labelsys.backend.entity.SysUser; +import com.labelsys.backend.enums.CompanyStatus; +import com.labelsys.backend.enums.UserStatus; +import com.labelsys.backend.mapper.SysCompanyMapper; +import com.labelsys.backend.mapper.SysUserMapper; +import com.labelsys.backend.service.session.TokenSessionRepository; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final SysCompanyMapper sysCompanyMapper; + private final SysUserMapper sysUserMapper; + private final PasswordEncoder passwordEncoder; + private final TokenSessionRepository tokenSessionRepository; + + @Value("${labelsys.session.ttl:PT2H}") + private Duration sessionTtl; + + public List listAvailableCompanies(String phone) { + return sysCompanyMapper.findEnabledCompaniesByPhone(phone).stream() + .map(CompanyOptionResponse::from) + .toList(); + } + + public LoginResponse login(LoginRequest request) { + SysCompany company = loadEnabledCompany(request.companyCode()); + SysUser user = loadEnabledUser(company.getId(), request.phone()); + if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new UnauthorizedException("手机号、公司或密码错误"); + } + LoginUser loginUser = LoginUser.from(user, company); + String token = UUID.randomUUID().toString(); + tokenSessionRepository.save(token, loginUser, sessionTtl); + return LoginResponse.from(token, loginUser, company); + } + + public LoginUser getCurrentUser(String token) { + return tokenSessionRepository.find(token) + .orElseThrow(() -> new UnauthorizedException("未登录或登录已过期")); + } + + @Transactional + public void changePassword(LoginUser currentUser, ChangePasswordRequest request) { + if (!request.newPassword().equals(request.confirmPassword())) { + throw new BusinessException(ResultCode.BAD_REQUEST, "两次输入的新密码不一致"); + } + SysUser user = sysUserMapper.findByIdAndCompanyId(currentUser.userId(), currentUser.companyId()); + if (user == null || user.getStatus() != UserStatus.ENABLED) { + throw new UnauthorizedException("未登录或登录已过期"); + } + if (!passwordEncoder.matches(request.oldPassword(), user.getPasswordHash())) { + throw new BusinessException(ResultCode.BAD_REQUEST, "旧密码错误"); + } + sysUserMapper.updatePassword(user.getId(), user.getCompanyId(), passwordEncoder.encode(request.newPassword()), false); + sysUserMapper.bumpSessionVersion(user.getId(), user.getCompanyId()); + tokenSessionRepository.removeAll(user.getId()); + } + + public void logout(String token) { + tokenSessionRepository.remove(token); + } + + private SysCompany loadEnabledCompany(String companyCode) { + SysCompany company = sysCompanyMapper.findByCompanyCode(companyCode); + if (company == null || company.getStatus() != CompanyStatus.ENABLED) { + throw new UnauthorizedException("手机号、公司或密码错误"); + } + return company; + } + + private SysUser loadEnabledUser(Long companyId, String phone) { + SysUser user = sysUserMapper.findByCompanyIdAndPhone(companyId, phone); + if (user == null || user.getStatus() != UserStatus.ENABLED) { + throw new UnauthorizedException("手机号、公司或密码错误"); + } + return user; + } +} diff --git a/src/main/java/com/labelsys/backend/service/CompanyService.java b/src/main/java/com/labelsys/backend/service/CompanyService.java new file mode 100644 index 0000000..0e37cb0 --- /dev/null +++ b/src/main/java/com/labelsys/backend/service/CompanyService.java @@ -0,0 +1,54 @@ +package com.labelsys.backend.service; + +import com.labelsys.backend.common.ResultCode; +import com.labelsys.backend.common.exception.BusinessException; +import com.labelsys.backend.common.exception.ForbiddenException; +import com.labelsys.backend.context.LoginUser; +import com.labelsys.backend.dto.request.CreateCompanyRequest; +import com.labelsys.backend.dto.request.UpdateCompanyStatusRequest; +import com.labelsys.backend.entity.SysCompany; +import com.labelsys.backend.mapper.SysCompanyMapper; +import com.labelsys.backend.util.IdGenerator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CompanyService { + + private final SysCompanyMapper sysCompanyMapper; + + public List listCompanies(LoginUser currentUser) { + assertPlatformAdmin(currentUser); + return sysCompanyMapper.listAll(); + } + + public SysCompany createCompany(LoginUser currentUser, CreateCompanyRequest request) { + assertPlatformAdmin(currentUser); + if (sysCompanyMapper.findByCompanyCode(request.companyCode()) != null) { + throw new BusinessException(ResultCode.CONFLICT, "公司编码已存在"); + } + SysCompany company = SysCompany.builder() + .id(IdGenerator.nextId()) + .companyCode(request.companyCode()) + .companyName(request.companyName()) + .status(com.labelsys.backend.enums.CompanyStatus.ENABLED) + .build(); + sysCompanyMapper.insert(company); + return company; + } + + public void updateStatus(LoginUser currentUser, Long companyId, UpdateCompanyStatusRequest request) { + assertPlatformAdmin(currentUser); + if (sysCompanyMapper.updateStatus(companyId, request.status()) == 0) { + throw new BusinessException(ResultCode.NOT_FOUND, "公司不存在"); + } + } + + private void assertPlatformAdmin(LoginUser currentUser) { + if (!currentUser.isPlatformAdmin()) { + throw new ForbiddenException("仅平台管理员可操作"); + } + } +} diff --git a/src/main/java/com/labelsys/backend/service/DataPermissionService.java b/src/main/java/com/labelsys/backend/service/DataPermissionService.java new file mode 100644 index 0000000..db04686 --- /dev/null +++ b/src/main/java/com/labelsys/backend/service/DataPermissionService.java @@ -0,0 +1,93 @@ +package com.labelsys.backend.service; + +import com.labelsys.backend.context.LoginUser; +import com.labelsys.backend.entity.BizDataRecord; +import com.labelsys.backend.enums.UserRole; +import com.labelsys.backend.mapper.BizDataRecordMapper; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DataPermissionService { + + private final BizDataRecordMapper bizDataRecordMapper; + + public List listVisibleRecords(LoginUser currentUser) { + return switch (currentUser.role()) { + case EMPLOYEE -> bizDataRecordMapper.listVisibleByEmployee(currentUser.companyId(), currentUser.userId()); + case MANAGER -> bizDataRecordMapper.listVisibleByManager(currentUser.companyId()); + case ENGINEER -> bizDataRecordMapper.listVisibleByEngineer(currentUser.companyId()); + }; + } + + public boolean canAccessCreator(LoginUser currentUser, Long creatorId, UserRole creatorRole) { + return switch (currentUser.role()) { + case EMPLOYEE -> currentUser.userId().equals(creatorId); + case MANAGER -> creatorRole == UserRole.EMPLOYEE || creatorRole == UserRole.MANAGER; + case ENGINEER -> true; + }; + } + + /** + * 通用数据过滤方法 + * + * @param currentUser 当前登录用户 + * @param allRecords 待过滤的全量数据列表 + * @param roleExtractor 从数据对象中提取“关联角色”或“创建者角色”的函数 + * @param ownerIdExtractor 从数据对象中提取“所有者ID”的函数(用于员工只能看自己的情况) + * @param 数据类型 + * @return 过滤后的数据列表 + */ + public List filterByRole( + LoginUser currentUser, + List allRecords, + Function roleExtractor, + Function ownerIdExtractor) { + + if (allRecords == null || allRecords.isEmpty()) { + return List.of(); + } + + UserRole currentRole = currentUser.role(); + Long currentUserId = currentUser.userId(); + + return allRecords.stream() + .filter(record -> { + UserRole recordRole = roleExtractor.apply(record); + Long recordOwnerId = ownerIdExtractor.apply(record); + + return switch (currentRole) { + case EMPLOYEE -> + // 员工只能查看自己创建/拥有的数据 + currentUserId.equals(recordOwnerId); + + case MANAGER -> + // 经理可以查看员工和经理的数据,不能查看总工程师的数据 + recordRole == UserRole.EMPLOYEE || recordRole == UserRole.MANAGER; + + case ENGINEER -> + // 总工程师可以查看所有数据 + true; + }; + }) + .collect(Collectors.toList()); + } + + /** + * 针对 BizDataRecord 的便捷调用方法 + */ + // public List listVisibleRecordsGeneric(LoginUser currentUser, List allRecords) { + // return filterByRole( + // currentUser, + // allRecords, + // BizDataRecord:: + // BizDataRecord::getCreatorRole, // 提取创建者角色 + // BizDataRecord::getCreatorId // 提取创建者ID + // ); + // } +} diff --git a/src/main/java/com/labelsys/backend/service/MenuService.java b/src/main/java/com/labelsys/backend/service/MenuService.java new file mode 100644 index 0000000..31af6d6 --- /dev/null +++ b/src/main/java/com/labelsys/backend/service/MenuService.java @@ -0,0 +1,26 @@ +package com.labelsys.backend.service; + +import com.labelsys.backend.context.LoginUser; +import com.labelsys.backend.dto.response.MenuResponse; +import com.labelsys.backend.mapper.SysMenuMapper; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MenuService { + + private final SysMenuMapper sysMenuMapper; + + public List listCurrentMenus(LoginUser currentUser) { + List positionCodes = java.util.Arrays.stream(com.labelsys.backend.enums.UserPosition.values()) + .filter(position -> currentUser.position().canAccess(position)) + .map(Enum::name) + .toList(); + return sysMenuMapper.listCurrentMenus(currentUser.companyId(), positionCodes) + .stream() + .map(MenuResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/labelsys/backend/service/UserService.java b/src/main/java/com/labelsys/backend/service/UserService.java new file mode 100644 index 0000000..8236682 --- /dev/null +++ b/src/main/java/com/labelsys/backend/service/UserService.java @@ -0,0 +1,134 @@ +package com.labelsys.backend.service; + +import com.labelsys.backend.common.ResultCode; +import com.labelsys.backend.common.exception.BusinessException; +import com.labelsys.backend.common.exception.ForbiddenException; +import com.labelsys.backend.context.LoginUser; +import com.labelsys.backend.dto.request.CreateCompanyAdminRequest; +import com.labelsys.backend.dto.request.CreateUserRequest; +import com.labelsys.backend.dto.request.UpdateUserAssignmentRequest; +import com.labelsys.backend.dto.request.UpdateUserStatusRequest; +import com.labelsys.backend.entity.SysCompany; +import com.labelsys.backend.entity.SysUser; +import com.labelsys.backend.enums.CompanyStatus; +import com.labelsys.backend.enums.UserPosition; +import com.labelsys.backend.enums.UserRole; +import com.labelsys.backend.enums.UserStatus; +import com.labelsys.backend.mapper.SysCompanyMapper; +import com.labelsys.backend.mapper.SysUserMapper; +import com.labelsys.backend.service.session.TokenSessionRepository; +import com.labelsys.backend.util.IdGenerator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserService { + + private static final String DEFAULT_PASSWORD = "123456"; + + private final SysUserMapper sysUserMapper; + private final SysCompanyMapper sysCompanyMapper; + private final PasswordEncoder passwordEncoder; + private final TokenSessionRepository tokenSessionRepository; + + public List listCompanyUsers(LoginUser currentUser) { + assertCompanyAdmin(currentUser); + return sysUserMapper.listByCompanyId(currentUser.companyId()); + } + + public List listCompanyAdmins(LoginUser currentUser, Long companyId) { + assertPlatformAdmin(currentUser); + return sysUserMapper.listCompanyAdmins(companyId); + } + + public SysUser createCompanyAdmin(LoginUser currentUser, CreateCompanyAdminRequest request) { + assertPlatformAdmin(currentUser); + ensureEnabledCompany(request.companyId()); + return createUser( + request.companyId(), + new CreateUserRequest(request.phone(), request.username(), request.realName(), UserRole.EMPLOYEE, UserPosition.ADMIN) + ); + } + + public SysUser createCompanyUser(LoginUser currentUser, CreateUserRequest request) { + assertCompanyAdmin(currentUser); + return createUser(currentUser.companyId(), request); + } + + public SysUser createCompanyUser(LoginUser currentUser, Long companyId, CreateUserRequest request) { + assertPlatformAdmin(currentUser); + ensureEnabledCompany(companyId); + return createUser(companyId, request); + } + + @Transactional + public void updateAssignment(LoginUser currentUser, Long userId, UpdateUserAssignmentRequest request) { + assertCompanyAdmin(currentUser); + if (sysUserMapper.updateAssignment(userId, currentUser.companyId(), request.role(), request.position()) == 0) { + throw new BusinessException(ResultCode.NOT_FOUND, "用户不存在"); + } + tokenSessionRepository.removeAll(userId); + } + + @Transactional + public void updateStatus(LoginUser currentUser, Long userId, UpdateUserStatusRequest request) { + assertCompanyAdmin(currentUser); + if (sysUserMapper.updateStatus(userId, currentUser.companyId(), request.status()) == 0) { + throw new BusinessException(ResultCode.NOT_FOUND, "用户不存在"); + } + tokenSessionRepository.removeAll(userId); + } + + @Transactional + public void updateCompanyAdminStatus(LoginUser currentUser, Long companyId, Long userId, UpdateUserStatusRequest request) { + assertPlatformAdmin(currentUser); + if (sysUserMapper.updateStatus(userId, companyId, request.status()) == 0) { + throw new BusinessException(ResultCode.NOT_FOUND, "用户不存在"); + } + tokenSessionRepository.removeAll(userId); + } + + private SysUser createUser(Long companyId, CreateUserRequest request) { + if (sysUserMapper.findByCompanyIdAndPhone(companyId, request.phone()) != null) { + throw new BusinessException(ResultCode.CONFLICT, "同一公司内手机号已存在"); + } + SysUser user = SysUser.builder() + .id(IdGenerator.nextId()) + .companyId(companyId) + .phone(request.phone()) + .username(request.username()) + .realName(request.realName()) + .role(request.role()) + .position(request.position()) + .passwordHash(passwordEncoder.encode(DEFAULT_PASSWORD)) + .mustChangePassword(true) + .status(UserStatus.ENABLED) + .sessionVersion(1) + .build(); + sysUserMapper.insert(user); + return user; + } + + private void ensureEnabledCompany(Long companyId) { + SysCompany company = sysCompanyMapper.findById(companyId); + if (company == null || company.getStatus() != CompanyStatus.ENABLED) { + throw new BusinessException(ResultCode.NOT_FOUND, "公司不存在或已禁用"); + } + } + + private void assertPlatformAdmin(LoginUser currentUser) { + if (!currentUser.isPlatformAdmin()) { + throw new ForbiddenException("仅平台管理员可操作"); + } + } + + private void assertCompanyAdmin(LoginUser currentUser) { + if (currentUser.isPlatformAdmin() || currentUser.position() != UserPosition.ADMIN) { + throw new ForbiddenException("仅公司管理员可操作"); + } + } +} diff --git a/src/main/java/com/labelsys/backend/service/session/InMemoryTokenSessionRepository.java b/src/main/java/com/labelsys/backend/service/session/InMemoryTokenSessionRepository.java new file mode 100644 index 0000000..244ca88 --- /dev/null +++ b/src/main/java/com/labelsys/backend/service/session/InMemoryTokenSessionRepository.java @@ -0,0 +1,68 @@ +package com.labelsys.backend.service.session; + +import com.labelsys.backend.context.LoginUser; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty(prefix = "labelsys.session", name = "store-type", havingValue = "memory") +public class InMemoryTokenSessionRepository implements TokenSessionRepository { + + private final Map sessions = new ConcurrentHashMap<>(); + private final Map> userTokens = new ConcurrentHashMap<>(); + + @Override + public void save(String token, LoginUser loginUser, Duration ttl) { + sessions.put(token, new SessionEntry(loginUser, Instant.now().plus(ttl))); + userTokens.computeIfAbsent(loginUser.userId(), ignored -> ConcurrentHashMap.newKeySet()).add(token); + } + + @Override + public Optional find(String token) { + SessionEntry entry = sessions.get(token); + if (entry == null) { + return Optional.empty(); + } + if (entry.expiresAt().isBefore(Instant.now())) { + remove(token); + return Optional.empty(); + } + return Optional.of(entry.loginUser()); + } + + @Override + public void refresh(String token, Duration ttl) { + SessionEntry entry = sessions.get(token); + if (entry != null) { + sessions.put(token, new SessionEntry(entry.loginUser(), Instant.now().plus(ttl))); + } + } + + @Override + public void remove(String token) { + SessionEntry removed = sessions.remove(token); + if (removed != null) { + Set tokens = userTokens.get(removed.loginUser().userId()); + if (tokens != null) { + tokens.remove(token); + } + } + } + + @Override + public void removeAll(Long userId) { + Set tokens = userTokens.remove(userId); + if (tokens != null) { + tokens.forEach(sessions::remove); + } + } + + private record SessionEntry(LoginUser loginUser, Instant expiresAt) { + } +} diff --git a/src/main/java/com/labelsys/backend/service/session/RedisTokenSessionRepository.java b/src/main/java/com/labelsys/backend/service/session/RedisTokenSessionRepository.java new file mode 100644 index 0000000..a7ccd83 --- /dev/null +++ b/src/main/java/com/labelsys/backend/service/session/RedisTokenSessionRepository.java @@ -0,0 +1,82 @@ +package com.labelsys.backend.service.session; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.labelsys.backend.common.exception.BusinessException; +import com.labelsys.backend.common.ResultCode; +import com.labelsys.backend.context.LoginUser; +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty(prefix = "labelsys.session", name = "store-type", havingValue = "redis", matchIfMissing = true) +public class RedisTokenSessionRepository implements TokenSessionRepository { + + private static final String TOKEN_PREFIX = "token:"; + private static final String USER_TOKENS_PREFIX = "user:tokens:"; + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public RedisTokenSessionRepository(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + } + + @Override + public void save(String token, LoginUser loginUser, Duration ttl) { + try { + redisTemplate.opsForValue().set(tokenKey(token), objectMapper.writeValueAsString(loginUser), ttl); + redisTemplate.opsForSet().add(userTokensKey(loginUser.userId()), token); + redisTemplate.expire(userTokensKey(loginUser.userId()), ttl); + } catch (JsonProcessingException exception) { + throw new BusinessException(ResultCode.ERROR, "登录态序列化失败"); + } + } + + @Override + public Optional find(String token) { + String value = redisTemplate.opsForValue().get(tokenKey(token)); + if (value == null) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(value, LoginUser.class)); + } catch (JsonProcessingException exception) { + remove(token); + return Optional.empty(); + } + } + + @Override + public void refresh(String token, Duration ttl) { + redisTemplate.expire(tokenKey(token), ttl); + } + + @Override + public void remove(String token) { + find(token).ifPresent(loginUser -> redisTemplate.opsForSet().remove(userTokensKey(loginUser.userId()), token)); + redisTemplate.delete(tokenKey(token)); + } + + @Override + public void removeAll(Long userId) { + Set tokens = redisTemplate.opsForSet().members(userTokensKey(userId)); + if (tokens != null && !tokens.isEmpty()) { + redisTemplate.delete(tokens.stream().map(this::tokenKey).toList()); + } + redisTemplate.delete(userTokensKey(userId)); + } + + private String tokenKey(String token) { + return TOKEN_PREFIX + token; + } + + private String userTokensKey(Long userId) { + return USER_TOKENS_PREFIX + userId; + } +} diff --git a/src/main/java/com/labelsys/backend/service/session/TokenSessionRepository.java b/src/main/java/com/labelsys/backend/service/session/TokenSessionRepository.java new file mode 100644 index 0000000..3369ab9 --- /dev/null +++ b/src/main/java/com/labelsys/backend/service/session/TokenSessionRepository.java @@ -0,0 +1,18 @@ +package com.labelsys.backend.service.session; + +import com.labelsys.backend.context.LoginUser; +import java.time.Duration; +import java.util.Optional; + +public interface TokenSessionRepository { + + void save(String token, LoginUser loginUser, Duration ttl); + + Optional find(String token); + + void refresh(String token, Duration ttl); + + void remove(String token); + + void removeAll(Long userId); +} diff --git a/src/main/java/com/labelsys/backend/util/IdGenerator.java b/src/main/java/com/labelsys/backend/util/IdGenerator.java new file mode 100644 index 0000000..4097e2d --- /dev/null +++ b/src/main/java/com/labelsys/backend/util/IdGenerator.java @@ -0,0 +1,16 @@ +package com.labelsys.backend.util; + +import java.util.concurrent.atomic.AtomicInteger; + +public final class IdGenerator { + + private static final AtomicInteger SEQUENCE = new AtomicInteger(); + + private IdGenerator() { + } + + public static long nextId() { + int sequence = SEQUENCE.updateAndGet(value -> value >= 999 ? 0 : value + 1); + return System.currentTimeMillis() * 1000 + sequence; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..a175856 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,40 @@ +server: + port: 18082 + servlet: + context-path: /label + +spring: + application: + name: lablesys-backend + datasource: + url: ${DB_URL:jdbc:postgresql://39.107.112.174:5432/lablesystem} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres!Pw} + driver-class-name: org.postgresql.Driver + data: + redis: + host: ${REDIS_HOST:39.107.227.165} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:jsti@2024} + timeout: 5s + sql: + init: + mode: never + +mybatis-plus: + mapper-locations: classpath*:mapper/*.xml + configuration: + map-underscore-to-camel-case: true + +springdoc: + swagger-ui: + path: /swagger-ui.html + +labelsys: + session: + ttl: PT2H + store-type: redis + +logging: + level: + com.labelsys.backend: DEBUG diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..3baf2e0 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + ${LOG_PATTERN} + + + + + + ${LOG_PATH}/${APP_NAME}.log + + ${LOG_PATTERN} + + + ${LOG_PATH}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log + 60MB + 30 + 3GB + + + + + + + + + diff --git a/src/main/resources/mapper/BizDataRecordMapper.xml b/src/main/resources/mapper/BizDataRecordMapper.xml new file mode 100644 index 0000000..bd3a0bd --- /dev/null +++ b/src/main/resources/mapper/BizDataRecordMapper.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + id, company_id, creator_id, creator_role, record_name, created_at, updated_at + + + + + + + + diff --git a/src/main/resources/mapper/SysCompanyMapper.xml b/src/main/resources/mapper/SysCompanyMapper.xml new file mode 100644 index 0000000..75689d2 --- /dev/null +++ b/src/main/resources/mapper/SysCompanyMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + id, company_code, company_name, status, created_at, updated_at + + + + insert into sys_company (id, company_code, company_name, status, created_at, updated_at) + values (#{id}, #{companyCode}, #{companyName}, #{status}, current_timestamp, current_timestamp) + + + + + + + + + + + + update sys_company + set status = #{status}, updated_at = current_timestamp + where id = #{id} + + + + delete from sys_company + + diff --git a/src/main/resources/mapper/SysMenuMapper.xml b/src/main/resources/mapper/SysMenuMapper.xml new file mode 100644 index 0000000..9aedbdf --- /dev/null +++ b/src/main/resources/mapper/SysMenuMapper.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/mapper/SysUserMapper.xml b/src/main/resources/mapper/SysUserMapper.xml new file mode 100644 index 0000000..6e20dee --- /dev/null +++ b/src/main/resources/mapper/SysUserMapper.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + id, company_id, phone, username, role, position, real_name, password_hash, must_change_password, + status, session_version, created_at, updated_at + + + + insert into sys_user ( + id, company_id, phone, username, role, position, real_name, password_hash, must_change_password, + status, session_version, created_at, updated_at + ) + values ( + #{id}, #{companyId}, #{phone}, #{username}, #{role}, #{position}, #{realName}, #{passwordHash}, #{mustChangePassword}, + #{status}, #{sessionVersion}, current_timestamp, current_timestamp + ) + + + + + + + + + + + + + + update sys_user + set password_hash = #{passwordHash}, + must_change_password = #{mustChangePassword}, + updated_at = current_timestamp + where id = #{id} and company_id = #{companyId} + + + + update sys_user + set role = #{role}, + position = #{position}, + session_version = session_version + 1, + updated_at = current_timestamp + where id = #{id} and company_id = #{companyId} + + + + update sys_user + set status = #{status}, + session_version = session_version + 1, + updated_at = current_timestamp + where id = #{id} and company_id = #{companyId} + + + + update sys_user + set session_version = session_version + 1, + updated_at = current_timestamp + where id = #{id} and company_id = #{companyId} + + + + delete from sys_user + + diff --git a/src/main/resources/sql/data.sql b/src/main/resources/sql/data.sql new file mode 100644 index 0000000..568c728 --- /dev/null +++ b/src/main/resources/sql/data.sql @@ -0,0 +1,23 @@ +insert into sys_company (id, company_code, company_name, status) values + (1, 'PLATFORM', '平台公司', 'ENABLED'), + (2, 'ALPHA', '甲公司', 'ENABLED') +on conflict do nothing; + +insert into sys_user (id, company_id, phone, username, role, position, real_name, password_hash, must_change_password, status, session_version) values + (1, 1, '13900000000', 'platform-admin', 'ENGINEER', 'ADMIN', '平台管理员', '$2a$10$TGPk5rNNhKNJQvTWImw5J.LVzw9HDFWR6hyNJCkLDcp0GU8/vp0aS', false, 'ENABLED', 1), + (2, 2, '13800138000', 'alpha-admin', 'EMPLOYEE', 'ADMIN', '甲公司管理员', '$2a$10$/hSD8ch7A9lFWi/DOb8yJOHdlrhV57p95CBv9Uv93Yky7t6c4Rs/S', true, 'ENABLED', 1), + (3, 2, '13700000000', 'alpha-annotator', 'EMPLOYEE', 'ANNOTATOR', '甲公司标注员', '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', false, 'ENABLED', 1), + (4, 2, '13600000000', 'alpha-manager', 'MANAGER', 'REVIEWER', '甲公司经理', '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', false, 'ENABLED', 1), + (5, 2, '13500000000', 'alpha-engineer', 'ENGINEER', 'ADMIN', '甲公司工程师', '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', false, 'ENABLED', 1) +on conflict do nothing; + +insert into sys_menu (id, company_id, permission_code, menu_code, menu_name, path, sort_order) values + (201, 2, 'USER_MANAGE', 'USER_MANAGE', '员工管理', '/users', 1), + (202, 2, 'DATA_RECORD_VIEW', 'DATA_RECORDS', '数据记录', '/data-records', 2) +on conflict do nothing; + +insert into biz_data_record (id, company_id, creator_id, creator_role, record_name) values + (401, 2, 3, 'EMPLOYEE', '员工创建的数据'), + (402, 2, 4, 'MANAGER', '经理创建的数据'), + (403, 2, 5, 'ENGINEER', '工程师创建的数据') +on conflict do nothing; diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql new file mode 100644 index 0000000..b598e1e --- /dev/null +++ b/src/main/resources/sql/schema.sql @@ -0,0 +1,47 @@ +create table if not exists sys_company ( + id bigint primary key, + company_code varchar(64) not null unique, + company_name varchar(128) not null, + status varchar(32) not null, + created_at timestamp default current_timestamp, + updated_at timestamp default current_timestamp +); + +create table if not exists sys_user ( + id bigint primary key, + company_id bigint not null, + phone varchar(32) not null, + username varchar(64), + role varchar(32) not null, + position varchar(32) not null, + real_name varchar(64) not null, + password_hash varchar(255) not null, + must_change_password boolean not null default true, + status varchar(32) not null, + session_version integer not null default 1, + created_at timestamp default current_timestamp, + updated_at timestamp default current_timestamp, + constraint uq_sys_user_company_phone unique (company_id, phone) +); + +create table if not exists sys_menu ( + id bigint primary key, + company_id bigint not null, + permission_code varchar(64) not null, + menu_code varchar(64) not null, + menu_name varchar(128) not null, + path varchar(255) not null, + sort_order integer not null default 0, + created_at timestamp default current_timestamp, + updated_at timestamp default current_timestamp +); + +create table if not exists biz_data_record ( + id bigint primary key, + company_id bigint not null, + creator_id bigint not null, + creator_role varchar(32) not null, + record_name varchar(255) not null, + created_at timestamp default current_timestamp, + updated_at timestamp default current_timestamp +);