下载逻辑

This commit is contained in:
2026-06-03 16:46:10 +08:00
parent debb901f32
commit 38b6f9c89d
7 changed files with 430 additions and 4 deletions
@@ -275,6 +275,9 @@ public class ContractLaunchDto implements Serializable {
/** 签署完成后的合同文件大小(字节) */
private Long signedFileSize;
/** 在线签章平台原始下载URL(保留原始地址,signedFilePath 存本地路径) */
private String signedFileUrl;
/** 未在线签署的原因(线下签署时填写) */
private String signedOfflineReason;
@@ -36,5 +36,8 @@ public class ContractLaunchSealDto implements Serializable {
private Long signedFileSize;
/** 未在线签署的原因(线下签署时填写) */
/** 在线签章平台原始下载URL(云平台签署完成时返回的下载地址) */
private String signedFileUrl;
private String signedOfflineReason;
}
@@ -21,6 +21,12 @@ import com.nbport.zgwl.contractlaunch.utils.JsonUtils;
import com.nbport.zgwl.contractpush.dto.PushPayloadDTO;
import com.nbport.zgwl.contractpush.service.DataCollectionPushService;
import com.nbport.zgwl.contractpush.service.SettlementPushService;
import com.nbport.zgwl.seal.service.SealTokenService;
import com.nbport.zgwl.seal.config.SealConfig;
import com.nbport.zgwl.seal.utils.SealHttpClient;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.nbport.zgwl.service.LocalFileService;
import com.nbport.zgwl.entity.SysUserEntity;
import com.nbport.zgwl.exception.ServiceException;
import com.nbport.zgwl.utils.AdminSecurityManage;
@@ -54,6 +60,9 @@ public class ContractLaunchServiceImpl implements ContractLaunchService {
private final SettlementPushService settlementPushService;
private final DataCollectionPushService dataCollectionPushService;
private final ContractLaunchLegalFileBridgeService legalFileBridgeService;
private final LocalFileService localFileService;
private final SealTokenService sealTokenService;
private final SealConfig sealConfig;
/** 分页查询,附带合同方/文件/履行计划(列表页不查日志) */
@Override
@@ -390,7 +399,10 @@ public class ContractLaunchServiceImpl implements ContractLaunchService {
contract.setSealContractId(sealDto.getSealContractId() == null || sealDto.getSealContractId().isBlank() ? contract.getSealContractId() : sealDto.getSealContractId());
contract.setSealContractStatus(sealDto.getSealContractStatus());
contract.setSignedFileName(sealDto.getSignedFileName());
contract.setSignedFilePath(sealDto.getSignedFilePath());
contract.setSignedFilePath(resolveSignedFilePath(sealDto));
contract.setSignedFileUrl(sealDto.getSignedFileUrl() != null && !sealDto.getSignedFileUrl().isBlank()
? sealDto.getSignedFileUrl()
: (isExternalUrl(sealDto.getSignedFilePath()) ? sealDto.getSignedFilePath() : null));
contract.setSignedFileType(sealDto.getSignedFileType());
contract.setSignedFileSize(sealDto.getSignedFileSize());
contract.setSignedOfflineReason(sealDto.getSignedOfflineReason());
@@ -1261,6 +1273,61 @@ public class ContractLaunchServiceImpl implements ContractLaunchService {
.collect(Collectors.toList());
}
/**
* 如果 signedFilePath 是外部URL,则下载到本地并返回本地路径;
* 如果已是本地路径则直接返回。
*/
private String resolveSignedFilePath(ContractLaunchSealDto sealDto) {
String filePath = sealDto.getSignedFilePath();
if (filePath == null || filePath.isBlank()) {
return filePath;
}
if (isExternalUrl(filePath)) {
log.info("签章文件为外部URL,先通过签章API获取新下载地址: {}", filePath);
String token = sealTokenService.getAccessToken();
// 通过签章API获取服务端可用的下载URL
String freshUrl = fetchSealDownloadUrl(sealDto.getSealContractId(), token, filePath);
log.info("获取到签章下载地址: {}", freshUrl);
String localPath = localFileService.downloadFromUrl(freshUrl, token);
log.info("签章文件已下载到本地: {}", localPath);
return localPath;
}
return filePath;
}
/**
* 通过签章平台API获取下载URL(服务端IP绑定的新URL)
*/
private String fetchSealDownloadUrl(String sealContractId, String token, String fallbackUrl) {
try {
String apiUrl = sealConfig.getBaseUrl() + "/saas/api/contract/download";
JSONObject body = new JSONObject();
body.put("contractId", sealContractId);
String response = SealHttpClient.post(apiUrl, body.toJSONString(), token, "application/json");
log.info("签章平台下载API响应: {}", response);
JSONObject result = JSON.parseObject(response);
if (result != null && result.getBooleanValue("isSuccess")) {
Object dataObj = result.get("data");
log.info("签章平台data类型: {}, 值: {}", dataObj != null ? dataObj.getClass().getSimpleName() : "null", dataObj);
if (dataObj instanceof JSONObject) {
String url = ((JSONObject) dataObj).getString("downloadUrl");
if (url != null && !url.isBlank()) return url;
} else if (dataObj instanceof String && !((String) dataObj).isBlank()) {
return (String) dataObj;
}
} else {
log.warn("签章平台下载API返回失败: {}", response);
}
} catch (Exception e) {
log.warn("通过签章API获取下载URL失败,使用原始URL: {}", e.getMessage());
}
return fallbackUrl;
}
private boolean isExternalUrl(String path) {
return path != null && (path.startsWith("http://") || path.startsWith("https://"));
}
private String trim(String value) {
return value == null ? null : value.trim();
}
@@ -17,6 +17,11 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -79,6 +84,128 @@ public class LocalFileService {
}
}
/**
* 从外部URL下载文件并保存到本地,返回本地相对路径(如 20260603/uuid.pdf)。
* 文件名优先从 Content-Disposition 或 URL 路径提取,否则使用兜底名。
*/
public String downloadFromUrl(String fileUrl) {
return downloadFromUrl(fileUrl, null);
}
public String downloadFromUrl(String fileUrl, String accessToken) {
if (fileUrl == null || fileUrl.isBlank()) {
throw new ServiceException("下载URL不能为空");
}
// 先尝试不带 token(签章平台下载 URL 自带 JWT 认证)
try {
return doDownload(fileUrl, null);
} catch (ServiceException e) {
// 如果 401/403,再尝试带 token
if (accessToken != null && !accessToken.isBlank() && e.getMessage().contains("40")) {
log.info("无token下载失败,尝试带token重试: {}", fileUrl);
return doDownload(fileUrl, accessToken);
}
throw e;
}
}
private String doDownload(String fileUrl, String accessToken) {
HttpURLConnection conn = null;
try {
String cleanUrl = fileUrl.contains("?") ? fileUrl.substring(0, fileUrl.indexOf("?")) : fileUrl;
URL url = new URL(cleanUrl);
conn = openConnection(url);
conn.setConnectTimeout(30000);
conn.setReadTimeout(60000);
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
if (accessToken != null && !accessToken.isBlank()) {
conn.setRequestProperty("access_token", accessToken);
}
int responseCode = conn.getResponseCode();
if (responseCode < 200 || responseCode >= 300) {
// 读取错误响应体用于排查
String errorBody = "";
try (InputStream es = conn.getErrorStream()) {
if (es != null) {
errorBody = new String(es.readAllBytes(), StandardCharsets.UTF_8);
}
} catch (Exception ignored) {}
log.error("下载签章文件失败, URL={}, code={}, body={}", fileUrl, responseCode, errorBody);
throw new ServiceException("下载签章文件失败,HTTP状态码: " + responseCode + ", 详情: " + errorBody);
}
String fileName = extractFileName(fileUrl, conn);
String suffix = FileStorageUtils.getFileSuffix(fileName);
Path rootPath = Paths.get(localPath).toAbsolutePath().normalize();
Files.createDirectories(rootPath);
String dateFolder = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String storedName = IdUtil.simpleUUID() + suffix;
String relativePath = dateFolder + "/" + storedName;
Path targetPath = FileStorageUtils.resolveFilePath(rootPath, relativePath);
Files.createDirectories(targetPath.getParent());
try (InputStream is = conn.getInputStream()) {
Files.copy(is, targetPath);
}
log.info("从URL下载文件成功: {} -> {}", fileUrl, targetPath);
return relativePath;
} catch (IOException e) {
log.error("从URL下载文件失败: {}", fileUrl, e);
throw new ServiceException("下载签章文件失败: " + e.getMessage());
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
private HttpURLConnection openConnection(URL url) throws IOException {
try {
if ("https".equalsIgnoreCase(url.getProtocol())) {
javax.net.ssl.HttpsURLConnection httpsConn = (javax.net.ssl.HttpsURLConnection) url.openConnection();
javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLS");
sslContext.init(null, new javax.net.ssl.TrustManager[]{
new javax.net.ssl.X509TrustManager() {
public void checkClientTrusted(java.security.cert.X509Certificate[] c, String a) {}
public void checkServerTrusted(java.security.cert.X509Certificate[] c, String a) {}
public java.security.cert.X509Certificate[] getAcceptedIssuers() { return new java.security.cert.X509Certificate[0]; }
}
}, new java.security.SecureRandom());
httpsConn.setSSLSocketFactory(sslContext.getSocketFactory());
httpsConn.setHostnameVerifier((hostname, session) -> true);
return httpsConn;
}
return (HttpURLConnection) url.openConnection();
} catch (Exception e) {
log.warn("SSL初始化失败,使用默认连接: {}", e.getMessage());
return (HttpURLConnection) url.openConnection();
}
}
private String extractFileName(String urlStr, HttpURLConnection conn) {
String disposition = conn.getHeaderField("Content-Disposition");
if (disposition != null) {
for (String part : disposition.split(";")) {
part = part.trim();
if (part.toLowerCase().startsWith("filename=")) {
String name = part.substring(part.indexOf('=') + 1).replaceAll("^[\"\']|[\"\']$", "").trim();
if (!name.isBlank()) return name;
}
}
}
String path = urlStr.contains("?") ? urlStr.substring(0, urlStr.indexOf('?')) : urlStr;
String name = path.substring(path.lastIndexOf('/') + 1);
String suffix = FileStorageUtils.getFileSuffix(name);
// 后缀过长(>10)说明是JWT/乱码而非真实文件名, 默认pdf
if (!name.isBlank() && suffix.length() > 0 && suffix.length() <= 10) {
return URLDecoder.decode(name, StandardCharsets.UTF_8);
}
return "signed_contract.pdf";
}
public byte[] readFileBytes(String fileName) {
try {
Path rootPath = Paths.get(localPath).toAbsolutePath().normalize();
@@ -92,4 +219,5 @@ public class LocalFileService {
throw new ServiceException("文件读取失败");
}
}
}
@@ -75,6 +75,7 @@
<result property="signedFilePath" column="SIGNED_FILE_PATH"/>
<result property="signedFileType" column="SIGNED_FILE_TYPE"/>
<result property="signedFileSize" column="SIGNED_FILE_SIZE"/>
<result property="signedFileUrl" column="SIGNED_FILE_URL"/>
<result property="signedOfflineReason" column="SIGNED_OFFLINE_REASON"/>
<result property="signDate" column="SIGN_DATE"/>
<result property="activeDate" column="ACTIVE_DATE"/>
@@ -181,7 +182,7 @@
BIDDING_NO, OPEN_BIDDING_REMARK, CONTRACT_GIST_REMARK,
EW_AMOUNT_TYPE, DJ_AMOUNT, YJ_AMOUNT, YFK_AMOUNT, BZJ_AMOUNT, BODY_SUMMARY,
CONTRACT_TEXT_FILE_NAME, CONTRACT_TEXT_FILE_PATH, CONTRACT_TEXT_FILE_TYPE, CONTRACT_TEXT_FILE_SIZE,
SIGNED_FILE_NAME, SIGNED_FILE_PATH, SIGNED_FILE_TYPE, SIGNED_FILE_SIZE, SIGNED_OFFLINE_REASON,
SIGNED_FILE_NAME, SIGNED_FILE_PATH, SIGNED_FILE_TYPE, SIGNED_FILE_SIZE, SIGNED_FILE_URL, SIGNED_OFFLINE_REASON,
SIGN_DATE, ACTIVE_DATE, ARCHIVE_DATE, ARCHIVE_KEEPER, ARCHIVE_KEEPER_UNIT,
ARCHIVE_KEEPER_UNIT_CODE, INVOICE_DATE, RECEIVE_DATE, REMIND_DAYS,
INITIATOR, INITIATOR_DEPT, COMPANY_NAME, ORG_ID, QUERY_SCOPE,
@@ -259,7 +260,7 @@
EW_AMOUNT_TYPE, DJ_AMOUNT, YJ_AMOUNT, YFK_AMOUNT, BZJ_AMOUNT, BODY_SUMMARY,
TEMPLATE_FIELD_VALUES_JSON,
CONTRACT_TEXT_FILE_NAME, CONTRACT_TEXT_FILE_PATH, CONTRACT_TEXT_FILE_TYPE, CONTRACT_TEXT_FILE_SIZE,
SIGNED_FILE_NAME, SIGNED_FILE_PATH, SIGNED_FILE_TYPE, SIGNED_FILE_SIZE, SIGNED_OFFLINE_REASON,
SIGNED_FILE_NAME, SIGNED_FILE_PATH, SIGNED_FILE_TYPE, SIGNED_FILE_SIZE, SIGNED_FILE_URL, SIGNED_OFFLINE_REASON,
SIGN_DATE, ACTIVE_DATE, ARCHIVE_DATE, ARCHIVE_KEEPER, ARCHIVE_KEEPER_UNIT,
ARCHIVE_KEEPER_UNIT_CODE, INVOICE_DATE, RECEIVE_DATE, REMIND_DAYS,
INITIATOR, INITIATOR_DEPT, COMPANY_NAME, ORG_ID, QUERY_SCOPE,
@@ -279,7 +280,7 @@
#{ewAmountType}, #{djAmount}, #{yjAmount}, #{yfkAmount}, #{bzjAmount}, #{bodySummary},
#{templateFieldValuesJson},
#{contractTextFileName}, #{contractTextFilePath}, #{contractTextFileType}, #{contractTextFileSize},
#{signedFileName}, #{signedFilePath}, #{signedFileType}, #{signedFileSize}, #{signedOfflineReason},
#{signedFileName}, #{signedFilePath}, #{signedFileType}, #{signedFileSize}, #{signedFileUrl}, #{signedOfflineReason},
#{signDate}, #{activeDate}, #{archiveDate}, #{archiveKeeper}, #{archiveKeeperUnit},
#{archiveKeeperUnitCode}, #{invoiceDate}, #{receiveDate}, #{remindDays},
#{initiator}, #{initiatorDept}, #{companyName}, #{orgId}, #{queryScope},
@@ -359,6 +360,7 @@
SIGNED_FILE_PATH = #{signedFilePath},
SIGNED_FILE_TYPE = #{signedFileType},
SIGNED_FILE_SIZE = #{signedFileSize},
SIGNED_FILE_URL = #{signedFileUrl},
SIGNED_OFFLINE_REASON = #{signedOfflineReason},
SIGN_DATE = #{signDate},
ACTIVE_DATE = #{activeDate},
@@ -0,0 +1,207 @@
package com.nbport.zgwl.test;
import com.nbport.zgwl.seal.utils.SealHttpClient;
import com.nbport.zgwl.seal.utils.HMacSHA256Util;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.http.MediaType;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.cert.X509Certificate;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
public class SealDownloadManualTest {
private static final String BASE_URL = "https://saasapi.certca.cn";
private static final String APP_KEY = "A7C4FD32BE454B5";
private static final String APP_SECRET = "6c74358c28c149f18242bd614a101910";
private static final String SAVE_DIR = "D:/temp-seal-download";
public static void main(String[] args) throws Exception {
String contractId = args.length > 0 ? args[0] : "63b5552bfebe44a9de0d16d39162b678";
System.out.println("===== Seal Download Test =====");
String token = getAccessToken();
System.out.println("[1] token OK");
JSONObject body = new JSONObject();
body.put("contractId", contractId);
String response = SealHttpClient.post(
BASE_URL + "/saas/api/contract/download",
body.toJSONString(), token, MediaType.APPLICATION_JSON_VALUE);
JSONObject result = JSON.parseObject(response);
if (result == null || !result.getBooleanValue("isSuccess")) {
System.err.println("[FAIL] " + response);
return;
}
Object dataObj = result.get("data");
String rawUrl = null;
if (dataObj instanceof JSONObject) {
rawUrl = ((JSONObject) dataObj).getString("downloadUrl");
} else if (dataObj instanceof String) {
rawUrl = (String) dataObj;
}
System.out.println("[2] rawUrl: " + rawUrl);
// 尝试多种方式下载
System.out.println("\n[3] Trying download methods...");
// 方法1: 直接GET
System.out.println(" [3a] Direct GET...");
tryDownload(rawUrl, null, false);
// 方法2: 带 access_token header
System.out.println(" [3b] GET + access_token header...");
tryDownload(rawUrl, token, false);
// 方法3: URL编码中文参数后再GET
String encodedUrl = encodeChineseInUrl(rawUrl);
if (!encodedUrl.equals(rawUrl)) {
System.out.println(" [3c] GET with URL-encoded params...");
tryDownload(encodedUrl, null, false);
System.out.println(" [3d] GET URL-encoded + access_token...");
tryDownload(encodedUrl, token, false);
}
// 方法4: POST方式
System.out.println(" [3e] POST download (as API)...");
tryPostDownload(rawUrl, token);
// 方法5: 去掉query string
String noQuery = rawUrl.contains("?") ? rawUrl.substring(0, rawUrl.indexOf('?')) : rawUrl;
System.out.println(" [3f] GET without query string...");
tryDownload(noQuery, token, false);
System.out.println("\n===== Done =====");
}
private static String encodeChineseInUrl(String urlStr) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < urlStr.length(); i++) {
char c = urlStr.charAt(i);
if (c > 127) {
sb.append(URLEncoder.encode(String.valueOf(c), StandardCharsets.UTF_8));
} else {
sb.append(c);
}
}
return sb.toString();
}
private static void tryDownload(String fileUrl, String token, boolean isPost) {
try {
URL url = new URL(fileUrl);
HttpURLConnection conn = openConnection(url);
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
if (token != null && !token.isBlank()) {
conn.setRequestProperty("access_token", token);
}
if (isPost) {
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
try (OutputStream os = conn.getOutputStream()) {
os.write("{}".getBytes(StandardCharsets.UTF_8));
}
}
int code = conn.getResponseCode();
if (code >= 200 && code < 300) {
long size = conn.getContentLengthLong();
System.out.println(" [OK] HTTP " + code + ", size=" + size);
} else {
String err = readStream(conn.getErrorStream());
System.out.println(" [FAIL] HTTP " + code + " body=" + err);
}
conn.disconnect();
} catch (Exception e) {
System.out.println(" [ERR] " + e.getMessage());
}
}
private static void tryPostDownload(String fileUrl, String token) {
try {
URL url = new URL(fileUrl);
HttpURLConnection conn = openConnection(url);
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
if (token != null && !token.isBlank()) {
conn.setRequestProperty("access_token", token);
}
try (OutputStream os = conn.getOutputStream()) {
os.write(new byte[0]);
}
int code = conn.getResponseCode();
if (code >= 200 && code < 300) {
System.out.println(" [OK] HTTP " + code);
} else {
String err = readStream(conn.getErrorStream());
System.out.println(" [FAIL] HTTP " + code + " body=" + err);
}
conn.disconnect();
} catch (Exception e) {
System.out.println(" [ERR] " + e.getMessage());
}
}
private static String getAccessToken() throws Exception {
long ts = System.currentTimeMillis();
JSONObject signData = new JSONObject();
signData.put("appKey", APP_KEY);
signData.put("timestamp", ts);
String sig = HMacSHA256Util.encrypt(JSON.toJSONString(signData), APP_SECRET);
JSONObject req = new JSONObject();
req.put("appKey", APP_KEY);
req.put("timestamp", ts);
req.put("signature", sig);
String resp = SealHttpClient.post(
BASE_URL + "/saas/api/token/getAccessToken",
req.toJSONString(), null, MediaType.APPLICATION_JSON_VALUE);
return JSON.parseObject(resp).getJSONObject("data").getString("access_token");
}
private static HttpURLConnection openConnection(URL url) throws Exception {
if ("https".equalsIgnoreCase(url.getProtocol())) {
HttpsURLConnection h = (HttpsURLConnection) url.openConnection();
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {}
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
}}, new java.security.SecureRandom());
h.setSSLSocketFactory(ctx.getSocketFactory());
h.setHostnameVerifier((host, sess) -> true);
return h;
}
return (HttpURLConnection) url.openConnection();
}
private static String readStream(InputStream is) throws IOException {
if (is == null) return "";
StringBuilder sb = new StringBuilder();
try (BufferedReader r = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line; while ((line = r.readLine()) != null) sb.append(line);
}
return sb.toString();
}
}
+16
View File
@@ -0,0 +1,16 @@
-- 补充 SIGNED_FILE_URL 字段(在线签章原始下载地址)
-- 幂等执行:如已存在则跳过
DECLARE
v_count NUMBER;
BEGIN
SELECT COUNT(*) INTO v_count FROM ALL_TAB_COLUMNS
WHERE OWNER = 'DEVELOPER' AND TABLE_NAME = 'SEAL_CONTRACT_LAUNCH_MAIN' AND COLUMN_NAME = 'SIGNED_FILE_URL';
IF v_count = 0 THEN
EXECUTE IMMEDIATE 'ALTER TABLE SEAL_CONTRACT_LAUNCH_MAIN ADD SIGNED_FILE_URL VARCHAR2(500)';
EXECUTE IMMEDIATE 'COMMENT ON COLUMN SEAL_CONTRACT_LAUNCH_MAIN.SIGNED_FILE_URL IS ''在线签章平台原始下载地址''';
DBMS_OUTPUT.PUT_LINE('SIGNED_FILE_URL column added.');
ELSE
DBMS_OUTPUT.PUT_LINE('SIGNED_FILE_URL already exists, skipped.');
END IF;
END;
/