import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; /** * SAP C4C 附件下载工具 - Java 调用封装 * *

通过 ProcessBuilder 调用 Python 脚本 sap-c4c-AttachmentFolder.py, * 以 --json 模式获取结构化结果。

* *

前置条件

*
 *   pip install requests scrapling[all] playwright
 *   python -m playwright install chromium
 * 
* *

使用示例

*
{@code
 * C4CAttachmentDownloader downloader = new C4CAttachmentDownloader(
 *     "/path/to/sap-c4c-AttachmentFolder.py",
 *     "https://xxx.c4c.saphybriscloud.cn",
 *     "admin",
 *     "password"
 * );
 *
 * // 仅列出附件清单
 * C4CAttachmentDownloader.Result result = downloader.listAttachments("24588");
 *
 * // 下载全部附件到指定目录
 * C4CAttachmentDownloader.Result result = downloader.download("24588", "/tmp/ticket_24588");
 * for (C4CAttachmentDownloader.DownloadedFile f : result.getDownloadedFiles()) {
 *     System.out.println(f.getSavedPath());
 * }
 *
 * // 下载附件并上传到群晖 DSM
 * downloader.setDsmConfig("http://10.0.10.235:5000", "PLM", "123456", "/Newgonow/AU-SPFJ");
 * C4CAttachmentDownloader.Result result = downloader.download("24588", "/tmp/ticket_24588");
 * for (C4CAttachmentDownloader.DsmUploadEntry u : result.getDsmUpload()) {
 *     System.out.println(u.getFile() + " -> " + u.getRemotePath() + " success=" + u.isSuccess());
 * }
 * }
*/ // [2026-03-12 11:46:10] INFO: Fetched (200) (referer: https://www.google.com/) // [2026-03-12 11:46:24] INFO: Fetched (200) (referer: https://www.google.com/) // [2026-03-12 11:46:40] INFO: Fetched (200) (referer: https://www.google.com/) // [2026-03-12 11:46:56] INFO: Fetched (200) (referer: https://www.google.com/) // { // "ticketId": "24588", // "outputDir": "/tmp/att", // "success": true, // "error": null, // "srAttachments": [ // { // "UUID": "FA163E87-49F0-1FD0-A0C8-22793B4E3F77", // "ObjectID": "FA163E8749F01FD0A0C822793B4E3F77", // "ParentObjectID": "FA163E8749F01FD0A0C822793B4EDF77", // "FileName": "Quotation", // "MimeType": "application/octet-stream", // "CategoryCode": "3", // "LinkWebURI": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ3B/4JQvOAb263K5kCW8sIAbYT.nFMeW8z7hT9QS4h0P9Mg", // "DocumentLink": "", // "SizeInkB": "0.00000000000000" // }, // { // "UUID": "FA163E87-49F0-1FD0-A0C8-22793B4E5F77", // "ObjectID": "FA163E8749F01FD0A0C822793B4E5F77", // "ParentObjectID": "FA163E8749F01FD0A0C822793B4EDF77", // "FileName": "Proof of Purchase", // "MimeType": "application/octet-stream", // "CategoryCode": "3", // "LinkWebURI": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ4n/ppCBH.gcnV2K.3qCA_U9ZDtZ9r_UffyJGEuteuWMFAE", // "DocumentLink": "", // "SizeInkB": "0.00000000000000" // }, // { // "UUID": "FA163E87-49F0-1FD0-A0C8-22793B4E7F77", // "ObjectID": "FA163E8749F01FD0A0C822793B4E7F77", // "ParentObjectID": "FA163E8749F01FD0A0C822793B4EDF77", // "FileName": "Chassis Number", // "MimeType": "application/octet-stream", // "CategoryCode": "3", // "LinkWebURI": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ6P/mDuF2NKyP96SGjWzuxXl7eejFb84ORKtcG0bPUAqrsg", // "DocumentLink": "", // "SizeInkB": "0.00000000000000" // } // ], // "issueItems": [ // { // "objectId": "FA163E8749F01FD0A0C822793B4F1F77", // "uuid": "FA163E87-49F0-1FD0-A0C8-228700443F77", // "description": "Camera not paring. tried 4 times not working", // "attachments": [ // { // "UUID": "FA163E82-A315-1FD0-A2C0-35B6AE95B9BA", // "ObjectID": "FA163E82A3151FD0A2C035B6AE95B9BA", // "ParentObjectID": "FA163E8749F01FD0A0C8228700449F77", // "FileName": "IMG_4307.mp4", // "MimeType": "video/mp4", // "CategoryCode": "2", // "LinkWebURI": "", // "DocumentLink": "https://my300375.c4c.saphybriscloud.cn/sap/c4c/odata/cust/v1/custticketapi/BO_XSRIssueItemAttachmentCollection('FA163E8749F01FD0A0C8228700449F77')/BO_XSRIssueItemAttachmentFolder('FA163E82A3151FD0A2C035B6AE95B9BA')/Binary/$value", // "SizeInkB": "2997017.00000000000000" // }, // { // "UUID": "FA163E87-49F0-1FD0-A0C8-22870048FF77", // "ObjectID": "FA163E8749F01FD0A0C822870048FF77", // "ParentObjectID": "FA163E8749F01FD0A0C8228700449F77", // "FileName": "PD Camera video.docx", // "MimeType": "application/octet-stream", // "CategoryCode": "3", // "LinkWebURI": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EWRu/v46Grw_mnbsjKrU1LlWrN7Ysh9vT5iOydQbr0nSYsxw", // "DocumentLink": "", // "SizeInkB": "0.00000000000000" // } // ] // } // ], // "downloadedFiles": [ // { // "source": "SR", // "c4cName": "Quotation", // "type": "link", // "linkUrl": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ3B/4JQvOAb263K5kCW8sIAbYT.nFMeW8z7hT9QS4h0P9Mg", // "savedPath": "/tmp/att/Warranty Quoting PD.xlsx", // "savedName": "Warranty Quoting PD.xlsx" // }, // { // "source": "SR", // "c4cName": "Proof of Purchase", // "type": "link", // "linkUrl": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ4n/ppCBH.gcnV2K.3qCA_U9ZDtZ9r_UffyJGEuteuWMFAE", // "savedPath": "/tmp/att/PD VIN.jpg", // "savedName": "PD VIN.jpg" // }, // { // "source": "SR", // "c4cName": "Chassis Number", // "type": "link", // "linkUrl": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ6P/mDuF2NKyP96SGjWzuxXl7eejFb84ORKtcG0bPUAqrsg", // "savedPath": "/tmp/att/PD Chassis.jpg", // "savedName": "PD Chassis.jpg" // }, // { // "source": "IssueItem-FA163E8749F0", // "c4cName": "PD Camera video.docx", // "type": "link", // "linkUrl": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EWRu/v46Grw_mnbsjKrU1LlWrN7Ysh9vT5iOydQbr0nSYsxw", // "savedPath": "/tmp/att/PD Camera video.docx", // "savedName": "PD Camera video.docx" // }, // { // "source": "IssueItem-FA163E8749F0", // "c4cName": "IMG_4307.mp4", // "type": "file", // "mime": "video/mp4", // "savedPath": "/tmp/att/IMG_4307.mp4", // "savedName": "IMG_4307.mp4" // } // ], // "srObjectId": "FA163E8749F01FD0A0C822793B4EDF77" // } public class C4CAttachmentDownloader { private final String pythonCmd; private final String scriptPath; private final String tenant; private final String username; private final String password; private final long timeoutMinutes; // 群晖 DSM 配置(可选) private String dsmUrl; private String dsmUser; private String dsmPassword; private String dsmPath; /** * @param scriptPath Python 脚本的绝对路径 * @param tenant C4C 租户地址 (如 https://xxx.c4c.saphybriscloud.cn) * @param username C4C 用户名 * @param password C4C 密码 */ public C4CAttachmentDownloader(String scriptPath, String tenant, String username, String password) { this(scriptPath, tenant, username, password, "python3", 30); } /** * @param scriptPath Python 脚本的绝对路径 * @param tenant C4C 租户地址 * @param username C4C 用户名 * @param password C4C 密码 * @param pythonCmd Python 命令 (python3 / python) * @param timeoutMinutes 超时时间(分钟) */ public C4CAttachmentDownloader(String scriptPath, String tenant, String username, String password, String pythonCmd, long timeoutMinutes) { this.scriptPath = scriptPath; this.tenant = tenant; this.username = username; this.password = password; this.pythonCmd = pythonCmd; this.timeoutMinutes = timeoutMinutes; } /** * 设置群晖 DSM 上传配置(可选)。设置后,download() 会在下载完成后自动上传到 DSM。 * * @param dsmUrl DSM 地址 (如 http://10.0.10.235:5000) * @param dsmUser DSM 用户名 * @param dsmPassword DSM 密码 * @param dsmPath DSM 目标路径 (如 /Newgonow/AU-SPFJ) */ public void setDsmConfig(String dsmUrl, String dsmUser, String dsmPassword, String dsmPath) { this.dsmUrl = dsmUrl; this.dsmUser = dsmUser; this.dsmPassword = dsmPassword; this.dsmPath = dsmPath; } /** * 仅列出附件清单,不下载 */ public Result listAttachments(String ticketId) throws Exception { return execute(ticketId, null, true); } /** * 下载全部附件到默认 downloads 目录 */ public Result download(String ticketId) throws Exception { return execute(ticketId, null, false); } /** * 下载全部附件到指定目录 */ public Result download(String ticketId, String outputDir) throws Exception { return execute(ticketId, outputDir, false); } private Result execute(String ticketId, String outputDir, boolean listOnly) throws Exception { List cmd = new ArrayList<>(); cmd.add(pythonCmd); cmd.add(scriptPath); cmd.add("--ticket"); cmd.add(ticketId); cmd.add("--json"); if (outputDir != null && !outputDir.isEmpty()) { cmd.add("--output-dir"); cmd.add(outputDir); } if (listOnly) { cmd.add("--list-only"); } ProcessBuilder pb = new ProcessBuilder(cmd); // 凭证通过环境变量传递,比命令行参数更安全(不会出现在 ps 进程列表中) pb.environment().put("C4C_TENANT", tenant); pb.environment().put("C4C_USERNAME", username); pb.environment().put("C4C_PASSWORD", password); // DSM 配置通过环境变量传递 if (dsmUrl != null && !dsmUrl.isEmpty()) { pb.environment().put("DSM_URL", dsmUrl); pb.environment().put("DSM_USERNAME", dsmUser != null ? dsmUser : ""); pb.environment().put("DSM_PASSWORD", dsmPassword != null ? dsmPassword : ""); pb.environment().put("DSM_PATH", dsmPath != null ? dsmPath : ""); } pb.redirectErrorStream(false); Process process = pb.start(); String stdout; String stderr; try ( InputStream is = process.getInputStream(); InputStream es = process.getErrorStream() ) { stdout = new String(is.readAllBytes(), StandardCharsets.UTF_8); stderr = new String(es.readAllBytes(), StandardCharsets.UTF_8); } boolean finished = process.waitFor(timeoutMinutes, TimeUnit.MINUTES); if (!finished) { process.destroyForcibly(); throw new RuntimeException("Python 脚本执行超时 (" + timeoutMinutes + " 分钟)"); } int exitCode = process.exitValue(); if (stdout.isBlank()) { throw new RuntimeException("Python 脚本无输出, exitCode=" + exitCode + ", stderr=" + stderr); } ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(stdout); Result result = new Result(); result.setTicketId(root.path("ticketId").asText()); result.setOutputDir(root.path("outputDir").asText()); result.setSuccess(root.path("success").asBoolean()); result.setError(root.path("error").asText(null)); result.setSrObjectId(root.path("srObjectId").asText(null)); // SR 附件 for (JsonNode att : root.path("srAttachments")) { result.getSrAttachments().add(parseAttachment(att)); } // XIssueItem 附件 for (JsonNode item : root.path("issueItems")) { IssueItem ii = new IssueItem(); ii.setObjectId(item.path("objectId").asText()); ii.setUuid(item.path("uuid").asText()); ii.setDescription(item.path("description").asText()); for (JsonNode att : item.path("attachments")) { ii.getAttachments().add(parseAttachment(att)); } result.getIssueItems().add(ii); } // 已下载文件 for (JsonNode df : root.path("downloadedFiles")) { DownloadedFile f = new DownloadedFile(); f.setSource(df.path("source").asText()); f.setC4cName(df.path("c4cName").asText()); f.setType(df.path("type").asText()); f.setMime(df.path("mime").asText(null)); f.setLinkUrl(df.path("linkUrl").asText(null)); f.setSavedPath(df.path("savedPath").asText(null)); f.setSavedName(df.path("savedName").asText(null)); f.setError(df.path("error").asText(null)); result.getDownloadedFiles().add(f); } // DSM 上传结果 for (JsonNode du : root.path("dsmUpload")) { DsmUploadEntry entry = new DsmUploadEntry(); entry.setFile(du.path("file").asText()); entry.setRemotePath(du.path("remotePath").asText()); entry.setSuccess(du.path("success").asBoolean(false)); entry.setError(du.path("error").asText(null)); result.getDsmUpload().add(entry); } return result; } private Attachment parseAttachment(JsonNode node) { Attachment a = new Attachment(); a.setUuid(node.path("UUID").asText()); a.setObjectId(node.path("ObjectID").asText()); a.setFileName(node.path("FileName").asText()); a.setMimeType(node.path("MimeType").asText()); a.setCategoryCode(node.path("CategoryCode").asText()); a.setLinkWebURI(node.path("LinkWebURI").asText(null)); a.setDocumentLink(node.path("DocumentLink").asText(null)); String size = node.path("SizeInkB").asText(null); if (size != null && !size.isEmpty()) { try { a.setSizeInKB(Double.parseDouble(size)); } catch (NumberFormatException ignored) {} } return a; } // ==================== 数据模型 ==================== public static class Result { private String ticketId; private String outputDir; private boolean success; private String error; private String srObjectId; private List srAttachments = new ArrayList<>(); private List issueItems = new ArrayList<>(); private List downloadedFiles = new ArrayList<>(); private List dsmUpload = new ArrayList<>(); public String getTicketId() { return ticketId; } public void setTicketId(String ticketId) { this.ticketId = ticketId; } public String getOutputDir() { return outputDir; } public void setOutputDir(String outputDir) { this.outputDir = outputDir; } public boolean isSuccess() { return success; } public void setSuccess(boolean success) { this.success = success; } public String getError() { return error; } public void setError(String error) { this.error = error; } public String getSrObjectId() { return srObjectId; } public void setSrObjectId(String srObjectId) { this.srObjectId = srObjectId; } public List getSrAttachments() { return srAttachments; } public void setSrAttachments(List srAttachments) { this.srAttachments = srAttachments; } public List getIssueItems() { return issueItems; } public void setIssueItems(List issueItems) { this.issueItems = issueItems; } public List getDownloadedFiles() { return downloadedFiles; } public void setDownloadedFiles(List downloadedFiles) { this.downloadedFiles = downloadedFiles; } public List getDsmUpload() { return dsmUpload; } public void setDsmUpload(List dsmUpload) { this.dsmUpload = dsmUpload; } /** 获取全部附件(SR + 所有 IssueItem)的合并列表 */ public List getAllAttachments() { List all = new ArrayList<>(srAttachments); for (IssueItem ii : issueItems) { all.addAll(ii.getAttachments()); } return all; } } public static class Attachment { private String uuid; private String objectId; private String fileName; private String mimeType; private String categoryCode; // "2"=文件, "3"=链接 private String linkWebURI; private String documentLink; private double sizeInKB; public boolean isFile() { return "2".equals(categoryCode); } public boolean isLink() { return "3".equals(categoryCode); } public String getUuid() { return uuid; } public void setUuid(String uuid) { this.uuid = uuid; } public String getObjectId() { return objectId; } public void setObjectId(String objectId) { this.objectId = objectId; } public String getFileName() { return fileName; } public void setFileName(String fileName) { this.fileName = fileName; } public String getMimeType() { return mimeType; } public void setMimeType(String mimeType) { this.mimeType = mimeType; } public String getCategoryCode() { return categoryCode; } public void setCategoryCode(String categoryCode) { this.categoryCode = categoryCode; } public String getLinkWebURI() { return linkWebURI; } public void setLinkWebURI(String linkWebURI) { this.linkWebURI = linkWebURI; } public String getDocumentLink() { return documentLink; } public void setDocumentLink(String documentLink) { this.documentLink = documentLink; } public double getSizeInKB() { return sizeInKB; } public void setSizeInKB(double sizeInKB) { this.sizeInKB = sizeInKB; } } public static class IssueItem { private String objectId; private String uuid; private String description; private List attachments = new ArrayList<>(); public String getObjectId() { return objectId; } public void setObjectId(String objectId) { this.objectId = objectId; } public String getUuid() { return uuid; } public void setUuid(String uuid) { this.uuid = uuid; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public List getAttachments() { return attachments; } public void setAttachments(List attachments) { this.attachments = attachments; } } public static class DownloadedFile { private String source; // "SR" / "IssueItem-xxx" private String c4cName; // C4C 里的附件名 private String type; // "file" / "link" private String mime; private String linkUrl; private String savedPath; // 下载后的绝对路径 private String savedName; // 下载后的文件名 private String error; // 下载失败的错误信息 public boolean isOk() { return savedPath != null && error == null; } public String getSource() { return source; } public void setSource(String source) { this.source = source; } public String getC4cName() { return c4cName; } public void setC4cName(String c4cName) { this.c4cName = c4cName; } public String getType() { return type; } public void setType(String type) { this.type = type; } public String getMime() { return mime; } public void setMime(String mime) { this.mime = mime; } public String getLinkUrl() { return linkUrl; } public void setLinkUrl(String linkUrl) { this.linkUrl = linkUrl; } public String getSavedPath() { return savedPath; } public void setSavedPath(String savedPath) { this.savedPath = savedPath; } public String getSavedName() { return savedName; } public void setSavedName(String savedName) { this.savedName = savedName; } public String getError() { return error; } public void setError(String error) { this.error = error; } } public static class DsmUploadEntry { private String file; // 文件名 private String remotePath; // DSM 远程路径 private boolean success; private String error; public boolean isSuccess() { return success; } public String getFile() { return file; } public void setFile(String file) { this.file = file; } public String getRemotePath() { return remotePath; } public void setRemotePath(String remotePath) { this.remotePath = remotePath; } public void setSuccess(boolean success) { this.success = success; } public String getError() { return error; } public void setError(String error) { this.error = error; } } }