服务请求附件:从上传到预览、下载的实现详解

10 阅读4分钟

1. 前端:如何拿到 OSS 临时凭证并初始化客户端

浏览器不持有长期密钥,先调业务接口 oss/sts,再把返回的临时凭证交给 ali-oss

API 定义:

// 获取临时oss的 sts token
export const getOssSTSTokenApi = () =>
  ajax({
    url: 'oss/sts'
  })

getStsTokenlocalStorage 缓存(按 expiration 判断是否复用),避免每次上传都打 STS:

// ali-oss获取临时凭证
export async function getStsToken() {
  // 先从存储里获取
  const _storageSts = localStorage.getItem('sts') || ''
  const _expiration = _storageSts && JSON.parse(_storageSts).expiration
  if (_expiration && new Date().getTime() < new Date(_expiration).getTime()) {
    return JSON.parse(_storageSts)
  } else {
    const { data } = await getOssSTSTokenApi()
    const _sts = data.credentials
    localStorage.setItem('sts', JSON.stringify(_sts))
    return _sts
  }
}
// ali-oss初始化
export function getBucketName(isPublic?: boolean) {
  return isPublic ? process.env.VUE_APP_BUCKET_PUBLIC : process.env.VUE_APP_BUCKET_PRIVATE
}
export async function initOss(isPublic?: boolean) {
  const _sts = await getStsToken()
  const _bucket = getBucketName(isPublic)
  const _ossClient = new OSS({
    region: 'oss-cn-beijing',
    bucket: _bucket,
    accessKeyId: _sts.accessKeyId,
    accessKeySecret: _sts.accessKeySecret,
    stsToken: _sts.securityToken
  })
  return _ossClient
}

下载私有对象时,同一套客户端用 signatureUrl 生成短期 GET 链接(并带 content-disposition):

// 下载文件
export async function downloadFile(url: string, title: string, haveExt?: boolean) {
  const ossClient = await initOss(false)
  if (!haveExt) title += url.substring(url.lastIndexOf('.'))
  const response = {
    'content-disposition': `attachment; filename=${title}`
  }

  // 尝试匹配OSS路径,如果匹配失败则使用完整URL
  const _pathMatch = url.match(/(product|patch|tool|fault|sr|plan|asset|doc|task).*/)
  let _path = ''

  if (_pathMatch && _pathMatch[0]) {
    _path = _pathMatch[0]
  } else {
    // ...
  }

  try {
    const _downurl = ossClient.signatureUrl(_path, { response })
    const a = document.createElement('a')
    a.href = _downurl
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
  } catch (error) {
    // ...
  }
}

2. 后端:STS 接口实现

Controller 将 AssumeRoleResponse 序列化为 JSON 返回给前端(前端再取 credentials):

    @ApiOperation("获得STS")
    @GetMapping("/sts")
    public String getStsToken() {
        AssumeRoleResponse response = ossService.getStsToken();
        return new Gson().toJson(response);
    }

OssService.getStsToken 使用阿里云 STS AssumeRole

    /**
     * STS方式访问
     */
    public AssumeRoleResponse getStsToken() {
        IAcsClient acsClient = getAcsClient();
        //构造请求,设置参数。
        AssumeRoleRequest request = new AssumeRoleRequest();
        request.setSysRegionId(OSS_REGION_ID);
        request.setRoleArn(ROLE_ARN);
        request.setRoleSessionName(ROLE_SESSION_NAME);
        // 设置凭证有效时间
        request.setDurationSeconds(1800L);

        AssumeRoleResponse response = null;
        //发起请求,并得到响应。
        try {
            response = acsClient.getAcsResponse(request);
        } catch (com.aliyuncs.exceptions.ClientException e) {
            log.error("ErrCode:{}", e.getErrCode());
            log.error("ErrMsg:{}", e.getErrMsg());
            log.error("RequestId:{}", e.getRequestId());
        } finally {
            acsClient.shutdown();
        }
        return response;
    }

3. 可选:预签名 PUT 与预签名下载(另一路直传/读)

