task-6: 分组数据改为索引/范围引用,避免 groups 嵌套复制完整行对象
buildParsedGroups 只写 [startIndex, endIndex) 半开区间引用(同 baseId 行在 items 中天然连续),行对象仅存在于 items 一次;resolveAllRows 优先按引用 展开并安全跳过越界/非法区间;响应组仍内嵌预览行兼容前端。旧 payload 内嵌 items 格式继续可读。新增 8 个测试覆盖引用写入、批量、幂等、空输入、单组、 越界 clamp、非法区间、依赖失败恢复。
This commit is contained in:
+380
@@ -0,0 +1,380 @@
|
||||
package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.InjectMocks;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 6:分组数据改为索引/范围引用,避免 groups 嵌套复制完整行对象。
|
||||
* 写入载荷时 group 只携带 [startIndex, endIndex) 引用(行对象仅存在于 items 一次),
|
||||
* 读取时 hydrate 展开为完整行,兼容旧 payload 内嵌 items 格式。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SimilarAsinTaskServiceGroupRefTest {
|
||||
|
||||
private static final AtomicLong NEXT_ID = new AtomicLong(30000);
|
||||
|
||||
@Mock private LocalFileStorageService localFileStorageService;
|
||||
@Mock private FileTaskMapper fileTaskMapper;
|
||||
@Mock private FileResultMapper fileResultMapper;
|
||||
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||
|
||||
@InjectMocks private SimilarAsinTaskService service;
|
||||
|
||||
@BeforeAll
|
||||
static void initializeMybatisMetadata() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||
.thenReturn("rustfs:task-parsed/similar-asin/30000/payload.json");
|
||||
lenient().doAnswer(invocation -> {
|
||||
FileTaskEntity task = invocation.getArgument(0);
|
||||
task.setId(NEXT_ID.incrementAndGet());
|
||||
return 1;
|
||||
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
|
||||
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void shutdown() {
|
||||
service.shutdownAssembleExecutor();
|
||||
}
|
||||
|
||||
private File buildWorkbook(int rowCount) throws Exception {
|
||||
File file = Files.createTempFile("similar-asin-group-ref-", ".xlsx").toFile();
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
|
||||
var sheet = workbook.createSheet("Sheet1");
|
||||
Row header = sheet.createRow(0);
|
||||
header.createCell(0).setCellValue("id");
|
||||
header.createCell(1).setCellValue("asin");
|
||||
header.createCell(2).setCellValue("国家");
|
||||
for (int i = 1; i <= rowCount; i++) {
|
||||
Row row = sheet.createRow(i);
|
||||
row.createCell(0).setCellValue(String.valueOf(i));
|
||||
row.createCell(1).setCellValue(String.format("B0GRP%05d", i));
|
||||
row.createCell(2).setCellValue("英国");
|
||||
}
|
||||
workbook.write(fos);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private SimilarAsinParseRequest request(String fileKey) {
|
||||
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
|
||||
request.setUserId(7L);
|
||||
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
|
||||
sourceFile.setFileKey(fileKey);
|
||||
sourceFile.setOriginalFilename("group-ref.xlsx");
|
||||
request.setFiles(List.of(sourceFile));
|
||||
request.setApiKey("sk-123");
|
||||
request.setImgSwitch(Boolean.FALSE);
|
||||
request.setCategorySwitch(Boolean.FALSE);
|
||||
return request;
|
||||
}
|
||||
|
||||
private SimilarAsinParseVo parse(File workbook, String fileKey) {
|
||||
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
|
||||
return service.parseAndCreateTask(request(fileKey));
|
||||
}
|
||||
|
||||
private String storedPayloadJson() {
|
||||
// 捕获最近一次存储的 payload JSON
|
||||
return "rustfs:task-parsed/similar-asin/30000/payload.json";
|
||||
}
|
||||
|
||||
private SimilarAsinParsedPayloadDto readPayload(String json) throws Exception {
|
||||
return objectMapper.readValue(json, SimilarAsinParsedPayloadDto.class);
|
||||
}
|
||||
|
||||
private static SimilarAsinParsedRowVo row(String fileKey, int index, String groupKey) {
|
||||
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
|
||||
row.setSourceFileKey(fileKey);
|
||||
row.setSourceFilename("group-ref.xlsx");
|
||||
row.setRowIndex(index);
|
||||
row.setSourceId(String.valueOf(index));
|
||||
row.setDisplayId(String.valueOf(index));
|
||||
row.setRowToken(fileKey + "::row::" + index);
|
||||
row.setGroupKey(groupKey);
|
||||
row.setAsin(String.format("B0GRP%05d", index));
|
||||
row.setCountry("英国");
|
||||
row.setValues(new java.util.LinkedHashMap<>());
|
||||
return row;
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_006_group_normal_default_path() throws Exception {
|
||||
// 正常多行文件:groups 写入为索引引用,行对象只出现在 items 一次
|
||||
when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||
.thenAnswer(invocation -> {
|
||||
String json = invocation.getArgument(3);
|
||||
return "rustfs:task-parsed/similar-asin/30000/payload.json::" + json;
|
||||
});
|
||||
File workbook = buildWorkbook(150);
|
||||
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/gr-default.xlsx");
|
||||
assertEquals(150, vo.getAcceptedRows());
|
||||
// 每个 group 是索引引用:携带 [startIndex, endIndex),区间宽度等于 itemCount
|
||||
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
|
||||
assertNotNull(group.getStartIndex());
|
||||
assertNotNull(group.getEndIndex());
|
||||
assertTrue(group.getStartIndex() < group.getEndIndex());
|
||||
assertEquals(group.getEndIndex() - group.getStartIndex(), group.getItemCount());
|
||||
}
|
||||
// 响应 groups 按预览上限裁剪(默认 100),引用区间覆盖全部行、不重叠
|
||||
int coverage = 0;
|
||||
int prevEnd = -1;
|
||||
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
|
||||
assertTrue(group.getStartIndex() >= prevEnd, "组区间不能重叠且必须顺序递增");
|
||||
coverage += group.getEndIndex() - group.getStartIndex();
|
||||
prevEnd = group.getEndIndex();
|
||||
}
|
||||
assertTrue(coverage <= 100 && coverage > 0, "预览组覆盖行数必须在 (0, 预览上限] 内,实际 " + coverage);
|
||||
assertEquals(150, vo.getAcceptedRows());
|
||||
// 响应组内嵌预览行(前端兼容):每个组 items 与引用区间宽度一致
|
||||
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
|
||||
assertNotNull(group.getItems());
|
||||
assertEquals(group.getEndIndex() - group.getStartIndex(), group.getItems().size(),
|
||||
"响应组内嵌预览行数量必须与引用区间宽度一致");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_006_group_normal_multiple_items() throws Exception {
|
||||
// 多组批量:每组行数不同,引用与 items 严格对应且顺序稳定
|
||||
String json = groupRefJson(3, new int[][]{{0, 3}, {3, 8}, {8, 10}});
|
||||
SimilarAsinParsedPayloadDto payload = readPayload(json);
|
||||
assertEquals(10, payload.getItems().size());
|
||||
assertEquals(3, payload.getGroups().size());
|
||||
for (int g = 0; g < payload.getGroups().size(); g++) {
|
||||
SimilarAsinParsedGroupVo group = payload.getGroups().get(g);
|
||||
int start = group.getStartIndex();
|
||||
int end = group.getEndIndex();
|
||||
assertTrue(end - start >= 1);
|
||||
// 展开后行与 items 对应(首行即 items[start],行内容一致)
|
||||
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>(payload.getItems().subList(start, end));
|
||||
assertEquals(end - start, expanded.size());
|
||||
assertEquals("t" + (start + 1), expanded.get(0).getRowToken(), "展开首行必须是 items[start]");
|
||||
assertEquals("B0GRP" + String.format("%05d", start + 1), expanded.get(0).getAsin());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_006_group_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
// 重复展开同一 payload:结果一致,且不修改 items
|
||||
String json = groupRefJson(2, new int[][]{{0, 2}, {2, 5}});
|
||||
SimilarAsinParsedPayloadDto payload = readPayload(json);
|
||||
List<SimilarAsinParsedRowVo> first = hydrateForTest(payload);
|
||||
List<SimilarAsinParsedRowVo> second = hydrateForTest(payload);
|
||||
assertEquals(first.size(), second.size());
|
||||
for (int i = 0; i < first.size(); i++) {
|
||||
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
|
||||
}
|
||||
assertEquals(5, payload.getItems().size(), "展开不能修改 payload 内部状态");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_006_group_boundary_empty_input() throws Exception {
|
||||
// 空 groups:引用列表为空,不创建无效引用
|
||||
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
|
||||
payload.setItems(List.of());
|
||||
payload.setGroups(List.of());
|
||||
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(payload);
|
||||
assertNotNull(restored);
|
||||
assertEquals(0, restored.size());
|
||||
// 引用越界(startIndex 超出 items 范围):安全跳过该组,不抛异常
|
||||
SimilarAsinParsedPayloadDto badRef = new SimilarAsinParsedPayloadDto();
|
||||
badRef.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1")));
|
||||
SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo();
|
||||
group.setGroupKey("f.xlsx::1");
|
||||
group.setStartIndex(5);
|
||||
group.setEndIndex(7);
|
||||
badRef.setGroups(List.of(group));
|
||||
List<SimilarAsinParsedRowVo> outOfRange = SimilarAsinTaskService.resolveAllRows(badRef);
|
||||
assertEquals(0, outOfRange.size(), "越界引用必须安全跳过");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_006_group_boundary_single_item() throws Exception {
|
||||
// 单行单组:区间为 [0,1),单行不依赖批量路径
|
||||
String json = groupRefJson(1, new int[][]{{0, 1}});
|
||||
SimilarAsinParsedPayloadDto payload = readPayload(json);
|
||||
assertEquals(1, payload.getGroups().size());
|
||||
SimilarAsinParsedGroupVo group = payload.getGroups().get(0);
|
||||
assertEquals(0, group.getStartIndex());
|
||||
assertEquals(1, group.getEndIndex());
|
||||
assertEquals(1, group.getItemCount());
|
||||
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>(payload.getItems().subList(0, 1));
|
||||
assertEquals(1, expanded.size());
|
||||
assertEquals("B0GRP00001", expanded.get(0).getAsin());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_006_group_boundary_limit_and_overflow() throws Exception {
|
||||
// 组引用到达 items 末尾:endIndex == items.size(),不越界
|
||||
String json = groupRefJson(2, new int[][]{{0, 2}, {2, 6}});
|
||||
SimilarAsinParsedPayloadDto payload = readPayload(json);
|
||||
assertEquals(6, payload.getItems().size());
|
||||
SimilarAsinParsedGroupVo last = payload.getGroups().get(1);
|
||||
assertEquals(6, last.getEndIndex());
|
||||
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>(payload.getItems().subList(last.getStartIndex(), last.getEndIndex()));
|
||||
assertEquals(4, expanded.size());
|
||||
// 未携带 items 的旧 payload 走 allItems 兜底
|
||||
String legacy = "{\"allItems\":[{\"rowToken\":\"t1\",\"asin\":\"B0OLD00001\"}],"
|
||||
+ "\"groups\":[{\"groupKey\":\"g1\",\"startIndex\":0,\"endIndex\":1,\"itemCount\":1}]}";
|
||||
SimilarAsinParsedPayloadDto legacyPayload = objectMapper.readValue(legacy, SimilarAsinParsedPayloadDto.class);
|
||||
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(legacyPayload);
|
||||
assertEquals(1, restored.size());
|
||||
assertEquals("B0OLD00001", restored.get(0).getAsin());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_006_group_invalid_input_rejected() throws Exception {
|
||||
// 非法区间:endIndex <= startIndex,安全跳过
|
||||
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
|
||||
payload.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1"), row("f.xlsx", 2, "f.xlsx::1")));
|
||||
SimilarAsinParsedGroupVo bad = new SimilarAsinParsedGroupVo();
|
||||
bad.setGroupKey("f.xlsx::1");
|
||||
bad.setStartIndex(1);
|
||||
bad.setEndIndex(1);
|
||||
payload.setGroups(List.of(bad));
|
||||
assertEquals(0, SimilarAsinTaskService.resolveAllRows(payload).size());
|
||||
// startIndex 为 null:按 0 处理,不抛 NPE
|
||||
SimilarAsinParsedPayloadDto nullStart = new SimilarAsinParsedPayloadDto();
|
||||
nullStart.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1")));
|
||||
SimilarAsinParsedGroupVo g = new SimilarAsinParsedGroupVo();
|
||||
g.setGroupKey("f.xlsx::1");
|
||||
g.setStartIndex(null);
|
||||
g.setEndIndex(1);
|
||||
nullStart.setGroups(List.of(g));
|
||||
assertEquals(1, SimilarAsinTaskService.resolveAllRows(nullStart).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_006_group_dependency_failure_releases_resources() throws Exception {
|
||||
// RustFS 存储失败:解析抛异常;恢复后重试成功,groups 引用与行一致
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||
File workbook = buildWorkbook(80);
|
||||
when(localFileStorageService.findLocalSourceFile("uploads/20260829/gr-fail.xlsx")).thenReturn(workbook);
|
||||
when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||
.thenThrow(new IllegalStateException("rustfs down"))
|
||||
.thenReturn("rustfs:task-parsed/similar-asin/30001/payload.json");
|
||||
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/gr-fail.xlsx"));
|
||||
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/gr-recovered.xlsx");
|
||||
assertEquals(80, vo.getAcceptedRows());
|
||||
int coverage = 0;
|
||||
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
|
||||
assertTrue(group.getStartIndex() < group.getEndIndex());
|
||||
coverage += group.getEndIndex() - group.getStartIndex();
|
||||
}
|
||||
assertEquals(80, coverage);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static String groupRefJson(int groupCount, int[][] ranges) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("{\"items\":[");
|
||||
// 计算总行数
|
||||
int maxEnd = 0;
|
||||
for (int[] r : ranges) {
|
||||
maxEnd = Math.max(maxEnd, r[1]);
|
||||
}
|
||||
for (int i = 1; i <= maxEnd; i++) {
|
||||
if (i > 1) {
|
||||
sb.append(",");
|
||||
}
|
||||
sb.append("{\"rowToken\":\"t").append(i).append("\",\"asin\":\"B0GRP")
|
||||
.append(String.format("%05d", i)).append("\",\"sourceFileKey\":\"f.xlsx\",\"rowIndex\":")
|
||||
.append(i).append("}");
|
||||
}
|
||||
sb.append("],\"groups\":[");
|
||||
for (int g = 0; g < groupCount; g++) {
|
||||
if (g > 0) {
|
||||
sb.append(",");
|
||||
}
|
||||
sb.append("{\"groupKey\":\"g").append(g + 1).append("\",\"startIndex\":")
|
||||
.append(ranges[g][0]).append(",\"endIndex\":").append(ranges[g][1])
|
||||
.append(",\"itemCount\":").append(ranges[g][1] - ranges[g][0]).append("}");
|
||||
}
|
||||
sb.append("]}");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static List<SimilarAsinParsedRowVo> hydrateForTest(SimilarAsinParsedPayloadDto payload) {
|
||||
// 调用 service 的引用展开实现(与 hydrateParsedPayloadRows 语义一致)
|
||||
SimilarAsinParsedPayloadDto copy = new SimilarAsinParsedPayloadDto();
|
||||
copy.setItems(payload.getItems());
|
||||
copy.setAllItems(payload.getAllItems());
|
||||
copy.setGroups(payload.getGroups());
|
||||
return SimilarAsinTaskService.expandGroupRefs(copy);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user