Selaa lähdekoodia

增加cos工具包

dsx 2 vuotta sitten
vanhempi
commit
a6b93ba5c7

+ 1 - 0
4dkankan-common-utils/src/main/java/com/fdkankan/common/constant/ErrorCode.java

@@ -163,6 +163,7 @@ public enum ErrorCode {
     FAILURE_CODE_5072(5072, "相机原始资源已被删除,不支持重算"),
     FAILURE_CODE_5073(5073, "相机原始资源正在被清除"),
     FAILURE_CODE_5074(5074, "场景原始资源被冻结,不支持重算"),
+    FAILURE_CODE_5075(5075, "模型不正确"),
 
     FAILURE_CODE_6003(6003, "该相机未被绑定,请前往 我的相机 先绑定相机后再进行授权 "),
 

+ 4 - 22
4dkankan-utils-fyun-parent/src/main/java/com/fdkankan/fyun/config/FYunFileConfig.java

@@ -1,8 +1,10 @@
 package com.fdkankan.fyun.config;
 
+import lombok.Data;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 
+@Data
 @Component
 public class FYunFileConfig {
 
@@ -27,27 +29,7 @@ public class FYunFileConfig {
     @Value("${fyun.host}")
     private String host;
 
-    public String getKey() {
-        return key;
-    }
+    @Value("${fyun.region:#{null}}")
+    private String region;
 
-    public String getSecret() {
-        return secret;
-    }
-
-    public String getBucket() {
-        return bucket;
-    }
-
-    public String getEndPoint() {
-        return endPoint;
-    }
-
-    public String getFyunType() {
-        return fyunType;
-    }
-
-    public String getHost() {
-        return host;
-    }
 }

+ 28 - 0
4dkankan-utils-fyun-tencent/pom.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <artifactId>4dkankan-utils</artifactId>
+        <groupId>com.fdkankan</groupId>
+        <version>3.0.0-SNAPSHOT</version>
+    </parent>
+    <artifactId>4dkankan-utils-fyun-tencent</artifactId>
+    <properties>
+        <java.version>1.8</java.version>
+    </properties>
+    <dependencies>
+        <dependency>
+            <groupId>com.qcloud</groupId>
+            <artifactId>cos_api</artifactId>
+            <version>5.6.155</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.fdkankan</groupId>
+            <artifactId>4dkankan-utils-fyun-parent</artifactId>
+            <version>3.0.0-SNAPSHOT</version>
+        </dependency>
+    </dependencies>
+
+</project>

+ 464 - 0
4dkankan-utils-fyun-tencent/src/main/java/com/fdkankan/fyun/oss/CosFileService.java

@@ -0,0 +1,464 @@
+package com.fdkankan.fyun.oss;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.io.IoUtil;
+import com.fdkankan.fyun.constant.FYunTypeEnum;
+import com.fdkankan.fyun.face.AbstractFYunFileService;
+import com.qcloud.cos.COSClient;
+import com.qcloud.cos.http.HttpMethodName;
+import com.qcloud.cos.model.*;
+import com.qcloud.cos.utils.IOUtils;
+import com.qcloud.cos.utils.Md5Utils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.ObjectUtils;
+
+import java.io.*;
+import java.math.BigDecimal;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Component
+@ConditionalOnProperty(name = "fyun.type", havingValue = "cos")
+public class CosFileService extends AbstractFYunFileService {
+
+    private Logger log = LoggerFactory.getLogger(this.getClass().getName());
+
+    @Autowired
+    private COSClient cosClient;
+
+    @Override
+    public String uploadFile(String bucket, byte[] data, String remoteFilePath) {
+        String contentMd5 = Md5Utils.md5AsBase64(data);
+        InputStream ins = new ByteArrayInputStream(data);
+        ObjectMetadata metadata = new ObjectMetadata();
+        metadata.setContentType("text/plain");
+        metadata.setContentLength(data.length);
+        metadata.setContentMD5(contentMd5);
+        cosClient.putObject(bucket, remoteFilePath, ins, metadata);
+        return null;
+    }
+
+    @Override
+    public String uploadFile(String bucket, String filePath, String remoteFilePath) {
+        return uploadFile(bucket, filePath, remoteFilePath, null);
+    }
+
+    @Override
+    public String uploadFile(String bucket, InputStream inputStream, String remoteFilePath) {
+        try {
+            cosClient.putObject(bucket, remoteFilePath, inputStream, new ObjectMetadata());
+            log.info("文件流上传成功,目标路径:remoteFilePath:{}", remoteFilePath);
+        } catch (Exception e) {
+            log.error("oss上传文件失败,remoteFilePath:"+remoteFilePath, e);
+        }
+        return null;
+    }
+
+    @Override
+    public String uploadFile(String bucket, String filePath, String remoteFilePath, Map<String, String> headers) {
+        File file = new File(filePath);
+        if (!file.exists()) {
+            log.warn("要上传的文件不存在,filePath" + filePath);
+            return null;
+        }
+        try (InputStream ins = new FileInputStream(file)){
+            ObjectMetadata metadata = new ObjectMetadata();
+            if (filePath.contains(".jpg")) {
+                metadata.setContentType("image/jpeg");
+            }
+            if (filePath.contains(".mp4")) {
+                metadata.setContentType("video/mp4");
+            }
+            if (filePath.contains(".mp3")) {
+                metadata.setContentType("audio/mp3");
+            }
+            if (org.apache.commons.lang3.ObjectUtils.isNotEmpty(headers)) {
+                for (Map.Entry<String, String> header : headers.entrySet()) {
+                    metadata.setHeader(header.getKey(), header.getValue());
+                }
+            }
+            cosClient.putObject(bucket, remoteFilePath, ins, metadata);
+            log.info("文件上传成功,path:{}", filePath);
+        } catch (Exception e) {
+            log.error("cos上传文件失败,filePath:"+filePath, e);
+        }
+        return null;
+    }
+
+    @Override
+    public String uploadFileByCommand(String bucket, String filePath, String remoteFilePath) {
+        try {
+            String optType = new File(filePath).isDirectory() ? "folder" : "file";
+            String command = String.format(fYunConstants.UPLOAD_SH, bucket, filePath, remoteFilePath, FYunTypeEnum.OSS.code(), optType);
+            log.info("开始上传文件, ossPath:{}, srcPath:{}", remoteFilePath, filePath);
+            callshell(command);
+            log.info("上传文件完毕, ossPath:{}, srcPath:{}", remoteFilePath, filePath);
+        } catch (Exception e) {
+            log.error(String.format("上传文件失败, ossPath:%s, srcPath:%s", remoteFilePath, filePath), e);
+        }
+        return null;
+    }
+
+    @Override
+    public void downloadFileByCommand(String bucket, String filePath, String remoteFilePath) {
+        try {
+            String optType = remoteFilePath.contains(".") ? "file" : "folder";
+            String command = String.format(fYunConstants.DOWNLOAD_SH, bucket, remoteFilePath, filePath, FYunTypeEnum.OSS.code(), optType);
+            log.info("开始下载文件, ossPath:{}, srcPath:{}", remoteFilePath, filePath);
+            callshell(command);
+            log.info("下载文件完毕, ossPath:{}, srcPath:{}", remoteFilePath, filePath);
+        } catch (Exception e) {
+            log.error(String.format("下载文件失败, ossPath:%s, srcPath:%s", remoteFilePath, filePath), e);
+        }
+    }
+
+    @Override
+    public void deleteFile(String bucket, String remoteFilePath) throws IOException {
+        try {
+            cosClient.deleteObject(bucket, remoteFilePath);
+        } catch (Exception e) {
+            log.error("OSS删除文件失败,key:" + remoteFilePath, e);
+        }
+    }
+
+    @Override
+    public void deleteFolder(String bucket, String remoteFolderPath) {
+        try {
+
+            if (!remoteFolderPath.endsWith("/")) {
+                remoteFolderPath = remoteFolderPath + "/";
+            }
+            log.info("开始删除文件夹:{}", remoteFolderPath);
+            boolean flag = true;
+            String nextMaker = null;
+            ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
+            // 设置 bucket 名称
+            listObjectsRequest.setBucketName(bucket);
+            // prefix 表示列出的对象名以 prefix 为前缀
+            // 这里填要列出的目录的相对 bucket 的路径
+            listObjectsRequest.setPrefix(remoteFolderPath);
+            // delimiter 表示目录的截断符, 例如:设置为 / 则表示对象名遇到 / 就当做一级目录)
+            listObjectsRequest.setDelimiter("/");
+            // 设置最大遍历出多少个对象, 一次 listobject 最大支持1000
+            listObjectsRequest.setMaxKeys(1000);
+
+
+            DeleteObjectsRequest request = new DeleteObjectsRequest(bucket);
+            do {
+                //获取下一页的起始点,它的下一项
+                ObjectListing objectListing = cosClient.listObjects(listObjectsRequest);
+                List<DeleteObjectsRequest.KeyVersion> keys = objectListing.getObjectSummaries().parallelStream()
+                        .map(cosObjectSummary -> new DeleteObjectsRequest.KeyVersion(cosObjectSummary.getKey())).collect(Collectors.toList());
+                if (!CollectionUtils.isEmpty(keys)) {
+                    request.setKeys(keys);
+                    cosClient.deleteObjects(request);
+                }
+                nextMaker = objectListing.getNextMarker();
+                listObjectsRequest.setMarker(nextMaker);
+                //全部执行完后,为false
+                flag = objectListing.isTruncated();
+            } while (flag);
+        } catch (Exception e) {
+            log.error("coss删除目录失败,key:" + remoteFolderPath, e);
+        }
+    }
+
+    @Override
+    public void uploadMulFiles(String bucket, Map<String, String> filepaths) {
+        try {
+            for (Map.Entry<String, String> entry : filepaths.entrySet()) {
+                uploadFile(bucket, entry.getKey(), entry.getValue(), null);
+            }
+        } catch (Exception e) {
+            log.error("OSS批量上传文件失败!");
+        }
+    }
+
+    @Override
+    public List<String> listRemoteFiles(String bucket, String sourcePath) {
+        List<String> keyList = new ArrayList<>();
+        try {
+            boolean flag = true;
+            String nextMaker = null;
+            ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
+            listObjectsRequest.setBucketName(bucket);
+            //指定下一级文件
+            listObjectsRequest.setPrefix(sourcePath);
+            //设置分页的页容量
+            listObjectsRequest.setMaxKeys(1000);
+            do {
+                //获取下一页的起始点,它的下一项
+                ObjectListing objectListing = cosClient.listObjects(listObjectsRequest);
+                List<String> collect = objectListing.getObjectSummaries().parallelStream()
+                        .map(COSObjectSummary::getKey).filter(entity -> entity.contains(".")).collect(Collectors.toList());
+                if (!CollectionUtils.isEmpty(collect)) {
+                    keyList.addAll(collect);
+                }
+                nextMaker = objectListing.getNextMarker();
+                listObjectsRequest.setMarker(nextMaker);
+                //全部执行完后,为false
+                flag = objectListing.isTruncated();
+            } while (flag);
+        } catch (Exception e) {
+            log.error("获取文件列表失败,path:" + sourcePath, e);
+        }
+        return keyList;
+    }
+
+    @Override
+    public void copyFileBetweenBucket(String sourceBucketName, String sourcePath, String targetBucketName, String targetPath) {
+        try {
+            List<String> files = listRemoteFiles(sourceBucketName, sourcePath);
+            if (ObjectUtils.isEmpty(files)) {
+                return;
+            }
+            files.stream().forEach(file -> {
+                cosClient.copyObject(sourceBucketName, file, targetBucketName, file.replace(sourcePath, targetPath));
+            });
+        } catch (Exception e) {
+            log.error("复制文件或目录失败,key:" + sourcePath, e);
+        }
+    }
+
+    @Override
+    public void copyFilesBetweenBucket(String sourceBucketName, String targetBucketName, Map<String, String> pathMap) {
+        if (ObjectUtils.isEmpty(pathMap)) {
+            return;
+        }
+        try {
+            for (Map.Entry<String, String> entry : pathMap.entrySet()) {
+                copyFileBetweenBucket(sourceBucketName, entry.getKey(), targetBucketName, entry.getValue());
+            }
+        } catch (Exception e) {
+            log.error(String.format("批量复制文件失败, sourceBucketName:%s, targetBucketName:%s", sourceBucketName, targetBucketName), e);
+        }
+    }
+
+    @Override
+    public String getFileContent(String bucketName, String remoteFilePath) {
+        try (COSObject object = cosClient.getObject(bucketName, remoteFilePath)){
+            InputStream objectContent = object.getObjectContent();
+            StringBuilder contentJson = new StringBuilder();
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(objectContent))) {
+                while (true) {
+                    String line = reader.readLine();
+                    if (line == null) break;
+                    contentJson.append(line);
+                }
+            } catch (IOException e) {
+                throw e;
+            }
+            return contentJson.toString();
+        } catch (Exception e) {
+            log.error("获取文件内容失败:key:"+remoteFilePath, e);
+        }
+        return null;
+    }
+
+    @Override
+    public boolean fileExist(String bucket, String key) {
+        try {
+            return cosClient.doesObjectExist(bucket, key);
+        } catch (Exception e) {
+            log.error("判断文件是否存在失败,key:"+key, e);
+        }
+        return false;
+    }
+
+    @Override
+    public void downloadFile(String bucket, String remoteFilePath, String localPath) {
+        try {
+            File localFile = new File(localPath);
+            if (!localFile.getParentFile().exists()) {
+                localFile.getParentFile().mkdirs();
+            }
+            if(localFile.isDirectory()){
+                String fileName = remoteFilePath.substring(remoteFilePath.lastIndexOf("/")+1);
+                log.info("未配置文件名,使用默认文件名:{}",fileName);
+                localPath = localPath.concat(File.separator).concat(fileName);
+            }
+
+            GetObjectRequest getObjectRequest = new GetObjectRequest(bucket, remoteFilePath);
+            cosClient.getObject(getObjectRequest, new File(localPath));
+        } catch (Throwable throwable) {
+            log.error("文件下载失败,key:"+remoteFilePath, throwable);
+        }
+    }
+
+    @Override
+    public URL getPresignedUrl(String bucket, String url) {
+        java.util.Date expiration = new java.util.Date();
+        long expTimeMillis = expiration.getTime();
+        expTimeMillis += 1000 * 60 * 60 * 8;
+        expiration.setTime(expTimeMillis);
+        GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucket, url);
+        generatePresignedUrlRequest.setMethod(HttpMethodName.PUT);
+        generatePresignedUrlRequest.setExpiration(expiration);
+        return cosClient.generatePresignedUrl(generatePresignedUrlRequest);
+    }
+
+    @Override
+    public long getSubFileNums(String bucket, String url) {
+        long totalSubFileNum = 0;
+        try {
+            boolean flag = true;
+            String nextMaker = null;
+            ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
+            listObjectsRequest.setBucketName(bucket);
+            //指定下一级文件
+            listObjectsRequest.setPrefix(url);
+            //设置分页的页容量
+            listObjectsRequest.setMaxKeys(1000);
+            do {
+                //获取下一页的起始点,它的下一项
+                listObjectsRequest.setMarker(nextMaker);
+                ObjectListing objectListing = cosClient.listObjects(listObjectsRequest);
+                List<String> collect = objectListing.getObjectSummaries().parallelStream()
+                        .map(COSObjectSummary::getKey).collect(Collectors.toList());
+                if (!CollectionUtils.isEmpty(collect)) {
+                    totalSubFileNum = totalSubFileNum + collect.size();
+                }
+                nextMaker = objectListing.getNextMarker();
+                //全部执行完后,为false
+                flag = objectListing.isTruncated();
+            } while (flag);
+        } catch (Exception e) {
+            log.error("获取文件数量失败,path:" + url, e);
+        }
+        return totalSubFileNum;
+    }
+
+    @Override
+    public Boolean checkStore(String bucket,String filePath){
+        // TODO: 2023/8/1
+//        ObjectMetadata objectMetadata =  cosClient.getObjectMetadata(bucket, filePath);
+//        return !objectMetadata.get();
+        return false;
+    }
+
+    @Override
+    public void restoreFolder(String bucket,String folderName){
+        ObjectMetadata objectMetadata ;
+
+        List<String> objectList = this.listRemoteFiles(bucket, folderName);
+        if(CollUtil.isEmpty(objectList)){
+            return;
+        }
+        for (String objectName : objectList) {
+            objectMetadata =  cosClient.getObjectMetadata(bucket, objectName);
+            // 校验Object是否为归档类型Object。
+            StorageClass storageClass = objectMetadata.getStorageClassEnum();
+            if (storageClass == StorageClass.Archive) {
+                // 解冻Object。
+                cosClient.restoreObject(bucket, objectName,1);
+            }
+        }
+    }
+
+    @Override
+    public Integer getRestoreFolderProcess(String bucket,String folderName){
+        ObjectMetadata objectMetadata ;
+
+        List<String> objectList = this.listRemoteFiles(bucket, folderName);
+        if(CollUtil.isEmpty(objectList)){
+            return 100;
+        }
+        List<String> restoreFileList = new ArrayList<>();
+        for (String objectName : objectList) {
+            objectMetadata = cosClient.getObjectMetadata(bucket, objectName);
+//            if(objectMetadata.isRestoreCompleted()){
+//                restoreFileList.add(objectName);
+//            }
+            // TODO: 2023/8/1
+        }
+        if(objectList.size() <= restoreFileList.size() ){
+            return 100;
+        }
+        BigDecimal rite = new BigDecimal(restoreFileList.size()).divide(new BigDecimal(objectList.size()),2,BigDecimal.ROUND_HALF_UP);
+        BigDecimal multiply = rite.multiply(new BigDecimal(100));
+        return multiply.intValue();
+    }
+
+    @Override
+    public void restoreFolder(String bucket, String folderName, Integer priority) {
+
+        List<String> objectList = this.listRemoteFiles(bucket, folderName);
+        if(CollUtil.isEmpty(objectList)){
+            return;
+        }
+        objectList.parallelStream().forEach(objectName -> {
+            this.restoreFile(bucket, objectName, priority);
+        });
+    }
+
+    @Override
+    public void restoreFile(String bucket, String objectName, Integer priority){
+        ObjectMetadata objectMetadata = cosClient.getObjectMetadata(bucket, objectName);
+        // 校验Object是否为归档类型Object。
+        StorageClass storageClass = objectMetadata.getStorageClassEnum();
+        if (storageClass == StorageClass.Archive) {
+            // 设置解冻冷归档Object的优先级。
+            // RestoreTier.RESTORE_TIER_EXPEDITED 表示1小时内完成解冻。
+            // RestoreTier.RESTORE_TIER_STANDARD 表示2~5小时内完成解冻。
+            // RestoreTier.RESTORE_TIER_BULK 表示5~12小时内完成解冻。
+            Tier tier = null;
+            switch (priority){
+                case 1 :
+                    tier = Tier.Expedited;
+                    break;
+                case 2 :
+                    tier = Tier.Standard;
+                    break;
+                case 3 :
+                    tier = Tier.Bulk;
+                    break;
+                default:
+                    tier = Tier.Standard;
+            }
+            // 设置 restore 得到的临时副本过期天数为1天
+            RestoreObjectRequest restoreObjectRequest = new RestoreObjectRequest(bucket, objectName, 1);
+            // 设置恢复模式为 Standard,其他的可选模式包括 Expedited 和 Bulk。若恢复归档存储类型数据,则支持上述三种恢复模式,选择不同恢复模式,在费用和恢复速度上不一样。若恢复深度归档存储类型数据,则仅支持 Standard 和 Bulk 恢复模式
+            CASJobParameters casJobParameters = new CASJobParameters();
+            casJobParameters.setTier(tier);
+            restoreObjectRequest.setCASJobParameters(casJobParameters);
+
+            //开始解冻
+            cosClient.restoreObject(restoreObjectRequest);
+        }
+    }
+
+    @Override
+    public Long getSpace(String bucket, String key) {
+
+        Long total = 0L;
+        boolean flag = true;
+        String nextMaker = null;
+        ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
+        listObjectsRequest.setBucketName(bucket);
+        //指定下一级文件
+        listObjectsRequest.setPrefix(key);
+        //设置分页的页容量
+        listObjectsRequest.setMaxKeys(1000);
+        do {
+            //获取下一页的起始点,它的下一项
+            listObjectsRequest.setMarker(nextMaker);
+            ObjectListing objectListing = cosClient.listObjects(listObjectsRequest);
+            Long space = objectListing.getObjectSummaries().parallelStream()
+                    .mapToLong(COSObjectSummary::getSize).sum();
+            total += space;
+            nextMaker = objectListing.getNextMarker();
+            //全部执行完后,为false
+            flag = objectListing.isTruncated();
+        } while (flag);
+        return total;
+    }
+
+}

