From f28333c692d1d5f7ef5e938d435e7e13f6387f97 Mon Sep 17 00:00:00 2001 From: canonical Date: Sun, 13 Aug 2023 22:52:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=96=87=E6=A1=A3=E3=80=82?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E4=B8=8B=E8=BD=BD=E6=97=B6=E6=9D=83=E9=99=90?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev-guide/graphql/graphql-java.md | 6 +- docs/dev-guide/graphql/upload.md | 153 +++++++++++++++++- .../nop-quarkus-demo/reflect-config.json | 14 ++ .../nop/file/core/NopFileStoreBizModel.java | 4 +- .../file/spring/web/SpringFileService.java | 6 +- .../api/file/IFileServiceClient.java | 2 +- .../local/LocalFileServiceClientFactory.java | 4 +- .../integration/oss/OssFileServiceClient.java | 3 +- .../io/nop/integration/sftp/SftpClient.java | 3 +- 9 files changed, 181 insertions(+), 14 deletions(-) diff --git a/docs/dev-guide/graphql/graphql-java.md b/docs/dev-guide/graphql/graphql-java.md index 8d7f2e56b..18889a249 100644 --- a/docs/dev-guide/graphql/graphql-java.md +++ b/docs/dev-guide/graphql/graphql-java.md @@ -345,4 +345,8 @@ NopAuthDept_findList{ } ``` -`@TreeChild(max=5)`表示按照本层的结构最多嵌套5层。 \ No newline at end of file +`@TreeChild(max=5)`表示按照本层的结构最多嵌套5层。 + +## 文件上传下载 + +参见[upload.md](upload.md) diff --git a/docs/dev-guide/graphql/upload.md b/docs/dev-guide/graphql/upload.md index 38835b8de..8a65c66ad 100644 --- a/docs/dev-guide/graphql/upload.md +++ b/docs/dev-guide/graphql/upload.md @@ -1,9 +1,110 @@ -# 文件上传下载 +# GraphQL引擎的上传下载扩展 + +标准的GraphQL引擎只支持JSON格式的输入输出。为了支持文件上传下载,NopGraphQL在接口层增加了一些扩展约定。 + +1. 文件上传信息被转换为UploadRequestBean对象,在GraphQL引擎内部只要针对UploadRequestBean进行编程即可。相当于是在JSON序列化协议的基础上增加一个自动的针对上传文件的序列化机制。 +目前缺省情况下/f/upload这个端点会自动解析上传文件并调用GraphQL引擎。 +2. GraphQL引擎可以返回WebContentBean来表示下载资源文件。Web框架调用GraphQL引擎发现返回结果是WebContentBean之后,会自动从中读取到Resource对象,并设置Content-Type和Content-Disposition等header配置。 +目前缺省情况下/p/{bizObjName}__{bizAction}以及/f/download/{fileId}这两种调用形式会自动识别WebContentBean + +````java + +@BizModel("NopFileStore") +public class NopFileStoreBizModel { + ... + @BizMutation + public UploadResponseBean upload(@RequestBean UploadRequestBean record, IServiceContext context) { + checkMaxSize(record.getLength()); + checkFileExt(record.getFileExt()); + checkBizObjName(record.getBizObjName()); + + String fileId = fileStore.saveFile(record, maxFileSize); + + UploadResponseBean ret = new UploadResponseBean(); + ret.setValue(fileStore.getFileLink(fileId)); + ret.setFilename(record.getFileName()); + return ret; + } + + @BizQuery + public WebContentBean download(@Name("fileId") String fileId, + @Name("contentType") String contentType) { + IFileRecord record = fileStore.getFile(fileId); + if (StringHelper.isEmpty(contentType)) + contentType = MediaType.APPLICATION_OCTET_STREAM; + + return new WebContentBean(contentType, record.getResource(), record.getFileName()); + } + + protected IFileRecord loadFileRecord(String fileId, IServiceContext ctx) { + IFileRecord record = fileStore.getFile(fileId); + if (bizAuthChecker != null) { + bizAuthChecker.checkAuth(record.getBizObjName(), record.getBizObjId(), record.getFieldName(), ctx); + } + return record; + } +} +```` + +NopFileStoreBizModel只是针对POJO对象进行编程,它完全不需要具有任何关于特定Web框架的知识,因此我们可以将它适配到不同的Web框架。例如,对于SpringMVC, + +````java +@RestController +public class SpringFileService extends AbstractGraphQLFileService { + + @PostMapping("/f/upload") + public CompletionStage> upload(MultipartFile file, HttpServletRequest request) { + String locale = ContextProvider.currentLocale(); + CompletionStage> res; + try { + InputStream is = file.getInputStream(); + String fileName = StringHelper.fileFullName(file.getOriginalFilename()); + String mimeType = MediaTypeHelper.getMimeType(file.getContentType(), StringHelper.fileExt(fileName)); + UploadRequestBean input = new UploadRequestBean(is, fileName, file.getSize(), mimeType); + input.setBizObjName(request.getParameter(FileConstants.PARAM_BIZ_OBJ_NAME)); + + IGraphQLEngine graphQLEngine = getGraphQLEngine(); + + IGraphQLExecutionContext ctx = graphQLEngine.newRpcContext(GraphQLOperationType.mutation, + "NopFileStore__upload", buildApiRequest(request,input)); + res = graphQLEngine.executeRpcAsync(ctx); + } catch (IOException e) { + res = FutureHelper.success(ErrorMessageManager.instance().buildResponse(locale, e)); + } + return res.thenApply(response -> SpringWebHelper.buildResponse(response.getHttpStatus(), response)); + } + + protected ApiRequest buildApiRequest(HttpServletRequest req, T data) { + ApiRequest ret = new ApiRequest<>(); + Enumeration it = req.getHeaderNames(); + while (it.hasMoreElements()) { + String name = it.nextElement(); + name = name.toLowerCase(Locale.ENGLISH); + if (shouldIgnoreHeader(name)) + continue; + ret.setHeader(name, req.getHeader(name)); + } + ret.setData(data); + return ret; + } +} +```` + +## 模块依赖 引入nop-quarkus-web-starter或者nop-quarkus-spring-starter依赖后,会自动引入 -* nop-file-dao: 包含文件上传下载服务实现 +* nop-file-dao: 包含文件上传下载服务NopFileStoreBizModel的实现 * nop-integration-sso: 包含AmazonS3对象存储支持,阿里云OSS,腾讯云COS,七牛云,京东云,minio都支持这一接口标准。 +* nop-file-spring或者 nop-file-quarkus: 引入处理/f/upload和/f/download链接的REST服务 + +# 实体字段支持附件类型 + +NopORM并没有内置对于附件字段的支持,在应用层我们通过OrmFileComponent这种字段级别的抽象将文件存储与数据库存储结合在一起。 + +1. 附件字段中保存文件下载链接 +2. 在数据库中插入NopFileRecord保存附件的大小、文件名、Hash值等元数据,同时保存fileId和实体之间的关联关系,下载文件时可以验证是否具有实体访问权限 +3. 通过IFileStore接口保存具体的二进制文件数据。Nop平台中内置了本地文件存储以及AmazonS3对象存储支持,支持Minio、七牛云、腾讯云、阿里云等兼容S3的云存储。 ## Excel数据模型 @@ -41,6 +142,52 @@ 当实体更新或者删除的时候,会触发IOrmComponent接口上的onEntityFlush和onEntityDelete回调函数,在回调函数中将更新NopFileRecord对象上的bizObjName,bizObjId属性。 +````java + +public class OrmFileComponent extends AbstractOrmComponent { + public static final String PROP_NAME_filePath = "filePath"; + + public String getFilePath() { + return ConvertHelper.toString(internalGetPropValue(PROP_NAME_filePath)); + } + + public void setFilePath(String value) { + internalSetPropValue(PROP_NAME_filePath, value); + } + + @Override + public void onEntityFlush() { + IOrmEntity entity = orm_owner(); + int propId = getColPropId(PROP_NAME_filePath); + if (entity.orm_state().isUnsaved() || entity.orm_propDirty(propId)) { + IBeanProvider beanProvider = entity.orm_enhancer().getBeanProvider(); + IOrmEntityFileStore fileStore = (IOrmEntityFileStore) beanProvider.getBean(OrmConstants.BEAN_ORM_ENTITY_FILE_STORE); + String oldValue = (String) entity.orm_propOldValue(propId); + + String fileId = fileStore.decodeFileId(getFilePath()); + String propName = entity.orm_propName(propId); + + String bizObjName = getBizObjName(); + + if (!StringHelper.isEmpty(oldValue)) { + String oldFileId = fileStore.decodeFileId(oldValue); + if (!StringHelper.isEmpty(oldFileId)) { + fileStore.detachFile(oldFileId, bizObjName, entity.orm_idString(), propName); + } + } + + if (!StringHelper.isEmpty(fileId)) { + fileStore.attachFile(fileId, bizObjName, entity.orm_idString(), propName); + } + } + } + +} +```` + +这里很重要的一个设计就是实体层面上记录了附件字段是否已经被修改,以及修改前的值。可以想见,如果没有这种历史记录信息,我们就无法在单个字段层面确定如何实现文件存储与实体字段的同步, +而必须上升到整个实体的处理函数中进行。 + ## 前端控件 在control.xlib标签库中,根据stdDomain设置会自动为字段选择对应的编辑和显示控件。 @@ -55,7 +202,7 @@ } ```` -下载链接为 /f/download/{fileId} +下载链接的格式为 /f/download/{fileId} 在meta的prop节点上,可以配置以下属性: diff --git a/nop-demo/nop-quarkus-demo/src/main/resources/META-INF/native-image/io.nop.demo/nop-quarkus-demo/reflect-config.json b/nop-demo/nop-quarkus-demo/src/main/resources/META-INF/native-image/io.nop.demo/nop-quarkus-demo/reflect-config.json index b930d930c..6e66b544d 100644 --- a/nop-demo/nop-quarkus-demo/src/main/resources/META-INF/native-image/io.nop.demo/nop-quarkus-demo/reflect-config.json +++ b/nop-demo/nop-quarkus-demo/src/main/resources/META-INF/native-image/io.nop.demo/nop-quarkus-demo/reflect-config.json @@ -33158,6 +33158,14 @@ "java.lang.String" ] }, + { + "name": "newPath", + "parameterTypes": [ + "java.lang.String", + "java.lang.String", + "java.lang.String" + ] + }, { "name": "saveFile", "parameterTypes": [ @@ -33171,6 +33179,12 @@ "io.nop.dao.api.IDaoProvider" ] }, + { + "name": "setKeepFileExt", + "parameterTypes": [ + "boolean" + ] + }, { "name": "setLocalDir", "parameterTypes": [ diff --git a/nop-file/nop-file-core/src/main/java/io/nop/file/core/NopFileStoreBizModel.java b/nop-file/nop-file-core/src/main/java/io/nop/file/core/NopFileStoreBizModel.java index 9cdf1b5d4..babac3d3c 100644 --- a/nop-file/nop-file-core/src/main/java/io/nop/file/core/NopFileStoreBizModel.java +++ b/nop-file/nop-file-core/src/main/java/io/nop/file/core/NopFileStoreBizModel.java @@ -101,8 +101,8 @@ public UploadResponseBean upload(@RequestBean UploadRequestBean record, IService @BizQuery public WebContentBean download(@Name("fileId") String fileId, - @Name("contentType") String contentType) { - IFileRecord record = fileStore.getFile(fileId); + @Name("contentType") String contentType, IServiceContext ctx) { + IFileRecord record = loadFileRecord(fileId,ctx); if (StringHelper.isEmpty(contentType)) contentType = MediaType.APPLICATION_OCTET_STREAM; diff --git a/nop-file/nop-file-spring/src/main/java/io/nop/file/spring/web/SpringFileService.java b/nop-file/nop-file-spring/src/main/java/io/nop/file/spring/web/SpringFileService.java index 287bd4197..1f912f07f 100644 --- a/nop-file/nop-file-spring/src/main/java/io/nop/file/spring/web/SpringFileService.java +++ b/nop-file/nop-file-spring/src/main/java/io/nop/file/spring/web/SpringFileService.java @@ -43,14 +43,14 @@ public CompletionStage> upload(MultipartFile file, HttpSe String mimeType = MediaTypeHelper.getMimeType(file.getContentType(), StringHelper.fileExt(fileName)); UploadRequestBean input = new UploadRequestBean(is, fileName, file.getSize(), mimeType); input.setBizObjName(request.getParameter(FileConstants.PARAM_BIZ_OBJ_NAME)); - res = uploadAsync(buildRequest(request, input)); + res = uploadAsync(buildApiRequest(request, input)); } catch (IOException e) { res = FutureHelper.success(ErrorMessageManager.instance().buildResponse(locale, e)); } return res.thenApply(response -> SpringWebHelper.buildResponse(response.getHttpStatus(), response)); } - protected ApiRequest buildRequest(HttpServletRequest req, T data) { + protected ApiRequest buildApiRequest(HttpServletRequest req, T data) { ApiRequest ret = new ApiRequest<>(); Enumeration it = req.getHeaderNames(); while (it.hasMoreElements()) { @@ -71,7 +71,7 @@ public CompletionStage> download(@PathVariable("fileId") DownloadRequestBean req = new DownloadRequestBean(); req.setFileId(fileId); req.setContentType(contentType); - CompletionStage> future = downloadAsync(buildRequest(request, req)); + CompletionStage> future = downloadAsync(buildApiRequest(request, req)); return future.thenApply(res -> { if (!res.isOk()) { int status = res.getHttpStatus(); diff --git a/nop-integration/nop-integration-api/src/main/java/io/nop/integration/api/file/IFileServiceClient.java b/nop-integration/nop-integration-api/src/main/java/io/nop/integration/api/file/IFileServiceClient.java index b2dfdefda..d352e192e 100644 --- a/nop-integration/nop-integration-api/src/main/java/io/nop/integration/api/file/IFileServiceClient.java +++ b/nop-integration/nop-integration-api/src/main/java/io/nop/integration/api/file/IFileServiceClient.java @@ -15,7 +15,7 @@ public interface IFileServiceClient extends AutoCloseable { List listFiles(String remotePath); - void deleteFile(String remotePath); + boolean deleteFile(String remotePath); FileStatus getFileStatus(String remotePath); diff --git a/nop-integration/nop-integration-file-local/src/main/java/io/nop/integration/file/local/LocalFileServiceClientFactory.java b/nop-integration/nop-integration-file-local/src/main/java/io/nop/integration/file/local/LocalFileServiceClientFactory.java index fefc45e6a..630b0edbd 100644 --- a/nop-integration/nop-integration-file-local/src/main/java/io/nop/integration/file/local/LocalFileServiceClientFactory.java +++ b/nop-integration/nop-integration-file-local/src/main/java/io/nop/integration/file/local/LocalFileServiceClientFactory.java @@ -52,8 +52,8 @@ private FileStatus newFileStatus(IResource resource) { } @Override - public void deleteFile(String remotePath) { - resourceStore.getResource(remotePath).delete(); + public boolean deleteFile(String remotePath) { + return resourceStore.getResource(remotePath).delete(); } @Override diff --git a/nop-integration/nop-integration-oss/src/main/java/io/nop/integration/oss/OssFileServiceClient.java b/nop-integration/nop-integration-oss/src/main/java/io/nop/integration/oss/OssFileServiceClient.java index 83dfda6f7..5067ec028 100644 --- a/nop-integration/nop-integration-oss/src/main/java/io/nop/integration/oss/OssFileServiceClient.java +++ b/nop-integration/nop-integration-oss/src/main/java/io/nop/integration/oss/OssFileServiceClient.java @@ -68,9 +68,10 @@ protected String getBucketName(String remotePath) { } @Override - public void deleteFile(String remotePath) { + public boolean deleteFile(String remotePath) { remotePath = normalizePath(remotePath); client.deleteObject(getBucketName(remotePath), remotePath); + return true; } @Override diff --git a/nop-integration/nop-integration-sftp/src/main/java/io/nop/integration/sftp/SftpClient.java b/nop-integration/nop-integration-sftp/src/main/java/io/nop/integration/sftp/SftpClient.java index 60b79a949..fa54d6de9 100644 --- a/nop-integration/nop-integration-sftp/src/main/java/io/nop/integration/sftp/SftpClient.java +++ b/nop-integration/nop-integration-sftp/src/main/java/io/nop/integration/sftp/SftpClient.java @@ -179,7 +179,7 @@ public void downloadToStream(String remotePath, OutputStream out) { } } - public void deleteFile(String remotePath) { + public boolean deleteFile(String remotePath) { LOG.info("nop.sftp.delete:remotePath={}", remotePath); try { channel.rm(remotePath); @@ -187,6 +187,7 @@ public void deleteFile(String remotePath) { throw new NopException(ERR_SFTP_DELETE_FILE_FAIL, e) .param(ARG_REMOTE_PATH, remotePath); } + return true; } /**