mcp权限添加、界面加载添加懒加载、docker镜像打包配置文件
parent
5da2565298
commit
f3966a45ed
|
|
@ -0,0 +1,11 @@
|
|||
target/*
|
||||
!target/unis-crm-backend-1.0.0-SNAPSHOT.jar
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
*.md
|
||||
*.log
|
||||
.DS_Store
|
||||
build
|
||||
sql
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# ============ 运行阶段 ============
|
||||
FROM eclipse-temurin:17-jre
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 依赖 com.unisbase 为内部 Maven 依赖,在镜像构建前由 CI/本地 Maven 构建产物
|
||||
COPY target/unis-crm-backend-1.0.0-SNAPSHOT.jar app.jar
|
||||
|
||||
# 创建日志目录
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# 默认使用 prod profile,可通过环境变量覆盖
|
||||
ENV SPRING_PROFILES_ACTIVE=prod
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<mybatis-plus.version>3.5.6</mybatis-plus.version>
|
||||
<minio.version>8.5.17</minio.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
|
@ -53,6 +54,11 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
<version>${minio.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.unis.crm.config.WecomProperties;
|
|||
import com.unis.crm.config.InternalAuthProperties;
|
||||
import com.unis.crm.config.OmsProperties;
|
||||
import com.unis.crm.config.WorkReportProperties;
|
||||
import com.unis.crm.config.MinioProperties;
|
||||
import java.util.TimeZone;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
|
|
@ -13,7 +14,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||
|
||||
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
||||
@MapperScan({"com.unis.crm.mapper", "com.unis.crm.llm.mapper"})
|
||||
@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class, WorkReportProperties.class})
|
||||
@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class, WorkReportProperties.class, MinioProperties.class})
|
||||
@EnableScheduling
|
||||
public class UnisCrmBackendApplication {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
package com.unis.crm.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
public class InternalIntegrationSecurityConfig {
|
||||
|
||||
@Bean
|
||||
public WebSecurityCustomizer internalIntegrationWebSecurityCustomizer() {
|
||||
return web -> web.ignoring().requestMatchers("/api/opportunities/integration/**");
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain internalIntegrationSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http.securityMatcher("/api/opportunities/integration/**")
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.unis.crm.config;
|
||||
|
||||
import io.minio.MinioClient;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MinioConfig {
|
||||
|
||||
@Bean
|
||||
public MinioClient minioClient(MinioProperties properties) {
|
||||
return MinioClient.builder()
|
||||
.endpoint(properties.getEndpointUrl())
|
||||
.credentials(properties.getAccessKey(), properties.getSecretKey())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package com.unis.crm.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "minio")
|
||||
public class MinioProperties {
|
||||
|
||||
private String endpoint;
|
||||
private String accessKey;
|
||||
private String secretKey;
|
||||
private String bucket;
|
||||
private String basePath;
|
||||
private boolean useSsl;
|
||||
|
||||
public String getEndpoint() {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
public void setEndpoint(String endpoint) {
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
public String getAccessKey() {
|
||||
return accessKey;
|
||||
}
|
||||
|
||||
public void setAccessKey(String accessKey) {
|
||||
this.accessKey = accessKey;
|
||||
}
|
||||
|
||||
public String getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public void setSecretKey(String secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
public String getBucket() {
|
||||
return bucket;
|
||||
}
|
||||
|
||||
public void setBucket(String bucket) {
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
public String getBasePath() {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
public void setBasePath(String basePath) {
|
||||
this.basePath = basePath;
|
||||
}
|
||||
|
||||
public boolean isUseSsl() {
|
||||
return useSsl;
|
||||
}
|
||||
|
||||
public void setUseSsl(boolean useSsl) {
|
||||
this.useSsl = useSsl;
|
||||
}
|
||||
|
||||
public String getEndpointUrl() {
|
||||
String value = endpoint == null ? "" : endpoint.trim();
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
return value;
|
||||
}
|
||||
return (useSsl ? "https://" : "http://") + value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package com.unis.crm.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
public class WecomSsoSecurityConfig {
|
||||
|
||||
@Bean
|
||||
public WebSecurityCustomizer wecomSsoWebSecurityCustomizer() {
|
||||
return web -> web.ignoring().requestMatchers("/api/wecom/sso/**");
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(0)
|
||||
public SecurityFilterChain wecomSsoSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http.securityMatcher("/api/wecom/sso/**")
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -54,8 +54,10 @@ public class ExpansionController {
|
|||
@GetMapping("/overview")
|
||||
public ApiResponse<ExpansionOverviewDTO> getOverview(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
return ApiResponse.success(expansionService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword));
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "includeDetails", defaultValue = "true") boolean includeDetails,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
return ApiResponse.success(expansionService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, includeDetails, limit));
|
||||
}
|
||||
|
||||
@GetMapping("/opportunity-form-options")
|
||||
|
|
|
|||
|
|
@ -42,8 +42,11 @@ public class OpportunityController {
|
|||
public ApiResponse<OpportunityOverviewDTO> getOverview(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "stage", required = false) String stage) {
|
||||
return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage));
|
||||
@RequestParam(value = "stage", required = false) String stage,
|
||||
@RequestParam(value = "includeDetails", defaultValue = "true") boolean includeDetails,
|
||||
@RequestParam(value = "limit", required = false) Integer limit,
|
||||
@RequestParam(value = "archived", required = false) Boolean archived) {
|
||||
return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage, includeDetails, limit, archived));
|
||||
}
|
||||
|
||||
@GetMapping("/{opportunityId}")
|
||||
|
|
|
|||
|
|
@ -5,18 +5,16 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import com.unisbase.annotation.DataScope;
|
||||
|
||||
@Mapper
|
||||
public interface LlmMcpMapper {
|
||||
|
||||
Map<String, Object> selectUserProfile(@Param("userId") Long userId);
|
||||
|
||||
List<Map<String, Object>> selectUserRoles(@Param("userId") Long userId);
|
||||
List<Map<String, Object>> selectUserRoles(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
|
||||
|
||||
List<Map<String, Object>> selectUserOrgs(@Param("userId") Long userId);
|
||||
List<Map<String, Object>> selectUserOrgs(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "r", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> searchWorkReports(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
|
|
@ -27,7 +25,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> searchOpportunities(
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
@Param("tenantId") Long tenantId,
|
||||
|
|
@ -37,7 +34,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> universalSearchCustomers(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
|
|
@ -46,7 +42,6 @@ public interface LlmMcpMapper {
|
|||
@Param("endDate") LocalDate endDate,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> universalSearchOpportunities(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
|
|
@ -55,7 +50,6 @@ public interface LlmMcpMapper {
|
|||
@Param("endDate") LocalDate endDate,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> universalSearchSalesExpansions(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
|
|
@ -64,7 +58,6 @@ public interface LlmMcpMapper {
|
|||
@Param("endDate") LocalDate endDate,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> universalSearchChannelExpansions(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
|
|
@ -73,7 +66,6 @@ public interface LlmMcpMapper {
|
|||
@Param("endDate") LocalDate endDate,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "r", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> universalSearchWorkReports(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
|
|
@ -82,7 +74,6 @@ public interface LlmMcpMapper {
|
|||
@Param("endDate") LocalDate endDate,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> universalSearchCheckins(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
|
|
@ -91,7 +82,6 @@ public interface LlmMcpMapper {
|
|||
@Param("endDate") LocalDate endDate,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "f", ownerColumn = "followup_user_id")
|
||||
List<Map<String, Object>> universalSearchFollowups(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
|
|
@ -100,7 +90,6 @@ public interface LlmMcpMapper {
|
|||
@Param("endDate") LocalDate endDate,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "t", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> universalSearchTodos(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
|
|
@ -109,7 +98,6 @@ public interface LlmMcpMapper {
|
|||
@Param("endDate") LocalDate endDate,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "l", ownerColumn = "operator_user_id")
|
||||
List<Map<String, Object>> universalSearchActivities(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
|
|
@ -118,42 +106,36 @@ public interface LlmMcpMapper {
|
|||
@Param("endDate") LocalDate endDate,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
Map<String, Object> dashboardCustomerMetric(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
@Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
Map<String, Object> dashboardNewOpportunityMetric(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
@Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
Map<String, Object> dashboardWonOpportunityMetric(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
@Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "r", ownerColumn = "user_id")
|
||||
Map<String, Object> dashboardDailyReportMetric(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
@Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "user_id")
|
||||
Map<String, Object> dashboardCheckinMetric(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
@Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "u", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> salesPerformance(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
|
|
@ -162,7 +144,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> opportunityFunnel(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
|
|
@ -171,7 +152,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> opportunityTrend(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
|
|
@ -181,7 +161,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "u", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> dailyReportCompletion(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
|
|
@ -191,7 +170,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> checkinSummary(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
|
|
@ -201,21 +179,18 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||
Map<String, Object> salesExpansionSummary(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
@Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
Map<String, Object> channelExpansionSummary(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("ownerUserId") Long ownerUserId,
|
||||
@Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> customerSummary(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
|
|
@ -225,7 +200,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "t", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> todoSummary(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
|
|
@ -235,13 +209,11 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "u", ownerColumn = "user_id")
|
||||
Map<String, Object> selectWorkTodayStatus(
|
||||
@Param("targetUserId") Long targetUserId,
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("queryDate") LocalDate queryDate);
|
||||
|
||||
@DataScope(tableAlias = "t", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> searchTodos(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("status") String status,
|
||||
|
|
@ -254,7 +226,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "u", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> searchOrgUsers(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("status") Integer status,
|
||||
|
|
@ -274,7 +245,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> searchCustomers(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("status") String status,
|
||||
|
|
@ -287,7 +257,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> searchCheckins(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("status") String status,
|
||||
|
|
@ -299,7 +268,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> searchSalesExpansions(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("stage") String stage,
|
||||
|
|
@ -311,7 +279,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> searchChannelExpansions(
|
||||
@Param("keyword") String keyword,
|
||||
@Param("stage") String stage,
|
||||
|
|
@ -323,7 +290,6 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "f", ownerColumn = "followup_user_id")
|
||||
List<Map<String, Object>> searchFollowups(
|
||||
@Param("bizType") String bizType,
|
||||
@Param("keyword") String keyword,
|
||||
|
|
@ -334,49 +300,34 @@ public interface LlmMcpMapper {
|
|||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
Map<String, Object> selectCustomerDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
Map<String, Object> selectOpportunityDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "r", ownerColumn = "user_id")
|
||||
Map<String, Object> selectWorkReportDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "user_id")
|
||||
Map<String, Object> selectCheckinDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||
Map<String, Object> selectSalesExpansionDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
Map<String, Object> selectChannelExpansionDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "t", ownerColumn = "user_id")
|
||||
Map<String, Object> selectTodoDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> selectCustomerOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> selectOpportunityFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "r", ownerColumn = "user_id")
|
||||
List<Map<String, Object>> selectWorkReportComments(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> selectSalesExpansionFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> selectSalesExpansionOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> selectChannelExpansionContacts(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> selectChannelExpansionFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
List<Map<String, Object>> selectChannelExpansionOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
|
||||
|
||||
List<Map<String, Object>> selectDictTypes(@Param("keyword") String keyword, @Param("limit") int limit, @Param("offset") int offset);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,462 @@
|
|||
package com.unis.crm.llm.security;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.service.CrmDataVisibilityService;
|
||||
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
|
||||
import com.unis.crm.service.CrmDataVisibilityService.OwnerAreaRule;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import java.sql.Connection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||
import net.sf.jsqlparser.statement.Statement;
|
||||
import net.sf.jsqlparser.statement.select.ParenthesedSelect;
|
||||
import net.sf.jsqlparser.statement.select.PlainSelect;
|
||||
import net.sf.jsqlparser.statement.select.Select;
|
||||
import net.sf.jsqlparser.statement.select.SetOperationList;
|
||||
import net.sf.jsqlparser.statement.select.WithItem;
|
||||
import org.apache.ibatis.executor.statement.StatementHandler;
|
||||
import org.apache.ibatis.mapping.BoundSql;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.plugin.Interceptor;
|
||||
import org.apache.ibatis.plugin.Intercepts;
|
||||
import org.apache.ibatis.plugin.Invocation;
|
||||
import org.apache.ibatis.plugin.Plugin;
|
||||
import org.apache.ibatis.plugin.Signature;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.apache.ibatis.reflection.SystemMetaObject;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@Intercepts(@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}))
|
||||
public class McpDataPermissionInterceptor implements Interceptor {
|
||||
|
||||
private static final String MAPPER_PREFIX = "com.unis.crm.llm.mapper.LlmMcpMapper.";
|
||||
private static final Map<String, QueryPolicy> POLICIES = buildPolicies();
|
||||
|
||||
private final ObjectProvider<CrmDataVisibilityService> visibilityServiceProvider;
|
||||
|
||||
public McpDataPermissionInterceptor(ObjectProvider<CrmDataVisibilityService> visibilityServiceProvider) {
|
||||
this.visibilityServiceProvider = visibilityServiceProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object intercept(Invocation invocation) throws Throwable {
|
||||
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
|
||||
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
|
||||
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
|
||||
if (mappedStatement == null || !mappedStatement.getId().startsWith(MAPPER_PREFIX)) {
|
||||
return invocation.proceed();
|
||||
}
|
||||
|
||||
LoginUser loginUser = requireLoginUser();
|
||||
String methodName = mappedStatement.getId().substring(MAPPER_PREFIX.length());
|
||||
BoundSql boundSql = statementHandler.getBoundSql();
|
||||
validateParameters(methodName, boundSql.getParameterObject(), loginUser);
|
||||
|
||||
CrmDataVisibilityService visibilityService = visibilityServiceProvider.getIfAvailable();
|
||||
if ("searchFollowups".equals(methodName)) {
|
||||
if (visibilityService == null) {
|
||||
throw new BusinessException("MCP 数据权限服务不可用");
|
||||
}
|
||||
DataVisibility opportunityVisibility = visibilityService.resolveVisibility(
|
||||
loginUser.getUserId(), loginUser.getTenantId(), CrmDataVisibilityService.RESOURCE_OPPORTUNITY);
|
||||
DataVisibility expansionVisibility = visibilityService.resolveVisibility(
|
||||
loginUser.getUserId(), loginUser.getTenantId(), CrmDataVisibilityService.RESOURCE_EXPANSION);
|
||||
String condition = buildFollowupVisibilityCondition(opportunityVisibility, expansionVisibility, loginUser);
|
||||
PluginUtils.mpBoundSql(boundSql).sql(rewriteSql(boundSql.getSql(), condition));
|
||||
return invocation.proceed();
|
||||
}
|
||||
if ("salesPerformance".equals(methodName)) {
|
||||
if (visibilityService == null) {
|
||||
throw new BusinessException("MCP 数据权限服务不可用");
|
||||
}
|
||||
Map<String, DataVisibility> visibilityByResource = new LinkedHashMap<>();
|
||||
for (String resourceType : List.of(
|
||||
CrmDataVisibilityService.RESOURCE_CUSTOMER,
|
||||
CrmDataVisibilityService.RESOURCE_OPPORTUNITY,
|
||||
CrmDataVisibilityService.RESOURCE_DAILY_REPORT,
|
||||
CrmDataVisibilityService.RESOURCE_CHECKIN)) {
|
||||
visibilityByResource.put(resourceType, visibilityService.resolveVisibility(
|
||||
loginUser.getUserId(), loginUser.getTenantId(), resourceType));
|
||||
}
|
||||
PluginUtils.mpBoundSql(boundSql).sql(rewriteSalesPerformanceSql(
|
||||
boundSql.getSql(), visibilityByResource));
|
||||
return invocation.proceed();
|
||||
}
|
||||
|
||||
QueryPolicy policy = POLICIES.get(methodName);
|
||||
if (policy != null) {
|
||||
if (visibilityService == null) {
|
||||
throw new BusinessException("MCP 数据权限服务不可用");
|
||||
}
|
||||
DataVisibility visibility = visibilityService.resolveVisibility(
|
||||
loginUser.getUserId(), loginUser.getTenantId(), policy.resourceType());
|
||||
String condition = buildVisibilityCondition(methodName, visibility, loginUser);
|
||||
PluginUtils.mpBoundSql(boundSql).sql(rewriteSql(boundSql.getSql(), condition));
|
||||
}
|
||||
return invocation.proceed();
|
||||
}
|
||||
|
||||
String buildFollowupVisibilityCondition(
|
||||
DataVisibility opportunityVisibility,
|
||||
DataVisibility expansionVisibility,
|
||||
LoginUser loginUser) {
|
||||
QueryPolicy opportunityPolicy = new QueryPolicy(
|
||||
CrmDataVisibilityService.RESOURCE_OPPORTUNITY,
|
||||
"\"scopeOwnerUserId\"",
|
||||
"\"scopeAreaCode\"",
|
||||
false,
|
||||
"");
|
||||
String opportunityOwner = opportunityVisibility != null && opportunityVisibility.allDataAccess()
|
||||
? "1 = 1"
|
||||
: buildOwnerAreaCondition(
|
||||
opportunityPolicy,
|
||||
opportunityVisibility == null ? List.of() : opportunityVisibility.visibleOwnerAreaRules());
|
||||
String opportunityCondition = addProjectedPreSalesCondition(opportunityOwner, loginUser);
|
||||
String expansionCondition = expansionVisibility != null && expansionVisibility.allDataAccess()
|
||||
? "1 = 1"
|
||||
: buildOwnerCondition(
|
||||
"\"scopeOwnerUserId\"",
|
||||
expansionVisibility == null ? List.of() : expansionVisibility.visibleOwnerUserIds());
|
||||
return "((\"scopeResourceType\" = 'OPPORTUNITY' and (" + opportunityCondition + "))"
|
||||
+ " or (\"scopeResourceType\" = 'EXPANSION' and (" + expansionCondition + ")))";
|
||||
}
|
||||
|
||||
private String addProjectedPreSalesCondition(String ownerCondition, LoginUser loginUser) {
|
||||
StringBuilder condition = new StringBuilder("(").append(ownerCondition)
|
||||
.append(" or \"scopePreSalesId\" = ").append(loginUser.getUserId());
|
||||
List<String> names = currentUserNames(loginUser);
|
||||
if (!names.isEmpty()) {
|
||||
condition.append(" or nullif(btrim(\"scopePreSalesName\"), '') in (")
|
||||
.append(names.stream().map(this::quote).collect(Collectors.joining(",")))
|
||||
.append(")");
|
||||
}
|
||||
return condition.append(")").toString();
|
||||
}
|
||||
|
||||
String rewriteSalesPerformanceSql(String sql, Map<String, DataVisibility> visibilityByResource) {
|
||||
DataVisibility customer = visibilityByResource.get(CrmDataVisibilityService.RESOURCE_CUSTOMER);
|
||||
DataVisibility opportunity = visibilityByResource.get(CrmDataVisibilityService.RESOURCE_OPPORTUNITY);
|
||||
DataVisibility dailyReport = visibilityByResource.get(CrmDataVisibilityService.RESOURCE_DAILY_REPORT);
|
||||
DataVisibility checkin = visibilityByResource.get(CrmDataVisibilityService.RESOURCE_CHECKIN);
|
||||
|
||||
Map<String, String> cteConditions = new LinkedHashMap<>();
|
||||
cteConditions.put("users_scope", buildUnionOwnerCondition(
|
||||
"u.user_id", List.of(customer, opportunity, dailyReport, checkin)));
|
||||
cteConditions.put("customer_stats", scopedOwnerCondition("c.owner_user_id", customer));
|
||||
cteConditions.put("opportunity_stats", scopedOpportunityOwnerCondition(opportunity));
|
||||
cteConditions.put("followup_stats", scopedOpportunityOwnerCondition(opportunity));
|
||||
cteConditions.put("report_stats", scopedOwnerCondition("r.user_id", dailyReport));
|
||||
cteConditions.put("checkin_stats", scopedOwnerCondition("ck.user_id", checkin));
|
||||
|
||||
try {
|
||||
Statement statement = CCJSqlParserUtil.parse(sql);
|
||||
if (!(statement instanceof Select select) || select.getWithItemsList() == null) {
|
||||
throw new BusinessException("MCP 经营业绩报表缺少权限分段");
|
||||
}
|
||||
java.util.LinkedHashSet<String> applied = new java.util.LinkedHashSet<>();
|
||||
for (WithItem withItem : select.getWithItemsList()) {
|
||||
String name = withItem.getAlias() == null ? null : withItem.getAlias().getName();
|
||||
String conditionSql = cteConditions.get(name);
|
||||
if (conditionSql == null) {
|
||||
continue;
|
||||
}
|
||||
if (!applyCondition(withItem.getSelect(), CCJSqlParserUtil.parseCondExpression(conditionSql))) {
|
||||
throw new BusinessException("MCP 无法应用经营业绩分段权限");
|
||||
}
|
||||
applied.add(name);
|
||||
}
|
||||
if (!applied.equals(cteConditions.keySet())) {
|
||||
throw new BusinessException("MCP 经营业绩权限分段不完整");
|
||||
}
|
||||
return statement.toString();
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("MCP 经营业绩权限 SQL 处理失败");
|
||||
}
|
||||
}
|
||||
|
||||
private String scopedOwnerCondition(String ownerColumn, DataVisibility visibility) {
|
||||
return visibility != null && visibility.allDataAccess()
|
||||
? "1 = 1"
|
||||
: buildOwnerCondition(ownerColumn, visibility == null ? List.of() : visibility.visibleOwnerUserIds());
|
||||
}
|
||||
|
||||
private String scopedOpportunityOwnerCondition(DataVisibility visibility) {
|
||||
QueryPolicy policy = new QueryPolicy(
|
||||
CrmDataVisibilityService.RESOURCE_OPPORTUNITY,
|
||||
"o.owner_user_id",
|
||||
"o.project_ownership_location",
|
||||
false,
|
||||
"o");
|
||||
return visibility != null && visibility.allDataAccess()
|
||||
? "1 = 1"
|
||||
: buildOwnerAreaCondition(policy, visibility == null ? List.of() : visibility.visibleOwnerAreaRules());
|
||||
}
|
||||
|
||||
private String buildUnionOwnerCondition(String ownerColumn, List<DataVisibility> visibilities) {
|
||||
if (visibilities.stream().anyMatch(value -> value != null && value.allDataAccess())) {
|
||||
return "1 = 1";
|
||||
}
|
||||
List<Long> ownerUserIds = visibilities.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.flatMap(value -> value.visibleOwnerUserIds().stream())
|
||||
.distinct()
|
||||
.toList();
|
||||
return buildOwnerCondition(ownerColumn, ownerUserIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object plugin(Object target) {
|
||||
return Plugin.wrap(target, this);
|
||||
}
|
||||
|
||||
private LoginUser requireLoginUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) {
|
||||
throw new BusinessException("MCP 数据查询缺少认证上下文");
|
||||
}
|
||||
if (loginUser.getUserId() == null || loginUser.getUserId() <= 0
|
||||
|| loginUser.getTenantId() == null || loginUser.getTenantId() <= 0) {
|
||||
throw new BusinessException("MCP 数据查询缺少有效用户或租户");
|
||||
}
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
private void validateParameters(String methodName, Object parameterObject, LoginUser loginUser) {
|
||||
Map<?, ?> parameters = parameterObject instanceof Map<?, ?> map ? map : Map.of();
|
||||
if (methodName.equals("selectUserProfile") || methodName.equals("selectUserRoles") || methodName.equals("selectUserOrgs")) {
|
||||
Long requestedUserId = asLong(parameters.get("userId"));
|
||||
if (!Objects.equals(requestedUserId, loginUser.getUserId())) {
|
||||
throw new BusinessException("MCP 禁止查询其他用户的账号画像");
|
||||
}
|
||||
if (parameters.containsKey("tenantId")
|
||||
&& !Objects.equals(asLong(parameters.get("tenantId")), loginUser.getTenantId())) {
|
||||
throw new BusinessException("MCP 用户画像租户与认证租户不一致");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (methodName.equals("selectDictTypes") || methodName.equals("selectDictOptions")) {
|
||||
return;
|
||||
}
|
||||
Long requestedTenantId = asLong(parameters.get("tenantId"));
|
||||
if (!Objects.equals(requestedTenantId, loginUser.getTenantId())) {
|
||||
throw new BusinessException("MCP 查询租户与认证租户不一致");
|
||||
}
|
||||
}
|
||||
|
||||
String buildVisibilityCondition(String methodName, DataVisibility visibility, LoginUser loginUser) {
|
||||
QueryPolicy policy = POLICIES.get(methodName);
|
||||
if (policy == null) {
|
||||
throw new BusinessException("MCP 查询未配置数据权限策略");
|
||||
}
|
||||
if (visibility != null && visibility.allDataAccess()) {
|
||||
return "1 = 1";
|
||||
}
|
||||
String ownerCondition = policy.areaColumn() == null
|
||||
? buildOwnerCondition(policy.ownerColumn(), visibility == null ? List.of() : visibility.visibleOwnerUserIds())
|
||||
: buildOwnerAreaCondition(policy, visibility == null ? List.of() : visibility.visibleOwnerAreaRules());
|
||||
if (!policy.preSalesVisible()) {
|
||||
return ownerCondition;
|
||||
}
|
||||
|
||||
StringBuilder condition = new StringBuilder("(").append(ownerCondition)
|
||||
.append(" or ").append(policy.opportunityAlias()).append(".pre_sales_id = ").append(loginUser.getUserId());
|
||||
List<String> names = currentUserNames(loginUser);
|
||||
if (!names.isEmpty()) {
|
||||
condition.append(" or nullif(btrim(").append(policy.opportunityAlias()).append(".pre_sales_name), '') in (")
|
||||
.append(names.stream().map(this::quote).collect(Collectors.joining(",")))
|
||||
.append(")");
|
||||
}
|
||||
return condition.append(")").toString();
|
||||
}
|
||||
|
||||
private List<String> currentUserNames(LoginUser loginUser) {
|
||||
return Stream.of(loginUser.getDisplayName(), loginUser.getUsername())
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Set<String> policyMethodNames() {
|
||||
java.util.LinkedHashSet<String> methodNames = new java.util.LinkedHashSet<>(POLICIES.keySet());
|
||||
methodNames.add("searchFollowups");
|
||||
methodNames.add("salesPerformance");
|
||||
return Set.copyOf(methodNames);
|
||||
}
|
||||
|
||||
private String buildOwnerCondition(String ownerColumn, List<Long> ownerUserIds) {
|
||||
String ids = normalizeIds(ownerUserIds);
|
||||
return ids.isEmpty() ? "1 = 0" : ownerColumn + " in (" + ids + ")";
|
||||
}
|
||||
|
||||
private String buildOwnerAreaCondition(QueryPolicy policy, List<OwnerAreaRule> rules) {
|
||||
if (rules == null || rules.isEmpty()) {
|
||||
return "1 = 0";
|
||||
}
|
||||
List<String> conditions = rules.stream()
|
||||
.filter(rule -> rule != null && rule.ownerUserId() != null && rule.ownerUserId() > 0)
|
||||
.map(rule -> {
|
||||
if (rule.allAreas()) {
|
||||
return policy.ownerColumn() + " = " + rule.ownerUserId();
|
||||
}
|
||||
String areas = rule.areaCodes() == null ? "" : rule.areaCodes().stream()
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.distinct()
|
||||
.map(this::quote)
|
||||
.collect(Collectors.joining(","));
|
||||
if (areas.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return "(" + policy.ownerColumn() + " = " + rule.ownerUserId()
|
||||
+ " and " + policy.areaColumn() + " in (" + areas + "))";
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
return conditions.isEmpty() ? "1 = 0" : "(" + String.join(" or ", conditions) + ")";
|
||||
}
|
||||
|
||||
String rewriteSql(String sql, String conditionSql) {
|
||||
try {
|
||||
Statement statement = CCJSqlParserUtil.parse(sql);
|
||||
if (!(statement instanceof Select select)) {
|
||||
throw new BusinessException("MCP 数据权限仅允许查询语句");
|
||||
}
|
||||
Expression condition = CCJSqlParserUtil.parseCondExpression(conditionSql);
|
||||
if (!applyCondition(select, condition)) {
|
||||
throw new BusinessException("MCP 无法对当前查询应用数据权限");
|
||||
}
|
||||
return statement.toString();
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("MCP 数据权限 SQL 处理失败");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean applyCondition(Select select, Expression condition) throws Exception {
|
||||
PlainSelect plainSelect = select.getPlainSelect();
|
||||
if (plainSelect != null) {
|
||||
Expression current = plainSelect.getWhere();
|
||||
plainSelect.setWhere(current == null
|
||||
? condition
|
||||
: CCJSqlParserUtil.parseCondExpression("(" + current + ") and (" + condition + ")"));
|
||||
return true;
|
||||
}
|
||||
SetOperationList setOperationList = select.getSetOperationList();
|
||||
if (setOperationList != null && setOperationList.getSelects() != null) {
|
||||
boolean applied = false;
|
||||
for (Select child : setOperationList.getSelects()) {
|
||||
applied = applyCondition(child, condition) || applied;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
if (select instanceof ParenthesedSelect parenthesedSelect) {
|
||||
return applyCondition(parenthesedSelect.getSelect(), condition);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String normalizeIds(List<Long> ids) {
|
||||
if (ids == null) {
|
||||
return "";
|
||||
}
|
||||
return ids.stream()
|
||||
.filter(value -> value != null && value > 0)
|
||||
.distinct()
|
||||
.map(String::valueOf)
|
||||
.collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
private Long asLong(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(String.valueOf(value));
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String quote(String value) {
|
||||
return "'" + value.replace("'", "''") + "'";
|
||||
}
|
||||
|
||||
private static Map<String, QueryPolicy> buildPolicies() {
|
||||
Map<String, QueryPolicy> policies = new LinkedHashMap<>();
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_DAILY_REPORT, "r.user_id", null, false,
|
||||
"searchWorkReports", "universalSearchWorkReports", "dashboardDailyReportMetric",
|
||||
"selectWorkReportDetail", "selectWorkReportComments");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_CHECKIN, "c.user_id", null, false,
|
||||
"universalSearchCheckins", "dashboardCheckinMetric", "checkinSummary",
|
||||
"searchCheckins", "selectCheckinDetail");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_CUSTOMER, "c.owner_user_id", null, false,
|
||||
"universalSearchCustomers", "dashboardCustomerMetric", "customerSummary",
|
||||
"searchCustomers", "selectCustomerDetail");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "s.owner_user_id", null, false,
|
||||
"universalSearchSalesExpansions", "salesExpansionSummary", "searchSalesExpansions",
|
||||
"selectSalesExpansionDetail", "selectSalesExpansionFollowups");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "c.owner_user_id", null, false,
|
||||
"universalSearchChannelExpansions", "channelExpansionSummary", "searchChannelExpansions",
|
||||
"selectChannelExpansionDetail", "selectChannelExpansionContacts", "selectChannelExpansionFollowups");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_WORK, "t.user_id", null, false,
|
||||
"universalSearchTodos", "searchTodos", "todoSummary", "selectTodoDetail");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_WORK, "l.operator_user_id", null, false,
|
||||
"universalSearchActivities");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_OPPORTUNITY, "o.owner_user_id",
|
||||
"o.project_ownership_location", true, "universalSearchFollowups");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_WORK, "u.user_id", null, false,
|
||||
"selectWorkTodayStatus");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_ALL, "u.user_id", null, false,
|
||||
"searchOrgUsers");
|
||||
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_OPPORTUNITY, "o.owner_user_id",
|
||||
"o.project_ownership_location", true,
|
||||
"searchOpportunities", "universalSearchOpportunities", "dashboardNewOpportunityMetric",
|
||||
"dashboardWonOpportunityMetric", "opportunityFunnel", "opportunityTrend",
|
||||
"selectOpportunityDetail", "selectCustomerOpportunities", "selectOpportunityFollowups",
|
||||
"selectSalesExpansionOpportunities", "selectChannelExpansionOpportunities");
|
||||
add(policies, CrmDataVisibilityService.RESOURCE_DAILY_REPORT, "e.user_id", null, false,
|
||||
"dailyReportCompletion");
|
||||
return Map.copyOf(policies);
|
||||
}
|
||||
|
||||
private static void add(
|
||||
Map<String, QueryPolicy> target,
|
||||
String resourceType,
|
||||
String ownerColumn,
|
||||
String areaColumn,
|
||||
boolean preSalesVisible,
|
||||
String... methodNames) {
|
||||
int separator = ownerColumn.indexOf('.');
|
||||
String alias = separator > 0 ? ownerColumn.substring(0, separator) : "";
|
||||
QueryPolicy policy = new QueryPolicy(resourceType, ownerColumn, areaColumn, preSalesVisible, alias);
|
||||
for (String methodName : methodNames) {
|
||||
target.put(methodName, policy);
|
||||
}
|
||||
}
|
||||
|
||||
private record QueryPolicy(
|
||||
String resourceType,
|
||||
String ownerColumn,
|
||||
String areaColumn,
|
||||
boolean preSalesVisible,
|
||||
String opportunityAlias) {
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.llm.mapper.LlmMcpMapper;
|
||||
import com.unis.crm.llm.tools.support.PermissionedMcpToolProvider;
|
||||
import com.unisbase.security.PermissionService;
|
||||
import com.unisbase.security.SpringSecurityTenantProvider;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
|
@ -18,17 +19,22 @@ import org.springframework.util.StringUtils;
|
|||
public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
|
||||
|
||||
private static final int MAX_DATE_RANGE_DAYS = 366;
|
||||
private static final String STATS_PERMISSION = "dashboard_stats_card:view";
|
||||
private static final String ANALYTICS_PERMISSION = "dashboard_analytics_card:view";
|
||||
|
||||
private final LlmMcpMapper llmMcpMapper;
|
||||
private final SpringSecurityTenantProvider tenantProvider;
|
||||
private final PermissionService permissionService;
|
||||
|
||||
public CrmReportQueryToolProvider(
|
||||
ObjectMapper objectMapper,
|
||||
LlmMcpMapper llmMcpMapper,
|
||||
SpringSecurityTenantProvider tenantProvider) {
|
||||
SpringSecurityTenantProvider tenantProvider,
|
||||
PermissionService permissionService) {
|
||||
super(objectMapper);
|
||||
this.llmMcpMapper = llmMcpMapper;
|
||||
this.tenantProvider = tenantProvider;
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -56,7 +62,7 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
|
|||
"todo_summary")));
|
||||
queryProperties.put("startDate", stringProperty("开始日期 YYYY-MM-DD;不传默认本月。"));
|
||||
queryProperties.put("endDate", stringProperty("结束日期 YYYY-MM-DD;不传默认今天,最大跨度 366 天。"));
|
||||
queryProperties.put("ownerUserId", integerProperty("目标用户 ID。普通用户只能查自己,平台管理员可查指定人员或全员。"));
|
||||
queryProperties.put("ownerUserId", integerProperty("目标用户 ID,实际可见范围仍按系统数据权限裁剪。"));
|
||||
queryProperties.put("groupBy", enumStringProperty("分组维度。", List.of("none", "day", "month", "owner", "stage", "status", "source")));
|
||||
queryProperties.put("page", integerProperty("页码,默认 1。"));
|
||||
queryProperties.put("pageSize", integerProperty("每页条数,默认 20,最大 50。"));
|
||||
|
|
@ -74,6 +80,7 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
|
|||
if (!StringUtils.hasText(reportType)) {
|
||||
throw new BusinessException("reportType 不能为空");
|
||||
}
|
||||
requireReportPermission(reportType);
|
||||
QueryContext context = buildContext(arguments);
|
||||
List<Map<String, Object>> rows = switch (reportType) {
|
||||
case "dashboard_summary" -> dashboardSummary(context);
|
||||
|
|
@ -101,6 +108,13 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
|
|||
return result;
|
||||
}
|
||||
|
||||
private void requireReportPermission(String reportType) {
|
||||
String permission = "dashboard_summary".equals(reportType) ? STATS_PERMISSION : ANALYTICS_PERMISSION;
|
||||
if (!permissionService.hasPermi(permission)) {
|
||||
throw new BusinessException("无权查询该 CRM 报表");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> dashboardSummary(QueryContext context) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
addRow(rows, llmMcpMapper.dashboardCustomerMetric(context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId()));
|
||||
|
|
|
|||
|
|
@ -60,9 +60,10 @@ public class UserProfileToolProvider extends PermissionedMcpToolProvider {
|
|||
}
|
||||
|
||||
profile = new LinkedHashMap<>(profile);
|
||||
profile.put("currentTenantId", tenantProvider.getCurrentTenantId());
|
||||
profile.put("roles", llmMcpMapper.selectUserRoles(userId));
|
||||
profile.put("orgs", llmMcpMapper.selectUserOrgs(userId));
|
||||
Long tenantId = tenantProvider.getCurrentTenantId();
|
||||
profile.put("currentTenantId", tenantId);
|
||||
profile.put("roles", llmMcpMapper.selectUserRoles(userId, tenantId));
|
||||
profile.put("orgs", llmMcpMapper.selectUserOrgs(userId, tenantId));
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@ package com.unis.crm.llm.tools.support;
|
|||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unisbase.llm.McpTool;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
public abstract class PermissionedMcpToolProvider extends com.unisbase.llm.tools.support.AbstractMcpToolProvider {
|
||||
|
|
@ -22,6 +26,30 @@ public abstract class PermissionedMcpToolProvider extends com.unisbase.llm.tools
|
|||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpTool buildTool() {
|
||||
McpTool tool = super.buildTool();
|
||||
tool.setHandler(arguments -> {
|
||||
requireAuthenticatedContext();
|
||||
return handle(arguments);
|
||||
});
|
||||
return tool;
|
||||
}
|
||||
|
||||
protected LoginUser requireAuthenticatedContext() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) {
|
||||
throw new BusinessException("MCP 身份认证失败");
|
||||
}
|
||||
if (loginUser.getUserId() == null || loginUser.getUserId() <= 0) {
|
||||
throw new BusinessException("MCP 未获取到有效用户");
|
||||
}
|
||||
if (loginUser.getTenantId() == null || loginUser.getTenantId() <= 0) {
|
||||
throw new BusinessException("MCP 必须进入有效租户后才能查询 CRM 数据");
|
||||
}
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
protected Map<String, Object> integerProperty(String description) {
|
||||
return property("integer", description);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,18 +30,20 @@ public interface ExpansionMapper {
|
|||
String selectNextChannelCode();
|
||||
|
||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||
List<SalesExpansionItemDTO> selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
|
||||
List<SalesExpansionItemDTO> selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword, @Param("limit") Integer limit);
|
||||
|
||||
List<SalesExpansionItemDTO> selectSalesExpansionsByOwnerUserIds(
|
||||
@Param("ownerUserIds") List<Long> ownerUserIds,
|
||||
@Param("keyword") String keyword);
|
||||
@Param("keyword") String keyword,
|
||||
@Param("limit") Integer limit);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
List<ChannelExpansionItemDTO> selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
|
||||
List<ChannelExpansionItemDTO> selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword, @Param("limit") Integer limit);
|
||||
|
||||
List<ChannelExpansionItemDTO> selectChannelExpansionsByOwnerUserIds(
|
||||
@Param("ownerUserIds") List<Long> ownerUserIds,
|
||||
@Param("keyword") String keyword);
|
||||
@Param("keyword") String keyword,
|
||||
@Param("limit") Integer limit);
|
||||
|
||||
List<SalesExpansionItemDTO> selectSalesExpansionsForTenant(
|
||||
@Param("tenantId") Long tenantId,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,9 @@ public interface OpportunityMapper {
|
|||
@Param("visibleOwnerUserIds") List<Long> visibleOwnerUserIds,
|
||||
@Param("visibleOwnerAreaRules") List<OwnerAreaRule> visibleOwnerAreaRules,
|
||||
@Param("preSalesUserId") Long preSalesUserId,
|
||||
@Param("preSalesUserNames") List<String> preSalesUserNames);
|
||||
@Param("preSalesUserNames") List<String> preSalesUserNames,
|
||||
@Param("limit") Integer limit,
|
||||
@Param("archived") Boolean archived);
|
||||
|
||||
OpportunityItemDTO selectOpportunityDetail(
|
||||
@Param("userId") Long userId,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ public interface CrmDataVisibilityService {
|
|||
String RESOURCE_EXPANSION = "EXPANSION";
|
||||
String RESOURCE_DAILY_REPORT = "DAILY_REPORT";
|
||||
String RESOURCE_CHECKIN = "CHECKIN";
|
||||
String RESOURCE_CUSTOMER = "CUSTOMER";
|
||||
String RESOURCE_WORK = "WORK";
|
||||
|
||||
DataVisibility resolveVisibility(Long currentUserId, Long tenantId, String resourceType);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ public interface ExpansionService {
|
|||
|
||||
ExpansionOverviewDTO getOverview(Long userId, String keyword);
|
||||
|
||||
ExpansionOverviewDTO getOverview(Long userId, String keyword, boolean includeDetails);
|
||||
|
||||
ExpansionOverviewDTO getOverview(Long userId, String keyword, boolean includeDetails, Integer limit);
|
||||
|
||||
ExpansionOverviewDTO getOpportunityFormOptions(Long userId, String keyword, Integer limit);
|
||||
|
||||
ExpansionDuplicateCheckDTO checkSalesEmployeeNoDuplicate(Long userId, String employeeNo, Long excludeId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.unis.crm.service;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface FileStorageService {
|
||||
|
||||
void upload(String objectName, MultipartFile file);
|
||||
|
||||
Resource load(String objectName);
|
||||
}
|
||||
|
|
@ -16,6 +16,12 @@ public interface OpportunityService {
|
|||
|
||||
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage);
|
||||
|
||||
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails);
|
||||
|
||||
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit);
|
||||
|
||||
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived);
|
||||
|
||||
OpportunityItemDTO getDetail(Long userId, Long opportunityId);
|
||||
|
||||
List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);
|
||||
|
|
|
|||
|
|
@ -106,24 +106,42 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
|
||||
@Override
|
||||
public ExpansionOverviewDTO getOverview(Long userId, String keyword) {
|
||||
return getOverview(userId, keyword, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExpansionOverviewDTO getOverview(Long userId, String keyword, boolean includeDetails) {
|
||||
return getOverview(userId, keyword, includeDetails, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExpansionOverviewDTO getOverview(Long userId, String keyword, boolean includeDetails, Integer limit) {
|
||||
String normalizedKeyword = normalizeKeyword(keyword);
|
||||
Integer normalizedLimit = limit != null && limit > 0 ? Math.min(limit, 1000) : null;
|
||||
List<Long> extraVisibleOwnerUserIds = resolveExtraVisibleOwnerUserIds(userId);
|
||||
List<SalesExpansionItemDTO> salesItems = mergeSalesExpansionItems(
|
||||
expansionMapper.selectSalesExpansions(userId, normalizedKeyword),
|
||||
expansionMapper.selectSalesExpansions(userId, normalizedKeyword, normalizedLimit),
|
||||
extraVisibleOwnerUserIds.isEmpty()
|
||||
? List.of()
|
||||
: expansionMapper.selectSalesExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword));
|
||||
: expansionMapper.selectSalesExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword, normalizedLimit));
|
||||
List<ChannelExpansionItemDTO> channelItems = mergeChannelExpansionItems(
|
||||
expansionMapper.selectChannelExpansions(userId, normalizedKeyword),
|
||||
expansionMapper.selectChannelExpansions(userId, normalizedKeyword, normalizedLimit),
|
||||
extraVisibleOwnerUserIds.isEmpty()
|
||||
? List.of()
|
||||
: expansionMapper.selectChannelExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword));
|
||||
: expansionMapper.selectChannelExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword, normalizedLimit));
|
||||
|
||||
if (normalizedLimit != null) {
|
||||
salesItems = new ArrayList<>(salesItems.subList(0, Math.min(normalizedLimit, salesItems.size())));
|
||||
channelItems = new ArrayList<>(channelItems.subList(0, Math.min(normalizedLimit, channelItems.size())));
|
||||
}
|
||||
|
||||
attachSalesFollowUps(userId, salesItems);
|
||||
attachSalesRelatedProjects(userId, salesItems);
|
||||
attachChannelFollowUps(userId, channelItems);
|
||||
attachChannelContacts(userId, channelItems);
|
||||
attachChannelRelatedProjects(userId, channelItems);
|
||||
if (includeDetails) {
|
||||
attachSalesFollowUps(userId, salesItems);
|
||||
attachChannelFollowUps(userId, channelItems);
|
||||
attachChannelContacts(userId, channelItems);
|
||||
}
|
||||
fillChannelDisplayFields(channelItems);
|
||||
|
||||
return new ExpansionOverviewDTO(salesItems, channelItems);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
package com.unis.crm.service.impl;
|
||||
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.config.MinioProperties;
|
||||
import com.unis.crm.service.FileStorageService;
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.GetObjectResponse;
|
||||
import io.minio.MakeBucketArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
public class MinioFileStorageService implements FileStorageService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MinioFileStorageService.class);
|
||||
|
||||
private final MinioClient minioClient;
|
||||
private final MinioProperties properties;
|
||||
private final AtomicBoolean bucketReady = new AtomicBoolean(false);
|
||||
|
||||
public MinioFileStorageService(MinioClient minioClient, MinioProperties properties) {
|
||||
this.minioClient = minioClient;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void upload(String objectName, MultipartFile file) {
|
||||
ensureBucket();
|
||||
try {
|
||||
minioClient.putObject(PutObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(resolveObjectName(objectName))
|
||||
.stream(file.getInputStream(), file.getSize(), -1)
|
||||
.contentType(file.getContentType())
|
||||
.build());
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("文件上传到MinIO失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource load(String objectName) {
|
||||
String resolvedObjectName = resolveObjectName(objectName);
|
||||
try {
|
||||
GetObjectResponse response = minioClient.getObject(GetObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(resolvedObjectName)
|
||||
.build());
|
||||
String fileName = Path.of(resolvedObjectName).getFileName().toString();
|
||||
return new InputStreamResource(response) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long contentLength() {
|
||||
String contentLength = response.headers().get("Content-Length");
|
||||
if (contentLength == null) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(contentLength);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (Exception exception) {
|
||||
log.error(
|
||||
"Failed to read MinIO object bucket={}, object={}",
|
||||
properties.getBucket(),
|
||||
resolvedObjectName,
|
||||
exception);
|
||||
throw new BusinessException("MinIO文件读取失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureBucket() {
|
||||
if (bucketReady.get()) {
|
||||
return;
|
||||
}
|
||||
synchronized (bucketReady) {
|
||||
if (bucketReady.get()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.build());
|
||||
if (!exists) {
|
||||
minioClient.makeBucket(MakeBucketArgs.builder().bucket(properties.getBucket()).build());
|
||||
}
|
||||
bucketReady.set(true);
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("MinIO存储桶初始化失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveObjectName(String objectName) {
|
||||
String basePath = properties.getBasePath() == null ? "" : properties.getBasePath().trim();
|
||||
basePath = basePath.replace('\\', '/').replaceAll("^/+|/+$", "");
|
||||
String normalizedObjectName = objectName.replace('\\', '/').replaceAll("^/+", "");
|
||||
return basePath.isEmpty() ? normalizedObjectName : basePath + "/" + normalizedObjectName;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -128,6 +128,21 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
|
||||
@Override
|
||||
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage) {
|
||||
return getOverview(userId, keyword, stage, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails) {
|
||||
return getOverview(userId, keyword, stage, includeDetails, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit) {
|
||||
return getOverview(userId, keyword, stage, includeDetails, limit, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived) {
|
||||
String normalizedKeyword = normalizeKeyword(keyword);
|
||||
String normalizedStage = normalizeStage(stage);
|
||||
OpportunityVisibility visibility = resolveOpportunityVisibility(userId);
|
||||
|
|
@ -139,8 +154,12 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
visibility.visibleOwnerUserIds(),
|
||||
visibility.visibleOwnerAreaRules(),
|
||||
visibility.preSalesUserId(),
|
||||
visibility.preSalesUserNames());
|
||||
attachFollowUps(userId, items, visibility);
|
||||
visibility.preSalesUserNames(),
|
||||
limit != null && limit > 0 ? Math.min(limit, 1000) : null,
|
||||
archived);
|
||||
if (includeDetails) {
|
||||
attachFollowUps(userId, items, visibility);
|
||||
}
|
||||
return new OpportunityOverviewDTO(items);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import com.unis.crm.mapper.ProfileMapper;
|
|||
import com.unis.crm.mapper.WorkMapper;
|
||||
import com.unis.crm.service.CrmDataVisibilityService;
|
||||
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
|
||||
import com.unis.crm.service.FileStorageService;
|
||||
import com.unis.crm.service.ReportReminderService;
|
||||
import com.unis.crm.service.WorkService;
|
||||
import com.unisbase.service.SysPermissionService;
|
||||
|
|
@ -38,10 +39,6 @@ import java.net.http.HttpClient;
|
|||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
|
@ -63,7 +60,6 @@ import java.util.regex.Matcher;
|
|||
import java.util.regex.Pattern;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
|
@ -119,8 +115,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
private final WorkReportProperties workReportProperties;
|
||||
private final SysPermissionService sysPermissionService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Path checkInPhotoDirectory;
|
||||
private final Path reportAttachmentDirectory;
|
||||
private final FileStorageService fileStorageService;
|
||||
private final String tencentMapKey;
|
||||
private final Map<String, String> locationNameCache = new ConcurrentHashMap<>();
|
||||
|
||||
|
|
@ -135,7 +130,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
CrmDataVisibilityService crmDataVisibilityService,
|
||||
SysPermissionService sysPermissionService,
|
||||
JdbcTemplate jdbcTemplate,
|
||||
@Value("${unisbase.app.upload-path}") String uploadPath,
|
||||
FileStorageService fileStorageService,
|
||||
@Value("${unisbase.app.tencent-map.key:}") String tencentMapKey) {
|
||||
this.workMapper = workMapper;
|
||||
this.opportunityMapper = opportunityMapper;
|
||||
|
|
@ -147,11 +142,10 @@ public class WorkServiceImpl implements WorkService {
|
|||
this.crmDataVisibilityService = crmDataVisibilityService;
|
||||
this.sysPermissionService = sysPermissionService;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.fileStorageService = fileStorageService;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
.build();
|
||||
this.checkInPhotoDirectory = Paths.get(uploadPath, "work-checkin");
|
||||
this.reportAttachmentDirectory = Paths.get(uploadPath, "work-report-attachments");
|
||||
this.tencentMapKey = normalizeOptionalText(tencentMapKey);
|
||||
}
|
||||
|
||||
|
|
@ -446,17 +440,8 @@ public class WorkServiceImpl implements WorkService {
|
|||
String extension = resolveFileExtension(contentType, file.getOriginalFilename());
|
||||
String fileName = userId + "-" + UUID.randomUUID().toString().replace("-", "") + extension;
|
||||
|
||||
try {
|
||||
Files.createDirectories(checkInPhotoDirectory);
|
||||
Path targetPath = checkInPhotoDirectory.resolve(fileName).normalize();
|
||||
if (!targetPath.startsWith(checkInPhotoDirectory)) {
|
||||
throw new BusinessException("图片路径非法");
|
||||
}
|
||||
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
return "/api/work/checkin-photos/" + fileName;
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException("现场照片上传失败,请稍后重试");
|
||||
}
|
||||
fileStorageService.upload("work-checkin/" + fileName, file);
|
||||
return "/api/work/checkin-photos/" + fileName;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -466,15 +451,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
throw new BusinessException("图片不存在");
|
||||
}
|
||||
|
||||
try {
|
||||
Path filePath = checkInPhotoDirectory.resolve(normalizedFileName).normalize();
|
||||
if (!filePath.startsWith(checkInPhotoDirectory) || !Files.exists(filePath)) {
|
||||
throw new BusinessException("图片不存在");
|
||||
}
|
||||
return new UrlResource(filePath.toUri());
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException("图片读取失败");
|
||||
}
|
||||
return fileStorageService.load("work-checkin/" + normalizedFileName);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -496,22 +473,13 @@ public class WorkServiceImpl implements WorkService {
|
|||
String extension = resolveAttachmentExtension(contentType, originalName);
|
||||
String fileName = userId + "-" + UUID.randomUUID().toString().replace("-", "") + extension;
|
||||
|
||||
try {
|
||||
Files.createDirectories(reportAttachmentDirectory);
|
||||
Path targetPath = reportAttachmentDirectory.resolve(fileName).normalize();
|
||||
if (!targetPath.startsWith(reportAttachmentDirectory)) {
|
||||
throw new BusinessException("附件路径非法");
|
||||
}
|
||||
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
WorkReportAttachmentDTO attachment = new WorkReportAttachmentDTO();
|
||||
attachment.setName(originalName);
|
||||
attachment.setUrl("/api/work/report-attachments/" + fileName);
|
||||
attachment.setContentType(contentType);
|
||||
attachment.setSize(file.getSize());
|
||||
return attachment;
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException("附件上传失败,请稍后重试");
|
||||
}
|
||||
fileStorageService.upload("work-report-attachments/" + fileName, file);
|
||||
WorkReportAttachmentDTO attachment = new WorkReportAttachmentDTO();
|
||||
attachment.setName(originalName);
|
||||
attachment.setUrl("/api/work/report-attachments/" + fileName);
|
||||
attachment.setContentType(contentType);
|
||||
attachment.setSize(file.getSize());
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -521,15 +489,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
throw new BusinessException("附件不存在");
|
||||
}
|
||||
|
||||
try {
|
||||
Path filePath = reportAttachmentDirectory.resolve(normalizedFileName).normalize();
|
||||
if (!filePath.startsWith(reportAttachmentDirectory) || !Files.exists(filePath)) {
|
||||
throw new BusinessException("附件不存在");
|
||||
}
|
||||
return new UrlResource(filePath.toUri());
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException("附件读取失败");
|
||||
}
|
||||
return fileStorageService.load("work-report-attachments/" + normalizedFileName);
|
||||
}
|
||||
|
||||
private void requireUser(Long userId) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
server:
|
||||
port: 8080
|
||||
|
||||
minio:
|
||||
endpoint: https://miniodown.nex.unisspace.com
|
||||
access_key: admin
|
||||
secret_key: Admin@123456
|
||||
bucket: crm
|
||||
base_path: uploadPath
|
||||
use_ssl: false
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: unis-crm-backend
|
||||
|
|
@ -49,7 +60,6 @@ unisbase:
|
|||
secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77
|
||||
header-name: X-Internal-Secret
|
||||
app:
|
||||
upload-path: /Users/kangwenjing/Downloads/crm/uploads
|
||||
resource-prefix: /sys/api/static/
|
||||
tencent-map:
|
||||
key: ${TENCENT_MAP_KEY:LJYBZ-HCQCV-N37PU-5FIOX-QFA26-FPB6U}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
server:
|
||||
port: 8080
|
||||
|
||||
minio:
|
||||
endpoint: 192.168.124.202:9000
|
||||
access_key: admin
|
||||
secret_key: Admin@123456
|
||||
bucket: crm
|
||||
base_path: uploadPath
|
||||
use_ssl: false
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: unis-crm-backend
|
||||
|
|
@ -58,7 +66,6 @@ unisbase:
|
|||
secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77
|
||||
header-name: X-Internal-Secret
|
||||
app:
|
||||
upload-path: /Users/kangwenjing/Downloads/crm/uploads
|
||||
resource-prefix: /sys/api/static/
|
||||
tencent-map:
|
||||
key: ${TENCENT_MAP_KEY:LJYBZ-HCQCV-N37PU-5FIOX-QFA26-FPB6U}
|
||||
|
|
|
|||
|
|
@ -359,6 +359,7 @@
|
|||
)
|
||||
</if>
|
||||
order by s.updated_at desc, s.id desc
|
||||
<if test="limit != null and limit > 0">limit #{limit}</if>
|
||||
</select>
|
||||
|
||||
<select id="selectSalesExpansionsByOwnerUserIds" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
|
||||
|
|
@ -372,6 +373,7 @@
|
|||
</foreach>
|
||||
<include refid="salesExpansionKeywordFilter"/>
|
||||
order by s.updated_at desc, s.id desc
|
||||
<if test="limit != null and limit > 0">limit #{limit}</if>
|
||||
</select>
|
||||
|
||||
<select id="selectChannelExpansions" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
|
||||
|
|
@ -507,6 +509,7 @@
|
|||
)
|
||||
</if>
|
||||
order by c.updated_at desc, c.id desc
|
||||
<if test="limit != null and limit > 0">limit #{limit}</if>
|
||||
</select>
|
||||
|
||||
<select id="selectChannelExpansionsByOwnerUserIds" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
|
||||
|
|
@ -520,6 +523,7 @@
|
|||
</foreach>
|
||||
<include refid="channelExpansionKeywordFilter"/>
|
||||
order by c.updated_at desc, c.id desc
|
||||
<if test="limit != null and limit > 0">limit #{limit}</if>
|
||||
</select>
|
||||
|
||||
<select id="selectSalesExpansionsForTenant" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
from sys_user_role ur
|
||||
join sys_role r on r.role_id = ur.role_id
|
||||
where ur.user_id = #{userId}
|
||||
and ur.tenant_id = #{tenantId}
|
||||
and coalesce(ur.is_deleted, 0) = 0
|
||||
and coalesce(r.is_deleted, 0) = 0
|
||||
order by r.role_id asc
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
from sys_tenant_user tu
|
||||
left join sys_org o on o.id = tu.org_id and coalesce(o.is_deleted, 0) = 0
|
||||
where tu.user_id = #{userId}
|
||||
and tu.tenant_id = #{tenantId}
|
||||
and coalesce(tu.is_deleted, 0) = 0
|
||||
order by tu.id asc
|
||||
</select>
|
||||
|
|
@ -799,40 +801,41 @@
|
|||
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = u.user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
|
||||
),
|
||||
customer_stats as (
|
||||
select owner_user_id, count(1)::bigint as customer_count
|
||||
from crm_customer
|
||||
where created_at::date between #{startDate} and #{endDate}
|
||||
group by owner_user_id
|
||||
select c.owner_user_id, count(1)::bigint as customer_count
|
||||
from crm_customer c
|
||||
where c.created_at::date between #{startDate} and #{endDate}
|
||||
group by c.owner_user_id
|
||||
),
|
||||
opportunity_stats as (
|
||||
select
|
||||
owner_user_id,
|
||||
o.owner_user_id,
|
||||
count(1)::bigint as opportunity_count,
|
||||
coalesce(sum(amount), 0) as opportunity_amount,
|
||||
count(case when coalesce(status, '') = 'won' then 1 end)::bigint as won_count,
|
||||
coalesce(sum(case when coalesce(status, '') = 'won' then amount else 0 end), 0) as won_amount
|
||||
from crm_opportunity
|
||||
where created_at::date between #{startDate} and #{endDate}
|
||||
group by owner_user_id
|
||||
from crm_opportunity o
|
||||
where o.created_at::date between #{startDate} and #{endDate}
|
||||
group by o.owner_user_id
|
||||
),
|
||||
followup_stats as (
|
||||
select followup_user_id as user_id, count(1)::bigint as followup_count
|
||||
from crm_opportunity_followup
|
||||
where followup_time::date between #{startDate} and #{endDate}
|
||||
group by followup_user_id
|
||||
select f.followup_user_id as user_id, count(1)::bigint as followup_count
|
||||
from crm_opportunity_followup f
|
||||
join crm_opportunity o on o.id = f.opportunity_id
|
||||
where f.followup_time::date between #{startDate} and #{endDate}
|
||||
group by f.followup_user_id
|
||||
),
|
||||
report_stats as (
|
||||
select user_id, count(1)::bigint as report_count
|
||||
from work_daily_report
|
||||
where report_date between #{startDate} and #{endDate}
|
||||
and coalesce(status, 'submitted') in ('submitted', 'read', 'reviewed')
|
||||
group by user_id
|
||||
select r.user_id, count(1)::bigint as report_count
|
||||
from work_daily_report r
|
||||
where r.report_date between #{startDate} and #{endDate}
|
||||
and coalesce(r.status, 'submitted') in ('submitted', 'read', 'reviewed')
|
||||
group by r.user_id
|
||||
),
|
||||
checkin_stats as (
|
||||
select user_id, count(1)::bigint as checkin_count
|
||||
from work_checkin
|
||||
where checkin_date between #{startDate} and #{endDate}
|
||||
group by user_id
|
||||
select ck.user_id, count(1)::bigint as checkin_count
|
||||
from work_checkin ck
|
||||
where ck.checkin_date between #{startDate} and #{endDate}
|
||||
group by ck.user_id
|
||||
)
|
||||
select
|
||||
u.user_id as "ownerUserId",
|
||||
|
|
@ -1501,7 +1504,12 @@
|
|||
left(coalesce(f.next_action, ''), 300) as "nextAction",
|
||||
f.followup_user_id as "userId",
|
||||
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "userName",
|
||||
f.followup_time as "followupTime"
|
||||
f.followup_time as "followupTime",
|
||||
'OPPORTUNITY' as "scopeResourceType",
|
||||
o.owner_user_id as "scopeOwnerUserId",
|
||||
o.project_ownership_location as "scopeAreaCode",
|
||||
o.pre_sales_id as "scopePreSalesId",
|
||||
o.pre_sales_name as "scopePreSalesName"
|
||||
from crm_opportunity_followup f
|
||||
join crm_opportunity o on o.id = f.opportunity_id
|
||||
left join sys_user u on u.user_id = f.followup_user_id and coalesce(u.is_deleted, 0) = 0
|
||||
|
|
@ -1530,7 +1538,12 @@
|
|||
left(coalesce(f.next_action, f.next_plan, ''), 300) as "nextAction",
|
||||
f.followup_user_id as "userId",
|
||||
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "userName",
|
||||
f.followup_time as "followupTime"
|
||||
f.followup_time as "followupTime",
|
||||
'EXPANSION' as "scopeResourceType",
|
||||
case when f.biz_type = 'sales' then s.owner_user_id else c.owner_user_id end as "scopeOwnerUserId",
|
||||
null::varchar as "scopeAreaCode",
|
||||
null::bigint as "scopePreSalesId",
|
||||
null::varchar as "scopePreSalesName"
|
||||
from crm_expansion_followup f
|
||||
left join crm_sales_expansion s on s.id = f.biz_id and f.biz_type = 'sales'
|
||||
left join crm_channel_expansion c on c.id = f.biz_id and f.biz_type = 'channel'
|
||||
|
|
|
|||
|
|
@ -258,6 +258,9 @@
|
|||
and operator_dict.status = 1
|
||||
and coalesce(operator_dict.is_deleted, 0) = 0
|
||||
where 1 = 1
|
||||
<if test="archived != null">
|
||||
and coalesce(o.archived, false) = #{archived}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
and (
|
||||
o.opportunity_name ilike concat('%', #{keyword}, '%')
|
||||
|
|
@ -276,6 +279,9 @@
|
|||
</if>
|
||||
<include refid="opportunityVisibilityCondition"/>
|
||||
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
|
||||
<if test="limit != null and limit > 0">
|
||||
limit #{limit}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="selectOpportunityDetail" resultType="com.unis.crm.dto.opportunity.OpportunityItemDTO">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
package com.unis.crm.llm.security;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.unisbase.auth.McpBotAuthenticationFilter;
|
||||
import com.unisbase.llm.auth.McpAuthenticationException;
|
||||
import com.unisbase.llm.auth.McpBotAuthService;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
class McpBotAuthenticationFilterTest {
|
||||
|
||||
private McpBotAuthService authService;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
authService = mock(McpBotAuthService.class);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
|
||||
.addFilters(new McpBotAuthenticationFilter(authService))
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
org.springframework.security.core.context.SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingCredentialShouldReturnHttp401() throws Exception {
|
||||
when(authService.authenticate(isNull(), isNull(), isNull(), anyString()))
|
||||
.thenThrow(new McpAuthenticationException("X-Bot-Id and X-Bot-Secret are required"));
|
||||
|
||||
mockMvc.perform(post("/mcp").contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string("WWW-Authenticate", org.hamcrest.Matchers.containsString("invalid_request")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("X-Bot-Id and X-Bot-Secret are required")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidCredentialShouldReturnHttp401() throws Exception {
|
||||
when(authService.authenticate(
|
||||
org.mockito.ArgumentMatchers.eq("bad-id"),
|
||||
org.mockito.ArgumentMatchers.eq("bad-secret"),
|
||||
isNull(),
|
||||
anyString()))
|
||||
.thenThrow(new McpAuthenticationException("Invalid bot credentials"));
|
||||
|
||||
mockMvc.perform(post("/mcp")
|
||||
.header("X-Bot-Id", "bad-id")
|
||||
.header("X-Bot-Secret", "bad-secret")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string("WWW-Authenticate", org.hamcrest.Matchers.containsString("invalid_client")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validCredentialShouldReachMcpEndpoint() throws Exception {
|
||||
LoginUser loginUser = new LoginUser(9L, 100L, "user", false, false, Set.of());
|
||||
when(authService.authenticate(
|
||||
org.mockito.ArgumentMatchers.eq("bot-id"),
|
||||
org.mockito.ArgumentMatchers.eq("bot-secret"),
|
||||
org.mockito.ArgumentMatchers.eq("100"),
|
||||
anyString()))
|
||||
.thenReturn(loginUser);
|
||||
|
||||
mockMvc.perform(post("/mcp")
|
||||
.header("X-Bot-Id", "bot-id")
|
||||
.header("X-Bot-Secret", "bot-secret")
|
||||
.header("X-Tenant-Id", "100")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string("ok"));
|
||||
|
||||
verify(authService).authenticate("bot-id", "bot-secret", "100", "127.0.0.1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonMcpRequestShouldNotInvokeBotAuthentication() throws Exception {
|
||||
mockMvc.perform(post("/health"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verifyNoInteractions(authService);
|
||||
}
|
||||
|
||||
@RestController
|
||||
private static final class TestController {
|
||||
|
||||
@PostMapping("/mcp")
|
||||
String mcp() {
|
||||
return "ok";
|
||||
}
|
||||
|
||||
@PostMapping("/health")
|
||||
String health() {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
package com.unis.crm.llm.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.service.CrmDataVisibilityService;
|
||||
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
|
||||
import com.unis.crm.service.CrmDataVisibilityService.OwnerAreaRule;
|
||||
import com.unis.crm.llm.mapper.LlmMcpMapper;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.Arrays;
|
||||
import java.time.LocalDate;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
|
||||
import org.apache.ibatis.mapping.BoundSql;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
class McpDataPermissionInterceptorTest {
|
||||
|
||||
private McpDataPermissionInterceptor interceptor;
|
||||
private LoginUser loginUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
@SuppressWarnings("unchecked")
|
||||
ObjectProvider<CrmDataVisibilityService> provider = mock(ObjectProvider.class);
|
||||
interceptor = new McpDataPermissionInterceptor(provider);
|
||||
loginUser = new LoginUser(9L, 100L, "zhangsan", false, false, Set.of());
|
||||
loginUser.setDisplayName("张三");
|
||||
}
|
||||
|
||||
@Test
|
||||
void opportunityConditionShouldMergeOwnerAreaAndPreSalesVisibility() {
|
||||
DataVisibility visibility = new DataVisibility(false, List.of(2L, 3L), List.of(
|
||||
new OwnerAreaRule(2L, true, List.of()),
|
||||
new OwnerAreaRule(3L, false, List.of("110000", "310000"))));
|
||||
|
||||
String condition = interceptor.buildVisibilityCondition("searchOpportunities", visibility, loginUser);
|
||||
|
||||
assertTrue(condition.contains("o.owner_user_id = 2"));
|
||||
assertTrue(condition.contains("o.owner_user_id = 3"));
|
||||
assertTrue(condition.contains("o.project_ownership_location in ('110000','310000')"));
|
||||
assertTrue(condition.contains("o.pre_sales_id = 9"));
|
||||
assertTrue(condition.contains("'张三','zhangsan'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void regularConditionShouldUseMergedVisibleOwners() {
|
||||
DataVisibility visibility = new DataVisibility(false, List.of(9L, 20L, 20L), List.of());
|
||||
|
||||
String condition = interceptor.buildVisibilityCondition("searchWorkReports", visibility, loginUser);
|
||||
|
||||
assertEquals("r.user_id in (9,20)", condition);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyVisibilityShouldFailClosed() {
|
||||
DataVisibility visibility = new DataVisibility(false, List.of(), List.of());
|
||||
|
||||
assertEquals("1 = 0", interceptor.buildVisibilityCondition("searchCustomers", visibility, loginUser));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allAccessShouldNotAddOwnerRestriction() {
|
||||
DataVisibility visibility = new DataVisibility(true, List.of(), List.of());
|
||||
|
||||
assertEquals("1 = 1", interceptor.buildVisibilityCondition("searchCustomers", visibility, loginUser));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rewriteSqlShouldAppendConditionToExistingWhere() {
|
||||
String sql = interceptor.rewriteSql(
|
||||
"select o.id from crm_opportunity o where o.archived = false order by o.id desc",
|
||||
"o.owner_user_id in (9,20)");
|
||||
|
||||
assertTrue(sql.contains("o.archived = false"));
|
||||
assertTrue(sql.contains("o.owner_user_id IN (9, 20)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rewriteSqlShouldSupportCteOuterScope() {
|
||||
String sql = interceptor.rewriteSql(
|
||||
"with expected_rows as (select u.user_id from sys_user u) "
|
||||
+ "select e.user_id from expected_rows e where e.user_id > 0",
|
||||
"e.user_id in (9,20)");
|
||||
|
||||
assertTrue(sql.contains("e.user_id IN (9, 20)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rewriteSqlShouldSupportQuotedCteProjection() {
|
||||
String sql = interceptor.rewriteSql(
|
||||
"with followup_rows as (select f.followup_user_id as \"userId\" from crm_followup f) "
|
||||
+ "select \"userId\" from followup_rows",
|
||||
"\"userId\" in (9,20)");
|
||||
|
||||
assertTrue(sql.contains("\"userId\" IN (9, 20)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void followupConditionShouldUseParentResourcePermissions() {
|
||||
DataVisibility opportunityVisibility = new DataVisibility(false, List.of(2L), List.of(
|
||||
new OwnerAreaRule(2L, false, List.of("110000"))));
|
||||
DataVisibility expansionVisibility = new DataVisibility(false, List.of(3L), List.of());
|
||||
|
||||
String condition = interceptor.buildFollowupVisibilityCondition(
|
||||
opportunityVisibility, expansionVisibility, loginUser);
|
||||
|
||||
assertTrue(condition.contains("\"scopeResourceType\" = 'OPPORTUNITY'"));
|
||||
assertTrue(condition.contains("\"scopeOwnerUserId\" = 2"));
|
||||
assertTrue(condition.contains("\"scopeAreaCode\" in ('110000')"));
|
||||
assertTrue(condition.contains("\"scopePreSalesId\" = 9"));
|
||||
assertTrue(condition.contains("\"scopeResourceType\" = 'EXPANSION'"));
|
||||
assertTrue(condition.contains("\"scopeOwnerUserId\" in (3)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPolicyShouldBeRejected() {
|
||||
DataVisibility visibility = new DataVisibility(false, List.of(9L), List.of());
|
||||
|
||||
assertThrows(BusinessException.class,
|
||||
() -> interceptor.buildVisibilityCondition("unknownQuery", visibility, loginUser));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyProtectedMapperStatementShouldProduceRewritableSql() throws Exception {
|
||||
Configuration configuration = new Configuration();
|
||||
Path mapperPath = Path.of("src/main/resources/mapper/llm/LlmMcpMapper.xml");
|
||||
try (InputStream inputStream = Files.newInputStream(mapperPath)) {
|
||||
new XMLMapperBuilder(inputStream, configuration, mapperPath.toString(), configuration.getSqlFragments()).parse();
|
||||
}
|
||||
Map<String, Object> parameters = allParameters();
|
||||
DataVisibility visibility = new DataVisibility(false, List.of(9L, 20L), List.of(
|
||||
new OwnerAreaRule(9L, true, List.of()),
|
||||
new OwnerAreaRule(20L, false, List.of("110000"))));
|
||||
|
||||
for (String methodName : McpDataPermissionInterceptor.policyMethodNames()) {
|
||||
MappedStatement statement = configuration.getMappedStatement(
|
||||
"com.unis.crm.llm.mapper.LlmMcpMapper." + methodName);
|
||||
BoundSql boundSql = statement.getBoundSql(parameters);
|
||||
String rewritten;
|
||||
if ("salesPerformance".equals(methodName)) {
|
||||
rewritten = interceptor.rewriteSalesPerformanceSql(boundSql.getSql(), Map.of(
|
||||
CrmDataVisibilityService.RESOURCE_CUSTOMER, visibility,
|
||||
CrmDataVisibilityService.RESOURCE_OPPORTUNITY, visibility,
|
||||
CrmDataVisibilityService.RESOURCE_DAILY_REPORT, visibility,
|
||||
CrmDataVisibilityService.RESOURCE_CHECKIN, visibility));
|
||||
} else {
|
||||
String condition = "searchFollowups".equals(methodName)
|
||||
? interceptor.buildFollowupVisibilityCondition(visibility, visibility, loginUser)
|
||||
: interceptor.buildVisibilityCondition(methodName, visibility, loginUser);
|
||||
rewritten = interceptor.rewriteSql(boundSql.getSql(), condition);
|
||||
}
|
||||
assertTrue(rewritten.toLowerCase().contains("where"), methodName + " should contain a permission predicate");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyMcpMapperMethodShouldBeProtectedOrExplicitlyTenantSafe() {
|
||||
Set<String> tenantSafeMethods = Set.of(
|
||||
"selectUserProfile",
|
||||
"selectUserRoles",
|
||||
"selectUserOrgs",
|
||||
"selectDictTypes",
|
||||
"selectDictOptions",
|
||||
"searchOrganizations",
|
||||
"searchRoles");
|
||||
Set<String> protectedMethods = McpDataPermissionInterceptor.policyMethodNames();
|
||||
|
||||
Arrays.stream(LlmMcpMapper.class.getDeclaredMethods()).forEach(method ->
|
||||
assertTrue(
|
||||
protectedMethods.contains(method.getName()) || tenantSafeMethods.contains(method.getName()),
|
||||
method.getName() + " must have an MCP permission policy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void salesPerformanceShouldApplyEachResourceScopeInsideItsOwnCte() throws Exception {
|
||||
Configuration configuration = new Configuration();
|
||||
Path mapperPath = Path.of("src/main/resources/mapper/llm/LlmMcpMapper.xml");
|
||||
try (InputStream inputStream = Files.newInputStream(mapperPath)) {
|
||||
new XMLMapperBuilder(inputStream, configuration, mapperPath.toString(), configuration.getSqlFragments()).parse();
|
||||
}
|
||||
BoundSql boundSql = configuration.getMappedStatement(
|
||||
"com.unis.crm.llm.mapper.LlmMcpMapper.salesPerformance")
|
||||
.getBoundSql(allParameters());
|
||||
|
||||
String rewritten = interceptor.rewriteSalesPerformanceSql(boundSql.getSql(), Map.of(
|
||||
CrmDataVisibilityService.RESOURCE_CUSTOMER,
|
||||
new DataVisibility(false, List.of(11L), List.of(new OwnerAreaRule(11L, true, List.of()))),
|
||||
CrmDataVisibilityService.RESOURCE_OPPORTUNITY,
|
||||
new DataVisibility(false, List.of(12L), List.of(new OwnerAreaRule(12L, false, List.of("110000")))),
|
||||
CrmDataVisibilityService.RESOURCE_DAILY_REPORT,
|
||||
new DataVisibility(false, List.of(13L), List.of(new OwnerAreaRule(13L, true, List.of()))),
|
||||
CrmDataVisibilityService.RESOURCE_CHECKIN,
|
||||
new DataVisibility(false, List.of(14L), List.of(new OwnerAreaRule(14L, true, List.of())))));
|
||||
|
||||
assertTrue(rewritten.contains("c.owner_user_id IN (11)"));
|
||||
assertTrue(rewritten.contains("o.owner_user_id = 12"));
|
||||
assertTrue(rewritten.contains("o.project_ownership_location IN ('110000')"));
|
||||
assertTrue(rewritten.contains("r.user_id IN (13)"));
|
||||
assertTrue(rewritten.contains("ck.user_id IN (14)"));
|
||||
assertTrue(rewritten.contains("u.user_id IN (11, 12, 13, 14)"));
|
||||
}
|
||||
|
||||
private Map<String, Object> allParameters() {
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("startDate", LocalDate.of(2026, 7, 1));
|
||||
parameters.put("endDate", LocalDate.of(2026, 7, 20));
|
||||
parameters.put("queryDate", LocalDate.of(2026, 7, 20));
|
||||
parameters.put("targetUserId", 9L);
|
||||
parameters.put("ownerUserId", null);
|
||||
parameters.put("tenantId", 100L);
|
||||
parameters.put("keyword", "");
|
||||
parameters.put("status", "");
|
||||
parameters.put("stage", "");
|
||||
parameters.put("source", "");
|
||||
parameters.put("industry", "");
|
||||
parameters.put("priority", "");
|
||||
parameters.put("bizType", "all");
|
||||
parameters.put("intentLevel", "");
|
||||
parameters.put("includeArchived", false);
|
||||
parameters.put("groupBy", "day");
|
||||
parameters.put("bucket", "day");
|
||||
parameters.put("id", 1L);
|
||||
parameters.put("limit", 10);
|
||||
parameters.put("offset", 0);
|
||||
return parameters;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.unis.crm.llm.tools;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.llm.mapper.LlmMcpMapper;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import com.unisbase.security.PermissionService;
|
||||
import com.unisbase.security.SpringSecurityTenantProvider;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
class CrmReportQueryToolProviderTest {
|
||||
|
||||
private PermissionService permissionService;
|
||||
private CrmReportQueryToolProvider provider;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
permissionService = mock(PermissionService.class);
|
||||
SpringSecurityTenantProvider tenantProvider = mock(SpringSecurityTenantProvider.class);
|
||||
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
|
||||
provider = new CrmReportQueryToolProvider(
|
||||
new ObjectMapper(), mock(LlmMcpMapper.class), tenantProvider, permissionService);
|
||||
LoginUser loginUser = new LoginUser(9L, 100L, "user", false, false, Set.of());
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void dashboardSummaryShouldRequireStatsCardPermission() {
|
||||
when(permissionService.hasPermi("dashboard_stats_card:view")).thenReturn(false);
|
||||
|
||||
assertThrows(BusinessException.class, () -> provider.buildTool().getHandler().apply(Map.of(
|
||||
"reportType", "dashboard_summary")));
|
||||
|
||||
verify(permissionService).hasPermi("dashboard_stats_card:view");
|
||||
}
|
||||
|
||||
@Test
|
||||
void analyticsReportShouldRequireAnalyticsCardPermission() {
|
||||
when(permissionService.hasPermi("dashboard_analytics_card:view")).thenReturn(false);
|
||||
|
||||
assertThrows(BusinessException.class, () -> provider.buildTool().getHandler().apply(Map.of(
|
||||
"reportType", "opportunity_funnel")));
|
||||
|
||||
verify(permissionService).hasPermi("dashboard_analytics_card:view");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.unis.crm.llm.tools.support;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unisbase.llm.McpTool;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
class PermissionedMcpToolProviderTest {
|
||||
|
||||
private final TestProvider provider = new TestProvider();
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousInvocationShouldBeRejected() {
|
||||
McpTool tool = provider.buildTool();
|
||||
|
||||
assertThrows(BusinessException.class, () -> tool.getHandler().apply(Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tenantZeroInvocationShouldBeRejected() {
|
||||
authenticate(new LoginUser(9L, 0L, "admin", true, false, Set.of()));
|
||||
|
||||
McpTool tool = provider.buildTool();
|
||||
|
||||
assertThrows(BusinessException.class, () -> tool.getHandler().apply(Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedTenantInvocationShouldExecuteTool() {
|
||||
authenticate(new LoginUser(9L, 100L, "user", false, false, Set.of()));
|
||||
|
||||
Object result = provider.buildTool().getHandler().apply(Map.of());
|
||||
|
||||
assertEquals("ok", result);
|
||||
}
|
||||
|
||||
private void authenticate(LoginUser loginUser) {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()));
|
||||
}
|
||||
|
||||
private static final class TestProvider extends PermissionedMcpToolProvider {
|
||||
|
||||
private TestProvider() {
|
||||
super(new ObjectMapper());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getToolName() {
|
||||
return "test";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getToolDescription() {
|
||||
return "test";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, Object> buildInputSchema() {
|
||||
return objectSchema(new LinkedHashMap<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object handle(Map<String, Object> params) {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,15 +60,15 @@ class ExpansionServiceImplTest {
|
|||
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
|
||||
when(crmDataVisibilityService.resolveVisibility(29L, 100L, CrmDataVisibilityService.RESOURCE_EXPANSION))
|
||||
.thenReturn(new DataVisibility(false, List.of(29L, 36L), List.of()));
|
||||
when(expansionMapper.selectSalesExpansions(29L, null)).thenReturn(List.of(ownedItem));
|
||||
when(expansionMapper.selectSalesExpansionsByOwnerUserIds(List.of(36L), null)).thenReturn(List.of(grantedItem));
|
||||
when(expansionMapper.selectChannelExpansions(29L, null)).thenReturn(List.of());
|
||||
when(expansionMapper.selectChannelExpansionsByOwnerUserIds(List.of(36L), null)).thenReturn(List.of());
|
||||
when(expansionMapper.selectSalesExpansions(29L, null, null)).thenReturn(List.of(ownedItem));
|
||||
when(expansionMapper.selectSalesExpansionsByOwnerUserIds(List.of(36L), null, null)).thenReturn(List.of(grantedItem));
|
||||
when(expansionMapper.selectChannelExpansions(29L, null, null)).thenReturn(List.of());
|
||||
when(expansionMapper.selectChannelExpansionsByOwnerUserIds(List.of(36L), null, null)).thenReturn(List.of());
|
||||
ExpansionOverviewDTO result = expansionService.getOverview(29L, null);
|
||||
|
||||
assertEquals(2, result.getSalesItems().size());
|
||||
assertEquals(12L, result.getSalesItems().get(1).getId());
|
||||
verify(expansionMapper).selectSalesExpansionsByOwnerUserIds(List.of(36L), null);
|
||||
verify(expansionMapper).selectSalesExpansionsByOwnerUserIds(List.of(36L), null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -166,8 +166,8 @@ class ExpansionServiceImplTest {
|
|||
assertEquals(0, result.getChannelItems().size());
|
||||
verify(expansionMapper).selectSalesExpansionsForTenant(100L, null, 20);
|
||||
verify(expansionMapper).selectChannelExpansionsForTenant(100L, null, 20);
|
||||
verify(expansionMapper, never()).selectSalesExpansions(any(), any());
|
||||
verify(expansionMapper, never()).selectChannelExpansions(any(), any());
|
||||
verify(expansionMapper, never()).selectSalesExpansions(any(), any(), any());
|
||||
verify(expansionMapper, never()).selectChannelExpansions(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
package com.unis.crm.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.unis.crm.config.MinioProperties;
|
||||
import io.minio.GetObjectResponse;
|
||||
import io.minio.MinioClient;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import okhttp3.Headers;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
class MinioFileStorageServiceTest {
|
||||
|
||||
@Test
|
||||
void contentLengthDoesNotConsumeObjectStream() throws Exception {
|
||||
byte[] content = "photo-content".getBytes(StandardCharsets.UTF_8);
|
||||
Headers headers = new Headers.Builder()
|
||||
.add("Content-Length", String.valueOf(content.length))
|
||||
.build();
|
||||
GetObjectResponse response = new GetObjectResponse(
|
||||
headers, "bucket", "region", "checkin-photos/photo.jpg", new ByteArrayInputStream(content));
|
||||
MinioClient minioClient = mock(MinioClient.class);
|
||||
when(minioClient.getObject(any())).thenReturn(response);
|
||||
|
||||
MinioProperties properties = new MinioProperties();
|
||||
properties.setBucket("bucket");
|
||||
properties.setBasePath("crm");
|
||||
Resource resource = new MinioFileStorageService(minioClient, properties)
|
||||
.load("checkin-photos/photo.jpg");
|
||||
|
||||
assertEquals("photo.jpg", resource.getFilename());
|
||||
assertEquals(content.length, resource.contentLength());
|
||||
assertArrayEquals(content, resource.getInputStream().readAllBytes());
|
||||
}
|
||||
}
|
||||
|
|
@ -258,7 +258,9 @@ class OpportunityServiceImplTest {
|
|||
eq(List.of(2L, 3L)),
|
||||
eq(ownerAreaRules),
|
||||
eq(9L),
|
||||
eq(List.of("张售前", "presales.zhang")))).thenReturn(List.of(item));
|
||||
eq(List.of("张售前", "presales.zhang")),
|
||||
eq(null),
|
||||
eq(null))).thenReturn(List.of(item));
|
||||
when(opportunityMapper.selectOpportunityFollowUps(
|
||||
eq(9L),
|
||||
eq(List.of(10L)),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import java.time.LocalDate;
|
|||
import com.unis.crm.mapper.ProfileMapper;
|
||||
import com.unis.crm.mapper.WorkMapper;
|
||||
import com.unis.crm.service.CrmDataVisibilityService;
|
||||
import com.unis.crm.service.FileStorageService;
|
||||
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
|
||||
import com.unis.crm.service.ReportReminderService;
|
||||
import com.unisbase.service.SysPermissionService;
|
||||
|
|
@ -71,6 +72,9 @@ class WorkServiceImplTest {
|
|||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Mock
|
||||
private FileStorageService fileStorageService;
|
||||
|
||||
private WorkServiceImpl workService;
|
||||
|
||||
private WorkReportProperties workReportProperties;
|
||||
|
|
@ -89,7 +93,7 @@ class WorkServiceImplTest {
|
|||
crmDataVisibilityService,
|
||||
sysPermissionService,
|
||||
jdbcTemplate,
|
||||
"build/test-uploads",
|
||||
fileStorageService,
|
||||
"");
|
||||
}
|
||||
|
||||
|
|
@ -348,6 +352,9 @@ class WorkServiceImplTest {
|
|||
assertEquals("project-pack.zip", attachment.getName());
|
||||
assertEquals("application/zip", attachment.getContentType());
|
||||
assertEquals(4L, attachment.getSize());
|
||||
verify(fileStorageService).upload(
|
||||
org.mockito.ArgumentMatchers.startsWith("work-report-attachments/17-"),
|
||||
eq(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
services:
|
||||
|
||||
crm:
|
||||
container_name: unis-crm-backend
|
||||
platform: linux/amd64
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
ports:
|
||||
- "9001:8080"
|
||||
volumes:
|
||||
- /home/application/crm/logs:/app/logs
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- SPRING_PROFILES_ACTIVE=prod
|
||||
|
||||
frontend:
|
||||
container_name: unis-crm-frontend
|
||||
platform: linux/amd64
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "9002:80"
|
||||
environment:
|
||||
BACKEND_HOST: crm
|
||||
BACKEND_PORT: "8080"
|
||||
restart: unless-stopped
|
||||
|
||||
system:
|
||||
container_name: unis-crm-system
|
||||
platform: linux/amd64
|
||||
build:
|
||||
context: ./frontend1
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "9003:80"
|
||||
environment:
|
||||
BACKEND_HOST: crm
|
||||
BACKEND_PORT: "8080"
|
||||
restart: unless-stopped
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
*.md
|
||||
*.log
|
||||
.DS_Store
|
||||
.cert
|
||||
.env*
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# ============ 构建阶段 ============
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制 package.json,利用 Docker 缓存加速依赖安装
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --registry=https://registry.npmmirror.com
|
||||
|
||||
# 复制源码并构建
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ============ 运行阶段 ============
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx/default.conf.template /etc/nginx/default.conf.template
|
||||
COPY nginx/start.sh /etc/nginx/start.sh
|
||||
|
||||
# 从构建阶段复制 dist
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
RUN rm -f /etc/nginx/conf.d/default.conf && chmod +x /etc/nginx/start.sh
|
||||
|
||||
ENTRYPOINT ["/etc/nginx/start.sh"]
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
resolver ${RESOLVER} valid=30s ipv6=off;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# The system frontend prefixes canonical /sys paths with /api.
|
||||
location /api/sys/ {
|
||||
set $backend_host __BACKEND_HOST__;
|
||||
set $backend_port __BACKEND_PORT__;
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
proxy_pass http://$backend_host:$backend_port;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# CRM controllers already use /api as part of their canonical path.
|
||||
location /api/ {
|
||||
set $backend_host __BACKEND_HOST__;
|
||||
set $backend_port __BACKEND_PORT__;
|
||||
proxy_pass http://$backend_host:$backend_port;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location /sys/ {
|
||||
set $backend_host __BACKEND_HOST__;
|
||||
set $backend_port __BACKEND_PORT__;
|
||||
proxy_pass http://$backend_host:$backend_port;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
#!/bin/sh
|
||||
|
||||
RESOLVER=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}' | head -1)
|
||||
BACKEND_HOST=${BACKEND_HOST:-crm-backend.default.svc.cluster.local}
|
||||
BACKEND_PORT=${BACKEND_PORT:-8080}
|
||||
|
||||
sed \
|
||||
-e "s|\${RESOLVER}|$RESOLVER|g" \
|
||||
-e "s|__BACKEND_HOST__|$BACKEND_HOST|g" \
|
||||
-e "s|__BACKEND_PORT__|$BACKEND_PORT|g" \
|
||||
/etc/nginx/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
|
||||
exec nginx -g "daemon off;"
|
||||
|
|
@ -1607,7 +1607,7 @@ export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload)
|
|||
}, true);
|
||||
}
|
||||
|
||||
export async function getOpportunityOverview(keyword?: string, stage?: string) {
|
||||
export async function getOpportunityOverview(keyword?: string, stage?: string, includeDetails = true, limit?: number, archived?: boolean) {
|
||||
const params = new URLSearchParams();
|
||||
if (keyword && keyword.trim()) {
|
||||
params.set("keyword", keyword.trim());
|
||||
|
|
@ -1615,6 +1615,13 @@ export async function getOpportunityOverview(keyword?: string, stage?: string) {
|
|||
if (stage && stage.trim() && stage !== "全部") {
|
||||
params.set("stage", stage.trim());
|
||||
}
|
||||
params.set("includeDetails", String(includeDetails));
|
||||
if (limit !== undefined) {
|
||||
params.set("limit", String(limit));
|
||||
}
|
||||
if (archived !== undefined) {
|
||||
params.set("archived", String(archived));
|
||||
}
|
||||
const query = params.toString();
|
||||
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
|
||||
}
|
||||
|
|
@ -1659,11 +1666,15 @@ export async function createOpportunityFollowUp(opportunityId: CrmId, payload: C
|
|||
}, true);
|
||||
}
|
||||
|
||||
export async function getExpansionOverview(keyword?: string) {
|
||||
export async function getExpansionOverview(keyword?: string, includeDetails = true, limit?: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (keyword && keyword.trim()) {
|
||||
params.set("keyword", keyword.trim());
|
||||
}
|
||||
params.set("includeDetails", String(includeDetails));
|
||||
if (limit !== undefined) {
|
||||
params.set("limit", String(limit));
|
||||
}
|
||||
const query = params.toString();
|
||||
return request<ExpansionOverview>(`/api/expansion/overview${query ? `?${query}` : ""}`, undefined, true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
|
@ -32,6 +32,7 @@ import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
type ExpansionTab = "sales" | "channel";
|
||||
type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: CrmId } | null;
|
||||
type ExpansionExportFilters = {
|
||||
|
|
@ -1338,6 +1339,9 @@ export default function Expansion() {
|
|||
const [keyword, setKeyword] = useState("");
|
||||
const [salesData, setSalesData] = useState<SalesExpansionItem[]>([]);
|
||||
const [channelData, setChannelData] = useState<ChannelExpansionItem[]>([]);
|
||||
const [visibleItemCount, setVisibleItemCount] = useState(LIST_PAGE_SIZE);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const [officeOptions, setOfficeOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [industryOptions, setIndustryOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [provinceOptions, setProvinceOptions] = useState<ExpansionDictOption[]>([]);
|
||||
|
|
@ -1550,7 +1554,7 @@ export default function Expansion() {
|
|||
|
||||
async function loadExpansionData() {
|
||||
try {
|
||||
const data = await getExpansionOverview(keyword);
|
||||
const data = await getExpansionOverview(keyword, false, LIST_PAGE_SIZE + 1);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1574,8 +1578,61 @@ export default function Expansion() {
|
|||
};
|
||||
}, [keyword, refreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleItemCount(LIST_PAGE_SIZE);
|
||||
}, [keyword, activeTab]);
|
||||
|
||||
const activeData = activeTab === "sales" ? salesData : channelData;
|
||||
const hasMoreItems = visibleItemCount < activeData.length;
|
||||
|
||||
const loadMoreItems = async () => {
|
||||
if (!hasMoreItems || loadingMoreRef.current) {
|
||||
return;
|
||||
}
|
||||
loadingMoreRef.current = true;
|
||||
const nextVisibleCount = visibleItemCount + LIST_PAGE_SIZE;
|
||||
try {
|
||||
const data = await getExpansionOverview(keyword, false, nextVisibleCount + 1);
|
||||
setSalesData(dedupeExpansionItemsById(data.salesItems ?? []));
|
||||
setChannelData(dedupeExpansionItemsById(data.channelItems ?? []));
|
||||
setVisibleItemCount(nextVisibleCount);
|
||||
} finally {
|
||||
loadingMoreRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMoreItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
const loadMoreElement = loadMoreRef.current;
|
||||
if (loadMoreElement && loadMoreElement.getBoundingClientRect().top <= window.innerHeight + 120) {
|
||||
void loadMoreItems();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
return () => window.removeEventListener("scroll", handleScroll, true);
|
||||
}, [hasMoreItems, visibleItemCount, activeData.length, keyword, activeTab]);
|
||||
|
||||
const followUpRecords: ExpansionFollowUp[] = selectedItem?.followUps ?? [];
|
||||
|
||||
const handleSelectItem = (item: ExpansionItem) => {
|
||||
setSelectedItem(item);
|
||||
void getExpansionOverview(keyword, true)
|
||||
.then((data) => {
|
||||
const detailedItem = item.type === "sales"
|
||||
? (data.salesItems ?? []).find((candidate) => candidate.id === item.id)
|
||||
: (data.channelItems ?? []).find((candidate) => candidate.id === item.id);
|
||||
if (detailedItem) {
|
||||
setSelectedItem((current) => current?.id === item.id ? detailedItem : current);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem?.type === "sales") {
|
||||
setSalesDetailTab("projects");
|
||||
|
|
@ -2599,7 +2656,7 @@ export default function Expansion() {
|
|||
<div className="crm-list-stack">
|
||||
{activeTab === "sales" ? (
|
||||
salesData.length > 0 ? (
|
||||
salesData.map((item, i) => {
|
||||
salesData.slice(0, visibleItemCount).map((item, i) => {
|
||||
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -2607,7 +2664,7 @@ export default function Expansion() {
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
className={cn(
|
||||
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
|
||||
isOwnedByCurrentUser
|
||||
|
|
@ -2658,7 +2715,7 @@ export default function Expansion() {
|
|||
})
|
||||
) : renderEmpty()
|
||||
) : channelData.length > 0 ? (
|
||||
channelData.map((item, i) => {
|
||||
channelData.slice(0, visibleItemCount).map((item, i) => {
|
||||
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -2666,7 +2723,7 @@ export default function Expansion() {
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
className={cn(
|
||||
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
|
||||
isOwnedByCurrentUser
|
||||
|
|
@ -2718,6 +2775,23 @@ export default function Expansion() {
|
|||
);
|
||||
})
|
||||
) : renderEmpty()}
|
||||
{hasMoreItems ? (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => void loadMoreItems()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
void loadMoreItems();
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer rounded-lg border border-slate-200 bg-white py-3 text-center text-sm font-medium text-violet-600 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-violet-300"
|
||||
>
|
||||
展示更多
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ const OPPORTUNITY_EXPANSION_SEARCH_DEBOUNCE_MS = 300;
|
|||
const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划";
|
||||
const LEGACY_OPPORTUNITY_NEXT_PLAN_LABEL = "后续规划";
|
||||
const OPPORTUNITY_CREATE_PERMISSION = "opportunity:create";
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const OPPORTUNITY_EXPORT_PREFERENCES_STORAGE_KEY = "crm:opportunity-export-preferences";
|
||||
|
||||
const COMPETITOR_OPTIONS = [
|
||||
|
|
@ -1904,6 +1905,9 @@ export default function Opportunities() {
|
|||
const [error, setError] = useState("");
|
||||
const [exportError, setExportError] = useState("");
|
||||
const [items, setItems] = useState<OpportunityItem[]>([]);
|
||||
const [visibleItemCount, setVisibleItemCount] = useState(LIST_PAGE_SIZE);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
|
||||
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
|
||||
const [selectedSalesExpansionOption, setSelectedSalesExpansionOption] = useState<SearchableOption | null>(null);
|
||||
|
|
@ -1987,7 +1991,7 @@ export default function Opportunities() {
|
|||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await getOpportunityOverview(keyword, filter);
|
||||
const data = await getOpportunityOverview(keyword, filter, false, LIST_PAGE_SIZE + 1, archiveTab === "archived");
|
||||
if (!cancelled) {
|
||||
setItems(data.items ?? []);
|
||||
setSelectedItem(null);
|
||||
|
|
@ -2004,7 +2008,11 @@ export default function Opportunities() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [keyword, filter]);
|
||||
}, [keyword, filter, archiveTab]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleItemCount(LIST_PAGE_SIZE);
|
||||
}, [keyword, filter, archiveTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestedState = location.state as OpportunityLocationState;
|
||||
|
|
@ -2311,7 +2319,40 @@ export default function Opportunities() {
|
|||
|
||||
const detailItem = selectedItem ? (selectedItemDetail ?? selectedItem) : null;
|
||||
const followUpRecords: OpportunityFollowUp[] = detailItem?.followUps ?? [];
|
||||
const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
|
||||
const filteredItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
|
||||
const visibleItems = filteredItems.slice(0, visibleItemCount);
|
||||
const hasMoreItems = visibleItems.length < filteredItems.length;
|
||||
|
||||
const loadMoreItems = async () => {
|
||||
if (!hasMoreItems || loadingMoreRef.current) {
|
||||
return;
|
||||
}
|
||||
loadingMoreRef.current = true;
|
||||
const nextVisibleCount = visibleItemCount + LIST_PAGE_SIZE;
|
||||
try {
|
||||
const data = await getOpportunityOverview(keyword, filter, false, nextVisibleCount + 1, archiveTab === "archived");
|
||||
setItems(data.items ?? []);
|
||||
setVisibleItemCount(nextVisibleCount);
|
||||
} finally {
|
||||
loadingMoreRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMoreItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
const loadMoreElement = loadMoreRef.current;
|
||||
if (loadMoreElement && loadMoreElement.getBoundingClientRect().top <= window.innerHeight + 120) {
|
||||
void loadMoreItems();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
return () => window.removeEventListener("scroll", handleScroll, true);
|
||||
}, [hasMoreItems, visibleItemCount, filteredItems.length, keyword, filter, archiveTab]);
|
||||
const stageFilterOptions = [
|
||||
{ label: "全部", value: "全部" },
|
||||
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
|
||||
|
|
@ -2798,7 +2839,7 @@ export default function Opportunities() {
|
|||
};
|
||||
|
||||
const reload = async (preferredSelectedId?: CrmId) => {
|
||||
const data = await getOpportunityOverview(keyword, filter);
|
||||
const data = await getOpportunityOverview(keyword, filter, false);
|
||||
const nextItems = data.items ?? [];
|
||||
setItems(nextItems);
|
||||
if (preferredSelectedId) {
|
||||
|
|
@ -3175,6 +3216,23 @@ export default function Opportunities() {
|
|||
);
|
||||
})
|
||||
) : renderEmpty()}
|
||||
{hasMoreItems ? (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => void loadMoreItems()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
void loadMoreItems();
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer rounded-lg border border-slate-200 bg-white py-3 text-center text-sm font-medium text-violet-600 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-violet-300"
|
||||
>
|
||||
展示更多
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
*.md
|
||||
*.log
|
||||
.DS_Store
|
||||
.cert
|
||||
.env*
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# ============ 构建阶段 ============
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制 package.json,利用 Docker 缓存加速依赖安装
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --registry=https://registry.npmmirror.com
|
||||
|
||||
# 复制源码并构建
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ============ 运行阶段 ============
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx/default.conf.template /etc/nginx/default.conf.template
|
||||
COPY nginx/start.sh /etc/nginx/start.sh
|
||||
|
||||
# 从构建阶段复制 dist
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
RUN rm -f /etc/nginx/conf.d/default.conf && chmod +x /etc/nginx/start.sh
|
||||
|
||||
ENTRYPOINT ["/etc/nginx/start.sh"]
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
resolver ${RESOLVER} valid=30s ipv6=off;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/sys/ {
|
||||
set $backend_host __BACKEND_HOST__;
|
||||
set $backend_port __BACKEND_PORT__;
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
proxy_pass http://$backend_host:$backend_port;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
set $backend_host __BACKEND_HOST__;
|
||||
set $backend_port __BACKEND_PORT__;
|
||||
proxy_pass http://$backend_host:$backend_port;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location /sys/ {
|
||||
set $backend_host __BACKEND_HOST__;
|
||||
set $backend_port __BACKEND_PORT__;
|
||||
proxy_pass http://$backend_host:$backend_port;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
#!/bin/sh
|
||||
|
||||
RESOLVER=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}' | head -1)
|
||||
BACKEND_HOST=${BACKEND_HOST:-crm-backend.default.svc.cluster.local}
|
||||
BACKEND_PORT=${BACKEND_PORT:-8080}
|
||||
|
||||
sed \
|
||||
-e "s|\${RESOLVER}|$RESOLVER|g" \
|
||||
-e "s|__BACKEND_HOST__|$BACKEND_HOST|g" \
|
||||
-e "s|__BACKEND_PORT__|$BACKEND_PORT|g" \
|
||||
/etc/nginx/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
|
||||
exec nginx -g "daemon off;"
|
||||
Loading…
Reference in New Issue