基于spring-ai生成教学目标
This commit is contained in:
parent
9b90abf8e8
commit
3e1e89c213
@ -15,7 +15,7 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
@Slf4j
|
||||
@EnableFeignClients(basePackages = "com.seer.teach.*.api")
|
||||
@SpringBootApplication(scanBasePackages = "com.seer",exclude = QuartzAutoConfiguration.class)
|
||||
@SpringBootApplication(scanBasePackages = "com.seer",exclude = {QuartzAutoConfiguration.class})
|
||||
@EnableTransactionManagement
|
||||
@EnableAspectJAutoProxy
|
||||
@EnableAsync
|
||||
|
||||
@ -11,13 +11,8 @@ spring:
|
||||
main:
|
||||
allow-bean-definition-overriding: true
|
||||
allow-circular-references: true
|
||||
mvc:
|
||||
|
||||
profiles:
|
||||
active: dev
|
||||
mvc:
|
||||
pathmatch:
|
||||
matching-strategy: ant_path_matcher
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/mysql
|
||||
@ -39,6 +34,7 @@ spring:
|
||||
server-addr: 192.168.0.39:8848 # 配置中心地址
|
||||
file-extension: yaml # 配置文件后缀(yaml/properties)
|
||||
namespace: ${spring.profiles.active}
|
||||
|
||||
#日志
|
||||
logging:
|
||||
config: classpath:logback-${spring.profiles.active}.xml
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
<spring-cloud-alibaba.version>2025.0.0.0</spring-cloud-alibaba.version>
|
||||
|
||||
<spring-ai.version>1.1.2</spring-ai.version>
|
||||
<spring-ai-alibaba.version>1.1.0.0-M5</spring-ai-alibaba.version>
|
||||
<spring-ai-alibaba.version>1.1.0.0-RC2</spring-ai-alibaba.version>
|
||||
|
||||
|
||||
<!-- 认证 -->
|
||||
@ -660,6 +660,12 @@
|
||||
<version>${spring-ai-alibaba.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud.ai</groupId>
|
||||
<artifactId>spring-ai-alibaba-dashscope</artifactId>
|
||||
<version>${spring-ai-alibaba.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-deepseek</artifactId>
|
||||
@ -668,8 +674,17 @@
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-deepseek</artifactId>
|
||||
<artifactId>spring-ai-deepseek</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<scope>compile</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-openai</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
|
||||
@ -26,17 +26,18 @@
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud.ai</groupId>
|
||||
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
|
||||
<artifactId>spring-ai-alibaba-dashscope</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
<artifactId>spring-ai-openai</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-deepseek</artifactId>
|
||||
<artifactId>spring-ai-deepseek</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
|
||||
@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.model.tool.DefaultToolExecutionEligibilityPredicate;
|
||||
import org.springframework.ai.model.tool.ToolCallingManager;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.web.client.ResponseErrorHandler;
|
||||
import org.springframework.web.client.RestClient;
|
||||
@ -24,9 +25,11 @@ public abstract class AbstractAiModelClient implements AiModelClient {
|
||||
protected ResponseErrorHandler responseErrorHandler;
|
||||
|
||||
@Autowired(required = false)
|
||||
@Qualifier("aiRestClient")
|
||||
protected RestClient.Builder restClientBuilder;
|
||||
|
||||
@Autowired(required = false)
|
||||
@Qualifier("aiWebClient")
|
||||
protected WebClient.Builder webClientBuilder;
|
||||
|
||||
@Autowired
|
||||
@ -35,8 +38,7 @@ public abstract class AbstractAiModelClient implements AiModelClient {
|
||||
@Autowired
|
||||
protected ToolCallingManager toolCallingManager;
|
||||
|
||||
@Autowired(required = false)
|
||||
protected ObservationRegistry observationRegistry;
|
||||
protected ObservationRegistry observationRegistry = ObservationRegistry.create();
|
||||
|
||||
protected final DefaultToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate = new DefaultToolExecutionEligibilityPredicate();
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package com.seer.teach.ai.client.model;
|
||||
|
||||
import com.alibaba.cloud.ai.dashscope.audio.synthesis.SpeechSynthesisModel;
|
||||
import com.seer.teach.ai.client.model.config.ModelConfig;
|
||||
import org.springframework.ai.audio.tts.TextToSpeechModel;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
@ -43,17 +42,6 @@ public interface AiModelClient {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音合成模型(Speech Synthesis Model)
|
||||
* 用于将文本转换为语音音频,仅dashscope平台支持
|
||||
*
|
||||
* @param modelConfig 模型配置
|
||||
* @return 语音合成模型实例的Optional包装
|
||||
*/
|
||||
default Optional<SpeechSynthesisModel> createSpeechSynthesisModel(ModelConfig modelConfig) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文本转语音模型(Text To Speech Model)
|
||||
* 用于将文本转换为语音输出,支持多种平台
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
package com.seer.teach.ai.client.model.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpRequest;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.http.codec.json.Jackson2JsonDecoder;
|
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* DashScope API 配置类
|
||||
* 用于配置restClient以正确处理聊天完成请求
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class DashScopeClientConfig {
|
||||
|
||||
/**
|
||||
* 创建用于DashScope API的RestClient
|
||||
*/
|
||||
@Bean
|
||||
public RestClient aiRestClient() {
|
||||
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
|
||||
.modules(new JavaTimeModule())
|
||||
.build();
|
||||
|
||||
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(objectMapper);
|
||||
|
||||
return RestClient.builder()
|
||||
.requestInterceptor(new LoggingInterceptor())
|
||||
.messageConverters(converters -> converters.add(converter))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用于DashScope API的WebClient并启用日志
|
||||
*/
|
||||
@Bean
|
||||
public WebClient aiWebClient() {
|
||||
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
|
||||
.modules(new JavaTimeModule())
|
||||
.build();
|
||||
|
||||
return WebClient.builder()
|
||||
.codecs(configurer -> configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper)))
|
||||
.codecs(configurer -> configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper)))
|
||||
.filter(ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
|
||||
log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
|
||||
clientRequest.headers().forEach((name, values) ->
|
||||
values.forEach(value -> log.info("{}={}", name, value)));
|
||||
return Mono.just(clientRequest);
|
||||
}))
|
||||
.filter(ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
|
||||
log.info("Response Status: {}", clientResponse.statusCode());
|
||||
clientResponse.headers().asHttpHeaders().forEach((name, values) ->
|
||||
values.forEach(value -> log.info("{}={}", name, value)));
|
||||
return Mono.just(clientResponse);
|
||||
}))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
public static class LoggingInterceptor implements ClientHttpRequestInterceptor {
|
||||
|
||||
@Override
|
||||
public ClientHttpResponse intercept(
|
||||
HttpRequest request,
|
||||
byte[] body,
|
||||
ClientHttpRequestExecution execution) throws IOException {
|
||||
|
||||
// 记录请求信息
|
||||
log.info("HTTP请求方法: {}", request.getMethod());
|
||||
log.info("HTTP请求URL: {}", request.getURI());
|
||||
log.info("HTTP请求头: {}", request.getHeaders());
|
||||
if (body.length > 0) {
|
||||
log.info("HTTP请求体: {}", new String(body, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
ClientHttpResponse response = execution.execute(request, body);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,10 @@
|
||||
package com.seer.teach.ai.client.model.dashscope;
|
||||
|
||||
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeChatProperties;
|
||||
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
|
||||
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeEmbeddingProperties;
|
||||
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
|
||||
import com.alibaba.cloud.ai.dashscope.api.DashScopeAudioSpeechApi;
|
||||
import com.alibaba.cloud.ai.dashscope.api.DashScopeImageApi;
|
||||
import com.alibaba.cloud.ai.dashscope.audio.DashScopeAudioSpeechModel;
|
||||
import com.alibaba.cloud.ai.dashscope.audio.DashScopeAudioSpeechOptions;
|
||||
import com.alibaba.cloud.ai.dashscope.audio.synthesis.SpeechSynthesisModel;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||||
import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel;
|
||||
@ -17,12 +13,11 @@ import com.alibaba.cloud.ai.dashscope.image.DashScopeImageOptions;
|
||||
import com.seer.teach.ai.client.model.AbstractAiModelClient;
|
||||
import com.seer.teach.ai.client.model.config.ModelConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.audio.tts.TextToSpeechModel;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.document.MetadataMode;
|
||||
import org.springframework.ai.embedding.EmbeddingModel;
|
||||
import org.springframework.ai.image.ImageModel;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
@ -40,14 +35,9 @@ import java.util.Optional;
|
||||
@Component
|
||||
public class DashScopeModelClient extends AbstractAiModelClient {
|
||||
|
||||
private DashScopeConnectionProperties connectionProperties;
|
||||
|
||||
@Autowired(required = false)
|
||||
private DashScopeChatProperties chatProperties;
|
||||
|
||||
@Override
|
||||
public String getPlatformName() {
|
||||
return "dashscope";
|
||||
return "aliyun_bailian";
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -125,17 +115,15 @@ public class DashScopeModelClient extends AbstractAiModelClient {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<SpeechSynthesisModel> createSpeechSynthesisModel(ModelConfig modelConfig) {
|
||||
public Optional<TextToSpeechModel> createTextToSpeechModel(ModelConfig modelConfig) {
|
||||
try {
|
||||
DashScopeAudioSpeechApi audioSpeechApi = new DashScopeAudioSpeechApi(modelConfig.getApiKey(), modelConfig.getUrl());
|
||||
|
||||
DashScopeAudioSpeechOptions options = DashScopeAudioSpeechOptions.builder()
|
||||
.model(modelConfig.getModel())
|
||||
.requestText(DashScopeAudioSpeechApi.RequestTextType.PLAIN_TEXT)
|
||||
.voice("longyingxiao")
|
||||
.build();
|
||||
DashScopeAudioSpeechModel speechSynthesisModel = new DashScopeAudioSpeechModel(audioSpeechApi, options, retryTemplate);
|
||||
|
||||
return Optional.of(speechSynthesisModel);
|
||||
} catch (Exception e) {
|
||||
log.error("创建DashScope语音合成模型失败", e);
|
||||
@ -147,10 +135,9 @@ public class DashScopeModelClient extends AbstractAiModelClient {
|
||||
@Override
|
||||
public Optional<EmbeddingModel> createEmbeddingModel(ModelConfig modelConfig) {
|
||||
try {
|
||||
DashScopeEmbeddingProperties embeddingProperties = new DashScopeEmbeddingProperties();
|
||||
DashScopeApi dashScopeApi = createDashScopeApi(modelConfig.getApiKey(), modelConfig.getUrl());
|
||||
|
||||
DashScopeEmbeddingModel embeddingModel = new DashScopeEmbeddingModel(dashScopeApi, MetadataMode.EMBED, embeddingProperties.getOptions(), retryTemplate, observationRegistry);
|
||||
DashScopeEmbeddingModel embeddingModel = new DashScopeEmbeddingModel(dashScopeApi);
|
||||
|
||||
return Optional.of(embeddingModel);
|
||||
} catch (Exception e) {
|
||||
@ -162,11 +149,10 @@ public class DashScopeModelClient extends AbstractAiModelClient {
|
||||
/**
|
||||
* 创建DashScope API实例
|
||||
*/
|
||||
private DashScopeApi createDashScopeApi(String apiKey, String baseUrl) {
|
||||
private DashScopeApi createDashScopeApi(String apiKey, String completionsPath) {
|
||||
DashScopeApi.Builder builder = DashScopeApi.builder()
|
||||
.apiKey(apiKey)
|
||||
.baseUrl(baseUrl);
|
||||
|
||||
.baseUrl("https://dashscope.aliyuncs.com");
|
||||
// 如果有可用的客户端构建器,添加它们
|
||||
if (webClientBuilder != null) {
|
||||
builder.webClientBuilder(webClientBuilder);
|
||||
|
||||
@ -6,13 +6,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.audio.tts.TextToSpeechModel;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.document.MetadataMode;
|
||||
import org.springframework.ai.embedding.EmbeddingModel;
|
||||
import org.springframework.ai.image.ImageModel;
|
||||
import org.springframework.ai.model.SimpleApiKey;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechProperties;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiChatProperties;
|
||||
import org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingProperties;
|
||||
import org.springframework.ai.openai.OpenAiAudioSpeechModel;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
@ -131,8 +127,7 @@ public class OpenAIModelClient extends AbstractAiModelClient {
|
||||
.responseErrorHandler(responseErrorHandler)
|
||||
.build();
|
||||
|
||||
OpenAiAudioSpeechProperties properties = new OpenAiAudioSpeechProperties();
|
||||
TextToSpeechModel textToSpeechModel = new OpenAiAudioSpeechModel(audioApi, properties.getOptions(), retryTemplate);
|
||||
TextToSpeechModel textToSpeechModel = new OpenAiAudioSpeechModel(audioApi);
|
||||
return Optional.of(textToSpeechModel);
|
||||
} catch (Exception e) {
|
||||
log.error("创建OpenAI文本转语音模型失败", e);
|
||||
@ -144,8 +139,7 @@ public class OpenAIModelClient extends AbstractAiModelClient {
|
||||
public Optional<EmbeddingModel> createEmbeddingModel(ModelConfig modelConfig) {
|
||||
try {
|
||||
OpenAiApi openAiApi = createOpenAiApi(modelConfig.getApiKey(), modelConfig.getUrl());
|
||||
OpenAiEmbeddingProperties properties = new OpenAiEmbeddingProperties();
|
||||
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(openAiApi, MetadataMode.EMBED,properties.getOptions(), retryTemplate);
|
||||
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(openAiApi);
|
||||
return Optional.of(embeddingModel);
|
||||
} catch (Exception e) {
|
||||
log.error("创建OpenAI嵌入模型失败", e);
|
||||
@ -157,8 +151,6 @@ public class OpenAIModelClient extends AbstractAiModelClient {
|
||||
return OpenAiApi.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(new SimpleApiKey(apiKey))
|
||||
.completionsPath(OpenAiChatProperties.DEFAULT_COMPLETIONS_PATH)
|
||||
.embeddingsPath(OpenAiEmbeddingProperties.DEFAULT_EMBEDDINGS_PATH)
|
||||
.restClientBuilder(restClientBuilder)
|
||||
.webClientBuilder(webClientBuilder).build();
|
||||
}
|
||||
|
||||
@ -38,6 +38,12 @@
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>seer-teacher-ai</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
|
||||
@ -2,11 +2,14 @@ package com.seer.teach.admin.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.seer.teach.ai.service.AiChatModelService;
|
||||
import com.seer.teach.common.constants.CommonConstant;
|
||||
import com.seer.teach.common.entity.BaseEntity;
|
||||
import com.seer.teach.teacher.service.AiModelCallService;
|
||||
import com.seer.teach.teacher.service.platform.LlmResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
@ -16,6 +19,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
@ -41,6 +45,9 @@ public abstract class AbstractGenerationService<T, R, E extends BaseEntity> {
|
||||
@Autowired
|
||||
protected TransactionTemplate transactionTemplate;
|
||||
|
||||
@Autowired
|
||||
protected AiChatModelService chatModelService;
|
||||
|
||||
/**
|
||||
* 检查当前线程是否被中断
|
||||
*
|
||||
@ -78,6 +85,10 @@ public abstract class AbstractGenerationService<T, R, E extends BaseEntity> {
|
||||
*/
|
||||
protected LlmResponse callLLM(String scenarioCode, String prompt, Map<String, Object> params, Integer userId) {
|
||||
log.info("调用大模型,场景编码: {}, 提示词长度: {}", scenarioCode, prompt.length());
|
||||
//ChatResponse chatResponse = chatModelService.chatMessage(scenarioCode, prompt);
|
||||
LlmResponse response = new LlmResponse();
|
||||
//Generation result = chatResponse.getResult();
|
||||
|
||||
return aiModelCallService.callModel(scenarioCode, prompt, params, userId);
|
||||
}
|
||||
|
||||
@ -160,8 +171,8 @@ public abstract class AbstractGenerationService<T, R, E extends BaseEntity> {
|
||||
* @param saver 保存器函数
|
||||
*/
|
||||
protected Integer executeBatchProcess(R request,
|
||||
Function<R, List<T>> generator,
|
||||
java.util.function.Consumer<List<T>> saver) {
|
||||
Function<R, List<T>> generator,
|
||||
java.util.function.Consumer<List<T>> saver) {
|
||||
// 检查任务是否被取消
|
||||
String taskId = getTaskIdFromRequest(request);
|
||||
if (isThreadInterrupted() || isTaskCancelled(taskId)) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user