简介这份资源面向需要与金蝶苍穹平台做系统集成的Java开发者聚焦第三方系统向苍穹上传附件、引入业务数据的接口调用场景。压缩包共8个文件全部为java源码整体约13KB涵盖登录鉴权、HTTP通信、文件上传服务及带附件的远程操作等模块并附有苍穹侧自定义保存插件示例便于对照理解服务端与调用端的配合方式。内容围绕接口定义、身份验证、文件编码与异步处理、错误重试、数据安全及日志记录等关键环节展开可帮助读者快速理清上传附件的完整链路。目前已有325人学习下载适合作为接口对接与附件管理功能的参考实现用于搭建联调环境、排查上传失败问题或改造为自身业务的上传工具。1. 第三方系统往苍穹传附件这套 Java 案例到底能不能直接抄做过金蝶苍穹集成的同学大概率都遇到过这个场景MES 或者 WMS 那边生成了一张质检报告 PDF业务要求它必须挂到苍穹对应的质检单附件区里而不是丢一个文件服务器链接让人手动下载再上传。手动传一次两次还行订单一多就是纯体力活而且附件和业务单据的关联关系全靠人记迟早出错。这个「上传文件至金蝶苍穹平台.zip」就是冲着这个场景来的里面是一套完整的 Java 调用案例覆盖了从第三方系统登录苍穹、拿会话、调文件上传接口、再把附件和业务单据绑定的全链路。压缩包里能看到的类名很直白AppLoginService、UserLoginService负责身份认证HttpClientFactory、HttpService管 HTTP 通信FileUploadService和RemoteOperationWithAttachment处理文件上传与远程附件操作BizOperateService和BizCustomSaveWebApiPlugin则对应业务数据的保存与插件扩展点。换句话说它不是一篇讲原理的文档而是一份能跑起来的代码骨架。适合谁正在做苍穹和外围系统对接的 Java 后端尤其是被附件上传卡住、翻官方文档翻到怀疑人生的那批人。下面我按「先搞懂认证怎么走、再动手传文件、最后避坑」的顺序把它拆开讲。2. 苍穹接口认证链路AppLoginService 和 UserLoginService 到底怎么配合2.1 两套登录体系不是二选一而是分工很多人第一次看苍穹的接口文档会懵一会儿是应用级登录一会儿是用户级登录到底用哪个血泪经验是——它们不是替代关系而是配合关系。AppLoginService走的是应用身份用应用的 appId 和 appSecret 换取一个应用级会话这个会话决定了「你这个第三方系统有没有资格调苍穹的开放接口」。UserLoginService走的是用户身份用具体用户的账号密码或者第三方免登票据换取用户级会话它决定的是「这次操作是以谁的名义执行的数据权限归谁」。为什么必须两个都有因为苍穹的附件上传接口在鉴权时会同时校验应用权限和用户上下文。只拿应用会话去传文件接口可能返回「无权限操作该业务对象」只拿用户会话连接口网关都过不去。常见做法是应用会话在系统启动时获取一次并缓存用户会话按实际操作用户动态获取两者拼在一起放进请求头。2.2 登录调用的代码骨架与参数说明下面这段是AppLoginService里最核心的登录逻辑我按实际能跑通的结构整理了一下// AppLoginService.java 核心登录方法 public String appLogin(String appId, String appSecret, String tenantId) { // 1. 组装登录参数苍穹开放平台要求 form 表单格式 MapString, String params new HashMap(); params.put(appId, appId); // 应用唯一标识苍穹后台创建应用时生成 params.put(appSecret, appSecret); // 应用密钥注意不要硬编码在代码里 params.put(tenantId, tenantId); // 租户ID多租户环境下必须传对 params.put(language, zh_CN); // 语言影响返回消息的语种 // 2. 通过 HttpClientFactory 获取一个复用的 HttpClient 实例 CloseableHttpClient client HttpClientFactory.getInstance(); // 3. 发起 POST 请求到应用登录接口 String url https://你的苍穹域名/ierp/api/login/app; String response HttpService.postForm(client, url, params); // 4. 解析返回 JSON取出 sessionId 或 token JSONObject json JSON.parseObject(response); if (!json.getBooleanValue(success)) { throw new RuntimeException(应用登录失败: json.getString(message)); } return json.getJSONObject(data).getString(sessionId); }逻辑说明第一步组装参数时appSecret绝对不能写死在代码里我一般会放到配置中心或者环境变量这是审计红线。第二步HttpClientFactory的作用是复用连接池苍穹接口调用频繁每次 new 一个 HttpClient 会导致连接泄漏跑一段时间就报Too many open files。第三步的 URL 里/ierp/api/login/app是应用登录的标准路径不同版本可能有细微差异以你环境实际为准。第四步解析返回时一定要判success字段苍穹失败时 HTTP 状态码可能还是 200不看业务码会误判。UserLoginService的结构类似只是参数换成用户凭证返回的用户会话需要和应用会话一起放进后续请求的 header。我一般会封装一个SessionHolder把两个会话 ID 存起来设置过期时间快过期时自动刷新避免每次调用都重新登录。2.3 会话缓存与刷新策略会话不是永久有效的苍穹默认的应用会话有效期通常在几小时级别用户会话更短。如果每次上传文件都重新登录接口耗时直接翻倍而且频繁登录可能触发风控。常见做法是用一个带过期时间的本地缓存比如ConcurrentHashMap加时间戳或者直接上 Caffeine。刷新时机很关键不要等到接口返回「会话失效」才去刷新那样第一次失败的业务请求就丢了。我一般会在会话剩余有效期低于 20% 时主动异步刷新业务线程永远拿有效会话。3. 文件上传与附件绑定FileUploadService 和 RemoteOperationWithAttachment 的实操3.1 上传分两步先传文件拿 fileId再绑业务单据这是最容易翻车的地方。很多人以为调一个接口就能把文件挂到单据上实际上苍穹的设计是分离的第一步调文件上传接口把二进制流传上去苍穹返回一个fileId第二步调业务保存接口在单据的附件字段里引用这个fileId。两步之间如果断了文件就成了孤儿占着存储空间但没人引用。FileUploadService负责第一步RemoteOperationWithAttachment负责第二步的组装。先看上传// FileUploadService.java 文件上传核心逻辑 public String uploadFile(File file, String sessionId, String appSessionId) { // 1. 构建 multipart 请求 CloseableHttpClient client HttpClientFactory.getInstance(); HttpPost post new HttpPost(https://你的苍穹域名/ierp/api/file/upload); // 2. 设置双会话请求头缺一不可 post.setHeader(Cookie, sessionId sessionId ; appSessionId appSessionId); // 3. 包装文件体指定文件名和内容类型 MultipartEntityBuilder builder MultipartEntityBuilder.create(); builder.addBinaryBody(file, file, ContentType.APPLICATION_OCTET_STREAM, file.getName()); builder.addTextBody(bizType, quality_report); // 业务类型标识按实际场景填 post.setEntity(builder.build()); // 4. 执行并解析 fileId String response HttpService.execute(client, post); JSONObject json JSON.parseObject(response); return json.getJSONObject(data).getString(fileId); }参数说明bizType这个字段容易被忽略它决定了文件在苍穹文件服务里的分类传错了虽然能上传成功但后续在单据附件区可能查不到。file.getName()里的中文文件名要确认编码苍穹对文件名编码敏感乱码的话附件列表里显示会出问题。ContentType.APPLICATION_OCTET_STREAM是通用二进制流如果明确是 PDF 或图片用对应的 MIME 类型更规范。3.2 附件绑定到业务单据的字段映射拿到fileId之后RemoteOperationWithAttachment要做的是把它塞进业务单据的保存报文里。苍穹的单据附件字段通常是一个数组结构每个元素包含fileId、fileName、fileSize等属性。下面是一个典型的绑定报文构造// RemoteOperationWithAttachment.java 附件绑定逻辑 public void bindAttachment(String billId, String fileId, String fileName) { // 1. 构造业务单据的保存报文 JSONObject billData new JSONObject(); billData.put(id, billId); // 单据主键新增时为空更新时必填 // 2. 附件字段苍穹标准字段名一般是 attachmentpanel 或类似 JSONArray attachments new JSONArray(); JSONObject att new JSONObject(); att.put(fileId, fileId); att.put(fileName, fileName); att.put(type, attachment); // 固定值标识这是附件而非其他文件 attachments.add(att); billData.put(attachments, attachments); // 3. 调业务保存接口 String url https://你的苍穹域名/ierp/api/bill/save; String response HttpService.postJson(url, billData.toJSONString(), sessionId); // 4. 校验保存结果 JSONObject result JSON.parseObject(response); if (!result.getBooleanValue(success)) { throw new RuntimeException(附件绑定失败: result.getString(message)); } }逻辑说明附件字段的名称在不同单据上可能不一样有的叫attachments有的叫attachmentpanel这个必须去苍穹的元数据设计器里确认不能猜。billId在新增场景下为空苍穹会自动生成但附件绑定通常发生在单据已存在之后所以一般是更新操作。保存接口返回失败时message里会带具体原因比如「附件不存在」说明fileId无效「字段不存在」说明附件字段名写错了。3.3 BizCustomSaveWebApiPlugin 的扩展点用法BizCustomSaveWebApiPlugin这个类名透露了它的定位苍穹的业务保存 WebAPI 插件。它的作用是在单据保存前后插入自定义逻辑比如附件校验、数据补全。常见用法是继承苍穹的插件基类重写beforeSave或afterSave方法。在附件场景下我一般会在beforeSave里校验附件数量是否超限、文件大小是否合规在afterSave里记录附件绑定日志。这个插件是部署在苍穹服务端的不是第三方系统里别搞混了。4. 避坑与排查附件上传失败时先看这几个地方4.1 现象接口返回 200 但 success 为 false提示「会话无效」原因应用会话和用户会话的拼接方式不对或者会话已过期但缓存没刷新。苍穹的会话校验是双重的只传一个必失败。解决先单独调应用登录接口确认能拿到 sessionId再单独调用户登录接口两个都通了再拼一起。缓存过期时间设短一点比如应用会话设 30 分钟用户会话设 15 分钟宁可多刷几次也别用失效会话。4.2 现象文件上传成功但单据附件区看不到原因bizType传错或者附件绑定报文里的字段名和单据元数据不匹配。苍穹的文件服务和业务单据是两套存储fileId有效不代表绑定成功。解决去苍穹的附件管理后台按fileId搜一下能搜到说明文件在问题出在绑定环节。用元数据设计器确认附件字段的准确名称别用文档里的示例名硬套。4.3 现象大文件上传到一半超时报 SocketTimeoutException原因苍穹默认的接口超时时间比较短大文件传输时间不够。另外HttpClientFactory如果没配连接超时和读取超时默认值可能不适用。解决在HttpClientFactory里显式设置RequestConfig连接超时设 10 秒读取超时设 120 秒根据文件大小调整。超过 50MB 的文件建议走分块上传苍穹有对应的分片接口别硬传。4.4 现象中文文件名变成乱码附件列表显示问号原因HTTP 请求的编码没指定 UTF-8或者苍穹服务端的默认编码不是 UTF-8。解决在MultipartEntityBuilder里显式设置setCharset(StandardCharsets.UTF_8)请求头里加Content-Type: multipart/form-data; charsetUTF-8。如果还乱码检查文件名的 URL 编码有些环境需要先URLEncoder.encode再传。4.5 现象并发上传时部分请求失败日志显示连接池耗尽原因HttpClientFactory用了默认的连接池配置最大连接数太小高并发下请求排队直到超时。解决把PoolingHttpClientConnectionManager的maxTotal和defaultMaxPerRoute调大一般设 100 和 50 够用。同时确保每次请求后释放连接HttpService里要用 try-with-resources 或者手动close。5. 进阶技巧用日志和重试把上传成功率拉到 99% 以上附件上传这种事最怕的不是报错而是静默失败——接口返回成功但文件没挂上等业务发现的时候已经过了好几天。我现在的习惯是每次上传都打三条日志上传前记录文件名和大小上传后记录fileId绑定后记录单据 ID 和附件 ID 的对应关系。这三条日志用同一个 traceId 串起来出问题直接搜 traceId 就能还原整个链路。重试策略也有讲究。不是所有失败都值得重试会话失效可以重试重试前先刷新会话网络超时可以重试但要有次数上限我一般设 3 次间隔用指数退避第一次等 1 秒第二次 2 秒第三次 4 秒参数错误重试一万次也没用直接抛异常让业务处理。下面是一个简单的重试封装// 带指数退避的重试封装 public String uploadWithRetry(File file, String sessionId, String appSessionId) { int maxRetries 3; long delay 1000; // 初始延迟 1 秒 for (int i 0; i maxRetries; i) { try { return uploadFile(file, sessionId, appSessionId); } catch (SessionExpiredException e) { // 会话失效刷新后立即重试不等待 sessionId userLoginService.refreshSession(); continue; } catch (SocketTimeoutException e) { // 网络超时指数退避后重试 if (i maxRetries - 1) throw e; try { Thread.sleep(delay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } delay * 2; } catch (BizException e) { // 业务异常重试无意义直接抛出 throw e; } } throw new RuntimeException(上传重试次数耗尽); }这段代码的关键在于异常分类SessionExpiredException和SocketTimeoutException走重试BizException直接抛。很多人的重试逻辑不分异常类型结果参数错误也重试三次白白浪费 7 秒。另外Thread.sleep要处理中断异常恢复中断状态这是并发编程的基本功。验证上传是否真的成功不能只看接口返回。我一般会写一个对账任务每天跑一次把第三方系统里标记为「已上传」的记录和苍穹附件表做比对发现差异就告警。这个对账逻辑用 SQL 就能做不需要调接口效率高。从那以后我每次对接新的文件上传需求都强制走一遍「上传-绑定-对账」三步验证再也没出现过附件丢失的投诉。希望帮到你。本文还有配套的精品资源点击获取