Skip to content

Commit

Permalink
增加文档。修正下载时权限验证
Browse files Browse the repository at this point in the history
  • Loading branch information
entropy-cloud committed Aug 13, 2023
1 parent 53e5cfc commit f28333c
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 14 deletions.
6 changes: 5 additions & 1 deletion docs/dev-guide/graphql/graphql-java.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,4 +345,8 @@ NopAuthDept_findList{
}
```
`@TreeChild(max=5)`表示按照本层的结构最多嵌套5层。
`@TreeChild(max=5)`表示按照本层的结构最多嵌套5层。
## 文件上传下载
参见[upload.md](upload.md)
153 changes: 150 additions & 3 deletions docs/dev-guide/graphql/upload.md
Original file line number Diff line number Diff line change
@@ -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<ResponseEntity<Object>> upload(MultipartFile file, HttpServletRequest request) {
String locale = ContextProvider.currentLocale();
CompletionStage<ApiResponse<?>> 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 <T> ApiRequest<T> buildApiRequest(HttpServletRequest req, T data) {
ApiRequest<T> ret = new ApiRequest<>();
Enumeration<String> 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数据模型

Expand Down Expand Up @@ -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设置会自动为字段选择对应的编辑和显示控件。
Expand All @@ -55,7 +202,7 @@
}
````

下载链接为 /f/download/{fileId}
下载链接的格式为 /f/download/{fileId}

在meta的prop节点上,可以配置以下属性:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33158,6 +33158,14 @@
"java.lang.String"
]
},
{
"name": "newPath",
"parameterTypes": [
"java.lang.String",
"java.lang.String",
"java.lang.String"
]
},
{
"name": "saveFile",
"parameterTypes": [
Expand All @@ -33171,6 +33179,12 @@
"io.nop.dao.api.IDaoProvider"
]
},
{
"name": "setKeepFileExt",
"parameterTypes": [
"boolean"
]
},
{
"name": "setLocalDir",
"parameterTypes": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ public CompletionStage<ResponseEntity<Object>> 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 <T> ApiRequest<T> buildRequest(HttpServletRequest req, T data) {
protected <T> ApiRequest<T> buildApiRequest(HttpServletRequest req, T data) {
ApiRequest<T> ret = new ApiRequest<>();
Enumeration<String> it = req.getHeaderNames();
while (it.hasMoreElements()) {
Expand All @@ -71,7 +71,7 @@ public CompletionStage<ResponseEntity<Object>> download(@PathVariable("fileId")
DownloadRequestBean req = new DownloadRequestBean();
req.setFileId(fileId);
req.setContentType(contentType);
CompletionStage<ApiResponse<WebContentBean>> future = downloadAsync(buildRequest(request, req));
CompletionStage<ApiResponse<WebContentBean>> future = downloadAsync(buildApiRequest(request, req));
return future.thenApply(res -> {
if (!res.isOk()) {
int status = res.getHttpStatus();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
public interface IFileServiceClient extends AutoCloseable {
List<FileStatus> listFiles(String remotePath);

void deleteFile(String remotePath);
boolean deleteFile(String remotePath);

FileStatus getFileStatus(String remotePath);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,15 @@ 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);
} catch (Exception e) {
throw new NopException(ERR_SFTP_DELETE_FILE_FAIL, e)
.param(ARG_REMOTE_PATH, remotePath);
}
return true;
}

/**
Expand Down

0 comments on commit f28333c

Please sign in to comment.