与 STS SDK 上传并列,后端还提供 PUT 预签名上传GET 预签名下载

    @ApiOperation("获取预签名上传URL(客户端直传OSS)")
    @GetMapping("/presign")
    public Map<String, String> getPresignedPutUrl(
            @RequestParam String ext,
            @RequestParam(defaultValue = "IMAGE_BASE") String type,
            @RequestParam(required = false) Integer srId) {
        OssPathEnum pathEnum = Arrays.stream(OssPathEnum.values())
                .filter(e -> e.name().equalsIgnoreCase(type))
                .findFirst()
                .orElse(OssPathEnum.IMAGE_BASE);
        return ossService.generatePresignedPutUrl(ext, pathEnum, srId);
    }

    @ApiOperation("获取预签名下载URL(私有文件临时访问)")
    @GetMapping("/download-url")
    public Map<String, String> getPresignedDownloadUrl(@RequestParam String url) {
        String presignedUrl = ossService.getPrivateUrl(url, null);
        Map<String, String> result = Maps.newHashMap();
        result.put("url", presignedUrl);
        return result;
    }

预签名 PUT 的路径规则(srId固定 sr/{id}/uuid.ext,否则用枚举前缀):

    public Map<String, String> generatePresignedPutUrl(String ext, OssPathEnum pathEnum, Integer srId) {
        String objectName;
        if (srId != null && srId > 0) {
            objectName = "sr/" + srId + "/" + UUID.randomUUID() + "." + ext;
        } else {
            objectName = pathEnum.getPath() + UUID.randomUUID() + "." + ext;
        }
        String bucketName = getBucketName(pathEnum);

        Date expiration = new Date(System.currentTimeMillis() + 5 * 60 * 1000);
        GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, objectName, HttpMethod.PUT);
        request.setExpiration(expiration);

        OSS ossClient = getOssClient();
        String uploadUrl = ossClient.generatePresignedUrl(request).toString();
        ossClient.shutdown();

        String publicUrl = "https://" + bucketName + "." + ENDPOINT.replace("https://", "") + objectName;

        Map<String, String> result = new LinkedHashMap<>();
        result.put("uploadUrl", uploadUrl);
        result.put("objectKey", objectName);
        result.put("publicUrl", publicUrl);
        return result;
    }

私有读:getPrivateUrl 用 OSS SDK 生成带过期时间的 GET URL(可带图片处理 style),视频截帧封面会用到:

    /**
     * 根据公开链接获取临时链接
     */
    public String getPrivateUrl(String publicUrl, String style) {
        if (StringUtils.isBlank(publicUrl)) {
            return null;
        }
        OSS ossClient = getOssClient();
        String privateUrl = null;
        try {
            publicUrl = decode(publicUrl, "UTF-8");
            publicUrl = decode(publicUrl, "UTF-8");
            Date expiration = new Date(System.currentTimeMillis() + 3600 * 1000);
            GeneratePresignedUrlRequest request = getGeneratePresignedUrlRequest(publicUrl, style, expiration);
            privateUrl = ossClient.generatePresignedUrl(request).toString();
            ossClient.shutdown();
            if (envService.isProd()) {
                privateUrl = StringUtils.replace(privateUrl, ALIYUN_URL, PROD_URL);
            }
        } catch (ClientException | UnsupportedEncodingException ce) {
            log.error("Error Message: {}", ce.getMessage());
        } finally {
            ossClient.shutdown();
        }
        return privateUrl;
    }

4. 前端:通用封装 useOssClient(按 path 上传)

