Browse Source

取消负载控制验证

dev
吴远 2 days ago
parent
commit
6c2650bd40
  1. 23
      dk-common/common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java
  2. 80
      dk-modules/sample/src/main/java/org/dromara/sample/media/controller/FileController.java
  3. 6
      dk-modules/sample/src/main/java/org/dromara/sample/media/service/IFileService.java
  4. 16
      dk-modules/sample/src/main/java/org/dromara/sample/media/service/impl/FileServiceImpl.java
  5. 5
      dk-modules/sample/src/main/java/org/dromara/sample/wayline/controller/WaylineJobController.java
  6. 4
      dk-modules/sample/src/main/java/org/dromara/sample/wayline/model/entity/WaylineJobEntity.java
  7. 6
      dk-modules/sample/src/main/java/org/dromara/sample/wayline/service/IWaylineJobService.java
  8. 5
      dk-modules/sample/src/main/java/org/dromara/sample/wayline/service/impl/FlightTaskServiceImpl.java
  9. 39
      dk-modules/sample/src/main/java/org/dromara/sample/wayline/service/impl/WaylineJobServiceImpl.java
  10. 23
      dk-modules/sample/src/main/resources/mapper/WaylineJobMapper.xml

23
dk-common/common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java

@ -309,29 +309,6 @@ public class OssClient {
} }
} }
public long downloadZip(String key, OutputStream out) {
try {
// 构建下载请求
DownloadRequest<ResponseInputStream<GetObjectResponse>> downloadRequest = DownloadRequest.builder()
// 文件对象
.getObjectRequest(y -> y.bucket(properties.getBucketName())
.key(key)
.build())
.addTransferListener(LoggingTransferListener.create())
// 使用订阅转换器
.responseTransformer(AsyncResponseTransformer.toBlockingInputStream())
.build();
// 使用 S3TransferManager 下载文件
Download<ResponseInputStream<GetObjectResponse>> responseFuture = transferManager.download(downloadRequest);
// 输出到流中
try (ResponseInputStream<GetObjectResponse> responseStream = responseFuture.completionFuture().join().result()) { // auto-closeable stream
return responseStream.transferTo(out); // 阻塞调用线程 blocks the calling thread
}
} catch (Exception e) {
throw new OssException("文件下载失败,错误信息:[" + e.getMessage() + "]");
}
}
/** /**
* 删除云存储服务中指定路径下文件 * 删除云存储服务中指定路径下文件
* *

80
dk-modules/sample/src/main/java/org/dromara/sample/media/controller/FileController.java

@ -1,9 +1,12 @@
package org.dromara.sample.media.controller; package org.dromara.sample.media.controller;
import cn.hutool.core.util.ObjectUtil;
import com.amazonaws.util.IOUtils; import com.amazonaws.util.IOUtils;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.file.FileUtils;
import org.dromara.common.oss.core.OssClient; import org.dromara.common.oss.core.OssClient;
import org.dromara.common.oss.factory.OssFactory; import org.dromara.common.oss.factory.OssFactory;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
@ -11,15 +14,19 @@ import org.dromara.common.sdk.common.HttpResultResponse;
import org.dromara.common.sdk.common.PaginationData; import org.dromara.common.sdk.common.PaginationData;
import org.dromara.sample.manage.service.IDeviceProService; import org.dromara.sample.manage.service.IDeviceProService;
import org.dromara.sample.media.model.MediaFileDTO; import org.dromara.sample.media.model.MediaFileDTO;
import org.dromara.sample.media.model.MediaFileEntity;
import org.dromara.sample.media.service.IFileService; import org.dromara.sample.media.service.IFileService;
import org.dromara.sample.wayline.service.IWaylineJobService;
import org.dromara.system.api.model.LoginUser; import org.dromara.system.api.model.LoginUser;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.*; import java.io.*;
import java.net.URL; import java.net.URL;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@ -36,6 +43,9 @@ public class FileController {
@Autowired @Autowired
private IFileService fileService; private IFileService fileService;
@Autowired
private IWaylineJobService waylineJobService;
/** /**
@ -87,40 +97,68 @@ public class FileController {
@RequestParam(name = "type", required = false) Integer type) { @RequestParam(name = "type", required = false) Integer type) {
PaginationData<MediaFileDTO> filesList = fileService.getMediaFilesPaginationByJobId(workspaceId, page, pageSize,jobId,type); PaginationData<MediaFileDTO> filesList = fileService.getMediaFilesPaginationByJobId(workspaceId, page, pageSize,jobId,type);
for (MediaFileDTO mediaFileDTO :filesList.getList()){ for (MediaFileDTO mediaFileDTO :filesList.getList()){
mediaFileDTO.setUrl(fileService.getObjectUrl(workspaceId, mediaFileDTO.getFileId()).toString()); mediaFileDTO.setUrl(fileService.getObjectUrl(mediaFileDTO.getObjectKey()).toString());
} }
return HttpResultResponse.success(filesList); return HttpResultResponse.success(filesList);
} }
@GetMapping("/{workspace_id}/file/downloadZip/{job_id}") @GetMapping("/{workspace_id}/file/downloadZip/{job_id}")
public void downloadZip(HttpServletResponse response, @PathVariable(name = "workspace_id") String workspaceId, public void downloadZip( HttpServletResponse response, @PathVariable(name = "workspace_id") String workspaceId,
@PathVariable(name = "job_id") String jobId, @RequestParam(name = "jobName", required = false) String jobName) throws UnsupportedEncodingException { @PathVariable(name = "job_id") String jobId, @RequestParam(name = "jobName", required = false) String jobName) throws IOException {
List<MediaFileDTO> mediaFileDTO = fileService.getFilesByWorkspaceAndJobId(workspaceId, jobId); List<String> mediaFileDTO = fileService.getFilesByWorkspaceAndJobId(workspaceId, jobId).stream().map(MediaFileDTO::getObjectKey).toList();
String fileName = "jobName.zip"; if(mediaFileDTO.size() == 0){
throw new ServiceException("文件不存在");
}
OssClient storage = OssFactory.instance("mediafile");
response.setContentType("application/zip"); response.setContentType("application/zip");
response.setHeader("content-disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8")); if(ObjectUtil.isNull(jobName)){
try (OutputStream out = response.getOutputStream(); jobName = waylineJobService.getJobByJobId(workspaceId,jobId).get().getJobName();
ZipOutputStream zs = new ZipOutputStream(new BufferedOutputStream(out))) { }
String encodedJobName = URLEncoder.encode(jobName, "UTF-8");
zs.setMethod(ZipOutputStream.DEFLATED); // 设置压缩方法 response.setHeader("Content-Disposition", "attachment; filename="+encodedJobName+".zip");
try (ZipOutputStream zipOut = new ZipOutputStream(response.getOutputStream())) {
for (MediaFileDTO mediaFile : mediaFileDTO) { byte[] buffer = new byte[1024];
OssClient storage = OssFactory.instance("mediafile"); for (String objectKey : mediaFileDTO) {
//String privateUrlURL = storage.getPrivateUrl(mediaFile.getObjectKey(), 3600); try (InputStream inputStream = storage.getObjectContent(objectKey)) {
InputStream is = storage.getObjectContent(mediaFile.getObjectKey()); // 创建一个新的 ZIP 条目
String uniqueFileName = mediaFile.getFileName(); // 假设MediaFileDTO有getFileName方法 zipOut.putNextEntry(new ZipEntry(objectKey));
ZipEntry entry = new ZipEntry(uniqueFileName); // 使用每个文件的实际名称
zs.putNextEntry(entry); int len;
IOUtils.copy(is, zs); // 读取文件内容并写入到 ZIP 输出流
zs.closeEntry(); while ((len = inputStream.read(buffer)) != -1) {
zipOut.write(buffer, 0, len);
}
// 完成当前条目的写入
zipOut.closeEntry();
} catch (IOException e) {
e.printStackTrace(); // 如果有某个文件出错,跳过该文件并继续处理其他文件
}
} }
} catch (Exception e) {
zipOut.flush();
} catch (IOException e) {
e.printStackTrace(); e.printStackTrace();
} }
} }
@GetMapping("/{workspace_id}/file/download/{file_id}")
public void downloadZip( HttpServletResponse response, @PathVariable(name = "workspace_id") String workspaceId,
@PathVariable(name = "file_id") String fileId) throws IOException {
Optional<MediaFileEntity> mediaFileDTO = fileService.getObjectByFileId(workspaceId,fileId);
OssClient storage = OssFactory.instance("mediafile");
String jobName = waylineJobService.getJobByJobId(workspaceId,mediaFileDTO.get().getJobId()).get().getJobName();
String[] filePaths = mediaFileDTO.get().getObjectKey().split("\\.");
FileUtils.setAttachmentResponseHeader(response, jobName+"."+filePaths[filePaths.length-1]);
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
long contentLength = storage.download(mediaFileDTO.get().getObjectKey(), response.getOutputStream());
response.setContentLengthLong(contentLength);
}
/** /**
* 根据文件id查询图片的偏航角 * 根据文件id查询图片的偏航角
* @param fileId * @param fileId

6
dk-modules/sample/src/main/java/org/dromara/sample/media/service/IFileService.java

@ -4,9 +4,11 @@ import org.dromara.common.sdk.cloudapi.media.FlightTask;
import org.dromara.common.sdk.cloudapi.media.MediaUploadCallbackRequest; import org.dromara.common.sdk.cloudapi.media.MediaUploadCallbackRequest;
import org.dromara.common.sdk.common.PaginationData; import org.dromara.common.sdk.common.PaginationData;
import org.dromara.sample.media.model.MediaFileDTO; import org.dromara.sample.media.model.MediaFileDTO;
import org.dromara.sample.media.model.MediaFileEntity;
import java.net.URL; import java.net.URL;
import java.util.List; import java.util.List;
import java.util.Optional;
/** /**
* @author sean * @author sean
@ -55,6 +57,10 @@ public interface IFileService {
*/ */
URL getObjectUrl(String workspaceId, String fileId); URL getObjectUrl(String workspaceId, String fileId);
Optional<MediaFileEntity> getObjectByFileId(String workspaceId, String fileId);
URL getObjectUrl(String objectKey);
/** /**
* Query all media files of a job. * Query all media files of a job.
* @param workspaceId * @param workspaceId

16
dk-modules/sample/src/main/java/org/dromara/sample/media/service/impl/FileServiceImpl.java

@ -140,6 +140,22 @@ public class FileServiceImpl implements IFileService {
return storage.getPrivateUrlURL(mediaFileOpt.get().getObjectKey(),3600); return storage.getPrivateUrlURL(mediaFileOpt.get().getObjectKey(),3600);
} }
@Override
public URL getObjectUrl(String objectKey) {
OssClient storage = OssFactory.instance("mediafile");
return storage.getPrivateUrlURL(objectKey,3600);
}
@Override
public Optional<MediaFileEntity> getObjectByFileId(String workspaceId, String fileId) {
Optional<MediaFileEntity> mediaFileOpt = getMediaByFileId(workspaceId, fileId);
if (mediaFileOpt.isEmpty()) {
throw new IllegalArgumentException("{} 不存在。");
}
return mediaFileOpt;
}
@Override @Override
public List<MediaFileDTO> getFilesByWorkspaceAndJobId(String workspaceId, String jobId) { public List<MediaFileDTO> getFilesByWorkspaceAndJobId(String workspaceId, String jobId) {
return mapper.selectList(new LambdaQueryWrapper<MediaFileEntity>() return mapper.selectList(new LambdaQueryWrapper<MediaFileEntity>()

5
dk-modules/sample/src/main/java/org/dromara/sample/wayline/controller/WaylineJobController.java

@ -89,8 +89,9 @@ public class WaylineJobController {
@PathVariable(name = "workspace_id") String workspaceId, @PathVariable(name = "workspace_id") String workspaceId,
@RequestParam(name = "proIds" , required = false) List<Integer> proIds, @RequestParam(name = "proIds" , required = false) List<Integer> proIds,
@RequestParam(name = "fileId" , required = false) String fileId, @RequestParam(name = "fileId" , required = false) String fileId,
@RequestParam(name = "name" , required = false) String name) { @RequestParam(name = "name" , required = false) String name,
PaginationData<WaylineJobDTO> data = waylineJobService.getJobsByWorkspaceId(workspaceId, page, pageSize,fileId,proIds,name); @RequestParam(name = "mediaCount" , required = false) Integer mediaCount) {
PaginationData<WaylineJobDTO> data = waylineJobService.getJobsByWorkspaceId(workspaceId, page, pageSize,fileId,proIds,name,mediaCount);
return HttpResultResponse.success(data); return HttpResultResponse.success(data);
} }

4
dk-modules/sample/src/main/java/org/dromara/sample/wayline/model/entity/WaylineJobEntity.java

@ -97,4 +97,8 @@ public class WaylineJobEntity implements Serializable {
@TableField("pro_id") @TableField("pro_id")
private Integer proId; private Integer proId;
@TableField("dock_name")
private String dockName;
} }

6
dk-modules/sample/src/main/java/org/dromara/sample/wayline/service/IWaylineJobService.java

@ -28,7 +28,7 @@ public interface IWaylineJobService {
* @param endTime The time the job ended. * @param endTime The time the job ended.
* @return * @return
*/ */
Optional<WaylineJobDTO> createWaylineJob(CreateJobParam param, String workspaceId, String username, Date beginTime, Date endTime, Integer proId); Optional<WaylineJobDTO> createWaylineJob(CreateJobParam param, String workspaceId, String username, Date beginTime, Date endTime, Integer proId,String dockName);
/** /**
* Create a sub-task based on the information of the parent task. * Create a sub-task based on the information of the parent task.
@ -71,7 +71,9 @@ public interface IWaylineJobService {
* @param pageSize * @param pageSize
* @return * @return
*/ */
PaginationData<WaylineJobDTO> getJobsByWorkspaceId(String workspaceId, long page, long pageSize,String fileId,List<Integer> proIds,String name); PaginationData<WaylineJobDTO> getJobsByWorkspaceId(String workspaceId, long page, long pageSize,String fileId,List<Integer> proIds,String name,Integer mediaCount);
/** /**
* Query the wayline execution status of the dock. * Query the wayline execution status of the dock.

5
dk-modules/sample/src/main/java/org/dromara/sample/wayline/service/impl/FlightTaskServiceImpl.java

@ -248,7 +248,7 @@ public class FlightTaskServiceImpl extends AbstractWaylineService implements IFl
if(deviceOnline.get().getProId() == null){ if(deviceOnline.get().getProId() == null){
throw new SQLException("项目组不存在"); throw new SQLException("项目组不存在");
} }
Optional<WaylineJobDTO> waylineJobOpt = waylineJobService.createWaylineJob(param, workspaceId, loginUser.getUsername(), new Date(beginTime), new Date(endTime),deviceOnline.get().getProId()); Optional<WaylineJobDTO> waylineJobOpt = waylineJobService.createWaylineJob(param, workspaceId, loginUser.getUsername(), new Date(beginTime), new Date(endTime),deviceOnline.get().getProId(),deviceOnline.get().getNickname());
if (waylineJobOpt.isEmpty()) { if (waylineJobOpt.isEmpty()) {
throw new SQLException("无法创建路线作业。"); throw new SQLException("无法创建路线作业。");
} }
@ -286,7 +286,7 @@ public class FlightTaskServiceImpl extends AbstractWaylineService implements IFl
if(deviceOnline.get().getProId() == null){ if(deviceOnline.get().getProId() == null){
throw new SQLException("项目组不存在"); throw new SQLException("项目组不存在");
} }
Optional<WaylineJobDTO> waylineJobOpt = waylineJobService.createWaylineJob(param, workspaceId, username, new Date(beginTime), new Date(endTime),deviceOnline.get().getProId()); Optional<WaylineJobDTO> waylineJobOpt = waylineJobService.createWaylineJob(param, workspaceId, username, new Date(beginTime), new Date(endTime),deviceOnline.get().getProId(),deviceOnline.get().getNickname());
if (waylineJobOpt.isEmpty()) { if (waylineJobOpt.isEmpty()) {
throw new SQLException("无法创建路线作业。"); throw new SQLException("无法创建路线作业。");
} }
@ -342,6 +342,7 @@ public class FlightTaskServiceImpl extends AbstractWaylineService implements IFl
if (waylineFile.isEmpty()) { if (waylineFile.isEmpty()) {
throw new SQLException("路线文件不存在。"); throw new SQLException("路线文件不存在。");
} }
waylineJob.setWaylineName(waylineFile.get().getName());
// get file url // get file url
URL url = waylineFileService.getObjectUrl(waylineJob.getWorkspaceId(), waylineFile.get().getId()); URL url = waylineFileService.getObjectUrl(waylineJob.getWorkspaceId(), waylineFile.get().getId());

39
dk-modules/sample/src/main/java/org/dromara/sample/wayline/service/impl/WaylineJobServiceImpl.java

@ -92,7 +92,7 @@ public class WaylineJobServiceImpl implements IWaylineJobService {
} }
@Override @Override
public Optional<WaylineJobDTO> createWaylineJob(CreateJobParam param, String workspaceId, String username, Date beginTime, Date endTime,Integer proId) { public Optional<WaylineJobDTO> createWaylineJob(CreateJobParam param, String workspaceId, String username, Date beginTime, Date endTime,Integer proId,String dockName) {
if (Objects.isNull(param)) { if (Objects.isNull(param)) {
return Optional.empty(); return Optional.empty();
} }
@ -116,6 +116,7 @@ public class WaylineJobServiceImpl implements IWaylineJobService {
.breakPoint(param.getBreakPoint()) .breakPoint(param.getBreakPoint())
.jobType(param.getJobType().getType()) .jobType(param.getJobType().getType())
.proId(proId) .proId(proId)
.dockSn(dockName)
.build(); .build();
return insertWaylineJob(jobEntity); return insertWaylineJob(jobEntity);
@ -175,7 +176,7 @@ public class WaylineJobServiceImpl implements IWaylineJobService {
} }
@Override @Override
public PaginationData<WaylineJobDTO> getJobsByWorkspaceId(String workspaceId, long page, long pageSize,String fileId,List<Integer> proIds,String name) { public PaginationData<WaylineJobDTO> getJobsByWorkspaceId(String workspaceId, long page, long pageSize,String fileId,List<Integer> proIds,String name,Integer mediaCount) {
LoginUser loginUser = LoginHelper.getLoginUser(); LoginUser loginUser = LoginHelper.getLoginUser();
if(ObjectUtil.isAllEmpty(proIds)){ if(ObjectUtil.isAllEmpty(proIds)){
proIds = deviceProService.listDeviceGroup(loginUser.getUserId()); proIds = deviceProService.listDeviceGroup(loginUser.getUserId());
@ -185,6 +186,9 @@ public class WaylineJobServiceImpl implements IWaylineJobService {
if(ObjectUtil.isNotEmpty(fileId)) { if(ObjectUtil.isNotEmpty(fileId)) {
waylineJobEntityLambdaQueryWrapper.eq(WaylineJobEntity::getFileId, fileId); waylineJobEntityLambdaQueryWrapper.eq(WaylineJobEntity::getFileId, fileId);
} }
if(ObjectUtil.isNotEmpty(mediaCount)) {
waylineJobEntityLambdaQueryWrapper.ge(WaylineJobEntity::getMediaCount, mediaCount);
}
waylineJobEntityLambdaQueryWrapper.in(ObjectUtil.isAllNotEmpty(proIds),WaylineJobEntity::getProId,proIds); waylineJobEntityLambdaQueryWrapper.in(ObjectUtil.isAllNotEmpty(proIds),WaylineJobEntity::getProId,proIds);
if(ObjectUtil.isNotEmpty(name)){ if(ObjectUtil.isNotEmpty(name)){
waylineJobEntityLambdaQueryWrapper.and(wrapper ->{ waylineJobEntityLambdaQueryWrapper.and(wrapper ->{
@ -297,11 +301,12 @@ public class WaylineJobServiceImpl implements IWaylineJobService {
.jobId(entity.getJobId()) .jobId(entity.getJobId())
.jobName(entity.getName()) .jobName(entity.getName())
.fileId(entity.getFileId()) .fileId(entity.getFileId())
.fileName(waylineFileService.getWaylineByWaylineId(entity.getWorkspaceId(), entity.getFileId()) .fileName(entity.getWaylineName())
.orElse(new GetWaylineListResponse()).getName()) // .orElse(new GetWaylineListResponse()).getName())
.dockSn(entity.getDockSn()) .dockSn(entity.getDockSn())
.dockName(deviceService.getDeviceBySn(entity.getDockSn()) .dockName(entity.getDockName())
.orElse(DeviceDTO.builder().build()).getNickname()) // .dockName(deviceService.getDeviceBySn(entity.getDockSn())
// .orElse(DeviceDTO.builder().build()).getNickname())
.username(entity.getUsername()) .username(entity.getUsername())
.workspaceId(entity.getWorkspaceId()) .workspaceId(entity.getWorkspaceId())
.status(WaylineJobStatusEnum.IN_PROGRESS.getVal() == entity.getStatus() && .status(WaylineJobStatusEnum.IN_PROGRESS.getVal() == entity.getStatus() &&
@ -348,17 +353,17 @@ public class WaylineJobServiceImpl implements IWaylineJobService {
.uploading(RedisOpsUtils.checkExist(key) && entity.getJobId().equals(((MediaFileCountDTO)RedisOpsUtils.get(key)).getJobId())); .uploading(RedisOpsUtils.checkExist(key) && entity.getJobId().equals(((MediaFileCountDTO)RedisOpsUtils.get(key)).getJobId()));
return builder.build(); return builder.build();
} }
//
int uploadedSize = fileService.getFilesByWorkspaceAndJobId(entity.getWorkspaceId(), entity.getJobId()).size(); // int uploadedSize = fileService.getFilesByWorkspaceAndJobId(entity.getWorkspaceId(), entity.getJobId()).size();
// All media for this job have been uploaded. // //All media for this job have been uploaded.
if (uploadedSize >= entity.getMediaCount()) { // if (uploadedSize >= entity.getMediaCount()) {
return builder.uploadedCount(uploadedSize).build(); // return builder.uploadedCount(uploadedSize).build();
} // }
RedisOpsUtils.hashSet(countKey, entity.getJobId(), // RedisOpsUtils.hashSet(countKey, entity.getJobId(),
MediaFileCountDTO.builder() // MediaFileCountDTO.builder()
.jobId(entity.getJobId()) // .jobId(entity.getJobId())
.mediaCount(entity.getMediaCount()) // .mediaCount(entity.getMediaCount())
.uploadedCount(uploadedSize).build()); // .build());
return builder.build(); return builder.build();
} }
} }

23
dk-modules/sample/src/main/resources/mapper/WaylineJobMapper.xml

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.dromara.sample.wayline.mapper.IWaylineJobMapper">
<select id="getPage"
resultType="org.dromara.sample.wayline.model.entity.WaylineJobEntity">
SELECT
*
FROM
wayline_file wf
LEFT JOIN wayline_device wd ON wd.wayline_id = wf.wayline_id
LEFT JOIN manage_device md ON md.device_sn = wd.device_sn
where 1=1
<if test="param.deviceSn != null and param.deviceSn != ''">
AND wd.device_sn = #{param.deviceSn}
</if>
GROUP BY wf.wayline_id
</select>
</mapper>
Loading…
Cancel
Save