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

0. 涉及的主要模块

层级 路径 / 类
前端 STS + 上传 service-front/src/utils/util.tscompositions/ossClient.tsviews/sr/Break.vue
前端 API service-front/src/apis/resource.tsapis/manage.ts
网关 OSS 接口 enmo_support/.../OssController.java
STS / 预签名 / 私有 URL enmo_support/.../OssService.java
路径与 IMM 输入 enmo_support/.../OssUtils.javaOssPathEnum.java
附件入库与触发预览 EnclosureServiceImplFaultServiceImpl.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'
  })

getStsTokenlocalStorage 缓存 (按 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),TgtTypevector ;轮询 GetOfficeConversionTask不是 OSS 的 ListObjects 查转换状态)。成功后 previewUrlOssUtils.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 处理参数截帧 → 读流 → 再 uploadtransform 路径下的 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. 串成一条时间线(便于对照代码)

  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。

9. 安全与工程卫生(必读)

  • 仓库中若仍存在明文 AccessKey / Secret (例如历史代码里的 IAliyunConfig),应迁移到 环境变量、KMS、RAM 角色 等,并轮换密钥;本文刻意不引用该配置文件全文。
  • STS 的 RAM 角色最小权限 、IMM 项目绑定 Bucket跨域 CORS (若浏览器直传 OSS)需在运维侧与代码中的 region、bucket、endpoint 一致。
相关推荐
程序员辉哥1 小时前
从零构建Agent智能体系列01-从零理解智能体
后端·openai·ai编程
客场消音器2 小时前
我用两周半 Vibe Coding 做了一个前额叶训练的微信小程序
前端·javascript·后端
杨凯凡2 小时前
【032】排查入门:jstack、heap dump、Arthas 初识
java·开发语言·后端
铁皮饭盒3 小时前
成为AI全栈 - 第4课:Drizzle ORM SQLite Elysia 数据库实战
前端·后端
用户0534369380733 小时前
# LangChainRust Agent 引擎:Graph 构建到执行
后端
彭于晏Yan3 小时前
Spring Boot 聚合MongoDB查询
spring boot·后端·mongodb
Nyarlathotep01133 小时前
并发集合类(3):LinkedBlockingQueue
java·后端
Apifox3 小时前
Apifox 近期更新|AI Agent Debugger、A2A Debugger、Postman API 导入、Ask AI 侧边栏对话
前端·人工智能·后端
知识浅谈4 小时前
面向方面编程(AOP)VS 面向对象编程(OOP)
后端