main主干首次提交,包含用户认证模块

This commit is contained in:
wh
2026-04-23 11:59:31 +08:00
commit cbef58aee5
65 changed files with 2335 additions and 0 deletions

View File

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

View File

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

View File

@@ -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<T> {
@Schema(description = "业务状态码")
private Integer code;
@Schema(description = "返回消息")
private String message;
@Schema(description = "返回数据")
private T data;
public static <T> Result<T> success() {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null);
}
public static <T> Result<T> success(T data) {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("公司、员工、认证、岗位权限和数据权限接口"));
}
}

View File

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

View File

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

View File

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

View File

@@ -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<LoginUser> HOLDER = new ThreadLocal<>();
private UserContext() {
}
public static void set(LoginUser loginUser) {
HOLDER.set(loginUser);
}
public static Optional<LoginUser> get() {
return Optional.ofNullable(HOLDER.get());
}
public static LoginUser requireUser() {
return get().orElseThrow(() -> new UnauthorizedException("未登录或登录已过期"));
}
public static void clear() {
HOLDER.remove();
}
}

View File

@@ -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<List<CompanyOptionResponse>> listCompanies(@RequestParam String phone) {
return Result.success(authService.listAvailableCompanies(phone));
}
@Operation(summary = "登录")
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
return Result.success(authService.login(request));
}
@Operation(summary = "修改密码")
@PostMapping("/change-password")
public Result<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request) {
authService.changePassword(UserContext.requireUser(), request);
return Result.success();
}
@Operation(summary = "退出登录")
@PostMapping("/logout")
public Result<Void> logout(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) {
authService.logout(extractToken(authorization));
return Result.success();
}
@Operation(summary = "获取当前登录态")
@GetMapping("/me")
public Result<CurrentUserResponse> currentUser() {
LoginUser loginUser = UserContext.requireUser();
return Result.success(CurrentUserResponse.from(loginUser));
}
private String extractToken(String authorization) {
return authorization.substring(7).trim();
}
}

View File

@@ -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<List<UserResponse>> listUsers() {
return Result.success(userService.listCompanyUsers(UserContext.requireUser()).stream().map(UserResponse::from).toList());
}
@Operation(summary = "创建当前公司员工")
@PostMapping
public Result<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
return Result.success(UserResponse.from(userService.createCompanyUser(UserContext.requireUser(), request)));
}
@Operation(summary = "修改员工角色和岗位")
@PutMapping("/{userId}/assignment")
public Result<Void> updateAssignment(@PathVariable Long userId, @Valid @RequestBody UpdateUserAssignmentRequest request) {
userService.updateAssignment(UserContext.requireUser(), userId, request);
return Result.success();
}
@Operation(summary = "修改员工状态")
@PutMapping("/{userId}/status")
public Result<Void> updateStatus(@PathVariable Long userId, @Valid @RequestBody UpdateUserStatusRequest request) {
userService.updateStatus(UserContext.requireUser(), userId, request);
return Result.success();
}
}

View File

@@ -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<List<MenuResponse>> currentMenus() {
// return Result.success(menuService.listCurrentMenus(UserContext.requireUser()));
// }
// }

View File

@@ -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<List<UserResponse>> listCompanyAdmins(@RequestParam Long companyId) {
return Result.success(userService.listCompanyAdmins(UserContext.requireUser(), companyId).stream().map(UserResponse::from).toList());
}
@Operation(summary = "创建公司管理员")
@PostMapping
public Result<UserResponse> createCompanyAdmin(@Valid @RequestBody CreateCompanyAdminRequest request) {
return Result.success(UserResponse.from(userService.createCompanyAdmin(UserContext.requireUser(), request)));
}
@Operation(summary = "修改公司管理员状态")
@PutMapping("/{companyId}/{userId}/status")
public Result<Void> updateCompanyAdminStatus(
@PathVariable Long companyId,
@PathVariable Long userId,
@Valid @RequestBody UpdateUserStatusRequest request
) {
userService.updateCompanyAdminStatus(UserContext.requireUser(), companyId, userId, request);
return Result.success();
}
}