+ 38 - 0
4dkankan-utils-fyun-tencent/src/main/java/com/fdkankan/fyun/oss/config/CosConfig.java

@@ -0,0 +1,38 @@
+package com.fdkankan.fyun.oss.config;
+
+import com.fdkankan.fyun.config.FYunFileConfig;
+import com.qcloud.cos.COSClient;
+import com.qcloud.cos.ClientConfig;
+import com.qcloud.cos.auth.BasicCOSCredentials;
+import com.qcloud.cos.auth.COSCredentials;
+import com.qcloud.cos.http.HttpProtocol;
+import com.qcloud.cos.region.Region;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConditionalOnProperty(name = "fyun.type", havingValue = "cos")
+public class CosConfig {
+
+    @Autowired
+    private FYunFileConfig fYunFileConfig;
+
+    @Bean
+    public COSClient cosClient(){
+        // 1 初始化用户身份信息(secretId, secretKey)。
+        // SECRETID 和 SECRETKEY 请登录访问管理控制台 https://console.cloud.tencent.com/cam/capi 进行查看和管理
+        COSCredentials cred = new BasicCOSCredentials(fYunFileConfig.getKey(), fYunFileConfig.getSecret());
+        // 2 设置 bucket 的地域, COS 地域的简称请参见 https://cloud.tencent.com/document/product/436/6224
+        // clientConfig 中包含了设置 region, https(默认 http), 超时, 代理等 set 方法, 使用可参见源码或者常见问题 Java SDK 部分。
+        Region region = new Region(fYunFileConfig.getRegion());
+        ClientConfig clientConfig = new ClientConfig(region);
+        // 这里建议设置使用 https 协议
+        // 从 5.6.54 版本开始,默认使用了 https
+        clientConfig.setHttpProtocol(HttpProtocol.https);
+        // 3 生成 cos 客户端。
+        COSClient cosClient = new COSClient(cred, clientConfig);
+        return cosClient;
+    }
+}

+ 1 - 0
pom.xml

@@ -21,6 +21,7 @@
         <module>4dkankan-utils-fyun-parent</module>
         <module>4dkankan-utils-fyun-oss</module>
         <module>4dkankan-utils-fyun-s3</module>
+        <module>4dkankan-utils-fyun-tencent</module>
         <module>4dkankan-utils-fyun-local</module>
         <module>4dkankan-utils-model</module>
         <module>4dkankan-utils-wechat</module>