From 92335758bd4e3099204e633523347a001f2ea65a Mon Sep 17 00:00:00 2001 From: wh Date: Tue, 12 May 2026 17:04:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=81=94=E8=B0=83?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit b9000e18d87a5b115e6da54de05f74916fcd91c0) --- src/main/java/com/labelsys/backend/service/UserService.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/com/labelsys/backend/service/UserService.java b/src/main/java/com/labelsys/backend/service/UserService.java index f38bda0..86f1ee4 100644 --- a/src/main/java/com/labelsys/backend/service/UserService.java +++ b/src/main/java/com/labelsys/backend/service/UserService.java @@ -43,12 +43,9 @@ public class UserService { public List listAllUsers(LoginUser currentUser) { try { assertSystemAdmin(currentUser); -// LambdaQueryWrapper wrapper = new LambdaQueryWrapper() -// .eq(SysUser::getPosition, UserPosition.ADMIN) // 添加岗位过滤 -// .orderByAsc(SysUser::getId); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .ne(SysUser::getId, currentUser.userId()) - .eq(SysUser::getPosition, UserPosition.ADMIN) + .in(SysUser::getPosition, UserPosition.ADMIN, UserPosition.SUPER_ADMIN) .orderByAsc(SysUser::getId); return sysUserMapper.selectList(wrapper); } catch (ForbiddenException e) { From c51f64522796242e340c2eb95a95dc527ab546f6 Mon Sep 17 00:00:00 2001 From: wh Date: Wed, 13 May 2026 23:29:43 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=96=B9=E6=B3=95=EF=BC=8C=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/config/MybatisPlusConfig.java | 23 +- .../backend/config/ObjectStorageConfig.java | 44 +- .../config/ObjectStorageProperties.java | 41 +- .../backend/config/OpenApiConfig.java | 19 +- .../backend/config/SecurityBeanConfig.java | 15 +- .../labelsys/backend/config/WebMvcConfig.java | 18 +- .../labelsys/backend/context/UserContext.java | 36 +- .../backend/interceptor/AuthInterceptor.java | 137 +++- .../AutoArchiveAnnotationResultJob.java | 17 +- .../service/AnnotationAgentConfigService.java | 126 ++-- .../AnnotationResultArchiveService.java | 124 ++-- .../service/AnnotationResultService.java | 639 ++++++++++-------- .../service/AnnotationTaskService.java | 160 ++++- .../labelsys/backend/service/AuthService.java | 74 +- .../backend/service/CompanyService.java | 145 ++-- .../service/DataPermissionService.java | 53 +- .../service/ObjectStorageInitializer.java | 45 +- .../backend/service/ObjectStorageService.java | 27 + .../service/RustfsObjectStorageService.java | 75 +- .../service/SourceResourceService.java | 164 ++++- .../backend/service/SysConfigService.java | 84 ++- .../labelsys/backend/service/UserService.java | 128 +++- .../labelsys/backend/util/IdGenerator.java | 21 +- .../util/ObjectStoragePathBuilder.java | 71 +- 24 files changed, 1728 insertions(+), 558 deletions(-) diff --git a/src/main/java/com/labelsys/backend/config/MybatisPlusConfig.java b/src/main/java/com/labelsys/backend/config/MybatisPlusConfig.java index 7e1fc34..7b5a0c8 100644 --- a/src/main/java/com/labelsys/backend/config/MybatisPlusConfig.java +++ b/src/main/java/com/labelsys/backend/config/MybatisPlusConfig.java @@ -6,17 +6,38 @@ import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerIntercept import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * MyBatis Plus 配置类 + * + *

配置 MyBatis Plus 的增强功能,包括分页插件等。 + */ @Configuration public class MybatisPlusConfig { + /** + * 配置 MyBatis Plus 拦截器 + * + *

注册分页拦截器,支持 PostgreSQL 数据库的分页查询。 + * 设置分页溢出处理为 false(超出页数返回空页),最大分页限制为 200 条。 + * + * @return MyBatis Plus 拦截器 + */ @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + + // 创建分页拦截器,指定数据库类型为 PostgreSQL PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.POSTGRE_SQL); + + // 设置分页溢出处理:false 表示超出页数时返回空页,不进行页码修正 paginationInnerInterceptor.setOverflow(false); + + // 设置最大分页限制:200 条,防止一次性查询过多数据 paginationInnerInterceptor.setMaxLimit(200L); + interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } -} + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/config/ObjectStorageConfig.java b/src/main/java/com/labelsys/backend/config/ObjectStorageConfig.java index 161698b..297f179 100644 --- a/src/main/java/com/labelsys/backend/config/ObjectStorageConfig.java +++ b/src/main/java/com/labelsys/backend/config/ObjectStorageConfig.java @@ -1,6 +1,5 @@ package com.labelsys.backend.config; -import java.net.URI; import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -11,21 +10,50 @@ import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Configuration; +import java.net.URI; + +/** + * 对象存储配置类 + * + *

配置 AWS S3 客户端,用于与对象存储服务(如 MinIO、AWS S3)进行交互。 + * 通过配置属性读取 endpoint、region、accessKey、secretKey 等参数。 + */ @Configuration @RequiredArgsConstructor @EnableConfigurationProperties(ObjectStorageProperties.class) public class ObjectStorageConfig { + /** + * 对象存储配置属性 + */ private final ObjectStorageProperties properties; + /** + * 创建并配置 S3 客户端 + * + * 配置内容包括: + * - endpoint: 对象存储服务地址 + * - region: 区域标识 + * - credentials: 访问密钥(accessKey 和 secretKey) + * - pathStyleAccess: 是否启用路径风格访问 + * + * @return 配置完成的 S3 客户端 + */ @Bean public S3Client s3Client() { return S3Client.builder() - .endpointOverride(URI.create(properties.getEndpoint())) - .region(Region.of(properties.getRegion())) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))) - .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(properties.isPathStyleAccess()).build()) - .build(); + // 设置自定义 endpoint(支持非 AWS S3 服务如 MinIO) + .endpointOverride(URI.create(properties.getEndpoint())) + // 设置区域 + .region(Region.of(properties.getRegion())) + // 设置凭证提供者 + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))) + // 配置路径风格访问(对于 MinIO 等自建存储服务通常需要启用) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(properties.isPathStyleAccess()) + .build()) + .build(); } -} + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/config/ObjectStorageProperties.java b/src/main/java/com/labelsys/backend/config/ObjectStorageProperties.java index b3ca457..036e59f 100644 --- a/src/main/java/com/labelsys/backend/config/ObjectStorageProperties.java +++ b/src/main/java/com/labelsys/backend/config/ObjectStorageProperties.java @@ -3,15 +3,54 @@ package com.labelsys.backend.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; +/** + * 对象存储配置属性类 + * + * 用于读取 `labelsys.object-storage` 前缀的配置项, + * 包括连接信息和存储桶配置。 + */ @Data @ConfigurationProperties(prefix = "labelsys.object-storage") public class ObjectStorageProperties { + + /** + * 对象存储服务地址 + */ private String endpoint; + + /** + * 区域标识 + */ private String region; + + /** + * 访问密钥 ID + */ private String accessKey; + + /** + * 访问密钥 + */ private String secretKey; + + /** + * 是否启用路径风格访问(默认 true,适用于 MinIO 等自建存储) + */ private boolean pathStyleAccess = true; + + /** + * 源文件存储桶名称 + */ private String sourceBucket; + + /** + * 产物存储桶名称 + */ private String artifactBucket; + + /** + * 导出文件存储桶名称 + */ private String exportBucket; -} + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/config/OpenApiConfig.java b/src/main/java/com/labelsys/backend/config/OpenApiConfig.java index 783157d..9db35e8 100644 --- a/src/main/java/com/labelsys/backend/config/OpenApiConfig.java +++ b/src/main/java/com/labelsys/backend/config/OpenApiConfig.java @@ -5,14 +5,25 @@ import io.swagger.v3.oas.models.info.Info; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * OpenAPI (Swagger) 配置类 + * + * 配置 API 文档的基本信息,包括标题、版本和描述。 + */ @Configuration public class OpenApiConfig { + /** + * 创建 OpenAPI 文档配置 + * + * @return OpenAPI 配置实例 + */ @Bean public OpenAPI labelsysOpenApi() { return new OpenAPI().info(new Info() - .title("LabelSys 后台认证鉴权接口") - .version("0.0.1") - .description("公司、员工、认证、岗位权限和数据权限接口")); + .title("LabelSys 后台认证鉴权接口") + .version("0.0.1") + .description("公司、员工、认证、岗位权限和数据权限接口")); } -} + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/config/SecurityBeanConfig.java b/src/main/java/com/labelsys/backend/config/SecurityBeanConfig.java index a3ce514..69b4690 100644 --- a/src/main/java/com/labelsys/backend/config/SecurityBeanConfig.java +++ b/src/main/java/com/labelsys/backend/config/SecurityBeanConfig.java @@ -5,11 +5,24 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +/** + * 安全配置类 + * + * 配置安全相关的 Bean,如密码编码器等。 + */ @Configuration public class SecurityBeanConfig { + /** + * 创建密码编码器 + * + *

使用 BCrypt 算法对密码进行加密,BCrypt 是一种安全的密码哈希算法, + * 具有自动加盐和可配置的计算复杂度特性。 + * + * @return BCryptPasswordEncoder 实例 + */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/config/WebMvcConfig.java b/src/main/java/com/labelsys/backend/config/WebMvcConfig.java index d973380..26e3ca4 100644 --- a/src/main/java/com/labelsys/backend/config/WebMvcConfig.java +++ b/src/main/java/com/labelsys/backend/config/WebMvcConfig.java @@ -6,14 +6,30 @@ import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +/** + * Web MVC 配置类 + *

+ * 实现 WebMvcConfigurer 接口,配置 Spring MVC 拦截器等功能。 + */ @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { + /** + * 认证拦截器 + */ private final AuthInterceptor authInterceptor; + /** + * 注册拦截器 + *

+ * 将认证拦截器注册到拦截器链中,拦截所有 `/api/**` 路径的请求。 + * + * @param registry 拦截器注册器 + */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor).addPathPatterns("/api/**"); } -} + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/context/UserContext.java b/src/main/java/com/labelsys/backend/context/UserContext.java index 201cf25..ccbfebe 100644 --- a/src/main/java/com/labelsys/backend/context/UserContext.java +++ b/src/main/java/com/labelsys/backend/context/UserContext.java @@ -1,28 +1,62 @@ package com.labelsys.backend.context; import com.labelsys.backend.common.exception.UnauthorizedException; + import java.util.Optional; +/** + * 用户上下文管理类 + * + * 使用 ThreadLocal 存储当前线程的登录用户信息, + * 实现请求级别的用户上下文传递。 + */ public final class UserContext { + /** + * 用户上下文持有者,存储当前线程的登录用户 + */ private static final ThreadLocal HOLDER = new ThreadLocal<>(); + /** + * 私有构造函数,防止实例化 + */ private UserContext() { } + /** + * 设置当前登录用户 + * + * @param loginUser 登录用户信息 + */ public static void set(LoginUser loginUser) { HOLDER.set(loginUser); } + /** + * 获取当前登录用户 + * + * @return 当前登录用户的 Optional 对象,未登录时返回 empty + */ public static Optional get() { return Optional.ofNullable(HOLDER.get()); } + /** + * 获取当前登录用户,未登录时抛出异常 + * + * @return 当前登录用户 + * @throws UnauthorizedException 未登录或登录已过期时抛出 + */ public static LoginUser requireUser() { return get().orElseThrow(() -> new UnauthorizedException("未登录或登录已过期")); } + /** + * 清理用户上下文 + * + * 在请求结束时调用,防止 ThreadLocal 内存泄漏 + */ public static void clear() { HOLDER.remove(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java b/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java index 8dadd0d..c326f24 100644 --- a/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java +++ b/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java @@ -1,13 +1,5 @@ package com.labelsys.backend.interceptor; -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; - import com.labelsys.backend.annotation.RequirePosition; import com.labelsys.backend.common.exception.ForbiddenException; import com.labelsys.backend.common.exception.UnauthorizedException; @@ -20,84 +12,176 @@ 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 org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import java.time.Duration; +import java.util.Set; + +/** + * 认证拦截器 + * + *

实现 HandlerInterceptor 接口,用于在请求处理前进行身份认证和权限校验。 + * 负责验证用户登录状态、会话有效性、密码修改强制要求以及岗位权限检查。 + */ @Component public class AuthInterceptor implements HandlerInterceptor { + /** + * 公开路径集合(无需登录即可访问) + * 包括认证相关接口和 Swagger 文档接口 + */ private static final Set OPEN_PATHS = Set.of( - "/api/auth/companies", "/label/api/auth/companies", - "/api/auth/login", "/label/api/auth/login", - "/swagger-ui.html", "/label/swagger-ui.html", - "/v3/api-docs", "/label/v3/api-docs", - "/v3/api-docs/swagger-config", "/label/v3/api-docs/swagger-config"); + "/api/auth/companies", "/label/api/auth/companies", + "/api/auth/login", "/label/api/auth/login", + "/swagger-ui.html", "/label/swagger-ui.html", + "/v3/api-docs", "/label/v3/api-docs", + "/v3/api-docs/swagger-config", "/label/v3/api-docs/swagger-config"); + /** + * 强制修改密码时允许访问的路径集合 + * 包括修改密码、登出和获取当前用户信息接口 + */ private static final Set ALLOWED_WHEN_MUST_CHANGE_PASSWORD = Set.of( - "/api/auth/change-password", "/label/api/auth/change-password", - "/api/auth/logout", "/label/api/auth/logout", - "/api/auth/me", "/label/api/auth/me"); + "/api/auth/change-password", "/label/api/auth/change-password", + "/api/auth/logout", "/label/api/auth/logout", + "/api/auth/me", "/label/api/auth/me"); + /** + * Token会话存储仓库 + */ private final TokenSessionRepository tokenSessionRepository; + + /** + * 用户数据访问层 + */ private final SysUserMapper sysUserMapper; + + /** + * 公司数据访问层 + */ private final SysCompanyMapper sysCompanyMapper; + + /** + * 会话有效期 + */ private final Duration sessionTtl; + /** + * 构造函数 + * + * @param tokenSessionRepository Token会话存储仓库 + * @param sysUserMapper 用户数据访问层 + * @param sysCompanyMapper 公司数据访问层 + * @param sessionTtl 会话有效期(默认2小时) + */ public AuthInterceptor(TokenSessionRepository tokenSessionRepository, SysUserMapper sysUserMapper, - SysCompanyMapper sysCompanyMapper, @Value("${labelsys.session.ttl:PT2H}") Duration sessionTtl) { + SysCompanyMapper sysCompanyMapper, + @Value("${labelsys.session.ttl:PT2H}") Duration sessionTtl) { this.tokenSessionRepository = tokenSessionRepository; this.sysUserMapper = sysUserMapper; this.sysCompanyMapper = sysCompanyMapper; this.sessionTtl = sessionTtl; } + /** + * 请求处理前的认证拦截 + * + *

执行以下校验逻辑: + * 1. OPTIONS 请求直接放行 + * 2. 公开路径和 Swagger 路径直接放行 + * 3. 非 HandlerMethod 请求直接放行 + * 4. 验证 Token 并获取登录用户 + * 5. 验证用户和公司状态 + * 6. 验证会话版本(密码修改后会话失效) + * 7. 检查强制修改密码状态 + * 8. 设置用户上下文并刷新会话 + * 9. 检查岗位权限 + * + * @param request HTTP请求 + * @param response HTTP响应 + * @param handler 处理器 + * @return 是否放行 + */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // OPTIONS 请求直接放行(CORS预检请求) if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } + String path = request.getRequestURI(); + // Swagger 相关路径和公开路径直接放行 if (path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || OPEN_PATHS.contains(path)) { return true; } + + // 非 HandlerMethod 请求直接放行 if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } + + // 提取 Token 并验证会话 String token = extractToken(request.getHeader("Authorization")); LoginUser loginUser = - tokenSessionRepository.find(token).orElseThrow(() -> new UnauthorizedException("未登录或登录已过期")); + tokenSessionRepository.find(token).orElseThrow(() -> new UnauthorizedException("未登录或登录已过期")); + // 验证用户和公司状态 SysUser user = sysUserMapper.findByIdAndCompanyId(loginUser.userId(), loginUser.companyId()); SysCompany company = sysCompanyMapper.selectById(loginUser.companyId()); if (user == null || company == null || user.getStatus() != UserStatus.ENABLED - || company.getStatus() != CompanyStatus.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; } + /** + * 请求处理完成后清理用户上下文 + * + * @param request HTTP请求 + * @param response HTTP响应 + * @param handler 处理器 + * @param ex 异常(如果有) + */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, - Exception ex) { + Exception ex) { UserContext.clear(); } + /** + * 从 Authorization 头中提取 Token + * + * @param authorization Authorization 头值 + * @return Token + */ private String extractToken(String authorization) { if (authorization == null || !authorization.startsWith("Bearer ")) { throw new UnauthorizedException("未登录或登录已过期"); @@ -105,6 +189,14 @@ public class AuthInterceptor implements HandlerInterceptor { return authorization.substring(7).trim(); } + /** + * 解析方法或类上的 @RequirePosition 注解 + * + *

优先从方法上获取注解,若方法上没有则从类上获取。 + * + * @param handlerMethod 处理器方法 + * @return RequirePosition 注解(可能为null) + */ private RequirePosition resolveRequirePosition(HandlerMethod handlerMethod) { RequirePosition methodAnnotation = handlerMethod.getMethodAnnotation(RequirePosition.class); if (methodAnnotation != null) { @@ -112,4 +204,5 @@ public class AuthInterceptor implements HandlerInterceptor { } return handlerMethod.getBeanType().getAnnotation(RequirePosition.class); } -} + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/scheduled/AutoArchiveAnnotationResultJob.java b/src/main/java/com/labelsys/backend/scheduled/AutoArchiveAnnotationResultJob.java index f18bbd1..d7245f9 100644 --- a/src/main/java/com/labelsys/backend/scheduled/AutoArchiveAnnotationResultJob.java +++ b/src/main/java/com/labelsys/backend/scheduled/AutoArchiveAnnotationResultJob.java @@ -6,13 +6,28 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +/** + * 标注结果自动归档定时任务 + *

+ * 定期检查并归档符合条件的标注结果, + * 将已完成且超过自动归档超时时间的结果归档到历史表。 + */ @Slf4j @Component @RequiredArgsConstructor public class AutoArchiveAnnotationResultJob { + /** + * 标注结果归档服务 + */ private final AnnotationResultArchiveService annotationResultArchiveService; + /** + * 自动归档符合条件的标注结果 + *

+ * 定时执行,默认每5分钟执行一次,可通过配置调整执行间隔。 + * 归档完成后记录日志。 + */ @Scheduled(fixedDelayString = "${labelsys.annotation.auto-archive-fixed-delay:300000}") public void autoArchiveEligibleResults() { int archivedCount = annotationResultArchiveService.autoArchiveEligibleResults(); @@ -20,4 +35,4 @@ public class AutoArchiveAnnotationResultJob { log.info("auto archived annotation results, count={}", archivedCount); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/AnnotationAgentConfigService.java b/src/main/java/com/labelsys/backend/service/AnnotationAgentConfigService.java index 764a61c..2f8c890 100644 --- a/src/main/java/com/labelsys/backend/service/AnnotationAgentConfigService.java +++ b/src/main/java/com/labelsys/backend/service/AnnotationAgentConfigService.java @@ -26,19 +26,36 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; -@Slf4j -@Service -@Transactional -@RequiredArgsConstructor -public class AnnotationAgentConfigService { +/** + * 标注代理配置服务 + * + *

提供标注代理配置的保存、查询等核心业务操作, + * 支持多种Agent类型的配置管理,关联模型配置和提示词配置。 + */ + @Slf4j + @Service + @Transactional + @RequiredArgsConstructor + public class AnnotationAgentConfigService { - private final AnnotationAgentConfigMapper agentConfigMapper; + /** + * 标注代理配置数据访问层 + */ + private final AnnotationAgentConfigMapper agentConfigMapper; - private final SysConfigService sysConfigService; + /** + * 系统配置服务 + */ + private final SysConfigService sysConfigService; - // 保存多个Agent配置 - // 保存多个Agent配置 - public AgentConfigListResponse saveAgentConfigs(LoginUser user, SaveAgentConfigRequest request) { + /** + * 保存多个Agent配置 + * + * @param user 当前登录用户 + * @param request 保存代理配置请求 + * @return 代理配置列表响应 + */ + public AgentConfigListResponse saveAgentConfigs(LoginUser user, SaveAgentConfigRequest request) { // 遍历所有Agent配置项 for (Map.Entry entry : request.getAgentConfigs().entrySet()) { String agentType = entry.getKey(); @@ -109,48 +126,67 @@ public class AnnotationAgentConfigService { return getAgentConfigsForCompany(user.companyId()); } - // 从模型配置值中提取API密钥 - private String extractApiKeyFromConfig(String configValue) { - try { - ObjectMapper objectMapper = new ObjectMapper(); - LlmConfigModel llmConfig = objectMapper.readValue(configValue, LlmConfigModel.class); - return llmConfig != null ? llmConfig.getApiKey() : null; - } catch (Exception e) { - return null; + /** + * 从模型配置值中提取API密钥 + * + * @param configValue 配置值 + * @return API密钥 + */ + private String extractApiKeyFromConfig(String configValue) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + LlmConfigModel llmConfig = objectMapper.readValue(configValue, LlmConfigModel.class); + return llmConfig != null ? llmConfig.getApiKey() : null; + } catch (Exception e) { + return null; + } } - } - - // 从模型配置值中提取模型URL - private String extractModelUrlFromConfig(String configValue) { - try { - ObjectMapper objectMapper = new ObjectMapper(); - LlmConfigModel llmConfig = objectMapper.readValue(configValue, LlmConfigModel.class); - return llmConfig != null ? llmConfig.getModelUrl() : null; - } catch (Exception e) { - return null; + /** + * 从模型配置值中提取模型URL + * + * @param configValue 配置值 + * @return 模型URL + */ + private String extractModelUrlFromConfig(String configValue) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + LlmConfigModel llmConfig = objectMapper.readValue(configValue, LlmConfigModel.class); + return llmConfig != null ? llmConfig.getModelUrl() : null; + } catch (Exception e) { + return null; + } } - } - // 从模型配置值中提取LLM类型 - private String extractLlmTypeFromConfig(String configValue) { - try { - ObjectMapper objectMapper = new ObjectMapper(); - LlmConfigModel llmConfig = objectMapper.readValue(configValue, LlmConfigModel.class); - return llmConfig != null ? llmConfig.getLlmType() : null; - } catch (Exception e) { - return null; + /** + * 从模型配置值中提取LLM类型 + * + * @param configValue 配置值 + * @return LLM类型 + */ + private String extractLlmTypeFromConfig(String configValue) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + LlmConfigModel llmConfig = objectMapper.readValue(configValue, LlmConfigModel.class); + return llmConfig != null ? llmConfig.getLlmType() : null; + } catch (Exception e) { + return null; + } } - } - // 获取提示词文本 - private String getPromptText(Long promptConfigId) { - if (promptConfigId == null) { - return null; + /** + * 获取提示词文本 + * + * @param promptConfigId 提示词配置ID + * @return 提示词文本 + */ + private String getPromptText(Long promptConfigId) { + if (promptConfigId == null) { + return null; + } + SysConfig promptConfig = sysConfigService.getById(promptConfigId); + return promptConfig != null ? promptConfig.getConfigValue() : null; } - SysConfig promptConfig = sysConfigService.getById(promptConfigId); - return promptConfig != null ? promptConfig.getConfigValue() : null; - } // 获取特定Agent类型的配置实体 public AnnotationAgentConfig getAgentConfigEntity(Long companyId, String agentType) { diff --git a/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java b/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java index 671ee9f..99c5a51 100644 --- a/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java +++ b/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java @@ -32,56 +32,94 @@ import java.util.List; import static org.springframework.util.StringUtils.hasText; -@Slf4j -@Service -@RequiredArgsConstructor -public class AnnotationResultArchiveService { +/** + * 标注结果归档服务 + * + *

提供标注结果历史记录的分页查询、详情获取、自动归档等核心业务操作, + * 支持从对象存储加载QA内容并归档到历史表。 + */ + @Slf4j + @Service + @RequiredArgsConstructor + public class AnnotationResultArchiveService { - private static final String MANUAL_ARCHIVE_REASON = "MANUAL_REVIEW"; + /** + * 人工归档原因标识 + */ + private static final String MANUAL_ARCHIVE_REASON = "MANUAL_REVIEW"; - private final AnnotationResultMapper annotationResultMapper; - private final AnnotationResultHistoryMapper annotationResultHistoryMapper; - private final ObjectStorageService objectStorageService; - private final ObjectMapper objectMapper; - private final DataPermissionService dataPermissionService; + /** + * 标注结果数据访问层 + */ + private final AnnotationResultMapper annotationResultMapper; - @Value("${labelsys.annotation.auto-archive-timeout:PT2H}") - private Duration autoArchiveTimeout; + /** + * 标注结果历史数据访问层 + */ + private final AnnotationResultHistoryMapper annotationResultHistoryMapper; - public PageResult pageHistory(LoginUser currentUser, - AnnotationResultHistoryPageQuery query) { - try { - List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); - boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); + /** + * 对象存储服务 + */ + private final ObjectStorageService objectStorageService; - var wrapper = new LambdaQueryWrapper() - .eq(AnnotationResultHistory::getCompanyId, currentUser.companyId()) - .eq(query.taskId() != null, AnnotationResultHistory::getTaskId, query.taskId()) - .eq(query.resourceId() != null, AnnotationResultHistory::getResourceId, query.resourceId()) - .orderByDesc(AnnotationResultHistory::getCreatedAt); + /** + * JSON 对象映射器 + */ + private final ObjectMapper objectMapper; - if (shouldFilterByUserId) { - wrapper.eq(AnnotationResultHistory::getCreatorId, currentUser.userId()); - } else if (!allowedRoles.isEmpty()) { - wrapper.in(AnnotationResultHistory::getCreatorRole, allowedRoles); + /** + * 数据权限服务 + */ + private final DataPermissionService dataPermissionService; + + /** + * 自动归档超时时间(默认2小时) + */ + @Value("${labelsys.annotation.auto-archive-timeout:PT2H}") + private Duration autoArchiveTimeout; + + /** + * 分页查询归档历史记录 + * + * @param currentUser 当前登录用户 + * @param query 分页查询条件 + * @return 分页历史记录列表 + */ + public PageResult pageHistory(LoginUser currentUser, + AnnotationResultHistoryPageQuery query) { + try { + List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); + boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); + + var wrapper = new LambdaQueryWrapper() + .eq(AnnotationResultHistory::getCompanyId, currentUser.companyId()) + .eq(query.taskId() != null, AnnotationResultHistory::getTaskId, query.taskId()) + .eq(query.resourceId() != null, AnnotationResultHistory::getResourceId, query.resourceId()) + .orderByDesc(AnnotationResultHistory::getCreatedAt); + + if (shouldFilterByUserId) { + wrapper.eq(AnnotationResultHistory::getCreatorId, currentUser.userId()); + } else if (!allowedRoles.isEmpty()) { + wrapper.in(AnnotationResultHistory::getCreatorRole, allowedRoles); + } + + var page = new Page(query.pageNo(), query.pageSize()); + var resultPage = annotationResultHistoryMapper.selectPage(page, wrapper); + + // 分页查询不加载 qa 内容 + var records = resultPage.getRecords().stream() + .map(this::toResponse) + .toList(); + + return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), + (int) resultPage.getSize()); + } catch (Exception e) { + log.error("pageHistory failed, companyId={}, userId={}, error={}", + currentUser.companyId(), currentUser.userId(), e.getMessage(), e); + throw e; } - - var page = new Page(query.pageNo(), query.pageSize()); - var resultPage = annotationResultHistoryMapper.selectPage(page, wrapper); - - // 分页查询不加载 qa 内容 - var records = resultPage.getRecords().stream() - .map(this::toResponse) - .toList(); - - return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), - (int) resultPage.getSize()); - } catch (Exception e) { - log.error("pageHistory failed, companyId={}, userId={}, error={}", - currentUser.companyId(), currentUser.userId(), e.getMessage(), e); - throw e; } - } public AnnotationResultHistoryDetailResponse getHistory(LoginUser currentUser, Long historyId) { try { @@ -366,4 +404,4 @@ public class AnnotationResultArchiveService { } } -} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/AnnotationResultService.java b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java index c420df4..21b9097 100644 --- a/src/main/java/com/labelsys/backend/service/AnnotationResultService.java +++ b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java @@ -32,324 +32,401 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; -@Slf4j -@Service -@RequiredArgsConstructor -public class AnnotationResultService { +/** + * 标注结果服务 + * + *

提供标注结果的分页查询、详情获取、审核对比、审核结果合并等核心业务操作, + * 支持标注结果的归档管理和权限控制。 + */ + @Slf4j + @Service + @RequiredArgsConstructor + public class AnnotationResultService { - private final AnnotationResultMapper annotationResultMapper; - private final AnnotationResultHistoryMapper annotationResultHistoryMapper; - private final SourceResourceMapper sourceResourceMapper; - private final DataPermissionService dataPermissionService; - private final ObjectStorageService objectStorageService; - private final ObjectMapper objectMapper; + /** + * 标注结果数据访问层 + */ + private final AnnotationResultMapper annotationResultMapper; - public PageResult pageResults(LoginUser currentUser, AnnotationResultPageQuery query) { - try { - List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); - boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); + /** + * 标注结果历史数据访问层 + */ + private final AnnotationResultHistoryMapper annotationResultHistoryMapper; - var wrapper = new LambdaQueryWrapper() - .eq(AnnotationResult::getIsDeleted, Boolean.FALSE) - .eq(AnnotationResult::getCompanyId, currentUser.companyId()) - .eq(query.taskId() != null, AnnotationResult::getTaskId, query.taskId()) - .eq(query.resourceId() != null, AnnotationResult::getResourceId, query.resourceId()) - .eq(query.requiresManualReview() != null, AnnotationResult::getRequiresManualReview, - query.requiresManualReview()) - .orderByDesc(AnnotationResult::getCreatedAt); + /** + * 源资源数据访问层 + */ + private final SourceResourceMapper sourceResourceMapper; - if (shouldFilterByUserId) { - wrapper.eq(AnnotationResult::getCreatorId, currentUser.userId()); - } else if (!allowedRoles.isEmpty()) { - wrapper.in(AnnotationResult::getCreatorRole, allowedRoles); + /** + * 数据权限服务 + */ + private final DataPermissionService dataPermissionService; + + /** + * 对象存储服务 + */ + private final ObjectStorageService objectStorageService; + + /** + * JSON 对象映射器 + */ + private final ObjectMapper objectMapper; + + /** + * 分页查询标注结果列表 + * + * @param currentUser 当前登录用户 + * @param query 分页查询条件 + * @return 分页结果列表 + */ + public PageResult pageResults(LoginUser currentUser, AnnotationResultPageQuery query) { + try { + List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); + boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); + + var wrapper = new LambdaQueryWrapper() + .eq(AnnotationResult::getIsDeleted, Boolean.FALSE) + .eq(AnnotationResult::getCompanyId, currentUser.companyId()) + .eq(query.taskId() != null, AnnotationResult::getTaskId, query.taskId()) + .eq(query.resourceId() != null, AnnotationResult::getResourceId, query.resourceId()) + .eq(query.requiresManualReview() != null, AnnotationResult::getRequiresManualReview, + query.requiresManualReview()) + .orderByDesc(AnnotationResult::getCreatedAt); + + if (shouldFilterByUserId) { + wrapper.eq(AnnotationResult::getCreatorId, currentUser.userId()); + } else if (!allowedRoles.isEmpty()) { + wrapper.in(AnnotationResult::getCreatorRole, allowedRoles); + } + + var page = new Page(query.pageNo(), query.pageSize()); + var resultPage = annotationResultMapper.selectPage(page, wrapper); + + var records = resultPage.getRecords().stream() + .map(this::toResponse) + .filter(response -> query.runtimeStatus() == null + || query.runtimeStatus().equals(response.runtimeStatus().name())) + .toList(); + + return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), + (int) resultPage.getSize()); + } catch (Exception e) { + log.error("pageResults failed, companyId={}, userId={}, error={}", + currentUser.companyId(), currentUser.userId(), e.getMessage(), e); + throw e; } - - var page = new Page(query.pageNo(), query.pageSize()); - var resultPage = annotationResultMapper.selectPage(page, wrapper); - - var records = resultPage.getRecords().stream() - .map(this::toResponse) - .filter(response -> query.runtimeStatus() == null - || query.runtimeStatus().equals(response.runtimeStatus().name())) - .toList(); - - return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), - (int) resultPage.getSize()); - } catch (Exception e) { - log.error("pageResults failed, companyId={}, userId={}, error={}", - currentUser.companyId(), currentUser.userId(), e.getMessage(), e); - throw e; } - } - public AnnotationResultDetailResponse getResult(LoginUser currentUser, Long resultId) { - try { - AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, - currentUser.companyId()); - if (result == null) { - log.warn("Result not found or cross-tenant access attempt: resultId={}, companyId={}, userId={}", - resultId, - currentUser.companyId(), currentUser.userId()); - throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); + /** + * 获取标注结果详情 + * + * @param currentUser 当前登录用户 + * @param resultId 结果ID + * @return 结果详情响应 + */ + public AnnotationResultDetailResponse getResult(LoginUser currentUser, Long resultId) { + try { + AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, + currentUser.companyId()); + if (result == null) { + log.warn("Result not found or cross-tenant access attempt: resultId={}, companyId={}, userId={}", + resultId, + currentUser.companyId(), currentUser.userId()); + throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); + } + //assertResultPermission(currentUser, result); + + // 加载文件内容 + QaContent qaContent = loadQaContent(result); + DiffContent diffContent = Boolean.TRUE.equals(result.getRequiresManualReview()) ? + loadDiffSummary(result) : null; + + return toDetailResponse(result, qaContent, diffContent); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("getResult failed, companyId={}, userId={}, resultId={}, error={}", + currentUser.companyId(), currentUser.userId(), resultId, e.getMessage(), e); + throw e; } - //assertResultPermission(currentUser, result); - - // 加载文件内容 - QaContent qaContent = loadQaContent(result); - DiffContent diffContent = Boolean.TRUE.equals(result.getRequiresManualReview()) ? - loadDiffSummary(result) : null; - - return toDetailResponse(result, qaContent, diffContent); - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - log.error("getResult failed, companyId={}, userId={}, resultId={}, error={}", - currentUser.companyId(), currentUser.userId(), resultId, e.getMessage(), e); - throw e; } - } - private AnnotationResultDetailResponse toDetailResponse(AnnotationResult result, - QaContent qaContent, DiffContent diffContent) { + /** + * 转换为详情响应对象 + * + * @param result 标注结果实体 + * @param qaContent QA内容 + * @param diffContent 差异内容 + * @return 详情响应对象 + */ + private AnnotationResultDetailResponse toDetailResponse(AnnotationResult result, + QaContent qaContent, DiffContent diffContent) { - // 转换 QA 内容(仅保留 records) - AnnotationResultDetailResponse.QaContentDto qaContentDto = new AnnotationResultDetailResponse.QaContentDto( - qaContent.records().stream() - .map(r -> new AnnotationResultDetailResponse.QaRecordDto( - r.id(), - r.batchId(), - r.question(), - r.answer(), - r.requiresReview(), - r.sourceSegments() != null ? new AnnotationResultDetailResponse.SourceSegmentsDto( - r.sourceSegments().segment(), - r.sourceSegments().chunkIndex(), - r.sourceSegments().chunkTitle(), - r.sourceSegments().chunkContent()) : null, - r.questionCategory(), - r.scores() != null ? new AnnotationResultDetailResponse.ScoresDto( - r.scores().similarity(), - r.scores().confidence1(), - r.scores().confidence2(), - r.scores().hallucination(), - r.scores().trust()) : null, - r.reviewComment())) - .toList() - ); - - // 转换差异内容(仅保留 records) - AnnotationResultDetailResponse.DiffContentDto diffContentDto = null; - if (diffContent != null) { - diffContentDto = new AnnotationResultDetailResponse.DiffContentDto( - diffContent.records().stream() - .map(r -> new AnnotationResultDetailResponse.DiffRecordDto( - r.qaId(), r.question(), r.extractAnswer(), - r.verifyAnswer(), r.diffReason(), r.mergedAnswer(), + // 转换 QA 内容(仅保留 records) + AnnotationResultDetailResponse.QaContentDto qaContentDto = new AnnotationResultDetailResponse.QaContentDto( + qaContent.records().stream() + .map(r -> new AnnotationResultDetailResponse.QaRecordDto( + r.id(), + r.batchId(), + r.question(), + r.answer(), + r.requiresReview(), + r.sourceSegments() != null ? new AnnotationResultDetailResponse.SourceSegmentsDto( + r.sourceSegments().segment(), + r.sourceSegments().chunkIndex(), + r.sourceSegments().chunkTitle(), + r.sourceSegments().chunkContent()) : null, r.questionCategory(), r.scores() != null ? new AnnotationResultDetailResponse.ScoresDto( r.scores().similarity(), r.scores().confidence1(), r.scores().confidence2(), r.scores().hallucination(), - r.scores().trust()) : null)) + r.scores().trust()) : null, + r.reviewComment())) .toList() ); - } - return new AnnotationResultDetailResponse( - result.getId(), - result.getTaskId(), - result.getTaskName(), - result.getResourceId(), - result.getResourceName(), - deriveStatus(result), - result.getRequiresManualReview(), - result.getIsDeleted(), - result.getQaContentFilePath(), - result.getDiffSummaryFilePath(), - qaContentDto, - diffContentDto, - result.getCreatedAt() - ); - } - - public AnnotationResultCompareResponse compareResult(LoginUser currentUser, Long resultId) { - try { - AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, - currentUser.companyId()); - if (result == null) { - log.warn("Result not found or cross-tenant access attempt: resultId={}, companyId={}, userId={}", - resultId, - currentUser.companyId(), currentUser.userId()); - throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); + // 转换差异内容(仅保留 records) + AnnotationResultDetailResponse.DiffContentDto diffContentDto = null; + if (diffContent != null) { + diffContentDto = new AnnotationResultDetailResponse.DiffContentDto( + diffContent.records().stream() + .map(r -> new AnnotationResultDetailResponse.DiffRecordDto( + r.qaId(), r.question(), r.extractAnswer(), + r.verifyAnswer(), r.diffReason(), r.mergedAnswer(), + r.questionCategory(), + r.scores() != null ? new AnnotationResultDetailResponse.ScoresDto( + r.scores().similarity(), + r.scores().confidence1(), + r.scores().confidence2(), + r.scores().hallucination(), + r.scores().trust()) : null)) + .toList() + ); } - //assertResultPermission(currentUser, result); - QaContent qaContent = loadQaContent(result); - DiffContent diffContent = Boolean.TRUE.equals(result.getRequiresManualReview()) ? - loadDiffSummary(result) : null; - - SourceResource resource = sourceResourceMapper.selectById(result.getResourceId()); - - // 转换 QA 记录 - List qaRecords = qaContent.records().stream() - .map(qa -> new AnnotationResultCompareResponse.QaRecord( - qa.id(), - qa.batchId(), - qa.question(), - qa.answer(), - qa.requiresReview(), - qa.sourceSegments() != null ? new AnnotationResultCompareResponse.SourceSegments( - qa.sourceSegments().segment(), - qa.sourceSegments().chunkIndex(), - qa.sourceSegments().chunkTitle(), - qa.sourceSegments().chunkContent()) : null, - qa.questionCategory(), - qa.scores() != null ? new AnnotationResultCompareResponse.Scores( - qa.scores().similarity(), - qa.scores().confidence1(), - qa.scores().confidence2(), - qa.scores().hallucination(), - qa.scores().trust()) : null, - qa.reviewComment() - )).toList(); - - // 转换差异记录 - List diffRecords = diffContent != null ? - diffContent.records().stream() - .map(diff -> new AnnotationResultCompareResponse.DiffRecord( - diff.qaId(), - diff.question(), - diff.extractAnswer(), - diff.verifyAnswer(), - diff.diffReason(), - diff.mergedAnswer(), - diff.questionCategory(), - diff.scores() != null ? new AnnotationResultCompareResponse.Scores( - diff.scores().similarity(), - diff.scores().confidence1(), - diff.scores().confidence2(), - diff.scores().hallucination(), - diff.scores().trust()) : null - )).toList() : List.of(); - - return new AnnotationResultCompareResponse( + return new AnnotationResultDetailResponse( result.getId(), result.getTaskId(), + result.getTaskName(), result.getResourceId(), - qaRecords, - diffRecords + result.getResourceName(), + deriveStatus(result), + result.getRequiresManualReview(), + result.getIsDeleted(), + result.getQaContentFilePath(), + result.getDiffSummaryFilePath(), + qaContentDto, + diffContentDto, + result.getCreatedAt() ); - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - log.error("compareResult failed, companyId={}, userId={}, resultId={}, error={}", - currentUser.companyId(), currentUser.userId(), resultId, e.getMessage(), e); - throw e; } - } - @Transactional - public void mergeReviewResult(LoginUser currentUser, Long resultId, MergeReviewResultRequest request) { - try { - AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, - currentUser.companyId()); - if (result == null) { - throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); + /** + * 获取标注结果对比数据 + * + * @param currentUser 当前登录用户 + * @param resultId 结果ID + * @return 对比响应对象 + */ + public AnnotationResultCompareResponse compareResult(LoginUser currentUser, Long resultId) { + try { + AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, + currentUser.companyId()); + if (result == null) { + log.warn("Result not found or cross-tenant access attempt: resultId={}, companyId={}, userId={}", + resultId, + currentUser.companyId(), currentUser.userId()); + throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); + } + //assertResultPermission(currentUser, result); + + QaContent qaContent = loadQaContent(result); + DiffContent diffContent = Boolean.TRUE.equals(result.getRequiresManualReview()) ? + loadDiffSummary(result) : null; + + SourceResource resource = sourceResourceMapper.selectById(result.getResourceId()); + + // 转换 QA 记录 + List qaRecords = qaContent.records().stream() + .map(qa -> new AnnotationResultCompareResponse.QaRecord( + qa.id(), + qa.batchId(), + qa.question(), + qa.answer(), + qa.requiresReview(), + qa.sourceSegments() != null ? new AnnotationResultCompareResponse.SourceSegments( + qa.sourceSegments().segment(), + qa.sourceSegments().chunkIndex(), + qa.sourceSegments().chunkTitle(), + qa.sourceSegments().chunkContent()) : null, + qa.questionCategory(), + qa.scores() != null ? new AnnotationResultCompareResponse.Scores( + qa.scores().similarity(), + qa.scores().confidence1(), + qa.scores().confidence2(), + qa.scores().hallucination(), + qa.scores().trust()) : null, + qa.reviewComment() + )).toList(); + + // 转换差异记录 + List diffRecords = diffContent != null ? + diffContent.records().stream() + .map(diff -> new AnnotationResultCompareResponse.DiffRecord( + diff.qaId(), + diff.question(), + diff.extractAnswer(), + diff.verifyAnswer(), + diff.diffReason(), + diff.mergedAnswer(), + diff.questionCategory(), + diff.scores() != null ? new AnnotationResultCompareResponse.Scores( + diff.scores().similarity(), + diff.scores().confidence1(), + diff.scores().confidence2(), + diff.scores().hallucination(), + diff.scores().trust()) : null + )).toList() : List.of(); + + return new AnnotationResultCompareResponse( + result.getId(), + result.getTaskId(), + result.getResourceId(), + qaRecords, + diffRecords + ); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("compareResult failed, companyId={}, userId={}, resultId={}, error={}", + currentUser.companyId(), currentUser.userId(), resultId, e.getMessage(), e); + throw e; } - //assertResultPermission(currentUser, result); + } - // 读取当前 qa.json - QaContent qaContent = loadQaContent(result); + /** + * 合并审核结果 + * + * @param currentUser 当前登录用户 + * @param resultId 结果ID + * @param request 合并审核请求 + */ + @Transactional + public void mergeReviewResult(LoginUser currentUser, Long resultId, MergeReviewResultRequest request) { + try { + AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, + currentUser.companyId()); + if (result == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); + } + //assertResultPermission(currentUser, result); - // 更新 qa.json 的 answer 字段和 reviewComment - List updatedQaRecords = qaContent.records().stream() - .map(record -> { - String mergedAnswer = request.mergedAnswers().get(record.id()); - String reviewComment = - request.reviewComments() != null ? request.reviewComments().get(record.id()) : null; - if (mergedAnswer != null || reviewComment != null) { - return new QaContent.QaRecord( - record.id(), - record.batchId(), - record.question(), - mergedAnswer != null ? mergedAnswer : record.answer(), - false, - record.sourceSegments(), - record.questionCategory(), - record.scores(), - reviewComment != null ? reviewComment : record.reviewComment() - ); - } - return record; - }) - .toList(); + // 读取当前 qa.json + QaContent qaContent = loadQaContent(result); - QaContent updatedQaContent = new QaContent( - qaContent.taskId(), - qaContent.resourceId(), - updatedQaRecords, - new QaContent.Metadata( - qaContent.metadata().createdAt(), - LocalDateTime.now().toString() - ) - ); - saveQaContent(result, updatedQaContent); + // 更新 qa.json 的 answer 字段和 reviewComment + List updatedQaRecords = qaContent.records().stream() + .map(record -> { + String mergedAnswer = request.mergedAnswers().get(record.id()); + String reviewComment = + request.reviewComments() != null ? request.reviewComments().get(record.id()) : null; + if (mergedAnswer != null || reviewComment != null) { + return new QaContent.QaRecord( + record.id(), + record.batchId(), + record.question(), + mergedAnswer != null ? mergedAnswer : record.answer(), + false, + record.sourceSegments(), + record.questionCategory(), + record.scores(), + reviewComment != null ? reviewComment : record.reviewComment() + ); + } + return record; + }) + .toList(); - // 用单条 SQL 原子完成人工审核归档,避免状态部分更新后再次被自动归档扫描到。 - int updated = annotationResultMapper.markReviewedAndArchived( + QaContent updatedQaContent = new QaContent( + qaContent.taskId(), + qaContent.resourceId(), + updatedQaRecords, + new QaContent.Metadata( + qaContent.metadata().createdAt(), + LocalDateTime.now().toString() + ) + ); + saveQaContent(result, updatedQaContent); + + // 用单条 SQL 原子完成人工审核归档,避免状态部分更新后再次被自动归档扫描到。 + int updated = annotationResultMapper.markReviewedAndArchived( + result.getId(), + currentUser.companyId(), + currentUser.userId()); + + if (updated == 0) { + // 记录已被其他进程归档 + throw new BusinessException(ResultCode.CONFLICT, "记录已被归档"); + } + + result.setRequiresManualReview(false); + result.setIsDeleted(true); + result.setReviewerId(currentUser.userId()); + + // 归档到历史表(人工审核后归档) + archiveToHistory(result, currentUser, "审核通过后归档", false); + + log.info("merged review result, companyId={}, userId={}, resultId={}", + currentUser.companyId(), currentUser.userId(), resultId); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("mergeReviewResult failed, companyId={}, userId={}, resultId={}, error={}", + currentUser.companyId(), currentUser.userId(), resultId, e.getMessage(), e); + throw e; + } + } + + /** + * 转换为响应对象 + * + * @param result 标注结果实体 + * @return 响应对象 + */ + private AnnotationResultResponse toResponse(AnnotationResult result) { + return new AnnotationResultResponse( result.getId(), - currentUser.companyId(), - currentUser.userId()); + result.getTaskId(), + result.getTaskName(), + result.getResourceId(), + result.getResourceName(), + deriveStatus(result), + result.getRequiresManualReview(), + result.getIsDeleted(), + result.getQaContentFilePath(), + result.getDiffSummaryFilePath(), + result.getCreatedAt() + ); + } - if (updated == 0) { - // 记录已被其他进程归档 - throw new BusinessException(ResultCode.CONFLICT, "记录已被归档"); + /** + * 推导标注结果状态 + * + * @param result 标注结果实体 + * @return 标注结果状态 + */ + private AnnotationResultStatus deriveStatus(AnnotationResult result) { + if (Boolean.TRUE.equals(result.getIsDeleted())) { + return AnnotationResultStatus.ARCHIVED; } - - result.setRequiresManualReview(false); - result.setIsDeleted(true); - result.setReviewerId(currentUser.userId()); - - // 归档到历史表(人工审核后归档) - archiveToHistory(result, currentUser, "审核通过后归档", false); - - log.info("merged review result, companyId={}, userId={}, resultId={}", - currentUser.companyId(), currentUser.userId(), resultId); - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - log.error("mergeReviewResult failed, companyId={}, userId={}, resultId={}, error={}", - currentUser.companyId(), currentUser.userId(), resultId, e.getMessage(), e); - throw e; + if (Boolean.TRUE.equals(result.getRequiresManualReview())) { + return AnnotationResultStatus.MANUAL_REVIEW_PENDING; + } + return AnnotationResultStatus.AUTO_ARCHIVE_PENDING; } - } - - private AnnotationResultResponse toResponse(AnnotationResult result) { - return new AnnotationResultResponse( - result.getId(), - result.getTaskId(), - result.getTaskName(), // 新增 - result.getResourceId(), - result.getResourceName(), // 新增 - deriveStatus(result), - result.getRequiresManualReview(), - result.getIsDeleted(), - result.getQaContentFilePath(), - result.getDiffSummaryFilePath(), - result.getCreatedAt() - ); - } - - private AnnotationResultStatus deriveStatus(AnnotationResult result) { - if (Boolean.TRUE.equals(result.getIsDeleted())) { - return AnnotationResultStatus.ARCHIVED; - } - if (Boolean.TRUE.equals(result.getRequiresManualReview())) { - return AnnotationResultStatus.MANUAL_REVIEW_PENDING; - } - return AnnotationResultStatus.AUTO_ARCHIVE_PENDING; - } private QaContent loadQaContent(AnnotationResult result) { try { diff --git a/src/main/java/com/labelsys/backend/service/AnnotationTaskService.java b/src/main/java/com/labelsys/backend/service/AnnotationTaskService.java index 5c9d121..8d29678 100644 --- a/src/main/java/com/labelsys/backend/service/AnnotationTaskService.java +++ b/src/main/java/com/labelsys/backend/service/AnnotationTaskService.java @@ -10,11 +10,13 @@ import com.labelsys.backend.dto.request.AnnotationTaskPageQuery; import com.labelsys.backend.dto.request.CreateAnnotationTaskRequest; import com.labelsys.backend.dto.request.UpdateAnnotationTaskRequest; import com.labelsys.backend.dto.response.AnnotationTaskResponse; +import com.labelsys.backend.entity.AnnotationResult; import com.labelsys.backend.entity.AnnotationTask; import com.labelsys.backend.entity.AnnotationTaskResource; import com.labelsys.backend.entity.SourceResource; import com.labelsys.backend.enums.TaskStatus; import com.labelsys.backend.enums.TaskType; +import com.labelsys.backend.mapper.AnnotationResultMapper; import com.labelsys.backend.mapper.AnnotationTaskMapper; import com.labelsys.backend.mapper.AnnotationTaskResourceMapper; import com.labelsys.backend.mapper.SourceResourceMapper; @@ -31,21 +33,56 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +/** + * 标注任务服务 + * + *

提供标注任务的创建、更新、查询、删除等核心业务操作, + * 管理任务与资源的关联关系,并处理任务的状态流转。 + */ @Slf4j @Service @RequiredArgsConstructor public class AnnotationTaskService { - private final AnnotationTaskMapper annotationTaskMapper; - private final AnnotationTaskResourceMapper annotationTaskResourceMapper; - private final SourceResourceMapper sourceResourceMapper; - private final DataPermissionService dataPermissionService; + /** + * 标注任务数据访问层 + */ + private final AnnotationTaskMapper annotationTaskMapper; + /** + * 任务资源关联数据访问层 + */ + private final AnnotationTaskResourceMapper annotationTaskResourceMapper; + + /** + * 标注结果数据访问层 + */ + private final AnnotationResultMapper annotationResultMapper; + + /** + * 源资源数据访问层 + */ + private final SourceResourceMapper sourceResourceMapper; + + /** + * 数据权限服务 + */ + private final DataPermissionService dataPermissionService; + + /** + * 创建标注任务 + * + * @param currentUser 当前登录用户 + * @param request 创建任务请求,包含任务名称、任务类型和资源ID列表 + * @return 创建的任务响应对象 + */ @Transactional public AnnotationTaskResponse createTask(LoginUser currentUser, CreateAnnotationTaskRequest request) { try { + // 加载并验证资源列表 List resources = loadAndValidateResources(currentUser, request.resourceIds()); + // 构建任务实体 AnnotationTask task = AnnotationTask.builder() .id(IdGenerator.nextId()) .companyId(currentUser.companyId()) @@ -57,7 +94,9 @@ public class AnnotationTaskService { .isDeleted(false) .build(); + // 插入任务记录 annotationTaskMapper.insert(task); + // 保存任务与资源的关联关系 saveTaskBindings(task.getId(), currentUser.companyId(), resources); log.info("created annotation task, companyId={}, userId={}, taskId={}, resourceCount={}", @@ -71,9 +110,18 @@ public class AnnotationTaskService { } } + /** + * 更新标注任务 + * + * @param currentUser 当前登录用户 + * @param taskId 任务ID + * @param request 更新任务请求 + * @return 更新后的任务响应对象 + */ @Transactional public AnnotationTaskResponse updateTask(LoginUser currentUser, Long taskId, UpdateAnnotationTaskRequest request) { try { + // 查询任务并验证权限 AnnotationTask task = annotationTaskMapper.findByIdAndCompanyId(taskId, currentUser.companyId()); if (task == null) { throw new BusinessException(ResultCode.NOT_FOUND, "任务不存在"); @@ -83,24 +131,29 @@ public class AnnotationTaskService { boolean resourcesChanged = false; List resources = null; + // 处理资源变更 if (request.resourceIds() != null && !request.resourceIds().isEmpty()) { List currentResourceIds = normalizeIds( annotationTaskResourceMapper.listResourceIdsByTaskId(taskId)); List targetResourceIds = normalizeIds(request.resourceIds()); resourcesChanged = !currentResourceIds.equals(targetResourceIds); + // 运行中的任务不允许修改资源 if (TaskStatus.RUNNING.name().equals(task.getTaskStatus()) && resourcesChanged) { throw new BusinessException(ResultCode.CONFLICT, "运行中的任务不允许修改资源"); } resources = loadAndValidateResources(currentUser, request.resourceIds()); } + // 更新任务类型 if (request.taskType() != null) { task.setTaskType(request.taskType()); } + // 保存任务更新 annotationTaskMapper.updateById(task); + // 更新资源绑定关系 if (resourcesChanged && resources != null) { annotationTaskResourceMapper.deleteByTaskId(taskId); saveTaskBindings(taskId, currentUser.companyId(), resources); @@ -120,6 +173,13 @@ public class AnnotationTaskService { } } + /** + * 查询单个标注任务详情 + * + * @param currentUser 当前登录用户 + * @param taskId 任务ID + * @return 任务响应对象 + */ public AnnotationTaskResponse getTask(LoginUser currentUser, Long taskId) { try { AnnotationTask task = annotationTaskMapper.findByIdAndCompanyId(taskId, currentUser.companyId()); @@ -135,11 +195,20 @@ public class AnnotationTaskService { } } + /** + * 分页查询标注任务列表 + * + * @param currentUser 当前登录用户 + * @param query 分页查询条件 + * @return 分页任务列表 + */ public PageResult pageTasks(LoginUser currentUser, AnnotationTaskPageQuery query) { try { + // 获取数据权限过滤条件 List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); + // 构建查询条件 LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(AnnotationTask::getCompanyId, currentUser.companyId()) .eq(query.taskType() != null, AnnotationTask::getTaskType, query.taskType()) @@ -147,17 +216,21 @@ public class AnnotationTaskService { .eq(query.isDeleted() != null, AnnotationTask::getIsDeleted, query.isDeleted()) .like(StringUtils.hasText(query.keyword()), AnnotationTask::getTaskName, query.keyword()); + // 应用数据权限过滤 if (shouldFilterByUserId) { wrapper.eq(AnnotationTask::getCreatorId, currentUser.userId()); } else if (!allowedRoles.isEmpty()) { wrapper.in(AnnotationTask::getCreatorRole, allowedRoles); } + // 按创建时间降序排序 wrapper.orderByDesc(AnnotationTask::getCreatedAt); + // 执行分页查询 Page page = new Page<>(query.pageNo(), query.pageSize()); Page resultPage = annotationTaskMapper.selectPage(page, wrapper); + // 转换为响应对象,并支持按资源ID过滤 List records = resultPage.getRecords().stream() .filter(task -> query.resourceId() == null || annotationTaskResourceMapper.listResourceIdsByTaskId(task.getId()) @@ -175,6 +248,18 @@ public class AnnotationTaskService { } } + /** + * 删除标注任务(软删除) + * + *

删除前会检查: + *

    + *
  • 任务是否处于运行状态(运行中不允许删除)
  • + *
  • 是否存在关联的标注结果(有结果不允许删除)
  • + *
+ * + * @param currentUser 当前登录用户 + * @param taskId 任务ID + */ @Transactional public void deleteTask(LoginUser currentUser, Long taskId) { try { @@ -183,11 +268,29 @@ public class AnnotationTaskService { throw new BusinessException(ResultCode.NOT_FOUND, "任务不存在"); } assertTaskPermission(currentUser, task); + + // 检查运行状态 if (TaskStatus.RUNNING.name().equals(task.getTaskStatus())) { throw new BusinessException(ResultCode.CONFLICT, "运行中的任务不允许删除"); } - task.setIsDeleted(true); - annotationTaskMapper.updateById(task); + + // 检查是否存在关联的标注结果(防止误删有价值数据) + long resultCount = annotationResultMapper.selectCount( + new LambdaQueryWrapper() + .eq(AnnotationResult::getTaskId, taskId) + .eq(AnnotationResult::getIsDeleted, false) + ); + if (resultCount > 0) { + throw new BusinessException(ResultCode.CONFLICT, + String.format("任务存在 %d 条标注结果,不允许删除", resultCount)); + } + + // 软删除任务(使用 deleteById 触发 @TableLogic 注解的逻辑删除) + annotationTaskMapper.deleteById(taskId); + + // 清理关联的任务资源记录 + annotationTaskResourceMapper.deleteByTaskId(taskId); + log.info("deleted annotation task logically, companyId={}, userId={}, taskId={}", currentUser.companyId(), currentUser.userId(), taskId); } catch (Exception e) { @@ -197,6 +300,13 @@ public class AnnotationTaskService { } } + /** + * 加载并验证资源列表 + * + * @param currentUser 当前登录用户 + * @param resourceIds 资源ID列表 + * @return 验证通过的资源列表 + */ private List loadAndValidateResources(LoginUser currentUser, List resourceIds) { if (resourceIds == null || resourceIds.isEmpty()) { throw new BusinessException(ResultCode.BAD_REQUEST, "任务资源不能为空"); @@ -217,6 +327,13 @@ public class AnnotationTaskService { return resources; } + /** + * 保存任务与资源的绑定关系 + * + * @param taskId 任务ID + * @param companyId 公司ID + * @param resources 资源列表 + */ private void saveTaskBindings(Long taskId, Long companyId, List resources) { for (SourceResource resource : resources) { annotationTaskResourceMapper.insert(AnnotationTaskResource.builder() @@ -228,6 +345,13 @@ public class AnnotationTaskService { } } + /** + * 构建任务响应对象 + * + * @param task 任务实体 + * @param resourceIds 资源ID列表 + * @return 任务响应对象 + */ private AnnotationTaskResponse buildTaskResponse(AnnotationTask task, List resourceIds) { return new AnnotationTaskResponse( task.getId(), @@ -240,10 +364,22 @@ public class AnnotationTaskService { ); } + /** + * 从资源列表提取ID列表 + * + * @param resources 资源列表 + * @return 排序后的资源ID列表 + */ private List resourceIds(List resources) { return resources.stream().map(SourceResource::getId).sorted().toList(); } + /** + * 标准化ID列表(去重并排序) + * + * @param resourceIds 原始ID列表 + * @return 去重排序后的ID列表 + */ private List normalizeIds(List resourceIds) { Set uniqueIds = new HashSet<>(resourceIds); List sortedIds = new ArrayList<>(uniqueIds); @@ -251,12 +387,24 @@ public class AnnotationTaskService { return sortedIds; } + /** + * 断言任务操作权限 + * + * @param currentUser 当前登录用户 + * @param task 任务实体 + */ private void assertTaskPermission(LoginUser currentUser, AnnotationTask task) { if (!dataPermissionService.canAccessCreator(currentUser, task.getCreatorId(), task.getCreatorRole())) { throw new BusinessException(ResultCode.FORBIDDEN, "无权操作任务"); } } + /** + * 获取默认任务类型 + * + * @param taskType 任务类型(可为null) + * @return 任务类型,若为null则返回默认类型 EXTRACT_QA + */ private TaskType defaultTaskType(TaskType taskType) { return taskType != null ? taskType : TaskType.EXTRACT_QA; } diff --git a/src/main/java/com/labelsys/backend/service/AuthService.java b/src/main/java/com/labelsys/backend/service/AuthService.java index 4fc36fd..8186aa3 100644 --- a/src/main/java/com/labelsys/backend/service/AuthService.java +++ b/src/main/java/com/labelsys/backend/service/AuthService.java @@ -26,19 +26,48 @@ import java.time.Duration; import java.util.List; import java.util.UUID; +/** + * 认证服务 + * + *

提供用户登录、登出、密码修改、获取当前用户等核心认证业务操作。 + */ @Slf4j @Service @RequiredArgsConstructor public class AuthService { - private final SysCompanyMapper sysCompanyMapper; - private final SysUserMapper sysUserMapper; - private final PasswordEncoder passwordEncoder; + /** + * 公司数据访问层 + */ + private final SysCompanyMapper sysCompanyMapper; + + /** + * 用户数据访问层 + */ + private final SysUserMapper sysUserMapper; + + /** + * 密码编码器 + */ + private final PasswordEncoder passwordEncoder; + + /** + * Token会话存储仓库 + */ private final TokenSessionRepository tokenSessionRepository; + /** + * 会话有效期(默认2小时) + */ @Value("${labelsys.session.ttl:PT2H}") private Duration sessionTtl; + /** + * 获取可用公司列表 + * + * @param phone 用户手机号 + * @return 可用公司选项列表 + */ public List listAvailableCompanies(String phone) { try { return sysCompanyMapper.findEnabledCompaniesByPhone(phone).stream() @@ -50,6 +79,12 @@ public class AuthService { } } + /** + * 用户登录 + * + * @param request 登录请求 + * @return 登录响应 + */ public LoginResponse login(LoginRequest request) { try { SysCompany company = loadEnabledCompany(request.companyCode()); @@ -70,6 +105,12 @@ public class AuthService { } } + /** + * 获取当前登录用户 + * + * @param token 登录令牌 + * @return 当前登录用户 + */ public LoginUser getCurrentUser(String token) { try { return tokenSessionRepository.find(token) @@ -82,6 +123,12 @@ public class AuthService { } } + /** + * 修改密码 + * + * @param currentUser 当前登录用户 + * @param request 修改密码请求 + */ @Transactional public void changePassword(LoginUser currentUser, ChangePasswordRequest request) { try { @@ -108,6 +155,11 @@ public class AuthService { } } + /** + * 用户登出 + * + * @param token 登录令牌 + */ public void logout(String token) { try { tokenSessionRepository.remove(token); @@ -117,6 +169,12 @@ public class AuthService { } } + /** + * 加载启用状态的公司 + * + * @param companyCode 公司编码 + * @return 公司实体 + */ private SysCompany loadEnabledCompany(String companyCode) { SysCompany company = sysCompanyMapper.findByCompanyCode(companyCode); if (company == null || company.getStatus() != CompanyStatus.ENABLED) { @@ -125,6 +183,13 @@ public class AuthService { return company; } + /** + * 加载启用状态的用户 + * + * @param companyId 公司ID + * @param phone 用户手机号 + * @return 用户实体 + */ private SysUser loadEnabledUser(Long companyId, String phone) { SysUser user = sysUserMapper.findByCompanyIdAndPhone(companyId, phone); if (user == null || user.getStatus() != UserStatus.ENABLED) { @@ -132,4 +197,5 @@ public class AuthService { } return user; } -} + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/CompanyService.java b/src/main/java/com/labelsys/backend/service/CompanyService.java index 4cd8765..604469c 100644 --- a/src/main/java/com/labelsys/backend/service/CompanyService.java +++ b/src/main/java/com/labelsys/backend/service/CompanyService.java @@ -15,67 +15,102 @@ import org.springframework.stereotype.Service; import java.util.List; -@Slf4j -@Service -@RequiredArgsConstructor -public class CompanyService { +/** + * 公司服务 + * + *

提供公司的创建、查询、状态更新等核心业务操作, + * 所有操作仅限系统管理员执行。 + */ + @Slf4j + @Service + @RequiredArgsConstructor + public class CompanyService { - private final SysCompanyMapper sysCompanyMapper; + /** + * 公司数据访问层 + */ + private final SysCompanyMapper sysCompanyMapper; - public List listCompanies(LoginUser currentUser) { - try { - assertPlatformAdmin(currentUser); - return sysCompanyMapper.selectList(null); - } catch (ForbiddenException e) { - throw e; - } catch (Exception e) { - log.error("listCompanies failed, companyId={}, userId={}, error={}", - currentUser.companyId(), currentUser.userId(), e.getMessage(), e); - throw e; - } - } - - public SysCompany createCompany(LoginUser currentUser, CreateCompanyRequest request) { - try { - assertPlatformAdmin(currentUser); - if (sysCompanyMapper.findByCompanyCode(request.companyCode()) != null) { - throw new BusinessException(ResultCode.CONFLICT, "公司编码已存在"); + /** + * 查询所有公司列表 + * + * @param currentUser 当前登录用户(必须是系统管理员) + * @return 公司列表 + */ + public List listCompanies(LoginUser currentUser) { + try { + assertPlatformAdmin(currentUser); + return sysCompanyMapper.selectList(null); + } catch (ForbiddenException e) { + throw e; + } catch (Exception e) { + log.error("listCompanies failed, companyId={}, userId={}, error={}", + currentUser.companyId(), currentUser.userId(), e.getMessage(), e); + throw e; } - 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; - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - log.error("createCompany failed, companyId={}, userId={}, companyCode={}, error={}", - currentUser.companyId(), currentUser.userId(), request.companyCode(), e.getMessage(), e); - throw e; } - } - public void updateStatus(LoginUser currentUser, Long companyId, UpdateCompanyStatusRequest request) { - try { - assertPlatformAdmin(currentUser); - if (sysCompanyMapper.updateStatus(companyId, request.status()) == 0) { - throw new BusinessException(ResultCode.NOT_FOUND, "公司不存在"); + /** + * 创建公司 + * + * @param currentUser 当前登录用户(必须是系统管理员) + * @param request 创建公司请求 + * @return 创建的公司实体 + */ + public SysCompany createCompany(LoginUser currentUser, CreateCompanyRequest request) { + try { + 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; + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("createCompany failed, companyId={}, userId={}, companyCode={}, error={}", + currentUser.companyId(), currentUser.userId(), request.companyCode(), e.getMessage(), e); + throw e; } - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - log.error("updateStatus failed, companyId={}, userId={}, targetCompanyId={}, error={}", - currentUser.companyId(), currentUser.userId(), companyId, e.getMessage(), e); - throw e; } - } - private void assertPlatformAdmin(LoginUser currentUser) { - if (!currentUser.isSuperAdmin()) { - throw new ForbiddenException("仅系统管理员可操作"); + /** + * 更新公司状态 + * + * @param currentUser 当前登录用户(必须是系统管理员) + * @param companyId 公司ID + * @param request 更新状态请求 + */ + public void updateStatus(LoginUser currentUser, Long companyId, UpdateCompanyStatusRequest request) { + try { + assertPlatformAdmin(currentUser); + if (sysCompanyMapper.updateStatus(companyId, request.status()) == 0) { + throw new BusinessException(ResultCode.NOT_FOUND, "公司不存在"); + } + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("updateStatus failed, companyId={}, userId={}, targetCompanyId={}, error={}", + currentUser.companyId(), currentUser.userId(), companyId, e.getMessage(), e); + throw e; + } } - } -} + + /** + * 验证是否为系统管理员 + * + * @param currentUser 当前登录用户 + */ + private void assertPlatformAdmin(LoginUser currentUser) { + if (!currentUser.isSuperAdmin()) { + throw new ForbiddenException("仅系统管理员可操作"); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/DataPermissionService.java b/src/main/java/com/labelsys/backend/service/DataPermissionService.java index 243dda8..26e0d6a 100644 --- a/src/main/java/com/labelsys/backend/service/DataPermissionService.java +++ b/src/main/java/com/labelsys/backend/service/DataPermissionService.java @@ -12,26 +12,43 @@ import java.util.List; import java.util.function.Function; import java.util.stream.Collectors; -@Slf4j -@Service -@RequiredArgsConstructor -public class DataPermissionService { +/** + * 数据权限服务 + * + *

提供数据访问权限控制,基于用户角色实现数据可见性过滤。 + * 支持 EMPLOYEE(员工)、MANAGER(管理者)、ENGINEER(工程师)三种角色的权限控制。 + */ + @Slf4j + @Service + @RequiredArgsConstructor + public class DataPermissionService { - private final JdbcTemplate jdbcTemplate; + /** + * JDBC模板 + */ + private final JdbcTemplate jdbcTemplate; - public boolean canAccessCreator(LoginUser currentUser, Long creatorId, UserRole creatorRole) { - try { - return switch (currentUser.role()) { - case EMPLOYEE -> currentUser.userId().equals(creatorId); - case MANAGER -> creatorRole == UserRole.EMPLOYEE || creatorRole == UserRole.MANAGER; - case ENGINEER -> true; - }; - } catch (Exception e) { - log.error("canAccessCreator failed, companyId={}, userId={}, creatorId={}, error={}", - currentUser.companyId(), currentUser.userId(), creatorId, e.getMessage(), e); - throw e; + /** + * 判断当前用户是否有权限访问创建者的数据 + * + * @param currentUser 当前登录用户 + * @param creatorId 创建者ID + * @param creatorRole 创建者角色 + * @return 是否有权限 + */ + public boolean canAccessCreator(LoginUser currentUser, Long creatorId, UserRole creatorRole) { + try { + return switch (currentUser.role()) { + case EMPLOYEE -> currentUser.userId().equals(creatorId); + case MANAGER -> creatorRole == UserRole.EMPLOYEE || creatorRole == UserRole.MANAGER; + case ENGINEER -> true; + }; + } catch (Exception e) { + log.error("canAccessCreator failed, companyId={}, userId={}, creatorId={}, error={}", + currentUser.companyId(), currentUser.userId(), creatorId, e.getMessage(), e); + throw e; + } } - } public List filterByRole( LoginUser currentUser, @@ -119,4 +136,4 @@ public class DataPermissionService { throw e; } } -} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/ObjectStorageInitializer.java b/src/main/java/com/labelsys/backend/service/ObjectStorageInitializer.java index ead66ce..c4a1f17 100644 --- a/src/main/java/com/labelsys/backend/service/ObjectStorageInitializer.java +++ b/src/main/java/com/labelsys/backend/service/ObjectStorageInitializer.java @@ -1,34 +1,47 @@ package com.labelsys.backend.service; +import com.labelsys.backend.config.ObjectStorageProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; - -import com.labelsys.backend.config.ObjectStorageProperties; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.CreateBucketRequest; import software.amazon.awssdk.services.s3.model.HeadBucketRequest; import software.amazon.awssdk.services.s3.model.NoSuchBucketException; +/** + * 对象存储初始化器 + * + *

在应用启动时自动初始化所需的对象存储桶,包括源文件桶、产物桶和导出桶。 + */ @Slf4j @Component @RequiredArgsConstructor public class ObjectStorageInitializer { + /** + * S3 客户端 + */ private final S3Client s3Client; + + /** + * 对象存储配置属性 + */ private final ObjectStorageProperties properties; + /** + * 应用启动完成后初始化存储桶 + */ @EventListener(ApplicationReadyEvent.class) public void initializeBuckets() { log.info("开始初始化对象存储桶..."); - + String[] buckets = { - properties.getSourceBucket(), - properties.getArtifactBucket(), - properties.getExportBucket() + properties.getSourceBucket(), + properties.getArtifactBucket(), + properties.getExportBucket() }; for (String bucketName : buckets) { @@ -43,10 +56,16 @@ public class ObjectStorageInitializer { log.error("初始化存储桶 {} 失败: {}", bucketName, e.getMessage()); } } - + log.info("对象存储桶初始化完成"); } + /** + * 检查存储桶是否存在 + * + * @param bucketName 存储桶名称 + * @return 是否存在 + */ private boolean bucketExists(String bucketName) { try { s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build()); @@ -56,7 +75,13 @@ public class ObjectStorageInitializer { } } + /** + * 创建存储桶 + * + * @param bucketName 存储桶名称 + */ private void createBucket(String bucketName) { s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build()); } + } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/ObjectStorageService.java b/src/main/java/com/labelsys/backend/service/ObjectStorageService.java index d2ceef5..c1e2064 100644 --- a/src/main/java/com/labelsys/backend/service/ObjectStorageService.java +++ b/src/main/java/com/labelsys/backend/service/ObjectStorageService.java @@ -2,12 +2,39 @@ package com.labelsys.backend.service; import java.time.Duration; +/** + * 对象存储服务接口 + * + *

定义对象存储的核心操作,包括上传、下载、删除和生成预签名URL。 + */ public interface ObjectStorageService { + /** + * 上传文件到对象存储 + * + * @param bucketName 存储桶名称 + * @param objectKey 对象键 + * @param content 文件内容 + * @param contentType 内容类型 + * @return 对象键 + */ String upload(String bucketName, String objectKey, byte[] content, String contentType); + /** + * 从对象存储删除文件 + * + * @param bucketName 存储桶名称 + * @param objectKey 对象键 + */ void delete(String bucketName, String objectKey); + /** + * 从对象存储下载文件 + * + * @param bucketName 存储桶名称 + * @param objectKey 对象键 + * @return 文件内容字节数组 + */ byte[] download(String bucketName, String objectKey); /** diff --git a/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java b/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java index 30f5675..f2192d5 100644 --- a/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java +++ b/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java @@ -16,23 +16,44 @@ import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + import java.net.URI; import java.time.Duration; -import java.time.Instant; +/** + * Rustfs 对象存储服务实现 + * + * 基于 AWS S3 SDK 实现的对象存储服务,提供上传、下载、删除和生成预签名URL等功能。 + */ @Service @RequiredArgsConstructor public class RustfsObjectStorageService implements ObjectStorageService { + /** + * S3 客户端 + */ private final S3Client s3Client; + + /** + * 对象存储配置属性 + */ private final ObjectStorageProperties objectStorageProperties; + /** + * 上传文件到对象存储 + * + * @param bucketName 存储桶名称 + * @param objectKey 对象键 + * @param content 文件内容 + * @param contentType 内容类型 + * @return 对象键 + */ @Override public String upload(String bucketName, String objectKey, byte[] content, String contentType) { try { PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder() - .bucket(bucketName) - .key(objectKey); + .bucket(bucketName) + .key(objectKey); if (contentType != null && !contentType.isBlank()) { requestBuilder.contentType(contentType); } @@ -43,31 +64,52 @@ public class RustfsObjectStorageService implements ObjectStorageService { } } + /** + * 从对象存储删除文件 + * + * @param bucketName 存储桶名称 + * @param objectKey 对象键 + */ @Override public void delete(String bucketName, String objectKey) { try { s3Client.deleteObject(DeleteObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build()); + .bucket(bucketName) + .key(objectKey) + .build()); } catch (Exception ex) { throw new BusinessException(ResultCode.ERROR, "对象存储删除失败"); } } + /** + * 从对象存储下载文件 + * + * @param bucketName 存储桶名称 + * @param objectKey 对象键 + * @return 文件内容字节数组 + */ @Override public byte[] download(String bucketName, String objectKey) { try { GetObjectRequest request = GetObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); + .bucket(bucketName) + .key(objectKey) + .build(); return s3Client.getObject(request, ResponseTransformer.toBytes()).asByteArray(); } catch (Exception ex) { throw new BusinessException(ResultCode.ERROR, "对象存储下载失败"); } } + /** + * 生成预签名URL + * + * @param bucketName 存储桶名称 + * @param objectKey 对象键 + * @param duration 有效期 + * @return 预签名URL + */ @Override public String generatePresignedUrl(String bucketName, String objectKey, Duration duration) { try (S3Presigner presigner = S3Presigner.builder() @@ -75,22 +117,23 @@ public class RustfsObjectStorageService implements ObjectStorageService { .region(Region.of(objectStorageProperties.getRegion())) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create( - objectStorageProperties.getAccessKey(), + objectStorageProperties.getAccessKey(), objectStorageProperties.getSecretKey()))) .build()) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); + .bucket(bucketName) + .key(objectKey) + .build(); GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(duration) - .getObjectRequest(getObjectRequest) - .build(); + .signatureDuration(duration) + .getObjectRequest(getObjectRequest) + .build(); return presigner.presignGetObject(presignRequest).url().toString(); } catch (Exception ex) { throw new BusinessException(ResultCode.ERROR, "生成预签名URL失败"); } } + } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/SourceResourceService.java b/src/main/java/com/labelsys/backend/service/SourceResourceService.java index 7b0c726..05f6772 100644 --- a/src/main/java/com/labelsys/backend/service/SourceResourceService.java +++ b/src/main/java/com/labelsys/backend/service/SourceResourceService.java @@ -45,23 +45,79 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.List; +/** + * 源资源服务 + * + *

提供资源的上传、下载、查询、删除等核心业务操作, + * 支持图片BBOX标注功能,并处理资源与任务的关联关系。 + */ @Slf4j @Service @RequiredArgsConstructor public class SourceResourceService { - private final SourceResourceMapper sourceResourceMapper; - private final AnnotationResultMapper annotationResultMapper; - private final AnnotationResultHistoryMapper annotationResultHistoryMapper; - private final AnnotationTaskResourceMapper annotationTaskResourceMapper; - private final SysUserMapper sysUserMapper; - private final DataPermissionService dataPermissionService; - private final ObjectStorageService objectStorageService; - private final ObjectStorageProperties objectStorageProperties; - private final ImageBboxAnnotationMapper imageBboxAnnotationMapper; - private final ObjectMapper objectMapper; - private final AnnotationTaskMapper annotationTaskMapper; + /** + * 源资源数据访问层 + */ + private final SourceResourceMapper sourceResourceMapper; + /** + * 标注结果数据访问层 + */ + private final AnnotationResultMapper annotationResultMapper; + + /** + * 标注历史数据访问层 + */ + private final AnnotationResultHistoryMapper annotationResultHistoryMapper; + + /** + * 任务资源关联数据访问层 + */ + private final AnnotationTaskResourceMapper annotationTaskResourceMapper; + + /** + * 用户数据访问层 + */ + private final SysUserMapper sysUserMapper; + + /** + * 数据权限服务 + */ + private final DataPermissionService dataPermissionService; + + /** + * 对象存储服务 + */ + private final ObjectStorageService objectStorageService; + + /** + * 对象存储配置 + */ + private final ObjectStorageProperties objectStorageProperties; + + /** + * 图片BBOX标注数据访问层 + */ + private final ImageBboxAnnotationMapper imageBboxAnnotationMapper; + + /** + * JSON序列化工具 + */ + private final ObjectMapper objectMapper; + + /** + * 标注任务数据访问层 + */ + private final AnnotationTaskMapper annotationTaskMapper; + + /** + * 上传资源 + * + * @param currentUser 当前登录用户 + * @param request 上传请求,包含文件和资源类型 + * @return 上传响应,包含资源信息 + */ @Transactional public SourceUploadResponse upload(LoginUser currentUser, SourceUploadRequest request) { try { @@ -72,21 +128,25 @@ public class SourceResourceService { if (!ResourceType.isValid(request.getResourceType())) { throw new BusinessException(ResultCode.BAD_REQUEST, "资源类型非法"); } + // 确定资源名称 String resourceName = StringUtils.hasText(request.getResourceName()) ? request.getResourceName() : file.getOriginalFilename(); + // 检查资源名称是否重复 SourceResource existingResource = sourceResourceMapper.selectByCompanyIdAndResourceName(currentUser.companyId(), resourceName); if (existingResource != null) { throw new BusinessException(ResultCode.BAD_REQUEST, "资源名称已存在:" + resourceName); } + // 生成资源ID和存储路径 long resourceId = IdGenerator.nextId(); String extension = resolveExtension(file.getOriginalFilename(), request.getResourceType()); String objectKey = ObjectStoragePathBuilder.sourceObjectKey(currentUser.companyId(), request.getResourceType(), resourceId, extension); + // 上传文件到对象存储 try { objectStorageService.upload(objectStorageProperties.getSourceBucket(), objectKey, file.getBytes(), file.getContentType()); @@ -94,6 +154,7 @@ public class SourceResourceService { throw new BusinessException(ResultCode.BAD_REQUEST, "读取上传文件失败"); } + // 保存资源记录 SourceResource resource = SourceResource.builder().id(resourceId).companyId(currentUser.companyId()) .creatorId(currentUser.userId()).creatorRole(currentUser.role()) .resourceName( @@ -118,11 +179,20 @@ public class SourceResourceService { } } + /** + * 分页查询资源列表 + * + * @param currentUser 当前登录用户 + * @param query 分页查询条件 + * @return 分页资源列表 + */ public PageResult pageResources(LoginUser currentUser, SourceResourcePageQuery query) { try { + // 获取数据权限过滤条件 List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); + // 构建查询条件 LambdaQueryWrapper wrapper = new LambdaQueryWrapper().eq(SourceResource::getCompanyId, currentUser.companyId()) .eq(StringUtils.hasText(query.resourceType()), SourceResource::getResourceType, @@ -130,12 +200,14 @@ public class SourceResourceService { .like(StringUtils.hasText(query.keyword()), SourceResource::getResourceName, query.keyword()); + // 应用数据权限过滤 if (shouldFilterByUserId) { wrapper.eq(SourceResource::getCreatorId, currentUser.userId()); } else if (!allowedRoles.isEmpty()) { wrapper.in(SourceResource::getCreatorRole, allowedRoles); } + // 按创建时间降序排序 wrapper.orderByDesc(SourceResource::getCreatedAt); // 判断是否需要分页 @@ -158,6 +230,13 @@ public class SourceResourceService { } } + /** + * 查询单个资源详情 + * + * @param currentUser 当前登录用户 + * @param resourceId 资源ID + * @return 资源响应对象 + */ public SourceResourceResponse getResource(LoginUser currentUser, Long resourceId) { try { SourceResource resource = sourceResourceMapper.selectById(resourceId); @@ -191,6 +270,19 @@ public class SourceResourceService { } } + /** + * 删除资源(软删除) + * + *

删除前会检查: + *

    + *
  • 资源是否被标注任务引用(活跃任务不允许删除)
  • + *
  • 资源是否存在标注历史记录(有历史不允许删除)
  • + *
  • 资源是否有未删除的标注结果(有结果不允许删除)
  • + *
+ * + * @param currentUser 当前登录用户 + * @param resourceId 资源ID + */ @Transactional public void deleteResource(LoginUser currentUser, Long resourceId) { try { @@ -198,6 +290,7 @@ public class SourceResourceService { if (resource == null || !currentUser.companyId().equals(resource.getCompanyId())) { throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在"); } + // 检查操作权限 if (!dataPermissionService.canAccessCreator(currentUser, resource.getCreatorId(), resource.getCreatorRole())) { throw new BusinessException(ResultCode.FORBIDDEN, "无权删除资源"); @@ -209,10 +302,8 @@ public class SourceResourceService { // 删除关联资源 deleteAssociatedRecords(resourceId); - // 执行软删除 - resource.setDeleted(true); - resource.setDeletedAt(LocalDateTime.now()); - sourceResourceMapper.updateById(resource); + // 执行软删除(触发 @TableLogic 注解) + sourceResourceMapper.deleteById(resourceId); // 删除对象存储中的文件 objectStorageService.delete(resource.getBucketName(), resource.getFilePath()); @@ -271,6 +362,13 @@ public class SourceResourceService { } } + /** + * 获取图片BBOX标注信息 + * + * @param currentUser 当前登录用户 + * @param resourceId 资源ID + * @return BBOX标注响应对象 + */ public ImageBboxResponse getImageBbox(LoginUser currentUser, Long resourceId) { try { SourceResource resource = sourceResourceMapper.selectById(resourceId); @@ -314,6 +412,14 @@ public class SourceResourceService { } } + /** + * 保存图片BBOX标注 + * + * @param currentUser 当前登录用户 + * @param resourceId 资源ID + * @param request BBOX标注请求 + * @return BBOX标注响应对象 + */ @Transactional public ImageBboxResponse saveImageBbox(LoginUser currentUser, Long resourceId, SaveImageBboxRequest request) { try { @@ -325,6 +431,7 @@ public class SourceResourceService { throw new BusinessException(ResultCode.BAD_REQUEST, "仅图片资源支持BBOX标注"); } + // 序列化BBOX数据 String bboxJson; try { bboxJson = objectMapper.writeValueAsString(request.bboxes()); @@ -334,6 +441,7 @@ public class SourceResourceService { boolean isNewAnnotation = imageBboxAnnotationMapper.selectByResourceId(resourceId) == null; + // 更新或插入BBOX标注记录 ImageBboxAnnotation existing = imageBboxAnnotationMapper.selectByResourceId(resourceId); if (existing != null) { existing.setBboxJson(bboxJson); @@ -370,6 +478,12 @@ public class SourceResourceService { } } + /** + * 删除图片BBOX标注 + * + * @param currentUser 当前登录用户 + * @param resourceId 资源ID + */ @Transactional public void deleteImageBbox(LoginUser currentUser, Long resourceId) { try { @@ -377,6 +491,7 @@ public class SourceResourceService { if (resource == null || !currentUser.companyId().equals(resource.getCompanyId())) { throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在"); } + // 删除BBOX标注记录 imageBboxAnnotationMapper.deleteByResourceId(resourceId); // 更新资源表的has_bbox字段为false @@ -393,6 +508,12 @@ public class SourceResourceService { } } + /** + * 解析BBOX JSON数据 + * + * @param bboxJson BBOX坐标JSON字符串 + * @return BBOX坐标响应列表 + */ private List parseBboxJson(String bboxJson) { if (!StringUtils.hasText(bboxJson)) { return List.of(); @@ -407,6 +528,12 @@ public class SourceResourceService { } } + /** + * 转换为资源响应对象 + * + * @param resource 资源实体 + * @return 资源响应对象 + */ private SourceResourceResponse toResponse(SourceResource resource) { SysUser creator = sysUserMapper.selectById(resource.getCreatorId()); return new SourceResourceResponse(resource.getId(), resource.getResourceName(), resource.getResourceType(), @@ -415,6 +542,13 @@ public class SourceResourceService { creator == null ? null : creator.getRealName(), resource.getCreatedAt(), resource.getUpdatedAt()); } + /** + * 解析文件扩展名 + * + * @param originalFilename 原始文件名 + * @param resourceType 资源类型 + * @return 文件扩展名 + */ private String resolveExtension(String originalFilename, String resourceType) { if (StringUtils.hasText(originalFilename) && originalFilename.contains(".")) { return originalFilename.substring(originalFilename.lastIndexOf('.') + 1); diff --git a/src/main/java/com/labelsys/backend/service/SysConfigService.java b/src/main/java/com/labelsys/backend/service/SysConfigService.java index 4b01af7..22b3105 100644 --- a/src/main/java/com/labelsys/backend/service/SysConfigService.java +++ b/src/main/java/com/labelsys/backend/service/SysConfigService.java @@ -30,19 +30,42 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.List; +/** + * 系统配置服务 + * + *

提供系统配置的创建、更新、查询等核心业务操作, + * 支持模型配置、提示词配置和系统参数配置的管理, + * 并负责配置更新时同步更新标注代理配置。 + */ @Slf4j @Service @RequiredArgsConstructor public class SysConfigService { - private final SysConfigMapper sysConfigMapper; + /** + * 系统配置数据访问层 + */ + private final SysConfigMapper sysConfigMapper; + + /** + * 标注代理配置数据访问层 + */ private final AnnotationAgentConfigMapper agentConfigMapper; //private final DataPermissionService dataPermissionService; + /** + * 保存系统配置 + * + * @param currentUser 当前登录用户 + * @param request 保存配置请求 + * @return 保存的配置实体 + */ @Transactional public SysConfig saveConfig(LoginUser currentUser, SaveSysConfigRequest request) { try { + // 验证配置类型 validateConfigType(request.configType()); + // 检查配置名称是否重复 SysConfig existing = sysConfigMapper.findByCompanyIdAndConfigName(currentUser.companyId(), request.configName()); if (existing != null) { @@ -55,6 +78,7 @@ public class SysConfigService { processedConfigValue = processModelConfigValue(processedConfigValue, true); } + // 构建配置实体 SysConfig config = SysConfig.builder() .id(IdGenerator.nextId()) .companyId(currentUser.companyId()) @@ -79,6 +103,14 @@ public class SysConfigService { } } + /** + * 更新系统配置 + * + * @param currentUser 当前登录用户 + * @param configId 配置ID + * @param request 更新配置请求 + * @return 更新后的配置实体 + */ @Transactional public SysConfig updateConfig(LoginUser currentUser, Long configId, UpdateSysConfigRequest request) { try { @@ -99,18 +131,20 @@ public class SysConfigService { existing.setConfigName(request.configName()); } + // 更新配置类型 if (StringUtils.hasText(request.configType())) { validateConfigType(request.configType()); existing.setConfigType(request.configType()); } + // 更新配置值(模型类型需要加密API密钥) if (StringUtils.hasText(request.configValue())) { - // 如果是model类型,对apiKey进行加密处理 if (ConfigType.MODEL.name().equalsIgnoreCase(existing.getConfigType())) { existing.setConfigValue(processModelConfigValue(request.configValue(), true)); } else { existing.setConfigValue(request.configValue()); } } + // 更新配置状态 if (StringUtils.hasText(request.status())) { existing.setStatus(request.status()); } @@ -131,6 +165,13 @@ public class SysConfigService { } } + /** + * 分页查询系统配置列表 + * + * @param currentUser 当前登录用户 + * @param query 分页查询条件 + * @return 分页配置列表 + */ public PageResult pageConfigs(LoginUser currentUser, SysConfigPageQuery query) { try { // List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); @@ -166,6 +207,12 @@ public class SysConfigService { } } + /** + * 转换为配置响应对象 + * + * @param config 配置实体 + * @return 配置响应对象 + */ public SysConfigResponse toResponse(SysConfig config) { // 如果是模型配置,需要解密API密钥 if (ConfigType.MODEL.name().equalsIgnoreCase(config.getConfigType()) && config.getConfigValue() != null) { @@ -208,6 +255,13 @@ public class SysConfigService { ); } + /** + * 获取配置实体(内部方法) + * + * @param currentUser 当前登录用户 + * @param configId 配置ID + * @return 配置实体 + */ private SysConfig getConfigEntity(LoginUser currentUser, Long configId) { SysConfig config = sysConfigMapper.selectById(configId); if (config == null || !currentUser.companyId().equals(config.getCompanyId())) { @@ -216,6 +270,11 @@ public class SysConfigService { return config; } + /** + * 验证配置类型 + * + * @param configType 配置类型 + */ private void validateConfigType(String configType) { if (!ConfigType.isValid(configType)) { throw new BusinessException(ResultCode.BAD_REQUEST, "配置类型非法"); @@ -253,18 +312,27 @@ public class SysConfigService { /** * 根据ID获取配置实体 + * + * @param configId 配置ID + * @return 配置实体 */ public SysConfig getById(Long configId) { return sysConfigMapper.selectById(configId); } /** - * 获取配置详情(根据配置ID查询数据库判断类型并返回SysConfigResponse格式) + * 获取配置详情 + * + *

根据配置ID查询数据库,对于模型配置会解密API密钥后返回。 + * + * @param currentUser 当前登录用户 + * @param configId 配置ID + * @return 配置响应对象 */ public SysConfigResponse getConfigDetail(LoginUser currentUser, Long configId) { SysConfig config = getConfigEntity(currentUser, configId); - // 如果是模型配置,我们需要在返回前处理API密钥解密 + // 如果是模型配置,需要在返回前处理API密钥解密 if (ConfigType.MODEL.name().equalsIgnoreCase(config.getConfigType())) { if (config.getConfigValue() != null) { try { @@ -275,7 +343,7 @@ public class SysConfigService { String decryptedApiKey = SM4Util.decryptSafe(model.getApiKey()); model.setApiKey(decryptedApiKey); - // 将更新后的对象转回JSON字符串,但这次API密钥是解密的 + // 将更新后的对象转回JSON字符串 String updatedConfigValue = objectMapper.writeValueAsString(model); config.setConfigValue(updatedConfigValue); } @@ -286,12 +354,15 @@ public class SysConfigService { } } - // 总是返回SysConfigResponse对象 return toResponse(config); } /** * 获取公司特定类型的配置实体列表 + * + * @param companyId 公司ID + * @param configType 配置类型 + * @return 配置列表 */ public List getCompanyConfigsByType(Long companyId, String configType) { LambdaQueryWrapper wrapper = @@ -358,5 +429,6 @@ public class SysConfigService { log.error("syncUpdateAgentConfigs failed, configId={}, error={}", configId, e.getMessage(), e); throw e; } + } } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/UserService.java b/src/main/java/com/labelsys/backend/service/UserService.java index 86f1ee4..f295710 100644 --- a/src/main/java/com/labelsys/backend/service/UserService.java +++ b/src/main/java/com/labelsys/backend/service/UserService.java @@ -28,18 +28,48 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 用户服务 + * + * 提供用户的创建、查询、更新等核心业务操作, + * 支持不同角色的用户管理,包括公司管理员和系统管理员。 + */ @Slf4j @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 SysUserMapper sysUserMapper; + + /** + * 公司数据访问层 + */ + private final SysCompanyMapper sysCompanyMapper; + + /** + * 密码加密器 + */ + private final PasswordEncoder passwordEncoder; + + /** + * Token会话存储库 + */ private final TokenSessionRepository tokenSessionRepository; + /** + * 查询所有管理员用户(超级管理员专用) + * + * @param currentUser 当前登录用户 + * @return 用户列表 + */ public List listAllUsers(LoginUser currentUser) { try { assertSystemAdmin(currentUser); @@ -57,6 +87,12 @@ public class UserService { } } + /** + * 查询公司用户列表(公司管理员专用) + * + * @param currentUser 当前登录用户 + * @return 用户列表 + */ public List listCompanyUsers(LoginUser currentUser) { try { assertCompanyAdmin(currentUser); @@ -72,6 +108,13 @@ public class UserService { } } + /** + * 查询指定公司的管理员列表(超级管理员专用) + * + * @param currentUser 当前登录用户 + * @param companyId 公司ID + * @return 用户列表 + */ public List listCompanyAdmins(LoginUser currentUser, Long companyId) { try { assertSystemAdmin(currentUser); @@ -85,6 +128,13 @@ public class UserService { } } + /** + * 创建公司管理员(超级管理员专用) + * + * @param currentUser 当前登录用户 + * @param request 创建请求 + * @return 创建的用户 + */ public SysUser createCompanyAdmin(LoginUser currentUser, CreateCompanyAdminRequest request) { try { assertSystemAdmin(currentUser); @@ -101,6 +151,13 @@ public class UserService { } } + /** + * 创建系统工程师管理员(超级管理员专用) + * + * @param currentUser 当前登录用户 + * @param request 创建请求 + * @return 创建的用户 + */ public SysUser createSystemEngineerAdmin(LoginUser currentUser, CreateSystemEngineerAdminRequest request) { try { assertSystemAdmin(currentUser); @@ -126,6 +183,13 @@ public class UserService { } } + /** + * 创建公司用户(公司管理员专用) + * + * @param currentUser 当前登录用户 + * @param request 创建请求 + * @return 创建的用户 + */ public SysUser createCompanyUser(LoginUser currentUser, CreateUserRequest request) { try { assertCompanyAdmin(currentUser); @@ -139,6 +203,14 @@ public class UserService { } } + /** + * 创建指定公司的用户(超级管理员专用) + * + * @param currentUser 当前登录用户 + * @param companyId 公司ID + * @param request 创建请求 + * @return 创建的用户 + */ public SysUser createCompanyUser(LoginUser currentUser, Long companyId, CreateUserRequest request) { try { assertSystemAdmin(currentUser); @@ -153,6 +225,13 @@ public class UserService { } } + /** + * 更新用户角色和岗位(公司管理员专用) + * + * @param currentUser 当前登录用户 + * @param userId 用户ID + * @param request 更新请求 + */ @Transactional public void updateAssignment(LoginUser currentUser, Long userId, UpdateUserAssignmentRequest request) { try { @@ -161,6 +240,7 @@ public class UserService { == 0) { throw new BusinessException(ResultCode.NOT_FOUND, "用户不存在"); } + // 强制用户重新登录 tokenSessionRepository.removeAll(userId); } catch (BusinessException e) { throw e; @@ -171,6 +251,13 @@ public class UserService { } } + /** + * 更新用户状态(公司管理员专用) + * + * @param currentUser 当前登录用户 + * @param userId 用户ID + * @param request 更新请求 + */ @Transactional public void updateStatus(LoginUser currentUser, Long userId, UpdateUserStatusRequest request) { try { @@ -178,6 +265,7 @@ public class UserService { if (sysUserMapper.updateStatus(userId, currentUser.companyId(), request.status()) == 0) { throw new BusinessException(ResultCode.NOT_FOUND, "用户不存在"); } + // 强制用户重新登录 tokenSessionRepository.removeAll(userId); } catch (BusinessException e) { throw e; @@ -188,6 +276,14 @@ public class UserService { } } + /** + * 更新公司管理员状态(超级管理员专用) + * + * @param currentUser 当前登录用户 + * @param companyId 公司ID + * @param userId 用户ID + * @param request 更新请求 + */ @Transactional public void updateCompanyAdminStatus(LoginUser currentUser, Long companyId, Long userId, UpdateUserStatusRequest request) { @@ -196,6 +292,7 @@ public class UserService { if (sysUserMapper.updateStatus(userId, companyId, request.status()) == 0) { throw new BusinessException(ResultCode.NOT_FOUND, "用户不存在"); } + // 强制用户重新登录 tokenSessionRepository.removeAll(userId); } catch (BusinessException e) { throw e; @@ -207,6 +304,13 @@ public class UserService { } } + /** + * 创建用户(内部方法) + * + * @param companyId 公司ID + * @param request 创建请求 + * @return 创建的用户 + */ private SysUser createUser(Long companyId, CreateUserRequest request) { if (sysUserMapper.findByCompanyIdAndPhone(companyId, request.phone()) != null) { throw new BusinessException(ResultCode.CONFLICT, "同一公司内手机号已存在"); @@ -221,6 +325,11 @@ public class UserService { return user; } + /** + * 确保公司存在且已启用 + * + * @param companyId 公司ID + */ private void ensureEnabledCompany(Long companyId) { SysCompany company = sysCompanyMapper.selectById(companyId); if (company == null || company.getStatus() != CompanyStatus.ENABLED) { @@ -234,15 +343,26 @@ public class UserService { // } // } + /** + * 断言超级管理员权限 + * + * @param currentUser 当前登录用户 + */ private void assertSystemAdmin(LoginUser currentUser) { if (!currentUser.isSuperAdmin()) { throw new ForbiddenException("仅超级管理员可操作"); } } + /** + * 断言公司管理员权限 + * + * @param currentUser 当前登录用户 + */ private void assertCompanyAdmin(LoginUser currentUser) { if (currentUser.isSuperAdmin() || currentUser.position() != UserPosition.ADMIN) { throw new ForbiddenException("仅公司管理员可操作"); } } -} + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/util/IdGenerator.java b/src/main/java/com/labelsys/backend/util/IdGenerator.java index 4097e2d..5cf665a 100644 --- a/src/main/java/com/labelsys/backend/util/IdGenerator.java +++ b/src/main/java/com/labelsys/backend/util/IdGenerator.java @@ -2,15 +2,34 @@ package com.labelsys.backend.util; import java.util.concurrent.atomic.AtomicInteger; +/** + * ID 生成器 + * 使用时间戳 + 序列号的方式生成唯一ID, + * 确保在同一毫秒内生成多个ID时不会重复。 + */ public final class IdGenerator { + /** + * 序列号,范围 0-999,超过后自动归零 + */ private static final AtomicInteger SEQUENCE = new AtomicInteger(); + /** + * 私有构造函数,防止实例化 + */ private IdGenerator() { } + /** + * 生成下一个唯一ID + * ID 结构:时间戳(毫秒) * 1000 + 序列号(0-999) + * 序列号在同一毫秒内递增,超过 999 时归零。 + * + * @return 唯一ID + */ public static long nextId() { int sequence = SEQUENCE.updateAndGet(value -> value >= 999 ? 0 : value + 1); return System.currentTimeMillis() * 1000 + sequence; } -} + +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/util/ObjectStoragePathBuilder.java b/src/main/java/com/labelsys/backend/util/ObjectStoragePathBuilder.java index 30574a1..efbc4f3 100644 --- a/src/main/java/com/labelsys/backend/util/ObjectStoragePathBuilder.java +++ b/src/main/java/com/labelsys/backend/util/ObjectStoragePathBuilder.java @@ -3,36 +3,79 @@ package com.labelsys.backend.util; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +/** + * 对象存储路径构建器 + * + * 提供对象存储中文件路径的统一构建方法, + * 按照固定格式生成源文件和结果文件的存储路径。 + */ public final class ObjectStoragePathBuilder { + /** + * 年月格式化器,格式为 yyyyMM(如 202401) + */ private static final DateTimeFormatter YEAR_MONTH = DateTimeFormatter.ofPattern("yyyyMM"); + /** + * 私有构造函数,防止实例化 + */ private ObjectStoragePathBuilder() { } + /** + * 构建源文件对象键 + * + * 路径格式:source/{companyId}/{category}/{yearMonth}/{resourceId}/original.{extension} + * + * @param companyId 公司ID + * @param resourceType 资源类型 + * @param resourceId 资源ID + * @param extension 文件扩展名 + * @return 对象键 + */ public static String sourceObjectKey(Long companyId, String resourceType, Long resourceId, String extension) { String category = resourceType.toLowerCase(); return "source/%d/%s/%s/%d/original.%s".formatted( - companyId, - category, - YEAR_MONTH.format(LocalDateTime.now()), - resourceId, - extension.toLowerCase()); + companyId, + category, + YEAR_MONTH.format(LocalDateTime.now()), + resourceId, + extension.toLowerCase()); } + /** + * 构建标注结果 QA 文件对象键 + * + * 路径格式:result/{companyId}/{yearMonth}/{taskId}/{resultId}/qa.json + * + * @param companyId 公司ID + * @param taskId 任务ID + * @param resultId 结果ID + * @return 对象键 + */ public static String resultQaObjectKey(Long companyId, Long taskId, Long resultId) { return "result/%d/%s/%d/%d/qa.json".formatted( - companyId, - YEAR_MONTH.format(LocalDateTime.now()), - taskId, - resultId); + companyId, + YEAR_MONTH.format(LocalDateTime.now()), + taskId, + resultId); } + /** + * 构建标注结果差异文件对象键 + * + * 路径格式:result/{companyId}/{yearMonth}/{taskId}/{resultId}/diff.json + * + * @param companyId 公司ID + * @param taskId 任务ID + * @param resultId 结果ID + * @return 对象键 + */ public static String resultDiffObjectKey(Long companyId, Long taskId, Long resultId) { return "result/%d/%s/%d/%d/diff.json".formatted( - companyId, - YEAR_MONTH.format(LocalDateTime.now()), - taskId, - resultId); + companyId, + YEAR_MONTH.format(LocalDateTime.now()), + taskId, + resultId); } -} +} \ No newline at end of file