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()