View File

@@ -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<List<CompanyResponse>> listCompanies() {
return Result.success(companyService.listCompanies(UserContext.requireUser()).stream().map(CompanyResponse::from).toList());
}
@Operation(summary = "创建公司")
@PostMapping
public Result<CompanyResponse> createCompany(@Valid @RequestBody CreateCompanyRequest request) {
return Result.success(CompanyResponse.from(companyService.createCompany(UserContext.requireUser(), request)));
}
@Operation(summary = "修改公司状态")
@PutMapping("/{companyId}/status")
public Result<Void> updateCompanyStatus(@PathVariable Long companyId, @Valid @RequestBody UpdateCompanyStatusRequest request) {
companyService.updateStatus(UserContext.requireUser(), companyId, request);
return Result.success();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<BizDataRecord> {
List<BizDataRecord> listVisibleByEmployee(@Param("companyId") Long companyId, @Param("creatorId") Long creatorId);
List<BizDataRecord> listVisibleByManager(@Param("companyId") Long companyId);
List<BizDataRecord> listVisibleByEngineer(@Param("companyId") Long companyId);
}

View File

@@ -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<SysCompany> {
int insert(SysCompany company);
SysCompany findById(@Param("id") Long id);
SysCompany findByCompanyCode(@Param("companyCode") String companyCode);
List<SysCompany> findEnabledCompaniesByPhone(@Param("phone") String phone);
List<SysCompany> listAll();
int updateStatus(@Param("id") Long id, @Param("status") CompanyStatus status);
void deleteAll();
}

View File

@@ -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<SysMenu> {
List<SysMenu> listCurrentMenus(@Param("companyId") Long companyId, @Param("positionCodes") List<String> positionCodes);
}

View File

@@ -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<SysUser> {
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<SysUser> listByCompanyId(@Param("companyId") Long companyId);
List<SysUser> 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();
}

View File

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

View File

@@ -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<SysCompany> 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("仅平台管理员可操作");
}
}
}

View File

@@ -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<BizDataRecord> 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 <T> 数据类型
* @return 过滤后的数据列表
*/
public <T> List<T> filterByRole(
LoginUser currentUser,
List<T> allRecords,
Function<T, UserRole> roleExtractor,
Function<T, Long> 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<BizDataRecord> listVisibleRecordsGeneric(LoginUser currentUser, List<BizDataRecord> allRecords) {
// return filterByRole(
// currentUser,
// allRecords,
// BizDataRecord::
// BizDataRecord::getCreatorRole, // 提取创建者角色
// BizDataRecord::getCreatorId // 提取创建者ID
// );
// }
}

View File

@@ -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<MenuResponse> listCurrentMenus(LoginUser currentUser) {
List<String> 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();
}
}

View File

@@ -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<SysUser> listCompanyUsers(LoginUser currentUser) {
assertCompanyAdmin(currentUser);
return sysUserMapper.listByCompanyId(currentUser.companyId());
}
public List<SysUser> 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("仅公司管理员可操作");
}
}
}

View File

@@ -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<String, SessionEntry> sessions = new ConcurrentHashMap<>();
private final Map<Long, Set<String>> 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<LoginUser> 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<String> tokens = userTokens.get(removed.loginUser().userId());
if (tokens != null) {
tokens.remove(token);
}
}
}
@Override
public void removeAll(Long userId) {
Set<String> tokens = userTokens.remove(userId);
if (tokens != null) {
tokens.forEach(sessions::remove);
}
}
private record SessionEntry(LoginUser loginUser, Instant expiresAt) {
}
}

View File

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

View File

@@ -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<LoginUser> find(String token);
void refresh(String token, Duration ttl);
void remove(String token);
void removeAll(Long userId);
}

View File

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