feat: add screenshot archive and article sync services
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package com.ruoyi.office.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 网站文章同步配置。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "website-sync")
|
||||
public class WebsiteSyncProperties
|
||||
{
|
||||
private String baseUrl;
|
||||
private String apiKey;
|
||||
private int connectTimeout = 5000;
|
||||
private int readTimeout = 30000;
|
||||
|
||||
public String getBaseUrl()
|
||||
{
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl)
|
||||
{
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public String getApiKey()
|
||||
{
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey)
|
||||
{
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public int getConnectTimeout()
|
||||
{
|
||||
return connectTimeout;
|
||||
}
|
||||
|
||||
public void setConnectTimeout(int connectTimeout)
|
||||
{
|
||||
this.connectTimeout = connectTimeout;
|
||||
}
|
||||
|
||||
public int getReadTimeout()
|
||||
{
|
||||
return readTimeout;
|
||||
}
|
||||
|
||||
public void setReadTimeout(int readTimeout)
|
||||
{
|
||||
this.readTimeout = readTimeout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ruoyi.office.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* 微信公众号配置对象 tt_wechat_account。
|
||||
*/
|
||||
public class TtWechatAccount extends BaseEntity
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long accountId;
|
||||
|
||||
private String accountName;
|
||||
|
||||
private String appId;
|
||||
|
||||
/** AppSecret 只允许写入,不允许通过接口序列化返回。 */
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
private String appSecret;
|
||||
|
||||
private String status;
|
||||
|
||||
private Boolean secretConfigured;
|
||||
|
||||
public Long getAccountId()
|
||||
{
|
||||
return accountId;
|
||||
}
|
||||
|
||||
public void setAccountId(Long accountId)
|
||||
{
|
||||
this.accountId = accountId;
|
||||
}
|
||||
|
||||
public String getAccountName()
|
||||
{
|
||||
return accountName;
|
||||
}
|
||||
|
||||
public void setAccountName(String accountName)
|
||||
{
|
||||
this.accountName = accountName;
|
||||
}
|
||||
|
||||
public String getAppId()
|
||||
{
|
||||
return appId;
|
||||
}
|
||||
|
||||
public void setAppId(String appId)
|
||||
{
|
||||
this.appId = appId;
|
||||
}
|
||||
|
||||
public String getAppSecret()
|
||||
{
|
||||
return appSecret;
|
||||
}
|
||||
|
||||
public void setAppSecret(String appSecret)
|
||||
{
|
||||
this.appSecret = appSecret;
|
||||
}
|
||||
|
||||
public String getStatus()
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Boolean getSecretConfigured()
|
||||
{
|
||||
return secretConfigured;
|
||||
}
|
||||
|
||||
public void setSecretConfigured(Boolean secretConfigured)
|
||||
{
|
||||
this.secretConfigured = secretConfigured;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ruoyi.office.domain;
|
||||
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* 公众号草稿推送记录对象 tt_wechat_article。
|
||||
*/
|
||||
public class TtWechatArticle extends BaseEntity
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long articleId;
|
||||
private Long accountId;
|
||||
private String accountName;
|
||||
private String requestNo;
|
||||
private String title;
|
||||
private String author;
|
||||
private String digest;
|
||||
private String contentHtml;
|
||||
private String contentSourceUrl;
|
||||
private String thumbMediaId;
|
||||
private Integer showCoverPic;
|
||||
private Integer needOpenComment;
|
||||
private Integer onlyFansCanComment;
|
||||
private String wechatMediaId;
|
||||
/** 0成功 1失败 2处理中。 */
|
||||
private String pushStatus;
|
||||
private Integer errorCode;
|
||||
private String errorMessage;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date pushTime;
|
||||
|
||||
public Long getArticleId() { return articleId; }
|
||||
public void setArticleId(Long articleId) { this.articleId = articleId; }
|
||||
public Long getAccountId() { return accountId; }
|
||||
public void setAccountId(Long accountId) { this.accountId = accountId; }
|
||||
public String getAccountName() { return accountName; }
|
||||
public void setAccountName(String accountName) { this.accountName = accountName; }
|
||||
public String getRequestNo() { return requestNo; }
|
||||
public void setRequestNo(String requestNo) { this.requestNo = requestNo; }
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getAuthor() { return author; }
|
||||
public void setAuthor(String author) { this.author = author; }
|
||||
public String getDigest() { return digest; }
|
||||
public void setDigest(String digest) { this.digest = digest; }
|
||||
public String getContentHtml() { return contentHtml; }
|
||||
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
|
||||
public String getContentSourceUrl() { return contentSourceUrl; }
|
||||
public void setContentSourceUrl(String contentSourceUrl) { this.contentSourceUrl = contentSourceUrl; }
|
||||
public String getThumbMediaId() { return thumbMediaId; }
|
||||
public void setThumbMediaId(String thumbMediaId) { this.thumbMediaId = thumbMediaId; }
|
||||
public Integer getShowCoverPic() { return showCoverPic; }
|
||||
public void setShowCoverPic(Integer showCoverPic) { this.showCoverPic = showCoverPic; }
|
||||
public Integer getNeedOpenComment() { return needOpenComment; }
|
||||
public void setNeedOpenComment(Integer needOpenComment) { this.needOpenComment = needOpenComment; }
|
||||
public Integer getOnlyFansCanComment() { return onlyFansCanComment; }
|
||||
public void setOnlyFansCanComment(Integer onlyFansCanComment) { this.onlyFansCanComment = onlyFansCanComment; }
|
||||
public String getWechatMediaId() { return wechatMediaId; }
|
||||
public void setWechatMediaId(String wechatMediaId) { this.wechatMediaId = wechatMediaId; }
|
||||
public String getPushStatus() { return pushStatus; }
|
||||
public void setPushStatus(String pushStatus) { this.pushStatus = pushStatus; }
|
||||
public Integer getErrorCode() { return errorCode; }
|
||||
public void setErrorCode(Integer errorCode) { this.errorCode = errorCode; }
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
public Date getPushTime() { return pushTime; }
|
||||
public void setPushTime(Date pushTime) { this.pushTime = pushTime; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ruoyi.office.domain;
|
||||
|
||||
/**
|
||||
* 从“复制网站文案”中拆出的文章标题和正文。
|
||||
*/
|
||||
public class WebsiteArticleDocument
|
||||
{
|
||||
private final String title;
|
||||
private final String contentMarkdown;
|
||||
private final String firstImageUrl;
|
||||
|
||||
public WebsiteArticleDocument(String title, String contentMarkdown, String firstImageUrl)
|
||||
{
|
||||
this.title = title;
|
||||
this.contentMarkdown = contentMarkdown;
|
||||
this.firstImageUrl = firstImageUrl;
|
||||
}
|
||||
|
||||
public String getTitle()
|
||||
{
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getContentMarkdown()
|
||||
{
|
||||
return contentMarkdown;
|
||||
}
|
||||
|
||||
public String getFirstImageUrl()
|
||||
{
|
||||
return firstImageUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.ruoyi.office.domain;
|
||||
|
||||
/**
|
||||
* 博客系统文章推送请求。
|
||||
*/
|
||||
public class WebsiteArticlePushRequest
|
||||
{
|
||||
private String externalSource;
|
||||
private String externalId;
|
||||
private String title;
|
||||
private String type;
|
||||
private String contentType;
|
||||
private String contentMarkdown;
|
||||
private String top;
|
||||
private String status;
|
||||
private String blogPicType;
|
||||
private String blogPicLink;
|
||||
|
||||
public String getExternalSource()
|
||||
{
|
||||
return externalSource;
|
||||
}
|
||||
|
||||
public void setExternalSource(String externalSource)
|
||||
{
|
||||
this.externalSource = externalSource;
|
||||
}
|
||||
|
||||
public String getExternalId()
|
||||
{
|
||||
return externalId;
|
||||
}
|
||||
|
||||
public void setExternalId(String externalId)
|
||||
{
|
||||
this.externalId = externalId;
|
||||
}
|
||||
|
||||
public String getTitle()
|
||||
{
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getType()
|
||||
{
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type)
|
||||
{
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getContentType()
|
||||
{
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public void setContentType(String contentType)
|
||||
{
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public String getContentMarkdown()
|
||||
{
|
||||
return contentMarkdown;
|
||||
}
|
||||
|
||||
public void setContentMarkdown(String contentMarkdown)
|
||||
{
|
||||
this.contentMarkdown = contentMarkdown;
|
||||
}
|
||||
|
||||
public String getTop()
|
||||
{
|
||||
return top;
|
||||
}
|
||||
|
||||
public void setTop(String top)
|
||||
{
|
||||
this.top = top;
|
||||
}
|
||||
|
||||
public String getStatus()
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getBlogPicType()
|
||||
{
|
||||
return blogPicType;
|
||||
}
|
||||
|
||||
public void setBlogPicType(String blogPicType)
|
||||
{
|
||||
this.blogPicType = blogPicType;
|
||||
}
|
||||
|
||||
public String getBlogPicLink()
|
||||
{
|
||||
return blogPicLink;
|
||||
}
|
||||
|
||||
public void setBlogPicLink(String blogPicLink)
|
||||
{
|
||||
this.blogPicLink = blogPicLink;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ruoyi.office.domain;
|
||||
|
||||
/**
|
||||
* 博客系统文章推送响应。
|
||||
*/
|
||||
public class WebsiteArticlePushResponse
|
||||
{
|
||||
private Integer code;
|
||||
private String msg;
|
||||
private WebsiteArticleSyncResult data;
|
||||
|
||||
public Integer getCode()
|
||||
{
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(Integer code)
|
||||
{
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMsg()
|
||||
{
|
||||
return msg;
|
||||
}
|
||||
|
||||
public void setMsg(String msg)
|
||||
{
|
||||
this.msg = msg;
|
||||
}
|
||||
|
||||
public WebsiteArticleSyncResult getData()
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(WebsiteArticleSyncResult data)
|
||||
{
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ruoyi.office.domain;
|
||||
|
||||
/**
|
||||
* 网站文章同步结果,同时用于接收博客接口的data字段。
|
||||
*/
|
||||
public class WebsiteArticleSyncResult
|
||||
{
|
||||
private Long articleId;
|
||||
private boolean created;
|
||||
|
||||
public Long getArticleId()
|
||||
{
|
||||
return articleId;
|
||||
}
|
||||
|
||||
public void setArticleId(Long articleId)
|
||||
{
|
||||
this.articleId = articleId;
|
||||
}
|
||||
|
||||
public boolean isCreated()
|
||||
{
|
||||
return created;
|
||||
}
|
||||
|
||||
public void setCreated(boolean created)
|
||||
{
|
||||
this.created = created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.ruoyi.office.domain;
|
||||
|
||||
/**
|
||||
* 直接同步公众号草稿箱的请求对象。contentHtml 必须是调用方准备好的 HTML。
|
||||
*/
|
||||
public class WechatDraftPushRequest
|
||||
{
|
||||
private Long accountId;
|
||||
private String requestNo;
|
||||
private String title;
|
||||
private String author;
|
||||
private String digest;
|
||||
private String contentHtml;
|
||||
private String contentSourceUrl;
|
||||
private String thumbMediaId;
|
||||
private Integer showCoverPic;
|
||||
private Integer needOpenComment;
|
||||
private Integer onlyFansCanComment;
|
||||
|
||||
public Long getAccountId() { return accountId; }
|
||||
public void setAccountId(Long accountId) { this.accountId = accountId; }
|
||||
public String getRequestNo() { return requestNo; }
|
||||
public void setRequestNo(String requestNo) { this.requestNo = requestNo; }
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getAuthor() { return author; }
|
||||
public void setAuthor(String author) { this.author = author; }
|
||||
public String getDigest() { return digest; }
|
||||
public void setDigest(String digest) { this.digest = digest; }
|
||||
public String getContentHtml() { return contentHtml; }
|
||||
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
|
||||
public String getContentSourceUrl() { return contentSourceUrl; }
|
||||
public void setContentSourceUrl(String contentSourceUrl) { this.contentSourceUrl = contentSourceUrl; }
|
||||
public String getThumbMediaId() { return thumbMediaId; }
|
||||
public void setThumbMediaId(String thumbMediaId) { this.thumbMediaId = thumbMediaId; }
|
||||
public Integer getShowCoverPic() { return showCoverPic; }
|
||||
public void setShowCoverPic(Integer showCoverPic) { this.showCoverPic = showCoverPic; }
|
||||
public Integer getNeedOpenComment() { return needOpenComment; }
|
||||
public void setNeedOpenComment(Integer needOpenComment) { this.needOpenComment = needOpenComment; }
|
||||
public Integer getOnlyFansCanComment() { return onlyFansCanComment; }
|
||||
public void setOnlyFansCanComment(Integer onlyFansCanComment) { this.onlyFansCanComment = onlyFansCanComment; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ruoyi.office.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.office.domain.TtWechatAccount;
|
||||
|
||||
public interface TtWechatAccountMapper
|
||||
{
|
||||
TtWechatAccount selectTtWechatAccountByAccountId(Long accountId);
|
||||
|
||||
List<TtWechatAccount> selectTtWechatAccountList(TtWechatAccount account);
|
||||
|
||||
List<TtWechatAccount> selectEnabledTtWechatAccounts();
|
||||
|
||||
int insertTtWechatAccount(TtWechatAccount account);
|
||||
|
||||
int updateTtWechatAccount(TtWechatAccount account);
|
||||
|
||||
int deleteTtWechatAccountByAccountIds(Long[] accountIds);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ruoyi.office.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.office.domain.TtWechatArticle;
|
||||
|
||||
public interface TtWechatArticleMapper
|
||||
{
|
||||
TtWechatArticle selectTtWechatArticleByArticleId(Long articleId);
|
||||
|
||||
TtWechatArticle selectTtWechatArticleByRequestNo(String requestNo);
|
||||
|
||||
List<TtWechatArticle> selectTtWechatArticleList(TtWechatArticle article);
|
||||
|
||||
int insertTtWechatArticle(TtWechatArticle article);
|
||||
|
||||
int updateTtWechatArticleResult(TtWechatArticle article);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import com.ruoyi.office.domain.WebsiteArticleSyncResult;
|
||||
|
||||
public interface IWebsiteArticleSyncService
|
||||
{
|
||||
WebsiteArticleSyncResult syncWebsite(Long codeId);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.file.FileUtils;
|
||||
import com.ruoyi.office.domain.TtCode;
|
||||
import com.ruoyi.office.domain.TtFile;
|
||||
import com.ruoyi.office.mapper.TtCodeMapper;
|
||||
import com.ruoyi.office.mapper.TtFileMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 源码项目截图压缩下载服务。
|
||||
*/
|
||||
@Service
|
||||
public class TtScreenshotArchiveService
|
||||
{
|
||||
private static final int CONNECT_TIMEOUT_MILLIS = 10_000;
|
||||
private static final int READ_TIMEOUT_MILLIS = 30_000;
|
||||
private static final int MAX_REDIRECTS = 5;
|
||||
private static final long MAX_IMAGE_BYTES = 20L * 1024 * 1024;
|
||||
private static final long MAX_TOTAL_BYTES = 120L * 1024 * 1024;
|
||||
private static final SSLSocketFactory INSECURE_SSL_SOCKET_FACTORY = createInsecureSslSocketFactory();
|
||||
|
||||
@Autowired
|
||||
private TtCodeMapper codeMapper;
|
||||
|
||||
@Autowired
|
||||
private TtFileMapper fileMapper;
|
||||
|
||||
/**
|
||||
* 将指定项目关联的截图打包后写入响应。
|
||||
*/
|
||||
public void download(Long codeId, HttpServletResponse response) throws IOException
|
||||
{
|
||||
TtCode code = codeMapper.selectTtCodeByCodeId(codeId);
|
||||
if (code == null)
|
||||
{
|
||||
throw new ServiceException("源码项目不存在");
|
||||
}
|
||||
|
||||
List<TtFile> screenshots = fileMapper.selectTtFileByCodeName(code.getCodeName());
|
||||
if (screenshots == null || screenshots.isEmpty())
|
||||
{
|
||||
throw new ServiceException("该项目暂无截图");
|
||||
}
|
||||
|
||||
Path archive = Files.createTempFile("ruoyi-screenshots-", ".zip");
|
||||
try
|
||||
{
|
||||
buildArchive(archive, screenshots);
|
||||
String projectName = sanitizeFileName(code.getCodeName(), "项目");
|
||||
response.setContentType("application/zip");
|
||||
response.setContentLengthLong(Files.size(archive));
|
||||
FileUtils.setAttachmentResponseHeader(response, projectName + "截图.zip");
|
||||
Files.copy(archive, response.getOutputStream());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Files.deleteIfExists(archive);
|
||||
}
|
||||
}
|
||||
|
||||
private void buildArchive(Path archive, List<TtFile> screenshots) throws IOException
|
||||
{
|
||||
long[] totalBytes = new long[] { 0L };
|
||||
try (OutputStream output = Files.newOutputStream(archive);
|
||||
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8))
|
||||
{
|
||||
for (int index = 0; index < screenshots.size(); index++)
|
||||
{
|
||||
addScreenshot(zip, screenshots.get(index), index + 1, totalBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addScreenshot(ZipOutputStream zip, TtFile screenshot, int sequence,
|
||||
long[] totalBytes) throws IOException
|
||||
{
|
||||
String displayName = StringUtils.isEmpty(screenshot.getFileName())
|
||||
? "截图" : screenshot.getFileName();
|
||||
URLConnection connection;
|
||||
try
|
||||
{
|
||||
connection = openConnection(screenshot.getFileUrl(), displayName);
|
||||
}
|
||||
catch (ServiceException exception)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”下载失败,请检查图片链接")
|
||||
.setDetailMessage(exception.getMessage());
|
||||
}
|
||||
try
|
||||
{
|
||||
long contentLength = connection.getContentLengthLong();
|
||||
if (contentLength > MAX_IMAGE_BYTES)
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”超过20MB,无法打包");
|
||||
}
|
||||
|
||||
String entryName = buildEntryName(sequence, displayName,
|
||||
screenshot.getFileUrl(), connection.getContentType());
|
||||
zip.putNextEntry(new ZipEntry(entryName));
|
||||
try (InputStream input = connection.getInputStream())
|
||||
{
|
||||
copyWithLimit(input, zip, displayName, totalBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
catch (ServiceException exception)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”下载失败,请检查图片链接")
|
||||
.setDetailMessage(exception.getMessage());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (connection instanceof HttpURLConnection)
|
||||
{
|
||||
((HttpURLConnection) connection).disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private URLConnection openConnection(String fileUrl, String displayName) throws IOException
|
||||
{
|
||||
if (StringUtils.isEmpty(fileUrl))
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”未配置图片链接");
|
||||
}
|
||||
|
||||
URL url;
|
||||
try
|
||||
{
|
||||
url = toAsciiUrl(fileUrl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”的图片链接无效");
|
||||
}
|
||||
String protocol = url.getProtocol().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(protocol) && !"https".equals(protocol))
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”仅支持HTTP或HTTPS图片链接");
|
||||
}
|
||||
|
||||
URL currentUrl = url;
|
||||
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++)
|
||||
{
|
||||
HttpURLConnection http = createHttpConnection(currentUrl);
|
||||
int status = http.getResponseCode();
|
||||
if (isRedirect(status))
|
||||
{
|
||||
String location = http.getHeaderField("Location");
|
||||
http.disconnect();
|
||||
if (StringUtils.isEmpty(location))
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”跳转失败,图片服务未返回新地址");
|
||||
}
|
||||
if (redirectCount >= MAX_REDIRECTS)
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”重定向次数过多");
|
||||
}
|
||||
|
||||
URL redirectUrl;
|
||||
try
|
||||
{
|
||||
redirectUrl = resolveRedirectUrl(currentUrl, location);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”的跳转地址无效");
|
||||
}
|
||||
String redirectProtocol = redirectUrl.getProtocol().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(redirectProtocol) && !"https".equals(redirectProtocol))
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”跳转到了不支持的图片地址");
|
||||
}
|
||||
currentUrl = redirectUrl;
|
||||
continue;
|
||||
}
|
||||
if (status < 200 || status >= 300)
|
||||
{
|
||||
http.disconnect();
|
||||
throw new ServiceException("截图“" + displayName + "”下载失败,图片服务返回" + status);
|
||||
}
|
||||
return http;
|
||||
}
|
||||
throw new ServiceException("截图“" + displayName + "”重定向次数过多");
|
||||
}
|
||||
|
||||
private HttpURLConnection createHttpConnection(URL url) throws IOException
|
||||
{
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
disableCertificateValidation(connection);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
|
||||
connection.setReadTimeout(READ_TIMEOUT_MILLIS);
|
||||
connection.setUseCaches(false);
|
||||
connection.setRequestProperty("User-Agent", "RuoYi-Screenshot-Downloader/1.0");
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅对截图下载连接跳过 HTTPS 证书链校验,不修改 JVM 全局 HTTPS 配置。
|
||||
* 域名校验仍由 HttpsURLConnection 的默认 HostnameVerifier 负责。
|
||||
*/
|
||||
static void disableCertificateValidation(HttpURLConnection connection)
|
||||
{
|
||||
if (connection instanceof HttpsURLConnection)
|
||||
{
|
||||
((HttpsURLConnection) connection).setSSLSocketFactory(INSECURE_SSL_SOCKET_FACTORY);
|
||||
}
|
||||
}
|
||||
|
||||
private static SSLSocketFactory createInsecureSslSocketFactory()
|
||||
{
|
||||
try
|
||||
{
|
||||
TrustManager[] trustManagers = new TrustManager[] { new X509TrustManager()
|
||||
{
|
||||
@Override
|
||||
public void checkClientTrusted(X509Certificate[] chain, String authType)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkServerTrusted(X509Certificate[] chain, String authType)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public X509Certificate[] getAcceptedIssuers()
|
||||
{
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
} };
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustManagers, new SecureRandom());
|
||||
return sslContext.getSocketFactory();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new IllegalStateException("初始化截图下载 HTTPS 配置失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将包含中文等非 ASCII 字符的图片地址转换为 HttpURLConnection 可安全请求的地址。
|
||||
*/
|
||||
private URL toAsciiUrl(String fileUrl) throws Exception
|
||||
{
|
||||
URI uri = new URI(fileUrl);
|
||||
return new URL(uri.toASCIIString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析相对或绝对重定向地址,并保证重定向后的 URL 同样完成百分号编码。
|
||||
*/
|
||||
private URL resolveRedirectUrl(URL currentUrl, String location) throws Exception
|
||||
{
|
||||
URI redirectUri = currentUrl.toURI().resolve(new URI(location));
|
||||
return new URL(redirectUri.toASCIIString());
|
||||
}
|
||||
|
||||
private boolean isRedirect(int status)
|
||||
{
|
||||
return status == HttpURLConnection.HTTP_MOVED_PERM
|
||||
|| status == HttpURLConnection.HTTP_MOVED_TEMP
|
||||
|| status == HttpURLConnection.HTTP_SEE_OTHER
|
||||
|| status == 307
|
||||
|| status == 308;
|
||||
}
|
||||
|
||||
private void copyWithLimit(InputStream input, ZipOutputStream zip, String displayName,
|
||||
long[] totalBytes) throws IOException
|
||||
{
|
||||
byte[] buffer = new byte[8192];
|
||||
long imageBytes = 0L;
|
||||
int length;
|
||||
while ((length = input.read(buffer)) != -1)
|
||||
{
|
||||
imageBytes += length;
|
||||
totalBytes[0] += length;
|
||||
if (imageBytes > MAX_IMAGE_BYTES)
|
||||
{
|
||||
throw new ServiceException("截图“" + displayName + "”超过20MB,无法打包");
|
||||
}
|
||||
if (totalBytes[0] > MAX_TOTAL_BYTES)
|
||||
{
|
||||
throw new ServiceException("项目截图总大小超过120MB,无法打包");
|
||||
}
|
||||
zip.write(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
|
||||
static String buildEntryName(int sequence, String fileName, String fileUrl, String contentType)
|
||||
{
|
||||
String normalizedName = sanitizeFileName(fileName, "截图");
|
||||
if (!hasExtension(normalizedName))
|
||||
{
|
||||
normalizedName += "." + resolveExtension(fileUrl, contentType);
|
||||
}
|
||||
return String.format(Locale.ROOT, "%02d_%s", sequence, normalizedName);
|
||||
}
|
||||
|
||||
private static boolean hasExtension(String fileName)
|
||||
{
|
||||
int dot = fileName.lastIndexOf('.');
|
||||
return dot > 0 && dot < fileName.length() - 1
|
||||
&& fileName.substring(dot + 1).matches("[A-Za-z0-9]{1,10}");
|
||||
}
|
||||
|
||||
private static String resolveExtension(String fileUrl, String contentType)
|
||||
{
|
||||
if (!StringUtils.isEmpty(fileUrl))
|
||||
{
|
||||
String path = fileUrl;
|
||||
int queryIndex = path.indexOf('?');
|
||||
if (queryIndex >= 0)
|
||||
{
|
||||
path = path.substring(0, queryIndex);
|
||||
}
|
||||
int dot = path.lastIndexOf('.');
|
||||
if (dot >= 0 && dot < path.length() - 1)
|
||||
{
|
||||
String extension = path.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
if (extension.matches("jpg|jpeg|png|gif|bmp|webp|svg"))
|
||||
{
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!StringUtils.isEmpty(contentType))
|
||||
{
|
||||
String type = contentType.toLowerCase(Locale.ROOT);
|
||||
if (type.contains("png")) return "png";
|
||||
if (type.contains("gif")) return "gif";
|
||||
if (type.contains("bmp")) return "bmp";
|
||||
if (type.contains("webp")) return "webp";
|
||||
if (type.contains("svg")) return "svg";
|
||||
}
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
static String sanitizeFileName(String fileName, String fallback)
|
||||
{
|
||||
String safeName = StringUtils.isEmpty(fileName) ? fallback : fileName.trim();
|
||||
safeName = safeName.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_")
|
||||
.replaceAll("[. ]+$", "");
|
||||
return StringUtils.isEmpty(safeName) ? fallback : safeName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.office.config.WebsiteSyncProperties;
|
||||
import com.ruoyi.office.domain.WebsiteArticlePushRequest;
|
||||
import com.ruoyi.office.domain.WebsiteArticlePushResponse;
|
||||
import com.ruoyi.office.domain.WebsiteArticleSyncResult;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* 博客系统文章推送客户端。
|
||||
*/
|
||||
@Component
|
||||
public class WebsiteArticleClient
|
||||
{
|
||||
private static final String PUSH_PATH = "/open-api/articles";
|
||||
|
||||
private final WebsiteSyncProperties properties;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
public WebsiteArticleClient(WebsiteSyncProperties properties)
|
||||
{
|
||||
this(properties, createRestTemplate(properties));
|
||||
}
|
||||
|
||||
WebsiteArticleClient(WebsiteSyncProperties properties, RestTemplate restTemplate)
|
||||
{
|
||||
this.properties = properties;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
public WebsiteArticleSyncResult sync(WebsiteArticlePushRequest request)
|
||||
{
|
||||
validateConfiguration();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-API-Key", properties.getApiKey());
|
||||
HttpEntity<WebsiteArticlePushRequest> entity = new HttpEntity<>(request, headers);
|
||||
|
||||
try
|
||||
{
|
||||
ResponseEntity<WebsiteArticlePushResponse> response = restTemplate.postForEntity(
|
||||
buildPushUrl(), entity, WebsiteArticlePushResponse.class);
|
||||
WebsiteArticlePushResponse body = response.getBody();
|
||||
if (body == null || body.getData() == null || body.getData().getArticleId() == null)
|
||||
{
|
||||
throw new ServiceException("网站返回了无效的同步结果");
|
||||
}
|
||||
return body.getData();
|
||||
}
|
||||
catch (HttpStatusCodeException e)
|
||||
{
|
||||
throw new ServiceException("同步网站失败:" + readRemoteMessage(e));
|
||||
}
|
||||
catch (ResourceAccessException e)
|
||||
{
|
||||
throw new ServiceException("同步网站超时或无法连接,请检查网站地址和网络");
|
||||
}
|
||||
catch (RestClientException e)
|
||||
{
|
||||
throw new ServiceException("同步网站请求失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateConfiguration()
|
||||
{
|
||||
if (StringUtils.isBlank(properties.getBaseUrl()))
|
||||
{
|
||||
throw new ServiceException("未配置website-sync.base-url");
|
||||
}
|
||||
if (StringUtils.isBlank(properties.getApiKey()))
|
||||
{
|
||||
throw new ServiceException("未配置website-sync.api-key");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildPushUrl()
|
||||
{
|
||||
String baseUrl = properties.getBaseUrl().trim();
|
||||
while (baseUrl.endsWith("/"))
|
||||
{
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
}
|
||||
return baseUrl + PUSH_PATH;
|
||||
}
|
||||
|
||||
private String readRemoteMessage(HttpStatusCodeException e)
|
||||
{
|
||||
try
|
||||
{
|
||||
JSONObject body = JSON.parseObject(e.getResponseBodyAsString());
|
||||
String message = body == null ? null : body.getString("msg");
|
||||
if (StringUtils.isNotBlank(message))
|
||||
{
|
||||
return message;
|
||||
}
|
||||
}
|
||||
catch (RuntimeException ignored)
|
||||
{
|
||||
}
|
||||
return "网站接口返回HTTP " + e.getRawStatusCode();
|
||||
}
|
||||
|
||||
private static RestTemplate createRestTemplate(WebsiteSyncProperties properties)
|
||||
{
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(properties.getConnectTimeout());
|
||||
factory.setReadTimeout(properties.getReadTimeout());
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.office.domain.WebsiteArticleDocument;
|
||||
|
||||
/**
|
||||
* 将网站文案的首个非空行作为标题,其余部分作为Markdown正文。
|
||||
*/
|
||||
@Component
|
||||
public class WebsiteArticleDocumentParser
|
||||
{
|
||||
private static final Pattern LEADING_BLANK_LINES = Pattern.compile("^(?:[\\t ]*\\n)+");
|
||||
private static final Pattern MARKDOWN_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
|
||||
|
||||
public WebsiteArticleDocument parse(String renderedContent)
|
||||
{
|
||||
if (StringUtils.isBlank(renderedContent))
|
||||
{
|
||||
throw new ServiceException("复制网站文案内容为空");
|
||||
}
|
||||
|
||||
String normalized = renderedContent.replace("\r\n", "\n").replace('\r', '\n');
|
||||
if (normalized.startsWith("\uFEFF"))
|
||||
{
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
normalized = LEADING_BLANK_LINES.matcher(normalized).replaceFirst("");
|
||||
|
||||
int titleEnd = normalized.indexOf('\n');
|
||||
if (titleEnd < 0)
|
||||
{
|
||||
throw new ServiceException("复制网站文案缺少正文");
|
||||
}
|
||||
String title = normalized.substring(0, titleEnd).trim();
|
||||
String contentMarkdown = LEADING_BLANK_LINES
|
||||
.matcher(normalized.substring(titleEnd + 1)).replaceFirst("");
|
||||
if (StringUtils.isBlank(title))
|
||||
{
|
||||
throw new ServiceException("复制网站文案第一行标题为空");
|
||||
}
|
||||
if (title.length() > 50)
|
||||
{
|
||||
throw new ServiceException("网站文章标题不能超过50个字符:" + title);
|
||||
}
|
||||
if (StringUtils.isBlank(contentMarkdown))
|
||||
{
|
||||
throw new ServiceException("复制网站文案正文为空");
|
||||
}
|
||||
|
||||
return new WebsiteArticleDocument(title, contentMarkdown, findFirstImage(contentMarkdown));
|
||||
}
|
||||
|
||||
private String findFirstImage(String contentMarkdown)
|
||||
{
|
||||
Matcher matcher = MARKDOWN_IMAGE.matcher(contentMarkdown);
|
||||
if (!matcher.find())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String imageUrl = matcher.group(1).trim();
|
||||
if (imageUrl.length() > 100)
|
||||
{
|
||||
throw new ServiceException("网站文章封面链接不能超过100个字符:" + imageUrl);
|
||||
}
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.ruoyi.office.service.impl;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.office.domain.TtCode;
|
||||
import com.ruoyi.office.domain.TtCopyTemplate;
|
||||
import com.ruoyi.office.domain.WebsiteArticleDocument;
|
||||
import com.ruoyi.office.domain.WebsiteArticlePushRequest;
|
||||
import com.ruoyi.office.domain.WebsiteArticleSyncResult;
|
||||
import com.ruoyi.office.service.CopyTemplateRenderer;
|
||||
import com.ruoyi.office.service.ITtCodeService;
|
||||
import com.ruoyi.office.service.ITtCopyTemplateService;
|
||||
import com.ruoyi.office.service.IWebsiteArticleSyncService;
|
||||
import com.ruoyi.office.service.WebsiteArticleClient;
|
||||
import com.ruoyi.office.service.WebsiteArticleDocumentParser;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 源码项目同步到网站文章。
|
||||
*/
|
||||
@Service
|
||||
public class WebsiteArticleSyncServiceImpl implements IWebsiteArticleSyncService
|
||||
{
|
||||
private static final String WEBSITE_TEMPLATE_NAME = "复制网站文案";
|
||||
private static final String EXTERNAL_SOURCE = "RuoYi-Vue";
|
||||
|
||||
private final ITtCodeService ttCodeService;
|
||||
private final ITtCopyTemplateService copyTemplateService;
|
||||
private final CopyTemplateRenderer copyTemplateRenderer;
|
||||
private final WebsiteArticleDocumentParser documentParser;
|
||||
private final WebsiteArticleClient websiteArticleClient;
|
||||
|
||||
@Autowired
|
||||
public WebsiteArticleSyncServiceImpl(ITtCodeService ttCodeService,
|
||||
ITtCopyTemplateService copyTemplateService,
|
||||
CopyTemplateRenderer copyTemplateRenderer,
|
||||
WebsiteArticleDocumentParser documentParser,
|
||||
WebsiteArticleClient websiteArticleClient)
|
||||
{
|
||||
this.ttCodeService = ttCodeService;
|
||||
this.copyTemplateService = copyTemplateService;
|
||||
this.copyTemplateRenderer = copyTemplateRenderer;
|
||||
this.documentParser = documentParser;
|
||||
this.websiteArticleClient = websiteArticleClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebsiteArticleSyncResult syncWebsite(Long codeId)
|
||||
{
|
||||
TtCode code = ttCodeService.selectTtCodeByCodeId(codeId);
|
||||
if (code == null)
|
||||
{
|
||||
throw new ServiceException("源码项目不存在");
|
||||
}
|
||||
TtCopyTemplate template = findWebsiteTemplate();
|
||||
WebsiteArticleDocument document = documentParser.parse(
|
||||
copyTemplateRenderer.render(template, code));
|
||||
|
||||
WebsiteArticlePushRequest request = new WebsiteArticlePushRequest();
|
||||
request.setExternalSource(EXTERNAL_SOURCE);
|
||||
request.setExternalId("tt_code:" + codeId);
|
||||
request.setTitle(document.getTitle());
|
||||
request.setType("1");
|
||||
request.setContentType("2");
|
||||
request.setContentMarkdown(document.getContentMarkdown());
|
||||
request.setTop("0");
|
||||
request.setStatus("1");
|
||||
if (document.getFirstImageUrl() != null)
|
||||
{
|
||||
request.setBlogPicType("0");
|
||||
request.setBlogPicLink(document.getFirstImageUrl());
|
||||
}
|
||||
return websiteArticleClient.sync(request);
|
||||
}
|
||||
|
||||
private TtCopyTemplate findWebsiteTemplate()
|
||||
{
|
||||
List<TtCopyTemplate> templates = copyTemplateService.selectEnabledTemplates();
|
||||
if (templates != null)
|
||||
{
|
||||
for (TtCopyTemplate template : templates)
|
||||
{
|
||||
if (WEBSITE_TEMPLATE_NAME.equals(template.getTemplateName()))
|
||||
{
|
||||
return template;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new ServiceException("未找到启用的“复制网站文案”模板");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user