接口迁移

This commit is contained in:
tanglong 2026-07-21 13:51:40 +08:00
parent ff8f30bee1
commit aaaee18491
8 changed files with 417 additions and 81 deletions

View File

@ -15,6 +15,6 @@ GET 用于企业微信保存回调配置时验签,POST 用于接收用户消
- 沿用 `foundation.wecom` 下现有的 CorpId、Token、EncodingAESKey。
- 问答小助手和运动馆智能助手分别配置在 `foundation.wecom.apps.general`、`foundation.wecom.apps.sports-venue`。
- `DEEPSEEK_API_KEY`(沿用项目现有 DeepSeek 配置)。
- `CRM_REPORT_BASE_URL`,指向学邦同步服务,例如 `http://127.0.0.1:9999`。
- 客资日报接口运行在当前 8880 服务内,直接查询 `xuebang_db` 中的同步数据,不调用 9999 同步服务。
`/v1/integrations/wecom/callback` 保持原有扣子默认工作流;`/v1/integrations/wecom/sports-venue/callback` 使用 DeepSeek 语义识别并调用客资日报接口。回复消息时,服务端会根据入口选择对应应用的 AgentId 和 Secret。

View File

@ -13,7 +13,6 @@ public class FoundationProperties {
private final Coze coze = new Coze();
private final Wecom wecom = new Wecom();
private final Semantic semantic = new Semantic();
private final InternalApis internalApis = new InternalApis();
private final MiniProgram miniProgram = new MiniProgram();
public Auth getAuth() { return auth; }
@ -22,7 +21,6 @@ public class FoundationProperties {
public Coze getCoze() { return coze; }
public Wecom getWecom() { return wecom; }
public Semantic getSemantic() { return semantic; }
public InternalApis getInternalApis() { return internalApis; }
public MiniProgram getMiniProgram() { return miniProgram; }
public static class Auth {
@ -148,24 +146,6 @@ public class FoundationProperties {
public void setMinimumConfidence(double minimumConfidence) { this.minimumConfidence = minimumConfidence; }
}
public static class InternalApis {
private final CrmReport crmReport = new CrmReport();
public CrmReport getCrmReport() { return crmReport; }
public static class CrmReport {
private String baseUrl = "http://127.0.0.1:9999";
private String path = "/api/crm/daily-report";
private int timeoutMs = 30000;
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public int getTimeoutMs() { return timeoutMs; }
public void setTimeoutMs(int timeoutMs) { this.timeoutMs = timeoutMs; }
}
}
public static class MiniProgram {
private final Datasource datasource = new Datasource();
public Datasource getDatasource() { return datasource; }

View File

@ -0,0 +1,23 @@
package com.company.agentfoundation.crm.report;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/crm")
public class CustomerDailyReportController {
private final CustomerDailyReportService reportService;
public CustomerDailyReportController(CustomerDailyReportService reportService) {
this.reportService = reportService;
}
@PostMapping("/daily-report")
public Map<String, Object> dailyReport(@RequestBody CustomerDailyReportRequest request) {
return reportService.generate(request);
}
}

View File

@ -0,0 +1,5 @@
package com.company.agentfoundation.crm.report;
import java.time.LocalDate;
public record CustomerDailyReportRequest(LocalDate startDate, LocalDate endDate, String wecomUserId) {}

View File

@ -0,0 +1,374 @@
package com.company.agentfoundation.crm.report;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
public class CustomerDailyReportService {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter SQL_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String CUSTOMER = "xuebang_db.xuebang_api_customer_findcustomerlist";
private static final String FOLLOWUP = "xuebang_db.xuebang_api_customer_customerfollowup_findbyinstitution";
private static final String APPOINTMENT = "xuebang_db.xuebang_api_customer_appiontmentmanage_findbyinstitution";
private static final String CONTRACT = "xuebang_db.xuebang_api_contract_findbyinstitution";
private static final String USER = "xuebang_db.xuebang_api_user_listbyinstitution";
private static final String USER_COMPOSITE = "xuebang_db.xuebang_api_user_composite_search";
private static final int WARNING_HOURS = 24;
private static final int CRITICAL_HOURS = 72;
private static final double LOW_CONVERSION = 0.35D;
private static final double COMPLETENESS_TARGET = 0.98D;
private final NamedParameterJdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public CustomerDailyReportService(NamedParameterJdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
public Map<String, Object> generate(CustomerDailyReportRequest request) {
if (request == null || clean(request.wecomUserId()).isEmpty()) {
throw new IllegalArgumentException("wecomUserId is required");
}
LocalDate today = LocalDate.now(BUSINESS_ZONE);
LocalDate startDate = request.startDate() == null ? today : request.startDate();
LocalDate endDate = request.endDate() == null ? startDate : request.endDate();
if (endDate.isBefore(startDate) || startDate.plusYears(1).isBefore(endDate)) {
throw new IllegalArgumentException("invalid report date range");
}
Caller caller = resolveCaller(request.wecomUserId());
if (!caller.resolved()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权限查看:企业微信账号未完成人员映射");
}
Scope scope = deriveScope(caller);
MapSqlParameterSource params = parameters(scope, startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay());
Map<String, Object> counts = jdbc.queryForMap(funnelSql(), params);
long added = number(counts, "new_count");
long assigned = number(counts, "assigned_count");
long firstCall = number(counts, "first_call_count");
long appointment = number(counts, "appointment_count");
long visit = number(counts, "visit_count");
long deal = number(counts, "deal_count");
long converted = number(counts, "converted_count");
long invalid = number(counts, "invalid_count");
List<Map<String, Object>> funnel = new ArrayList<>();
stage(funnel, "NEW", "新增客资", added, null);
stage(funnel, "ASSIGNED", "分配", assigned, added);
stage(funnel, "FIRST_CALL", "首电", firstCall, assigned);
stage(funnel, "APPOINTMENT", "邀约", appointment, firstCall);
stage(funnel, "VISIT", "到店", visit, appointment);
stage(funnel, "DEAL", "成交", deal, visit);
List<Map<String, Object>> alerts = loadAlerts(params);
long noFollowup = alerts.stream().mapToLong(row -> ((Number) row.get("customerCount")).longValue()).sum();
long over3Days = alerts.stream().mapToLong(row -> ((Number) row.get("over3DaysCount")).longValue()).sum();
Map<String, Object> completeness = jdbc.queryForMap(completenessSql(), params);
long total = number(completeness, "total_count");
long missingName = number(completeness, "missing_name");
long missingContact = number(completeness, "missing_contact");
long missingSource = number(completeness, "missing_source");
long missingCampus = number(completeness, "missing_campus");
long missingIntention = number(completeness, "missing_intention");
double completenessRate = total == 0 ? 1D : round(
(total * 5D - missingName - missingContact - missingSource - missingCampus - missingIntention) / (total * 5D));
Map<String, Object> result = new LinkedHashMap<>();
result.put("reportWindow", Map.of("startDate", startDate, "endDate", endDate, "zoneId", BUSINESS_ZONE.getId()));
result.put("scope", scopeMap(scope));
result.put("caller", callerMap(caller));
result.put("coreMetrics", Map.of(
"added", added, "assigned", assigned, "followed", firstCall, "firstCall", firstCall,
"converted", converted, "invalid", invalid));
result.put("funnel", funnel);
result.put("exceptionMonitor", Map.of(
"noFollowupCount", noFollowup, "over24HoursCount", noFollowup,
"over3DaysCount", over3Days, "alerts", alerts));
result.put("specificationCheck", Map.of(
"completenessRate", completenessRate, "targetRate", COMPLETENESS_TARGET,
"passed", completenessRate >= COMPLETENESS_TARGET,
"missingFields", Map.of("customerName", missingName, "contact", missingContact,
"source", missingSource, "campus", missingCampus, "intention", missingIntention)));
return result;
}
private Caller resolveCaller(String wecomUserId) {
String safeUserId = clean(wecomUserId);
try {
return jdbc.queryForObject("""
SELECT COALESCE(NULLIF(u.user_id, ''), c.boss_user_id, '') user_id,
COALESCE(NULLIF(u.name, ''), c.name, '') name,
COALESCE(u.organization_id, '') organization_id,
COALESCE(u.organization_name, '') organization_name,
COALESCE(u.role_names, '') role_names,
COALESCE(u.user_manage_organizations, '') manage_organizations
FROM %s c LEFT JOIN %s u ON u.user_id = c.boss_user_id
WHERE c.wxqy_user_id = :wecomUserId LIMIT 1
""".formatted(USER_COMPOSITE, USER), Map.of("wecomUserId", safeUserId),
(rs, row) -> caller(safeUserId, rs.getString("user_id"), rs.getString("name"),
rs.getString("organization_id"), rs.getString("organization_name"),
rs.getString("role_names"), rs.getString("manage_organizations"), "WECOM_USER_ID", ""));
} catch (EmptyResultDataAccessException ignored) {
return resolveLegacy(safeUserId);
}
}
private Caller resolveLegacy(String wecomUserId) {
Map<String, Object> legacy;
try {
legacy = jdbc.queryForMap("""
SELECT employee_name, COALESCE(xuebang_account, '') xuebang_account,
COALESCE(data_scope_type, '') data_scope_type
FROM employee_directory WHERE wecom_user_id = :wecomUserId LIMIT 1
""", Map.of("wecomUserId", wecomUserId));
} catch (EmptyResultDataAccessException ignored) {
return Caller.unresolved(wecomUserId);
}
String account = text(legacy, "xuebang_account");
if (!account.isEmpty()) {
try {
Map<String, Object> row = jdbc.queryForMap("SELECT * FROM " + USER + " WHERE account=:account LIMIT 1",
Map.of("account", account));
return callerFromRow(wecomUserId, row, "LEGACY_ACCOUNT", text(legacy, "data_scope_type"));
} catch (EmptyResultDataAccessException ignored) {
// Continue with the unique-name fallback.
}
}
String normalized = normalizeName(text(legacy, "employee_name"));
List<Map<String, Object>> matches = jdbc.queryForList("""
SELECT user_id,name,organization_id,organization_name,role_names,user_manage_organizations
FROM %s WHERE COALESCE(has_dimission, '0')='0'
""".formatted(USER), Map.of()).stream()
.filter(row -> normalized.equals(normalizeName(text(row, "name"))))
.toList();
return matches.size() == 1
? callerFromRow(wecomUserId, matches.get(0), "NORMALIZED_UNIQUE_NAME", text(legacy, "data_scope_type"))
: Caller.unresolved(wecomUserId);
}
private Caller callerFromRow(String wecomUserId, Map<String, Object> row, String method, String legacyScope) {
return caller(wecomUserId, text(row, "user_id"), text(row, "name"), text(row, "organization_id"),
text(row, "organization_name"), text(row, "role_names"), text(row, "user_manage_organizations"),
method, legacyScope);
}
private Caller caller(String wecomUserId, String userId, String name, String organizationId,
String organizationName, String roles, String managed, String method, String legacyScope) {
return new Caller(wecomUserId, !clean(userId).isEmpty(), clean(userId), clean(name), clean(organizationId),
clean(organizationName), clean(roles), clean(managed), method, clean(legacyScope));
}
private Scope deriveScope(Caller caller) {
if (caller.roles().contains("集团管理员") || "ALL_ORGANIZATION".equalsIgnoreCase(caller.legacyScope())) {
return new Scope("ALL_ORGANIZATION", true, false, List.of(), List.of(), caller.userId(), caller.name());
}
Organizations managed = parseOrganizations(caller.managedOrganizations());
boolean organizationRole = caller.roles().contains("城市总") || caller.roles().contains("馆长")
|| caller.roles().contains("教学主管") || "MULTI_VENUE".equalsIgnoreCase(caller.legacyScope())
|| "VENUE".equalsIgnoreCase(caller.legacyScope());
if (organizationRole || !managed.ids().isEmpty() || !managed.names().isEmpty()) {
Set<String> ids = new LinkedHashSet<>(managed.ids());
Set<String> names = new LinkedHashSet<>(managed.names());
if (!caller.organizationId().isEmpty()) ids.add(caller.organizationId());
if (!caller.organizationName().isEmpty()) names.add(caller.organizationName());
if (ids.isEmpty() && names.isEmpty()) throw forbidden();
return new Scope(ids.size() + names.size() > 2 ? "MULTI_ORGANIZATION" : "ORGANIZATION", false, false,
List.copyOf(ids), List.copyOf(names), caller.userId(), caller.name());
}
if (caller.organizationId().isEmpty() && caller.organizationName().isEmpty()) throw forbidden();
return new Scope("SELF_OR_ASSIGNED", false, true, List.of(), List.of(), caller.userId(), caller.name());
}
private ResponseStatusException forbidden() {
return new ResponseStatusException(HttpStatus.FORBIDDEN, "无权限查看:账号不在可查询的组织范围内");
}
private Organizations parseOrganizations(String raw) {
if (raw == null || raw.isBlank()) return new Organizations(List.of(), List.of());
try {
JsonNode root = objectMapper.readTree(raw);
Set<String> ids = new LinkedHashSet<>();
Set<String> names = new LinkedHashSet<>();
if (root.isArray()) root.forEach(node -> addOrganization(node, ids, names));
else addOrganization(root, ids, names);
return new Organizations(List.copyOf(ids), List.copyOf(names));
} catch (Exception ignored) {
return new Organizations(List.of(), List.of());
}
}
private void addOrganization(JsonNode node, Set<String> ids, Set<String> names) {
if (node == null || !node.isObject()) return;
addFirst(node, ids, "organizationId", "organization_id", "id");
addFirst(node, names, "organizationName", "organization_name", "name");
}
private void addFirst(JsonNode node, Set<String> target, String... fields) {
for (String field : fields) {
JsonNode value = node.get(field);
if (value != null && !value.asText().isBlank()) {
target.add(value.asText().trim());
return;
}
}
}
private List<Map<String, Object>> loadAlerts(MapSqlParameterSource params) {
return jdbc.query(alertSql(), params, (rs, row) -> {
long count = rs.getLong("customer_count");
long critical = rs.getLong("critical_count");
String advisorName = rs.getString("advisor_name");
Map<String, Object> alert = new LinkedHashMap<>();
alert.put("advisorId", rs.getString("advisor_id"));
alert.put("advisorName", advisorName);
alert.put("customerCount", count);
alert.put("over3DaysCount", critical);
alert.put("maxStaleHours", rs.getLong("max_stale_hours"));
alert.put("level", critical > 0 ? "CRITICAL" : "WARNING");
alert.put("message", "警告:顾问%s 名下 %d 个客资超过24小时未跟进,其中 %d 个已超过3天"
.formatted(advisorName, count, critical));
return alert;
});
}
private MapSqlParameterSource parameters(Scope scope, LocalDateTime start, LocalDateTime end) {
return new MapSqlParameterSource()
.addValue("startTime", SQL_TIME.format(start)).addValue("endTime", SQL_TIME.format(end))
.addValue("scopeAll", scope.all() ? 1 : 0).addValue("scopeAdvisor", scope.advisorOnly() ? 1 : 0)
.addValue("organizationIds", scope.organizationIds().isEmpty() ? List.of("__NONE__") : scope.organizationIds())
.addValue("organizationNames", scope.organizationNames().isEmpty() ? List.of("__NONE__") : scope.organizationNames())
.addValue("callerUserId", scope.advisorId()).addValue("callerUserName", scope.advisorName())
.addValue("warningHours", WARNING_HOURS).addValue("criticalHours", CRITICAL_HOURS).addValue("maxAlerts", 100);
}
private String scopeSql() {
return """
(:scopeAll=1 OR (:scopeAdvisor=1 AND (c.deliver_user_id=:callerUserId OR c.deliver_user_name=:callerUserName
OR c.deliver_user_ids REGEXP CONCAT('(^|[^0-9A-Za-z])',:callerUserId,'([^0-9A-Za-z]|$)')
OR c.deliver_user_names LIKE CONCAT('%%',:callerUserName,'%%')))
OR (:scopeAdvisor=0 AND (c.bl_school_id IN (:organizationIds) OR c.bl_school_name IN (:organizationNames))))
""";
}
private String funnelSql() {
return """
WITH base AS (SELECT COALESCE(NULLIF(c.customer_id,''),c.data_key) customer_key,c.status,c.deal_status,c.deliver_time
FROM %s c WHERE c.create_time>=:startTime AND c.create_time<:endTime AND %s),
followed AS (SELECT DISTINCT customer_id FROM %s WHERE create_time<:endTime AND customer_id IS NOT NULL AND customer_id<>''),
appointed AS (SELECT DISTINCT customer_id FROM %s WHERE record_time<:endTime AND customer_id IS NOT NULL AND customer_id<>''),
visited AS (SELECT DISTINCT customer_id FROM %s WHERE appointment_confirm_time<:endTime
AND appointment_confirm IN ('PARENT_COME','STUDENT_COME','PARENT_STUDENT_COME') AND customer_id IS NOT NULL AND customer_id<>''),
contracted AS (SELECT DISTINCT customer_id FROM %s WHERE create_time<:endTime AND customer_id IS NOT NULL AND customer_id<>''
AND COALESCE(contract_refund_status,'NORMAL') NOT IN ('REFUNDED','TRANSFER_LESSON'))
SELECT COUNT(DISTINCT b.customer_key) new_count,
COUNT(DISTINCT CASE WHEN b.deliver_time IS NOT NULL AND b.deliver_time<>'' THEN b.customer_key END) assigned_count,
COUNT(DISTINCT CASE WHEN b.deliver_time IS NOT NULL AND b.deliver_time<>'' AND f.customer_id IS NOT NULL THEN b.customer_key END) first_call_count,
COUNT(DISTINCT CASE WHEN f.customer_id IS NOT NULL AND a.customer_id IS NOT NULL THEN b.customer_key END) appointment_count,
COUNT(DISTINCT CASE WHEN a.customer_id IS NOT NULL AND v.customer_id IS NOT NULL THEN b.customer_key END) visit_count,
COUNT(DISTINCT CASE WHEN v.customer_id IS NOT NULL AND (b.deal_status='SIGNEUP' OR ct.customer_id IS NOT NULL) THEN b.customer_key END) deal_count,
COUNT(DISTINCT CASE WHEN b.deal_status='SIGNEUP' OR ct.customer_id IS NOT NULL THEN b.customer_key END) converted_count,
COUNT(DISTINCT CASE WHEN b.status='0' THEN b.customer_key END) invalid_count
FROM base b LEFT JOIN followed f ON f.customer_id=b.customer_key LEFT JOIN appointed a ON a.customer_id=b.customer_key
LEFT JOIN visited v ON v.customer_id=b.customer_key LEFT JOIN contracted ct ON ct.customer_id=b.customer_key
""".formatted(CUSTOMER, scopeSql(), FOLLOWUP, APPOINTMENT, APPOINTMENT, CONTRACT);
}
private String alertSql() {
return """
SELECT COALESCE(NULLIF(c.deliver_user_id,''),'UNASSIGNED') advisor_id,
COALESCE(NULLIF(c.deliver_user_name,''),NULLIF(c.deliver_user_names,''),'未指定顾问') advisor_name,
COUNT(DISTINCT COALESCE(NULLIF(c.customer_id,''),c.data_key)) customer_count,
SUM(CASE WHEN TIMESTAMPDIFF(HOUR,STR_TO_DATE(COALESCE(NULLIF(c.deliver_time,''),c.create_time),'%%Y-%%m-%%d %%H:%%i:%%s'),
STR_TO_DATE(:endTime,'%%Y-%%m-%%d %%H:%%i:%%s'))>=:criticalHours THEN 1 ELSE 0 END) critical_count,
MAX(TIMESTAMPDIFF(HOUR,STR_TO_DATE(COALESCE(NULLIF(c.deliver_time,''),c.create_time),'%%Y-%%m-%%d %%H:%%i:%%s'),
STR_TO_DATE(:endTime,'%%Y-%%m-%%d %%H:%%i:%%s'))) max_stale_hours
FROM %s c LEFT JOIN %s f ON f.customer_id=c.customer_id AND f.create_time<:endTime
WHERE c.create_time<:endTime AND c.status='1' AND c.deliver_time IS NOT NULL AND c.deliver_time<>'' AND f.customer_id IS NULL
AND TIMESTAMPDIFF(HOUR,STR_TO_DATE(COALESCE(NULLIF(c.deliver_time,''),c.create_time),'%%Y-%%m-%%d %%H:%%i:%%s'),
STR_TO_DATE(:endTime,'%%Y-%%m-%%d %%H:%%i:%%s'))>=:warningHours AND %s
GROUP BY advisor_id,advisor_name ORDER BY critical_count DESC,customer_count DESC,max_stale_hours DESC LIMIT :maxAlerts
""".formatted(CUSTOMER, FOLLOWUP, scopeSql());
}
private String completenessSql() {
return """
SELECT COUNT(*) total_count,
SUM(CASE WHEN customer_name IS NULL OR TRIM(customer_name)='' THEN 1 ELSE 0 END) missing_name,
SUM(CASE WHEN contact IS NULL OR TRIM(contact)='' THEN 1 ELSE 0 END) missing_contact,
SUM(CASE WHEN (cus_org_name IS NULL OR TRIM(cus_org_name)='') AND (cus_type_name IS NULL OR TRIM(cus_type_name)='') THEN 1 ELSE 0 END) missing_source,
SUM(CASE WHEN bl_school_name IS NULL OR TRIM(bl_school_name)='' THEN 1 ELSE 0 END) missing_campus,
SUM(CASE WHEN intention_name IS NULL OR TRIM(intention_name)='' THEN 1 ELSE 0 END) missing_intention
FROM %s c WHERE c.create_time>=:startTime AND c.create_time<:endTime AND %s
""".formatted(CUSTOMER, scopeSql());
}
private void stage(List<Map<String, Object>> stages, String key, String label, long count, Long previous) {
Double rate = previous == null ? null : previous == 0 ? 0D : round((double) count / previous);
Map<String, Object> stage = new LinkedHashMap<>();
stage.put("key", key); stage.put("label", label); stage.put("count", count);
stage.put("conversionRate", rate); stage.put("lowConversion", rate != null && previous > 0 && rate < LOW_CONVERSION);
stages.add(stage);
}
private Map<String, Object> scopeMap(Scope scope) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("type", scope.type()); map.put("organizationIds", scope.organizationIds());
map.put("organizationNames", scope.organizationNames()); map.put("advisorId", scope.advisorId());
map.put("advisorName", scope.advisorName()); return map;
}
private Map<String, Object> callerMap(Caller caller) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("wecomUserId", caller.wecomUserId()); map.put("mappingResolved", caller.resolved());
map.put("xuebangUserId", caller.userId()); map.put("xuebangUserName", caller.name());
map.put("xuebangOrganizationId", caller.organizationId()); map.put("xuebangOrganizationName", caller.organizationName());
map.put("xuebangRoleNames", caller.roles()); map.put("matchMethod", caller.matchMethod()); return map;
}
private static long number(Map<String, Object> row, String key) {
Object value = row.get(key); return value instanceof Number number ? number.longValue() : 0L;
}
private static String text(Map<String, Object> row, String key) {
Object value = row.get(key); return value == null ? "" : value.toString().trim();
}
private static String clean(String value) { return value == null ? "" : value.trim(); }
private static String normalizeName(String value) {
return clean(value).replaceFirst("(?i)^(SH|BJ|HF|GZ|CD|XZ)[\\s-]*", "")
.replace("跃动体育", "").replace("跃动运动馆", "")
.replaceAll("[\\s_,·.()()\\-]", "").toLowerCase(Locale.ROOT);
}
private static double round(double value) {
return BigDecimal.valueOf(value).setScale(4, RoundingMode.HALF_UP).doubleValue();
}
private record Caller(String wecomUserId, boolean resolved, String userId, String name, String organizationId,
String organizationName, String roles, String managedOrganizations, String matchMethod,
String legacyScope) {
static Caller unresolved(String userId) { return new Caller(userId, false, "", "", "", "", "", "", "UNMATCHED", ""); }
}
private record Scope(String type, boolean all, boolean advisorOnly, List<String> organizationIds,
List<String> organizationNames, String advisorId, String advisorName) {}
private record Organizations(List<String> ids, List<String> names) {}
}

View File

@ -1,47 +0,0 @@
package com.company.agentfoundation.semantic.client;
import com.company.agentfoundation.config.FoundationProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration;
import java.time.LocalDate;
@Component
public class CrmReportClient {
private final FoundationProperties.InternalApis.CrmReport properties;
private final WebClient webClient;
public CrmReportClient(FoundationProperties foundationProperties, WebClient.Builder builder) {
this.properties = foundationProperties.getInternalApis().getCrmReport();
this.webClient = builder.baseUrl(trimTrailingSlash(properties.getBaseUrl())).build();
}
public JsonNode dailyReport(LocalDate startDate, LocalDate endDate, String wecomUserId) {
return webClient.post()
.uri(properties.getPath())
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new DailyReportRequest(startDate, endDate, wecomUserId))
.retrieve()
.bodyToMono(JsonNode.class)
.block(Duration.ofMillis(Math.max(1000, properties.getTimeoutMs())));
}
public boolean isForbidden(Throwable throwable) {
return throwable instanceof WebClientResponseException.Forbidden;
}
private String trimTrailingSlash(String value) {
if (value == null || value.isBlank()) {
return "http://127.0.0.1:9999";
}
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
}
private record DailyReportRequest(LocalDate startDate, LocalDate endDate, String wecomUserId) {}
}

View File

@ -1,11 +1,13 @@
package com.company.agentfoundation.semantic.handler;
import com.company.agentfoundation.crm.report.CustomerDailyReportRequest;
import com.company.agentfoundation.crm.report.CustomerDailyReportService;
import com.company.agentfoundation.semantic.SemanticDateRangeResolver;
import com.company.agentfoundation.semantic.SemanticIntentHandler;
import com.company.agentfoundation.semantic.SemanticRecognition;
import com.company.agentfoundation.semantic.SemanticRequestContext;
import com.company.agentfoundation.semantic.client.CrmReportClient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@ -19,14 +21,17 @@ public class CustomerDailyReportIntentHandler implements SemanticIntentHandler {
private static final Logger log = LoggerFactory.getLogger(CustomerDailyReportIntentHandler.class);
private final SemanticDateRangeResolver dateRangeResolver;
private final CrmReportClient reportClient;
private final CustomerDailyReportService reportService;
private final ObjectMapper objectMapper;
public CustomerDailyReportIntentHandler(
SemanticDateRangeResolver dateRangeResolver,
CrmReportClient reportClient
CustomerDailyReportService reportService,
ObjectMapper objectMapper
) {
this.dateRangeResolver = dateRangeResolver;
this.reportClient = reportClient;
this.reportService = reportService;
this.objectMapper = objectMapper;
}
@Override
@ -49,13 +54,15 @@ public class CustomerDailyReportIntentHandler implements SemanticIntentHandler {
SemanticDateRangeResolver.DateRange range = dateRangeResolver.resolve(
context.userText(), recognition.parameters(), context.requestTime().toLocalDate());
try {
JsonNode report = reportClient.dailyReport(range.startDate(), range.endDate(), context.wecomUserId());
JsonNode report = objectMapper.valueToTree(reportService.generate(
new CustomerDailyReportRequest(range.startDate(), range.endDate(), context.wecomUserId())));
if (report == null || report.isNull()) {
return "客资日报服务暂未返回数据,请稍后重试。";
}
return format(report, range);
} catch (Exception e) {
if (reportClient.isForbidden(e)) {
if (e instanceof org.springframework.web.server.ResponseStatusException status
&& status.getStatusCode().value() == 403) {
return "无权限查看:你的企业微信账号不在可查询的组织范围内。";
}
log.error("customer daily report intent failed wecomUserId={} range={}~{} err={}",

View File

@ -38,7 +38,7 @@ foundation:
coze:
base-url: https://api.coze.cn
chat-path: /v1/workflows/chat
api-key: ${COZE_API_KEY:cztei_qKcNWz5IvhFXAkcxTNFOE7Tnaaua8GJ7VyOREi99l9nGvR7uGjbpqkf3ZmCrYu94T}
api-key: ${COZE_API_KEY:cztei_q6SLPbgARC2LugTaJNRUEsm8LhH4aFpWEy1C2vEyMIEiuSfpUnRS2cVSFAqjmkUtE}
default-workflow: wecom-default
workflows:
wecom-default:
@ -67,12 +67,6 @@ foundation:
semantic:
agent-id: crm-assistant
minimum-confidence: 0.60
internal-apis:
crm-report:
base-url: ${CRM_REPORT_BASE_URL:http://127.0.0.1:9999}
path: /api/crm/daily-report
timeout-ms: 30000
# wecom:
# enabled: true
# corp-id: ww11a329ca8ee0c40e