0. 涉及的主要模块
| 层级 | 路径 / 类 |
|---|---|
| 前端 STS + 上传 | service-front/src/utils/util.ts、compositions/ossClient.ts、views/sr/Break.vue |
| 前端 API | service-front/src/apis/resource.ts、apis/manage.ts |
| 网关 OSS 接口 | enmo_support/.../OssController.java |
| STS / 预签名 / 私有 URL | enmo_support/.../OssService.java |
| 路径与 IMM 输入 | enmo_support/.../OssUtils.java、OssPathEnum.java |
| 附件入库与触发预览 | EnclosureServiceImpl、FaultServiceImpl.saveEnclosureList |
| 预览与封面 | ImmService.java |
1. 前端:如何拿到 OSS 临时凭证并初始化客户端
浏览器不持有长期密钥,先调业务接口 oss/sts ,再把返回的临时凭证交给 ali-oss。
API 定义:
37:41:D:\mes\service-front\src\apis\resource.ts
// 获取临时oss的 sts token
export const getOssSTSTokenApi = () =>
ajax({
url: 'oss/sts'
})
getStsToken 带 localStorage 缓存 (按 expiration 判断是否复用),避免每次上传都打 STS:
157:186:D:\mes\service-front\src\utils\util.ts
// 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):
187:242:D:\mes\service-front\src\utils\util.ts
// 下载文件
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):
53:58:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\controller\OssController.java
@ApiOperation("获得STS")
@GetMapping("/sts")
public String getStsToken() {
AssumeRoleResponse response = ossService.getStsToken();
return new Gson().toJson(response);
}
OssService.getStsToken 使用阿里云 STS AssumeRole (地域、RAM 角色 ARN、会话名、有效期等从配置接口读取;勿在文章或公开仓库中粘贴真实 ARN/AK):
255:280:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\service\OssService.java
/**
* 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 预签名下载:
60:80:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\controller\OssController.java
@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,否则用枚举前缀):
105:135:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\service\OssService.java
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),视频截帧封面会用到:
61:87:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\service\OssService.java
/**
* 根据公开链接获取临时链接
*/
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}`)` 拼对象前缀:
46:67:D:\mes\service-front\src\compositions\ossClient.ts
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 在保存成功后再传:
1006:1009:D:\mes\service-front\src\views\sr\Break.vue
const initOssClient = async () => {
ossClient = await initOss(false)
}
initOssClient()
1121:1178:D:\mes\service-front\src\views\sr\Break.vue
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:
570:575:D:\mes\service-front\src\apis\manage.ts
export const saveEnclosureApi = (data = {}): Res<OperateRes> =>
ajax({
url: 'enclosure/save',
method: 'post',
data: data
})
后端 OssPathEnum.SR 与前端 sr/ 前缀对齐,供 IMM 路径匹配等逻辑使用:
37:45:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\service\OssPathEnum.java
PLAN("plan/", false),
/**
* 故障
*/
FAULT("fault/", false),
/**
* 服务请求附件(与前端 OSS 路径 sr/{faultId}/ 一致)
*/
SR("sr/", false),
6. 后端:附件插入后立即异步触发预览
saveEnclosure 默认 preview=true ,插入成功后调 immService.transformDoc2Preview:
87:103:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\workbench\service\Impl\EnclosureServiceImpl.java
@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:
438:449:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\workbench\service\Impl\FaultServiceImpl.java
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。
132:145:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\service\ImmService.java
@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),TgtType 为 vector ;轮询 GetOfficeConversionTask (不是 OSS 的 ListObjects 查转换状态)。成功后 previewUrl 用 OssUtils.getTransformPath(httpUrl) ;封面再调 handlePreviewCover。
147:185:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\service\ImmService.java
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 规则依赖后者):
17:52:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\utils\OssUtils.java
/**
* 将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 与普通文档返回路径后缀不同。
200:245:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\service\ImmService.java
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";
}
写回数据库:
256:262:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\service\ImmService.java
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 处理参数截帧 → 读流 → 再 upload 到 transform 路径下的 cover.jpeg:
377:393:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\aliyun\service\OssService.java
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. 串成一条时间线(便于对照代码)
- 用户打开表单 →
initOssClient→initOss→getStsToken。 - 用户提交 →
saveBreakApi/saveHistoryBreakApi(savePayload过滤无downloadUrl的占位附件)。 - 若有本地文件 →
multipartUpload到sr/{faultId}/...→saveEnclosureApi→EnclosureServiceImpl.saveEnclosure→@Async transformDoc2Preview。 - IMM 完成 →
updateEnclosure写入previewUrl/coverUrl。 - 用户下载 → 前端
downloadFile或后端getPrivateUrl//oss/download-url,用 短期签名 URL 访问 OSS。
9. 安全与工程卫生(必读)
- 仓库中若仍存在明文 AccessKey / Secret (例如历史代码里的
IAliyunConfig),应迁移到 环境变量、KMS、RAM 角色 等,并轮换密钥;本文刻意不引用该配置文件全文。 - STS 的 RAM 角色最小权限 、IMM 项目绑定 Bucket 、跨域 CORS (若浏览器直传 OSS)需在运维侧与代码中的 region、bucket、endpoint 一致。