详情页回复等场景用 onUpload(\sr/${faultId}`)` 拼对象前缀:

  const initOssClient = async () => {
    ossClient = await initOss(false)
  }
  initOssClient()

  const onUpload = async (path: string) => {
    if (file) {
      try {
        const _guid = guid()
        const _pathname = `${path}/${_guid}.${fileInfo.value.fileExt}`
        const result = await ossClient.multipartUpload(_pathname, file, {
          progress: function (p: any) {
            progress.value = Math.floor(p * 100)
          }
        })
        const _url = result.res.requestUrls[0]
        return _url.split('?')[0]
      } catch (e) {
        console.log(e)
      }
    }
  }

5. 服务请求表单:先保存拿 ID,再 sr/{id} 上传并 enclosure/save

页面加载时初始化 OSS 客户端;提交时 savePayload 里附件只带已有 downloadUrl 的项,本地待传文件用变量 file 在保存成功后再传:

const initOssClient = async () => {
  ossClient = await initOss(false)
}
initOssClient()
  const pendingLocalFile = file
  const savePayload = {
    ...breakInfo.value,
    enclosureList: (breakInfo.value.enclosureList || []).filter((item: any) => Boolean(item.downloadUrl))
  }

  state.loading = true
  progress.value = 0
  try {
    const { data } = isHistory.value ? await saveHistoryBreakApi(savePayload) : await saveBreakApi(savePayload)

    if (!data.success) {
      cbSuccess(data)
      return
    }

    if (pendingLocalFile) {
      const faultId = Number(breakInfo.value.id) || Number(route.query.id) || Number(data.operateCallBackObj)

      if (!faultId) {
        Message('保存成功但未获取服务请求编号,附件未上传,请稍后重试或联系管理员')
        cbSuccess(data, () => {
          if (route.query.id) router.push(`/service/request/${breakInfo.value.id}`)
          else router.push('/serviceRecords')
        })
        return
      }

      try {
        const ext = state.enclosureList.fileExt || pendingLocalFile.name.split('.').pop() || ''
        const _pathname = `sr/${faultId}/${guid()}.${ext}`
        const result = await ossClient.multipartUpload(_pathname, pendingLocalFile, {
          progress: function (p: any) {
            progress.value = Math.floor(p * 100)
          }
        })
        const _url = String(result.res.requestUrls[0]).split('?')[0]
        const { data: encData } = await saveEnclosureApi({
          rid: faultId,
          title: state.enclosureList.title || pendingLocalFile.name,
          downloadUrl: _url,
          fileExt: ext,
          size: pendingLocalFile.size,
          type: 3
        })
        if (!encData.success) {
          Message('服务请求已保存,附件入库失败,请在详情页重新上传')
        }
      } catch {
        Message('服务请求已保存,附件上传失败,请在详情页重新上传')
      }
      resetFiles()
    }

    cbSuccess(data, () => {
      if (route.query.id) router.push(`/service/request/${breakInfo.value.id}`)
      else router.push('/serviceRecords')
    })

附件入库 API:

export const saveEnclosureApi = (data = {}): Res<OperateRes> =>
  ajax({
    url: 'enclosure/save',
    method: 'post',
    data: data
  })

后端 OssPathEnum.SR 与前端 sr/ 前缀对齐,供 IMM 路径匹配等逻辑使用:

    PLAN("plan/", false),
    /**
     *  故障
     */
    FAULT("fault/", false),
    /**
     * 服务请求附件(与前端 OSS 路径 sr/{faultId}/ 一致)
     */
    SR("sr/", false),

6. 后端:附件插入后立即异步触发预览

saveEnclosure 默认 preview=true,插入成功后调 immService.transformDoc2Preview

    @Override
    public OperationInfo<Object> saveEnclosure(Enclosure enclosure) {
        return saveEnclosure(enclosure,true);
    }

    public OperationInfo<Object> saveEnclosure(Enclosure enclosure,boolean preview) {
        enclosure.setCreatedBy(UserUtils.getCurrentUserId());
        enclosure.setCreatedTime(new Date());
        int insert = enclosureDao.insert(enclosure);
        if (insert == 0) return OperationInfo.failure();
        // 异步预览转换
        if (preview) {
            immService.transformDoc2Preview(enclosure.getId(), enclosure.getDownloadUrl());
        }

        return OperationInfo.success();
    }

故障/服务请求保存 一并写入的附件列表,在 saveEnclosureList 里每条插入后同样触发 IMM:

    private void saveEnclosureList(Integer rid, Integer createdBy, Date createdTime, List<Enclosure> enclosureList) {
        if (CollectionUtils.isNotEmpty(enclosureList)) {
            enclosureList.stream().filter(Objects::nonNull).filter(enclosure -> StringUtils.isNotBlank(enclosure.getDownloadUrl())).forEach(enclosure -> {
                enclosure.setRid(rid);
                enclosure.setType(CommentType.SERVICE.getCode());
                enclosure.setCreatedBy(createdBy);
                enclosure.setCreatedTime(new Date());
                enclosure.setCreatedTime(createdTime);
                enclosureDao.insert(enclosure);
                immService.transformDoc2Preview(enclosure.getId(), enclosure.getDownloadUrl());
            });
        }
    }

7. IMM 预览与封面:实现代码 walkthrough

入口:按图片 / 视频 / 其它文件分支;文档走 doTransform

    @Async
    public void transformDoc2Preview(Integer enclosureId, String httpUrl) {
        if (FileUtils.isImage(httpUrl)) {
            //图片封面就是本身
            updateEnclosure(enclosureId, httpUrl, httpUrl);
        } else if (FileUtils.isVideo(httpUrl)) {
            //截图获取视频封面
            String coverUrl = ossService.handlerVideoCoverImg(httpUrl);
            updateEnclosure(enclosureId, httpUrl, coverUrl);
        } else {
            doTransform(enclosureId, httpUrl);
        }
    }

文档:创建 IMM Office 转换任务CreateOfficeConversionTask),TgtTypevector;轮询 GetOfficeConversionTask不是 OSS 的 ListObjects 查转换状态)。成功后 previewUrlOssUtils.getTransformPath(httpUrl);封面再调 handlePreviewCover

    private void doTransform(Integer enclosureId, String httpUrl) {
        IAcsClient client = getClient();
        try {
            // 创建文档转换异步请求任务
            // url转化为oss路径
            String ossSrcPath = OssUtils.http2OssPath(httpUrl);
            // 设置转换后的输出路径
            String ossTargetPath = OssUtils.getTransformPath(ossSrcPath);
            CreateOfficeConversionTaskRequest req = getCreateOfficeConversionTaskRequest(ossSrcPath, ossTargetPath, "vector");
            CreateOfficeConversionTaskResponse resp = client.getAcsResponse(req);
            // 获取文档转换任务结果,最多轮询 30 次,轮询的间隔为 2 秒
            GetOfficeConversionTaskRequest getOfficeConversionTaskRequest = getGetOfficeConversionTaskRequest(resp);
            int count = 0;
            while (count < 30) {
                ThreadUtil.sleep(2000);
                GetOfficeConversionTaskResponse taskInfo = client.getAcsResponse(getOfficeConversionTaskRequest);
                String status = taskInfo.getStatus();
                if (!Objects.equals("Running", status)) {
                    if (Objects.equals("Finished", status)) {
                        //处理预览和预览封面
                        String previewUrl = OssUtils.getTransformPath(httpUrl);
                        String coverUrl = handlePreviewCover(client, httpUrl, previewUrl);
                        updateEnclosure(enclosureId, previewUrl, coverUrl);
                    } else {
                        log.info("阿里云文档预览转换失败,enclosureId:{},url:{},FailDetail:{}", enclosureId, httpUrl, JSON.toJSONString(taskInfo.getFailDetail()));
                    }
                    break;
                }
                count++;
            }
            if (count >= 30) {
                log.error("阿里云文档预览转换超时,enclosureId:{},url:{}", enclosureId, httpUrl);
            }
        } catch (Exception ce) {
            log.error("阿里云文档预览转换异常,enclosureId:{},url:{}", enclosureId, httpUrl, ce);
        } finally {
            client.shutdown();
        }
    }

