diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/config/WebsiteSyncProperties.java b/ruoyi-office/src/main/java/com/ruoyi/office/config/WebsiteSyncProperties.java new file mode 100644 index 0000000..867079d --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/config/WebsiteSyncProperties.java @@ -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; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatAccount.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatAccount.java new file mode 100644 index 0000000..8744956 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatAccount.java @@ -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; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatArticle.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatArticle.java new file mode 100644 index 0000000..a2f8d51 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatArticle.java @@ -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; } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticleDocument.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticleDocument.java new file mode 100644 index 0000000..29b289a --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticleDocument.java @@ -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; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticlePushRequest.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticlePushRequest.java new file mode 100644 index 0000000..37a31d0 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticlePushRequest.java @@ -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; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticlePushResponse.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticlePushResponse.java new file mode 100644 index 0000000..961d10e --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticlePushResponse.java @@ -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; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticleSyncResult.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticleSyncResult.java new file mode 100644 index 0000000..0d20202 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WebsiteArticleSyncResult.java @@ -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; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPushRequest.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPushRequest.java new file mode 100644 index 0000000..45b26b9 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPushRequest.java @@ -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; } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatAccountMapper.java b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatAccountMapper.java new file mode 100644 index 0000000..8810182 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatAccountMapper.java @@ -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 selectTtWechatAccountList(TtWechatAccount account); + + List selectEnabledTtWechatAccounts(); + + int insertTtWechatAccount(TtWechatAccount account); + + int updateTtWechatAccount(TtWechatAccount account); + + int deleteTtWechatAccountByAccountIds(Long[] accountIds); +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatArticleMapper.java b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatArticleMapper.java new file mode 100644 index 0000000..923b119 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatArticleMapper.java @@ -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 selectTtWechatArticleList(TtWechatArticle article); + + int insertTtWechatArticle(TtWechatArticle article); + + int updateTtWechatArticleResult(TtWechatArticle article); +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/IWebsiteArticleSyncService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/IWebsiteArticleSyncService.java new file mode 100644 index 0000000..c4c54f6 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/IWebsiteArticleSyncService.java @@ -0,0 +1,8 @@ +package com.ruoyi.office.service; + +import com.ruoyi.office.domain.WebsiteArticleSyncResult; + +public interface IWebsiteArticleSyncService +{ + WebsiteArticleSyncResult syncWebsite(Long codeId); +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/TtScreenshotArchiveService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/TtScreenshotArchiveService.java new file mode 100644 index 0000000..1a0c3ed --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/TtScreenshotArchiveService.java @@ -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 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 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; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleClient.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleClient.java new file mode 100644 index 0000000..3a2c504 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleClient.java @@ -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 entity = new HttpEntity<>(request, headers); + + try + { + ResponseEntity 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); + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java new file mode 100644 index 0000000..358d08c --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java @@ -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; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/WebsiteArticleSyncServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/WebsiteArticleSyncServiceImpl.java new file mode 100644 index 0000000..3025b30 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/WebsiteArticleSyncServiceImpl.java @@ -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 templates = copyTemplateService.selectEnabledTemplates(); + if (templates != null) + { + for (TtCopyTemplate template : templates) + { + if (WEBSITE_TEMPLATE_NAME.equals(template.getTemplateName())) + { + return template; + } + } + } + throw new ServiceException("未找到启用的“复制网站文案”模板"); + } +} diff --git a/ruoyi-office/src/test/java/com/ruoyi/office/service/TtScreenshotArchiveServiceTest.java b/ruoyi-office/src/test/java/com/ruoyi/office/service/TtScreenshotArchiveServiceTest.java new file mode 100644 index 0000000..b05d1b5 --- /dev/null +++ b/ruoyi-office/src/test/java/com/ruoyi/office/service/TtScreenshotArchiveServiceTest.java @@ -0,0 +1,266 @@ +package com.ruoyi.office.service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; + +import com.ruoyi.common.exception.ServiceException; +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 com.sun.net.httpserver.HttpServer; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.any; + +@RunWith(MockitoJUnitRunner.class) +public class TtScreenshotArchiveServiceTest +{ + @InjectMocks + private TtScreenshotArchiveService service; + + @Mock + private TtCodeMapper codeMapper; + + @Mock + private TtFileMapper fileMapper; + + private HttpServer imageServer; + private String imageBaseUrl; + + @Before + public void setUp() throws IOException + { + imageServer = HttpServer.create(new InetSocketAddress(0), 0); + imageServer.createContext("/home.png", exchange -> { + byte[] content = "first-image".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "image/png"); + exchange.sendResponseHeaders(200, content.length); + exchange.getResponseBody().write(content); + exchange.close(); + }); + imageServer.createContext("/redirect-home", exchange -> { + exchange.getResponseHeaders().set("Location", "/home.png"); + exchange.sendResponseHeaders(301, -1); + exchange.close(); + }); + imageServer.createContext("/redirect-loop", exchange -> { + exchange.getResponseHeaders().set("Location", "/redirect-loop"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + imageServer.createContext("/detail", exchange -> { + byte[] content = "second-image".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "image/jpeg"); + exchange.sendResponseHeaders(200, content.length); + exchange.getResponseBody().write(content); + exchange.close(); + }); + imageServer.createContext("/中文截图.png", exchange -> { + byte[] content = "unicode-image".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "image/png"); + exchange.sendResponseHeaders(200, content.length); + exchange.getResponseBody().write(content); + exchange.close(); + }); + imageServer.start(); + imageBaseUrl = "http://127.0.0.1:" + imageServer.getAddress().getPort(); + } + + @After + public void tearDown() + { + imageServer.stop(0); + } + + @Test + public void downloadsScreenshotsWithStableNumberedNames() throws Exception + { + TtCode code = new TtCode(); + code.setCodeId(9L); + code.setCodeName("【K001】药店进销存系统"); + + TtFile first = screenshot("首页", imageBaseUrl + "/redirect-home"); + TtFile second = screenshot("详情图.jpg", imageBaseUrl + "/detail"); + when(codeMapper.selectTtCodeByCodeId(9L)).thenReturn(code); + when(fileMapper.selectTtFileByCodeName(code.getCodeName())) + .thenReturn(Arrays.asList(first, second)); + + MockHttpServletResponse response = new MockHttpServletResponse(); + service.download(9L, response); + + Assert.assertEquals("application/zip", response.getContentType()); + Assert.assertEquals(FileUtils.percentEncode("【K001】药店进销存系统截图.zip"), + response.getHeader("download-filename")); + List names = new ArrayList(); + List contents = new ArrayList(); + try (ZipInputStream zip = new ZipInputStream( + new ByteArrayInputStream(response.getContentAsByteArray()), StandardCharsets.UTF_8)) + { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) + { + names.add(entry.getName()); + contents.add(readText(zip)); + } + } + + Assert.assertEquals(Arrays.asList("01_首页.png", "02_详情图.jpg"), names); + Assert.assertEquals(Arrays.asList("first-image", "second-image"), contents); + } + + @Test + public void rejectsProjectWithoutScreenshots() throws Exception + { + TtCode code = new TtCode(); + code.setCodeName("无截图项目"); + when(codeMapper.selectTtCodeByCodeId(10L)).thenReturn(code); + when(fileMapper.selectTtFileByCodeName(code.getCodeName())).thenReturn(new ArrayList()); + + try + { + service.download(10L, new MockHttpServletResponse()); + Assert.fail("应拒绝下载空压缩包"); + } + catch (ServiceException exception) + { + Assert.assertEquals("该项目暂无截图", exception.getMessage()); + } + } + + @Test + public void downloadsScreenshotWithUnicodeUrlPath() throws Exception + { + TtCode code = new TtCode(); + code.setCodeName("中文路径项目"); + when(codeMapper.selectTtCodeByCodeId(14L)).thenReturn(code); + when(fileMapper.selectTtFileByCodeName(code.getCodeName())) + .thenReturn(Arrays.asList(screenshot("中文截图", imageBaseUrl + "/中文截图.png"))); + + MockHttpServletResponse response = new MockHttpServletResponse(); + service.download(14L, response); + + try (ZipInputStream zip = new ZipInputStream( + new ByteArrayInputStream(response.getContentAsByteArray()), StandardCharsets.UTF_8)) + { + Assert.assertEquals("01_中文截图.png", zip.getNextEntry().getName()); + Assert.assertEquals("unicode-image", readText(zip)); + } + } + + @Test + public void rejectsMissingProject() throws Exception + { + when(codeMapper.selectTtCodeByCodeId(12L)).thenReturn(null); + + try + { + service.download(12L, new MockHttpServletResponse()); + Assert.fail("应拒绝下载不存在项目的截图"); + } + catch (ServiceException exception) + { + Assert.assertEquals("源码项目不存在", exception.getMessage()); + } + } + + @Test + public void rejectsUnsupportedScreenshotUrl() throws Exception + { + TtCode code = new TtCode(); + code.setCodeName("无效链接项目"); + when(codeMapper.selectTtCodeByCodeId(13L)).thenReturn(code); + when(fileMapper.selectTtFileByCodeName(code.getCodeName())) + .thenReturn(Arrays.asList(screenshot("本地图片", "file:///tmp/image.png"))); + + try + { + service.download(13L, new MockHttpServletResponse()); + Assert.fail("应拒绝非HTTP截图地址"); + } + catch (ServiceException exception) + { + Assert.assertEquals("截图“本地图片”仅支持HTTP或HTTPS图片链接", exception.getMessage()); + } + } + + @Test + public void rejectsRedirectLoop() throws Exception + { + TtCode code = new TtCode(); + code.setCodeName("循环跳转项目"); + when(codeMapper.selectTtCodeByCodeId(11L)).thenReturn(code); + when(fileMapper.selectTtFileByCodeName(code.getCodeName())) + .thenReturn(Arrays.asList(screenshot("循环图片", imageBaseUrl + "/redirect-loop"))); + + try + { + service.download(11L, new MockHttpServletResponse()); + Assert.fail("应拒绝循环重定向"); + } + catch (ServiceException exception) + { + Assert.assertEquals("截图“循环图片”重定向次数过多", exception.getMessage()); + } + } + + @Test + public void sanitizesInvalidCharactersInScreenshotName() + { + Assert.assertEquals("03_功能_首页.webp", + TtScreenshotArchiveService.buildEntryName( + 3, "功能/首页", "https://example.com/image", "image/webp")); + } + + @Test + public void configuresCertificateBypassOnlyForHttpsConnections() + { + HttpsURLConnection https = mock(HttpsURLConnection.class); + + TtScreenshotArchiveService.disableCertificateValidation(https); + + verify(https).setSSLSocketFactory(any(SSLSocketFactory.class)); + } + + private TtFile screenshot(String name, String url) + { + TtFile file = new TtFile(); + file.setFileName(name); + file.setFileUrl(url); + return file; + } + + private String readText(ZipInputStream input) throws IOException + { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[128]; + int length; + while ((length = input.read(buffer)) != -1) + { + output.write(buffer, 0, length); + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } +} diff --git a/ruoyi-office/src/test/java/com/ruoyi/office/service/WebsiteArticleClientTest.java b/ruoyi-office/src/test/java/com/ruoyi/office/service/WebsiteArticleClientTest.java new file mode 100644 index 0000000..ea5461f --- /dev/null +++ b/ruoyi-office/src/test/java/com/ruoyi/office/service/WebsiteArticleClientTest.java @@ -0,0 +1,45 @@ +package com.ruoyi.office.service; + +import com.ruoyi.office.config.WebsiteSyncProperties; +import com.ruoyi.office.domain.WebsiteArticlePushRequest; +import com.ruoyi.office.domain.WebsiteArticleSyncResult; +import org.junit.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withCreatedEntity; + +public class WebsiteArticleClientTest +{ + @Test + public void shouldPostArticleWithApiKeyAndReadCreatedResult() + { + WebsiteSyncProperties properties = new WebsiteSyncProperties(); + properties.setBaseUrl("http://example.test/prod-api/"); + properties.setApiKey("test-key"); + + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + server.expect(once(), requestTo("http://example.test/prod-api/open-api/articles")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("X-API-Key", "test-key")) + .andRespond(withCreatedEntity(null) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"code\":200,\"msg\":\"操作成功\",\"data\":{\"articleId\":28,\"created\":true}}")); + + WebsiteArticleSyncResult result = new WebsiteArticleClient(properties, restTemplate) + .sync(new WebsiteArticlePushRequest()); + + assertEquals(Long.valueOf(28L), result.getArticleId()); + assertTrue(result.isCreated()); + server.verify(); + } +} diff --git a/ruoyi-office/src/test/java/com/ruoyi/office/service/WebsiteArticleDocumentParserTest.java b/ruoyi-office/src/test/java/com/ruoyi/office/service/WebsiteArticleDocumentParserTest.java new file mode 100644 index 0000000..fc608f9 --- /dev/null +++ b/ruoyi-office/src/test/java/com/ruoyi/office/service/WebsiteArticleDocumentParserTest.java @@ -0,0 +1,33 @@ +package com.ruoyi.office.service; + +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.office.domain.WebsiteArticleDocument; +import org.junit.Assert; +import org.junit.Test; + +public class WebsiteArticleDocumentParserTest +{ + private final WebsiteArticleDocumentParser parser = new WebsiteArticleDocumentParser(); + + @Test + public void splitsFirstLineFromMarkdownBodyAndFindsCover() + { + String content = "\n【S028】每日牛奶配送平台 | Springboot3 | Vue3\r\n\r\n" + + "#### 项目描述\r\n正文\r\n\r\n" + + "#### 项目截图\r\n1.统一登录入口\r\n" + + "![](http://img.yidaima.cn/cover.png)\r\n"; + + WebsiteArticleDocument document = parser.parse(content); + + Assert.assertEquals("【S028】每日牛奶配送平台 | Springboot3 | Vue3", document.getTitle()); + Assert.assertTrue(document.getContentMarkdown().startsWith("#### 项目描述\n正文")); + Assert.assertFalse(document.getContentMarkdown().contains(document.getTitle())); + Assert.assertEquals("http://img.yidaima.cn/cover.png", document.getFirstImageUrl()); + } + + @Test(expected = ServiceException.class) + public void rejectsContentWithoutBody() + { + parser.parse("只有标题"); + } +} diff --git a/ruoyi-office/src/test/java/com/ruoyi/office/service/impl/WebsiteArticleSyncServiceImplTest.java b/ruoyi-office/src/test/java/com/ruoyi/office/service/impl/WebsiteArticleSyncServiceImplTest.java new file mode 100644 index 0000000..a3b7fe7 --- /dev/null +++ b/ruoyi-office/src/test/java/com/ruoyi/office/service/impl/WebsiteArticleSyncServiceImplTest.java @@ -0,0 +1,81 @@ +package com.ruoyi.office.service.impl; + +import java.util.Arrays; + +import com.ruoyi.office.domain.TtCode; +import com.ruoyi.office.domain.TtCopyTemplate; +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.WebsiteArticleClient; +import com.ruoyi.office.service.WebsiteArticleDocumentParser; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class WebsiteArticleSyncServiceImplTest +{ + @InjectMocks + private WebsiteArticleSyncServiceImpl service; + + @Mock + private ITtCodeService codeService; + @Mock + private ITtCopyTemplateService templateService; + @Mock + private CopyTemplateRenderer renderer; + @Mock + private WebsiteArticleClient client; + + private final WebsiteArticleDocumentParser parser = new WebsiteArticleDocumentParser(); + + @Test + public void pushesWebsiteTemplateWithFirstLineAsTitle() + { + TtCode code = new TtCode(); + code.setCodeId(28L); + TtCopyTemplate template = new TtCopyTemplate(); + template.setTemplateName("复制网站文案"); + + String rendered = "【S028】每日牛奶配送平台 | Springboot3 | Vue3\n\n" + + "#### 项目描述\n正文\n\n" + + "![](http://img.yidaima.cn/cover.png)\n"; + WebsiteArticleSyncResult remoteResult = new WebsiteArticleSyncResult(); + remoteResult.setArticleId(123L); + remoteResult.setCreated(true); + + when(codeService.selectTtCodeByCodeId(28L)).thenReturn(code); + when(templateService.selectEnabledTemplates()).thenReturn(Arrays.asList(template)); + when(renderer.render(template, code)).thenReturn(rendered); + when(client.sync(any())).thenReturn(remoteResult); + + // @InjectMocks不能自动注入非Mock的解析器,直接构造被测服务。 + service = new WebsiteArticleSyncServiceImpl( + codeService, templateService, renderer, parser, client); + WebsiteArticleSyncResult result = service.syncWebsite(28L); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(WebsiteArticlePushRequest.class); + verify(client).sync(requestCaptor.capture()); + WebsiteArticlePushRequest request = requestCaptor.getValue(); + Assert.assertEquals(Long.valueOf(123L), result.getArticleId()); + Assert.assertEquals("RuoYi-Vue", request.getExternalSource()); + Assert.assertEquals("tt_code:28", request.getExternalId()); + Assert.assertEquals("【S028】每日牛奶配送平台 | Springboot3 | Vue3", request.getTitle()); + Assert.assertTrue(request.getContentMarkdown().startsWith("#### 项目描述")); + Assert.assertEquals("2", request.getContentType()); + Assert.assertEquals("1", request.getStatus()); + Assert.assertEquals("http://img.yidaima.cn/cover.png", request.getBlogPicLink()); + } +}