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
This commit is contained in:
afei A
2026-03-12 12:56:28 +08:00
commit 929d3c2ec9
6 changed files with 1420 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git init:*)",
"Bash(git add:*)"
]
}
}

55
.gitignore vendored Normal file
View File

@@ -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

View File

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

187
CLAUDE.md Normal file
View File

@@ -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.

43
dsm-upload.py Normal file
View File

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

620
sap-c4c-AttachmentFolder.py Normal file
View File

@@ -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 的文件附件)"""
# 优先使用 DocumentLinkcustticketapi 返回的完整 $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"<DocumentUUID>{u}</DocumentUUID>" for u in document_uuids
)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:glob="http://sap.com/xi/SAPGlobal20/Global">
<soapenv:Header/>
<soapenv:Body>
<glob:AttachmentFolderDocumentsFileContentByIDQuery_sync>
<AttachmentFolderDocumentFileContentByIDQuery>
{uuid_xml}
</AttachmentFolderDocumentFileContentByIDQuery>
<ProcessingConditions>
<QueryFileSizeMaximumNumberValue>{size_limit_kb}</QueryFileSizeMaximumNumberValue>
</ProcessingConditions>
</glob:AttachmentFolderDocumentsFileContentByIDQuery_sync>
</soapenv:Body>
</soapenv:Envelope>"""
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()