You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
307 lines
12 KiB
307 lines
12 KiB
package com.wok.supportbot.service;
|
|
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
import java.util.stream.Collectors;
|
|
|
|
@Service
|
|
@Slf4j
|
|
public class CustomerServiceRoleService {
|
|
|
|
@Autowired
|
|
private JdbcTemplate jdbcTemplate;
|
|
|
|
@Autowired
|
|
private ObjectMapper objectMapper;
|
|
|
|
public List<Map<String, Object>> listRoles() {
|
|
return listRoles(false);
|
|
}
|
|
|
|
/**
|
|
* @param includeDisabled true=包含已停用角色(管理页用);false=仅启用角色(对话页用)
|
|
*/
|
|
public List<Map<String, Object>> listRoles(boolean includeDisabled) {
|
|
StringBuilder roleSql = new StringBuilder(
|
|
"SELECT id::text AS id, role_key, name, description, prompt, enabled, allowed_mcp_tools "
|
|
+ "FROM customer_service_role WHERE is_delete = false ");
|
|
if (!includeDisabled) {
|
|
roleSql.append("AND enabled = true ");
|
|
}
|
|
roleSql.append("ORDER BY id ASC");
|
|
List<Map<String, Object>> roles = jdbcTemplate.queryForList(roleSql.toString());
|
|
|
|
// 查询角色-分类关联
|
|
String categorySql = """
|
|
SELECT rc.role_id::text AS role_id, rc.category_id::text AS category_id, c.name AS category_name
|
|
FROM customer_service_role_category rc
|
|
LEFT JOIN knowledge_category c ON c.id = rc.category_id AND c.is_delete = false
|
|
WHERE rc.is_delete = false
|
|
ORDER BY rc.role_id ASC, rc.id ASC
|
|
""";
|
|
List<Map<String, Object>> relations = jdbcTemplate.queryForList(categorySql);
|
|
|
|
Map<String, List<String>> categoryIdsByRole = new LinkedHashMap<>();
|
|
Map<String, List<Map<String, Object>>> categoriesByRole = new LinkedHashMap<>();
|
|
for (Map<String, Object> relation : relations) {
|
|
String roleId = String.valueOf(relation.get("role_id"));
|
|
String categoryId = String.valueOf(relation.get("category_id"));
|
|
categoryIdsByRole.computeIfAbsent(roleId, key -> new ArrayList<>()).add(categoryId);
|
|
categoriesByRole.computeIfAbsent(roleId, key -> new ArrayList<>()).add(Map.of(
|
|
"id", categoryId,
|
|
"name", Objects.toString(relation.get("category_name"), "")
|
|
));
|
|
}
|
|
|
|
for (Map<String, Object> role : roles) {
|
|
String roleId = String.valueOf(role.get("id"));
|
|
role.put("categoryIds", categoryIdsByRole.getOrDefault(roleId, List.of()));
|
|
role.put("categories", categoriesByRole.getOrDefault(roleId, List.of()));
|
|
}
|
|
return roles;
|
|
}
|
|
|
|
@Transactional(rollbackFor = Exception.class)
|
|
public void updateRoleCategories(Long roleId, List<Long> categoryIds) {
|
|
jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId);
|
|
|
|
if (categoryIds == null || categoryIds.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
List<Long> normalizedIds = categoryIds.stream()
|
|
.filter(Objects::nonNull)
|
|
.filter(id -> id > 0)
|
|
.distinct()
|
|
.toList();
|
|
for (Long categoryId : normalizedIds) {
|
|
jdbcTemplate.update("""
|
|
INSERT INTO customer_service_role_category (role_id, category_id, is_delete)
|
|
VALUES (?, ?, false)
|
|
ON CONFLICT (role_id, category_id)
|
|
DO UPDATE SET is_delete = false
|
|
""", roleId, categoryId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 新增角色。role_key 缺省时自动生成。
|
|
*/
|
|
@Transactional(rollbackFor = Exception.class)
|
|
public void createRole(String roleKey, String name, String description, String prompt, Boolean enabled) {
|
|
if (name == null || name.trim().isEmpty()) {
|
|
throw new IllegalArgumentException("角色名称不能为空");
|
|
}
|
|
String key = (roleKey == null || roleKey.trim().isEmpty())
|
|
? "role_" + System.currentTimeMillis()
|
|
: roleKey.trim();
|
|
jdbcTemplate.update("""
|
|
INSERT INTO customer_service_role (role_key, name, description, prompt, enabled)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
key, name.trim(), description, prompt,
|
|
enabled != null ? enabled : true);
|
|
}
|
|
|
|
/**
|
|
* 编辑角色基本信息(不含知识库分类,分类走 {@link #updateRoleCategories})。
|
|
*/
|
|
@Transactional(rollbackFor = Exception.class)
|
|
public void updateRole(Long roleId, String name, String description, String prompt, Boolean enabled) {
|
|
if (roleId == null || roleId <= 0) {
|
|
throw new IllegalArgumentException("角色ID无效");
|
|
}
|
|
if (name == null || name.trim().isEmpty()) {
|
|
throw new IllegalArgumentException("角色名称不能为空");
|
|
}
|
|
jdbcTemplate.update("""
|
|
UPDATE customer_service_role
|
|
SET name = ?, description = ?, prompt = ?, enabled = ?, update_time = CURRENT_TIMESTAMP
|
|
WHERE id = ? AND is_delete = false
|
|
""",
|
|
name.trim(), description, prompt,
|
|
enabled != null ? enabled : true,
|
|
roleId);
|
|
}
|
|
|
|
/**
|
|
* 逻辑删除角色,并连带逻辑删除其知识库分类关联。
|
|
*/
|
|
@Transactional(rollbackFor = Exception.class)
|
|
public void deleteRole(Long roleId) {
|
|
if (roleId == null || roleId <= 0) {
|
|
return;
|
|
}
|
|
jdbcTemplate.update("UPDATE customer_service_role SET is_delete = true, update_time = CURRENT_TIMESTAMP WHERE id = ?", roleId);
|
|
jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId);
|
|
}
|
|
|
|
/**
|
|
* 更新角色的 MCP 工具权限。
|
|
*
|
|
* @param roleId 角色ID
|
|
* @param allowedMcpTools 允许的 MCP 工具名列表;["*"]=全部允许,空/null=不允许任何工具
|
|
*/
|
|
@Transactional(rollbackFor = Exception.class)
|
|
public void updateRoleMcpTools(Long roleId, List<String> allowedMcpTools) {
|
|
if (roleId == null || roleId <= 0) {
|
|
throw new IllegalArgumentException("角色ID无效");
|
|
}
|
|
String json;
|
|
try {
|
|
json = objectMapper.writeValueAsString(allowedMcpTools != null ? allowedMcpTools : List.of());
|
|
} catch (Exception e) {
|
|
json = "[]";
|
|
}
|
|
jdbcTemplate.update(
|
|
"UPDATE customer_service_role SET allowed_mcp_tools = ?::jsonb, update_time = CURRENT_TIMESTAMP WHERE id = ? AND is_delete = false",
|
|
json, roleId);
|
|
}
|
|
|
|
/**
|
|
* 服务端解析角色的知识库范围与人设。
|
|
* 用于对话时强制约束:角色只能检索其绑定分类下的内容,由后端决定,客户端无法越权跨域。
|
|
*
|
|
* @param roleId 角色ID
|
|
* @return 角色范围;角色不存在或被禁用时返回 {@link RoleScope#empty()}
|
|
*/
|
|
public RoleScope getRoleScope(Long roleId) {
|
|
if (roleId == null || roleId <= 0) {
|
|
return RoleScope.empty();
|
|
}
|
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
|
"SELECT name, prompt, allowed_mcp_tools FROM customer_service_role WHERE id = ? AND is_delete = false AND enabled = true",
|
|
roleId);
|
|
if (rows.isEmpty()) {
|
|
return RoleScope.empty();
|
|
}
|
|
String name = Objects.toString(rows.get(0).get("name"), "");
|
|
String prompt = Objects.toString(rows.get(0).get("prompt"), "");
|
|
List<Long> categoryIds = jdbcTemplate.queryForList(
|
|
"SELECT category_id FROM customer_service_role_category WHERE role_id = ? AND is_delete = false",
|
|
Long.class, roleId);
|
|
// 解析 allowed_mcp_tools JSONB 字段
|
|
List<String> allowedMcpTools = parseAllowedMcpTools(rows.get(0).get("allowed_mcp_tools"));
|
|
return new RoleScope(true, name, prompt, categoryIds, allowedMcpTools);
|
|
}
|
|
|
|
/**
|
|
* 解析 allowed_mcp_tools JSONB 字段为 List<String>
|
|
* 兼容 PostgreSQL JSONB、Java List、String 等多种输入类型
|
|
*/
|
|
private List<String> parseAllowedMcpTools(Object raw) {
|
|
if (raw == null) {
|
|
return List.of();
|
|
}
|
|
try {
|
|
if (raw instanceof List<?> list) {
|
|
return list.stream().map(Objects::toString).toList();
|
|
}
|
|
String json = raw.toString().trim();
|
|
if (json.isEmpty() || "[]".equals(json) || "null".equals(json)) {
|
|
return List.of();
|
|
}
|
|
return objectMapper.readValue(json, new TypeReference<>() {});
|
|
} catch (Exception e) {
|
|
log.warn("解析 allowed_mcp_tools 失败: {}", raw, e);
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 精简角色信息(Token 换取接口用)
|
|
*/
|
|
public record RoleBrief(Long id, String roleKey, String name) {}
|
|
|
|
/**
|
|
* 查询所有启用的客服角色(精简字段)
|
|
*
|
|
* @return 精简角色信息列表
|
|
*/
|
|
public List<RoleBrief> listEnabledRolesBrief() {
|
|
String sql = "SELECT id, role_key, name FROM customer_service_role " +
|
|
"WHERE enabled = true AND is_delete = false ORDER BY id";
|
|
return jdbcTemplate.query(sql, (rs, i) -> new RoleBrief(
|
|
rs.getLong("id"),
|
|
rs.getString("role_key"),
|
|
rs.getString("name")
|
|
));
|
|
}
|
|
|
|
/**
|
|
* 根据 ID 列表查询启用的客服角色(精简字段,用于 API Key 角色绑定的 Token 换取)
|
|
*
|
|
* @param roleIds 角色 ID 列表
|
|
* @return 精简角色信息列表
|
|
*/
|
|
public List<RoleBrief> listRolesByIds(List<Long> roleIds) {
|
|
if (roleIds == null || roleIds.isEmpty()) {
|
|
return List.of();
|
|
}
|
|
String placeholders = roleIds.stream().map(id -> "?").collect(Collectors.joining(","));
|
|
String sql = "SELECT id, role_key, name FROM customer_service_role " +
|
|
"WHERE enabled = true AND is_delete = false AND id IN (" + placeholders + ") ORDER BY id";
|
|
return jdbcTemplate.query(sql, (rs, i) -> new RoleBrief(
|
|
rs.getLong("id"),
|
|
rs.getString("role_key"),
|
|
rs.getString("name")
|
|
), roleIds.toArray());
|
|
}
|
|
|
|
/**
|
|
* 角色范围:是否命中角色、角色人设、可检索的知识库分类、MCP 工具权限。
|
|
*/
|
|
public record RoleScope(boolean present, String name, String prompt, List<Long> categoryIds, List<String> allowedMcpTools) {
|
|
|
|
public static RoleScope empty() {
|
|
return new RoleScope(false, "", "", List.of(), List.of());
|
|
}
|
|
|
|
public boolean hasRole() {
|
|
return present;
|
|
}
|
|
|
|
/** 是否允许使用 MCP 工具(非空即允许) */
|
|
public boolean isMcpToolsAllowed() {
|
|
return allowedMcpTools != null && !allowedMcpTools.isEmpty();
|
|
}
|
|
|
|
/** 是否允许使用所有 MCP 工具(["*"] 表示全部允许) */
|
|
public boolean isAllMcpToolsAllowed() {
|
|
return allowedMcpTools != null && allowedMcpTools.contains("*");
|
|
}
|
|
|
|
/**
|
|
* 组合角色系统提示词:当前客服角色 + 人设;都为空时返回 null(退回基础提示词)。
|
|
*/
|
|
public String systemPrompt() {
|
|
if (!present) {
|
|
return null;
|
|
}
|
|
String trimmedName = name == null ? "" : name.trim();
|
|
String trimmedPrompt = prompt == null ? "" : prompt.trim();
|
|
if (trimmedName.isEmpty() && trimmedPrompt.isEmpty()) {
|
|
return null;
|
|
}
|
|
StringBuilder sb = new StringBuilder();
|
|
// if (!trimmedName.isEmpty()) {
|
|
// sb.append("当前客服角色:").append(trimmedName).append("。");
|
|
// }
|
|
if (!trimmedPrompt.isEmpty()) {
|
|
sb.append(trimmedPrompt);
|
|
}
|
|
return sb.toString();
|
|
}
|
|
}
|
|
}
|