mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-04-13 11:53:48 +00:00
v3.0.0 init
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package org.ruoyi.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
/**
|
||||
* 图谱构建异步任务配置
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2025-10-11
|
||||
*/
|
||||
@Slf4j
|
||||
@EnableAsync
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "knowledge.graph", name = "enabled", havingValue = "true")
|
||||
public class GraphAsyncConfig {
|
||||
|
||||
/**
|
||||
* 图谱构建专用线程池
|
||||
* 用于执行图谱构建任务,避免阻塞主线程池
|
||||
*/
|
||||
@Bean("graphBuildExecutor")
|
||||
public Executor graphBuildExecutor() {
|
||||
log.info("初始化图谱构建线程池...");
|
||||
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
|
||||
// 核心线程数:CPU核心数
|
||||
int processors = Runtime.getRuntime().availableProcessors();
|
||||
executor.setCorePoolSize(processors);
|
||||
|
||||
// 最大线程数:CPU核心数 * 2
|
||||
executor.setMaxPoolSize(processors * 2);
|
||||
|
||||
// 队列容量:100个任务
|
||||
executor.setQueueCapacity(100);
|
||||
|
||||
// 线程空闲时间:60秒
|
||||
executor.setKeepAliveSeconds(60);
|
||||
|
||||
// 线程名称前缀
|
||||
executor.setThreadNamePrefix("graph-build-");
|
||||
|
||||
// 拒绝策略:由调用线程处理
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
|
||||
// 等待所有任务完成后关闭线程池
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
|
||||
// 等待时间:60秒
|
||||
executor.setAwaitTerminationSeconds(60);
|
||||
|
||||
// 初始化
|
||||
executor.initialize();
|
||||
|
||||
log.info("图谱构建线程池初始化完成: corePoolSize={}, maxPoolSize={}, queueCapacity={}",
|
||||
processors, processors * 2, 100);
|
||||
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.ruoyi.config;
|
||||
|
||||
|
||||
import org.ruoyi.constant.GraphConstants;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 图谱实体关系抽取提示词
|
||||
* 参考 Microsoft GraphRAG 项目
|
||||
* https://github.com/microsoft/graphrag/blob/main/graphrag/index/graph/extractors/graph/prompts.py
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2025-09-30
|
||||
*/
|
||||
public class GraphExtractPrompt {
|
||||
|
||||
/**
|
||||
* 中文实体关系抽取提示词
|
||||
*/
|
||||
public static final String GRAPH_EXTRACTION_PROMPT_CN = """
|
||||
-目标-
|
||||
给定一个可能与此活动相关的文本文档以及实体类型列表,从文本中识别出所有这些类型的实体以及识别出的实体之间的所有关系。
|
||||
|
||||
-步骤-
|
||||
1. 识别所有实体。对于每个识别出的实体,提取以下信息:
|
||||
- entity_name:实体的名称,首字母大写
|
||||
- entity_type:以下类型之一:[{entity_types}]
|
||||
- entity_description:实体的属性和活动的全面描述
|
||||
将每个实体格式化为 ("entity"{tuple_delimiter}<entity_name>{tuple_delimiter}<entity_type>{tuple_delimiter}<entity_description>)
|
||||
|
||||
2. 从步骤1中识别出的实体中,识别出所有明确相关的 (source_entity, target_entity) 对。
|
||||
对于每对相关的实体,提取以下信息:
|
||||
- source_entity:在步骤1中识别的源实体的名称
|
||||
- target_entity:在步骤1中识别的目标实体的名称
|
||||
- relationship_description:解释你认为源实体和目标实体之间相关的原因
|
||||
- relationship_strength:一个表示源实体和目标实体之间关系强度的数字分数(0-10)
|
||||
将每个关系格式化为 ("relationship"{tuple_delimiter}<source_entity>{tuple_delimiter}<target_entity>{tuple_delimiter}<relationship_description>{tuple_delimiter}<relationship_strength>)
|
||||
|
||||
3. 以英文返回输出,作为所有在步骤1和步骤2中识别的实体和关系的列表。使用 **{record_delimiter}** 作为列表分隔符。
|
||||
|
||||
4. 完成时,输出 {completion_delimiter}
|
||||
|
||||
######################
|
||||
-示例-
|
||||
######################
|
||||
示例 1:
|
||||
Entity_types: ORGANIZATION,PERSON
|
||||
文本:
|
||||
The Verdantis's Central Institution is scheduled to meet on Monday and Thursday, with the institution planning to release its latest policy decision on Thursday at 1:30 p.m. PDT, followed by a press conference where Central Institution Chair Martin Smith will take questions. Investors expect the Market Strategy Committee to hold its benchmark interest rate steady in a range of 3.5%-3.75%.
|
||||
######################
|
||||
输出:
|
||||
("entity"{tuple_delimiter}CENTRAL INSTITUTION{tuple_delimiter}ORGANIZATION{tuple_delimiter}The Central Institution is the Federal Reserve of Verdantis, which is setting interest rates on Monday and Thursday)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}MARTIN SMITH{tuple_delimiter}PERSON{tuple_delimiter}Martin Smith is the chair of the Central Institution)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}MARKET STRATEGY COMMITTEE{tuple_delimiter}ORGANIZATION{tuple_delimiter}The Central Institution committee makes key decisions about interest rates and the growth of Verdantis's money supply)
|
||||
{record_delimiter}
|
||||
("relationship"{tuple_delimiter}MARTIN SMITH{tuple_delimiter}CENTRAL INSTITUTION{tuple_delimiter}Martin Smith is the Chair of the Central Institution and will answer questions at a press conference{tuple_delimiter}9)
|
||||
{completion_delimiter}
|
||||
|
||||
######################
|
||||
示例 2:
|
||||
Entity_types: ORGANIZATION
|
||||
文本:
|
||||
TechGlobal's (TG) stock skyrocketed in its opening day on the Global Exchange Thursday. But IPO experts warn that the semiconductor corporation's debut on the public markets isn't indicative of how other newly listed companies may perform.
|
||||
|
||||
TechGlobal, a formerly public company, was taken private by Vision Holdings in 2014. The well-established chip designer says it powers 85% of premium smartphones.
|
||||
######################
|
||||
输出:
|
||||
("entity"{tuple_delimiter}TECHGLOBAL{tuple_delimiter}ORGANIZATION{tuple_delimiter}TechGlobal is a stock now listed on the Global Exchange which powers 85% of premium smartphones)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}VISION HOLDINGS{tuple_delimiter}ORGANIZATION{tuple_delimiter}Vision Holdings is a firm that previously owned TechGlobal)
|
||||
{record_delimiter}
|
||||
("relationship"{tuple_delimiter}TECHGLOBAL{tuple_delimiter}VISION HOLDINGS{tuple_delimiter}Vision Holdings formerly owned TechGlobal from 2014 until present{tuple_delimiter}5)
|
||||
{completion_delimiter}
|
||||
|
||||
######################
|
||||
示例 3:
|
||||
Entity_types: ORGANIZATION,LOCATION,PERSON
|
||||
文本:
|
||||
Five Aurelians jailed for 8 years in Firuzabad and widely regarded as hostages are on their way home to Aurelia.
|
||||
|
||||
The swap orchestrated by Quintara was finalized when $8bn of Firuzi funds were transferred to financial institutions in Krohaara, the capital of Quintara.
|
||||
|
||||
The exchange initiated in Firuzabad's capital, Tiruzia, led to the four men and one woman, who are also Firuzi nationals, boarding a chartered flight to Krohaara.
|
||||
|
||||
They were welcomed by senior Aurelian officials and are now on their way to Aurelia's capital, Cashion.
|
||||
|
||||
The Aurelians include 39-year-old businessman Samuel Namara, who has been held in Tiruzia's Alhamia Prison, as well as journalist Durke Bataglani, 59, and environmentalist Meggie Tazbah, 53, who also holds Bratinas nationality.
|
||||
######################
|
||||
输出:
|
||||
("entity"{tuple_delimiter}FIRUZABAD{tuple_delimiter}LOCATION{tuple_delimiter}Firuzabad held Aurelians as hostages)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}AURELIA{tuple_delimiter}LOCATION{tuple_delimiter}Country seeking to release hostages)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}QUINTARA{tuple_delimiter}LOCATION{tuple_delimiter}Country that negotiated a swap of money in exchange for hostages)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}TIRUZIA{tuple_delimiter}LOCATION{tuple_delimiter}Capital of Firuzabad where the Aurelians were being held)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}KROHAARA{tuple_delimiter}LOCATION{tuple_delimiter}Capital city in Quintara)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}CASHION{tuple_delimiter}LOCATION{tuple_delimiter}Capital city in Aurelia)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}SAMUEL NAMARA{tuple_delimiter}PERSON{tuple_delimiter}Aurelian who spent time in Tiruzia's Alhamia Prison)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}ALHAMIA PRISON{tuple_delimiter}LOCATION{tuple_delimiter}Prison in Tiruzia)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}DURKE BATAGLANI{tuple_delimiter}PERSON{tuple_delimiter}Aurelian journalist who was held hostage)
|
||||
{record_delimiter}
|
||||
("entity"{tuple_delimiter}MEGGIE TAZBAH{tuple_delimiter}PERSON{tuple_delimiter}Bratinas national and environmentalist who was held hostage)
|
||||
{record_delimiter}
|
||||
("relationship"{tuple_delimiter}FIRUZABAD{tuple_delimiter}AURELIA{tuple_delimiter}Firuzabad negotiated a hostage exchange with Aurelia{tuple_delimiter}2)
|
||||
{record_delimiter}
|
||||
("relationship"{tuple_delimiter}QUINTARA{tuple_delimiter}AURELIA{tuple_delimiter}Quintara brokered the hostage exchange between Firuzabad and Aurelia{tuple_delimiter}2)
|
||||
{record_delimiter}
|
||||
("relationship"{tuple_delimiter}SAMUEL NAMARA{tuple_delimiter}ALHAMIA PRISON{tuple_delimiter}Samuel Namara was a prisoner at Alhamia prison{tuple_delimiter}8)
|
||||
{record_delimiter}
|
||||
("relationship"{tuple_delimiter}SAMUEL NAMARA{tuple_delimiter}MEGGIE TAZBAH{tuple_delimiter}Samuel Namara and Meggie Tazbah were exchanged in the same hostage release{tuple_delimiter}2)
|
||||
{completion_delimiter}
|
||||
|
||||
######################
|
||||
-真实数据-
|
||||
######################
|
||||
Entity_types: {entity_types}
|
||||
文本: {input_text}
|
||||
######################
|
||||
输出:
|
||||
""".replace("{tuple_delimiter}", GraphConstants.GRAPH_TUPLE_DELIMITER)
|
||||
.replace("{entity_types}", Arrays.stream(GraphConstants.DEFAULT_ENTITY_TYPES).collect(Collectors.joining(",")))
|
||||
.replace("{completion_delimiter}", GraphConstants.GRAPH_COMPLETION_DELIMITER)
|
||||
.replace("{record_delimiter}", GraphConstants.GRAPH_RECORD_DELIMITER);
|
||||
|
||||
/**
|
||||
* 继续抽取提示词(当第一次抽取遗漏实体时使用)
|
||||
*/
|
||||
public static final String CONTINUE_PROMPT = """
|
||||
在上一次抽取中遗漏了许多实体和关系。
|
||||
请记住只提取与之前提取的类型匹配的实体。
|
||||
使用相同的格式在下面添加它们:
|
||||
""";
|
||||
|
||||
/**
|
||||
* 循环检查提示词
|
||||
*/
|
||||
public static final String LOOP_PROMPT = """
|
||||
似乎仍然可能遗漏了一些实体和关系。
|
||||
如果还有需要添加的实体或关系,请回答 YES | NO。
|
||||
""";
|
||||
|
||||
/**
|
||||
* 生成提取提示词
|
||||
*
|
||||
* @param inputText 输入文本
|
||||
* @return 完整的提示词
|
||||
*/
|
||||
public static String buildExtractionPrompt(String inputText) {
|
||||
return GRAPH_EXTRACTION_PROMPT_CN.replace("{input_text}", inputText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成提取提示词(自定义实体类型)
|
||||
*
|
||||
* @param inputText 输入文本
|
||||
* @param entityTypes 实体类型列表
|
||||
* @return 完整的提示词
|
||||
*/
|
||||
public static String buildExtractionPrompt(String inputText, String[] entityTypes) {
|
||||
String entityTypesStr = Arrays.stream(entityTypes).collect(Collectors.joining(","));
|
||||
return GRAPH_EXTRACTION_PROMPT_CN
|
||||
.replace("{input_text}", inputText)
|
||||
.replace(Arrays.stream(GraphConstants.DEFAULT_ENTITY_TYPES).collect(Collectors.joining(",")), entityTypesStr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package org.ruoyi.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 知识图谱配置属性
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2025-09-30
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "knowledge.graph")
|
||||
public class GraphProperties {
|
||||
|
||||
/**
|
||||
* 是否启用知识图谱功能
|
||||
*/
|
||||
private Boolean enabled = false;
|
||||
|
||||
/**
|
||||
* 图数据库类型: neo4j 或 apache-age
|
||||
*/
|
||||
private String databaseType = "neo4j";
|
||||
|
||||
/**
|
||||
* 是否自动创建索引
|
||||
*/
|
||||
private Boolean autoCreateIndex = true;
|
||||
|
||||
/**
|
||||
* 批量处理大小
|
||||
*/
|
||||
private Integer batchSize = 1000;
|
||||
|
||||
/**
|
||||
* 最大重试次数
|
||||
*/
|
||||
private Integer maxRetryCount = 3;
|
||||
|
||||
/**
|
||||
* 实体抽取配置
|
||||
*/
|
||||
private ExtractionConfig extraction = new ExtractionConfig();
|
||||
|
||||
/**
|
||||
* 查询配置
|
||||
*/
|
||||
private QueryConfig query = new QueryConfig();
|
||||
|
||||
@Data
|
||||
public static class ExtractionConfig {
|
||||
/**
|
||||
* 置信度阈值(低于此值的实体将被过滤)
|
||||
*/
|
||||
private Double confidenceThreshold = 0.7;
|
||||
|
||||
/**
|
||||
* 最大实体数量(每个文档)
|
||||
*/
|
||||
private Integer maxEntitiesPerDoc = 100;
|
||||
|
||||
/**
|
||||
* 最大关系数量(每个文档)
|
||||
*/
|
||||
private Integer maxRelationsPerDoc = 200;
|
||||
|
||||
/**
|
||||
* 文本分片大小(用于长文档)
|
||||
*/
|
||||
private Integer chunkSize = 2000;
|
||||
|
||||
/**
|
||||
* 分片重叠大小
|
||||
*/
|
||||
private Integer chunkOverlap = 200;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class QueryConfig {
|
||||
/**
|
||||
* 默认查询限制数量
|
||||
*/
|
||||
private Integer defaultLimit = 100;
|
||||
|
||||
/**
|
||||
* 最大查询限制数量
|
||||
*/
|
||||
private Integer maxLimit = 1000;
|
||||
|
||||
/**
|
||||
* 路径查询最大深度
|
||||
*/
|
||||
private Integer maxPathDepth = 5;
|
||||
|
||||
/**
|
||||
* 查询超时时间(秒)
|
||||
*/
|
||||
private Integer timeoutSeconds = 30;
|
||||
|
||||
/**
|
||||
* 是否启用查询缓存
|
||||
*/
|
||||
private Boolean cacheEnabled = true;
|
||||
|
||||
/**
|
||||
* 缓存过期时间(分钟)
|
||||
*/
|
||||
private Integer cacheExpireMinutes = 60;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.ruoyi.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.neo4j.driver.AuthTokens;
|
||||
import org.neo4j.driver.Driver;
|
||||
import org.neo4j.driver.GraphDatabase;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Neo4j配置类
|
||||
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(Neo4jProperties.class)
|
||||
public class Neo4jConfig {
|
||||
|
||||
public Neo4jConfig() {
|
||||
log.warn("========== Neo4jConfig 已激活 ==========");
|
||||
log.warn("知识图谱功能(Neo4j): 已启用");
|
||||
log.warn("========================================");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Neo4j Driver Bean
|
||||
*
|
||||
* @param neo4jProperties Neo4j 配置属性
|
||||
* @return Neo4j Driver
|
||||
*/
|
||||
@Bean
|
||||
public Driver neo4jDriver(Neo4jProperties neo4jProperties) {
|
||||
log.info("========== 正在初始化 Neo4j Driver ==========");
|
||||
log.info("Neo4j 连接地址: {}", neo4jProperties.getUri());
|
||||
log.info("Neo4j 用户名: {}", neo4jProperties.getUsername());
|
||||
log.info("Neo4j 数据库: {}", neo4jProperties.getDatabase());
|
||||
log.info("最大连接池大小: {}", neo4jProperties.getMaxConnectionPoolSize());
|
||||
log.info("连接超时时间: {} 秒", neo4jProperties.getConnectionTimeoutSeconds());
|
||||
|
||||
try {
|
||||
Driver driver = GraphDatabase.driver(
|
||||
neo4jProperties.getUri(),
|
||||
AuthTokens.basic(neo4jProperties.getUsername(), neo4jProperties.getPassword()),
|
||||
org.neo4j.driver.Config.builder()
|
||||
.withMaxConnectionPoolSize(neo4jProperties.getMaxConnectionPoolSize())
|
||||
.withConnectionTimeout(neo4jProperties.getConnectionTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
);
|
||||
log.info("========== Neo4j Driver 初始化完成 ==========");
|
||||
return driver;
|
||||
} catch (Exception e) {
|
||||
log.error("Neo4j Driver 初始化失败", e);
|
||||
throw new RuntimeException("Failed to initialize Neo4j Driver", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.ruoyi.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Neo4j 配置属性
|
||||
*
|
||||
* 分离 @ConfigurationProperties 避免与 @ConditionalOnProperty 冲突
|
||||
* 不使用 @Component,而是在 Neo4jConfig 中通过 @EnableConfigurationProperties 启用
|
||||
* 参考: https://github.com/spring-projects/spring-boot/issues/26251
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2025-09-30
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "neo4j")
|
||||
public class Neo4jProperties {
|
||||
|
||||
/**
|
||||
* Neo4j连接URI
|
||||
* 例如: bolt://localhost:7687
|
||||
*/
|
||||
private String uri;
|
||||
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 密码
|
||||
*/
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 数据库名称(Neo4j 4.0+支持多数据库)
|
||||
* 默认: neo4j
|
||||
*/
|
||||
private String database = "neo4j";
|
||||
|
||||
/**
|
||||
* 最大连接池大小
|
||||
*/
|
||||
private Integer maxConnectionPoolSize = 50;
|
||||
|
||||
/**
|
||||
* 连接超时时间(秒)
|
||||
*/
|
||||
private Integer connectionTimeoutSeconds = 30;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.ruoyi.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 向量库配置属性
|
||||
*
|
||||
* @author ageer
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "vector-store")
|
||||
public class VectorStoreProperties {
|
||||
|
||||
/**
|
||||
* 向量库类型
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* Weaviate配置
|
||||
*/
|
||||
private Weaviate weaviate = new Weaviate();
|
||||
|
||||
/**
|
||||
* Milvus配置
|
||||
*/
|
||||
private Milvus milvus = new Milvus();
|
||||
|
||||
@Data
|
||||
public static class Weaviate {
|
||||
/**
|
||||
* 协议
|
||||
*/
|
||||
private String protocol;
|
||||
|
||||
/**
|
||||
* 主机地址
|
||||
*/
|
||||
private String host;
|
||||
|
||||
/**
|
||||
* 类名
|
||||
*/
|
||||
private String classname;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Milvus {
|
||||
/**
|
||||
* 连接URL
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 集合名称
|
||||
*/
|
||||
private String collectionname;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user