change
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git init:*)",
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(python -c \"import ast, sys; ast.parse\\(open\\('sap-c4c-AttachmentFolder.py'\\).read\\(\\)\\); print\\('语法检查通过'\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
88
CLAUDE.md
88
CLAUDE.md
@@ -1,88 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
SAP C4C (Cloud for Customer) attachment downloader toolkit that retrieves attachments from ServiceRequest tickets and optionally uploads them to Synology DSM NAS.
|
||||
|
||||
- **`sap-c4c-AttachmentFolder.py`**: Core downloader (Python >= 3.8) using OData APIs and web scraping
|
||||
- **`C4CAttachmentDownloader.java`**: Java wrapper that calls the Python script via ProcessBuilder
|
||||
- **`dsm-upload.py`**: Standalone Synology NAS upload example
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install requests scrapling[all] playwright
|
||||
python -m playwright install chromium
|
||||
|
||||
# Download attachments
|
||||
python sap-c4c-AttachmentFolder.py \
|
||||
--tenant https://xxx.c4c.saphybriscloud.cn \
|
||||
--user admin --password xxx --ticket 24588
|
||||
|
||||
# Download with custom concurrency (default: 5 threads)
|
||||
python sap-c4c-AttachmentFolder.py --ticket 24588 --max-workers 10
|
||||
|
||||
# List attachments only (no download)
|
||||
python sap-c4c-AttachmentFolder.py --ticket 24588 --list-only
|
||||
|
||||
# JSON mode (for Java/programmatic use)
|
||||
python sap-c4c-AttachmentFolder.py --ticket 24588 --json
|
||||
|
||||
# Download + upload to Synology DSM
|
||||
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
|
||||
|
||||
# All credentials also accept environment variables:
|
||||
# C4C_TENANT, C4C_USERNAME, C4C_PASSWORD, DSM_URL, DSM_USERNAME, DSM_PASSWORD, DSM_PATH
|
||||
```
|
||||
|
||||
```java
|
||||
// Java: compile requires Jackson (jackson-databind, jackson-core, jackson-annotations)
|
||||
javac -cp jackson-databind.jar:jackson-core.jar:jackson-annotations.jar C4CAttachmentDownloader.java
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. Authenticate to SAP C4C via Basic Auth
|
||||
2. Look up ServiceRequest by ticket ID -> get ObjectID and SerialID
|
||||
3. Fetch SR-level attachments via `/sap/c4c/odata/v1/c4codata/ServiceRequestCollection('{OID}')/ServiceRequestAttachmentFolder`
|
||||
4. Fetch XIssueItem-level attachments via `/sap/c4c/odata/cust/v1/custticketapi/BO_XSRIssueItemAttachmentCollection` (two-step: filter by UUID, then navigate to AttachmentFolder)
|
||||
5. Download concurrently using ThreadPoolExecutor:
|
||||
- **CategoryCode "2"** (file): OData `$value` endpoint or `DocumentLink` URL
|
||||
- **CategoryCode "3"** (link): Scrapling + Playwright opens Salesforce URL, clicks `button.downloadbutton[title='Download']`, captures download
|
||||
6. Optionally upload to Synology DSM via FileStation API, then **auto-delete local files**
|
||||
|
||||
### Two OData Endpoints
|
||||
|
||||
- `/sap/c4c/odata/v1/c4codata` (`ODATA_C4C`) - Standard C4C OData for ServiceRequest and SR-level attachments
|
||||
- `/sap/c4c/odata/cust/v1/custticketapi` (`ODATA_CUST`) - Custom ticket API for XIssueItem and its attachments
|
||||
|
||||
### Java Wrapper
|
||||
|
||||
Invokes Python script with `--json` flag, passes credentials via **environment variables** (not CLI args for security). Parses JSON into typed classes: `Result`, `Attachment`, `IssueItem`, `DownloadedFile`, `DsmUploadEntry`. Default timeout: 30 minutes.
|
||||
|
||||
### DSM Upload Directory Structure
|
||||
|
||||
- SR attachments: `{DSM_PATH}/{ticketID}_{serialID}/{filename}`
|
||||
- IssueItem attachments: `{DSM_PATH}/{ticketID}_{serialID}/{issueID}/{filename}`
|
||||
|
||||
### Concurrency Model
|
||||
|
||||
Multi-threaded via `ThreadPoolExecutor` (default 5, `--max-workers`). Both file and link downloads are submitted as futures. Thread-safe console output uses a `print_lock`. The `requests.Session` is shared across file-download threads (thread-safe). Scrapling/Playwright link downloads each launch their own browser.
|
||||
|
||||
### Global State
|
||||
|
||||
The Python script uses module-level globals (`TENANT`, `USERNAME`, `PASSWORD`, `ODATA_C4C`, `ODATA_CUST`, `OUTPUT_DIR`, `DSM_*`, `MAX_WORKERS`) initialized in `main()`. The `run()` function is the core entry point returning a structured dict.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Playwright not installed**: `python -m playwright install chromium`
|
||||
- **Link download fails**: Salesforce page selector `button.downloadbutton[title='Download']` may have changed; update `download_link_via_scrapling()`
|
||||
- **Timeout**: Increase Java wrapper timeout or Scrapling's `timeout` param (currently 60s page load, 120s download wait)
|
||||
- **SSL warnings**: `verify=False` is used throughout; `urllib3` warnings are suppressed
|
||||
@@ -19,7 +19,7 @@ SAP C4C 附件下载工具
|
||||
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 \
|
||||
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/其他程序调用)
|
||||
@@ -39,52 +39,51 @@ 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
|
||||
from dataclasses import dataclass
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from threading import Lock
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
TENANT = ""
|
||||
USERNAME = ""
|
||||
PASSWORD = ""
|
||||
print_lock = Lock()
|
||||
|
||||
# OData 路径(在 main 中根据 TENANT 初始化)
|
||||
ODATA_C4C = ""
|
||||
ODATA_CUST = ""
|
||||
|
||||
# SOAP endpoint(如需通过 SOAP 下载文件内容,请替换为实际地址)
|
||||
SOAP_URL = f"{TENANT}/sap/bc/srt/scs/sap/manageattachmentfolderin"
|
||||
@dataclass
|
||||
class Config:
|
||||
tenant: str = ""
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
odata_c4c: str = ""
|
||||
odata_cust: str = ""
|
||||
output_dir: str = "downloads"
|
||||
max_workers: int = 5
|
||||
dsm_url: str = ""
|
||||
dsm_user: str = ""
|
||||
dsm_password: str = ""
|
||||
dsm_path: str = ""
|
||||
|
||||
# 默认值,可通过命令行参数覆盖
|
||||
OUTPUT_DIR = "downloads"
|
||||
def init_endpoints(self):
|
||||
base = self.tenant.rstrip("/")
|
||||
self.odata_c4c = f"{base}/sap/c4c/odata/v1/c4codata"
|
||||
self.odata_cust = f"{base}/sap/c4c/odata/cust/v1/custticketapi"
|
||||
|
||||
# 群晖 DSM 配置(可选,在 main 中初始化)
|
||||
DSM_URL = ""
|
||||
DSM_USER = ""
|
||||
DSM_PASSWORD = ""
|
||||
DSM_PATH = ""
|
||||
|
||||
# 多线程配置
|
||||
MAX_WORKERS = 5 # 默认并发数
|
||||
print_lock = Lock() # 用于线程安全的打印输出
|
||||
cfg = Config()
|
||||
|
||||
|
||||
def get_session():
|
||||
s = requests.Session()
|
||||
s.auth = (USERNAME, PASSWORD)
|
||||
s.auth = (cfg.username, cfg.password)
|
||||
s.headers.update({"Accept": "application/json"})
|
||||
return s
|
||||
|
||||
|
||||
def find_service_request_object_id(session, ticket_id):
|
||||
"""通过人类可读的 ticket ID 查找 OData ObjectID 和 SerialID"""
|
||||
url = f"{ODATA_C4C}/ServiceRequestCollection"
|
||||
url = f"{cfg.odata_c4c}/ServiceRequestCollection"
|
||||
params = {"$format": "json", "$filter": f"ID eq '{ticket_id}'"}
|
||||
resp = session.get(url, params=params, timeout=60)
|
||||
resp.raise_for_status()
|
||||
@@ -92,8 +91,7 @@ def find_service_request_object_id(session, ticket_id):
|
||||
if not results:
|
||||
raise ValueError(f"未找到 ID={ticket_id} 的 ServiceRequest")
|
||||
sr = results[0]
|
||||
serial_id = sr.get("SerialID", "")
|
||||
return sr["ObjectID"], serial_id
|
||||
return sr["ObjectID"], sr.get("SerialID", "")
|
||||
|
||||
|
||||
def _parse_attachments(results):
|
||||
@@ -122,7 +120,7 @@ def _parse_attachments(results):
|
||||
|
||||
def list_sr_attachments(session, sr_object_id):
|
||||
"""获取 ServiceRequest 级别的附件(通过 c4codata 导航)"""
|
||||
url = f"{ODATA_C4C}/ServiceRequestCollection('{sr_object_id}')/ServiceRequestAttachmentFolder"
|
||||
url = f"{cfg.odata_c4c}/ServiceRequestCollection('{sr_object_id}')/ServiceRequestAttachmentFolder"
|
||||
params = {"$format": "json"}
|
||||
resp = session.get(url, params=params, timeout=60)
|
||||
resp.raise_for_status()
|
||||
@@ -132,7 +130,7 @@ def list_sr_attachments(session, sr_object_id):
|
||||
|
||||
def list_issue_items(session, ticket_id):
|
||||
"""获取 ServiceRequest 下的 XIssueItem 列表(通过 custticketapi)"""
|
||||
url = f"{ODATA_CUST}/ServiceRequest_XIssueItem_SDKCollection"
|
||||
url = f"{cfg.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()
|
||||
@@ -141,21 +139,32 @@ def list_issue_items(session, ticket_id):
|
||||
|
||||
def get_issue_item_detail(session, object_id):
|
||||
"""通过 ObjectID 获取 XIssueItem 详细信息,包括真实的 IssueID_SDK"""
|
||||
url = f"{ODATA_CUST}/ServiceRequest_XIssueItem_SDKCollection('{object_id}')"
|
||||
url = f"{cfg.odata_cust}/ServiceRequest_XIssueItem_SDKCollection('{object_id}')"
|
||||
params = {"$format": "json"}
|
||||
resp = session.get(url, params=params, timeout=60)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("d", {}).get("results", {})
|
||||
|
||||
|
||||
def _fetch_attachment_folder(session, att_oid):
|
||||
"""获取单个 AttachmentFolder 条目(供并发调用)"""
|
||||
folder_url = (
|
||||
f"{cfg.odata_cust}/BO_XSRIssueItemAttachmentCollection('{att_oid}')"
|
||||
f"/BO_XSRIssueItemAttachmentFolder"
|
||||
)
|
||||
resp = session.get(folder_url, params={"$format": "json"}, 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 2 并发请求各 AttachmentFolder。
|
||||
"""
|
||||
# Step 1: 通过 XIssueItemUUID 找到 BO_XSRIssueItemAttachment
|
||||
url = f"{ODATA_CUST}/BO_XSRIssueItemAttachmentCollection"
|
||||
url = f"{cfg.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()
|
||||
@@ -163,108 +172,43 @@ def list_issue_item_attachments(session, issue_item_uuid):
|
||||
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))
|
||||
with ThreadPoolExecutor(max_workers=cfg.max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(_fetch_attachment_folder, session, att["ObjectID"]): att
|
||||
for att in att_results
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
folders = future.result()
|
||||
all_attachments.extend(_parse_attachments(folders))
|
||||
except Exception as e:
|
||||
with print_lock:
|
||||
print(f" ⚠ 获取附件文件夹失败: {e}", file=sys.stderr)
|
||||
return all_attachments
|
||||
|
||||
|
||||
def download_file_via_odata(session, attachment, base_url=None):
|
||||
"""通过 OData $value 直接下载文件内容(适用于 CategoryCode=2 的文件附件)"""
|
||||
# 优先使用 DocumentLink(custticketapi 返回的完整 $value URL)
|
||||
def download_file_via_odata(session, attachment, file_path, base_url=None):
|
||||
"""通过 OData $value 流式下载文件,直接写入 file_path(避免大文件 OOM)"""
|
||||
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"
|
||||
base_url = f"{cfg.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]
|
||||
with open(file_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
def download_link_via_scrapling(link_url, save_name):
|
||||
"""通过 Scrapling 打开外部链接页面,点击 .downloadbutton 按钮下载文件"""
|
||||
from scrapling.fetchers import StealthyFetcher
|
||||
|
||||
result = {"saved": None, "error": None}
|
||||
|
||||
def click_download(page):
|
||||
@@ -273,7 +217,7 @@ def download_link_via_scrapling(link_url, save_name):
|
||||
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)
|
||||
save_path = os.path.join(cfg.output_dir, filename)
|
||||
download.save_as(save_path)
|
||||
result["saved"] = save_path
|
||||
|
||||
@@ -295,9 +239,9 @@ def download_link_via_scrapling(link_url, save_name):
|
||||
|
||||
def dsm_login():
|
||||
"""登录群晖 DSM,返回 SID"""
|
||||
resp = requests.get(f"{DSM_URL}/webapi/auth.cgi", params={
|
||||
resp = requests.get(f"{cfg.dsm_url}/webapi/auth.cgi", params={
|
||||
"api": "SYNO.API.Auth", "version": "3", "method": "login",
|
||||
"account": DSM_USER, "passwd": DSM_PASSWORD,
|
||||
"account": cfg.dsm_user, "passwd": cfg.dsm_password,
|
||||
"session": "FileStation", "format": "sid",
|
||||
}, verify=False, timeout=30)
|
||||
resp.raise_for_status()
|
||||
@@ -311,7 +255,6 @@ 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",
|
||||
@@ -322,7 +265,7 @@ def dsm_upload_file(sid, local_path, remote_path):
|
||||
}
|
||||
with open(local_path, "rb") as f:
|
||||
resp = requests.post(
|
||||
f"{DSM_URL}/webapi/entry.cgi",
|
||||
f"{cfg.dsm_url}/webapi/entry.cgi",
|
||||
data=form,
|
||||
files={"file": (filename, f, mime)},
|
||||
cookies={"id": sid},
|
||||
@@ -337,24 +280,22 @@ def dsm_upload_file(sid, local_path, remote_path):
|
||||
|
||||
|
||||
def dsm_upload_downloaded_files(downloaded_files, ticket_id, serial_id, json_mode=False):
|
||||
"""将所有已下载文件上传到群晖 DSM,按 ticket 和 issue 组织目录结构"""
|
||||
if not DSM_URL or not DSM_USER or not DSM_PASSWORD or not DSM_PATH:
|
||||
"""将所有已下载文件并发上传到群晖 DSM,按 ticket 和 issue 组织目录结构"""
|
||||
if not cfg.dsm_url or not cfg.dsm_user or not cfg.dsm_password or not cfg.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 []
|
||||
|
||||
# 目录名: ticketID_serialID
|
||||
folder_name = f"{ticket_id}_{serial_id}" if serial_id else ticket_id
|
||||
|
||||
if not json_mode:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"上传到群晖 DSM: {DSM_URL}")
|
||||
print(f"目标路径: {DSM_PATH}/{folder_name}")
|
||||
print(f"上传到群晖 DSM: {cfg.dsm_url}")
|
||||
print(f"目标路径: {cfg.dsm_path}/{folder_name}")
|
||||
print('='*60)
|
||||
|
||||
upload_results = []
|
||||
try:
|
||||
sid = dsm_login()
|
||||
if not json_mode:
|
||||
@@ -364,20 +305,16 @@ def dsm_upload_downloaded_files(downloaded_files, ticket_id, serial_id, json_mod
|
||||
print(f" DSM 登录失败: {e}", file=sys.stderr)
|
||||
return [{"error": f"DSM 登录失败: {e}"}]
|
||||
|
||||
for f in files_to_upload:
|
||||
def _upload_one(f):
|
||||
local_path = f["savedPath"]
|
||||
filename = os.path.basename(local_path)
|
||||
issue_id = f.get("issueId", "")
|
||||
|
||||
# 根据 issueId 判断目录结构
|
||||
# SR 附件: {DSM_PATH}/{ticketID_serialID}/{filename}
|
||||
# IssueItem 附件: {DSM_PATH}/{ticketID_serialID}/{issueID}/{filename}
|
||||
if issue_id:
|
||||
remote_path = f"{DSM_PATH}/{folder_name}/{issue_id}"
|
||||
else:
|
||||
remote_path = f"{DSM_PATH}/{folder_name}"
|
||||
remote_path = (
|
||||
f"{cfg.dsm_path}/{folder_name}/{issue_id}"
|
||||
if issue_id else
|
||||
f"{cfg.dsm_path}/{folder_name}"
|
||||
)
|
||||
full_remote_path = f"{remote_path}/{filename}"
|
||||
|
||||
entry = {
|
||||
"file": filename,
|
||||
"ticketId": ticket_id,
|
||||
@@ -389,13 +326,18 @@ def dsm_upload_downloaded_files(downloaded_files, ticket_id, serial_id, json_mod
|
||||
dsm_upload_file(sid, local_path, remote_path)
|
||||
entry["success"] = True
|
||||
if not json_mode:
|
||||
print(f" 上传成功: {filename} -> {full_remote_path}")
|
||||
with print_lock:
|
||||
print(f" 上传成功: {filename} -> {full_remote_path}")
|
||||
except Exception as e:
|
||||
entry["success"] = False
|
||||
entry["error"] = str(e)
|
||||
if not json_mode:
|
||||
print(f" 上传失败: {filename}: {e}")
|
||||
upload_results.append(entry)
|
||||
with print_lock:
|
||||
print(f" 上传失败: {filename}: {e}")
|
||||
return entry
|
||||
|
||||
with ThreadPoolExecutor(max_workers=cfg.max_workers) as executor:
|
||||
upload_results = list(executor.map(_upload_one, files_to_upload))
|
||||
|
||||
if not json_mode:
|
||||
ok = sum(1 for r in upload_results if r.get("success"))
|
||||
@@ -414,15 +356,8 @@ def print_attachment_summary(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
|
||||
|
||||
total_files = sum(1 for _, atts in all_attachments for a in atts if a["CategoryCode"] == "2")
|
||||
total_links = sum(1 for _, atts in all_attachments for a in atts if a["CategoryCode"] == "3")
|
||||
print(f" 合计: {total_files} 个文件附件, {total_links} 个链接附件\n")
|
||||
|
||||
idx = 0
|
||||
@@ -433,37 +368,164 @@ def print_attachment_summary(all_attachments):
|
||||
for a in atts:
|
||||
idx += 1
|
||||
cat = "文件" if a["CategoryCode"] == "2" else "链接"
|
||||
size = a.get("SizeInkB", "")
|
||||
size_str = ""
|
||||
if size and cat == "文件":
|
||||
if a.get("SizeInkB") and cat == "文件":
|
||||
try:
|
||||
kb = float(size)
|
||||
if kb > 1024:
|
||||
size_str = f" ({kb/1024:.1f} MB)"
|
||||
else:
|
||||
size_str = f" ({kb:.0f} KB)"
|
||||
kb = float(a["SizeInkB"])
|
||||
size_str = f" ({kb/1024:.1f} MB)" if kb > 1024 else 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}")
|
||||
if a.get("MimeType"):
|
||||
print(f" MIME: {a['MimeType']}")
|
||||
if a.get("LinkWebURI"):
|
||||
print(f" 链接: {a['LinkWebURI']}")
|
||||
print()
|
||||
|
||||
|
||||
def _download_single_file(session, att, label, issue_id, odata_url, json_mode):
|
||||
"""下载单个文件附件,流式写入磁盘(用于多线程)"""
|
||||
entry = {
|
||||
"source": label, "issueId": issue_id,
|
||||
"c4cName": att["FileName"], "type": "file", "mime": att.get("MimeType"),
|
||||
}
|
||||
file_path = os.path.join(cfg.output_dir, att["FileName"])
|
||||
try:
|
||||
download_file_via_odata(session, att, file_path, odata_url)
|
||||
entry["savedPath"] = os.path.abspath(file_path)
|
||||
entry["savedName"] = att["FileName"]
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" ✓ saved: {file_path}")
|
||||
except Exception as e:
|
||||
entry["error"] = str(e)
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" ✗ OData 下载失败 ({att['FileName']}): {e}")
|
||||
return entry
|
||||
|
||||
|
||||
def _download_single_link(link_att, label, issue_id, json_mode):
|
||||
"""下载单个链接附件(用于多线程)"""
|
||||
link_url = link_att.get("LinkWebURI")
|
||||
entry = {
|
||||
"source": label, "issueId": issue_id,
|
||||
"c4cName": link_att["FileName"], "type": "link", "linkUrl": link_url,
|
||||
}
|
||||
|
||||
if not link_url:
|
||||
entry["error"] = "无链接地址"
|
||||
return entry
|
||||
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" {link_att['FileName']}: {link_url}")
|
||||
|
||||
r = download_link_via_scrapling(link_url, link_att["FileName"])
|
||||
if r["saved"]:
|
||||
entry["savedPath"] = os.path.abspath(r["saved"])
|
||||
entry["savedName"] = os.path.basename(r["saved"])
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" ✓ saved: {r['saved']}")
|
||||
else:
|
||||
entry["error"] = r["error"]
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" ✗ 下载失败: {r['error']}")
|
||||
return entry
|
||||
|
||||
|
||||
def _do_download(session, attachments, label, issue_id, odata_url, json_mode):
|
||||
"""并发下载附件列表,返回下载结果列表"""
|
||||
file_atts = [a for a in attachments if a["CategoryCode"] == "2"]
|
||||
link_atts = [a for a in attachments if a["CategoryCode"] == "3"]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=cfg.max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(_download_single_file, session, att, label, issue_id, odata_url, json_mode)
|
||||
for att in file_atts
|
||||
] + [
|
||||
executor.submit(_download_single_link, att, label, issue_id, json_mode)
|
||||
for att in link_atts
|
||||
]
|
||||
|
||||
downloaded_entries = []
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
downloaded_entries.append(future.result())
|
||||
except Exception as e:
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" ✗ 下载任务异常: {e}")
|
||||
|
||||
return downloaded_entries
|
||||
|
||||
|
||||
def _process_issue_item(session, item, list_only, json_mode):
|
||||
"""处理单个 XIssueItem:fetch detail → list attachments → download(供并发调用)"""
|
||||
item_oid = item["ObjectID"]
|
||||
issue_uuid = item.get("XIssueItemUUIDcontent_SDK", "")
|
||||
issue_desc = (item.get("IssuesDescriptionX_SDK") or "")[:80]
|
||||
|
||||
issue_id = ""
|
||||
try:
|
||||
item_detail = get_issue_item_detail(session, item_oid)
|
||||
issue_id = item_detail.get("IssueID_SDK", "")
|
||||
except Exception as e:
|
||||
with print_lock:
|
||||
print(f" ⚠ 获取 IssueID 失败 ({item_oid}): {e}", file=sys.stderr)
|
||||
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f"\n XIssueItem: {item_oid}")
|
||||
if issue_id:
|
||||
print(f" IssueID: {issue_id}")
|
||||
print(f" UUID: {issue_uuid}")
|
||||
print(f" 描述: {issue_desc}")
|
||||
|
||||
issue_entry = {
|
||||
"objectId": item_oid,
|
||||
"issueId": issue_id,
|
||||
"uuid": issue_uuid,
|
||||
"description": issue_desc,
|
||||
"attachments": [],
|
||||
}
|
||||
downloaded_entries = []
|
||||
|
||||
if not issue_uuid:
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(" ⚠ 无 XIssueItemUUID,跳过")
|
||||
return issue_entry, downloaded_entries
|
||||
|
||||
atts = list_issue_item_attachments(session, issue_uuid)
|
||||
issue_entry["attachments"] = atts
|
||||
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" 找到 {len(atts)} 个附件")
|
||||
|
||||
if not list_only:
|
||||
label = f"IssueItem-{issue_id}" if issue_id else f"IssueItem-{item_oid[:12]}"
|
||||
downloaded_entries = _do_download(
|
||||
session, atts, label, issue_id,
|
||||
f"{cfg.odata_cust}/BO_XSRIssueItemAttachmentFolderCollection",
|
||||
json_mode,
|
||||
)
|
||||
|
||||
return issue_entry, downloaded_entries
|
||||
|
||||
|
||||
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)
|
||||
cfg.output_dir = output_dir
|
||||
os.makedirs(cfg.output_dir, exist_ok=True)
|
||||
|
||||
session = get_session()
|
||||
result = {
|
||||
"ticketId": ticket_id,
|
||||
"outputDir": os.path.abspath(OUTPUT_DIR),
|
||||
"outputDir": os.path.abspath(cfg.output_dir),
|
||||
"success": True,
|
||||
"error": None,
|
||||
"srAttachments": [],
|
||||
@@ -490,9 +552,10 @@ def run(ticket_id, output_dir, list_only=False, json_mode=False):
|
||||
print(f"找到 {len(sr_attachments)} 个附件")
|
||||
|
||||
if not list_only:
|
||||
_do_download(session, sr_attachments, "SR", "", None, result, json_mode)
|
||||
entries = _do_download(session, sr_attachments, "SR", "", None, json_mode)
|
||||
result["downloadedFiles"].extend(entries)
|
||||
|
||||
# 3) XIssueItem 级别附件
|
||||
# 3) XIssueItem 级别附件(并发处理每个 item)
|
||||
if not json_mode:
|
||||
print(f"\n{'='*60}")
|
||||
print("XIssueItem 级别附件 (BO_XSRIssueItemAttachmentFolder)")
|
||||
@@ -501,60 +564,28 @@ def run(ticket_id, output_dir, list_only=False, json_mode=False):
|
||||
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]
|
||||
|
||||
# 通过 ObjectID 查询详细信息,获取真实的 IssueID_SDK
|
||||
issue_id = ""
|
||||
try:
|
||||
item_detail = get_issue_item_detail(session, item_oid)
|
||||
issue_id = item_detail.get("IssueID_SDK", "")
|
||||
except Exception as e:
|
||||
print(f" ⚠ 获取 IssueID 失败: {e}", file=sys.stderr)
|
||||
|
||||
issue_entry = {
|
||||
"objectId": item_oid,
|
||||
"issueId": issue_id,
|
||||
"uuid": issue_uuid,
|
||||
"description": issue_desc,
|
||||
"attachments": [],
|
||||
}
|
||||
|
||||
if not json_mode:
|
||||
print(f"\n XIssueItem: {item_oid}")
|
||||
if issue_id:
|
||||
print(f" IssueID: {issue_id}")
|
||||
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:
|
||||
label = f"IssueItem-{issue_id}" if issue_id else f"IssueItem-{item_oid[:12]}"
|
||||
_do_download(
|
||||
session, atts, label, issue_id,
|
||||
f"{ODATA_CUST}/BO_XSRIssueItemAttachmentFolderCollection",
|
||||
result, json_mode,
|
||||
)
|
||||
|
||||
result["issueItems"].append(issue_entry)
|
||||
with ThreadPoolExecutor(max_workers=cfg.max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(_process_issue_item, session, item, list_only, json_mode)
|
||||
for item in issue_items
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
issue_entry, downloaded_entries = future.result()
|
||||
result["issueItems"].append(issue_entry)
|
||||
result["downloadedFiles"].extend(downloaded_entries)
|
||||
except Exception as e:
|
||||
if not json_mode:
|
||||
print(f" ✗ XIssueItem 处理异常: {e}", file=sys.stderr)
|
||||
|
||||
# 4) 汇总清单
|
||||
if not json_mode:
|
||||
all_attachments = [("SR", sr_attachments)]
|
||||
for ie in result["issueItems"]:
|
||||
ie_label = f"IssueItem-{ie['issueId']}" if ie.get("issueId") else f"IssueItem-{ie['objectId'][:12]}"
|
||||
ie_label = (
|
||||
f"IssueItem-{ie['issueId']}" if ie.get("issueId")
|
||||
else f"IssueItem-{ie['objectId'][:12]}"
|
||||
)
|
||||
all_attachments.append((ie_label, ie["attachments"]))
|
||||
print_attachment_summary(all_attachments)
|
||||
|
||||
@@ -567,90 +598,6 @@ def run(ticket_id, output_dir, list_only=False, json_mode=False):
|
||||
return result
|
||||
|
||||
|
||||
def _download_single_file(session, att, label, issue_id, odata_url, json_mode):
|
||||
"""下载单个文件附件(用于多线程)"""
|
||||
entry = {"source": label, "issueId": issue_id, "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:
|
||||
with print_lock:
|
||||
print(f" ✓ saved: {file_path}")
|
||||
except Exception as e:
|
||||
entry["error"] = str(e)
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" ✗ OData 下载失败 ({att['FileName']}): {e}")
|
||||
return entry
|
||||
|
||||
|
||||
def _download_single_link(link_att, label, issue_id, json_mode):
|
||||
"""下载单个链接附件(用于多线程)"""
|
||||
link_url = link_att.get("LinkWebURI")
|
||||
entry = {"source": label, "issueId": issue_id, "c4cName": link_att["FileName"], "type": "link", "linkUrl": link_url}
|
||||
|
||||
if not link_url:
|
||||
entry["error"] = "无链接地址"
|
||||
return entry
|
||||
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" {link_att['FileName']}: {link_url}")
|
||||
|
||||
r = download_link_via_scrapling(link_url, link_att["FileName"])
|
||||
if r["saved"]:
|
||||
entry["savedPath"] = os.path.abspath(r["saved"])
|
||||
entry["savedName"] = os.path.basename(r["saved"])
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" ✓ saved: {r['saved']}")
|
||||
else:
|
||||
entry["error"] = r["error"]
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" ✗ 下载失败: {r['error']}")
|
||||
return entry
|
||||
|
||||
|
||||
def _do_download(session, attachments, label, issue_id, 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"]
|
||||
|
||||
downloaded_entries = []
|
||||
|
||||
# 使用线程池并发下载
|
||||
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
||||
futures = []
|
||||
|
||||
# 提交文件附件下载任务
|
||||
for att in file_atts:
|
||||
future = executor.submit(_download_single_file, session, att, label, issue_id, odata_url, json_mode)
|
||||
futures.append(future)
|
||||
|
||||
# 提交链接附件下载任务
|
||||
for att in link_atts:
|
||||
future = executor.submit(_download_single_link, att, label, issue_id, json_mode)
|
||||
futures.append(future)
|
||||
|
||||
# 收集结果
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
entry = future.result()
|
||||
downloaded_entries.append(entry)
|
||||
except Exception as e:
|
||||
if not json_mode:
|
||||
with print_lock:
|
||||
print(f" ✗ 下载任务异常: {e}")
|
||||
|
||||
# 将结果追加到总结果中
|
||||
result["downloadedFiles"].extend(downloaded_entries)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="SAP C4C 附件下载工具")
|
||||
parser.add_argument("--tenant", default=os.environ.get("C4C_TENANT", ""),
|
||||
@@ -663,7 +610,7 @@ def main():
|
||||
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="仅列出附件清单,不下载")
|
||||
parser.add_argument("--max-workers", type=int, default=5, help="并发下载线程数 (默认: 5)")
|
||||
parser.add_argument("--max-workers", type=int, default=5, help="并发线程数 (默认: 5)")
|
||||
|
||||
# 群晖 DSM 上传参数
|
||||
parser.add_argument("--dsm-url", default=os.environ.get("DSM_URL", ""),
|
||||
@@ -681,31 +628,27 @@ def main():
|
||||
parser.error("必须提供 --tenant, --user, --password 参数,或设置 C4C_TENANT, C4C_USERNAME, C4C_PASSWORD 环境变量")
|
||||
|
||||
# 初始化全局配置
|
||||
global TENANT, USERNAME, PASSWORD, ODATA_C4C, ODATA_CUST, SOAP_URL, MAX_WORKERS
|
||||
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"
|
||||
MAX_WORKERS = args.max_workers
|
||||
|
||||
# 初始化 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
|
||||
cfg.tenant = args.tenant.rstrip("/")
|
||||
cfg.username = args.user
|
||||
cfg.password = args.password
|
||||
cfg.max_workers = args.max_workers
|
||||
cfg.dsm_url = args.dsm_url.rstrip("/") if args.dsm_url else ""
|
||||
cfg.dsm_user = args.dsm_user
|
||||
cfg.dsm_password = args.dsm_password
|
||||
cfg.dsm_path = args.dsm_path
|
||||
cfg.init_endpoints()
|
||||
|
||||
if not args.json_mode and not args.list_only:
|
||||
print(f"并发下载线程数: {MAX_WORKERS}")
|
||||
print(f"并发线程数: {cfg.max_workers}")
|
||||
|
||||
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"]:
|
||||
if cfg.dsm_url and not args.list_only and result["success"]:
|
||||
serial_id = result.get("serialId", "")
|
||||
upload_results = dsm_upload_downloaded_files(result["downloadedFiles"], args.ticket, serial_id, args.json_mode)
|
||||
upload_results = dsm_upload_downloaded_files(
|
||||
result["downloadedFiles"], args.ticket, serial_id, args.json_mode
|
||||
)
|
||||
result["dsmUpload"] = upload_results
|
||||
|
||||
# 上传完成后清理本地下载文件
|
||||
|
||||
Reference in New Issue
Block a user