HTTP URL → OSS URI、以及 transform/ 插入规则(IMM 的 srcUri/tgtUri 依赖前者;预览 URL 规则依赖后者):

    /**
     * 将http路径转成oss协议路径
     * @param httpUrl http://oss.aliyuncs.com/plan/7ca72a6f-5674-4bca-aa75-5aa1abe2cef2.java
     * @return oss://oss/plan/7ca72a6f-5674-4bca-aa75-5aa1abe2cef2.java
     */
    public static String http2OssPath(String httpUrl) {
        String path = RegExUtils.replacePattern(httpUrl, "(http|https)://", "oss://");
        return StringUtils.replace(path, "." + IAliyunConfig.ALIYUN_URL, "")
                .replace("." + IAliyunConfig.PROD_URL, "");
    }
    // ... http2OssInfo ...

    public static String getTransformPath(String ossSrcPath) {
        OssPathEnum pathEnum = OssPathEnum.getEnumWithContainPath(ossSrcPath);
        String path = pathEnum.getPath();
        return RegExUtils.replacePattern(ossSrcPath, path, path + IAliyunConfig.TRANSFORM_SUFFIX);
    }

封面:第二道 IMM 任务,源仍是原文件,目标为预览路径下的 /cover,只转第 1 页为 jpg;Excel 与普通文档返回路径后缀不同。

    public String handlePreviewCover(IAcsClient client, String httpUrl, String previewUrl) {
        String coverUrl = null;
        String coverPath = getCoverPath(httpUrl);
        //将原文件的第一页转成jpg当作封面
        // url转化为oss路径
        String ossSrcPath = OssUtils.http2OssPath(httpUrl);
        // 设置转换后的输出路径
        String ossTargetPath = OssUtils.http2OssPath(previewUrl) + "/cover";
        CreateOfficeConversionTaskRequest request = getCreateOfficeConversionTaskRequest(ossSrcPath, ossTargetPath, "jpg");
        request.setStartPage(1L);
        request.setEndPage(1L);
        try {
            CreateOfficeConversionTaskResponse resp = client.getAcsResponse(request);
            // 获取文档转换任务结果,最多轮询 30 次,轮询的间隔为 2 秒
            GetOfficeConversionTaskRequest getOfficeConversionTaskRequest = getGetOfficeConversionTaskRequest(resp);
            int count = 0;
            while (count < 30) {
                ThreadUtil.sleep(2000);
                GetOfficeConversionTaskResponse taskInfo = client.getAcsResponse(getOfficeConversionTaskRequest);
                String status = taskInfo.getStatus();
                if (!Objects.equals("Running", status)) {
                    if (Objects.equals("Finished", status)) {
                        coverUrl = previewUrl + coverPath;
                    } else {
                        log.info("阿里云文档预览封面转换失败,url:{},FailDetail:{}", httpUrl, JSON.toJSONString(taskInfo.getFailDetail()));
                    }
                    break;
                }
                count++;
            }
            // ...
        } catch (Exception ce) {
            log.error("阿里云文档预览封面转换异常,url:{}", httpUrl, ce);
        }
        return coverUrl;
    }

    private String getCoverPath(String httpUrl) {
        if (FileUtils.isExcel(httpUrl)) {
            return "/cover/s1/1.jpg";
        }
        return "/cover/1.jpg";

    }

