feat(llm-tools): 为所有搜索工具添加总数和是否有更多数据返回

1. 为跟进、签到、客户、商机、待办、工作报告、拓展搜索工具新增总数统计和分页判断逻辑
2. 新增对应count查询的Mapper接口和XML实现
3. 完善数据权限拦截器对count方法的支持
4. 新增测试用例校验count语句不包含分页和排序
main
kangwenjing 2026-09-15 16:16:17 +08:00
parent b5b01d4f79
commit 28c336fd13
12 changed files with 544 additions and 12 deletions

View File

@ -101,6 +101,7 @@
<workItem from="1787721570035" duration="18307000" />
<workItem from="1788916111243" duration="625000" />
<workItem from="1789033867432" duration="17000" />
<workItem from="1789440226191" duration="937000" />
</task>
<task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" />

View File

@ -25,6 +25,14 @@ public interface LlmMcpMapper {
@Param("limit") int limit,
@Param("offset") int offset);
long countWorkReports(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("targetUserId") Long targetUserId,
@Param("tenantId") Long tenantId,
@Param("keyword") String keyword,
@Param("status") String status);
List<Map<String, Object>> searchOpportunities(
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId,
@ -34,6 +42,13 @@ public interface LlmMcpMapper {
@Param("limit") int limit,
@Param("offset") int offset);
long countOpportunities(
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId,
@Param("keyword") String keyword,
@Param("stage") String stage,
@Param("includeArchived") boolean includeArchived);
List<Map<String, Object>> universalSearchCustomers(
@Param("keyword") String keyword,
@Param("ownerUserId") Long ownerUserId,
@ -226,6 +241,16 @@ public interface LlmMcpMapper {
@Param("limit") int limit,
@Param("offset") int offset);
long countTodos(
@Param("keyword") String keyword,
@Param("status") String status,
@Param("priority") String priority,
@Param("bizType") String bizType,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
List<Map<String, Object>> searchOrgUsers(
@Param("keyword") String keyword,
@Param("status") Integer status,
@ -257,6 +282,16 @@ public interface LlmMcpMapper {
@Param("limit") int limit,
@Param("offset") int offset);
long countCustomers(
@Param("keyword") String keyword,
@Param("status") String status,
@Param("source") String source,
@Param("industry") String industry,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
List<Map<String, Object>> searchCheckins(
@Param("keyword") String keyword,
@Param("status") String status,
@ -268,6 +303,15 @@ public interface LlmMcpMapper {
@Param("limit") int limit,
@Param("offset") int offset);
long countCheckins(
@Param("keyword") String keyword,
@Param("status") String status,
@Param("bizType") String bizType,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
List<Map<String, Object>> searchSalesExpansions(
@Param("keyword") String keyword,
@Param("stage") String stage,
@ -279,6 +323,15 @@ public interface LlmMcpMapper {
@Param("limit") int limit,
@Param("offset") int offset);
long countSalesExpansions(
@Param("keyword") String keyword,
@Param("stage") String stage,
@Param("intentLevel") String intentLevel,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
List<Map<String, Object>> searchChannelExpansions(
@Param("keyword") String keyword,
@Param("stage") String stage,
@ -290,6 +343,15 @@ public interface LlmMcpMapper {
@Param("limit") int limit,
@Param("offset") int offset);
long countChannelExpansions(
@Param("keyword") String keyword,
@Param("stage") String stage,
@Param("intentLevel") String intentLevel,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
List<Map<String, Object>> searchFollowups(
@Param("bizType") String bizType,
@Param("keyword") String keyword,
@ -300,6 +362,14 @@ public interface LlmMcpMapper {
@Param("limit") int limit,
@Param("offset") int offset);
long countFollowups(
@Param("bizType") String bizType,
@Param("keyword") String keyword,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
Map<String, Object> selectCustomerDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
Map<String, Object> selectOpportunityDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);

View File

@ -65,7 +65,7 @@ public class McpDataPermissionInterceptor implements Interceptor {
validateParameters(methodName, boundSql.getParameterObject(), loginUser);
CrmDataVisibilityService visibilityService = visibilityServiceProvider.getIfAvailable();
if ("searchFollowups".equals(methodName)) {
if ("searchFollowups".equals(methodName) || "countFollowups".equals(methodName)) {
if (visibilityService == null) {
throw new BusinessException("MCP 数据权限服务不可用");
}
@ -293,6 +293,7 @@ public class McpDataPermissionInterceptor implements Interceptor {
static Set<String> policyMethodNames() {
java.util.LinkedHashSet<String> methodNames = new java.util.LinkedHashSet<>(POLICIES.keySet());
methodNames.add("searchFollowups");
methodNames.add("countFollowups");
methodNames.add("salesPerformance");
return Set.copyOf(methodNames);
}
@ -401,22 +402,22 @@ public class McpDataPermissionInterceptor implements Interceptor {
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",
"searchWorkReports", "countWorkReports", "universalSearchWorkReports", "dashboardDailyReportMetric",
"selectWorkReportDetail", "selectWorkReportComments");
add(policies, CrmDataVisibilityService.RESOURCE_CHECKIN, "c.user_id", null, false,
"universalSearchCheckins", "dashboardCheckinMetric", "checkinSummary",
"universalSearchCheckins", "countCheckins", "dashboardCheckinMetric", "checkinSummary",
"searchCheckins", "selectCheckinDetail");
add(policies, CrmDataVisibilityService.RESOURCE_CUSTOMER, "c.owner_user_id", null, false,
"universalSearchCustomers", "dashboardCustomerMetric", "customerSummary",
"universalSearchCustomers", "countCustomers", "dashboardCustomerMetric", "customerSummary",
"searchCustomers", "selectCustomerDetail");
add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "s.owner_user_id", null, false,
"universalSearchSalesExpansions", "salesExpansionSummary", "searchSalesExpansions",
"universalSearchSalesExpansions", "countSalesExpansions", "salesExpansionSummary", "searchSalesExpansions",
"selectSalesExpansionDetail", "selectSalesExpansionFollowups");
add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "c.owner_user_id", null, false,
"universalSearchChannelExpansions", "channelExpansionSummary", "searchChannelExpansions",
"universalSearchChannelExpansions", "countChannelExpansions", "channelExpansionSummary", "searchChannelExpansions",
"selectChannelExpansionDetail", "selectChannelExpansionContacts", "selectChannelExpansionFollowups");
add(policies, CrmDataVisibilityService.RESOURCE_WORK, "t.user_id", null, false,
"universalSearchTodos", "searchTodos", "todoSummary", "selectTodoDetail");
"universalSearchTodos", "countTodos", "searchTodos", "todoSummary", "selectTodoDetail");
add(policies, CrmDataVisibilityService.RESOURCE_WORK, "l.operator_user_id", null, false,
"universalSearchActivities");
add(policies, CrmDataVisibilityService.RESOURCE_OPPORTUNITY, "o.owner_user_id",
@ -428,7 +429,7 @@ public class McpDataPermissionInterceptor implements Interceptor {
add(policies, CrmDataVisibilityService.RESOURCE_OPPORTUNITY, "o.owner_user_id",
"o.project_ownership_location", true,
"searchOpportunities", "universalSearchOpportunities", "dashboardNewOpportunityMetric",
"searchOpportunities", "countOpportunities", "universalSearchOpportunities", "dashboardNewOpportunityMetric",
"dashboardWonOpportunityMetric", "opportunityFunnel", "opportunityTrend",
"selectOpportunityDetail", "selectCustomerOpportunities", "selectOpportunityFollowups",
"selectSalesExpansionOpportunities", "selectChannelExpansionOpportunities");

View File

@ -49,7 +49,17 @@ public class CrmCheckinSearchToolProvider extends PermissionedMcpToolProvider {
@Override
protected Object handle(Map<String, Object> arguments) {
QueryContext context = buildContext(arguments);
long total = llmMcpMapper.countCheckins(
getString(arguments, "keyword"),
getString(arguments, "status"),
getString(arguments, "bizType"),
context.startDate(),
context.endDate(),
context.ownerUserId(),
context.tenantId());
Map<String, Object> result = wrap(context);
result.put("total", total);
result.put("hasMore", (long) context.offset() + context.pageSize() < total);
result.put("rows", sanitizeRows(llmMcpMapper.searchCheckins(
getString(arguments, "keyword"),
getString(arguments, "status"),

View File

@ -50,7 +50,18 @@ public class CrmDetailSearchToolProvider extends PermissionedMcpToolProvider {
@Override
protected Object handle(Map<String, Object> arguments) {
QueryContext context = buildContext(arguments);
long total = llmMcpMapper.countCustomers(
getString(arguments, "keyword"),
getString(arguments, "status"),
getString(arguments, "source"),
getString(arguments, "industry"),
context.startDate(),
context.endDate(),
context.ownerUserId(),
context.tenantId());
Map<String, Object> result = wrap(context);
result.put("total", total);
result.put("hasMore", (long) context.offset() + context.pageSize() < total);
result.put("rows", sanitizeRows(llmMcpMapper.searchCustomers(
getString(arguments, "keyword"),
getString(arguments, "status"),

View File

@ -61,6 +61,40 @@ public class CrmExpansionSearchToolProvider extends PermissionedMcpToolProvider
if (!StringUtils.hasText(expansionType)) {
expansionType = "all";
}
long total = switch (expansionType) {
case "sales" -> llmMcpMapper.countSalesExpansions(
keyword,
stage,
intentLevel,
context.startDate(),
context.endDate(),
context.ownerUserId(),
context.tenantId());
case "channel" -> llmMcpMapper.countChannelExpansions(
keyword,
stage,
intentLevel,
context.startDate(),
context.endDate(),
context.ownerUserId(),
context.tenantId());
default -> llmMcpMapper.countSalesExpansions(
keyword,
stage,
intentLevel,
context.startDate(),
context.endDate(),
context.ownerUserId(),
context.tenantId())
+ llmMcpMapper.countChannelExpansions(
keyword,
stage,
intentLevel,
context.startDate(),
context.endDate(),
context.ownerUserId(),
context.tenantId());
};
List<Map<String, Object>> rows = switch (expansionType) {
case "sales" -> normalizeRows(llmMcpMapper.searchSalesExpansions(
keyword,
@ -85,6 +119,8 @@ public class CrmExpansionSearchToolProvider extends PermissionedMcpToolProvider
default -> mergedRows(keyword, stage, intentLevel, context);
};
Map<String, Object> result = wrap(context);
result.put("total", total);
result.put("hasMore", (long) context.offset() + context.pageSize() < total);
result.put("rows", rows);
return result;
}

View File

@ -53,7 +53,16 @@ public class CrmFollowupSearchToolProvider extends PermissionedMcpToolProvider {
if (!StringUtils.hasText(bizType)) {
bizType = "all";
}
long total = llmMcpMapper.countFollowups(
bizType,
getString(arguments, "keyword"),
context.startDate(),
context.endDate(),
context.ownerUserId(),
context.tenantId());
Map<String, Object> result = wrap(context);
result.put("total", total);
result.put("hasMore", (long) context.offset() + context.pageSize() < total);
result.put("rows", sanitizeRows(llmMcpMapper.searchFollowups(
bizType,
getString(arguments, "keyword"),

View File

@ -70,6 +70,16 @@ public class CrmTodoSearchToolProvider extends PermissionedMcpToolProvider {
int page = getPage(arguments);
Long tenantId = tenantProvider.getCurrentTenantId();
Long ownerUserId = getLong(arguments, "ownerUserId");
int offset = getOffset(page, pageSize);
long total = llmMcpMapper.countTodos(
getString(arguments, "keyword"),
getString(arguments, "status"),
getString(arguments, "priority"),
getString(arguments, "bizType"),
startDate,
endDate,
ownerUserId,
tenantId);
List<Map<String, Object>> rows = llmMcpMapper.searchTodos(
getString(arguments, "keyword"),
getString(arguments, "status"),
@ -80,7 +90,7 @@ public class CrmTodoSearchToolProvider extends PermissionedMcpToolProvider {
ownerUserId,
tenantId,
pageSize,
getOffset(page, pageSize));
offset);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ownerUserId", ownerUserId);
@ -89,6 +99,8 @@ public class CrmTodoSearchToolProvider extends PermissionedMcpToolProvider {
result.put("endDate", endDate == null ? null : endDate.toString());
result.put("page", page);
result.put("pageSize", pageSize);
result.put("total", total);
result.put("hasMore", (long) offset + pageSize < total);
result.put("rows", sanitizeRows(rows));
return result;
}

View File

@ -63,6 +63,13 @@ public class OpportunitySearchToolProvider extends PermissionedMcpToolProvider {
boolean includeArchived = getBoolean(arguments, "includeArchived", false);
Long tenantId = tenantProvider.getCurrentTenantId();
int offset = getOffset(page, pageSize);
long total = llmMcpMapper.countOpportunities(
ownerUserId,
tenantId,
keyword,
stage,
includeArchived);
List<Map<String, Object>> rows = sanitizeRows(llmMcpMapper.searchOpportunities(
ownerUserId,
tenantId,
@ -70,12 +77,14 @@ public class OpportunitySearchToolProvider extends PermissionedMcpToolProvider {
stage,
includeArchived,
pageSize,
getOffset(page, pageSize)));
offset));
Map<String, Object> result = new LinkedHashMap<>();
result.put("ownerUserId", ownerUserId);
result.put("tenantId", tenantId);
result.put("page", page);
result.put("pageSize", pageSize);
result.put("total", total);
result.put("hasMore", (long) offset + pageSize < total);
result.put("rows", rows);
return result;
}

View File

@ -67,7 +67,15 @@ public class WorkReportSearchToolProvider extends PermissionedMcpToolProvider {
String keyword = getString(arguments, "keyword");
String status = getString(arguments, "status");
Long tenantId = tenantProvider.getCurrentTenantId();
int offset = getOffset(page, pageSize);
long total = llmMcpMapper.countWorkReports(
dateRange.startDate(),
dateRange.endDate(),
targetUserId,
tenantId,
keyword,
status);
List<Map<String, Object>> rows = sanitizeRows(llmMcpMapper.searchWorkReports(
dateRange.startDate(),
dateRange.endDate(),
@ -76,13 +84,15 @@ public class WorkReportSearchToolProvider extends PermissionedMcpToolProvider {
keyword,
status,
pageSize,
getOffset(page, pageSize)));
offset));
Map<String, Object> result = new LinkedHashMap<>();
result.put("startDate", dateRange.startDate().toString());
result.put("endDate", dateRange.endDate().toString());
result.put("userId", targetUserId);
result.put("page", page);
result.put("pageSize", pageSize);
result.put("total", total);
result.put("hasMore", (long) offset + pageSize < total);
result.put("rows", rows);
return result;
}

View File

@ -99,6 +99,36 @@
offset #{offset}
</select>
<select id="countWorkReports" resultType="long">
select count(*)
from work_daily_report r
left join sys_user u on u.user_id = r.user_id and coalesce(u.is_deleted, 0) = 0
where r.report_date between #{startDate} and #{endDate}
<if test="targetUserId != null">
and r.user_id = #{targetUserId}
</if>
<if test="tenantId != null">
and exists (
select 1
from sys_tenant_user tu
where tu.user_id = r.user_id
and tu.tenant_id = #{tenantId}
and coalesce(tu.is_deleted, 0) = 0
)
</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
or coalesce(r.work_content, '') ilike concat('%', #{keyword}, '%')
or coalesce(r.tomorrow_plan, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="status != null and status != ''">
and coalesce(r.status, 'submitted') = #{status}
</if>
</select>
<select id="searchOpportunities" resultType="java.util.LinkedHashMap">
select
o.id as "id",
@ -161,6 +191,46 @@
offset #{offset}
</select>
<select id="countOpportunities" resultType="long">
select count(*)
from crm_opportunity o
left join crm_customer c on c.id = o.customer_id
left join sys_user u on u.user_id = o.owner_user_id and coalesce(u.is_deleted, 0) = 0
left join cnarea project_ownership_area
on project_ownership_area.level = 1
and project_ownership_area.area_code = o.project_ownership_location
where 1 = 1
<if test="ownerUserId != null">
and o.owner_user_id = #{ownerUserId}
</if>
<if test="!includeArchived">
and coalesce(o.archived, false) = false
</if>
<if test="tenantId != null">
and exists (
select 1
from sys_tenant_user tu
where tu.user_id = o.owner_user_id
and tu.tenant_id = #{tenantId}
and coalesce(tu.is_deleted, 0) = 0
)
</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(o.opportunity_code, '') ilike concat('%', #{keyword}, '%')
or coalesce(o.opportunity_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.customer_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(o.project_location, '') ilike concat('%', #{keyword}, '%')
or coalesce(o.project_ownership_location, '') ilike concat('%', #{keyword}, '%')
or coalesce(project_ownership_area.short_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(o.product_type, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="stage != null and stage != ''">
and o.stage = #{stage}
</if>
</select>
<select id="universalSearch" resultType="java.util.LinkedHashMap">
with search_rows as (
select
@ -1159,6 +1229,28 @@
offset #{offset}
</select>
<select id="countTodos" resultType="long">
select count(*)
from work_todo t
left join sys_user u on u.user_id = t.user_id and coalesce(u.is_deleted, 0) = 0
where 1 = 1
<if test="ownerUserId != null">and t.user_id = #{ownerUserId}</if>
<if test="status != null and status != ''">and t.status = #{status}</if>
<if test="priority != null and priority != ''">and t.priority = #{priority}</if>
<if test="bizType != null and bizType != ''">and t.biz_type = #{bizType}</if>
<if test="startDate != null">and t.due_date::date &gt;= #{startDate}</if>
<if test="endDate != null">and t.due_date::date &lt;= #{endDate}</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = t.user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(t.title, '') ilike concat('%', #{keyword}, '%')
or coalesce(t.biz_type, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
)
</if>
</select>
<select id="searchOrgUsers" resultType="java.util.LinkedHashMap">
select
u.user_id as "userId",
@ -1311,6 +1403,29 @@
offset #{offset}
</select>
<select id="countCustomers" resultType="long">
select count(*)
from crm_customer c
where 1 = 1
<if test="ownerUserId != null">and c.owner_user_id = #{ownerUserId}</if>
<if test="startDate != null">and c.created_at::date &gt;= #{startDate}</if>
<if test="endDate != null">and c.created_at::date &lt;= #{endDate}</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(c.customer_code, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.customer_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.industry, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.province, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.city, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="status != null and status != ''">and c.status = #{status}</if>
<if test="source != null and source != ''">and c.source = #{source}</if>
<if test="industry != null and industry != ''">and c.industry = #{industry}</if>
</select>
<select id="searchCheckins" resultType="java.util.LinkedHashMap">
select
c.id as "id",
@ -1348,6 +1463,27 @@
offset #{offset}
</select>
<select id="countCheckins" resultType="long">
select count(*)
from work_checkin c
where 1 = 1
<if test="ownerUserId != null">and c.user_id = #{ownerUserId}</if>
<if test="startDate != null">and c.checkin_date &gt;= #{startDate}</if>
<if test="endDate != null">and c.checkin_date &lt;= #{endDate}</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(c.user_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.dept_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.biz_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.location_text, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="status != null and status != ''">and c.status = #{status}</if>
<if test="bizType != null and bizType != ''">and c.biz_type = #{bizType}</if>
</select>
<select id="searchExpansions" resultType="java.util.LinkedHashMap">
with expansion_rows as (
select
@ -1466,6 +1602,28 @@
offset #{offset}
</select>
<select id="countSalesExpansions" resultType="long">
select count(*)
from crm_sales_expansion s
where 1 = 1
<if test="ownerUserId != null">and s.owner_user_id = #{ownerUserId}</if>
<if test="stage != null and stage != ''">and s.stage = #{stage}</if>
<if test="intentLevel != null and intentLevel != ''">and s.intent_level = #{intentLevel}</if>
<if test="startDate != null">and s.created_at::date &gt;= #{startDate}</if>
<if test="endDate != null">and s.created_at::date &lt;= #{endDate}</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(s.employee_no, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.candidate_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.mobile, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.target_dept, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.industry, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.remark, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = s.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
</select>
<select id="searchChannelExpansions" resultType="java.util.LinkedHashMap">
select
'channel' as "expansionType",
@ -1504,6 +1662,30 @@
offset #{offset}
</select>
<select id="countChannelExpansions" resultType="long">
select count(*)
from crm_channel_expansion c
where 1 = 1
<if test="ownerUserId != null">and c.owner_user_id = #{ownerUserId}</if>
<if test="stage != null and stage != ''">and c.stage = #{stage}</if>
<if test="intentLevel != null and intentLevel != ''">and c.intent_level = #{intentLevel}</if>
<if test="startDate != null">and c.created_at::date &gt;= #{startDate}</if>
<if test="endDate != null">and c.created_at::date &lt;= #{endDate}</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(c.channel_code, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.channel_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.province, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.city, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.channel_industry, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.contact_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.contact_mobile, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
</select>
<select id="searchFollowups" resultType="java.util.LinkedHashMap">
with followup_rows as (
select
@ -1592,6 +1774,76 @@
offset #{offset}
</select>
<select id="countFollowups" resultType="long">
with followup_rows as (
select
'opportunity' as "bizType",
f.id as "id",
f.opportunity_id as "bizId",
coalesce(o.opportunity_name, '') as "bizName",
f.followup_type as "followupType",
left(coalesce(f.content, ''), 800) as "content",
left(coalesce(f.next_action, ''), 300) as "nextAction",
f.followup_user_id as "userId",
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
where #{bizType} in ('all', 'opportunity')
<if test="ownerUserId != null">and f.followup_user_id = #{ownerUserId}</if>
<if test="startDate != null">and f.followup_time::date &gt;= #{startDate}</if>
<if test="endDate != null">and f.followup_time::date &lt;= #{endDate}</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(o.opportunity_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(f.content, '') ilike concat('%', #{keyword}, '%')
or coalesce(f.next_action, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = f.followup_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
union all
select
f.biz_type as "bizType",
f.id as "id",
f.biz_id as "bizId",
case when f.biz_type = 'sales' then coalesce(s.candidate_name, '') else coalesce(c.channel_name, '') end as "bizName",
f.followup_type as "followupType",
left(coalesce(f.content, ''), 800) as "content",
left(coalesce(f.next_action, f.next_plan, ''), 300) as "nextAction",
f.followup_user_id as "userId",
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'
where #{bizType} in ('all', 'sales', 'channel')
<if test="ownerUserId != null">and f.followup_user_id = #{ownerUserId}</if>
<if test="startDate != null">and f.followup_time::date &gt;= #{startDate}</if>
<if test="endDate != null">and f.followup_time::date &lt;= #{endDate}</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(s.candidate_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.channel_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(f.content, '') ilike concat('%', #{keyword}, '%')
or coalesce(f.next_action, '') ilike concat('%', #{keyword}, '%')
or coalesce(f.next_plan, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = f.followup_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
)
select count(*) from followup_rows
</select>
<select id="selectCustomerDetail" resultType="java.util.LinkedHashMap">
select
c.*,
@ -1885,4 +2137,86 @@
offset #{offset}
</select>
<!-- 渠道统计分析:按认证级别(tz_rzjb字典)分组,全量无分页 -->
<select id="channelTierDistribution" resultType="java.util.LinkedHashMap">
select
coalesce(nullif(btrim(ti.item_label), ''), '未分级') as "tierName",
coalesce(ti.item_value, '') as "tierCode",
count(c.id)::bigint as "channelCount",
count(case when c.intent_level = 'high' then 1 end)::bigint as "highIntentCount",
count(case when c.stage = 'won' then 1 end)::bigint as "wonCount",
count(case when coalesce(c.has_desktop_exp, false) = true then 1 end)::bigint as "desktopExpCount"
from crm_channel_expansion c
left join sys_dict_item ti
on ti.type_code = 'tz_rzjb'
and ti.item_value = c.certification_level
and coalesce(ti.is_deleted, 0) = 0
and coalesce(ti.status, 1) = 1
where c.created_at::date between #{startDate} and #{endDate}
<if test="ownerUserId != null">and c.owner_user_id = #{ownerUserId}</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
group by coalesce(nullif(btrim(ti.item_label), ''), '未分级'), coalesce(ti.item_value, '')
order by "channelCount" desc, "tierName" asc
</select>
<!-- 渠道统计分析:按合作意向(intent_level)分组,全量无分页 -->
<select id="channelIntentDistribution" resultType="java.util.LinkedHashMap">
select
coalesce(c.intent_level, 'unknown') as "intentKey",
case lower(coalesce(c.intent_level, 'unknown'))
when 'high' then '高意向'
when 'medium' then '中意向'
when 'low' then '低意向'
else '未标注'
end as "intentName",
count(1)::bigint as "channelCount"
from crm_channel_expansion c
where c.created_at::date between #{startDate} and #{endDate}
<if test="ownerUserId != null">and c.owner_user_id = #{ownerUserId}</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
group by c.intent_level
order by "channelCount" desc, "intentName" asc
</select>
<!-- 渠道统计分析:覆盖省份/城市,基于覆盖率从表 crm_channel_expansion_coverage全量无分页 -->
<select id="channelCoverageSummary" resultType="java.util.LinkedHashMap">
select
coalesce(cov.province, '未知') as "province",
count(distinct c.id)::bigint as "channelCount",
count(distinct nullif(btrim(cov.city), ''))::bigint as "cityCount"
from crm_channel_expansion_coverage cov
join crm_channel_expansion c on c.id = cov.channel_id
where c.created_at::date between #{startDate} and #{endDate}
<if test="ownerUserId != null">and c.owner_user_id = #{ownerUserId}</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
group by cov.province
order by "channelCount" desc, "province" asc
</select>
<!-- 渠道统计分析:按年度营业额分档,全量无分页 -->
<select id="channelRevenueTier" resultType="java.util.LinkedHashMap">
select
case
when annual_revenue is null then '未填写'
when annual_revenue < 100 then '<100'
when annual_revenue < 500 then '100-500'
when annual_revenue < 1000 then '500-1000'
else '1000万+'
end as "revenueTier",
count(1)::bigint as "channelCount",
coalesce(round(avg(annual_revenue)), 0)::bigint as "avgRevenue"
from crm_channel_expansion c
where c.created_at::date between #{startDate} and #{endDate}
<if test="ownerUserId != null">and c.owner_user_id = #{ownerUserId}</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
group by case
when annual_revenue is null then '未填写'
when annual_revenue < 100 then '<100'
when annual_revenue < 500 then '100-500'
when annual_revenue < 1000 then '500-1000'
else '1000万+'
end
order by "channelCount" desc
</select>
</mapper>

View File

@ -159,7 +159,7 @@ class McpDataPermissionInterceptorTest {
CrmDataVisibilityService.RESOURCE_DAILY_REPORT, visibility,
CrmDataVisibilityService.RESOURCE_CHECKIN, visibility));
} else {
String condition = "searchFollowups".equals(methodName)
String condition = ("searchFollowups".equals(methodName) || "countFollowups".equals(methodName))
? interceptor.buildFollowupVisibilityCondition(visibility, visibility, loginUser)
: interceptor.buildVisibilityCondition(methodName, visibility, loginUser);
rewritten = interceptor.rewriteSql(boundSql.getSql(), condition);
@ -168,6 +168,35 @@ class McpDataPermissionInterceptorTest {
}
}
@Test
void everyCountStatementShouldNotPaginate() throws Exception {
String[] countMethods = {
"countWorkReports",
"countOpportunities",
"countTodos",
"countCustomers",
"countCheckins",
"countSalesExpansions",
"countChannelExpansions",
"countFollowups"};
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();
}
for (String methodName : countMethods) {
BoundSql boundSql = configuration.getMappedStatement(
"com.unis.crm.llm.mapper.LlmMcpMapper." + methodName)
.getBoundSql(allParameters());
String sql = boundSql.getSql().toLowerCase();
assertTrue(sql.startsWith("select count(*)") || sql.contains("select count(*)"),
methodName + " should count all rows");
assertTrue(!sql.contains(" limit ") && !sql.contains(" offset "),
methodName + " must not apply pagination to count");
assertTrue(!sql.contains("order by"), methodName + " must not order the count query");
}
}
@Test
void everyMcpMapperMethodShouldBeProtectedOrExplicitlyTenantSafe() {
Set<String> tenantSafeMethods = Set.of(