Files
c4c-download/C4CAttachmentDownloader.java
afei A 929d3c2ec9 Initial commit: SAP C4C attachment downloader toolkit
- Add Python script for downloading C4C attachments via OData and web scraping
- Add Java wrapper for programmatic access with typed API
- Add DSM upload utility for Synology NAS integration
- Add CLAUDE.md documentation for future development
- Add .gitignore for Python, Java, and sensitive files
2026-03-12 12:56:28 +08:00

508 lines
22 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 调用封装
*
* <p>通过 ProcessBuilder 调用 Python 脚本 sap-c4c-AttachmentFolder.py
* 以 --json 模式获取结构化结果。</p>
*
* <h3>前置条件</h3>
* <pre>
* pip install requests scrapling[all] playwright
* python -m playwright install chromium
* </pre>
*
* <h3>使用示例</h3>
* <pre>{@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());
* }
* }</pre>
*/
// [2026-03-12 11:46:10] INFO: Fetched (200) <GET https://regentrv.my.salesforce.com/sfc/p/#2t000000cc9V/a/W2000002EZ3B/4JQvOAb263K5kCW8sIAbYT.nFMeW8z7hT9QS4h0P9Mg> (referer: https://www.google.com/)
// [2026-03-12 11:46:24] INFO: Fetched (200) <GET https://regentrv.my.salesforce.com/sfc/p/#2t000000cc9V/a/W2000002EZ4n/ppCBH.gcnV2K.3qCA_U9ZDtZ9r_UffyJGEuteuWMFAE> (referer: https://www.google.com/)
// [2026-03-12 11:46:40] INFO: Fetched (200) <GET https://regentrv.my.salesforce.com/sfc/p/#2t000000cc9V/a/W2000002EZ6P/mDuF2NKyP96SGjWzuxXl7eejFb84ORKtcG0bPUAqrsg> (referer: https://www.google.com/)
// [2026-03-12 11:46:56] INFO: Fetched (200) <GET https://regentrv.my.salesforce.com/sfc/p/#2t000000cc9V/a/W2000002EWRu/v46Grw_mnbsjKrU1LlWrN7Ysh9vT5iOydQbr0nSYsxw> (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<String> 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<Attachment> srAttachments = new ArrayList<>();
private List<IssueItem> issueItems = new ArrayList<>();
private List<DownloadedFile> downloadedFiles = new ArrayList<>();
private List<DsmUploadEntry> 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<Attachment> getSrAttachments() { return srAttachments; }
public void setSrAttachments(List<Attachment> srAttachments) { this.srAttachments = srAttachments; }
public List<IssueItem> getIssueItems() { return issueItems; }
public void setIssueItems(List<IssueItem> issueItems) { this.issueItems = issueItems; }
public List<DownloadedFile> getDownloadedFiles() { return downloadedFiles; }
public void setDownloadedFiles(List<DownloadedFile> downloadedFiles) { this.downloadedFiles = downloadedFiles; }
public List<DsmUploadEntry> getDsmUpload() { return dsmUpload; }
public void setDsmUpload(List<DsmUploadEntry> dsmUpload) { this.dsmUpload = dsmUpload; }
/** 获取全部附件SR + 所有 IssueItem的合并列表 */
public List<Attachment> getAllAttachments() {
List<Attachment> 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<Attachment> 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<Attachment> getAttachments() { return attachments; }
public void setAttachments(List<Attachment> 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; }
}
}