feat: add screenshot archive and article sync services

This commit is contained in:
王鹏
2026-08-28 14:48:24 +08:00
parent 21eb026789
commit 074bbda460
19 changed files with 1625 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.office.service;
import com.ruoyi.office.domain.WebsiteArticleSyncResult;
public interface IWebsiteArticleSyncService
{
WebsiteArticleSyncResult syncWebsite(Long codeId);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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("未找到启用的“复制网站文案”模板");
}
}