写回数据库

    private void updateEnclosure(Integer enclosureId, String previewUrl, String coverUrl) {
        Enclosure enclosure = new Enclosure();
        enclosure.setId(enclosureId);
        enclosure.setPreviewUrl(previewUrl);
        enclosure.setCoverUrl(coverUrl);
        enclosureService.updateById(enclosure);
    }

视频封面:OSS 处理参数截帧 → 读流 → 再 uploadtransform 路径下的 cover.jpeg

    public String handlerVideoCoverImg(String httpUrl) {
        String style = "video/snapshot,t_3000,f_jpg,w_0,h_0,m_fast";
        String privateUrl = getPrivateUrl(httpUrl, style);
        InputStream ins;
        try {
            URL url = new URL(privateUrl);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            ins = conn.getInputStream();
        } catch (IOException e) {
            throw new EmcsCustomException(e);
        }
        OssInfo ossInfo = OssUtils.http2OssInfo(httpUrl);
        String transformPath = OssUtils.getTransformPath(ossInfo.getObjName());
        String coverObjName = transformPath + "/cover.jpeg";
        return upload(ossInfo.getBucketName(), coverObjName, ins);
    }

8. 串成一条时间线

  1. 用户打开表单 → initOssClientinitOssgetStsToken
  2. 用户提交 → saveBreakApi / saveHistoryBreakApisavePayload 过滤无 downloadUrl 的占位附件)。
  3. 若有本地文件 → multipartUploadsr/{faultId}/...saveEnclosureApiEnclosureServiceImpl.saveEnclosure@Async transformDoc2Preview
  4. IMM 完成 → updateEnclosure 写入 previewUrl / coverUrl
  5. 用户下载 → 前端 downloadFile 或后端 getPrivateUrl / /oss/download-url,用 短期签名 URL 访问 OSS。