在 SAP S/4HANA PCE (Private Cloud Edition / 私有云版本) 环境中,直接在 ABAP 层实现纯正的 SFTP (SSH File Transfer Protocol) 会面临网络白名单限制和内核不支持的双重物理墙。PCE 是由 SAP 原厂托管的封闭环境,默认禁止一切未经审批的外部端口访问,且剥夺了底层操作系统的 Shell 执行权限。
方案一:引入 SAP BTP Integration Suite (CPI) —— 官方强烈推荐 (Best Practice)
不要让核心 ERP 去做底层的文件传输。
流程:ABAP 将数据通过 OData API、Proxy (SOAP) 或标准的 HTTP 接口发送给 BTP 平台上的 CPI (Cloud Platform Integration)。
优势:CPI 自带标准且极其强大的 SFTP Adapter。CPI 负责存放私钥、密码,并与外部 SFTP 服务器建立安全的 SSH 隧道进行文件上传/下载。
架构收益:完全解耦,S/4HANA PCE 零定制代码,符合 SAP Clean Core 战略。
案二:让对方服务器提供 HTTPS / REST API 接口 (Modern Approach)
如果只有 ABAP 可以用,请让接收端将 SFTP 改造为 RESTful API (HTTPS)。
流程:在 S/4HANA SM59 中配置 Type G (HTTP Connection to External Server) 的 RFC。ABAP 通过 IF_HTTP_CLIENT (或现代的 cl_web_http_client_manager) 将文件转换为 Base64 或 XSTRING,通过 POST 请求直接发送。
优势:PCE 对 HTTPS (443) 端口的支持和打通极其容易,且完全属于标准的 ABAP Cloud 技术栈。
一个简单的示例上传文件
首先创建一个类方法
CLASS zcl_sftp_via_rest DEFINITION
PUBLIC
FINAL
CREATE PUBLIC.
PUBLIC SECTION.
* 1. 上传文件:传入文件名、二进制数据、目标目录
METHODS upload_file
IMPORTING
iv_filename TYPE string
iv_binary TYPE xstring
iv_remote_dir TYPE string
RETURNING
VALUE(rv_ok) TYPE abap_bool.
* 2. 下载文件:传入文件名、目标目录,导出二进制数据
METHODS download_file
IMPORTING
iv_filename TYPE string
iv_remote_dir TYPE string
EXPORTING
ev_binary TYPE xstring
RETURNING
VALUE(rv_ok) TYPE abap_bool.
* 3. 删除文件:传入文件名、目标目录
METHODS delete_file
IMPORTING
iv_filename TYPE string
iv_remote_dir TYPE string
RETURNING
VALUE(rv_ok) TYPE abap_bool.
PROTECTED SECTION.
PRIVATE SECTION.
* 内部统一 HTTP 调用引擎,实现代码高度复用 (Clean ABAP)
METHODS _call_rest_api
IMPORTING
iv_uri TYPE string
iv_payload TYPE string
EXPORTING
ev_response_body TYPE string
RETURNING
VALUE(rv_status) TYPE i.
ENDCLASS.
CLASS zcl_sftp_via_rest IMPLEMENTATION.
METHOD upload_file.
*----------------------------------------------------------------------*
* [业务方法] 1. 上传文件
*----------------------------------------------------------------------*
rv_ok = abap_false.
TYPES: BEGIN OF ty_request,
filename TYPE string,
content_base64 TYPE string,
remote_dir TYPE string,
END OF ty_request.
DATA: ls_request TYPE ty_request.
ls_request-filename = iv_filename.
ls_request-remote_dir = iv_remote_dir.
ls_request-content_base64 = cl_http_utility=>encode_x_base64( iv_binary ).
* 强制转为小写以适配 Python
DATA(lv_json_payload) = /ui2/cl_json=>serialize(
data = ls_request
pretty_name = /ui2/cl_json=>pretty_mode-low_case ).
* 提前声明接收变量,规避旧版 SAP 内联声明语法冲突
DATA: lv_response TYPE string.
* 调用统一 HTTP 引擎
DATA(lv_status) = _call_rest_api(
EXPORTING iv_uri = '/api/sftp/upload'
iv_payload = lv_json_payload
IMPORTING ev_response_body = lv_response ).
IF lv_status = 200.
rv_ok = abap_true.
ELSE.
MESSAGE |上传失败[{ lv_status }]: { lv_response }| TYPE 'E'.
ENDIF.
ENDMETHOD.
METHOD download_file.
*----------------------------------------------------------------------*
* [业务方法] 2. 下载文件
*----------------------------------------------------------------------*
rv_ok = abap_false.
CLEAR ev_binary.
TYPES: BEGIN OF ty_request,
filename TYPE string,
remote_dir TYPE string,
END OF ty_request.
TYPES: BEGIN OF ty_response,
status TYPE string,
message TYPE string,
content_base64 TYPE string,
END OF ty_response.
DATA: ls_request TYPE ty_request,
ls_response TYPE ty_response.
ls_request-filename = iv_filename.
ls_request-remote_dir = iv_remote_dir.
DATA(lv_json_payload) = /ui2/cl_json=>serialize(
data = ls_request
pretty_name = /ui2/cl_json=>pretty_mode-low_case ).
* 提前声明接收变量
DATA: lv_response_json TYPE string.
* 调用统一 HTTP 引擎
DATA(lv_status) = _call_rest_api(
EXPORTING iv_uri = '/api/sftp/download'
iv_payload = lv_json_payload
IMPORTING ev_response_body = lv_response_json ).
IF lv_status = 200.
* 解析响应 JSON 并将小写字段映射回 ABAP
/ui2/cl_json=>deserialize(
EXPORTING json = lv_response_json
pretty_name = /ui2/cl_json=>pretty_mode-low_case
CHANGING data = ls_response ).
* 将 Base64 解码为 XSTRING
ev_binary = cl_http_utility=>decode_x_base64( ls_response-content_base64 ).
rv_ok = abap_true.
ELSE.
MESSAGE |下载失败[{ lv_status }]: { lv_response_json }| TYPE 'E'.
ENDIF.
ENDMETHOD.
METHOD delete_file.
*----------------------------------------------------------------------*
* [业务方法] 3. 删除文件
*----------------------------------------------------------------------*
rv_ok = abap_false.
TYPES: BEGIN OF ty_request,
filename TYPE string,
remote_dir TYPE string,
END OF ty_request.
DATA(ls_request) = VALUE ty_request( filename = iv_filename remote_dir = iv_remote_dir ).
DATA(lv_json_payload) = /ui2/cl_json=>serialize(
data = ls_request
pretty_name = /ui2/cl_json=>pretty_mode-low_case ).
* 提前声明接收变量
DATA: lv_response TYPE string.
* 调用统一 HTTP 引擎
DATA(lv_status) = _call_rest_api(
EXPORTING iv_uri = '/api/sftp/delete'
iv_payload = lv_json_payload
IMPORTING ev_response_body = lv_response ).
IF lv_status = 200.
rv_ok = abap_true.
ELSE.
MESSAGE |删除失败[{ lv_status }]: { lv_response }| TYPE 'E'.
ENDIF.
ENDMETHOD.
METHOD _call_rest_api.
*----------------------------------------------------------------------*
* [底层引擎] 私有 HTTP 调用抽象
*----------------------------------------------------------------------*
DATA: lo_client TYPE REF TO if_http_client.
rv_status = 500.
* 使用传统的 CL_HTTP_CLIENT 调用 SM59
cl_http_client=>create_by_destination(
EXPORTING
destination = 'Z_PYTHON_SFTP_API'
IMPORTING
client = lo_client
EXCEPTIONS
OTHERS = 1 ).
IF sy-subrc <> 0.
MESSAGE '创建HTTP客户端失败,请检查 SM59 (Z_PYTHON_SFTP_API)' TYPE 'E'.
RETURN.
ENDIF.
* 构造请求参数
lo_client->request->set_method( if_http_request=>co_request_method_post ).
cl_http_utility=>set_request_uri( request = lo_client->request uri = iv_uri ).
lo_client->request->set_content_type( 'application/json' ).
lo_client->request->set_cdata( data = iv_payload ).
* 发送请求
lo_client->send( EXCEPTIONS OTHERS = 1 ).
IF sy-subrc <> 0.
lo_client->get_last_error( IMPORTING message = DATA(lv_err_send) ).
lo_client->close( ).
MESSAGE |发送请求失败: { lv_err_send }| TYPE 'E'.
RETURN.
ENDIF.
* 接收响应
lo_client->receive( EXCEPTIONS OTHERS = 1 ).
IF sy-subrc <> 0.
lo_client->get_last_error( IMPORTING message = DATA(lv_err_recv) ).
lo_client->close( ).
MESSAGE |接收响应失败: { lv_err_recv }| TYPE 'E'.
RETURN.
ENDIF.
* 返回状态码与响应体
lo_client->response->get_status( IMPORTING code = rv_status ).
ev_response_body = lo_client->response->get_cdata( ).
lo_client->close( ).
ENDMETHOD.
ENDCLASS.
python 文件代码:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import paramiko
import base64
import io
import traceback
app = FastAPI(title="SAP SFTP Bridge API")
# ---------------------------------------------------------
# 数据模型定义
# ---------------------------------------------------------
class SftpUploadRequest(BaseModel):
filename: str
content_base64: str
remote_dir: str = "/"
class SftpBasicRequest(BaseModel):
filename: str
remote_dir: str = "/"
# ---------------------------------------------------------
# 辅助函数:自动递归创建远端多级目录 (类似 mkdir -p)
# ---------------------------------------------------------
def ensure_remote_dir(sftp, remote_dir):
dirs = remote_dir.replace('\\', '/').split('/')
current_dir = ''
for d in dirs:
if not d:
continue
current_dir += '/' + d
try:
sftp.stat(current_dir)
except IOError:
try:
sftp.mkdir(current_dir)
except Exception as e:
raise Exception(f"无法创建远端目录 '{current_dir}',请检查该层级权限: {str(e)}")
# ---------------------------------------------------------
# 接口 1:上传文件 (Upload)
# ---------------------------------------------------------
@app.post("/api/sftp/upload")
async def upload_to_sftp(request: SftpUploadRequest):
host, port, username, password = '172.16.*.***', 8000, 'name', '***********'
try:
file_bytes = base64.b64decode(request.content_base64)
file_stream = io.BytesIO(file_bytes)
transport = paramiko.Transport((host, port))
transport.connect(username=username, password=password)
sftp = paramiko.SFTPClient.from_transport(transport)
target_dir = request.remote_dir if request.remote_dir.endswith('/') else request.remote_dir + '/'
final_path = target_dir + request.filename
# 自动创建不存在的文件夹
if target_dir != '/':
ensure_remote_dir(sftp, target_dir)
# 执行上传
sftp.putfo(file_stream, final_path)
sftp.close()
transport.close()
return {"status": "SUCCESS", "message": f"文件 '{request.filename}' 已成功上传至 {host}:{port}{final_path}"}
except Exception as e:
print("\n====== SFTP [Upload] Error ======")
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# ---------------------------------------------------------
# 接口 2:下载文件 (Download)
# ---------------------------------------------------------
@app.post("/api/sftp/download")
async def download_from_sftp(request: SftpBasicRequest):
host, port, username, password = '172.16.*.***', 8000, 'name', '***********'
try:
transport = paramiko.Transport((host, port))
transport.connect(username=username, password=password)
sftp = paramiko.SFTPClient.from_transport(transport)
target_dir = request.remote_dir if request.remote_dir.endswith('/') else request.remote_dir + '/'
final_path = target_dir + request.filename
# 在内存中读取文件内容
file_stream = io.BytesIO()
sftp.getfo(final_path, file_stream)
# 将二进制转回 Base64
file_bytes = file_stream.getvalue()
base64_str = base64.b64encode(file_bytes).decode('utf-8')
sftp.close()
transport.close()
return {
"status": "SUCCESS",
"message": f"文件 '{request.filename}' 下载成功",
"content_base64": base64_str
}
except IOError as e:
print("\n====== SFTP [Download] Error ======")
traceback.print_exc()
raise HTTPException(status_code=404, detail=f"文件不存在或权限拒绝: {str(e)}")
except Exception as e:
print("\n====== SFTP [Download] Error ======")
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# ---------------------------------------------------------
# 接口 3:删除文件 (Delete)
# ---------------------------------------------------------
@app.post("/api/sftp/delete")
async def delete_from_sftp(request: SftpBasicRequest):
host, port, username, password = '172.16.*.***', 8000, 'name', '***********'
try:
transport = paramiko.Transport((host, port))
transport.connect(username=username, password=password)
sftp = paramiko.SFTPClient.from_transport(transport)
target_dir = request.remote_dir if request.remote_dir.endswith('/') else request.remote_dir + '/'
final_path = target_dir + request.filename
# 执行删除
sftp.remove(final_path)
sftp.close()
transport.close()
return {"status": "SUCCESS", "message": f"文件 '{request.filename}' 已被成功删除"}
except IOError as e:
print("\n====== SFTP [Delete] Error ======")
traceback.print_exc()
raise HTTPException(status_code=404, detail=f"文件不存在或无权限删除: {str(e)}")
except Exception as e:
print("\n====== SFTP [Delete] Error ======")
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# ---------------------------------------------------------
# 服务启动入口
# ---------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
我是把python代码运行在自己电脑上,那么就需要用电脑的IP地址与SFTP服务器链接
- 使用局域网 IP(依赖企业专线/VPN)
这是企业开发中最标准的方法。通常你的公司网络与 SAP PCE(私有云)之间已经拉了 VPN 隧道 或 专线。
在你运行 Python 的本地电脑上,打开命令行(CMD),输入 ipconfig。
找到你的局域网 IP(通常是 10.x.x.x、172.x.x.x 或 192.168.x.x 格式)。
在 SM59 中配置:
目标主机:填入这个真实的局域网 IP(例如 172.16.5.43)。
服务编号/端口:8001。
注意网络防火墙:你本地电脑的防火墙(Windows Defender)默认可能会拦截入站流量。你必须在控制面板中为 Python 或 8001 端口添加一条“入站规则”,允许外部访问。
如果失败可以先用WinSCP测试一下自己电脑有没有连上。
后续可以把python文件布置在服务器上。
SAP 测试程序,可根据自己的需求调整,这只是一个简单的例子:
REPORT ztest_sftp_via_rest.
*----------------------------------------------------------------------*
* 屏幕定义 (Selection Screen Definition)
*----------------------------------------------------------------------*
SELECTION-SCREEN BEGIN OF BLOCK b_op WITH FRAME TITLE TEXT-001.
" 操作模式选择
PARAMETERS: p_upl RADIOBUTTON GROUP g1 DEFAULT 'X' USER-COMMAND uc1, " 上传
p_dwn RADIOBUTTON GROUP g1, " 下载
p_del RADIOBUTTON GROUP g1. " 删除
SELECTION-SCREEN END OF BLOCK b_op.
SELECTION-SCREEN BEGIN OF BLOCK b_path WITH FRAME TITLE TEXT-002.
" 本地文件路径 (带 F4 搜索帮助)
PARAMETERS: p_lfile TYPE string LOWER CASE.
" 远端文件名 (下载和删除时必须填写,上传时将自动从本地路径截取)
PARAMETERS: p_rfile TYPE string LOWER CASE.
" 远端目标目录
PARAMETERS: p_rmtdir TYPE string LOWER CASE DEFAULT '/share/sap/'.
SELECTION-SCREEN END OF BLOCK b_path.
*----------------------------------------------------------------------*
* 屏幕事件:动态校验必输项
*----------------------------------------------------------------------*
AT SELECTION-SCREEN.
" 仅在点击执行按钮时校验
IF sy-ucomm = 'ONLI'.
IF p_upl = abap_true AND p_lfile IS INITIAL.
MESSAGE '上传模式下,请指定本地文件路径' TYPE 'E'.
ENDIF.
IF p_dwn = abap_true.
IF p_lfile IS INITIAL.
MESSAGE '下载模式下,请指定本地保存路径 (通过 F4 选择)' TYPE 'E'.
ENDIF.
IF p_rfile IS INITIAL.
MESSAGE '下载模式下,请明确指定远端文件名' TYPE 'E'.
ENDIF.
ENDIF.
IF p_del = abap_true AND p_rfile IS INITIAL.
MESSAGE '删除模式下,请明确指定远端文件名' TYPE 'E'.
ENDIF.
ENDIF.
*----------------------------------------------------------------------*
* 屏幕事件:本地文件 F4 搜索帮助 (智能区分打开与保存)
*----------------------------------------------------------------------*
AT SELECTION-SCREEN ON VALUE-REQUEST FOR p_lfile.
DATA: lt_files TYPE filetable,
lv_rc TYPE i,
lv_path TYPE string,
lv_fname TYPE string.
IF p_upl = abap_true.
" 上传模式:弹出文件选择窗口
cl_gui_frontend_services=>file_open_dialog(
EXPORTING window_title = '请选择要上传的文件'
CHANGING file_table = lt_files
rc = lv_rc
EXCEPTIONS OTHERS = 5 ).
IF sy-subrc = 0 AND lv_rc > 0.
p_lfile = lt_files[ 1 ]-filename.
ENDIF.
ELSEIF p_dwn = abap_true.
" 下载模式:弹出文件保存窗口
cl_gui_frontend_services=>file_save_dialog(
EXPORTING window_title = '请选择下载后的保存位置'
default_file_name = p_rfile " 默认保存名与远端同名
CHANGING filename = lv_fname
path = lv_path
fullpath = p_lfile
EXCEPTIONS OTHERS = 5 ).
ELSE.
MESSAGE '删除操作无需选择本地文件' TYPE 'S'.
ENDIF.
*----------------------------------------------------------------------*
* 主程序逻辑 (Main Logic)
*----------------------------------------------------------------------*
START-OF-SELECTION.
DATA: lt_data TYPE STANDARD TABLE OF x255,
lv_length TYPE i,
lv_xstr TYPE xstring,
lv_filename TYPE string.
DATA(lo_sftp) = NEW zcl_sftp_via_rest( ).
DATA(lv_ok) = abap_false.
" ====================================================================
" 模式 1:上传逻辑 (Upload)
" ====================================================================
IF p_upl = abap_true.
" 1. 读取本地文件
cl_gui_frontend_services=>gui_upload(
EXPORTING filename = p_lfile
filetype = 'BIN'
IMPORTING filelength = lv_length
CHANGING data_tab = lt_data
EXCEPTIONS OTHERS = 19 ).
IF sy-subrc <> 0.
MESSAGE '读取本地文件失败' TYPE 'E'.
RETURN.
ENDIF.
" 2. 转为 XSTRING
CALL FUNCTION 'SCMS_BINARY_TO_XSTRING'
EXPORTING input_length = lv_length
IMPORTING buffer = lv_xstr
TABLES binary_tab = lt_data
EXCEPTIONS OTHERS = 2.
" 3. 提取文件名
CALL FUNCTION 'SO_SPLIT_FILE_AND_PATH'
EXPORTING full_name = p_lfile
IMPORTING stripped_name = lv_filename
EXCEPTIONS OTHERS = 1.
" 4. 执行上传
lv_ok = lo_sftp->upload_file(
iv_filename = lv_filename
iv_binary = lv_xstr
iv_remote_dir = p_rmtdir ).
IF lv_ok = abap_true.
MESSAGE |文件 [{ lv_filename }] 上传成功!| TYPE 'S'.
ENDIF.
" ====================================================================
" 模式 2:下载逻辑 (Download)
" ====================================================================
ELSEIF p_dwn = abap_true.
" 1. 执行下载,获取 XSTRING 二进制数据
lv_ok = lo_sftp->download_file(
EXPORTING iv_filename = p_rfile
iv_remote_dir = p_rmtdir
IMPORTING ev_binary = lv_xstr ).
IF lv_ok = abap_true.
" 2. 将 XSTRING 转换为内表
CALL FUNCTION 'SCMS_XSTRING_TO_BINARY'
EXPORTING buffer = lv_xstr
IMPORTING output_length = lv_length
TABLES binary_tab = lt_data.
" 3. 将文件写入本地电脑
cl_gui_frontend_services=>gui_download(
EXPORTING filename = p_lfile
filetype = 'BIN'
bin_filesize = lv_length
CHANGING data_tab = lt_data
EXCEPTIONS OTHERS = 24 ).
IF sy-subrc = 0.
MESSAGE |文件 [{ p_rfile }] 下载并保存成功!| TYPE 'S'.
ELSE.
MESSAGE '写入本地电脑失败,请检查路径权限或文件是否被占用' TYPE 'E'.
ENDIF.
ENDIF.
" ====================================================================
" 模式 3:删除逻辑 (Delete)
" ====================================================================
ELSEIF p_del = abap_true.
" 直接调用删除接口
lv_ok = lo_sftp->delete_file(
iv_filename = p_rfile
iv_remote_dir = p_rmtdir ).
IF lv_ok = abap_true.
MESSAGE |远端文件 [{ p_rfile }] 删除成功!| TYPE 'S'.
ENDIF.
ENDIF.

228

被折叠的 条评论
为什么被折叠?



