commit 929d3c2ec941ef979b57d5e63d886fe66482fcc2
Author: afei A <57030625+NewHubBoy@users.noreply.github.com>
Date: Thu Mar 12 12:56:28 2026 +0800
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
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..890754d
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(git init:*)",
+ "Bash(git add:*)"
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..64269d1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,55 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+env/
+venv/
+ENV/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Java
+*.class
+*.jar
+*.war
+*.ear
+target/
+.classpath
+.project
+.settings/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Credentials and sensitive data
+*.env
+.env
+credentials.json
+config.json
+
+# Downloaded files
+downloads/
+*.log
diff --git a/C4CAttachmentDownloader.java b/C4CAttachmentDownloader.java
new file mode 100644
index 0000000..230680d
--- /dev/null
+++ b/C4CAttachmentDownloader.java
@@ -0,0 +1,507 @@
+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; }
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..3a9fa2d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,187 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+This is a SAP C4C (Cloud for Customer) attachment downloader toolkit that retrieves attachments from ServiceRequest tickets and optionally uploads them to Synology DSM NAS. The project consists of:
+
+- **Python script** (`sap-c4c-AttachmentFolder.py`): Core downloader using OData APIs and web scraping
+- **Java wrapper** (`C4CAttachmentDownloader.java`): Java interface that calls the Python script via ProcessBuilder
+- **DSM upload script** (`dsm-upload.py`): Standalone Synology NAS upload utility
+
+## Architecture
+
+### Python Script (`sap-c4c-AttachmentFolder.py`)
+
+**Core functionality:**
+1. Authenticates to SAP C4C using Basic Auth
+2. Fetches ServiceRequest attachments via OData endpoints:
+ - `/sap/c4c/odata/v1/c4codata` - Standard C4C OData API
+ - `/sap/c4c/odata/cust/v1/custticketapi` - Custom ticket API
+3. Downloads two types of attachments:
+ - **File attachments** (CategoryCode=2): Downloaded via OData `$value` endpoint
+ - **Link attachments** (CategoryCode=3): External Salesforce links scraped using Scrapling + Playwright
+4. Handles XIssueItem-level attachments via `BO_XSRIssueItemAttachmentFolder`
+5. Optionally uploads downloaded files to Synology DSM via FileStation API
+
+**Key dependencies:**
+- `requests` - HTTP client for OData/REST APIs
+- `scrapling[all]` - Web scraping framework with stealth capabilities
+- `playwright` - Browser automation for downloading Salesforce attachments
+
+**Output modes:**
+- Human-readable console output (default)
+- JSON mode (`--json`) for programmatic consumption
+
+### Java Wrapper (`C4CAttachmentDownloader.java`)
+
+Provides a type-safe Java API that:
+- Invokes the Python script via `ProcessBuilder`
+- Passes credentials via environment variables (more secure than CLI args)
+- Parses JSON output into strongly-typed Java objects
+- Supports timeout configuration (default: 30 minutes)
+
+**Key classes:**
+- `Result` - Top-level response containing all attachment metadata
+- `Attachment` - Individual attachment metadata (UUID, filename, MIME type, category)
+- `IssueItem` - XIssueItem with nested attachments
+- `DownloadedFile` - Download result with local path and error info
+- `DsmUploadEntry` - DSM upload result per file
+
+### DSM Upload (`dsm-upload.py`)
+
+Standalone script demonstrating Synology FileStation API usage:
+1. Login via `SYNO.API.Auth` to obtain SID
+2. Upload files via `SYNO.FileStation.Upload` with SID cookie
+
+## Common Commands
+
+### Python Script
+
+```bash
+# Install dependencies
+pip install requests scrapling[all] playwright
+python -m playwright install chromium
+
+# Download attachments (credentials via CLI)
+python sap-c4c-AttachmentFolder.py \
+ --tenant https://xxx.c4c.saphybriscloud.cn \
+ --user admin \
+ --password xxx \
+ --ticket 24588
+
+# Download with DSM upload
+python sap-c4c-AttachmentFolder.py \
+ --tenant https://xxx.c4c.saphybriscloud.cn \
+ --user admin \
+ --password xxx \
+ --ticket 24588 \
+ --dsm-url http://10.0.10.235:5000 \
+ --dsm-user PLM \
+ --dsm-password 123456 \
+ --dsm-path /Newgonow/AU-SPFJ
+
+# JSON mode (for Java/programmatic use)
+python sap-c4c-AttachmentFolder.py --ticket 24588 --json
+
+# List attachments only (no download)
+python sap-c4c-AttachmentFolder.py --ticket 24588 --list-only
+
+# Using environment variables for credentials
+export C4C_TENANT=https://xxx.c4c.saphybriscloud.cn
+export C4C_USERNAME=admin
+export C4C_PASSWORD=xxx
+export DSM_URL=http://10.0.10.235:5000
+export DSM_USERNAME=PLM
+export DSM_PASSWORD=123456
+export DSM_PATH=/Newgonow/AU-SPFJ
+python sap-c4c-AttachmentFolder.py --ticket 24588 --json
+```
+
+### Java Wrapper
+
+```java
+// Compile (requires Jackson for JSON parsing)
+javac -cp jackson-databind.jar:jackson-core.jar:jackson-annotations.jar C4CAttachmentDownloader.java
+
+// Basic usage
+C4CAttachmentDownloader downloader = new C4CAttachmentDownloader(
+ "/path/to/sap-c4c-AttachmentFolder.py",
+ "https://xxx.c4c.saphybriscloud.cn",
+ "admin",
+ "password"
+);
+
+// List attachments only
+C4CAttachmentDownloader.Result result = downloader.listAttachments("24588");
+
+// Download to default directory
+C4CAttachmentDownloader.Result result = downloader.download("24588");
+
+// Download to specific directory
+C4CAttachmentDownloader.Result result = downloader.download("24588", "/tmp/ticket_24588");
+
+// Download with DSM upload
+downloader.setDsmConfig("http://10.0.10.235:5000", "PLM", "123456", "/Newgonow/AU-SPFJ");
+C4CAttachmentDownloader.Result result = downloader.download("24588", "/tmp/ticket_24588");
+```
+
+## Key Implementation Details
+
+### Attachment Categories
+
+SAP C4C uses `CategoryCode` to distinguish attachment types:
+- **"2"** = File attachment (binary content stored in C4C, downloaded via OData `$value`)
+- **"3"** = Link attachment (external URL, typically Salesforce links requiring web scraping)
+
+### OData Navigation Paths
+
+**ServiceRequest attachments:**
+```
+/ServiceRequestCollection('{ObjectID}')/ServiceRequestAttachmentFolder
+```
+
+**XIssueItem attachments (two-step navigation):**
+```
+1. /BO_XSRIssueItemAttachmentCollection?$filter=XIssueItemUUID eq guid'{uuid}'
+2. /BO_XSRIssueItemAttachmentCollection('{ObjectID}')/BO_XSRIssueItemAttachmentFolder
+```
+
+### Scrapling Download Strategy
+
+For CategoryCode=3 (link attachments), the script:
+1. Opens the Salesforce link in a headless Chromium browser
+2. Waits for `button.downloadbutton[title='Download']` selector
+3. Clicks the button and captures the download
+4. Saves with original or suggested filename
+
+### Security Considerations
+
+- Java wrapper passes credentials via **environment variables** (not CLI args) to avoid exposure in process lists
+- Python script supports both CLI args and environment variables
+- DSM API uses session-based authentication (SID cookie)
+- SSL verification disabled (`verify=False`) - consider enabling in production
+
+## File Structure
+
+```
+.
+├── C4CAttachmentDownloader.java # Java wrapper with typed API
+├── sap-c4c-AttachmentFolder.py # Core Python downloader
+├── dsm-upload.py # Standalone DSM upload example
+└── downloads/ # Default output directory
+```
+
+## Troubleshooting
+
+**Playwright not installed:**
+```bash
+python -m playwright install chromium
+```
+
+**Timeout errors:** Increase timeout in Java wrapper constructor (default 30 minutes) or adjust Scrapling timeout parameters.
+
+**DSM upload fails:** Verify DSM URL, credentials, and that target path exists or `create_parents=true` is set.
+
+**Link download fails:** Check that Salesforce page structure matches expected selector (`button.downloadbutton[title='Download']`). Update `download_link_via_scrapling()` if page structure changes.
diff --git a/dsm-upload.py b/dsm-upload.py
new file mode 100644
index 0000000..c4faab6
--- /dev/null
+++ b/dsm-upload.py
@@ -0,0 +1,43 @@
+import requests
+import urllib3
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+NAS = "http://10.0.10.235:5000"
+USER = "PLM"
+PWD = "123456"
+
+# 1) 登录
+login_url = f"{NAS}/webapi/auth.cgi"
+login_params = {
+ "api": "SYNO.API.Auth",
+ "version": "3",
+ "method": "login",
+ "account": USER,
+ "passwd": PWD,
+ "session": "FileStation",
+ "format": "sid",
+}
+r = requests.get(login_url, params=login_params, verify=False)
+r.raise_for_status()
+data = r.json()
+sid = data["data"]["sid"]
+
+# 2) 上传(SID 通过 cookie 传递)
+upload_url = f"{NAS}/webapi/entry.cgi"
+form = {
+ "api": "SYNO.FileStation.Upload",
+ "version": "2",
+ "method": "upload",
+ "path": "/Newgonow/AU-SPFJ",
+ "create_parents": "true",
+ "overwrite": "true",
+}
+with open("downloads/IMG_4307.mp4", "rb") as f:
+ files = {
+ "file": ("IMG_4307.mp4", f, "video/mp4")
+ }
+ r = requests.post(upload_url, data=form, files=files,
+ cookies={"id": sid}, verify=False)
+
+print(r.status_code)
+print(r.text)
\ No newline at end of file
diff --git a/sap-c4c-AttachmentFolder.py b/sap-c4c-AttachmentFolder.py
new file mode 100644
index 0000000..4273b52
--- /dev/null
+++ b/sap-c4c-AttachmentFolder.py
@@ -0,0 +1,620 @@
+"""
+SAP C4C 附件下载工具
+
+功能:
+ 1. 下载 ServiceRequest 级别附件
+ 2. 下载 XIssueItem 级别附件 (BO_XSRIssueItemAttachmentFolder)
+ 3. 通过 Scrapling 爬虫下载 Salesforce 外部链接附件
+ 4. 可选:将下载的附件上传到群晖 DSM
+
+环境要求:
+ Python >= 3.8
+
+安装依赖:
+ pip install requests scrapling[all] playwright
+ python -m playwright install chromium
+
+用法:
+ # 下载附件
+ python sap-c4c-AttachmentFolder.py --tenant https://xxx.c4c.saphybriscloud.cn --user admin --password xxx --ticket 24588
+
+ # 下载附件并上传到群晖
+ python sap-c4c-AttachmentFolder.py --tenant https://xxx.c4c.saphybriscloud.cn --user admin --password xxx --ticket 24588 \
+ --dsm-url http://10.0.10.235:5000 --dsm-user PLM --dsm-password 123456 --dsm-path /Newgonow/AU-SPFJ
+
+ # JSON 模式(供 Java/其他程序调用)
+ python sap-c4c-AttachmentFolder.py --ticket 24588 --dsm-url http://10.0.10.235:5000 --dsm-user PLM --dsm-password 123456 --dsm-path /Newgonow/AU-SPFJ --json
+
+ # 也可通过环境变量传入凭证
+ export C4C_TENANT=https://xxx.c4c.saphybriscloud.cn
+ export C4C_USERNAME=admin
+ export C4C_PASSWORD=xxx
+ export DSM_URL=http://10.0.10.235:5000
+ export DSM_USERNAME=PLM
+ export DSM_PASSWORD=123456
+ export DSM_PATH=/Newgonow/AU-SPFJ
+ python sap-c4c-AttachmentFolder.py --ticket 24588 --json
+"""
+import os
+import sys
+import json
+import argparse
+import base64
+import mimetypes
+import requests
+import urllib3
+import xml.etree.ElementTree as ET
+from scrapling.fetchers import StealthyFetcher
+
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+TENANT = ""
+USERNAME = ""
+PASSWORD = ""
+
+# OData 路径(在 main 中根据 TENANT 初始化)
+ODATA_C4C = ""
+ODATA_CUST = ""
+
+# SOAP endpoint(如需通过 SOAP 下载文件内容,请替换为实际地址)
+SOAP_URL = f"{TENANT}/sap/bc/srt/scs/sap/manageattachmentfolderin"
+
+# 默认值,可通过命令行参数覆盖
+OUTPUT_DIR = "downloads"
+
+# 群晖 DSM 配置(可选,在 main 中初始化)
+DSM_URL = ""
+DSM_USER = ""
+DSM_PASSWORD = ""
+DSM_PATH = ""
+
+
+def get_session():
+ s = requests.Session()
+ s.auth = (USERNAME, PASSWORD)
+ s.headers.update({"Accept": "application/json"})
+ return s
+
+
+def find_service_request_object_id(session, ticket_id):
+ """通过人类可读的 ticket ID 查找 OData ObjectID"""
+ url = f"{ODATA_C4C}/ServiceRequestCollection"
+ params = {"$format": "json", "$filter": f"ID eq '{ticket_id}'", "$select": "ObjectID,ID"}
+ resp = session.get(url, params=params, timeout=60)
+ resp.raise_for_status()
+ results = resp.json().get("d", {}).get("results", [])
+ if not results:
+ raise ValueError(f"未找到 ID={ticket_id} 的 ServiceRequest")
+ return results[0]["ObjectID"]
+
+
+def _parse_attachments(results):
+ """将 OData 返回的附件列表解析为统一格式"""
+ attachments = []
+ for row in results:
+ uuid = row.get("UUID")
+ file_name = row.get("Name") or f"{uuid}.bin"
+ mime_type = row.get("MimeType") or "application/octet-stream"
+ category = row.get("CategoryCode") # 2=File, 3=Link
+ link_uri = row.get("LinkWebURI")
+ if uuid:
+ attachments.append({
+ "UUID": uuid,
+ "ObjectID": row.get("ObjectID"),
+ "ParentObjectID": row.get("ParentObjectID"),
+ "FileName": file_name,
+ "MimeType": mime_type,
+ "CategoryCode": category,
+ "LinkWebURI": link_uri,
+ "DocumentLink": row.get("DocumentLink"),
+ "SizeInkB": row.get("SizeInkB"),
+ })
+ return attachments
+
+
+def list_sr_attachments(session, sr_object_id):
+ """获取 ServiceRequest 级别的附件(通过 c4codata 导航)"""
+ url = f"{ODATA_C4C}/ServiceRequestCollection('{sr_object_id}')/ServiceRequestAttachmentFolder"
+ params = {"$format": "json"}
+ resp = session.get(url, params=params, timeout=60)
+ resp.raise_for_status()
+ results = resp.json().get("d", {}).get("results", [])
+ return _parse_attachments(results)
+
+
+def list_issue_items(session, ticket_id):
+ """获取 ServiceRequest 下的 XIssueItem 列表(通过 custticketapi)"""
+ url = f"{ODATA_CUST}/ServiceRequest_XIssueItem_SDKCollection"
+ params = {"$format": "json", "$filter": f"TicketID eq '{ticket_id}'"}
+ resp = session.get(url, params=params, timeout=60)
+ resp.raise_for_status()
+ return resp.json().get("d", {}).get("results", [])
+
+
+def list_issue_item_attachments(session, issue_item_uuid):
+ """
+ 获取 XIssueItem 级别的附件。
+ 路径: BO_XSRIssueItemAttachmentCollection (按 XIssueItemUUID 过滤)
+ -> BO_XSRIssueItemAttachmentFolder (实际附件文件)
+ """
+ # Step 1: 通过 XIssueItemUUID 找到 BO_XSRIssueItemAttachment
+ url = f"{ODATA_CUST}/BO_XSRIssueItemAttachmentCollection"
+ params = {"$format": "json", "$filter": f"XIssueItemUUID eq guid'{issue_item_uuid}'"}
+ resp = session.get(url, params=params, timeout=60)
+ resp.raise_for_status()
+ att_results = resp.json().get("d", {}).get("results", [])
+ if not att_results:
+ return []
+
+ # Step 2: 对每个 Attachment 导航到 AttachmentFolder
+ all_attachments = []
+ for att in att_results:
+ att_oid = att["ObjectID"]
+ folder_url = (
+ f"{ODATA_CUST}/BO_XSRIssueItemAttachmentCollection('{att_oid}')"
+ f"/BO_XSRIssueItemAttachmentFolder"
+ )
+ resp2 = session.get(folder_url, params={"$format": "json"}, timeout=60)
+ resp2.raise_for_status()
+ folders = resp2.json().get("d", {}).get("results", [])
+ all_attachments.extend(_parse_attachments(folders))
+ return all_attachments
+
+
+def download_file_via_odata(session, attachment, base_url=None):
+ """通过 OData $value 直接下载文件内容(适用于 CategoryCode=2 的文件附件)"""
+ # 优先使用 DocumentLink(custticketapi 返回的完整 $value URL)
+ doc_link = attachment.get("DocumentLink")
+ if doc_link:
+ url = doc_link
+ else:
+ obj_id = attachment["ObjectID"]
+ if base_url is None:
+ base_url = f"{ODATA_C4C}/ServiceRequestAttachmentFolderCollection"
+ url = f"{base_url}('{obj_id}')/$value"
+ resp = session.get(url, timeout=300, stream=True)
+ resp.raise_for_status()
+ return resp.content
+
+
+def build_read_documents_payload(document_uuids, size_limit_kb=10240):
+ uuid_xml = "\n".join(
+ f"{u}" for u in document_uuids
+ )
+ return f"""
+
+
+
+
+
+ {uuid_xml}
+
+
+ {size_limit_kb}
+
+
+
+"""
+
+
+def read_documents_file_content(session, document_uuids):
+ payload = build_read_documents_payload(document_uuids)
+ headers = {"Content-Type": "text/xml; charset=utf-8"}
+ resp = session.post(SOAP_URL, data=payload.encode("utf-8"), headers=headers, timeout=120)
+ resp.raise_for_status()
+ return resp.text
+
+
+def parse_soap_response(xml_text):
+ root = ET.fromstring(xml_text)
+ items = []
+ for elem in root.iter():
+ if elem.tag.endswith("AttachmentFolderDocumentFileContent"):
+ item = {"DocumentUUID": None, "BinaryObject": None}
+ for child in elem:
+ if child.tag.endswith("DocumentUUID"):
+ item["DocumentUUID"] = child.text
+ elif child.tag.endswith("BinaryObject"):
+ item["BinaryObject"] = child.text
+ if item["DocumentUUID"] and item["BinaryObject"]:
+ items.append(item)
+
+ more_hits = False
+ for elem in root.iter():
+ if elem.tag.endswith("MoreHitsAvailableIndicator") and (elem.text or "").lower() == "true":
+ more_hits = True
+ break
+ return items, more_hits
+
+
+def save_files(content_items, attachment_index):
+ for item in content_items:
+ doc_uuid = item["DocumentUUID"]
+ binary_b64 = item["BinaryObject"]
+ meta = attachment_index.get(doc_uuid, {})
+ file_name = meta.get("FileName", f"{doc_uuid}.bin")
+ file_path = os.path.join(OUTPUT_DIR, file_name)
+ with open(file_path, "wb") as f:
+ f.write(base64.b64decode(binary_b64))
+ print(f" saved: {file_path}")
+
+
+def chunked(seq, size):
+ for i in range(0, len(seq), size):
+ yield seq[i:i + size]
+
+
+def download_link_via_scrapling(link_url, save_name):
+ """通过 Scrapling 打开外部链接页面,点击 .downloadbutton 按钮下载文件"""
+ result = {"saved": None, "error": None}
+
+ def click_download(page):
+ page.wait_for_selector("button.downloadbutton[title='Download']", timeout=15000)
+ with page.expect_download(timeout=120000) as download_info:
+ page.click("button.downloadbutton[title='Download']")
+ download = download_info.value
+ filename = download.suggested_filename or save_name
+ save_path = os.path.join(OUTPUT_DIR, filename)
+ download.save_as(save_path)
+ result["saved"] = save_path
+
+ try:
+ StealthyFetcher.fetch(
+ link_url,
+ headless=True,
+ network_idle=True,
+ timeout=60000,
+ page_action=click_download,
+ )
+ except Exception as e:
+ result["error"] = str(e)
+
+ return result
+
+
+# ==================== 群晖 DSM 上传 ====================
+
+def dsm_login():
+ """登录群晖 DSM,返回 SID"""
+ resp = requests.get(f"{DSM_URL}/webapi/auth.cgi", params={
+ "api": "SYNO.API.Auth", "version": "3", "method": "login",
+ "account": DSM_USER, "passwd": DSM_PASSWORD,
+ "session": "FileStation", "format": "sid",
+ }, verify=False, timeout=30)
+ resp.raise_for_status()
+ data = resp.json()
+ if not data.get("success"):
+ raise RuntimeError(f"DSM 登录失败: {data}")
+ return data["data"]["sid"]
+
+
+def dsm_upload_file(sid, local_path, remote_path):
+ """上传单个文件到群晖 DSM"""
+ filename = os.path.basename(local_path)
+ mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
+
+ form = {
+ "api": "SYNO.FileStation.Upload",
+ "version": "2",
+ "method": "upload",
+ "path": remote_path,
+ "create_parents": "true",
+ "overwrite": "true",
+ }
+ with open(local_path, "rb") as f:
+ resp = requests.post(
+ f"{DSM_URL}/webapi/entry.cgi",
+ data=form,
+ files={"file": (filename, f, mime)},
+ cookies={"id": sid},
+ verify=False,
+ timeout=600,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ if not data.get("success"):
+ raise RuntimeError(f"DSM 上传失败: {data}")
+ return data
+
+
+def dsm_upload_downloaded_files(downloaded_files, json_mode=False):
+ """将所有已下载文件上传到群晖 DSM"""
+ if not DSM_URL or not DSM_USER or not DSM_PASSWORD or not DSM_PATH:
+ return []
+
+ files_to_upload = [f for f in downloaded_files if f.get("savedPath") and not f.get("error")]
+ if not files_to_upload:
+ return []
+
+ if not json_mode:
+ print(f"\n{'='*60}")
+ print(f"上传到群晖 DSM: {DSM_URL}")
+ print(f"目标路径: {DSM_PATH}")
+ print('='*60)
+
+ upload_results = []
+ try:
+ sid = dsm_login()
+ if not json_mode:
+ print(f" DSM 登录成功")
+ except Exception as e:
+ if not json_mode:
+ print(f" DSM 登录失败: {e}", file=sys.stderr)
+ return [{"error": f"DSM 登录失败: {e}"}]
+
+ for f in files_to_upload:
+ local_path = f["savedPath"]
+ filename = os.path.basename(local_path)
+ entry = {"file": filename, "remotePath": f"{DSM_PATH}/{filename}"}
+ try:
+ dsm_upload_file(sid, local_path, DSM_PATH)
+ entry["success"] = True
+ if not json_mode:
+ print(f" 上传成功: {filename} -> {DSM_PATH}/{filename}")
+ except Exception as e:
+ entry["success"] = False
+ entry["error"] = str(e)
+ if not json_mode:
+ print(f" 上传失败: {filename}: {e}")
+ upload_results.append(entry)
+
+ if not json_mode:
+ ok = sum(1 for r in upload_results if r.get("success"))
+ fail = len(upload_results) - ok
+ print(f"\n 上传完成: {ok} 成功, {fail} 失败")
+
+ return upload_results
+
+
+def print_attachment_summary(all_attachments):
+ """打印附件清单汇总"""
+ print(f"\n{'='*60}")
+ print("附件清单汇总")
+ print('='*60)
+ if not all_attachments:
+ print(" 无附件")
+ return
+
+ total_files = 0
+ total_links = 0
+ for level, atts in all_attachments:
+ for a in atts:
+ if a["CategoryCode"] == "2":
+ total_files += 1
+ elif a["CategoryCode"] == "3":
+ total_links += 1
+
+ print(f" 合计: {total_files} 个文件附件, {total_links} 个链接附件\n")
+
+ idx = 0
+ for level, atts in all_attachments:
+ if not atts:
+ continue
+ print(f" [{level}]")
+ for a in atts:
+ idx += 1
+ cat = "文件" if a["CategoryCode"] == "2" else "链接"
+ size = a.get("SizeInkB", "")
+ size_str = ""
+ if size and cat == "文件":
+ try:
+ kb = float(size)
+ if kb > 1024:
+ size_str = f" ({kb/1024:.1f} MB)"
+ else:
+ size_str = f" ({kb:.0f} KB)"
+ except ValueError:
+ pass
+ mime = a.get("MimeType") or ""
+ link = a.get("LinkWebURI") or ""
+ print(f" {idx}. [{cat}] {a['FileName']}{size_str}")
+ if mime:
+ print(f" MIME: {mime}")
+ if link:
+ print(f" 链接: {link}")
+ print()
+
+
+def run(ticket_id, output_dir, list_only=False, json_mode=False):
+ """核心逻辑,返回结构化结果"""
+ global OUTPUT_DIR
+ OUTPUT_DIR = output_dir
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+ session = get_session()
+ result = {
+ "ticketId": ticket_id,
+ "outputDir": os.path.abspath(OUTPUT_DIR),
+ "success": True,
+ "error": None,
+ "srAttachments": [],
+ "issueItems": [],
+ "downloadedFiles": [],
+ }
+
+ try:
+ # 1) 通过 ticket ID 找到 ObjectID
+ sr_object_id = find_service_request_object_id(session, ticket_id)
+ result["srObjectId"] = sr_object_id
+ if not json_mode:
+ print(f"ServiceRequest ID={ticket_id}, ObjectID={sr_object_id}")
+
+ # 2) ServiceRequest 级别附件
+ if not json_mode:
+ print(f"\n{'='*60}")
+ print("ServiceRequest 级别附件")
+ print('='*60)
+ sr_attachments = list_sr_attachments(session, sr_object_id)
+ result["srAttachments"] = sr_attachments
+ if not json_mode:
+ print(f"找到 {len(sr_attachments)} 个附件")
+
+ if not list_only:
+ _do_download(session, sr_attachments, "SR", None, result, json_mode)
+
+ # 3) XIssueItem 级别附件
+ if not json_mode:
+ print(f"\n{'='*60}")
+ print("XIssueItem 级别附件 (BO_XSRIssueItemAttachmentFolder)")
+ print('='*60)
+ issue_items = list_issue_items(session, ticket_id)
+ if not json_mode:
+ print(f"找到 {len(issue_items)} 个 XIssueItem")
+
+ for item in issue_items:
+ item_oid = item["ObjectID"]
+ issue_uuid = item.get("XIssueItemUUIDcontent_SDK", "")
+ issue_desc = (item.get("IssuesDescriptionX_SDK") or "")[:80]
+
+ issue_entry = {
+ "objectId": item_oid,
+ "uuid": issue_uuid,
+ "description": issue_desc,
+ "attachments": [],
+ }
+
+ if not json_mode:
+ print(f"\n XIssueItem: {item_oid}")
+ print(f" UUID: {issue_uuid}")
+ print(f" 描述: {issue_desc}")
+
+ if not issue_uuid:
+ if not json_mode:
+ print(" ⚠ 无 XIssueItemUUID,跳过")
+ result["issueItems"].append(issue_entry)
+ continue
+
+ atts = list_issue_item_attachments(session, issue_uuid)
+ issue_entry["attachments"] = atts
+ if not json_mode:
+ print(f" 找到 {len(atts)} 个附件")
+
+ if not list_only:
+ _do_download(
+ session, atts, f"IssueItem-{item_oid[:12]}",
+ f"{ODATA_CUST}/BO_XSRIssueItemAttachmentFolderCollection",
+ result, json_mode,
+ )
+
+ result["issueItems"].append(issue_entry)
+
+ # 4) 汇总清单
+ if not json_mode:
+ all_attachments = [("SR", sr_attachments)]
+ for ie in result["issueItems"]:
+ all_attachments.append((f"IssueItem-{ie['objectId'][:12]}", ie["attachments"]))
+ print_attachment_summary(all_attachments)
+
+ except Exception as e:
+ result["success"] = False
+ result["error"] = str(e)
+ if not json_mode:
+ print(f"\n错误: {e}", file=sys.stderr)
+
+ return result
+
+
+def _do_download(session, attachments, label, odata_url, result, json_mode):
+ """执行下载并将结果追加到 result['downloadedFiles']"""
+ file_atts = [a for a in attachments if a["CategoryCode"] == "2"]
+ link_atts = [a for a in attachments if a["CategoryCode"] == "3"]
+
+ # 链接附件 -> Scrapling
+ for a in link_atts:
+ link_url = a.get("LinkWebURI")
+ if not link_url:
+ continue
+ if not json_mode:
+ print(f" {a['FileName']}: {link_url}")
+ r = download_link_via_scrapling(link_url, a["FileName"])
+ entry = {"source": label, "c4cName": a["FileName"], "type": "link", "linkUrl": link_url}
+ if r["saved"]:
+ entry["savedPath"] = os.path.abspath(r["saved"])
+ entry["savedName"] = os.path.basename(r["saved"])
+ if not json_mode:
+ print(f" saved: {r['saved']}")
+ else:
+ entry["error"] = r["error"]
+ if not json_mode:
+ print(f" 下载失败: {r['error']}")
+ result["downloadedFiles"].append(entry)
+
+ # 文件附件 -> OData
+ for att in file_atts:
+ entry = {"source": label, "c4cName": att["FileName"], "type": "file", "mime": att.get("MimeType")}
+ try:
+ content = download_file_via_odata(session, att, odata_url)
+ file_path = os.path.join(OUTPUT_DIR, att["FileName"])
+ with open(file_path, "wb") as f:
+ f.write(content)
+ entry["savedPath"] = os.path.abspath(file_path)
+ entry["savedName"] = att["FileName"]
+ if not json_mode:
+ print(f" saved: {file_path}")
+ except Exception as e:
+ entry["error"] = str(e)
+ if not json_mode:
+ print(f" OData 下载失败 ({att['FileName']}): {e}")
+ result["downloadedFiles"].append(entry)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="SAP C4C 附件下载工具")
+ parser.add_argument("--tenant", default=os.environ.get("C4C_TENANT", ""),
+ help="C4C 租户地址 (如 https://xxx.c4c.saphybriscloud.cn),也可设 C4C_TENANT 环境变量")
+ parser.add_argument("--user", default=os.environ.get("C4C_USERNAME", ""),
+ help="C4C 用户名,也可设 C4C_USERNAME 环境变量")
+ parser.add_argument("--password", default=os.environ.get("C4C_PASSWORD", ""),
+ help="C4C 密码,也可设 C4C_PASSWORD 环境变量")
+ parser.add_argument("--ticket", required=True, help="ServiceRequest ticket ID (如 24588)")
+ parser.add_argument("--output-dir", default="downloads", help="附件保存目录 (默认: downloads)")
+ parser.add_argument("--json", action="store_true", dest="json_mode", help="JSON 输出模式(供程序调用)")
+ parser.add_argument("--list-only", action="store_true", help="仅列出附件清单,不下载")
+
+ # 群晖 DSM 上传参数
+ parser.add_argument("--dsm-url", default=os.environ.get("DSM_URL", ""),
+ help="群晖 DSM 地址 (如 http://10.0.10.235:5000),也可设 DSM_URL 环境变量")
+ parser.add_argument("--dsm-user", default=os.environ.get("DSM_USERNAME", ""),
+ help="群晖 DSM 用户名,也可设 DSM_USERNAME 环境变量")
+ parser.add_argument("--dsm-password", default=os.environ.get("DSM_PASSWORD", ""),
+ help="群晖 DSM 密码,也可设 DSM_PASSWORD 环境变量")
+ parser.add_argument("--dsm-path", default=os.environ.get("DSM_PATH", ""),
+ help="群晖 DSM 目标路径 (如 /Newgonow/AU-SPFJ),也可设 DSM_PATH 环境变量")
+
+ args = parser.parse_args()
+
+ if not args.tenant or not args.user or not args.password:
+ parser.error("必须提供 --tenant, --user, --password 参数,或设置 C4C_TENANT, C4C_USERNAME, C4C_PASSWORD 环境变量")
+
+ # 初始化全局配置
+ global TENANT, USERNAME, PASSWORD, ODATA_C4C, ODATA_CUST, SOAP_URL
+ TENANT = args.tenant.rstrip("/")
+ USERNAME = args.user
+ PASSWORD = args.password
+ ODATA_C4C = f"{TENANT}/sap/c4c/odata/v1/c4codata"
+ ODATA_CUST = f"{TENANT}/sap/c4c/odata/cust/v1/custticketapi"
+ SOAP_URL = f"{TENANT}/sap/bc/srt/scs/sap/manageattachmentfolderin"
+
+ # 初始化 DSM 配置
+ global DSM_URL, DSM_USER, DSM_PASSWORD, DSM_PATH
+ DSM_URL = args.dsm_url.rstrip("/") if args.dsm_url else ""
+ DSM_USER = args.dsm_user
+ DSM_PASSWORD = args.dsm_password
+ DSM_PATH = args.dsm_path
+
+ result = run(args.ticket, args.output_dir, args.list_only, args.json_mode)
+
+ # 下载完成后上传到群晖 DSM
+ if DSM_URL and not args.list_only and result["success"]:
+ upload_results = dsm_upload_downloaded_files(result["downloadedFiles"], args.json_mode)
+ result["dsmUpload"] = upload_results
+
+ if args.json_mode:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ sys.exit(0 if result["success"] else 1)
+
+
+if __name__ == "__main__":
+ main()