|
|
@@ -0,0 +1,757 @@
|
|
|
+package com.muzhi.changguchuan.seal.service.impl;
|
|
|
+
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
+import com.alibaba.fastjson.JSONArray;
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
+import com.aliyun.dingtalkyida_2_0.models.GetInstanceByIdHeaders;
|
|
|
+import com.aliyun.dingtalkyida_2_0.models.GetInstanceByIdRequest;
|
|
|
+import com.aliyun.dingtalkyida_2_0.models.GetInstanceByIdResponse;
|
|
|
+import com.aliyun.dingtalkyida_1_0.models.GetOpenUrlHeaders;
|
|
|
+import com.aliyun.dingtalkyida_1_0.models.GetOpenUrlRequest;
|
|
|
+import com.aliyun.dingtalkyida_1_0.models.GetOpenUrlResponse;
|
|
|
+import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataHeaders;
|
|
|
+import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataRequest;
|
|
|
+import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataResponse;
|
|
|
+import com.aliyun.tea.TeaException;
|
|
|
+import com.muzhi.changguchuan.seal.config.SealProperties;
|
|
|
+import com.muzhi.changguchuan.seal.model.SealApprovalRequest;
|
|
|
+import com.muzhi.changguchuan.seal.model.SealResult;
|
|
|
+import com.muzhi.changguchuan.seal.service.SealService;
|
|
|
+import com.muzhi.changguchuan.service.AccessTokenService;
|
|
|
+import com.aliyun.teautil.models.RuntimeOptions;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.imageio.ImageIO;
|
|
|
+import java.awt.Color;
|
|
|
+import java.awt.Font;
|
|
|
+import java.awt.FontMetrics;
|
|
|
+import java.awt.Graphics2D;
|
|
|
+import java.awt.RenderingHints;
|
|
|
+import java.awt.image.BufferedImage;
|
|
|
+import java.io.ByteArrayInputStream;
|
|
|
+import java.io.ByteArrayOutputStream;
|
|
|
+import java.io.IOException;
|
|
|
+import java.io.InputStream;
|
|
|
+import java.net.HttpURLConnection;
|
|
|
+import java.net.URI;
|
|
|
+import java.net.URL;
|
|
|
+import java.net.URLEncoder;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.nio.file.Paths;
|
|
|
+import java.nio.file.StandardOpenOption;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.time.ZoneId;
|
|
|
+import java.time.ZonedDateTime;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
+import java.time.format.DateTimeParseException;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Locale;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.UUID;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 印章生成业务实现。
|
|
|
+ *
|
|
|
+ * <p>处理链路为:读取宜搭实例 -> 获取底图 -> 在底图空白区域绘制审批日期 ->
|
|
|
+ * 按日期保存到 sealFile -> 将公网 URL 回写到审批表单。</p>
|
|
|
+ */
|
|
|
+@Service
|
|
|
+@Slf4j
|
|
|
+public class SealServiceImpl implements SealService {
|
|
|
+
|
|
|
+ /** 文件夹按年/月/日分层,便于长期运行时管理文件。 */
|
|
|
+ private static final DateTimeFormatter DIRECTORY_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd");
|
|
|
+ /** 图片中显示的审批日期格式。 */
|
|
|
+ private static final DateTimeFormatter FILE_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private AccessTokenService accessTokenService;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private SealProperties properties;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 执行一次完整的印章生成和宜搭回写流程。
|
|
|
+ *
|
|
|
+ * <p>如果当前表单已经回写过本服务生成的公网 URL,则直接返回已有结果,
|
|
|
+ * 避免自动化重复触发时产生多张图片。</p>
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public SealResult generateAndWrite(SealApprovalRequest request) {
|
|
|
+ log.info("[seal] 开始处理审批印章请求,requestPresent={}", request != null);
|
|
|
+ if (request == null) {
|
|
|
+ request = new SealApprovalRequest();
|
|
|
+ }
|
|
|
+ String instanceId = firstNonBlank(request.getProcessInstanceId(), null);
|
|
|
+ if (instanceId == null) {
|
|
|
+ log.warn("[seal] 请求缺少流程实例 ID");
|
|
|
+ throw new IllegalArgumentException("缺少宜搭流程实例 ID(processInstanceId/formInstanceId)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 请求参数优先,未传应用编码时使用部署配置中的目标应用。
|
|
|
+ // 印章服务只允许访问配置的目标宜搭应用,避免连接器误传其他应用编码导致权限错误。
|
|
|
+ String appType = properties.getAppType();
|
|
|
+ if (!isBlank(request.getAppType()) && !appType.equals(request.getAppType())) {
|
|
|
+ log.warn("[seal.config] 忽略请求中的其他宜搭应用编码,requestAppType={}, configuredAppType={}",
|
|
|
+ request.getAppType(), appType);
|
|
|
+ }
|
|
|
+ log.info("[seal] 请求参数已解析,appType={}, instanceId={}, approvalTime={}, templateUrlPresent={}, systemToken={}",
|
|
|
+ appType, instanceId, request.getApprovalTime(), !isBlank(request.getTemplateImageUrl()),
|
|
|
+ properties.getSystemTokenFingerprint());
|
|
|
+ requiredSystemToken();
|
|
|
+ String accessToken = required(accessTokenService.getAccessToken(), "无法获取钉钉 AccessToken");
|
|
|
+ log.info("[seal] 已获取钉钉 AccessToken,tokenPresent={}, tokenLength={}",
|
|
|
+ !isBlank(accessToken), accessToken == null ? 0 : accessToken.length());
|
|
|
+
|
|
|
+ // 先读取业务实例,以获得表单编码和当前图片字段的已有值。
|
|
|
+ String targetFormUuid = firstNonBlank(request.getFormUuid(), properties.getTargetFormUuid());
|
|
|
+ JSONObject targetContent = getInstance(appType, instanceId, targetFormUuid, accessToken);
|
|
|
+ String formUuid = firstNonBlank(request.getFormUuid(), targetContent.getString("formUuid"), properties.getTargetFormUuid());
|
|
|
+ log.info("[seal] 业务实例读取完成,instanceId={}, formUuid={}, dataKeys={}",
|
|
|
+ instanceId, formUuid, targetContent == null ? 0 : targetContent.size());
|
|
|
+
|
|
|
+ String approvalDate = resolveApprovalDate(request.getApprovalTime(), properties.getTimeZone());
|
|
|
+ String existingUrl = findExistingUrl(targetContent, properties.getTargetImageField());
|
|
|
+ if (existingUrl != null && existingUrl.startsWith(trimTrailingSlash(properties.getPublicBaseUrl()))) {
|
|
|
+ // 已存在本服务生成的文件时执行幂等返回,不重复下载、绘图和回写。
|
|
|
+ log.info("[seal] 检测到已生成的印章 URL,跳过重复处理,instanceId={}, fileUrl={}", instanceId, existingUrl);
|
|
|
+ return new SealResult(instanceId, formUuid, null, existingUrl, approvalDate);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取底图、叠加日期、保存文件,最后把文件 URL 写回宜搭。
|
|
|
+ byte[] templateBytes = loadTemplateBytes(request.getTemplateImageUrl(), appType, accessToken);
|
|
|
+ log.info("[seal] 印章底图读取完成,bytes={}", templateBytes.length);
|
|
|
+ byte[] stampedBytes = renderSeal(templateBytes, approvalDate);
|
|
|
+ log.info("[seal] 日期绘制完成,approvalDate={}, outputBytes={}", approvalDate, stampedBytes.length);
|
|
|
+ StoredFile storedFile = store(stampedBytes, instanceId, approvalDate);
|
|
|
+ log.info("[seal] 印章文件保存完成,path={}, url={}", storedFile.path, storedFile.url);
|
|
|
+ updateTarget(appType, formUuid, instanceId, storedFile, accessToken);
|
|
|
+ log.info("[seal] 印章 URL 回写宜搭完成,instanceId={}, fieldId={}", instanceId, properties.getTargetImageField());
|
|
|
+ return new SealResult(instanceId, formUuid, storedFile.path.toString(), storedFile.url, approvalDate);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 通过宜搭 GetInstanceById 接口读取流程实例详情和表单数据。 */
|
|
|
+ private JSONObject getInstance(String appType, String instanceId, String formUuid, String accessToken) {
|
|
|
+ log.info("[seal.yida] GetInstanceById 开始,appType={}, instanceId={}, baseUrl={}, userId={}, systemToken={}",
|
|
|
+ appType, instanceId, properties.getYidaBaseUrl(), properties.getYidaUserId(), properties.getSystemTokenFingerprint());
|
|
|
+ try {
|
|
|
+ com.aliyun.dingtalkyida_2_0.Client client = createClientV2();
|
|
|
+ GetInstanceByIdHeaders headers = new GetInstanceByIdHeaders();
|
|
|
+ headers.xAcsDingtalkAccessToken = accessToken;
|
|
|
+ GetInstanceByIdRequest request = new GetInstanceByIdRequest()
|
|
|
+ .setSystemToken(requiredSystemToken())
|
|
|
+ .setUserId(properties.getYidaUserId())
|
|
|
+ .setAppType(appType);
|
|
|
+ if (!isBlank(formUuid)) {
|
|
|
+ request.setFormUuid(formUuid);
|
|
|
+ }
|
|
|
+ GetInstanceByIdResponse response = client.getInstanceByIdWithOptions(instanceId, request, headers, new RuntimeOptions());
|
|
|
+ log.info("[seal.yida] GetInstanceById 返回,statusCode={}, bodyPresent={}",
|
|
|
+ response == null ? null : response.getStatusCode(), response != null && response.getBody() != null);
|
|
|
+ JSONObject body = JSON.parseObject(JSON.toJSONString(response.getBody()));
|
|
|
+ JSONObject content = body == null ? null : body.getJSONObject("content");
|
|
|
+ // SDK 返回模型直接使用 data/formUuid,HTTP 原始接口则包在 content 中,统一成 content 结构。
|
|
|
+ JSONObject result = content == null ? body : content;
|
|
|
+ log.info("[seal.yida] GetInstanceById 解析完成,formUuid={}, dataPresent={}",
|
|
|
+ result == null ? null : result.getString("formUuid"),
|
|
|
+ result != null && (result.getJSONObject("data") != null || result.getJSONObject("formData") != null));
|
|
|
+ return result;
|
|
|
+ } catch (TeaException e) {
|
|
|
+ log.error("[seal.yida] GetInstanceById 权限/接口调用失败,appType={}, instanceId={}, userId={}, statusCode={}, code={}, requestId={}, error={}",
|
|
|
+ appType, instanceId, properties.getYidaUserId(), e.statusCode, e.code,
|
|
|
+ requestId(e), summarize(e), e);
|
|
|
+ if (isPermissionError(e)) {
|
|
|
+ throw new IllegalStateException("读取宜搭实例失败:钉钉 AccessToken 已获取,但当前钉钉应用没有目标宜搭应用 "
|
|
|
+ + appType + " 的访问权限。请在宜搭目标应用授权当前钉钉应用,并开通表单实例读取权限;"
|
|
|
+ + "requestId=" + requestId(e), e);
|
|
|
+ }
|
|
|
+ throw new IllegalStateException("读取宜搭实例失败:" + summarize(e), e);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[seal.yida] GetInstanceById 失败,appType={}, instanceId={}, systemToken={}, error={}",
|
|
|
+ appType, instanceId, properties.getSystemTokenFingerprint(), summarize(e), e);
|
|
|
+ throw new IllegalStateException("读取宜搭实例失败:" + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取印章底图字节。
|
|
|
+ *
|
|
|
+ * <p>优先使用请求指定的 URL;否则读取固定宜搭模板实例。下载时先调用官方免登接口,
|
|
|
+ * 失败后再尝试原始 URL、本地模板文件等回退方案。</p>
|
|
|
+ */
|
|
|
+ private byte[] loadTemplateBytes(String requestUrl, String appType, String accessToken) {
|
|
|
+ String url = requestUrl;
|
|
|
+ log.info("[seal] 开始读取印章底图,requestUrlPresent={}, templateInstanceId={}, imageField={}",
|
|
|
+ !isBlank(requestUrl), properties.getTemplateInstanceId(), properties.getTemplateImageField());
|
|
|
+ if (isBlank(url)) {
|
|
|
+ // 连接器没有传底图时,从固定的宜搭底图模板实例中解析图片字段。
|
|
|
+ JSONObject template = getFormDataById(appType, properties.getTemplateInstanceId(),
|
|
|
+ properties.getTemplateFormUuid(), accessToken);
|
|
|
+ url = extractImageUrl(required(template, "读取印章底图模板实例失败"), properties.getTemplateImageField());
|
|
|
+ log.info("[seal] 已从宜搭底图实例解析图片地址,urlPresent={}, url={}", !isBlank(url), safeUrl(url));
|
|
|
+ }
|
|
|
+ if (!isBlank(url)) {
|
|
|
+ try {
|
|
|
+ // 将宜搭文件地址换成短期免登地址后再下载,避免依赖浏览器 Cookie。
|
|
|
+ String openUrl = getOpenUrl(appType, url, accessToken);
|
|
|
+ String downloadUrl = firstNonBlank(openUrl, url);
|
|
|
+ log.info("[seal] 底图免登地址处理完成,openUrlPresent={}, downloadUrl={}", !isBlank(openUrl), safeUrl(downloadUrl));
|
|
|
+ return download(downloadUrl);
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ log.warn("下载宜搭印章底图失败,将尝试本地模板回退:{}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!isBlank(properties.getTemplateImageFile())) {
|
|
|
+ try {
|
|
|
+ // 服务器无法访问宜搭时,允许使用预置在本地的 PNG 底图。
|
|
|
+ byte[] bytes = Files.readAllBytes(Paths.get(properties.getTemplateImageFile()));
|
|
|
+ log.info("[seal] 已使用本地底图回退文件,file={}, bytes={}", properties.getTemplateImageFile(), bytes.length);
|
|
|
+ return bytes;
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalStateException("读取本地印章底图失败:" + properties.getTemplateImageFile(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw new IllegalStateException("无法获取印章底图。请配置 seal.yida-cookie、seal.template-image-file,或在连接器请求中传 templateImageUrl");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通过宜搭 v2.0 普通表单实例原始 HTTP 接口读取印章底图记录。
|
|
|
+ * 当前 SDK 的 GetFormDataByIDResponse 在 HTTP 200 时无法映射 formData,
|
|
|
+ * 因此这里严格复刻可用的 curl 请求并直接解析 JSON。
|
|
|
+ */
|
|
|
+ private JSONObject getFormDataById(String appType, String instanceId, String formUuid, String accessToken) {
|
|
|
+ log.info("[seal.yida] GetFormDataByID 开始,appType={}, formUuid={}, instanceId={}, userId={}, systemToken={}",
|
|
|
+ appType, formUuid, instanceId, properties.getYidaUserId(), properties.getSystemTokenFingerprint());
|
|
|
+ HttpURLConnection connection = null;
|
|
|
+ try {
|
|
|
+ String endpoint = trimTrailingSlash(properties.getYidaApiBaseUrl())
|
|
|
+ + "/v2.0/yida/forms/instances/" + encode(instanceId)
|
|
|
+ + "?appType=" + encode(appType)
|
|
|
+ + "&systemToken=" + encode(requiredSystemToken())
|
|
|
+ + "&userId=" + encode(properties.getYidaUserId())
|
|
|
+ + "&formUuid=" + encode(formUuid);
|
|
|
+ connection = (HttpURLConnection) new URL(endpoint).openConnection();
|
|
|
+ connection.setRequestMethod("GET");
|
|
|
+ connection.setConnectTimeout(properties.getHttpTimeoutMs());
|
|
|
+ connection.setReadTimeout(properties.getHttpTimeoutMs());
|
|
|
+ connection.setRequestProperty("Content-Type", "application/json");
|
|
|
+ connection.setRequestProperty("x-acs-dingtalk-access-token", accessToken);
|
|
|
+ int status = connection.getResponseCode();
|
|
|
+ String responseText = readResponse(connection, status);
|
|
|
+ log.info("[seal.yida] GetFormDataByID 返回,statusCode={}, bodyPresent={}, bodyLength={}",
|
|
|
+ status, !isBlank(responseText), responseText == null ? 0 : responseText.length());
|
|
|
+ if (status < 200 || status >= 300) {
|
|
|
+ throw new IllegalStateException("宜搭底图接口 HTTP " + status + ":" + responseText);
|
|
|
+ }
|
|
|
+ JSONObject body = JSON.parseObject(responseText);
|
|
|
+ log.info("[seal.yida] GetFormDataByID 解析完成,formInstId={}, formDataPresent={}",
|
|
|
+ body == null ? null : body.getString("formInstId"), body != null && body.getJSONObject("formData") != null);
|
|
|
+ return body;
|
|
|
+ } catch (TeaException e) {
|
|
|
+ log.error("[seal.yida] GetFormDataByID 失败,appType={}, formUuid={}, instanceId={}, statusCode={}, code={}, requestId={}, error={}",
|
|
|
+ appType, formUuid, instanceId, e.statusCode, e.code, requestId(e), summarize(e), e);
|
|
|
+ throw new IllegalStateException("读取宜搭印章底图实例失败:" + summarize(e), e);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[seal.yida] GetFormDataByID 失败,appType={}, formUuid={}, instanceId={}, error={}",
|
|
|
+ appType, formUuid, instanceId, summarize(e), e);
|
|
|
+ throw new IllegalStateException("读取宜搭印章底图实例失败:" + e.getMessage(), e);
|
|
|
+ } finally {
|
|
|
+ if (connection != null) {
|
|
|
+ connection.disconnect();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 读取宜搭 HTTP 响应正文;错误响应也保留正文用于定位 code/message/requestId。 */
|
|
|
+ private String readResponse(HttpURLConnection connection, int status) throws IOException {
|
|
|
+ InputStream stream = status >= 200 && status < 300 ? connection.getInputStream() : connection.getErrorStream();
|
|
|
+ if (stream == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ try (InputStream input = stream; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
|
|
+ byte[] buffer = new byte[4096];
|
|
|
+ int count;
|
|
|
+ while ((count = input.read(buffer)) >= 0) {
|
|
|
+ output.write(buffer, 0, count);
|
|
|
+ }
|
|
|
+ return new String(output.toByteArray(), java.nio.charset.StandardCharsets.UTF_8);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 对 OpenAPI 查询参数进行 UTF-8 编码,避免实例 ID 或 userId 中的特殊字符破坏请求。 */
|
|
|
+ private String encode(String value) throws IOException {
|
|
|
+ return URLEncoder.encode(value == null ? "" : value, "UTF-8");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 通过宜搭官方免登文件接口获取 OSS 临时地址,避免服务端依赖浏览器 Cookie。 */
|
|
|
+ private String getOpenUrl(String appType, String fileUrl, String accessToken) {
|
|
|
+ log.info("[seal.yida] GetOpenUrl 开始,appType={}, fileUrl={}, systemToken={}",
|
|
|
+ appType, safeUrl(fileUrl), properties.getSystemTokenFingerprint());
|
|
|
+ try {
|
|
|
+ com.aliyun.dingtalkyida_1_0.Client client = createClient();
|
|
|
+ GetOpenUrlHeaders headers = new GetOpenUrlHeaders();
|
|
|
+ headers.xAcsDingtalkAccessToken = accessToken;
|
|
|
+ GetOpenUrlRequest request = new GetOpenUrlRequest()
|
|
|
+ .setSystemToken(requiredSystemToken())
|
|
|
+ .setFileUrl(fileUrl)
|
|
|
+ .setUserId(properties.getYidaUserId())
|
|
|
+ .setTimeout(86400000L);
|
|
|
+ GetOpenUrlResponse response = client.getOpenUrlWithOptions(appType, request, headers, new RuntimeOptions());
|
|
|
+ String result = response == null || response.getBody() == null ? null : response.getBody().getResult();
|
|
|
+ log.info("[seal.yida] GetOpenUrl 返回,statusCode={}, resultPresent={}",
|
|
|
+ response == null ? null : response.getStatusCode(), !isBlank(result));
|
|
|
+ return result;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("[seal.yida] GetOpenUrl 失败,将尝试原始地址,fileUrl={}, error={}", safeUrl(fileUrl), summarize(e));
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从宜搭图片字段的数组值中提取第一张图片的可下载地址。 */
|
|
|
+ private String extractImageUrl(JSONObject content, String fieldId) {
|
|
|
+ JSONObject formData = getFieldContainer(content);
|
|
|
+ if (formData == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ Object raw = formData.get(fieldId);
|
|
|
+ if (raw == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ JSONArray images;
|
|
|
+ if (raw instanceof String) {
|
|
|
+ images = JSON.parseArray((String) raw);
|
|
|
+ } else if (raw instanceof JSONArray) {
|
|
|
+ images = (JSONArray) raw;
|
|
|
+ } else {
|
|
|
+ images = JSON.parseArray(JSON.toJSONString(raw));
|
|
|
+ }
|
|
|
+ if (images == null || images.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ JSONObject image = images.getJSONObject(0);
|
|
|
+ String imageUrl = firstNonBlank(image.getString("downloadUrl"), image.getString("url"), image.getString("previewUrl"));
|
|
|
+ return toAbsoluteUrl(imageUrl);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 使用 HTTP 下载底图字节,并在需要时附带宜搭 Cookie。 */
|
|
|
+ private byte[] download(String imageUrl) {
|
|
|
+ HttpURLConnection connection = null;
|
|
|
+ log.info("[seal.http] 图片下载开始,url={}", safeUrl(imageUrl));
|
|
|
+ try {
|
|
|
+ URL url = new URL(toAbsoluteUrl(imageUrl));
|
|
|
+ connection = (HttpURLConnection) url.openConnection();
|
|
|
+ connection.setConnectTimeout(properties.getHttpTimeoutMs());
|
|
|
+ connection.setReadTimeout(properties.getHttpTimeoutMs());
|
|
|
+ connection.setInstanceFollowRedirects(true);
|
|
|
+ connection.setRequestProperty("User-Agent", "changguchuan-seal/1.0");
|
|
|
+ if (!isBlank(properties.getYidaCookie())) {
|
|
|
+ // 仅在免登地址不可用时使用可选 Cookie 配置。
|
|
|
+ connection.setRequestProperty("Cookie", properties.getYidaCookie());
|
|
|
+ }
|
|
|
+ int status = connection.getResponseCode();
|
|
|
+ log.info("[seal.http] 图片下载响应,statusCode={}, contentType={}, contentLength={}",
|
|
|
+ status, connection.getContentType(), connection.getContentLengthLong());
|
|
|
+ if (status < 200 || status >= 300) {
|
|
|
+ throw new IllegalStateException("图片下载 HTTP " + status);
|
|
|
+ }
|
|
|
+ try (InputStream input = connection.getInputStream(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
|
|
+ byte[] buffer = new byte[8192];
|
|
|
+ int count;
|
|
|
+ while ((count = input.read(buffer)) >= 0) {
|
|
|
+ output.write(buffer, 0, count);
|
|
|
+ }
|
|
|
+ byte[] bytes = output.toByteArray();
|
|
|
+ log.info("[seal.http] 图片下载完成,bytes={}", bytes.length);
|
|
|
+ return bytes;
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ log.error("[seal.http] 图片下载失败,url={}, error={}", safeUrl(imageUrl), summarize(e), e);
|
|
|
+ throw new IllegalStateException("图片下载失败:" + e.getMessage(), e);
|
|
|
+ } finally {
|
|
|
+ if (connection != null) {
|
|
|
+ connection.disconnect();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 在底图中心空白区域绘制审批日期,并统一输出为 PNG 字节。
|
|
|
+ */
|
|
|
+ private byte[] renderSeal(byte[] sourceBytes, String dateText) {
|
|
|
+ try {
|
|
|
+ BufferedImage source = ImageIO.read(new ByteArrayInputStream(sourceBytes));
|
|
|
+ if (source == null) {
|
|
|
+ throw new IllegalStateException("印章底图不是有效图片");
|
|
|
+ }
|
|
|
+ log.info("[seal.image] 开始绘制日期,width={}, height={}, dateText={}",
|
|
|
+ source.getWidth(), source.getHeight(), dateText);
|
|
|
+ int imageType = source.getColorModel().hasAlpha() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
|
|
|
+ BufferedImage result = new BufferedImage(source.getWidth(), source.getHeight(), imageType);
|
|
|
+ Graphics2D graphics = result.createGraphics();
|
|
|
+ graphics.drawImage(source, 0, 0, null);
|
|
|
+ // 开启抗锯齿和高质量渲染,使日期文字边缘与印章底图更自然。
|
|
|
+ graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
|
|
+ graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
|
|
+
|
|
|
+ Color ink = detectInkColor(source);
|
|
|
+ int centerY = findBlankCenter(source);
|
|
|
+ // 字体大小根据图片尺寸计算,避免固定字号在不同分辨率下过大或过小。
|
|
|
+ int fontSize = Math.max(12, Math.min(source.getWidth(), source.getHeight()) / 10);
|
|
|
+ Font font = new Font("宋体", Font.BOLD, fontSize);
|
|
|
+ if (font.getFamily(Locale.CHINA).equalsIgnoreCase("Dialog")) {
|
|
|
+ font = new Font(Font.SERIF, Font.BOLD, fontSize);
|
|
|
+ }
|
|
|
+ graphics.setFont(font);
|
|
|
+ graphics.setColor(ink);
|
|
|
+ FontMetrics metrics = graphics.getFontMetrics();
|
|
|
+ int x = (source.getWidth() - metrics.stringWidth(dateText)) / 2;
|
|
|
+ // drawString 的 y 是文字基线;用 ascent-descent 计算,才能让实际字形包围盒的中心
|
|
|
+ // 对齐印章上下横线之间的空白中心。旧公式把 descent 也向上扣除,导致日期整体偏上。
|
|
|
+ int baselineY = centerY + (metrics.getAscent() - metrics.getDescent()) / 2;
|
|
|
+ log.info("[seal.image] 日期文字定位,centerY={}, baselineY={}, x={}, ascent={}, descent={}",
|
|
|
+ centerY, baselineY, x, metrics.getAscent(), metrics.getDescent());
|
|
|
+ graphics.drawString(dateText, x, baselineY);
|
|
|
+ graphics.dispose();
|
|
|
+
|
|
|
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
|
|
|
+ ImageIO.write(result, "png", output);
|
|
|
+ byte[] bytes = output.toByteArray();
|
|
|
+ log.info("[seal.image] 日期绘制输出 PNG,bytes={}", bytes.length);
|
|
|
+ return bytes;
|
|
|
+ } catch (IOException e) {
|
|
|
+ log.error("[seal.image] 图片绘制失败,dateText={}, error={}", dateText, summarize(e), e);
|
|
|
+ throw new IllegalStateException("生成印章图片失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从底图中统计红色印章笔画,得到与底图接近的文字颜色。 */
|
|
|
+ private Color detectInkColor(BufferedImage image) {
|
|
|
+ long red = 0;
|
|
|
+ long green = 0;
|
|
|
+ long blue = 0;
|
|
|
+ long count = 0;
|
|
|
+ for (int y = 0; y < image.getHeight(); y++) {
|
|
|
+ for (int x = 0; x < image.getWidth(); x++) {
|
|
|
+ Color color = new Color(image.getRGB(x, y), true);
|
|
|
+ if (color.getRed() > color.getGreen() + 40 && color.getRed() > color.getBlue() + 40) {
|
|
|
+ red += color.getRed();
|
|
|
+ green += color.getGreen();
|
|
|
+ blue += color.getBlue();
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return count == 0 ? Color.RED : new Color((int) (red / count), (int) (green / count), (int) (blue / count));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 根据红色笔画所在行估算印章中间空白区域的纵向中心。 */
|
|
|
+ private int findBlankCenter(BufferedImage image) {
|
|
|
+ List<Integer> lineRows = new ArrayList<>();
|
|
|
+ for (int y = 0; y < image.getHeight(); y++) {
|
|
|
+ int redPixels = 0;
|
|
|
+ for (int x = 0; x < image.getWidth(); x++) {
|
|
|
+ Color color = new Color(image.getRGB(x, y), true);
|
|
|
+ if (color.getRed() > color.getGreen() + 40 && color.getRed() > color.getBlue() + 40) {
|
|
|
+ redPixels++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (redPixels > image.getWidth() * 0.35) {
|
|
|
+ lineRows.add(y);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (lineRows.size() >= 2) {
|
|
|
+ return (lineRows.get(0) + lineRows.get(lineRows.size() - 1)) / 2;
|
|
|
+ }
|
|
|
+ return image.getHeight() / 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 将生成图片保存到 sealFile/yyyy/MM/dd,并生成对应的公网访问 URL。 */
|
|
|
+ private StoredFile store(byte[] imageBytes, String instanceId, String approvalDate) {
|
|
|
+ log.info("[seal.file] 开始保存印章文件,instanceId={}, approvalDate={}, bytes={}, storageRoot={}",
|
|
|
+ instanceId, approvalDate, imageBytes.length, properties.getStorageRoot());
|
|
|
+ LocalDate today = LocalDate.now(ZoneId.of(properties.getTimeZone()));
|
|
|
+ Path root = Paths.get(properties.getStorageRoot());
|
|
|
+ if (!root.isAbsolute()) {
|
|
|
+ root = Paths.get(System.getProperty("user.dir")).resolve(root);
|
|
|
+ }
|
|
|
+ Path directory = root.resolve(today.format(DIRECTORY_DATE));
|
|
|
+ // 清理实例 ID 中的特殊字符,防止实例值影响文件路径。
|
|
|
+ String safeId = instanceId.replaceAll("[^a-zA-Z0-9._-]", "_");
|
|
|
+ String fileName = "seal-" + safeId + "-" + UUID.randomUUID().toString().replace("-", "") + ".png";
|
|
|
+ Path file = directory.resolve(fileName).normalize();
|
|
|
+ if (!file.startsWith(root.normalize())) {
|
|
|
+ throw new IllegalStateException("生成的图片路径不合法");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ // CREATE_NEW 保证同一路径不会覆盖已有文件;文件名中的 UUID 进一步避免冲突。
|
|
|
+ Files.createDirectories(directory);
|
|
|
+ Files.write(file, imageBytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalStateException("保存印章图片失败:" + file, e);
|
|
|
+ }
|
|
|
+ String relative = today.format(DIRECTORY_DATE) + "/" + fileName;
|
|
|
+ String url = trimTrailingSlash(properties.getPublicBaseUrl()) + "/" + relative;
|
|
|
+ log.info("[seal.file] 文件保存成功,path={}, publicUrl={}", file, url);
|
|
|
+ return new StoredFile(file, url, imageBytes.length, fileName);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 使用宜搭 UpdateFormData 接口,把公网图片地址写入图片上传字段。 */
|
|
|
+ private void updateTarget(String appType, String formUuid, String instanceId, StoredFile file, String accessToken) {
|
|
|
+ log.info("[seal.yida] UpdateFormData 开始,appType={}, formUuid={}, instanceId={}, fieldId={}, fileUrl={}",
|
|
|
+ appType, formUuid, instanceId, properties.getTargetImageField(), file.url);
|
|
|
+ // 图片字段使用宜搭兼容的数组对象结构,保留名称、大小和各类访问地址。
|
|
|
+ Map<String, Object> image = new LinkedHashMap<>();
|
|
|
+ image.put("name", file.name);
|
|
|
+ image.put("size", file.size);
|
|
|
+ image.put("fileUuid", file.url);
|
|
|
+ image.put("url", file.url);
|
|
|
+ image.put("downloadUrl", file.url);
|
|
|
+ image.put("previewUrl", file.url);
|
|
|
+ List<Map<String, Object>> images = new ArrayList<>();
|
|
|
+ images.add(image);
|
|
|
+ Map<String, Object> formData = new HashMap<>();
|
|
|
+ formData.put(properties.getTargetImageField(), images);
|
|
|
+ try {
|
|
|
+ com.aliyun.dingtalkyida_2_0.Client client = createClientV2();
|
|
|
+ UpdateFormDataHeaders headers = new UpdateFormDataHeaders();
|
|
|
+ headers.xAcsDingtalkAccessToken = accessToken;
|
|
|
+ UpdateFormDataRequest request = new UpdateFormDataRequest()
|
|
|
+ .setSystemToken(requiredSystemToken())
|
|
|
+ .setFormInstanceId(instanceId)
|
|
|
+ .setFormUuid(formUuid)
|
|
|
+ .setUserId(properties.getYidaUserId())
|
|
|
+ .setUseLatestVersion(true)
|
|
|
+ .setAppType(appType)
|
|
|
+ .setUpdateFormDataJson(JSON.toJSONString(formData));
|
|
|
+ UpdateFormDataResponse response = client.updateFormDataWithOptions(request, headers, new RuntimeOptions());
|
|
|
+ // 当前 SDK 的 UpdateFormDataResponse 只有状态码,不提供 body 字段。
|
|
|
+ log.info("[seal.yida] UpdateFormData 返回,statusCode={}, responsePresent={}",
|
|
|
+ response == null ? null : response.getStatusCode(), response != null);
|
|
|
+ if (response != null && response.getStatusCode() != null && response.getStatusCode() >= 400) {
|
|
|
+ throw new IllegalStateException("宜搭回写失败,HTTP 状态码:" + response.getStatusCode());
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[seal.yida] UpdateFormData 失败,instanceId={}, fieldId={}, error={}",
|
|
|
+ instanceId, properties.getTargetImageField(), summarize(e), e);
|
|
|
+ throw new IllegalStateException("回写宜搭印章图片失败:" + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 读取业务表单中已有的印章 URL,用于幂等判断。 */
|
|
|
+ private String findExistingUrl(JSONObject content, String fieldId) {
|
|
|
+ JSONObject formData = getFieldContainer(content);
|
|
|
+ String value = formData == null ? null : formData.getString(fieldId);
|
|
|
+ if (isBlank(value)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ JSONArray array = JSON.parseArray(value);
|
|
|
+ if (array != null && !array.isEmpty()) {
|
|
|
+ return firstNonBlank(array.getJSONObject(0).getString("url"), array.getJSONObject(0).getString("downloadUrl"));
|
|
|
+ }
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // 兼容历史数据中直接保存 URL 字符串的情况。
|
|
|
+ }
|
|
|
+ return value.startsWith("http") ? value : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 兼容宜搭返回的 formData 和 SDK data 两种数据容器。 */
|
|
|
+ private JSONObject getFieldContainer(JSONObject content) {
|
|
|
+ if (content == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ JSONObject formData = content.getJSONObject("formData");
|
|
|
+ return formData == null ? content.getJSONObject("data") : formData;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 将毫秒时间戳、带时区时间或日期字符串统一转换为 yyyy-MM-dd。 */
|
|
|
+ private String resolveApprovalDate(String approvalTime, String zone) {
|
|
|
+ log.info("[seal] 开始解析审批日期,approvalTimePresent={}, zone={}", !isBlank(approvalTime), zone);
|
|
|
+ ZoneId zoneId = ZoneId.of(zone);
|
|
|
+ if (isBlank(approvalTime)) {
|
|
|
+ String result = LocalDate.now(zoneId).format(FILE_DATE);
|
|
|
+ log.info("[seal] 审批时间为空,使用服务器日期 {}", result);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ String result = Instant.ofEpochMilli(Long.parseLong(approvalTime)).atZone(zoneId).toLocalDate().format(FILE_DATE);
|
|
|
+ log.info("[seal] 按毫秒时间戳解析审批日期 {}", result);
|
|
|
+ return result;
|
|
|
+ } catch (NumberFormatException ignored) {
|
|
|
+ // 不是纯数字时继续尝试 ISO 格式时间。
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ String result = OffsetDateTime.parse(approvalTime).atZoneSameInstant(zoneId).toLocalDate().format(FILE_DATE);
|
|
|
+ log.info("[seal] 按 OffsetDateTime 解析审批日期 {}", result);
|
|
|
+ return result;
|
|
|
+ } catch (DateTimeParseException ignored) {
|
|
|
+ try {
|
|
|
+ String result = ZonedDateTime.parse(approvalTime).withZoneSameInstant(zoneId).toLocalDate().format(FILE_DATE);
|
|
|
+ log.info("[seal] 按 ZonedDateTime 解析审批日期 {}", result);
|
|
|
+ return result;
|
|
|
+ } catch (DateTimeParseException ignoredAgain) {
|
|
|
+ // 最后兼容带时间后缀但前十位为 yyyy-MM-dd 的字符串。
|
|
|
+ String result = LocalDate.parse(approvalTime.substring(0, Math.min(10, approvalTime.length()))).format(FILE_DATE);
|
|
|
+ log.info("[seal] 按日期前缀解析审批日期 {}", result);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 将宜搭返回的相对文件地址转换成绝对 URL。 */
|
|
|
+ private String toAbsoluteUrl(String value) {
|
|
|
+ if (isBlank(value) || value.startsWith("http://") || value.startsWith("https://")) {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ return URI.create(trimTrailingSlash(properties.getYidaBaseUrl()) + "/").resolve(value).toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 创建宜搭 Java SDK 客户端,统一设置 HTTPS 和区域参数。 */
|
|
|
+ private com.aliyun.dingtalkyida_1_0.Client createClient() throws Exception {
|
|
|
+ log.debug("[seal.yida] 创建宜搭 SDK Client,protocol=https, regionId=central");
|
|
|
+ com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
|
|
|
+ config.protocol = "https";
|
|
|
+ config.regionId = "central";
|
|
|
+ return new com.aliyun.dingtalkyida_1_0.Client(config);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 创建宜搭 v2.0 SDK 客户端;实例读取和表单回写与官方 v2.0 接口保持一致。 */
|
|
|
+ private com.aliyun.dingtalkyida_2_0.Client createClientV2() throws Exception {
|
|
|
+ log.debug("[seal.yida] 创建宜搭 v2.0 SDK Client,protocol=https, regionId=central");
|
|
|
+ com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
|
|
|
+ config.protocol = "https";
|
|
|
+ config.regionId = "central";
|
|
|
+ return new com.aliyun.dingtalkyida_2_0.Client(config);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从 JSON 对象中取得必需的子对象;不存在时抛出带业务含义的异常。 */
|
|
|
+ private JSONObject requiredObject(JSONObject object, String key, String message) {
|
|
|
+ JSONObject value = object == null ? null : object.getJSONObject(key);
|
|
|
+ if (value == null) {
|
|
|
+ throw new IllegalStateException(message);
|
|
|
+ }
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 校验 JSON 对象不为空。 */
|
|
|
+ private JSONObject required(JSONObject object, String message) {
|
|
|
+ if (object == null) {
|
|
|
+ throw new IllegalStateException(message);
|
|
|
+ }
|
|
|
+ return object;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取得目标应用专用 systemToken;禁止回退到旧应用密钥,避免产生误导性的 400 错误。
|
|
|
+ */
|
|
|
+ private String requiredSystemToken() {
|
|
|
+ if (!properties.hasSystemToken()) {
|
|
|
+ log.error("[seal.config] 未配置目标宜搭 systemToken,appType={}, expectedProperty=SEAL_YIDA_SYSTEM_TOKEN",
|
|
|
+ properties.getAppType());
|
|
|
+ throw new IllegalStateException("缺少目标宜搭应用 systemToken。请在服务器配置目标应用 APP_RP2SE56YJWMECIWYN4BR 的 SEAL_YIDA_SYSTEM_TOKEN(或 seal.yida.system-token)");
|
|
|
+ }
|
|
|
+ return properties.getSystemToken().trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 校验字符串配置或参数不为空。 */
|
|
|
+ private String required(String value, String message) {
|
|
|
+ if (isBlank(value)) {
|
|
|
+ throw new IllegalStateException(message);
|
|
|
+ }
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 将异常压缩为日志可读的一行,同时保留错误类型和原始消息。 */
|
|
|
+ private String summarize(Throwable exception) {
|
|
|
+ if (exception == null) {
|
|
|
+ return "unknown";
|
|
|
+ }
|
|
|
+ if (exception instanceof TeaException) {
|
|
|
+ TeaException teaException = (TeaException) exception;
|
|
|
+ return "TeaException{statusCode=" + teaException.statusCode
|
|
|
+ + ",code=" + teaException.code
|
|
|
+ + ",message=" + (teaException.message == null ? "" : teaException.message.replaceAll("\\s+", " "))
|
|
|
+ + ",requestId=" + requestId(teaException) + "}";
|
|
|
+ }
|
|
|
+ String message = exception.getMessage();
|
|
|
+ return exception.getClass().getSimpleName() + ":" + (message == null ? "" : message.replaceAll("\\s+", " "));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从宜搭 SDK 异常中提取 requestId,方便在宜搭后台定位同一条请求。 */
|
|
|
+ private String requestId(TeaException exception) {
|
|
|
+ if (exception == null || exception.data == null) {
|
|
|
+ return "UNKNOWN";
|
|
|
+ }
|
|
|
+ Object value = exception.data.get("requestId");
|
|
|
+ if (value == null) {
|
|
|
+ value = exception.data.get("requestid");
|
|
|
+ }
|
|
|
+ return value == null ? "UNKNOWN" : String.valueOf(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 判断宜搭返回的异常是否属于权限不足,而不是参数或网络故障。 */
|
|
|
+ private boolean isPermissionError(TeaException exception) {
|
|
|
+ String text = summarize(exception).toLowerCase(Locale.ROOT);
|
|
|
+ return text.contains("permission") || text.contains("没有权限") || text.contains("无权限")
|
|
|
+ || text.contains("accessdenied") || text.contains("forbidden");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 只记录 URL 的协议、主机和路径,避免把签名查询参数写入日志。 */
|
|
|
+ private String safeUrl(String value) {
|
|
|
+ if (isBlank(value)) {
|
|
|
+ return "EMPTY";
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ URI uri = URI.create(value);
|
|
|
+ String path = uri.getPath() == null ? "" : uri.getPath();
|
|
|
+ return (uri.getScheme() == null ? "" : uri.getScheme() + "://")
|
|
|
+ + (uri.getHost() == null ? "" : uri.getHost()) + path;
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ return "[invalid-url]";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 返回参数列表中第一个非空字符串。 */
|
|
|
+ private String firstNonBlank(String... values) {
|
|
|
+ for (String value : values) {
|
|
|
+ if (!isBlank(value)) {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 判断字符串是否为 null、空串或只包含空白字符。 */
|
|
|
+ private boolean isBlank(String value) {
|
|
|
+ return value == null || value.trim().isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 去除 URL 末尾多余的斜杠,避免拼接出双斜杠。 */
|
|
|
+ private String trimTrailingSlash(String value) {
|
|
|
+ return value == null ? "" : value.replaceAll("/+$", "");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 保存本地路径、访问 URL、文件大小和文件名的内部结果对象。 */
|
|
|
+ private static class StoredFile {
|
|
|
+ private final Path path;
|
|
|
+ private final String url;
|
|
|
+ private final int size;
|
|
|
+ private final String name;
|
|
|
+
|
|
|
+ /** 创建一次已保存文件的描述对象。 */
|
|
|
+ private StoredFile(Path path, String url, int size, String name) {
|
|
|
+ this.path = path;
|
|
|
+ this.url = url;
|
|
|
+ this.size = size;
|
|
|
+ this.name = name;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|