实现Rooming List Excel生成接口
This commit is contained in:
@@ -76,6 +76,7 @@ class AuthControllerTest {
|
||||
"RESERVATION_OPERA_SIM_EXECUTE",
|
||||
"RESERVATION_AUDIT_READ",
|
||||
"RESERVATION_INVOICE_GENERATE",
|
||||
"RESERVATION_ROOMING_LIST_GENERATE",
|
||||
"HOTEL_SWITCH",
|
||||
"SYSTEM_AUTH_READ",
|
||||
"SYSTEM_USER_MANAGE",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
|
||||
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
|
||||
import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
|
||||
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
|
||||
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
|
||||
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:reservation_rooming_list_generation;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"auth.bootstrap.admin.username=rooming-admin",
|
||||
"auth.bootstrap.admin.password=Admin@123456",
|
||||
"auth.bootstrap.admin.display-name=系统管理员",
|
||||
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
|
||||
"auth.bootstrap.default-hotel-name=测试酒店",
|
||||
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
|
||||
"superagent.task-result.hmac-secret=test-superagent-secret",
|
||||
"mcp.enabled=true",
|
||||
"mcp.auth-token=test-mcp-token"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class ReservationRoomingListGenerationControllerTest {
|
||||
|
||||
private static final String ENDPOINT = "/api/reservation/rooming-lists/generations";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Autowired
|
||||
private PlatformIdentityRepository identityRepository;
|
||||
@Autowired
|
||||
private PlatformHotelRepository hotelRepository;
|
||||
@Autowired
|
||||
private AuthPasswordService passwordService;
|
||||
|
||||
@BeforeEach
|
||||
void setUpNoPermissionUser() {
|
||||
PlatformUserEntity user = identityRepository.findUserByUsername("rooming-no-permission")
|
||||
.orElseGet(() -> {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
PlatformUserEntity created = new PlatformUserEntity();
|
||||
created.setUsername("rooming-no-permission");
|
||||
created.setPasswordHash(passwordService.hash("NoPerm@123456"));
|
||||
created.setDisplayName("无权限用户");
|
||||
created.setUserStatus(PlatformUserStatus.ACTIVE.name());
|
||||
created.setSuperAdmin(false);
|
||||
created.setPasswordChangedAt(now);
|
||||
created.setCreatedAt(now);
|
||||
created.setUpdatedAt(now);
|
||||
identityRepository.insertUser(created);
|
||||
return created;
|
||||
});
|
||||
hotelRepository.ensureUserHotel(user.getId(), "HOTEL-TEST", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDownloadGeneratedRoomingListWorkbook() throws Exception {
|
||||
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
|
||||
|
||||
performAuthorized(mockMvc, token, multipart(ENDPOINT)
|
||||
.file(sourceFile())
|
||||
.param("hotel_id", "HOTEL-TEST")
|
||||
.param("people_per_room", "2")
|
||||
.param("arrival", "2026-07-26")
|
||||
.param("departure", "2026-07-29")
|
||||
.param("room_type", "UG1")
|
||||
.param("rate_code", "RACK")
|
||||
.param("payment_type", "CA")
|
||||
.param("nationality", "CN"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.andExpect(header().string("Content-Disposition", containsString("attachment;")))
|
||||
.andExpect(header().string("Content-Disposition", containsString("rooming-list-HOTEL-TEST-")))
|
||||
.andExpect(result -> assertThat(result.getResponse().getContentAsByteArray())
|
||||
.startsWith(new byte[]{0x50, 0x4B}))
|
||||
.andExpect(content().string(not(containsString("P123456"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRoomingListGenerationWhenTokenMissing() throws Exception {
|
||||
mockMvc.perform(multipart(ENDPOINT)
|
||||
.file(sourceFile())
|
||||
.param("hotel_id", "HOTEL-TEST")
|
||||
.param("people_per_room", "2")
|
||||
.param("arrival", "2026-07-26")
|
||||
.param("departure", "2026-07-29")
|
||||
.param("room_type", "UG1"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRoomingListGenerationWhenPermissionMissing() throws Exception {
|
||||
String token = loginToken(mockMvc, "rooming-no-permission", "NoPerm@123456");
|
||||
|
||||
performAuthorized(mockMvc, token, multipart(ENDPOINT)
|
||||
.file(sourceFile())
|
||||
.param("hotel_id", "HOTEL-TEST")
|
||||
.param("people_per_room", "2")
|
||||
.param("arrival", "2026-07-26")
|
||||
.param("departure", "2026-07-29")
|
||||
.param("room_type", "UG1"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRoomingListGenerationWhenHotelAccessDenied() throws Exception {
|
||||
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
|
||||
|
||||
performAuthorized(mockMvc, token, multipart(ENDPOINT)
|
||||
.file(sourceFile())
|
||||
.param("hotel_id", "OTHER-HOTEL")
|
||||
.param("people_per_room", "2")
|
||||
.param("arrival", "2026-07-26")
|
||||
.param("departure", "2026-07-29")
|
||||
.param("room_type", "UG1"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRoomingListGenerationWhenRequiredFieldMissing() throws Exception {
|
||||
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
|
||||
|
||||
performAuthorized(mockMvc, token, multipart(ENDPOINT)
|
||||
.file(sourceFile())
|
||||
.param("hotel_id", "HOTEL-TEST")
|
||||
.param("people_per_room", "2")
|
||||
.param("arrival", "2026-07-26")
|
||||
.param("departure", "2026-07-29"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("room_type: 必填字段缺失。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRoomingListGenerationWhenDateFormatInvalid() throws Exception {
|
||||
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
|
||||
|
||||
performAuthorized(mockMvc, token, multipart(ENDPOINT)
|
||||
.file(sourceFile())
|
||||
.param("hotel_id", "HOTEL-TEST")
|
||||
.param("people_per_room", "2")
|
||||
.param("arrival", "2026/07/26")
|
||||
.param("departure", "2026-07-29")
|
||||
.param("room_type", "UG1"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("arrival: 参数格式不合法。"));
|
||||
}
|
||||
|
||||
private MockMultipartFile sourceFile() throws Exception {
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
var sheet = workbook.createSheet("Sheet1");
|
||||
Row headerRow = sheet.createRow(0);
|
||||
headerRow.createCell(0).setCellValue("姓名");
|
||||
headerRow.createCell(1).setCellValue("护照全名");
|
||||
Row row1 = sheet.createRow(1);
|
||||
row1.createCell(0).setCellValue("游客1");
|
||||
row1.createCell(1).setCellValue("LI/CHUNHONG");
|
||||
Row row2 = sheet.createRow(2);
|
||||
row2.createCell(0).setCellValue("游客2");
|
||||
row2.createCell(1).setCellValue("ZHANG GAILI");
|
||||
workbook.write(outputStream);
|
||||
return new MockMultipartFile(
|
||||
"file",
|
||||
"LLT260509FA203.xlsx",
|
||||
MediaType.APPLICATION_OCTET_STREAM_VALUE,
|
||||
outputStream.toByteArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
|
||||
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGeneratedFile;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
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.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ReservationRoomingListGenerationServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private HotelContextService hotelContextService;
|
||||
|
||||
private ReservationRoomingListGenerationServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ReservationRoomingListGenerationServiceImpl(
|
||||
hotelContextService,
|
||||
new RoomingListSourceExcelParser(new RoomingListNameParser()),
|
||||
new RoomingListGroupingService(),
|
||||
new RoomingListExcelRenderer());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGenerateRoomingListWorkbookByPassportNamesAndPeoplePerRoom() throws Exception {
|
||||
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
|
||||
|
||||
ReservationRoomingListGeneratedFile generatedFile = service.generate(
|
||||
sourceFile(passportNames32()),
|
||||
request(3),
|
||||
actor());
|
||||
|
||||
assertThat(generatedFile.fileName()).startsWith("rooming-list-HOTEL-TEST-");
|
||||
assertThat(generatedFile.contentType())
|
||||
.isEqualTo("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
assertThat(generatedFile.content()).startsWith(new byte[]{0x50, 0x4B});
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
|
||||
assertThat(workbook.getSheetAt(0).getPhysicalNumberOfRows()).isEqualTo(12);
|
||||
Row firstRoom = workbook.getSheetAt(0).getRow(1);
|
||||
assertThat(cellText(firstRoom.getCell(0))).isEqualTo("1");
|
||||
assertThat(cellText(firstRoom.getCell(1))).isEqualTo("LI");
|
||||
assertThat(cellText(firstRoom.getCell(2))).isEqualTo("CHUNHONG");
|
||||
assertThat(cellText(firstRoom.getCell(5))).isEqualTo("2026-07-29");
|
||||
assertThat(cellText(firstRoom.getCell(6))).isEqualTo("UG1");
|
||||
assertThat(cellText(firstRoom.getCell(8))).isEqualTo("1");
|
||||
assertThat(cellText(firstRoom.getCell(9))).isEqualTo("3");
|
||||
assertThat(cellText(firstRoom.getCell(13))).isEqualTo("ZHANG GAILI, LI HONG");
|
||||
|
||||
Row secondRoom = workbook.getSheetAt(0).getRow(2);
|
||||
assertThat(cellText(secondRoom.getCell(1))).isEqualTo("LIU");
|
||||
assertThat(cellText(secondRoom.getCell(2))).isEqualTo("JIAYI");
|
||||
assertThat(cellText(secondRoom.getCell(13))).isEqualTo("ZHU XINGTONG, CHEN YUCHAI");
|
||||
|
||||
Row lastRoom = workbook.getSheetAt(0).getRow(11);
|
||||
assertThat(cellText(lastRoom.getCell(0))).isEqualTo("11");
|
||||
assertThat(cellText(lastRoom.getCell(1))).isEqualTo("ZHOU");
|
||||
assertThat(cellText(lastRoom.getCell(2))).isEqualTo("XIAOYAN");
|
||||
assertThat(cellText(lastRoom.getCell(9))).isEqualTo("2");
|
||||
assertThat(cellText(lastRoom.getCell(13))).isEqualTo("GUO YAN");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSourceWorkbookWithoutPassportNameHeader() throws Exception {
|
||||
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
|
||||
|
||||
ReservationRoomingListGenerationException exception = catchThrowableOfType(
|
||||
() -> service.generate(sourceFileWithoutPassportHeader(), request(2), actor()),
|
||||
ReservationRoomingListGenerationException.class);
|
||||
|
||||
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID");
|
||||
assertThat(exception.getDetails()).contains("未找到表头:护照全名");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectNonExcelSourceFile() {
|
||||
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
|
||||
|
||||
ReservationRoomingListGenerationException exception = catchThrowableOfType(
|
||||
() -> service.generate(nonExcelFile(), request(2), actor()),
|
||||
ReservationRoomingListGenerationException.class);
|
||||
|
||||
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID");
|
||||
assertThat(exception.getDetails()).contains("file: 仅支持 .xls / .xlsx 文件。");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidPeoplePerRoom() throws Exception {
|
||||
ReservationRoomingListGenerationException exception = catchThrowableOfType(
|
||||
() -> service.generate(sourceFile(passportNames32()), request(0), actor()),
|
||||
ReservationRoomingListGenerationException.class);
|
||||
|
||||
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED");
|
||||
assertThat(exception.getDetails()).contains("people_per_room: 必须大于 0。");
|
||||
}
|
||||
|
||||
private ReservationRoomingListGenerationRequest request(int peoplePerRoom) {
|
||||
return new ReservationRoomingListGenerationRequest(
|
||||
"HOTEL-TEST",
|
||||
peoplePerRoom,
|
||||
LocalDate.of(2026, 7, 26),
|
||||
LocalDate.of(2026, 7, 29),
|
||||
"UG1",
|
||||
"RACK",
|
||||
null,
|
||||
0,
|
||||
"CA",
|
||||
"",
|
||||
"CN",
|
||||
"",
|
||||
"",
|
||||
"");
|
||||
}
|
||||
|
||||
private AuthenticatedUserContext actor() {
|
||||
return new AuthenticatedUserContext(
|
||||
1L,
|
||||
"rooming-admin",
|
||||
"系统管理员",
|
||||
true,
|
||||
"HOTEL-TEST",
|
||||
List.of("HOTEL-TEST"),
|
||||
List.of("RESERVATION_ROOMING_LIST_GENERATE"));
|
||||
}
|
||||
|
||||
private MockMultipartFile sourceFile(List<String> passportNames) throws Exception {
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
var sheet = workbook.createSheet("Sheet1");
|
||||
Row titleRow = sheet.createRow(0);
|
||||
titleRow.createCell(0).setCellValue("旅游批次");
|
||||
Row headerRow = sheet.createRow(1);
|
||||
headerRow.createCell(0).setCellValue("姓名");
|
||||
headerRow.createCell(1).setCellValue("护照全名");
|
||||
for (int index = 0; index < passportNames.size(); index++) {
|
||||
Row row = sheet.createRow(index + 2);
|
||||
row.createCell(0).setCellValue("游客" + (index + 1));
|
||||
row.createCell(1).setCellValue(passportNames.get(index));
|
||||
}
|
||||
workbook.write(outputStream);
|
||||
return new MockMultipartFile(
|
||||
"file",
|
||||
"LLT260509FA203.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
outputStream.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private MockMultipartFile sourceFileWithoutPassportHeader() throws Exception {
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
var sheet = workbook.createSheet("Sheet1");
|
||||
Row headerRow = sheet.createRow(0);
|
||||
headerRow.createCell(0).setCellValue("姓名");
|
||||
headerRow.createCell(1).setCellValue("证件号");
|
||||
Row row = sheet.createRow(1);
|
||||
row.createCell(0).setCellValue("游客1");
|
||||
row.createCell(1).setCellValue("P123456");
|
||||
workbook.write(outputStream);
|
||||
return new MockMultipartFile(
|
||||
"file",
|
||||
"invalid.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
outputStream.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private MockMultipartFile nonExcelFile() {
|
||||
return new MockMultipartFile(
|
||||
"file",
|
||||
"LLT260509FA203.txt",
|
||||
"text/plain",
|
||||
"LI/CHUNHONG".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private List<String> passportNames32() {
|
||||
return List.of(
|
||||
"LI/CHUNHONG",
|
||||
"ZHANG/GAILI",
|
||||
"LI HONG",
|
||||
"LIU/JIAYI ",
|
||||
"ZHU/XINGTONG",
|
||||
"CHEN/YUCHAI",
|
||||
"WANG/XIAOLI",
|
||||
"SUN/JIE",
|
||||
"MA/LING",
|
||||
"WU/YUAN",
|
||||
"HUANG/XIAOMEI",
|
||||
"ZHAO/MING",
|
||||
"YANG/LILI",
|
||||
"XU/JUN",
|
||||
"LIN/FANG",
|
||||
"LUO/HUA",
|
||||
"HE/MEI",
|
||||
"GAO/LEI",
|
||||
"PAN/XIN",
|
||||
"DENG/NA",
|
||||
"TANG/YAN",
|
||||
"CAO/YING",
|
||||
"FAN/QIANG",
|
||||
"JIANG/MIN",
|
||||
"SHEN/YUE",
|
||||
"YU/PING",
|
||||
"FENG/QIU",
|
||||
"XIE/RUI",
|
||||
"SONG/LAN",
|
||||
"LU/JIA",
|
||||
"ZHOU/XIAOYAN",
|
||||
"GUO/YAN");
|
||||
}
|
||||
|
||||
private String cellText(Cell cell) {
|
||||
if (cell == null) {
|
||||
return "";
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC) {
|
||||
double value = cell.getNumericCellValue();
|
||||
if (value == Math.rint(value)) {
|
||||
return String.valueOf((long) value);
|
||||
}
|
||||
return String.valueOf(value);
|
||||
}
|
||||
return cell.getStringCellValue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user