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> listRoles() { return listRoles(false); } /** * @param includeDisabled true=包含已停用角色(管理页用);false=仅启用角色(对话页用) */ public List> 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> 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> relations = jdbcTemplate.queryForList(categorySql); Map> categoryIdsByRole = new LinkedHashMap<>(); Map>> categoriesByRole = new LinkedHashMap<>(); for (Map 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 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 categoryIds) { jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId); if (categoryIds == null || categoryIds.isEmpty()) { return; } List 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 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> 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 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 allowedMcpTools = parseAllowedMcpTools(rows.get(0).get("allowed_mcp_tools")); return new RoleScope(true, name, prompt, categoryIds, allowedMcpTools); } /** * 解析 allowed_mcp_tools JSONB 字段为 List * 兼容 PostgreSQL JSONB、Java List、String 等多种输入类型 */ private List 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 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 listRolesByIds(List 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 categoryIds, List 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(); } } }